Scala 3におけるデータ指向プログラミング(以下DOP)について深掘りする。久々にScalaの話題を取り上げるが、これはScala Advent Calendar 2023の14日目の内容でもある。
早速だけど、DOPの基本原則は意外とシンプルだ。
- コード(動作)をデータから切り離す
- データを汎用的なデータ構造で表現する
- データをイミュータブル(不変)として扱う
- データスキーマをデータ表現から切り離す
イミュータブルなデータは採用することは多いと思うが、これをそのまま実践している人はどのくらいいるだろうか。Scalaではクラス中心の関数型プログラミングが主流だと思うし、私もそうしている。
DOPの詳細は下記の本(以下DOP本)を参照してほしい。
ちなみに留意すべき点がある。DOP本とJavaのProject Amberにおけるデータ指向プログラミングは、同じ用語を使っているが方向性が異なる。DOP本では上記の原則に重点を置いている一方で、Project Amberではデータの解釈を型で表現しモデリングのしやすさに焦点を当てている。これは多義的な用語となっているため注意が必要である。この違いは、データ指向アプローチを実践する際の重要な考慮事項である。詳細は 川島さんのスライドを参照すると良い。今回の記事もDOP本のほうで書いているので注意。
さて、今回はScala 3でDOPを試みる。題材は「ショッピングカート」。中心的な集約はカート(Cart型)と注文(Order型)。
興味のある方は、OOPスタイルとDOPスタイルの両方で記述された以下のソースコードを参照してほしい。(ちなみにドメイン部分を中心にコードを書いている)
DOPを試すために、まずはご多分に漏れず「お金」オブジェクトを実装してみた。(Scala 3で実践する都合上、DOP本のまま推奨の方法で行っていない部分もありますが、そこはご容赦ください)
// 原則2, 3 opaque type Money = Map[String, Any] object Money { final val DefaultCurrency: Currency = Currency.getInstance(Locale.getDefault) final val JPY: Currency = Currency.getInstance("JPY") def zero(currency: Currency = DefaultCurrency): Money = Money(0, currency) def apply(amount: BigDecimal, currency: Currency = DefaultCurrency): Money = Map("amount" -> amount, "currency" -> currency) def apply(amount: BigDecimal, currency: String): Money = Money(amount, Currency.getInstance(currency)) def unapply(self: Money): Option[(BigDecimal, Currency)] = Some((self.amount, self.currency)) // 原則1, 3 extension (self: Money) { // 原則4 def amount: BigDecimal = self("amount").asInstanceOf[BigDecimal] def currency: Currency = self("currency").asInstanceOf[Currency] // 二項演算子を定義。 例) val m3: Money = m1 + m2 infix def +(other: Money): Money = { require(currency == other.currency) Money(amount + other.amount, currency) } infix def -(other: Money): Money = self + -other infix def *(multiplier: Int): Money = Money(amount * multiplier, currency) infix def *(multiplier: Double): Money = Money(amount * multiplier, currency) infix def /(divisor: Int): Money = Money(amount / divisor, currency) infix def /(divisor: Double): Money = Money(amount / divisor, currency) // 単項演算子を定義。 例) val m2: Money = -m1 def unary_- : Money = Money(-amount, currency) def negated: Money = -self def plus(other: Money): Money = self + other def minus(other: Money): Money = self - other def times(multiplier: Int): Money = self * multiplier def times(multiplier: Double): Money = self * multiplier def divide(divisor: Int): Money = self / divisor def divide(divisor: Double): Money = self / divisor } // Moneyどうしの比較 given Ordering[Money] = (x: Money, y: Money) => { require(x.currency == y.currency) x.amount.compare(y.amount) } // IntからMoneyへの暗黙的型変換。 例) val m1: Money = 100 given Conversion[Int, Money] = (amount: Int) => Money(amount, DefaultCurrency) }
定義側は全く見慣れない構造だが、利用側は意外と慣れ親しんだスタイルで使える。
val money1 = Money(100) val money2 = Money(200) val result1 = money1 + money2 // Money(300) val result2 = money1 - money2 // Money(-100) val result3 = money1 * 2 // Money(200) val result4 = money1 / 2 // Money(50)
原則1 「コード(動作)をデータから切り離す」
原則1では、データ構造とメソッドを分離する。つまり、関数やメソッドを独立させるということ。
データの定義
データ型としてのMoney
はMap[String, Any]
として実装されている。クラスベースだと属性と振る舞いを含めて型というイメージだが、DOPでは型はデータのみ。
opaque type Money = Map[String, Any]
もちろん、これはScala 3の固有の機能なのでDOP本に言及はなく、今回の独自の工夫をしている部分になる。opaque type
を使うことで、Map
型でありながらMoney
として型安全を保てる。間違って他の型やMap[String, Any]
型の引数に渡すとコンパイルエラーになる。DOPするならこの機能は是非使いたいところ。
コードの定義
Money
に関連するメソッド群は、拡張メソッドとして定義した。これも今回の独自の工夫と言ってよい。this
に相当する部分はメソッドの引数として渡される。実装上はself
としている。GoやRustのメソッドに似たような記述になる。Money.plus(self, other)
のようにself
を引数の取ってもよいが、左から右に流れるように読むことが難しいので拡張メソッドにした。
extension (self: Money) { // ... infix def +(other: Money): Money = { require(currency == other.currency) Money(amount + other.amount, currency) // copyメソッドは使えない… } // ... }
amount
やcurrency
もメソッドとして定義されているため、ここではあまり差分はない。
+
とplus
の加算メソッドの使い方として、以下はすべて同じ意味になる。
val r1 = money1 + money2 val r2 = money1.+(money2) val r3 = Money.+(money1)(money2)
val r4 = money1.plus(money2) val r5 = Money.plus(money1)(money2)
原則2「データを汎用的なデータ構造で表す」
コンストラクタはapply
メソッドで表現。ただのMap[String, Any]
生成だ。これは柔軟性を得るためのトレードオフ。
def apply(amount: BigDecimal, currency: Currency = DefaultCurrency): Money = Map("amount" -> amount, "currency" -> currency) def apply(amount: BigDecimal, currency: String): Money = Money(amount, Currency.getInstance(currency))
もちろん、値型がAny
なので型安全性が失われる。これは具体的な値型を使うクラスの場合と違って値を取り扱う場合に注意が必要になる。
DOP的な柔軟性が下がるが型安全性が気になる場合は、構造体的なcase class
でもいいのではないか。
原則3 「データをイミュータブル(不変)として扱う」
Scalaではミュータブルなデータ構造を避けるだけで、この原則は容易に実現できる。Money
のメソッド群も不変。
原則4 「データスキーマをデータ表現から切り離す」
原則4 「データスキーマをデータ表現から切り離す」は、データとデータスキーマを分離する考え方。
一つには、フィールドアクセス用の専用メソッドがある意味データスキーマになっている。
extension (self: Money) { // ... def amount: BigDecimal = self("amount").asInstanceOf[BigDecimal] def currency: Currency = self("currency").asInstanceOf[Currency] // ... }
ところで、サブタイプの表現はDOPではどう扱うか。DOPでは継承を利用しないため、サブタイプごとのデータ型だけが存在することになる。
しかし、Scalaの場合、このアプローチに固執する必要はないだろう。sealed trait
や型クラスを利用すれば良いのではないか。今回の取り組みはあくまで実験的なものである。
Cart
に含める商品にはダウンロード型のコンテンツ(DownloadableItem
)や車(CarItem
)やそれ以外の商品(GenericItem
)がある。それらの共通項を括りだしたのがItem
型になる。
package example.j5ik2o.dop.domain import example.j5ik2o.common.domain.ItemType import java.net.URL opaque type Item = Map[String, Any] opaque type ItemId = String object ItemId { def apply(value: String): ItemId = value def unapply(self: ItemId): Option[String] = Some(self) given Conversion[String, ItemId] = ItemId(_) extension (self: ItemId) { def value: String = self } } object Item { def apply(id: ItemId, name: ItemName, price: Money, itemType: ItemType): Item = Map("id" -> id, "name" -> name, "price" -> price, "type" -> itemType) extension (self: Item) { def id: ItemId = self("id").asInstanceOf[ItemId] def name: ItemName = self("name").asInstanceOf[ItemName] def price: Money = self("price").asInstanceOf[Money] def itemType: ItemType = self("type").asInstanceOf[ItemType] } } opaque type GenericItem = Map[String, Any] object GenericItem { def apply(id: ItemId, name: ItemName, price: Money): GenericItem = Item.apply(id, name, price, ItemType.Generic) def unapply(self: GenericItem): Option[(ItemId, ItemName, Money)] = Some((Item.id(self), Item.name(self), Item.price(self))) extension (self: GenericItem) { def id: ItemId = Item.id(self) def name: ItemName = Item.name(self) def price: Money = Item.price(self) } given Conversion[GenericItem, Item] = _.asInstanceOf[Item] } opaque type DownloadableItem = Map[String, Any] object DownloadableItem { def apply(id: ItemId, name: ItemName, url: URL, price: Money): DownloadableItem = Item.apply(id, name, price, ItemType.Download) + ("url" -> url) def unapply(self: DownloadableItem): Option[(ItemId, ItemName, URL, Money)] = Some( ( Item.id(self), Item.name(self), self.url, Item.price(self) ) ) given Conversion[DownloadableItem, Item] = _.asInstanceOf[Item] extension (self: DownloadableItem) { def id: ItemId = Item.id(self) def name: ItemName = Item.name(self) def url: URL = self("url").asInstanceOf[URL] def price: Money = Item.price(self) } } opaque type CarItem = Map[String, Any] object CarItem { def apply(id: ItemId, name: ItemName, price: Money): CarItem = Item.apply(id, name, price, ItemType.Car) def unapply(self: CarItem): Option[(ItemId, ItemName, Money)] = Some((Item.id(self), Item.name(self), Item.price(self))) given Conversion[CarItem, Item] = _.asInstanceOf[Item] }
共通処理を行いたい場合、各型を共通のItem
型に変換する。
val genericItem: GenericItem = GenericItem("1", "name", 100) assert(genericItem.id.value == "1") val item: Item = genericItem assert(item.id.value == "1")
暗黙的型変換により、GenericItem
をItem
に変換できる。本質的にはMap
なので、単なるキャストに過ぎない。
given Conversion[GenericItem, Item] = _.asInstanceOf[Item]
抽象型に相当するItem
のメソッドは、GenericItem
に対しても呼び出せる。ただ、これは実装がやや面倒な側面がある。ちなみに、Item
のメソッドにGenericItem
のself
を渡しているが暗黙的型変換でItem
に変換されているので、問題なくコンパイル可能となっている。
extension (self: GenericItem) { def id: ItemId = Item.id(self) def name: ItemName = Item.name(self) def price: Money = Item.price(self) }
他にもurl
を持つDownloadableItem
のデータを必要に応じてItem
型で利用する部分があるが、検証すべきデータを自由に選択できるので確かに柔軟性が高いかもしれない。
Cart型, Order型
次はCart
を実装。Money
と同じスタイルで可能。
CartId
もopaque type
で文字列型。apply
メソッドで検証を行う。
copy
メソッドが使えないが、Map
の+
でcopy
メソッド相当のことができる。
Order
型も問題なく実装できた。1
ざっと実装コードを眺めるとやはりGo, Rustのような雰囲気がでてくる。面白い。
まとめ
Scala 3の機能を活用し、DOPの概念とその実装方法について深く掘り下げた結果、いくつかの重要な洞察を得た。
DOPの原則には現代のプログラミングにとって有益な考え方が多く含まれている。また、Scala 3でのDOP実践においてはopaque type
などの新機能がかなり役に立った。もはや必須機能!これにより、型安全性を維持しながらも、より柔軟で表現力に富んだデータモデリングが実現可能となる。このアプローチはOOPの一部の制約を超越し、データとその操作の明確な分離を可能にする。OOP上に組み込むとクラスを使わないので、ある意味新しいプログラミングスタイルに見える。
しかし、今回紹介したコードスタイルはOOPの標準的なスタイルと異なるため、人によっては慣れるまでに時間がかかるかもしれない。さらにDOPがその言語の設計思想にあっているかよく考える必要がある。驚きが最小にならないことも…。Scalaの場合でも無理にこのようなスタイルを採用する必要性はないと思われる。もちろん、DOPを部分的導入する考え方もあるが、やはりClojureのようなDOPに向いた言語の使用も検討する価値があると感じられる…。
いずれにしても、Scala 3の強力な表現力と型安全性を上手く活用することで、DOPスタイルでもScalaが有用であることがわかった。
付録
DOP本のソースコードはこちら。本書掲載のコード以外にchallenges
以下に他の言語での実装例もある。異世界を探検したい人は覗いてみるといいかも。
- Order#adjustPriceがクソコード臭がしてなんともいえないが、別の機会にDOPのリファクタリングについて書く際の題材にする予定。あしからず…。↩