Scala 3 には篩型(refinement types)を実現するためのライブラリであるIronが存在する。
このライブラリを使うと、非負整数とか、8桁の文字列とか、UUIDといった、元々の型に加えて制約を加えたものをそのまま型情報に載せて扱うことができる。詳しくは記事を読んでみてほしい。
↑の記事にも追記したことだが、先日 Iron v3.0.0 がリリースされた。おめでとう!
この記事では、ほぼ上に書かれていることをなぞっているだけだが、 Iron 3 の変更について紹介する。
また、公式にマイグレーションガイドが提供されている。
Iron 3
見ての通り Iron 3 は Iron の3つめのメジャーバージョンであり、いくつかの破壊的変更を含んでいる。しかしその差異は小さく軽微で、十数分もあれば訂正できてしまうようなものばかりだ。 それどころか、いくつかの型定義が簡略化されており、より簡単に篩型を定義できるようになっている。順に見ていこう:
破壊的変更: Scala 3.6.3 以上が必要になった
コンパイラのバグに影響を受けていた箇所があったようで、これが修正された Scala 3.6.3 以降でなければ Iron 3 は動作しない。
Ironのリリースポリシーは「可能な限りLTSに留まる」というものだが、メジャーバージョンアップにともなっていったん最新のScalaを利用しなければならなくなったようだ。このバグ修正が含まれた次回のLTSが発表されたタイミングで、IronがLTSにまた留まることになるはずだ。
破壊的変更: RefinedTypeを作成する手順の簡略化
従来のIronには、独自の篩型定義にapply
メソッドを付けて一般的なコンテナ型のように扱えるようにする機能が存在していた(とりあえずRefinedTypeと呼んでおく)。例えばKelvin
という篩型に対して、以下のような記述ができるようになる:
Kelvin(300.0)
この機能は従来はRefinedTypeOps
によって提供されていた:
import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.numeric.* // for Positive opaque type Kelvin= Double :| Positive object Kelvin extends RefinedTypeOps[Double, Positive, Kelvin]
Iron 3.0.0 以降は、RefinedType
を介することになる:
import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.numeric.* // for Positive type Kelvin = Kelvin.T object Kelvin extends RefinedType[Double, Positive]
ここで3つの変更点が生じている:
opaque type
宣言はもはや必要なく、type
を利用するだけでよい。type
宣言はもはやobject
のT
を呼ぶだけでよい。object
側ではRefinedType
を継承し、元々の型と制約の2つを記述するだけでよい。
この変更は軽微な一方、制約の定義が複雑な場合に威力を発揮する:
// 従来 type KelvinR = DescribedAs[Positive, "Kelvin should be positive"] opaque type Kelvin= Double :| KelvinR object Kelvin extends RefinedTypeOps[Double, KelvinR, Kelvin] // 3以降 type Kelvin = Kelvin.T object Kelvin extends RefinedType[Double, DescribedAs[Positive, "Kelvin should be positive"]]
一度中間にopaque type
を挟む必要がなくなったため、制約(ここではKelvinR
)の定義が複雑化しても素直に書けているのが分かるはずだ。
RefinedType#apply
は何の断りもなく呼び出せるようになった
先程も説明したRefinedTypeだが、利用するときには特殊なインポートを入れる必要があった:
//Double を Double :| Positive に変換するために必要 import io.github.iltotore.iron.autoRefine Kelvin(600.0)
しかしこれはライブラリ作者にとって不都合だった。というのもライブラリが使っている Iron の事情がアプリケーションコードにはみ出してしまうからだ。使う側からすれば、よくわからない import
が増えてしまうので嬉しくないし分かりにくい。
Iron 3からは、この import
はまったく不要になった:
Kelvin(600.0)
破壊的変更: カスタム制約の定義にinline
が必要になった
Iron では、既存の制約で足りない場合に自前で制約を定義できる:
// 従来のコード final class IsDNABase given Constraint[Char, IsDNABase] with override inline def test(value: Char): Boolean = value == 'A' || value == 'T' || value == 'G' | value == 'C' override inline def message: String = "Should be one of A, T, G, C"
Iron 3 からは、Constraint
のtest
メソッドの引数をinline
で定義しなければならなくなった:
final class IsDNABase given Constraint[Char, IsDNABase] with // inline value: Char になっている override inline def test(inline value: Char): Boolean = value == 'A' || value == 'T' || value == 'G' | value == 'C' override inline def message: String = "Should be one of A, T, G, C"
非-プリミティブな型もコンパイルタイミングでチェックできるようになった
Iron の面白い特徴として、リテラルはコンパイルタイミングでチェックしてもらえる、というものがある:
val k: Kelvin = Kelvin(-42.0) // コンパイルタイミングで弾かれる
他の多くのライブラリでは、リテラルであっても実行時にいったんバリデーション処理を通さなければならないことが多い:
import io.github.iltotore.iron.* // 極端な例 val k: Option[Kelvin] = -42.0.refineOption[Kelvin]
しかし、このコンパイルタイミングでチェックできるのはプリミティブな値、つまりただの文字列とか整数などに限られていた。
Iron 3 からはこの制約が一部緩和され、以下の型のリテラルがコンパイルタイミングで検証してもらえるようになった:
- BitInt
- BigDecimal
- Array
- List
- Set
例えば、偶数のInt
しか入らないSet
であるEvenSet
を定義した場合、以下のようなリテラルがコンパイルタイミングでチェックされる:
import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.collection.ForAll import io.github.iltotore.iron.constraint.numeric.Even type EvenSet = Set[Int] :| ForAll[Even] val es: EvenSet = Set(42) val ex: EvenSet = Set(41) // コンパイルしない
ちなみにこの改善は、前述したinline
を導入した副産物であるとのこと。面白い。
エラーメッセージの簡略化
これまで型チェックに失敗したエラーメッセージはけっこう長かったが、Iron 3 からはより簡潔に表示されるようになった。
PureConfig への標準対応
設定ファイル読み込みライブラリである PureConfig を扱うためには別途ライブラリを導入する必要があったが、 Iron 3 からは何もせずとも対応するようになった。