いものやま。

雑多な知識の寄せ集め

OOC2024の発表を見てみた。(その3)

前回の続き。

発注システムの例

この発表では代数的データ型の例として発注システムの例を挙げている。

簡単にいうと、注文にはいろいろ状態があって、状態ごとに持つべきプロパティやできることに違いがある、と。 そこで、それぞれの状態の直和型を注文型とすることで、各状態で持てるプロパティを限定させ、また処理では各型に対する網羅性を担保する、といった内容。

ただ、これはよくない設計で、前回の記事で書いた通り、データに対するロジックがあちこちに散らばって見通しがよくないし、修正するときも変更があちこちに及ぶことになる。 「コンパイルで型のチェックが入るのがいい」とか言ってるけど、それは直和型を使わなくても普通にオブジェクト指向で書いてやればできる話で。 むしろ、直和型にしてしまうことで本来なら呼べない処理までを呼べるようになってしまっていて、実行時エラーを起こす可能性を増やしてしまっている。

ポイントは2つあって、「そもそも不正なデータは作れないようにすること」、そして「不正な処理は型で呼べないようにすること」。 これらはオブジェクト指向の基本なんだけど、そういった基本ができてないとこういう設計になっちゃうんだろうなぁ・・・

代数的データ型での実装例

まず、上記の例を少し簡単にした要件で、代数的データ型での実装例を見てみる:

  • 注文の状態は「未確定」→「確定済み」→「発送開始済み」と進む
  • 「確定済み」で「発送開始済み」でない商品はキャンセル可能で、キャンセルした場合、状態は「キャンセル済み」となる
  • 「未確定」状態では「注文ID」「顧客ID」「届け先住所」「商品リスト」を持つ
  • 「確定済み」状態では、「未確定」状態に追加で「確定日時」を持つ
  • 「キャンセル済み」状態では、「確定済み」状態に追加で「キャンセル日時」「キャンセル理由」を持つ
  • 「発送開始済み」状態では、「確定済み」状態に追加で「発送ID」「発送開始日時」を持つ

これを発表だと次のような感じで実装している(ここではPythonで実装):

import datetime as dt
from dataclasses import dataclass

# 住所のAddressクラスと商品のItemクラスは別途定義

@dataclass(frozen=True)
class UnconfirmedOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]

@dataclass(frozen=True)
class ConfirmedOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]
    confirmed_at: dt.datetime

@dataclass(frozen=True)
class CancelledOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]
    confirmed_at: dt.datetime
    cancel_reason: str
    cancelled_at: dt.datetime

@dataclass(frozen=True)
class ShippingOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]
    confirmed_at: dt.datetime
    shipping_id: int
    shipping_at: dt.datetime

# 代数的データ型の形(※Pythonだと網羅性はチェックされないはず)
Order = Union[UnconfirmedOrder, ConfirmedOrder, CancelledOrder, ShippingOrder]

# 確定
def confirm(order: Order, now: dt.datetime) -> ConfirmedOrder:
    if not isinstance(order, UnconfirmedOrder):
        raise ValueError("Invalid state.")
    return ConfirmedOrder(
        order.order_id, order.customer_id, order.address, order.items, now
    )

# キャンセル
def cancel(order: Order, cancel_reason: dt.datetime, now: dt.datetime) -> CancelledOrder:
    if not isinstance(order, ConfirmedOrder):
        raise ValueError("Invalid state.")
    return CancelledOrder(
        order.order_id, order.customer_id, order.address, order.items, order.confirmed_at,
        cancel_reason, now,
    )

# 発送開始
def ship(order: Order, shipping_id: int, now: dt.datetime) -> ShippingOrder:
    if not isinstance(order, ConfirmedOrder):
        raise ValueError("Invalid state.")
    return ShippingOrder(
        order.order_id, order.customer_id, order.address, order.items, order.confirmed_at,
        shipping_id, now,
    )

ただ、これはヘンテコな実装で、そもそも引数としてOrder型を受け入れているのがおかしい。 この発表では全域性についても言及してるけど、たとえば本来cancel()という関数はConfirmedOrder型しか入力として受け付けてはいけないもので、それをOrder型を受け付けるようにしてしまっているので、全域性を自分から壊しにいってる。 こういうのをみると「ホントに理解して使ってるの?」ってなるよね・・・

さらにいえば、この例ではデータ構造がメソッドを持ってなくて、典型的なドメイン欠乏症なクラス。 値の不変性が確保されてるし、状態が型として分かれてるので、全部の状態を1つのクラスで扱うのに比べればまだマシともいえるけど。

オブジェクト指向での実装例

これをまともな形で実装すると、次のようになる:

import datetime as dt
from dataclasses import dataclass

# 住所のAddressクラスと商品のItemクラスは別途定義

@dataclass(frozen=True)
class UnconfirmedOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]

    def confirm(self, now: dt.datetime) -> "ConfirmedOrder":
        return ConfirmedOrder(
            self.order_id, self.customer_id, self.address, self.items, now
        )

@dataclass(frozen=True)
class ConfirmedOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]
    confirmed_at: dt.datetime

    def cancel(self, cancel_reason: str, now: dt.datetime) -> "CancelledOrder":
        return CancelledOrder(
            self.order_id, self.customer_id, self.address, self.items, self.confirmed_at,
            cancel_reason, now,
        )

    def ship(self, shipping_id: int, now: dt.datetime) -> "ShippingOrder":
        return ShippingOrder(
            self.order_id, self.customer_id, self.address, self.items, self.confirmed_at,
            shipping_id, now,
        )

@dataclass(frozen=True)
class CancelledOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]
    confirmed_at: dt.datetime
    cancel_reason: str
    cancelled_at: dt.datetime

@dataclass(frozen=True)
class ShippingOrder:
    order_id: int
    customer_id: int
    address: Address
    items: list[Item]
    confirmed_at: dt.datetime
    shipping_id: int
    shipping_at: dt.datetime

こうすると、たとえばUnconfirmedOrderクラスにはcancel()メソッドがないから、そもそも不正な呼び出しがなくなる。 全域性の確保っていうのはこういうことなんだよなぁ・・・

この実装の方が型チェックとしても優秀で、

# 代数的データ型の例だと、以下は型チェックではエラーにならない
# 実行時に例外が投げられてエラーになる
order: Order = UnconfirmedOrder(...)
cancel(order, ...)

# メソッドを生やした実装だと、型チェックでエラーになる
order = UnconfirmedOrder(...)
order.cancle(...)

のように、代数的データ型の方は実行時にならないとエラーがでないけど、オブジェクト指向の方なら型でエラーが分かる。

発表だとデータベースの都合で直和型の型がないと都合が悪いみたいなことも言ってたけど、そんなのはデータベースの都合に引っ張られているだけで、「ドメイン駆動」というのはドメイン知識を中心に考えないとなんよなぁ。 そのためにデータベースの都合から切り離すリポジトリパターンとかもあるわけだし。

発表のスライドをみると、order: Order = order_repository.find_by_id(order_id)みたいにしてるけど、これがデータベースの都合に引っ張られているところで、そんなどんな状態の注文が返ってくるか分からないメソッドを使うのが悪くて、confirmed_order: ConfirmedOrder = order_repository.find_confirmed_order_by_id(order_id)みたいなメソッドを用意しておくといいのよね。 ドメイン層に都合のいいインタフェースを用意しておいて、それを実現する実装を外部から注入するんよ。 それをデータベース側の都合でインタフェースを用意して、ドメイン層側で苦労しているようでは、ドメイン駆動とはちょっと呼べないかなぁ。

デザインパターン

上記の動画ではデータベースアクセスの部分を高階関数にするといいよと関数型の有用性を訴えてる。

ただ、高階関数を渡すというのはストラテジーパターンを使ってるのとほとんど同じで、わざわざ高階関数を使わなくてもというところがある (よくドメイン駆動設計ではリポジトリパターンと呼ばれてるけど、これはストラテジーパターンの特殊な形)。 場合によってはプロキシパターンを併用してデータアクセスを効率化したりというのも考えられるし。

なんとなく、デザインパターンに関する知識が乏しいと、関数型のテクニックを使って苦労しながら実装するというのになりがちなのかもしれない。

たとえば、上記の発注システムの例にしても、インタフェースは揃えてないので厳密にはステートパターンとは違うんだけど、状態遷移をオブジェクトとして表現するというステートパターンの知識があれば、各状態をクラスとして表現し、それぞれの状態でできる操作だけをメソッドとして生やして、その戻り値として次の状態のオブジェクトを返すというのは自然とできるはず。 けど、その知識がないから苦しい設計になってる感じ。

安易に関数型に走って苦労する前に、ちゃんとデザインパターンを学んで基礎を固めた方がいいと思う。

今日はここまで!