Scala 3のopaque typeとgeneralized type constraints

これを組み合わせてる例を雑にググっても見つけられなかったのですが、よく考えたら組み合わせたらそれなりに便利というか、劣化版HaskellのCoercibleと言えなくもないのでは???と今更気がついたので、それについて書きます。

昔こういうことをtweetして

今でもおそらくScala 3本体で追加でそういう系の機能が直接入ったりはしてないはずです。

https://docs.scala-lang.org/scala3/book/types-opaque-types.html

雑にopaque typeの使用例を解説をすると

opaque type UserId = String

object UserId {
  def apply(value: String): UserId = value

  extension (id: UserId) {
    def value: String = id
  }
}

というコードを書いておけば、UserIdはStringとは区別されるメリットがあるけれど、実際の実行時の表現はStringと同等なので、単に要素1つの

case class UserId(value: String)

というようなcase classを作る場合と比較して、余計なオブジェクトが生成されなくて原理上実行時の効率が良い、という使い方が可能です。

Scala 2だとこの用途にはvalue classといって extends AnyVal する

case class UserId(value: String) extends AnyVal

ものがありましたが、value classは特定の条件で結局instanceが生成されてしまう場合があるので、基本的に(記述の短さはともかく)実行時効率面ではopaque typeの方が優れています。

opaque typeにはこれ以外の使い方もありますが、詳細は割愛します。

さて、実際のプログラミングの用途において、UserIdを扱うなら、 Seq[UserId]Option[UserId]Map[UserId, 何か] のような、他のcollectionなどに包まれた型が出てきてそれを扱うことになりますよね?

Seq[UserId] で扱っていたものを、最終的にDBに入れる、あるいはJSONに変換する、など何かする際には、

  • UserId のtype classのinstanceを定義しておいてそれ経由で変換
  • Seq[UserId] の変数に対して .map(_.value) して Seq[String] に変換する

などをすることになると思います。type classでの変換の話は若干ややこしくなるので今回あえてしませんが、後者の

.map(_.value) して Seq[String] に変換」

は、よく考えると Seq[String]Seq[UserId] は実行時の表現は同じはずなので、ほぼゼロコストで変換出来るはずです。

言い換えると map メソッドをわざわざ呼びたくないです。

実際に Seq[String]Seq[UserId] は直接 asInstanceOf してもエラーにはなりません。

しかし asInstanceOf を直接使うのは安全かどうか?がわかりにくいので、出来るだけ避けたいですね?

ではコンパニオンオブジェクト*1に、そういうメソッドをそれぞれ定義すればいいでしょうか?

object UserId {
+  def unwrapSeq(values: Seq[UserId]): Seq[String] =
+    values.asInstanceOf[Seq[String]]
+
+  def wrapSeq(values: Seq[String]): Seq[UserId] =
+    values.asInstanceOf[Seq[UserId]]
}

実際に使うメソッドは大した数にはならないので、どうしても必要ならそれでも現実的にはいける可能性はありますが、単純にやったら、局所的な場所に押し込めるとはいえ、そこの中にだけ asInstanceOf は残ってダサいですね??? いやコンパニオン内部ならば、これは asInstanceOf 無しでcompile通った・・・

ここで 「generalized type constraints」の登場です。 これ自体の詳細な説明は割愛しますが、割と大昔のScalaから存在していて、importなしで使える <:<=:= という記号の型です。

例えばOptionのflattenに使われていて、以下のようになっています

https://github.com/scala/scala/blob/e56eceb383deda4f168e481bca7bed8d71916fe0/src/library/scala/Option.scala#L303

def flatten[B](implicit ev: A <:< Option[B]): Option[B] = // 実装省略

generalized type constraintsにはScala 2.13から以下のようなメソッドも追加されており

(ちなみに元々scalazにあったものが移植されたようなもの https://typelevel.org/blog/2014/07/02/type_equality_to_leibniz.html )

https://github.com/scala/scala/blob/e56eceb383deda4f168e481bca7bed8d71916fe0/src/library/scala/typeConstraints.scala

https://github.com/scala/scala/pull/5623

  def substituteBoth[F[-_, +_]](ftf: F[To, From]): F[From, To]

  def substituteCo[F[+_]](ff: F[From]): F[To]

  def substituteContra[F[-_]](ft: F[To]): F[From]

  def liftCo[F[+_]]: F[From] <:< F[To]

  def liftContra[F[-_]]: F[To] <:< F[From]

初めて見た人は、大抵わからないと思いますが、これらが実は求めている

Seq[String]Seq[UserId] は実行時の表現は同じはずなので、ほぼゼロコストで変換

するのに使えるものです。

ただし <:<=:= はsealedであり、直接継承したりnewで明示的に生成することは不可能です。

普通はデフォルトのinstanceを使うだけです。デフォルトのinstanceとは以下の

https://github.com/scala/scala/blob/e56eceb383deda4f168e481bca7bed8d71916fe0/src/library/scala/typeConstraints.scala#L168

implicit def refl[A]: A =:= A 

という定義です。では、元のサンプルコードに戻って、 UserId =:= String のinstanceが、任意の場所で手に入るか???というと、手に入りません。以下のようなエラーになります。

scala> summon[UserId =:= String]
-- [E172] Type Error: ----------------------------------------------------------
1 |summon[UserId =:= String]
  |                         ^
  |                         Cannot prove that UserId =:= String.
1 error found

これは、勝手に手に入ったらまずいというか、opaque typeは内部実装を隠蔽するためでもあるので、エラーになるのは当然です。

ではどうするのか???というと、単にコンパニオンオブジェクト内部に明示的に定義すればいいだけですね?

object UserId {
+  object instances {
+    given (UserId =:= String) = <:<.refl[UserId]
+  }
+

コンパニオンオブジェクト内部ならば、 UserIdString は型として同一視されるので、これでそのままcompileが通ります。

直接定義せずにさらに別のobjectに包んでいるのは、コンパニオン内部にgivenまたはimplicitで直接定義してしまうと、おそらくgeneralized type constraintsがFunction1を継承してるせいで、完全に暗黙的に任意の場所で相互変換(ある意味implicit conversion相当)がされてしまうことになってまずいからです。

これを使うと、以下のように asInstanceOf なしで任意の F[String] から F[UserId] の相互変換を記述することが可能になります、やった〜〜〜!!!

import UserId.instances.given

def wrap(values: Seq[String]): Seq[UserId] =
  summon[UserId =:= String].substituteContra(values)

def unwrap(values: Seq[UserId]): Seq[String] =
  summon[UserId =:= String].substituteCo(values)

最初に「劣化版HaskellのCoercible」と書いたのは、HaskellのCoercibleの方がtype role考慮したり色々原理上優れているはずなので、この程度が出来ても、Haskellと比較するとあくまで「劣化版」としか言えない気がしますが、劣化版だとしてもこの程度の記述でいけるなら、まぁまぁ実用的なのではないでしょうか、どうなんでしょう???

Seqの要素が多いとか、本当にギリギリまで最適化したい場合以外は結局微妙になる、という可能性は十分ありますが。

ただし、コンパニオンオブジェクトで asInstanceOf 書かなくていいとしても、いずれにせよ数行のボイラープレートは発生するので、(generalized type constraintsとか全く関係なく)さらに機能増やさない???

的な話題は過去に出たことがあるようですが

https://contributors.scala-lang.org/t/synthesize-constructor-for-opaque-types/4616

今のところ特に入る予定はなさそうですね。

途中で書いた

UserId のtype classのinstanceを定義しておいてそれ経由で変換」

の場合にゼロコスト変換組み合わせるのはどうするの???という課題はありますが、ひとまず今回はこれで終わりにします。

追記:

*1:opaque typeの場合その言い方でいいの???