Scala 3 Book にあるドメインモデリングと関数型を読む
はじめに
レバテック開発部でバックエンドエンジニアをしている瀬尾です!
ドメインモデリングに興味があります。
最近は関数型があついですよね~勉強しなきゃな~みたいな感じでいます。
SNS で Scala3 はいいぞ的な話を見かけて試しに公式ドキュメントの Scala 3 Book を見に行ったらなかなか充実していて、上述のことについて説明が書いてあったのでちょっと読んでみました。
具体的なコードとかは適当に飛ばして概要をつかみながら、僕が読みたい関数型とモデリングのところを翻訳していきます。そこにたまに感想を添えていきます。
興味がわいたらぜひ元のドキュメントを読んでみてください!
ドメインモデリング
これらについて書く。
- Tool: Class, Trait, Enumなどの導入
- OOP Modeling
- FP Modeling
Tool
個人的な理解の都合で、Trait と Enum だけ抜粋する。
Trait
基本的な使い方は、Interface として別の具体クラスで実装する抽象メンバーのみ定義する。
しかし、具体メンバーも含むこともできる。
例えば、いくつかの抽象メンバーと、きまった文章を出力するような具体メソッドを一緒に実装できる。
trait HasLegs:
def numLegs: Int
def walk(): Unit
def stop() = println("Stopped walking")
Traitの関心事はひとつにする。
そうすることで、extends
で継承するときに複数の Trait を組み合わせて大きなコンポーネントを構築できる。
ちなみに、同じ名前のメソッドや値の定義を持ってる Trait を同時に継承したとき、それらは自動的に対応付けられる?らしい。ちょっと怖い。
Enum
列挙型は、名前付きの値の有限な集合を定義できる。基本的にはSMLのサイズみたいな定数の集合を定義するときに使う。パターンマッチなど条件分岐に使える。
enum CrustSize:
case Small, Medium, Large
// if/then
if currentCrustSize == Large then
println("You get a prize!")
// match
currentCrustSize match
case Small => println("small")
case Medium => println("medium")
case Large => println("large")
また、Enumの要素は、値や Class を使って parameterized することもできる。
Rustにもこういうのあるね。
enum Color(val rgb: Int):
case Red extends Color(0xFF0000)
case Green extends Color(0x00FF00)
case Blue extends Color(0x0000FF)
OOP Modeling
Scala3では、下記機能を使ってモデリングする。
- Trait: 抽象のインターフェースを定義
- Mixin Composition: 小さいパーツからコンポーネントを作る(複数Traitを継承する)
- Class: 抽象の実装
- Instance: Classの実態で、それぞれの状態をもつ
- Subtyping: 親Traitが期待されるところで、あるクラスのインスタンスを使える
- Access modifiers: クラスが含むメンバーへのアクセス制御
Trait
Scala では、Trait が主要な分解の手段になる。
Odersky と Zenger は、サービス指向コンポーネントモデルというものを提唱してる。
そこでは、
- 抽象メンバー: required =サブクラスで実装される
- 具体メンバー: provided =サブクラスに提供される
とされていて、これらを Trait の機能を使って実践する。
Class
Scala でのソフトウェア設計では、継承モデルの最も葉っぱの部分でのみ Class を使うのがよいとされてるらしい。
その説明として、ドキュメントにはこんなのが載っていた。
Traits | T1, T2, T3 |
Composed traits | S1 extends T1, T2, S2 extends T2, T3 |
Classes | C extends S1, T3 |
Instances | C() |
いちおう Class を継承することもできるけど、継承するなら Trait を使うのが推奨とも書いてあった。
Scala3 でほんとに具体クラスを継承するときは、継承されるクラスに対して open
宣言しないといけないようになってるらしい。
open class Person(name: String)
FP Modeling
Scala の構文や構造を使ってモデリングする。
- Enumeration
- Case class(イミュータブルなクラス)
- Trait
ADT と GADT
前提として理解しておけとのことなので、まとめる。
ADT(代数的データ型)
ADT (Algebraic Datatypes) は Enum を使って構成する。例えば、共変型パラメータ T
(+
がつくと共変らしい)を持つ Option 型を ADT として表現するとこうなる。
enum Option[+T]:
case Some(x: T) // x で parameterized された Case class
case None
// 追加のメソッドを持ったりできる
def isDefined: Boolean = this match
case None => false
case Some(_) => true
GADT(一般化された代数的データ型)
GADT (Generalized Algebraic Datatypes) は、列挙型より明示的な表現によって強力な型を得られるもの。以下は、型パラメータ T
でボックスに格納されている値の型を指定する GADT の例。
enum Box[T]:
case IntBox(value: Int) extends Box[Int]
case StringBox(value: String) extends Box[String]
各ケースに型に関する情報を持たせることで、異なる型のデータを安全に扱うことができるし、パターンマッチでも各ケースの型を理解して処理することができる。
関数型の導入
FP では、データとそのデータに対する操作は別のものとして扱われる。
OOP のように一緒にカプセルすることは強制されない。
この概念は、0 以上の整数(0, 1, 2, …)とそれらに対する可能な操作(加算、減算、乗算)といった数値代数(numerical algebra)に似ている。
FP では、ビジネスドメインを同様の方法でモデリングする。
- データ=値の集合の定義
- 関数=値に対する操作の定義
例として、ピザ店の「ピザ」に関するデータと操作をモデリングする。
データのモデリング
- enum(和): 選択肢をもつデータのモデリングに利用
- case class(積): モノをグループ化したい、またはより細やかな制御が必要なときに利用
これらを使って、ピザを構成するデータをモデリングする。
enum CrustSize:
case Small, Medium, Large
enum CrustType:
case Thin, Thick, Regular
enum Topping:
case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions
case class Pizza(
crustSize: CrustSize,
crustType: CrustType,
toppings: Seq[Topping]
)
ピザ注文システムの全体をモデリングすると、以下のようになる。
case class Address(
street1: String,
street2: Option[String], // NullableをOption型で表現(後述)
city: String,
state: String,
zipCode: String
)
case class Customer(
name: String,
phone: String,
address: Address
)
case class Order(
pizzas: Seq[Pizza], // Seqは配列
customer: Customer
)
データモデルはふるまいを持たない。RDB の設計みたいになる。
余談だけど『Functional and Reactive Domain Modeling』という本では、このようなFPのデータモデルを"スキニードメインオブジェクト"、対してOOPのものを"リッチドメインモデル"と述べていたらしい。たしかに。
操作のモデリング
FP の操作は、モデリングしたデータの値に対して動作する関数として書く。
たとえば、ピザの価格を計算する関数は下記のようになる。
/*
ピザの価格計算
パターンマッチを用いたPizzaに対しての処理がある
*/
def pizzaPrice(p: Pizza): Double = p match
case Pizza(crustSize, crustType, toppings) =>
val base = 6.00
val crust = crustPrice(crustSize, crustType)
val tops = toppings.map(toppingPrice).sum
base + crust + tops
/*
生地の価格(pizzaPriceで使用)
*/
def crustPrice(s: CrustSize, t: CrustType): Double = (s, t) match
// if the crust size is small or medium,
// the type is not important
case (Small | Medium, _) => 0.25
case (Large, Thin) => 0.50
case (Large, Regular) => 0.75
case (Large, Thick) => 1.00
/*
トッピングの価格(pizzaPriceで使用)
*/
def toppingPrice(t: Topping): Double = t match
case Cheese | Onions => 0.5
case Pepperoni | BlackOlives | GreenOlives => 0.75
すべての関数が純粋関数で、データを変更したり例外を投げたりといった副作用がない点が重要。
これらは単に値を受け取って、結果を計算するだけ!
この性質によって、テストや再利用が容易になる。
公式ドキュメントでは、このあと pizzaPrice()
をどこに定義して機能を整理していくか、実装上の整頓アプローチを説明していた。Scalaでは 4 種類のアプローチがあるらしい。割愛!
関数型プログラミング(FP)
続いて、FPについて。
関数型コードを書くとき、数学者になったように感じます。
はやく自分のことを数学者だと思っている一般男性になりたい。
Immutable Values
FP では、常に不変な値が使用される。
- すべての変数を val(
const
的なの)で宣言 - 不変のコレクションクラス(List, Vector, 不変の Map/Set)のみ使用
コレクションを使用するときは、既存のコレクションを変更せず、高階関数の map/filter を適用して新しいコレクションを返すことで不変を保つ。
val で宣言した Class のインスタンスに変更が必要なときは、copy して変更する。
val reginald = Person("Reginald", "Dwight")
val elton = reginald.copy(
firstName = "Elton", // update the first name
lastName = "John" // update the last name
)
Pure Functions
純粋な関数は次のように定義できる:
- 関数
f
が純粋である
= 同じ入力x
が与えられたとき、常に同じ出力f(x)
を返す - 関数の出力は、その入力と実装にのみ依存する
- 出力を計算するだけで、関数外の世界を変更しない
???「純粋関数マンはね、入力を変更しないし、勝手に状態を変更しないし、外部のデータを読み書きしたりしちゃいけないの。」
例えば、Math 系のパッケージにある max 関数とか、String の length とか
コレクションクラスの drop, filter, map とかは純粋関数。
Impure Functions
逆に不純な関数とは、日時や時刻に関連するメソッドや例外を投げるメソッド。
でもこのような不純な関数は、アプリを書くうえで必要…
そこで、以下のようなことが推奨されていた:
アプリケーションのコアを純粋な関数を使って書き、その周りに外部世界と対話するための不純な「ラッパー」を書く。
個人的に読んでいる『関数型ドメインモデリング』の本の内容もこれに基づいてるなと思った。
関数型のプログラムは、純粋関数で構成されるコアがあり、外部と相互作用する他の関数でラップされているという理解をするのがキーポイント。
外部世界との不純な相互作用をより純粋に感じさせる方法として、モナドを使う方法があるみたい。
(モナド、まったくわからない…)
関数は値である
Scala など FP をサポートする言語の重要な特徴として、「関数を値として扱うことができる」がある。これによって、メソッドが関数パラメータを受け取ることができ、関数をメソッドに引数として渡すことができる。
別の関数を入力パラメータとして受け取ったり出力として返したりする関数は「高階関数」と呼ばれる。
関数型でのエラーハンドリング
代数には例外や null 値が存在しないが、FP ではどのようにそれらを扱うのかについて。
Scala では「Option 型」を使う。
Option型で例外を表現
以下の例は、変換が成功すれば正しい整数値を返すけど、失敗したら 0 を返す。
でも 0 が出力されたとき、本当は 0 を受け取っていたのか整数値以外を受け取っていたのか知るすべがない。
def makeInt(s: String): Int =
try
Integer.parseInt(s.trim)
catch
case e: Exception => 0
これを解決するために Option 型を利用して、整数に変換できる入力の場合は Some
でラップして返し、変換できない場合は None
を返すようにできる。
こうすると、メソッドが例外ではなく値を返すようになる。
def makeInt(s: String): Option[Int] =
try
Some(Integer.parseInt(s.trim))
catch
case e: Exception => None
val a = makeInt("1") // Some(1)
val b = makeInt("one") // None
Some
は 1つのアイテムを持つコンテナで、None
はアイテムを持たないコンテナだという理解をしておくとよい。
重要なポイントは、関数型のメソッドは例外を投げないこと!
Option 型で nullable を表現する
例えば street2
が null の可能性をもつ場合、Option 型にする。
class Address(
var street1: String,
var street2: Option[String], // an optional value
var city: String,
var state: String,
var zip: String
)
// street2がnull
val santa = Address(
"1 Main Street",
None, // 'street2' has no value
"North Pole",
"Alaska",
"99705"
)
// street2がnotnull
val santa = Address(
"123 Main Street",
Some("Apt. 2B"),
"Talkeetna",
"Alaska",
"99676"
)
Option 型から値を取り出すときは、match か for を使う。
おわりに
言語機能や関数型プログラミングの説明だけでなく、ドメインモデリングの具体例まであるのは手厚いなと思いました。
とくに関数型プログラミングの説明は、最近読んでいた本に通ずるところもあり面白かったです。
元のドキュメントにはより詳しく書いてありましたので、興味がある方は読んでみてください!
次は関数型プログラミングの理解もかねて、話題になっていた Haskell で作る Json パーサーに挑戦しようかなと考えています🌱
モナドのこともわかるかも知れないですしね!
レバテック開発部の公式テックブログです! レバテック開発部 Advent Calendar 2024 実施中: qiita.com/advent-calendar/2024/levtech
Discussion