OpenCVのDescriptorMatcherの話

特徴量の検出(detect)と記述(compute)については割愛。これはその後の特徴量を比較するときに使うDescriptorMatcherについての話。

まずDescriptorMatcherの使い方

コードはScalaです。

import org.opencv.core.MatOfDMatch
import org.opencv.features2d.DescriptorMatcher

import scala.collection.mutable.ListBuffer
import scala.collection.JavaConverters._

val matcher = DescriptorMatcher.create(アルゴリズム)

val matches = ListBuffer[MatOfDMatch]()
matcher.結果の取得方法(queryDescriptors, trainDescriptors, matches.asJava, ~)

matches // この中にマッチングの結果

基本はこう。結果の取得方法によってシグネチャが違うが、大筋としては:

  • DescriptorMatcherのアルゴリズムを選択し
  • descriptorsを2つ渡してマッチングを行う

となる。

アルゴリズム

「どのようにマッチングを行うか」を指定する。大きく分けると:

  • 総当たり法
  • FLANN

の2つに分けられる。

総当たり法

問い合わせる特徴量の集合(queryDescriptors)のひとつひとつが、訓練特徴量の集合(trainDescriptors)のどれにマッチングするかなんて、それぞれひとつひとつ地道に比較してみないと分かんないよね?

というわけで、それを実際に行うのが総当たり法。

さらに分けると:

  • BruteForce
  • BruteForce-SL2(ユークリッド2乗距離)
  • BruteForce-L1(マンハッタン距離)
  • BruteForce-Hamming/BruteForce-HammingLUT(ハミング距離)
  • BruteForce-Hamming(2)(ハミング距離……だと思う)

の5つに分けられます。定数ではBruteForce-Hamming(2)がなく、その代わりBruteForce-Hammingと同等のBruteForce-HammingLUTがあったりはしますが、その辺の事情はよく分かりません。

ユークリッド距離マンハッタン距離ハミング距離は適当にググってください。

FLANN

総当たりするとめっちゃ時間かかるから、マッチングする範囲を限定しない?

というわけで、そうするのがFLANN(高速近似近傍探索法)。

総当たり法と比べて以下の特徴がある。

  • 速度は総当たり法のだいたい4〜6倍(自環境でパッと見た感じ)
  • 範囲を限定しているので、本来マッチングしてほしい箇所同士がマッチしないこともある

使えるアルゴリズム

すべてのマッチングアルゴリズムがすべての特徴量に対して適用できるわけではない。使えるものと使えないものがあり、以下のようになる。

  • 総当たり法(BruteForce, BruteForce-SL2, BruteForce-L1): 何にでも使える
  • 総当たり法(BruteForce-Hamming, BruteForce-Hamming(2)): 特徴量の表現がバイナリコードのもの(ORB, AKAZEとか)
  • FLANN: 特徴量の表現が実数ベクトルのもの(SIFT, SURFとか)

使えないものの場合は実行時エラーが出るので分かるはず。

どのアルゴリズム使えば良いんだ!ってなると思うんですが、結局特徴量の表現によって使えるアルゴリズムが決まっているので、迷うこともあんまりない。迷ったらBruteForceで良いのかなと思う。最近の特徴量検出アルゴリズムによるものだとFLANN使えないこと多いしな!

結果の取得方法

アルゴリズムを決めたらいざマッチングで良いんですが、それの取得方法もいくつかあります。

knnMatch, radiusMatch

基本的に結果は「問い合わせる特徴量の個数 * 訓練特徴量の個数」の2次元配列になります。実際はマッチングしなかった場合結果として返されないので、それよりは少なくなります。

そして各マッチング結果には特徴量同士の距離が込められています。この距離が近ければ近いほど(数値的には小さいほど)その特徴量同士は似ていると言えます。

で、各問い合わせる特徴量ごとに距離が近い上位k個を取得するのが、knnMatchです(k-nearest neighbor)。上位k個とかではなく、距離が指定した閾値以下かどうかでフィルターをかけるのがradiusMatchです。この2つは問い合わせる各特徴量ごとに複数個の結果を持ち、その各特徴量ごとにMatOfDMatchを返すので、結果はList[MatOfDMatch]になります。複雑ですね。

import org.opencv.core.MatOfDMatch
import org.opencv.features2d.DescriptorMatcher

import scala.collection.mutable.ListBuffer
import scala.collection.JavaConverters._

val matcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE)

val matches = new ListBuffer[MatOfDMatch]()
matcher.knnMatch(queryDescriptors, trainDescriptors, matches.asJava, 2)

これは各特徴量ごとに距離が近い上位2個を取得します。2個に満たない場合もあるので、それよりも少なくはなる可能性はあります。

radiusMatchについては割愛。knnMatchのように個数を指定する箇所に距離の閾値を指定するだけです。

match

じゃあ何も修飾していないmatchはどうなるかと言うと、各特徴量において最も距離の近い1点を結果とする、となります。結果としては「問い合わせる特徴量の個数 * 1」となるので、ただの配列になります。よってMatOfDMatchで表されます。こう見ると、matchの方が特殊な形で、knnMatch, radiusMatchの方がより一般形のようですね。

import org.opencv.core.MatOfDMatch
import org.opencv.features2d.DescriptorMatcher

val matcher = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE)

val matches = new MatOfDMatch()
matcher.`match`(queryDescriptors, trainDescriptors, matches)

matches変数をprintlnなりすると、次元数が「問い合わせる特徴量の個数 * 1」になっているのが分かると思います。

マッチングの結果を描画する方法

マッチングの結果によってはその結果の描画方法も異なります。なぜなら結果がknnMatch, radiusMatchはList[MatOfDMatch]になるけど、matchはMatOfDMatchになり、型が異なるからです。

型を揃えても良いんですが、それぞれの型に描画する方法があるので、使い分けてあげれば大丈夫です。

// matchesの型がMatOfDMatchの場合
val result = new Mat()
Features2d.drawMatches(image1, keyPoints1, image2, keyPoints2, matches, result)

// matchesの型がList[MatOfDMatch]の場合
val result = new Mat()
Features2d.drawMatches2(image1, keyPoints1, image2, keyPoints2, matches, result)

drawMatchesdrawMatches2の違いです。どうでもいいですけど、この命名の感じ、古臭さを感じますね。

実際は結果を描画せず、matchesの中身を見て類似しているか判定して終わることが多いと思いますが、一応。

OpenCVをJavaから使うぞ〜〜〜完全インストールマニュアル

まあJavaというかScalaから使いたかったんですが、同じことなんでとりあえずJavaから使うぞ〜〜〜という話です。

macOSの話だけど)Homebrewで入れていいの?Mavenリポジトリにあるの使っていいの?とまあいろいろ躓いたんで、まとめときます。

OpenCV(とJavaバインディング)をインストール

Homebrewから入れます。どのみちソースをビルドするので、ソースをダウンロードして自前でビルドしてもそんな変わらないと思います、たぶん。そっちの方がオプションなど細かくいじれると思うので、最終的にはそっちの方がおすすめな気はしてる。

Java用のバインディングもオプションで一緒にインストールします。するんですが、Antでビルドするようなので、Antをあらかじめインストールしておかないと、Java用のバインディングを作ってくれないっぽい。のでまず:

$ brew install ant

し、そのあと:

$ brew edit opencv

します。OpenCV用のFormulaを少し変えるんですが、-DBUILD_opencv_javaが書いてある箇所を探し、OFFならばONに変更しておきます。これをONにすると、Java用のバインディングも一緒にインストールされます。あとは:

$ brew install --build-form-source opencv

でインストールできます。正しくインストールできていれば、/usr/local/Cellar/opencv/VERSION/share/OpenCV/javaというディレクトリが存在し、中にJava用のバインディング(共有ライブラリと.jarファイル)が入っていればインストール大成功です。

build.sbtを書く

インストール自体はもう終わってるんですが、とりあえず使えるところまで書きます。というわけでbuild.sbtを書きましょう。中身はこんな感じ。

javaOptions in run += "-Djava.library.path=lib"

fork in run := true

forktrueにしているのは、こうしておかないと共有ライブラリを読み込めないから。

あとはjavaOptionsjava.library.pathにJava用のバインディングまでのパスを通せば良いだけ。$ ln -s /usr/local/Cellar/opencv/VERSION/share/OpenCV/java libしておき、作成したlibシンボリックリンクを指定します。IntelliJ IDEAを使用している方はその中の.jarファイルをプロジェクトの依存に追加しておけば、補完も効いておすすめです。

コードも書く

OpenCVのコードはちょくちょく変わっているので、ググった記事の通りに書いたら動かないとかざらです。imreadメソッドで画像を読み込めますが、そのメソッドがHighguiからImgcodecsに移動してたり。まあその辺はインストールしたOpenCVのバージョンに適したドキュメント読めば分かると思います。

.jarファイルだけsbtのlibraryDependenciesでインストールする

こっからは捕捉なんですが、上記の方法でインストールすると、IntelliJ IDEAが補完してくれないんですね。sbtならlibraryDependenciesに指定したものは自動的に補完の対象(というかクラスパスに入る)になるのに、わざわざ指定しないといけないと。

ので、この部分だけはlibraryDependenciesに指定したいという方もいるかもしれません。というわけでその方法。

まずMavenリポジトリにあるopencvのバージョンを調べます。そのバージョンと同一のOpenCVでないと動作しないので注意が必要で、大抵はOpenCVの方がバージョンが進んでいるので間違いなく気に掛ける必要があるでしょう。

2018年12月5日現在ではorg.openpnpのopencvが3.4.2-1で、HomebrewのOpenCVは3.4.3です。なので何も考えずにインストールするとあ゛〜〜〜ってなること必至です。というわけで、HomebrewのOpenCVも3.4.2をインストールする必要があります。

まず/usr/local/Homebrew/Library/Taps/homebrew/homebrew-core/Formulaに移動し、$ git log opencv.rbします。で、3.4.2のコミットを探し、$ git checkout COMMIT -- opencv.rbで、3.4.2のFormulaを得たら準備は完了。あとは「OpenCV(とJavaバインディング)をインストール」と同じ手順で大丈夫です。なお最近のHomebrewはinstallのときなどに勝手にupdateをしてしまうので、brewコマンドの前にはHOMEBREW_NO_AUTO_UPDATE=1を付けておくと良いでしょう。

あとはbuild.sbtに:

libraryDependencies += "org.openpnp" % "opencv" % "3.4.2-1"

を追加すれば大丈夫です。

参考

ObjectOutputStreamでシリアライズしたオブジェクトを永続化したら、serialVersionUIDが変わっちゃって破滅するやつ

題名のことやったやつおる?

ここにおる。こういう例外が投げられる。

InvalidClassException: local class incompatible: stream classdesc serialVersionUID = 1083041391519420705, local class serialVersionUID = -8102506628468650634

シリアライズした文字列(stream classdesc)中に含まれるserialVersionUIDと、今JVMの中にあるクラス(local class)のserialVersionUIDが違うわボケということの模様。

時と場合によるけど、永続化するならもっとマシなシリアライズ方式使えば良かったと思う。か、serialVersionUIDを手動で設定しておくか。私が遭遇したケースでは、永続化してた値はScalaで言う値クラスみたいなやつだったので、その値そのものを入れとけば良かったのに……、と30秒天井仰いだ。

ただまあ永続化していたものはセッションでログイン中のユーザーIDとCSRFトークンで、件数も少なかった。Redisに入れてたので、正直「FLUSHALLしてええか?」ってなった。なったけど、グッと堪えてシリアライズした文字列中に含まれるserialVersionUIDを書き換えてやった(これもどうかと思うが……)。

調べると、オブジェクト直列化ストリームプロトコルの仕様に則りシリアライズされる模様。保存されていたバイト列と比較すると当然だが一致し、クラス名の後にserialVersionUIDが書かれていることが分かった。今回のケースでは以下のようなバイト列だったので:

  • 0xaced: STREAM_MAGIC
  • 0x0005: STREAM_VERSION
  • 0x73: TC_OBJECT
  • 0x72: TC_CLASSDESC
  • 0x0046: 10進数で70だが、次のクラス名の長さ
  • クラス名(70バイト)
  • serialVersionUID(8バイト) ← ここを書き換えた

でなんとかなった。なんとかなったのか?

JSR-310のDateTimeFormatterに気を付けろ

JSR-310に限った話ではないかもしれませんが。

JSR-310で日時なりを文字列にするときは、fomratメソッドにDateTimeFormatterクラスのインスタンスを渡してやる。んー分かりやすい。ちなみにtoStringメソッドでもOK。この場合はISO 8601の形式でフォーマットされます。

で、このtoStringメソッドで使われているフォーマッター(おそらくDateTimeFormatter.ISO_*として用意されている出来合いのフォーマッター)なんですが、気が利いています。どう気が利いているかと言うとミリ秒がゼロの場合はミリ秒を出力しない。き、気が利いてる〜。

けど、他の言語やライブラリがフォーマッターに厳密に従ってパースする場合はミリ秒があったりミリ秒がなかったりで非常に厳しいので、どちらかに統一した方がいいねという話です。というかそれだけの話でした。一応フォーマッターの定義だけ掲載しときます。

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

DateTimeFormatter alwaysWithoutMillis = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZZZZZ");

ZonedDateTime.of(2018, 4, 13, 18, 48, 21, 1000000, ZoneId.of("Asia/Tokyo")).format(alwaysWithoutMillis); // => 2018-04-13T18:48:21+09:00

DateTimeFormatter alwaysWithMillis = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ");

ZonedDateTime.of(2018, 4, 13, 18, 48, 21, 0, ZoneId.of("Asia/Tokyo")).format(alwaysWithMillis); // => 2018-04-13T18:48:21.000+09:00

Javaめっちゃ久し振りに書いた!

ELBを介したSSEを行う場合の設定や気を付けるところ

SSE(Content-Type: text/event-stream)便利ですよね。フレームワークがサポートしていることも多いですし、その通りやれば結構サクッと動作します。

しかし、ローカルで試した場合にサクッと動作しても、ELBを介した場合に結構ハマったりしたので、その場合の解消法というか、ここ設定しとくと良いかもを書きます。

クライアント(アプリなど) ⇔ ELB ⇔ nginx(EC2インスタンス) ⇔ サーバアプリケーション

という構成が前提です。

結論

結論から言うと、下記の設定をしておくと私が試した限りはスムーズでした。

  • ELBのリスナーはHTTP/HTTPSではなく、TCP/SSLを使用
  • nginxのproxy_bufferingはoff

ELBのリスナーはHTTP/HTTPSではなく、TCP/SSLを使用

これがほとんどなのですが、ELBのリスナーはHTTP/HTTPSを使用すると、ELBが接続を保持したままになり、クライアントがHTTPコネクションを切断しても、サーバアプリケーション側に切断が伝わらない場合があるっぽいです。Redis pub/subなどに接続し、そっからデータを受け取るようなことをしている場合、Redisとの接続を切る必要があるかと思いますが、そういう場合致命的です。

この場合、ELBのリスナーにTCP/SSLを使用してあげたら、スパッと解消されました。ただし、HTTP/HTTPSは専用の処理を挟んでいたり(X-Forwarded-Forリクエストヘッダの付与など)、あとこいつのせいでSSEとの相性が悪いのだと思いますが、バックエンドとのコネクションを維持するキープアライブが働いています。それらが必要な場合はまた一考の余地ありとなります。

nginxのproxy_bufferingはoff

単純な話なのですが、nginxのproxy_bufferingが有効なままだと、サーバアプリケーションでイベントを書き込んでもnginxがバッファリングしてしまい、しばらくの間クライアントが受け取れません。SSEの場合、接続を維持するためにハートビート("data:"など意味を為さないイベント)を流すと思うので、結果それによって押し出されはしますが、SSEの性質上バッファリングしない方が正しいと思うので、切ってしまった方が良いかと思います。

location /path/to/sse {
  # ...

  proxy_buffering off;

  # ...
}

とでも書いておきましょう。併せてproxy_read_timeoutも確認しておいた方が良いかもしれません。ハートビートの間隔より短いと接続が切れてしまいます。nginxのデフォルトは60秒(ついでに言うとELBのアイドルタイムアウトも60秒)なので、あまり考える必要はないかもしれませんが……。

紆余曲折の話

私がいろいろ試した話をだらだら書くだけなので、あまり実のある話はありません。

事の発端はAkka StreamsのalsoToを使うとSourceが停止しないで書いたような、既読処理が停止しない問題を調べていたとき。alsoToの使用をやめてこれで問題解消やろ!と思ったが、そんなことはなくあれー?てなった。

ELBを介さず、直接EC2インスタンスにアクセスするとすんなり動作することから、どうやらELBがコネクション張ったままにしてるっぽいことまで分かった。これ無効にしたいんだけど〜と正直思ったが、どうやら無理っぽい。

じゃあSSEのハートビートを無効にし、ELBのアイドルタイムアウト任せに切断、クライアントで再接続と割り切るかと思ったが、それでもサーバアプリケーションの処理が停止しない。ならばサーバアプリケーションから終了を送ってしまえと、60秒で完了を送るようにしたが、これだとサーバアプリケーションの処理は停止しても、クライアントでエラーが発生し(Google Chromeだとnet::ERR_INCOMPLETE_CHUNKED_ENCODING)、再接続もしばらくの間失敗するというイケてない感じになった。

ちなみにSSEのハートビートを無効にしたときもいろいろあった。nginxのproxy_bufferingがonになっていてクライアントがイベントを受け取れなかったり、Akka Streamsが下流の停止を検出できず、動作が停止しなかったり。まあ結局ハートビートは有効にしたのでこの辺は関係ないのだけど。

で、チームにひたすら進捗投げてたんだけど、そこでHTTP/SSLリスナーはどう?とアドバイスをもらい、一発で解決したのであった。おしまい。

Akka StreamsのalsoToを使うとSourceが停止しない

結論から言うと下記のコードには問題がある。rediscalaでRedis pub/subに繋いで、subscribeで受け取った内容をAkka HTTPでのSSEで返すコードの例です。

import java.nio.charset.Charset

import akka.actor.ActorSystem
import akka.stream._
import akka.stream.stage._
import redis.RedisPubSub
import redis.api.pubsub.Message

class RedisPubSubGraphStage(channel: String)(implicit actorSystem: ActorSystem) extends GraphStage[SourceShape[String]] {
  private val out: Outlet[String] = Outlet("RedisPubSubGraphStage.out")

  val shape: SourceShape[String] = SourceShape(out)

  def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
    new GraphStageLogic(shape) {
      private var redisPubSub: Option[RedisPubSub] = None

      private def receiveMessage(message: Message): Unit = {
        val data = message.data.decodeString(Charset.forName("UTF-8"))
        emit(out, data)
      }

      override def preStart(): Unit = {
        redisPubSub = Some(RedisPubSub("localhost", 6379, Seq(channel), Seq.empty, receiveMessage, _ => ()))
      }

      override def postStop(): Unit = {
        redisPubSub.foreach(_.stop())
      }

      setHandler(out, new OutHandler {
        override def onPull(): Unit = ()
      })
    }
}

このRedisPubSubGraphStageを使えば、与えたチャンネル(channel)をsubscribeし、受け取った内容をそのまま垂れ流すSourceを作ることが出来る。で、これをSSEで流すと下記のような感じになる。

import akka.http.server.Directives._
import akka.stream.scaladsl.Source
import akka.http.scaladsl.model.sse.ServerSentEvent
import akka.http.scaladsl.marshalling.sse.EventStreamMarshalling._

get {
  path("subscribe") {
    parameters('channel) { channel =>
      val source =
        Source
          .fromGraph(new RedisPubSubSourceGraph(channel))
          .map(s => ServerSentEvent(s))
          .alsoTo(Sink.foreach(sse => processSse(sse)))

      complete(source)
    }
  }
}

おかしいことは分かっているんだけど、subscribeしたらSSEのデータを別口で受け取り、何かしらの処理をしたいとする。例えばチャットなどで、チャットのメッセージが作られただとか削除されただとかのイベントが流れくる中、subscribeした時点でそのイベントの中のメッセージを開き、ついでに既読にしてしまうとか。正攻法ならば接続したクライアントで既読APIを叩いてやるべきだと分かっているんだけど。

とりあえず言い訳はしたが、こんな風にした場合、想定した通りにならない。Akka HTTPのSSEはHTTPのコネクションを切ると、そのために使ったRunnableGraphはきちんと閉じられる。当然RedisPubSubGraphStageが返したGraphStageLogicのpostStopだって呼ばれる。しかし、alsoToを使うと、HTTPのコネクションを切ってもそちらのSinkが生きているせいなのか、postStopが呼ばれない。

当然と言えば当然って感じで、そりゃSinkがひとつでも生きているならばRunnableGraph自体は有効にしておくしかない。別にSSEに限った話でもないが、alsoToを使う場合はこういうことが起こりえることを考慮すべきなんだろうなあ。

ちなみにこういったケースならば、viaでFlowを噛ましてやればalsoToを使わず似たようなことは出来ます。

sealed abstract case classがすごい

sealed abstract case classなるものがある。別のことを調べてたらこちらの記事(のコメント欄)で見掛けて感動してしまった。

軽く触れると、newできなくなり、case classによって自動で生成されるcopyメソッド、そしてコンパニオンオブジェクトのapplyメソッドが生成されなくなる、というもの。その上でcase classの他の特性を持つ。

これ、インスタンスの生成に関する手段のみが綺麗に潰されているので、バリデーションを挟むファクトリだったりを強制できるのが嬉しい。

私自身は今まで以下のようにしていた。

trait Person {
  def name: String
  
  def age: Int
}

private case class PersonImpl(name: String, age: Int) extends Person

object Person {
  def apply(name: String, age: Int): Person = {
    // ...
    PersonImpl(name, age)
  }
}

こんな風にtraitでインタフェースのみ定義し、その実装をcase classにする。これでequalsメソッドなどは自動で定義してくれる。で、traitの方のコンパニオンオブジェクトで生成してあげる。もちろん実装であるcase class自体は隠しちゃう。

しかしこれ、unapplyメソッドなどは自前で定義しなくちゃならないし、toStringメソッドは実装クラス(この場合はPersonImpl)のものなので、想定した結果にならないと、微妙なとこもあった。

ので、まあケースに応じはするわけだが、sealed abstract case classは有用そうだなと思う。

Scalaマクロのerrorとabortの違い

Scalaのマクロで、コンパイルエラーにしてしまうときは Context.error を使う。

def someMethod_impl(c: Context)(arg: c.Expr[String]): c.Expr[Unit] = {
  // ...

  if (cond) {
    c.error(c.enclosingPosition, "a message")
  }

  // ...
}

でもこれが微妙に使いづらくて、なぜなら戻り値が Unit だから。Unit だと:

val _arg = arg.tree match {
  case Literal(Constant(value: String)) =>
    value
  case _ =>
    c.error(c.enclosingPosition, "a message")
}

とかが出来ません。この場合変数 _argString になってほしいのですが、実際は StringUnit の共通の親である Any になってしまうわけです。

で、登場するのが Context.abort 。こちらは戻り値が Nothing になっているので、上記のようなケースで上手く動作します。

val _arg = arg.tree match {
  case Literal(Constant(value: String)) =>
    value
  case _ =>
    c.abort(c.enclosingPosition, "a message")
}

// 変数 `_arg` はちゃんと `String`

ケースに応じて使い分ければ良さそうですね。

Scala dynamics + macros

Type Dynamic

Scalaはメソッドなどの有無をコンパイルの時点で解決しますが、Dynamic traitを使うことによって動的にすることが出来ます。

// Dynamic traitを使うために必要。コンパイラオプションに `-language:dynamics` を渡してもOK
import scala.language.dynamics

object Adder extends Dynamic {
  // ...
}

こうするだけ。で、オブジェクト(この場合は Adder オブジェクト)に存在しないフィールドやメソッドを指定すると、特定のメソッドが呼ばれます。特定のメソッドは4種類あり、ケースに応じて呼び出されるメソッドが変わります。

  • フィールドの参照: selectDynamic
  • フィールドへの代入: updateDynamic
  • メソッド呼び出し: applyDynamic
  • 名前付き引数を伴ったメソッド呼び出し: applyDynamicNamed

という具合。試しに applyDynamic メソッドを定義してみます。

object Adder extends Dynamic {
  def applyDynamic(name: String)(value: Int): Int = {
    if (!increments.isDefinedAt(name)) {
      throw new IllegalArgumentException(s"$name is not supported")
    }

    val increment = increments(name)

    value + increment
  }

  val increments: Map[String, Int] = Map(
    "one" -> 1,
    "two" -> 2,
    "three" -> 3
  )
}

で実際に使ってみるとこんな感じになる。

println(Adder.one(100))   // => 101
println(Adder.two(100))   // => 102
println(Adder.three(100)) // => 103

本来 Adder オブジェクトには one メソッドなどが定義されていないわけですが、Dynamic traitを使用しているので、applyDynamic メソッドが呼ばれ、その中の処理が実行されるわけです。

先に書いたとおり、他にもメソッドがあるわけですが、これについては割愛。Dynamic traitのドキュメント読むだけでもなんとなく分かるんじゃないかなあと。

マクロ

Type Dynamic便利ですね。しかしこのケースにおいては Adder.four(100) などと呼ぼうものならエラーになります。実行時エラーです。Type Dynamic使わなければコンパイルエラーなのに。

だからこそType Dynamicとも思うのですが、場合によってはコンパイルエラーに出来ます。マクロと併用します。順を追って Adder.four(100)コンパイルエラーになるようにしてみましょう。

まず Adder.applyDynamic メソッドがマクロの呼び出しになるように書き換えます。

import scala.language.dynamics

// `macro` キーワードを使うために必要。コンパイラオプションに `-language:experimental.macros` を渡してもOK
import scala.language.experimental.macros

object Adder extends Dynamic {
  def applyDynamic(name: String)(value: Int): Int = macro AdderMacro.applyDynamic
}

使うのは macro キーワードで、このあとにマクロの実態となるメソッドの識別子を渡します。元のメソッド名と合わせる必要は特になく、macro Nyan.nyan とかでも大丈夫です。

で、実態側を定義する。

import scala.reflect.macros.blackbox.Context

object AdderMacro {
  def applyDynamic(c: Context)(name: c.Expr[String])(value: c.Expr[Int]): c.Expr[Int] = {
    import c.universe._

    val Literal(Constant(numName: String)) = name.tree

    if (!increments.isDefinedAt(numName)) {
      c.error(c.enclosingPosition, s"$numName is not supported")
    }

    val increment = Literal(Constant(increments(numName)))

    c.Expr[Int](q"$value + $increment")
  }

  val increments: Map[String, Int] = Map(
    "one" -> 1,
    "two" -> 2,
    "three" -> 3
  )
}

AdderMacro.applyDynamic がマクロの実態なわけですが、まずシグネチャをマクロ用のものにする必要があります。ひとつ目の引数リストで scala.reflect.macros.{blackbox,whitebox}.Context を受け取るようにし、あとは元のメソッドと同じ引数リストとなります。ただし、c.Expr で包んであげます(または c.Tree にする)。

これで Adder.one(100) を呼び出すと、Type Dynamicによって Adder.applyDynamic("one")(100) になり、マクロを通じてコンパイル時に 100 + 1 に置き換えられます。なので全体的にASTを操作する処理になります。

で、ポイントは c.error のところ。これでコンパイルエラーになります。実際に sbt console で試してみます。

[info] Starting scala interpreter...
Welcome to Scala 2.12.4 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_131).
Type in expressions for evaluation. Or try :help.

scala> Adder.one(100)
res0: Int = 101

scala> Adder.four(100)
<console>:12: error: four is not supported
       Adder.four(100)

意図した通りにコンパイルエラーになっています。と、まあこんな感じでType Dynamicしつつも、マクロを介して妥当かどうかをチェックしてあげられます。

ちなみにマクロの実態の方を先にコンパイルしてあげる必要などがあるので、sbtではプロジェクトを分けたりする必要があります。詳細はサンプルを参照してみてください。

実例

ScalikeJDBCのコードに良い実例があります。

import scalikejdbc._

case class PersonRecord(name: String, age: Int)

object PersonTable extends SQLSyntaxSupport[PersonRecord] {
  // ...
  
  // `column` がType Dynamicになっており、`SQLSyntaxSupport` に与えたクラスの
  // プライマリコンストラクタの引数のみ許可している。よって以下はコンパイルが通る
  column.name
  column.age
  
  // これは通らない。以下のようにコンパイルエラーとなる。
  //   PersonRecord#birthday not found. Expected fields are #name, #age.
  column.birthday
  
  // ...
}

こんな感じで型パラメータに与えられたクラスの情報を元にチェックできたり。ソースは下記の模様。

比較的安全に悪そうなこと出来たりしますが、乱用するとパッと見でどんなコードが生成されるか分かりづらいので、お約束ながら用法用量を守っていきたいですね。

パッケージの依存を単方向にするための拡張メソッド

DDDの話をするとしよう*1

IDDD 第9章 モジュールにおいて、モジュール(Scalaだとパッケージ)の依存は極力減らせ。現実的には難しいが、単方向にしたり、親子関係にあるならば双方向を許可する、ということが述べられている。

しかしこれめっちゃ難しい。集約が別集約のファクトリになる場合、単方向という制限はだいたい破綻する。

// userモジュールの集約
class User(id: UserId) {
  def createTask(): Task = // Taskを返すため、taskモジュールに依存
    new Task(TaskId.generate(), id)
}

// taskモジュールの集約
class Task(id: TaskId, ownerId: UserId) // ownerIdとしてUserIdを持つため、userモジュールに依存

そもそもこういった関係のものにファクトリを持たせるな、という話な気もしてくるが、だからといって避けては通れないときもあると思う。ならば親子関係にすれば良いのか、というとそうでもない。そうしちゃうとほとんどがuserモジュールの子になってしまう。たぶん。

そういうときに拡張メソッドを使うことも出来るな、という話をしたいわけです。

さきほどの例だと user.User をこうする。

class User(id: UserId)

要はファクトリを削る。これでtaskモジュールへの依存はなくなる。

で、taskモジュールに以下のようなのを書く。

import user._

package object task {
  implicit class TaskCreator(user: User) {
    def createTask(): Task =
      new Task(TaskId.generate(), user.id)
  }
}

user.User から task.TaskCreator への変換を定義し、そこに拡張メソッドとしてファクトリを持たせてあげる。こうするとファクトリのある場所がtaskモジュールに移動するので、依存が単方向に収まる。

ちなみに使うときは:

import user._
import task._

val user = createUser()
val task = user.createTask() // import task._ をしていないとcreateTaskメソッドが使えない

のように、両方のモジュールをimportしてあげる必要がある。が、むしろこれはtaskモジュール中のオブジェクトを使いますよ、という意味になるので、むしろ好都合だと思ってる。

実践で用いてはいないのだけど、「あれ、こうすればいいんじゃ?」と思った話でした。デメリットは実践してないから知らない。

*1:FGOやったことないです