Tagged Type というテクニックがある(TypeScript界隈などではBranded Typeと呼ばれているようだ)。実行時の型としては同じだが、型システム上はこれを区別して別物として扱い、混同できなくする仕組みを作るためのものだ。
Tagged Type
単位の取り違えによる事故は後を絶たない。世の中には、キログラムとポンドを混同して飛行機があわや墜落しかけたり、メートルとヤードを混同して人工衛星がどっかに行ったりしている。尊い人命や国民の血税と比べるといささか霞むかもしれないが、ユーザIDとペイロードを間違えて送信したり、金額と口座番号を取り違えて送金したり、秘密鍵と公開鍵を間違えて表示したりしてしまえば、プログラマが大変な苦労をするか、会社そのものが傾くだろう。
しかしながら、データとしてはどちらも同じDouble
だったりString
だったりするので、混同しない責任はひとえにプログラマに負わされてしまう。同じ型だが混同しないことを保障するのはたいへんな困難を伴うし(まさか命名規則で守るんですか?)、ホモサピエンスの脳に型システムはないので、どうしたって人間は間違えるのだ。
Tagged Typeは、こうした混同をコンパイル時に検出できるようにしてくれる。加えて、全ての検査はコンパイルの型検査フェーズで行なわれ、プログラムの実行時にはオーバーヘッドを残さないという重要な特長を持っている。実行時のオーバーヘッドを回避できるということは、いくら使ってもパフォーマンスに悪影響を及ぼさず、実行時の動作にも影響しないため容易に導入できるということだ。
実装は後述するが、Tagged Typeを利用することで以下のような処理が実現できる:
val width: Int @@ "px" = Tag(42) // => "px"でタグ付けされた42 val height: Int @@ "px" = Tag(666) // => "px"でタグ付けされた666 // "px"でタグ付けされたIntのみを受け付け、"px2"でタグ付けされたIntを返す関数 def area(w: Int @@ "px", h: Int @@ "px"): Int @@ "px2" = Tag(w * h) area(width, height) // => 27972 @@ "px2" val deg: Int @@ "deg" = Tag(90) // 同じIntでも、タグが異なる場合は受け付けない // area(width, deg) // area(10, 20)
この例ではタグとして"px"
といった文字列リテラル型を利用したが、Tagged Typeではあらゆる型をタグとして使うことができる。というのも、コンパイルした後はタグの情報は消えてなくなるからだ。例えば、UserId
という型を作って、他のIDと区別したりできるようになる。
println(area(width, height)) // => 実行時はタグ情報が消失するため、27972とだけ表示される
こんな便利な型を、Scala 3では数行書くだけで手に入れることができる。早速やってみよう。
Opaque Type
Scala 3で新たに導入された機能の一つに、Opaque Typeというのがある。これは、ある型に別名を付けつつ、型システム上では別物として扱えるようにする機能だ。
opaque type Name = String
一度Opaque Typeとして宣言された型は、元々の型情報を忘れてStringとして使えなくなり、混同できなくなる。これがopaque(不透明)と呼ばれる所以だ。
しかしこれだと何に使うこともできないので、特別にOpaque Typeが宣言されたスコープ内では元の型のことを覚えていてくれる:
object User { opaque type Name = String def unwrap(n: Name): String = n // ここではStringに戻せることを知っている } // ここではStringに戻せることを知らない
これを利用して、元の型に戻すための関数を生やしておくのが定石だ。
今回はOpaque Typeを利用してTagged Typeを実装する。ちなみにOpaque Typeを使わなくてもTagged Typeを実装できるが、いささか煩雑になってしまう。Scala 3を利用できるならOpaque Typeを利用するべきだ。
Opaque TypeでTagged Typeを実装する
Tagged Typeを実装するには、この5行だけ書けばよい:
object Tag: import scala.language.implicitConversions opaque type @@[R, T] = R inline def apply[R, T](r: R): R @@ T = r inline implicit def unwrap[R, T](tagged: R @@ T): R = tagged
早速、opaque type @@[R, T] = R
という記述がある。これはR
を@@[R, T]
とみなす、という定義だ。しかもOpaque Typeなので、Tag
オブジェクトの外ではタグ付きの型は混同できなくなる。Scalaでは2引数の型は中置記法で書くことができるので、R @@ T
と書けるようになる。
apply
は、Tag(値)
と書いたときに、この値をTagged Typeで聖別してやるための関数だ。inline def
になっているため、これを呼び出している箇所ではそのまま値が置かれ、型だけ書き換えられた状態になる。
最後に書かれているunwrap
は、Tagged Typeを元の型でも扱えるようにするための変換だ。implicit def
になっているため、この関数をimport
している状況では自動的にこの変換が行なわれる。2行目のimport
はこのために必要だ。これもinline
で定義されているため、これを呼び出している箇所ではそのまま値を利用している状態になる。
これらの工夫により、実行時は@@
を全く利用していないのと同じ状態になる。
ゼロオーバーヘッド
試しに、この方法によるTagged Typesを利用したコードをjavap
を利用してディスアセンブルしてみよう:
object Tag: import scala.language.implicitConversions opaque type @@[R, T] = R inline def apply[R, T](r: R): R @@ T = r inline implicit def unwrap[R, T](tagged: R @@ T): R = tagged object App { import Tag.* type UserId type Name val userId: String @@ UserId = Tag("42") val userName: String @@ Name = Tag("Windymelt") def greeting(id: String @@ UserId, name: String @@ Name): Unit = { println(s"Hello, $id, aka $name!") } }
いったんコンパイルする:
% scala-cli package --library TaggedType.scala
JARができるので、javap
コマンドに投入してみよう:
% javap -classpath TaggedType.jar App$ Compiled from "TaggedType.scala" public final class App$ implements java.io.Serializable { public static final App$ MODULE$; public static {}; public java.lang.String userId(); public java.lang.String userName(); public void greeting(java.lang.String, java.lang.String); }
タグの型情報は完全に消え去って、もとのString
に戻っていることがわかる。このおかげで、実行時のパフォーマンスはString
を使う場合と全く同じだ。
Tagged Typeの実際
実際はTagged Typeの記法をそのまま使うのではなく、エイリアスにして使うことが多い。典型的な利用例はIDの識別だ:
type UserId = BigInt @@ UserIdTag type PurchaseId = BigInt @@ PurchaseIdTag
まとめ
- Tagged Typeを利用すると、オーバヘッドなしで型安全にデータを識別できるようになることがわかった。
- つまり、実行時には型情報は消失して完全に元々の型として動作する。
- Scala 3に新たに追加されたOpaque Typeを利用すると、簡単にTagged Typeを利用できることがわかった。