以下のライブラリの話をします。
このライブラリの基本的な紹介はしません。ググるとか、流行のAIに聞く、などして調べてください。
タイトル通りの話をするのと、タイトル以外でも最近fthomasさんのものに限らず、Scalaの他のrefined関係ライブラリのあれこれを調べたり試行錯誤して、Scala 3含めて書きたいことは色々あるのですが、どうやっても全て書ききれないし、まだまだ調査中の部分もあるため、今回は基本的にはタイトルの
「fthomas/refinedのScala 2 macroの独自typeの場合compile速度爆発問題」
についてのみです。
refinedで定義できる型には、大きく分けて2種類、あるいは3種類あります。どういうことか?というと
- refined標準で定義済の型
- その中でmacroで特別扱いされている型
- なぜか?macroで特別扱いされていない型
- ユーザーが独自に定義する型
標準で定義されている型には、たとえばIntならば、PosInt(1以上)、NonNegInt(0以上)、NegInt(-1以下)、といったものがあります。 ユーザーが独自に定義とは、たとえば以下のような定義をすると
import eu.timepit.refined.api.Refined import eu.timepit.refined.numeric.Interval type MyInt = Int Refined Interval.Closed[0, 10000]
独自に、0から1万までを許可する型が作れます。
さて、refinedには、リテラルのIntやStringをcompile時に評価して、制約を満たしていればcompile成功、満たしていなければcompile errorにしてくれる、便利なmacroがあります。 使い方によって数種類ありますが、どれも最終的に呼ばれる内部の部分が同じならば、今回の話の詳細には直接関わってこないはずなので、とりあえず以下のautoについて話すことにします。
import eu.timepit.refined.auto._
これをimportしておくと、たとえば
val a1: PosInt = 2 // コンパイル成功 val a2: PosInt = -3 // コンパイル失敗
と、compile時に値をcheckしつつ暗黙変換してくれます。
さて、これは、ユーザー定義の型においても同様のことが可能です。素晴らしいですね。つまり、上記に出した MyInt
でも
val a1: MyInt = 2 // コンパイル成功 val a2: MyInt = -3 // コンパイル失敗
となります。
さて、ここまで準備段階の説明で、ここから本題なのですが、まず結論を言うと
「ユーザー定義の型においても同様のことが可能だが、コンパイル速度が爆発するので気をつけろ!覚悟して使え!」
ということです。
遅くなるのはあくまでmacroであって、macro以外ではあまり関係ないので、おそらくmacro使わないなら問題ありません。 とはいえ、macro使わないとなると、refinedの価値が半減する気がするので、難しいところです。
なぜそうなるか?というと、一部の型だけmacroで特別扱いされており、それ以外はcompile時にevalするからです。
「compile時にeval」の詳細は説明してもしょうがないというか、自分もそこの内部に詳しくないので触れませんが、とにかく基本的にmacro書く側としては、避けれるなら絶対に避けるべき機能で、最終手段的なものです。
大昔に、scalikejdbcのmacroが意味なくeval使っていて遅い事件があって修正されたりしました。
refinedにおける該当するmacroは以下です。
特定の事前定義された型ならば、そこから Validate
のインスタンスを取得、見つからなかったらevalしています。
では、実際にどの程度遅くなるのか?の実験を簡単にしてみましょう。
以下のようなコードを用意します
build.sbt
val common = Def.settings( scalaVersion := "2.13.12", libraryDependencies += "eu.timepit" %% "refined" % "0.11.1", scalacOptions ++= Seq("-Yprofile-trace", name.value + ".json") // ここはプロファイル結果見る時だけで良いかも ) def max: Int = 200 val a1 = project .settings( common, Compile / sourceGenerators += task { val dir = (Compile / sourceManaged).value val src = s""" package a1 import eu.timepit.refined.types.numeric.PosInt import eu.timepit.refined.auto._ object Main { ${(1 to max).map{ n => s" val x${n}: PosInt = $n"}.mkString("\n", "\n", "\n")} } """ val f = dir / "Main.scala" IO.write(f, src) Seq(f) } ) val a2 = project .settings( common, Compile / sourceGenerators += task { val dir = (Compile / sourceManaged).value val src = s""" package a2 import eu.timepit.refined.auto._ object Main { ${(1 to max).map{ n => s" val x${n}: MyInt = $n"}.mkString("\n", "\n", "\n")} } """ val f = dir / "Main.scala" IO.write(f, src) Seq(f) } )
a2/src/main/scala/a2/package.scala
import eu.timepit.refined.api.Refined import eu.timepit.refined.numeric.Interval package object a2 { type MyInt = Int Refined Interval.Closed[0, 10000] }
これで
- sub projectのa1は、事前定義されているPosInt
- sub projectのa2は、独自定義のMyInt
が大量に定義されていることになります。それぞれ以下をsbtのshellに複数回入力して、繰り返しcompileをしてみます。
;a1/clean ; a1/update ; a1/compile
;a2/clean ; a2/update ; a2/compile
結果(ローカルのMac) それぞれ10回やった。
- PosInt: sbtの表示では全部1秒か0秒(あの表示は切り捨てで、0秒は1秒以下、の意味のはず)
- MyInt: sbtの表示では17秒か18秒
と、明らかな差が出ます。 さて、build.sbtに以下を追加してありましたが、profile結果のjsonをchromeに食べさせて眺めてみましょう。
scalacOptions ++= Seq("-Yprofile-trace", name.value + ".json")
以下がa1のPosInt
以下がa2のMyInt
(PosIntの場合との時間の単位の違いに注目)
違いは圧倒的ですね。MyIntの場合、それぞれのmacro呼び出しというか1つのvalで、おそよ84ミリ秒かかっていました。 一方PosIntの場合は1.5ミリ秒程度です。
84 * 200回 = 16.8秒
なので、全体の 17秒か18秒
と、辻褄が合います。
さて、これの改善方法ですが、今考えてる最中です。
evalによるcompile速度爆発を避けつつ任意の独自定義型に対して、すごく綺麗に解決するmacroは、現状のScala 2では思い付いてません。
Scala 3なら原理上、綺麗にかける(まだcompile速度のベンチマークはしてない)、のですが、その詳細は別の機会に気が向いたら書きます。 (そもそもScala 3のmacroに2と同じevalがない気がする)
雑に思い付いている解決策は
- 一度compileしたものを内部にcacheしてしまう => そんなことして大丈夫なのかよくわかってない。shapelessに一応そういうことやるものあったような?
- 独自定義ですごくよく使うものだけ、専用のmacro作ってeval避ける => だるい・・・
などですが・・・、もっと良い案知ってる人がいたら教えてください。
そういえば、これ関連で 「PosBigDecimalやPosBigIntくらいキャッシュしておいてくれ〜」 というのが、以下の自分のpull reqです。
shapeless.test.compileTime
というの最高便利ですね。