Scala のケースクラスに制約を持たせる

Scala のケースクラスで値に制約を持たせたい場合にどうするか。

例えば、以下のケースクラスで amount の値を 0 以上となるように制限し、0 未満ならインスタンス化を失敗させる事を考えてみます。

case class Quantity(amount: Int)

使用した環境は以下

ソースは http://github.com/fits/try_samples/tree/master/blog/20181009/

ケースクラスの値を制限

まず、最も単純なのは以下のような実装だと思います。

case class Quantity(amount: Int) {
  if (amount < 0)
    throw new IllegalArgumentException(s"amount($amount) < 0")
}

これだと、例外が throw されてしまい関数プログラミングで扱い難いので Try[Quantity]Option[Quantity] 等を返すようにしたいところです。

そこで、以下のようにケースクラスを abstract 化して、コンパニオンオブジェクトへ生成関数を定義する方法を使ってみました。

sample.scala
import scala.util.{Try, Success, Failure}

sealed abstract case class Quantity private (amount: Int)

object Quantity {
  def apply(amount: Int): Try[Quantity] =
    if (amount >= 0)
      Success(new Quantity(amount){})
    else
      Failure(new IllegalArgumentException(s"amount($amount) < 0"))
}

println(Quantity(1))
println(Quantity(0))
println(Quantity(-1))

// この方法では Quantity へ copy がデフォルト定義されないため
// copy は使えません(error: value copy is not a member of this.Quantity)
//
// println(Quantity(1).map(_.copy(-1)))

実行結果は以下の通りです。

実行結果
> scala sample.scala

Success(Quantity(1))
Success(Quantity(0))
Failure(java.lang.IllegalArgumentException: amount(-1) < 0)

上記 sample.scala では、以下を直接呼び出せないようにしてケースクラスの勝手なインスタンス化を防止しています。

  • (a) コンストラクタ(new)
  • (b) コンパニオンオブジェクトへデフォルト定義される apply
  • (c) ケースクラスへデフォルト定義される copy

そのために、下記 2点を実施しています。

  • (1) コンストラクタの private 化 : (a) の防止
  • (2) ケースクラスの abstract 化 : (b) (c) の防止

(1) コンストラクタの private 化

以下のように private を付ける事でコンストラクタを private 化できます。

コンストラクタの private 化
case class Quantity private (amount: Int)

これで (a) new Quantity(・・・) の実行を防止できますが、以下のように (b) の apply や (c) の copy を実行できてしまいます。

検証例
scala> case class Quantity private (amount: Int)
defined class Quantity

scala> new Quantity(1)
<console>:14: error: constructor Quantity in class Quantity cannot be accessed in object $iw
       new Quantity(1)

scala> Quantity(1)
res1: Quantity = Quantity(1)

scala> Quantity.apply(2)
res2: Quantity = Quantity(2)

scala> Quantity(3).copy(30)
res3: Quantity = Quantity(30)

(2) ケースクラスの abstract 化

ケースクラスを abstract 化すると、通常ならデフォルト定義されるコンパニオンオブジェクトの apply やケースクラスの copy を防止できるようです。

そのため、(1) と組み合わせることで (a) ~ (c) を防止できます。

ケースクラスの abstract 化とコンストラクタの private 化
sealed abstract case class Quantity private (amount: Int)

以下のように Quantity.apply は定義されなくなります。

検証例
scala> sealed abstract case class Quantity private (amount: Int)
defined class Quantity

scala> new Quantity(1){}
<console>:14: error: constructor Quantity in class Quantity cannot be accessed in <$anon: Quantity>
       new Quantity(1){}
           ^

scala> Quantity.apply(1)
<console>:14: error: value apply is not a member of object Quantity
       Quantity.apply(1)

このままだと何もできなくなるため、実際はコンパニオンオブジェクトへ生成用の関数が必要になります。

sealed abstract case class Quantity private (amount: Int)

object Quantity {
  def create(amount: Int): Quantity = new Quantity(amount){}
}

備考

今回の方法は、以下の書籍に記載されているような ADTs(algebraic data types)と Smart constructors をより安全に定義するために活用できると考えています。

Functional and Reactive Domain Modeling

Functional and Reactive Domain Modeling