概要
以前書いた記事の一部をScala3で書き直してみます。ちなみに Scala3.0.0-RC3
です。
Value Object: enum
予め決まった値の集合である列挙型を表す場合、以下のようになります。
object Prefecture
を用意しなくてもenumに用意されている Prefecture.valueOf(str)
を使って値の取得は出来るのですが、その場合は集合に無い値を入れるとExceptionが飛んでしまい安全でないのでこのように書いています。
enum Prefecture(val name: String){
case `北海道` extends Prefecture("北海道")
case `青森県` extends Prefecture("青森県")
case `岩手県` extends Prefecture("岩手県")
}
object Prefecture {
def apply(name: String): Option[Prefecture] = values.find(_.name == name)
}
Value Object: case class
可能なら全部列挙型のオブジェクトで済めば楽なのですが、数量や何かの名称などとても列挙しきれないパターンを持つオブジェクトに関しては一般的にはclassを定義してインスタンスを都度生成することになります。
このときは、状態の整合性について気をつけて実装する必要があります。
例えば以下の例では出目を任意の確率で操作できるイカサマ用のサイコロのオブジェクトを定義してみます。このとき、各目が出る確率は100%以下でなくてはいけません。また同時に、全ての確率の合計は100%でなくてはいけません。
Scalaには case class
というクラスを定義する際に必要となるメソッドを生やしてくれるシンタックスがありますが、Scala2系ではこのcase classを定義すると apply
や copy
メソッドが露出してしまうのを避けられませんでした。何が問題かと言うと、せっかく定義したバリデーションをすり抜けてインスタンスの生成が出来てしまうことになります。
そこで、以前の記事では sealed abstract classを使うことでapplyなどを生やさないようにしていました。
仕様を見つけられていないのですが、Scala3では case class のコンストラクタをprivateにするとapplyやcopyもまとめてprivateにしてくれるようで、あるべき姿になった気がします。
import cats.data.ValidatedNel
import cats.syntax.contravariantSemigroupal._
import cats.syntax.validated._
// 出る目の確率が細工されているサイコロオブジェクト
case class CheatDice private (
ratioOf1: BigDecimal,
ratioOf2: BigDecimal,
ratioOf3: BigDecimal,
ratioOf4: BigDecimal,
ratioOf5: BigDecimal,
ratioOf6: BigDecimal
)
object CheatDice {
def create(
ratioOf1: BigDecimal,
ratioOf2: BigDecimal,
ratioOf3: BigDecimal,
ratioOf4: BigDecimal,
ratioOf5: BigDecimal,
ratioOf6: BigDecimal
): ValidatedNel[IllegalArgumentException, CheatDice] = {
type ValidationResult[A] = ValidatedNel[IllegalArgumentException, A]
val validateRatios: ValidationResult[Seq[BigDecimal]] = {
val ratioSeq = Seq(ratioOf1, ratioOf2, ratioOf3, ratioOf4, ratioOf5, ratioOf6)
if (ratioSeq.exists(ratio => ratio <= BigDecimal(0) || ratio >= BigDecimal(100))) {
new IllegalArgumentException("確率はいずれも0~100%にしてください").invalidNel
} else if (ratioSeq.sum != BigDecimal(100)) {
new IllegalArgumentException("確率の合計が100%になっていません").invalidNel
} else ratioSeq.validNel
}
validateRatios.map(ratioSeq => {
CheatDice(
ratioSeq(0),
ratioSeq(1),
ratioSeq(2),
ratioSeq(3),
ratioSeq(4),
ratioSeq(5)
)
})
}
}
// 呼び出し方
val dice = CheatDice.create(BigDecimal(10), BigDecimal(10), BigDecimal(10), BigDecimal(10), BigDecimal(10), BigDecimal(10))
//↑ Invalid(NonEmptyList(java.lang.IllegalArgumentException: 確率の合計が100%になっていません))
val dice2 = CheatDice.create(BigDecimal(10), BigDecimal(10), BigDecimal(10), BigDecimal(10), BigDecimal(10), BigDecimal(50))
//↑ Valid(CheatDice(10,10,10,10,10,50))
Entity: opaque
例えばQiitaにあるようなタグを扱うTagというEntityを定義するとするとします。(タグはValueObjectとみなすケースもあると思いますが、ここではテキストを更新可能なオブジェクトとみなします。)
このとき考慮することの一つは、IDの型を付けることです。Int型そのままでも動くのですが、Entityが複雑になってきて、金額や個数などの値もIntで扱いだすとうっかり書き間違えてもコンパイルが通ってしまいます。また、場合によってはIDには生成メソッドやバリデーション(0以下にはならない、など)を定義したい場合もあります。Scala2系のときはJavaと同じくValue Classという変数一つだけ持つクラスでラップしていましたが、Scala3では Opaque Type
と呼ばれる記法により、型安全を保ちつつ実行時のオーバーヘッドを無くすことが出来ています。
opaque type TagId = Int
object TagId {
def apply(i: Int): TagId = i
def createNew(): TagId = scala.util.Random().nextInt()
}
case class Tag private(
id: TagId,
title: String,
) {
def updateText(
newText: String,
): Either[NonEmptyList[IllegalArgumentException], Tag] = {
Tag.create(id, newText)
}
}
object Tag {
private def validateText(text: String): ValidatedNel[IllegalArgumentException, String] = {
if (text.isEmpty) {
IllegalArgumentException("内容が空になっています").invalidNel
} else text.validNel
}
def create(
id: TagId,
text: String,
): Either[NonEmptyList[IllegalArgumentException], Tag] = {
val validateText: ValidatedNel[IllegalArgumentException, String] = Tag.validateText(text)
val tag = validateText.map(text => Tag(id, text))
tag.toEither
}
}
// 呼び出し方
val tag = Tag.create(TagId.createNew(), "")
//↑ Left(NonEmptyList(java.lang.IllegalArgumentException: 内容が空になっています))
val tag2 = Tag.create(TagId.apply(10), "tag1")
// Right(Tag(10,tag1))
val validTag = tag2.right.get
val tag3 = validTag.updateText("")
//↑ Left(NonEmptyList(java.lang.IllegalArgumentException: 内容が空になっています))