Scala 2.12と3でSIP-23 Literal-based singleton typesの互換を頑張るいくつかの方法

もう9年以上前に以下のようなものを書きましたが、それに関連する話。

xuwei-k.hatenablog.com

簡単に状況というか前提を整理すると

  • Scala 2.12以前はSIP-23が存在しなかったので、ある意味上記のようなshapless 2の黒魔術が発明された
  • Scala 2.13からSIP-23が入った。つまり val x: 2 = 2 みたいに、型の部分にIntやStringのリテラルが書けるようになった
  • Scala 3でも、もちろんSIP-23は引き続き使える
  • Scala 3ではshapeless 2自体がビルドされていないので、もちろん以下のような記法は無理
Witness.`123`.T

よって

  • Scala 2.13以前だけcross buildするならshapeless使えば良い
  • Scala 2.13と3だけならSIP-23で良い
  • Scala 2.12, 2.13, 3と、3つの世代でcross buildしたいときに困る

となるはずです。まぁ今どきScala 2.12を切り捨ててScala 2.13以降にするのが健全でしょうけれど、色々あってそういうのが無理な場合もあるでしょう。

一例を挙げると、refinedというライブラリが、これを書いてる2024年1月の時点では、その3世代をサポートしているので、 ほとんど同じコードなのに、この問題のために、ファイル分けて書かれています。以下、つらい例

さて、現時点で思いついた解決策(= 出来るだけ重複を避ける)を書いていきます。

Scala 3でmatch typeとdynamicでshapeless 2と同じ記法で書けるようにする

以下、とりあえずIntの例です。 同じ原理でStringやその他も可能なはずです。 objectに無理やりtypeを追加しているのが、少し無理やり感があって、今後のScala 3で動き続けるのか?が不安、というのはあります。 もっと健全な方法ないんでしょうか・・・? あと、ひとまずmatch typeで書けたので書きましたが、コンパイル速度その他の細かい点を考慮すると、あえてmacro使って書いた方がいい可能性もあるけれど、詳細は未検証。 Doubleのリテラルのparser真面目に頑張ったら超辛そう、なども…

import scala.compiletime.ops.string.Length
import scala.compiletime.ops.string.CharAt
import scala.compiletime.ops.string.Substring
import scala.compiletime.ops.int
import scala.language.dynamics

object Witness extends Dynamic {
  type StringToInt[Input <: String] = Loop[Input, 0]

  type CharToInt[C <: Char] <: Int = C match {
    case '0' => 0
    case '1' => 1
    case '2' => 2
    case '3' => 3
    case '4' => 4
    case '5' => 5
    case '6' => 6
    case '7' => 7
    case '8' => 8
    case '9' => 9
  }

  type Loop[Input <: String, Acc <: Int] <: Int = Length[Input] match {
    case 0 =>
      Acc
    case _ =>
      Loop[
        Substring[Input, 1, Length[Input]],
        int.+[
          int.*[10, Acc],
          CharToInt[
            CharAt[Input, 0]
          ]
        ]
      ]
  }

  def selectDynamic(
      selector: String
  ): WitnessValue.type { type T = StringToInt[selector.type] } =
    WitnessValue.asInstanceOf[
      WitnessValue.type { type T = StringToInt[selector.type] }
    ]
}

object WitnessValue

scalafixでbuild時に書き換えてコード生成

project/plugins.sbt

libraryDependencies += "ch.epfl.scala" %% "scalafix-core" % "0.11.1"

project/なんとか.scala

import scala.meta.Lit
import scala.meta.Term
import scala.meta.Tree
import scala.meta.Type
import scala.meta.XtensionQuasiquoteImporter
import scala.meta.inputs.Input
import scalafix.Patch
import scalafix.internal.config.ScalaVersion
import scalafix.v1.SyntacticDocument

object RefinedTypeCompat {
  private def createPatch(tree: Tree): Patch = Seq(
    Patch.addGlobalImport(
      importer"eu.timepit.refined.W"
    ),
    tree.collect {
      case Type.ArgClause(args) =>
        args.collect {
          case n: Lit =>
            Patch.replaceTree(
              n,
              Type
                .Select(
                  Term.Select(Term.Name("W"), Term.Name(n.toString())),
                  Type.Name("T")
                ).toString
            )
        }.asPatch
    }.asPatch
  ).asPatch

  def fix(input: Input): String = {
    val doc   = SyntacticDocument.fromInput(input, ScalaVersion.scala2)
    val patch = createPatch(doc.tree)
    scalafix.internal.patch.PatchInternals
      .syntactic(
        Map(scalafix.rule.RuleName("RefinedTypeCompat") -> patch),
        doc,
        false
      ).fixed
  }
}

あとは細かい部分省略しますが、sbtのsourceGeneratorsからこれを呼び出し、とします。 上記は、2.13や3のコードから、Scala 2.12用のshapeless使ったコードを生成するパターンです。 まぁ逆も原理上は可能でしょうが、2.12が将来消えること考えると、普通はこちらの方法で良いでしょう。

他にも、もっといい案あったら教えてください。

後でrefinedにissueかpull reqで 「どちらかの方法でコードの重複消していい感じにしませんか?」 って提案するかもしれません。

追記:

github.com