かとじゅんの技術日誌

技術の話をするところ

Scala 3でデータ指向プログラミングは可能か

Scala 3におけるデータ指向プログラミング(以下DOP)について深掘りする。久々にScalaの話題を取り上げるが、これはScala Advent Calendar 2023の14日目の内容でもある。

早速だけど、DOPの基本原則は意外とシンプルだ。

  1. コード(動作)をデータから切り離す
  2. データを汎用的なデータ構造で表現する
  3. データをイミュータブル(不変)として扱う
  4. データスキーマをデータ表現から切り離す

イミュータブルなデータは採用することは多いと思うが、これをそのまま実践している人はどのくらいいるだろうか。Scalaではクラス中心の関数型プログラミングが主流だと思うし、私もそうしている。

DOPの詳細は下記の本(以下DOP本)を参照してほしい。

ちなみに留意すべき点がある。DOP本とJavaのProject Amberにおけるデータ指向プログラミングは、同じ用語を使っているが方向性が異なる。DOP本では上記の原則に重点を置いている一方で、Project Amberではデータの解釈を型で表現しモデリングのしやすさに焦点を当てている。これは多義的な用語となっているため注意が必要である。この違いは、データ指向アプローチを実践する際の重要な考慮事項である。詳細は 川島さんのスライドを参照すると良い。今回の記事もDOP本のほうで書いているので注意。

さて、今回はScala 3でDOPを試みる。題材は「ショッピングカート」。中心的な集約はカート(Cart型)と注文(Order型)。

興味のある方は、OOPスタイルとDOPスタイルの両方で記述された以下のソースコードを参照してほしい。(ちなみにドメイン部分を中心にコードを書いている)

github.com

DOPを試すために、まずはご多分に漏れず「お金」オブジェクトを実装してみた。(Scala 3で実践する都合上、DOP本のまま推奨の方法で行っていない部分もありますが、そこはご容赦ください)

oop-dop-other/src/main/scala/example/j5ik2o/dop/domain/Money.scala at main · j5ik2o/oop-dop-other · GitHub

// 原則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では、データ構造とメソッドを分離する。つまり、関数やメソッドを独立させるということ。

データの定義

データ型としてのMoneyMap[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メソッドは使えない…
    }
    // ...
}

amountcurrencyもメソッドとして定義されているため、ここではあまり差分はない。

+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型になる。

oop-dop-other/src/main/scala/example/j5ik2o/dop/domain/Item.scala at main · j5ik2o/oop-dop-other · GitHub

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")

暗黙的型変換により、GenericItemItemに変換できる。本質的にはMapなので、単なるキャストに過ぎない。

given Conversion[GenericItem, Item] = _.asInstanceOf[Item]

抽象型に相当するItemのメソッドは、GenericItemに対しても呼び出せる。ただ、これは実装がやや面倒な側面がある。ちなみに、ItemのメソッドにGenericItemselfを渡しているが暗黙的型変換で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と同じスタイルで可能。

oop-dop-other/src/main/scala/example/j5ik2o/dop/domain/Cart.scala at main · j5ik2o/oop-dop-other · GitHub

CartIdopaque typeで文字列型。applyメソッドで検証を行う。

copyメソッドが使えないが、Map+copyメソッド相当のことができる。

Order型も問題なく実装できた。1

oop-dop-other/src/main/scala/example/j5ik2o/dop/domain/Order.scala at main · j5ik2o/oop-dop-other · GitHub

ざっと実装コードを眺めるとやはり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以下に他の言語での実装例もある。異世界を探検したい人は覗いてみるといいかも。

github.com


  1. Order#adjustPriceがクソコード臭がしてなんともいえないが、別の機会にDOPのリファクタリングについて書く際の題材にする予定。あしからず…。