Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Tagged Type(Branded Type)を使って飛行機の不時着や人工衛星紛失を防ごう / Scala 3ではTagged Typeを簡単に作れる

Tagged Type というテクニックがある(TypeScript界隈などではBranded Typeと呼ばれているようだ)。実行時の型としては同じだが、型システム上はこれを区別して別物として扱い、混同できなくする仕組みを作るためのものだ。

AIくん!サムネイラスト作って!と頼んで作ってもらった画像

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を利用できることがわかった。
★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?