Lambdaカクテル

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

Invite link for Scalaわいわいランド

値の検証もコンパイラにやらせよう: Scala 3でRefinement TypesやるにはIronっていうライブラリが良さそう

Scala 3でRefinement Types(篩型)を実現するライブラリIronに入門したので紹介します。最初はRefinedに入門しようとしてたら、Scala 3ではあまり動かなかったのでそのままIronに入門しました。

tl;dr

  • Refinement Typesっていうのを使うと、普段動的にチェックしてる値の性質が型に反映されるのでコンパイラが助けてくれる
  • ScalaでRefinement TypesをやるライブラリとしてRefinedがある
  • Scala 3ではパワーを発揮できないので、Scala 3ではIronというライブラリをおすすめしたい
  • Refinedよりもシンプルでめちゃ良い感じです!

Refinement Types (篩型)

  • Refinement Typesとは、値の制約を型に反映させる手法。
    • 型で表現できるのがアツいんです!!!
  • Refinement Typesを使うことで、「正の整数」「8文字以上の文字列」といった、追加の性質を型レベルで表せる。
  • 型レベルで表せるということは、人間の代わりにコンパイラが性質の検査を行うため、コンパイル時に問題を検出できるようになり、開発を効率化できる。
  • Refinement Typesはずっと型として値に付いていくため、一度チェックしたらずっと安全なままを保つ

Refinement Types以前

Refinement Typesを使わずに、がんばって値の性質を保証するアプローチはいくつかある。

  • 値クラスを用いる方法
  • スマートコンストラクタを用いる方法

しかしこれらの手法には限界があり、人間に頼る箇所が多く現われる。

Refinedを紹介する記事に出た例から引きつつ説明しよう。

engineering.visional.inc

通常の型 値クラス 値クラス + スマートコンストラクタ Refinement Types
値を取り違えない ×
不正な値を防ぐ × ×
型が性質を保つ × × ×
性質をファーストクラスで扱う × × ×

順に問題点を確認してみよう。

通常の型

何もしない通常の型では、値の取り違えが容易に発生する:

def registerUser(name: String, email: String) = ???

// ...

registerUser("windymelt@example.com", "windymelt") // oops!!

これは型検査では検出できない。ここではただのStringなので、正しいメールアドレスかどうかも検査できていない。

つまり、人間が「正しい引数を正しい場所に渡せているか」をコードレビューで確認しなければならないということだ。オエッ!

値クラス

値クラスを使うと型に別名を付けることができ、なおかつ互いに区別される。

case class UserName(v: String) extends AnyVal
case class Email(v: String) extends AnyVal

def registerUser(name: UserName, email: Email) = ???

// ...

// compile error!!!
registerUser(Email("windymelt@example.com"), UserName("windymelt"))

これにより互いに取り違えることを防げるようになった。しかし内容までは検査することができない:

Email("asdfasdfasdf6666666666") // valid code!!

これの意味するところは、コンパイル時には検査できず、実行時に突然爆発し、休日を台無しにするバグを埋め込めるということだ。オエッ!

スマートコンストラクタ

値クラスにスマートコンストラクタパターンを採用することで、不正な値を持つ値クラスを作れなくできる。

case class UserName(v: String) extends AnyVal
object UserName {
  def apply(userName: String): Option[UserName] = userName match {
    case s if s.length < 20 => Some(new UserName(s))
    case _ => None
  }
}

// ...

UserName("windymelt") // => Some(UserName)
UserName("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") // => None

しかし悲しいことに、これは迂回できる:

new UserName("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
// => UserName(...)

また、20文字以内というUserNameの性質は、一度取り出すと失われてしまう:

val u = UserName("windymelt") // => Some(UserName("windymelt"))
u.get.v // => "windymelt": String

このため、どこか別の場所でUserNameの中身を扱いたいときは、長さに関する保証は型から失われているため、また再検査しなければ本当に性質が守られているかわからない。

Refinement Types with Refined

ここは前座なので、Ironのところまで飛ばしてもいいです。

Refinement Typesでは、型に付随する追加の性質をそのまま型として表現できるため、値を取り違えることを防ぎ、不正な値を排除し、値が性質を保ち続ける。

ScalaでRefinement Typesを扱うデファクトスタンダードのライブラリはRefinedだ。(後述する理由により、Scala 3ではIronを使うことになる)

Refinedのインストール

Refinedを利用するには"eu.timepit" %% "refined" % "0.11.1"を依存性に追加する(執筆時点で最新)。この例ではScala Scriptを用いて説明する。また、この例ではScala 3.3.0を利用している前提で解説する。

blog.3qe.us

ちなみに、Scala 3以降ではリテラル型をコンパイル時に検証できないという問題がある(後述)。

//> using dep "eu.timepit::refined:0.11.1"

Refinedで利用できる制約

Refinedは、eu.timepit.refined.以下に色々な制約を提供している。文字列が正規表現になっているか、みたいな面白い制約もある。

github.com

ここでは正の整数であることを扱う制約を例として使いたいので、eu.timepit.refined.numeric._importする:

import eu.timepit.refined.numeric._
import eu.timepit.refined._ // for refinedV

値をランタイムに検査してEitherに入れてくれるrefineVを使って、正の整数をRefinedで表現してみよう:

val posint = refineV[Positive](42) // => Right(Int Refined Positive)

refineVを使うことで、ただのIntが検査され、(Eitherに入った)Int Refined Positiveという型になる。この型が、「正のInt」という性質を表現している型だ。Refinedは便利なので色々な場所で使われているのだが・・・

Refinedの問題

RefinedはScala 2の時代から開発されてきたライブラリであるため、マクロまわりが大幅に変更されたScala 3では限定的な機能しか利用できない。例えば、ソースコード上のリテラルから直接Refine済みのリテラルを生成するためのrefineMVといった機能が使えない:

val n: Either[String, Int Refined Positive] = refineV[Positive](42)
// => Right(42: Int Refined Positive)
// 判定は実行時に行う

val nn: Int Refined Positive = refineMV[Positive](42)
// => 42: Int Refined Positive
// リテラルであり既に値が分かっているので判定はコンパイル時に行う
// リテラルが負の場合はコンパイルを落とす

これだとリテラルも実行時にチェックする必要があるので、どう見ても静的に判定できるのに実行時にEitherをもらう、といった非効率なやり方で利用するしかなくなってしまう。

Refinement Types with Iron

前述したRefinedの問題があるため、Scala 3ではRefinement Typesを利用することは難しかったが、Scala 3専用のIronというライブラリが登場したため、Scala 3でもRefinement Typesを手軽に利用できるようになった。

github.com

ロゴがなんかカッコいいよね。

IronはRefinedと比べて以下のような差異がある:

  • Scala 3専用
  • ジェネリックプログラミングのために、RefinedはShapelessを利用していたが、IronではScala 3の機能を直接利用するため、ジェネリックプログラミングのための外部依存ライブラリを持たない
  • 篩型は自動的に元の型のサブタイプになるようになっている
    • 正のIntという型は特に何もせずにIntに代入できるようになっている

Ironのインストール

Ironをインストールするには、依存性に"io.github.iltotore" %% "iron" % "2.4.0"を加える(執筆時点で最新)。

ここでもScala Scriptを使って説明する:

//> using dep "io.github.iltotore::iron:2.4.0"

blog.3qe.us

Ironを利用するには、以下の2つをimportする:

まずはIron本体の機能を使うためのimport:

import io.github.iltotore.iron.*

次に、利用したい制約の定義が書かれたパッケージ(ここでは数値まわりの制約を利用する):

import io.github.iltotore.iron.constraint.numeric.* // for Positive

面倒臭がりな君のために、全部の制約がやってくるパッケージもある。

import io.github.iltotore.iron.constraint.all.*

Ironで制約を利用する(リテラル)

Ironでは、型に対する制約は:|を用いて表現する。例えば、正の整数はInt :| Positiveという型で表現する:

val posint: Int :| Positive = 42 // コンパイル時に検査される
val err: Int :| Positive = -42 // Could not satisfy a constraint for type scala.Int.

コンパイル時にリテラルがちゃんと検査されるのが嬉しいところ。

他の制約についてはAPIドキュメントを読むと色々載っている。

iltotore.github.io

Refinedほど充実していないが、普通に手で作ることもできるのであまり困ることはなさそう。

Ironで制約を利用する(実行時)

不正な値は大抵外部からやってくる。外部からやってくる値はコンパイル時には不定なので、実行時に検査して安全性を確認する。

Ironで実行時の値の検査を行うには、.refine拡張メソッドを利用する:

val posInt: Int:| Positive = 42.refine
// 型パラメータを省略せずに書くとこんな感じ
val posInt: Int:| Positive = 42.refine[Positive]
// つまり、こういうふうにも書ける
val posInt = 42.refine[Positive]

ただし、.refineは制約に違反すると例外を投げる:

val err: Int :| Positive = -42.refine
// => IllegalArgumentException: Should be strictly positive

これだけと危ないので、より便利な.refineEither.refineOptionが提供されている:

val err: Option[Int :| Positive] = -42.refineOption
// => None

ちなみに、Cats / ZIO用のモジュールを利用すると.refineValidatedなどが利用できるようになるので、エラーを累積させて扱うこともできて便利だ:

//> using dep "io.github.iltotore::iron:2.4.0"
//> using dep "io.github.iltotore::iron-cats:2.4.0"

import cats.implicits.*
import cats.data.Validated
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.*
import io.github.iltotore.iron.cats.*

val errs = (
  -42.refineValidatedNel[Positive],
  42.refineValidatedNel[Positive],
  -666.refineValidatedNel[Positive]
) mapN ((_, _, _))

// => Invalid(NonEmptyList(Should be strictly positive, Should be strictly positive))

標準でCatsなどに対応しているのが素晴らしい。ちなみにCirceといったJSONライブラリにも対応しているみたい。

制約を組み合わせる

Ironでは、制約をそのまま&|で組み合わせることができる:

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.*
val fizzbuzzable = 6.refine[Multiple[3] | Multiple[5]]

直感的でエエですね。

もちろん、type aliasで新たな制約を作ることもできる:

import io.github.iltotore.iron.constraint.all.*

type GreaterEqual[V] = Greater[V] | StrictEqual[V]

素朴ですね。

制約を自作する

デフォルトで用意されている制約で足りないときは、自作できる。今回は「文字がATGCのどれか」という制約を作ってみよう。

制約を定義するには、まずfinal classで制約を表現するクラスを置く(これ自体はダミー)。

final class IsDNABase

次に、制約の対象にしたい型ごとにextension methodを定義する:

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"

すると制約として使えるようになる:

val c: Char :| IsDNABase = 'C'

より詳しい仕組みについては公式ドキュメントを見るとよい。個々の構成要素は素朴な感じで、そんなに難しくない。

iltotore.github.io

Ironとスマートコンストラクタ

Ironでは、.refineと書く代わりにスマートコンストラクタの形でも使うための仕組みが用意されている。DDD的な事をやりたいときはこっちのほうが扱いやすいだろう。

型エイリアスと、RefinedTypeOpsを継承したobjectを同名で定義すると、自動的に便利なメソッドが生える:

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.*

type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Double, Positive, Temperature]
Temperature(20.0) // => Double :| Positive
Temperature.option(-100.0) // => None
Temperature.either(...) // Either[String, Double :| Positive]

RefinedTypeOpsの名の通り、これは便利なメソッドを生やしてくれるただのユーティリティだ。

ドキュメントによれば、これらは全てinlineに展開されるので、追加のオーバーヘッドがないとのこと。

新しい型として扱う

さて、型に制約を与えても、完全に同一な制約同士は同一視されてしまうという問題がある:

import io.github.iltotore.iron.constraint.collection.*

object U {
  type UserName = String :| MinLength[10]
  object UserName extends RefinedTypeOps[String, MinLength[10], UserName]
}
object P {
  type Password = String :| MinLength[10]
  object Password extends RefinedTypeOps[String, MinLength[10], Password]
}

val p: P.Password = P.Password("foobar2000") // ok
val u: U.UserName = p // ok but danger!!

この例ではUserNameの型にPasswordを代入できてしまい、偶然取り違えてしまうかもしれないから危険だ。

Scala 3にはopaque typeという、型に別名を付けつつ、同一視はしないという型定義が可能なので、これを利用すると混同を回避できる:

import io.github.iltotore.iron.constraint.collection.*

object U {
  opaque type UserName = String :| MinLength[10]
  object UserName extends RefinedTypeOps[String, MinLength[10], UserName]
}
object P {
  opaque type Password = String :| MinLength[10]
  object Password extends RefinedTypeOps[String, MinLength[10], Password]
}

val p: P.Password = P.Password("foobar2000") // ok
val u: U.UserName = p // does not compile!!

まとめ

Ironを使うと、既存の型に追加の制約を与えられることがわかった。また、制約はファーストクラスで扱えるため、合成が可能だということもわかった。便利な拡張メソッドにより、構文上の破壊的な変更を最小限にしつつ既存のコードにRefinement Typesを導入できることも確認した。

個人的には、:|という記号が気に入った。全体的にシンプルな設計から最大のパワーを引き出すようにできているのも、素直で良いなと思う。しかし、Refinedと比べるとデフォルトで搭載されている制約の多様さは劣る面が否めない。複雑な制約は自作することになるだろう。しかし制約の自作はさほど難しくないので、パワーユーザであればたいして問題にならないはずだ。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?