いものやま。

雑多な知識の寄せ集め

数理最適化のための一歩進んだクラス設計。

数理最適化をプロダクトに組み込んでいく場合、クラスとして部品化しておくと、再利用性や保守性が高まったりする。

数理最適化のクラス設計に関しては、書籍『Pythonではじめる数理最適化』の著者である岩永さんが次のような記事を書かれている:

この記事はクラスってどう定義したらいいのか分からないって人には指針を与えてくれるのでとてもいいと思う。 Python使ってる人って自分でクラスを書いたりせずにコード書いてる人もけっこう多いので。

ただ、とりあえずの一歩としてはいいけど、コードをより堅牢にするという観点ではちょっと問題も多かったり。 このあたりはオブジェクト指向についての経験値も要求されるからね。

ということで、よりよいクラス設計について書いてみたい。

これは数理最適化 Advent Calendar 2024の5日目の記事です。

前述のクラス設計の問題点

まず、前述のクラス設計の問題点を具体的に示していきたい。

  • 引数の型、データ構造が分からない
  • 引数の妥当な値が分からない、不適切な値を渡せてしまう
  • オブジェクトの状態を気にしたメソッド呼び出しが必要
  • オブジェクトのデータを破壊できてしまう

引数の型、データ構造が分からない

クラスを使うときに見るのが、メソッドのシグネチャ(どんな引数を渡すか)。 コンストラクタのシグネチャを見ると、次のようになっている:

class ProdPlan:
    def __init__(self, P, M, m2s, p2g, pm2r):
        ...

ただ、これだけ見ても何のデータを渡せばいいのか、さっぱり分からない。 実装を追っていったり、呼び出し例を見てやる必要がある。 これはけっこう大変。

対策として、Pythonでは型ヒントをつけたりdocstringで説明したりができる:

class ProdPlan:
    def __init__(
        self,
        P: list[str], M: list[str],
        m2s: dict[str, int], p2g: dict[str, int],
        pm2r: dict[tuple[str, str], int],
    ) -> None:
        """
        Parameters
        ----------
        P : list[str]
            製品の一覧
        M : list[str]
            原料の一覧
        m2s : dict[str, int]
            在庫量(キーは原料)
        p2g : dict[str, int]
            利得(キーは製品)
        pm2r : dict[tuple[str, str], int]
            必要量(キーは製品と原料のペア)
        """
        ...

型ヒントやdocstringがあるだけで、使いやすさがだいぶ変わるのが分かるかと思う。 正直、書くのはかなり面倒だけど、プロダクトで使う場合は必須に近い。

引数の妥当な値が分からない、不適切な値を渡せてしまう

ただ、これでもまだ不十分で、というのもリストや辞書といったデータ構造では値に対する「縛り」がないため、引数として妥当な値が分からなかったり、それゆえ不適切な値も簡単に渡せてしまうから。

たとえば、次のようなコードを書けてしまったりする:

P = ["p1", "p2", "p3", "p3"]  # 同名の製品がある
M = ["m1", "p2", "m3"]  # m2をp2とタイポ(原料のキーと製品のキーで区別がつかなくなる)
m2s = {"m1": 35, "m2": -10}  # m2の在庫が負、m3の在庫が未定義
p2g = {"p1": 3, "p2": 4, "p3": 4, "p4": -5, "m1": 10}  # p4の利得が負、キーにm1も含まれる
pm2r = {
    ("p1", "m1"): 2, ("p1", "m2"): 0,  # P×Mの網羅ができてない
    ("hoge", "huga"): -2,  # 変なキーがある、必要量が負
}
prod_plan = ProdPlan(P, M, m2s, p2g, pm2r)

上記だと型チェックでエラーは検出されないし、実行時にもエラーは出てこない。 prod_plan.modeling()やprod_plan.solve()を呼び出したときに、運がよければエラーになるかも。 ただ、最悪エラーも出ずに、答えがおかしくなってることに気づかないまま使ってしまう可能性もある(エラーが出るのを嫌がる人も多いけど、問題があるのにエラーが出ないというのは、かなり有害;ガンがあるのに検査で引っかからないようなものなので)。

オブジェクトの状態を気にしたメソッド呼び出しが必要

このクラスはオブジェクトの状態を気にしてメソッド呼び出しをしないといけないというのもけっこう難点。

たとえば、このオブジェクトを何かしらの関数で引数として受け取ったとする。 最適化の結果を知りたいとして、どうしたらいいだろうか?

# 最適化の結果の表示
def print_result(prod_plan: ProdPlan) -> None:
    # prod_planに対して、何をすればいい?
    ...

答えを知りたいんだからprod_plan.xを見ればいいんでしょ?って思うかもしれないけど、必ずしもそうとは限らない。 だって、modeling()を呼び出してないとxはNoneだし、solve()を呼んでないとxは最適解になってないから。 使う側が状態を気にしながら必要なメソッドを呼び出してやる必要がある。

# 最適化の結果の表示
def print_result(prod_plan: ProdPlan) -> None:
    if prod_plan.x is None:
        prod_plan.modeling()
    if prod_plan.status is None:
        prod_plan.solve()
    print({p: prod_plan.x[p].value() for p in prod_plan.P})

一応、modeling()とsolve()を常に呼び出すようにすれば、状態の確認は不要になるけど、すでに解いてた場合は無駄に計算してることになるし、「この引数では解いたあとの状態のオブジェクトを常に渡すよ」と取り決めておけばこういったチェックは不要になるけど、その約束を知ってか知らずか破ってしまってバグを引き起こすというのもよくある話。 あるメソッドを呼び出し済みかどうかなんて、そこに至るパスを全部チェックしてやらないと、抜け漏れは普通に発生するし。

オブジェクトのデータを破壊できてしまう

このクラス設計はデータ構造が丸見えなので、オブジェクトのデータを簡単に破壊できてしまう問題もある。

たとえばこんな感じ:

# 以下のようにしたらエラーが出なくなったのでヨシ!
prod_plan.x = pulp.LpVariable.dicts("x", prod_plan.P)
prod_plan.status = pulp.LpStatusOptimal

print_result(prod_plan)

そんなアホなことするかいなと思うかもしれないけど、やったとしてもエラーは出てこないので、やってないことを確認するにはソースコードを全部チェックしないと分からないんよね。

問題への対応策

上記のような問題を防ぐには、以下のようなことをやってあげるといい:

  • 型ヒントやdocstringを書く(言及済み)
  • 異なるものには異なる型のクラスを用意する
  • 不適切なデータが存在できないようにする
  • できることだけをメソッドにする

異なるものには異なる型のクラスを用意する

製品や原料は異なるものなので、これらが混ざってしまうのはよくない。 けど、単に文字列で識別を行なっているのだと、型としては同じなので区別がつかず、混ざってしまう可能性がある。

こういうときには異なる型を用意しておくとよくて、そうすると混ざってしまうのを型チェックで防げるようになる:

from dataclasses import dataclass

@dataclass(frozen=True)
class Product:
    """製品"""
    name: str

@dataclass(frozen=True)
class Material:
    """原料"""
    name: str

products: list[Product] = [
    Product("p1"), Product("p2"), Product("p3")
]

materials: list[Material] = [
    Product("m1"), Material("m2")  # これは型チェックするとエラーが検出される
]

また、こうしておくと、辞書でキーになる要素がなんなのかも分かりやすくなったり:

# 元々は以下だった;
# キーと値が何を意味してるか、よく分からない
m2s = {"m1": 35, "m2": 22, "m3": 27}

# 以下のようにすると分かりやすい
stocks: dict[Material, int] = {
    Material("m1"): 35,
    Material("m2"): 22,
    Material("m3"): 27,
}

不適切なデータが存在できないようにする

扱ってるデータには、いくつか制約があったりする:

  • 製品の集合に同名の製品が含まれるのはNG
  • 原料の集合に同名の原料が含まれるのはNG
  • 原料の在庫は各原料に対して値があり、0以上の値を取る
  • 製品の利得は各製品に対して値があり、0以上の値を取る
  • 製品に対する原料の必要量は、各製品と各原料のペアに対して値があり、0以上の値をとる

こういったのはドメイン知識と呼ばれるもので、これらを洗い出し、コードとして表現しておけると、あとになって「えっ、そうだったの?」というのを防げたり。 そして、こういった制約を破る不適切なデータが存在できないようにしておくと、不適切なデータが紛れて気づかないうちに問題が発生していたというのを防げるようになる。

具体的には扱うデータや概念をクラスとして表現して、不適切なオブジェクトが作られないようにするといい(不適切になる場合は例外を投げる)。

たとえば、製品の一覧の実装例は以下:

class ProductList:
    """製品の一覧"""

    def __init__(self, items: list[Product]) -> None:
        """
        Parameters
        ----------
        items : list[Product]
            製品の一覧

        Raises
        ------
        ValueError
            重複した製品があった場合
        """
        # 重複チェック
        n_items = len(items)
        n_unique_items = len(set(items))
        if n_items != n_unique_items:
            raise ValueError("製品が重複してます")

        self.__items = list(items)

    @property
    def items(self) -> list[Product]:
        return list(self.__items)

    def __eq__(self, obj: object) -> bool:
        return (
            isinstance(obj, ProductList)
            and (set(self.__items) == set(obj.items))
        )

ポイントはコンストラクタで重複チェックをしてること。 こうやってデータを作るときに制約が守られているかのチェックを入れておくと、制約が守られてない不適切なデータはプログラム内に存在できなくなる:

# 次のコードは型チェックで弾かれる
product_list = ProductList([Product("p1"), Material("m1")])

# 次のコードは実行時にValueErrorが出る
product_list = ProductList([Product("p1"), Product("p2"), Product("p1")])

ちなみに、次の項目とも関連するけど、このProuctListに含まれる製品の一覧は、参照はできても変更はできないようになっている:

product_list = ProductList([...])

# 参照はできる
for product in product_list.items:
    print(product)

# 代入はできない
product_list.items = [Product("p3"), Product("p4")]  # エラー

これはオブジェクトの持つデータをプライベートにして外からはアクセスできないようにし、プロパティとして参照だけできるようにしてるから。 こうすることで、適切だったデータが途中で不適切なデータに変わってしまうのを防いでいる。

製品の利得の実装例も示しておくと、以下:

class Gain:
    """製品の利得"""

    def __init__(self, gains: dict[Product, int]) -> None:
        """
        Parameters
        ----------
        gains : dict[Product, int]
            製品の利得
            値は0以上であること

        Raises
        ------
        ValueError
            値が0未満の場合
        """
        for value in gains.values():
            if value < 0:
                raise ValueError("値が0未満です")

        self.__gains = dict(gains)

    @property
    def product_list(self) -> ProductList:
        return ProductList(list(self.__gains.keys()))

    def for_product(self, product: Product) -> int:
        """
        指定された製品に対する利得を返す

        Parameters
        ----------
        product : Product
            対象の製品

        Returns
        -------
        int
            指定された製品に対する利得

        Raises
        ------
        ValueError
            指定された製品に対する値がない場合
        """
        if product not in self.__gains:
            raise ValueError(f"{product}に対する値がありません")
        return self.__gains[product]

できることだけをメソッドにする

上記でデータを参照はできても変更はできないようにしたのと同様で、できることだけをメソッドとして公開するといい。

たとえば、元の設計だとProdPlanができることは次のようになっている:

  • 各属性の参照、変更
  • モデリングして内部状態を更新する
  • モデリングした問題を解いて内部状態を更新する

けど、これは公開しすぎで、前述のように簡単にデータを壊せてしまうし、使う側も状態を意識する必要があって使いにくい。

実際には次のことができれば十分:

  • 各属性の参照
  • 問題を解いて解を得る

気をつけたいのは、「モデリングする」「解を求める」「解の値を得る」というのを個別のメソッドにしないこと。 なぜなら、それぞれだけやるということは普通あり得ないから。 下手に分けてしまうと、内部の状態に気をつけながら必要なメソッドを順に叩いていく必要が出てしまう。 内部的に処理を分けて書きたい場合は、プライベートなメソッドとして書くといい。

具体的には、次のような感じになる:

from typing import Optional

class ProductionPlan:
    """生産計画"""
    def __init__(self, plan: dict[Product, float]) -> None:
        # 制約とか同様に表現する
        ...
    # 省略

class ProductionProblem:
    """生産問題"""

    def __init__(
        self,
        product_list: ProductList,
        material_list: MaterialList,
        stock: Stock,
        gain: Gain,
        require: Require,
    ) -> None:
        # docstring省略

        if stock.material_list != material_list:
            raise ValueError("在庫の情報が不正です")
        if gain.product_list != product_list:
            raise ValueError("利得の情報が不正です")
        if require.product_list != product_list:
            raise ValueError("必要量の情報が不正です")
        if require.material_list != material_list:
            raise ValueError("必要量の情報が不正です")

        self.__product_list = product_list
        self.__material_list = material_list
        self.__stock = stock
        self.__gain = gain
        self.__require = require

    @property
    def product_list(self) -> ProductList:
        return self.__product_list

    # 他のプロパティも同様;省略

    def solve(self) -> tuple[int, Optional[ProductionPlan]]:
        """
        生産問題を解いてステータスと解を返す

        Returns
        -------
        tuple[int, Optional[ProductionPlan]]
            解を解いたステータスと解のペア
            最適解が得られなかった場合、解はNoneを返す
        """
        x, model = self.__modeling()
        status = self.__solve(model)

        if status != pulp.LpStatusOptimal:
            return status, None

        plan = ProductionPlan({
            product: x[product].value() for product in self.__product_list.items
        })
        return status, plan

    def __modeling(self) -> tuple[dict[Product, pulp.LpVariable], pulp.LpProblem]
        # 変数
        x = pulp.LpVariable.dicts(
            "x", self.__product_list.items, cat="Continuous", lowBound=0
        )

        # 数理モデル作成
        model = pulp.LpProblem("ProductionProblem", pulp.LpMaximize)

        # 制約式
        for material in self.__material_list.items:
            required = pulp.lpSum(
                self.__require.for_pair(product, material) for product in self.__product_list.items
            )
            model += required <= self.__stock.for_material(material)

        # 目的関数
        total_gain = pulp.lpSum(
            self.__gain.for_product(product) * x[product] for product in self.__product_list.items
        )
        model += total_gain

        return x, model

    def __solve(self, model: pulp.LpProblem) -> int:
        return model.solve()

こうしておくと状態を気にする必要もなくなるのでかなり使いやすくなる。


かなり長くなったけど、プロダクトレベルで使える安全で堅牢なコードを書こうとすると、元のコードからかなり手を入れる必要がある。 正直かなりメンドイし、さらには単体テストも書く必要があって、実際にはもう少し手を抜いたりもするけど。

ただ、こうしておくと型チェックで問題に気付けたり、異常なデータが紛れ込むのを防げたり、オブジェクトの状態を気にせずに使えたりと、メリットも大きい。 型の情報でコードの読みやすさも上がってるし。

プロダクトを作っていくエンジニアは、数理最適化の知識だけでなく、こういったソフトウェア工学の知識も身につけておきたいよね。

今日はここまで!

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

前回、前々回は関数型に関する話だったけど、今回はその他で感じたことに関して。

データベースの重力

オブジェクト指向の理解を妨げる原因の一つとして、データベースに引っ張られがちというのがありそう。

この発表ではそのことを指摘してた:

前回の記事でも、データベースの都合に引っ張られて変な設計になってると書いたけど、これはよくあることなんだと思う。 そのせいで適切なクラスを用意してメソッドを用意するということができなくて、ただのデータクラスを関数でゴリゴリいじるというよくない設計になる感じ。

これは個人的にはなるほどというか、そんなこともあるんだなと思ったり。 というのも、自分はデータベースを使うような開発というのはほとんどやってきてなくて、RubyやPythonでCLIのツール書いたり、Cで組込み開発したり、JavaやC++でIDEの開発をやってきたりしたから。 もちろんデータの保存や読み込みをしたりはするけど、その場合もプログラムで扱うデータ構造がまずあって、必要に応じてそれをファイルに書き出したり読み込んだりするので、その入出力の形式がプログラムでのデータ構造に引っ張られることはあっても、その逆というのは普通ありえない。

ただ、今はWebアプリの開発をしてる人が多くて、そうなるとデータベースを使わない開発は少なく、まずデータベースでのデータ構造があって、それをどうこうするのがプログラムの役割になりがちなんだろうね。 そうすると、本来はプログラム内でのデータ構造がまずあるべきなのに、データベースの都合に引っ張られて歪な設計になってしまうと。

オブジェクト指向を理解したり、ドメイン駆動でビジネスロジックをコードで表現できるようにするには、この「データベースの重力」から逃れる必要がありそう。 データベースのデータ構造に引っ張られてる状態では、プログラム内で適切なクラスを定義できるようにならないんだろうね。

このデータベースに引っ張られているというのは他の発表でも感じたり。

たとえば、きしださんの「オブジェクト指向は必要なのか」もそう:

結局、ワンショットでデータベースからデータ読んでモニョモニョしてデータベースに返すみたいなアプリしか作らないから、そりゃオブジェクト指向いらないんじゃね?みたいにもなるよなぁと。 データベースでのデータ構造がまずあって、プログラムであるべきデータ構造とか考えることもないんだろうし。

これに関しては久保秋さんの「せっかくモデル図描くのなら、嬉しいことが多い方がいいよね!」でも、フロントエンド開発がホットになって、モデルを描くことが減ったという話が出てる:

まぁ、それで済む仕事だけしてるならそれでもいいけど、もっとプログラマとして高度な仕事をしていきたいなら、データベースを使わない開発も経験して、オブジェクト指向やドメイン駆動設計について理解を深めた方がいいんじゃないかと思う。

フロー指向とオブジェクト指向

他に感じたこととして、やっぱりフロー指向で考えてるかオブジェクト指向で考えてるかの違いは大きいんだろうなということ。

たとえばこの発表:

タスク指向UIとオブジェクト指向UIという形で対比してるけど、これはフロー指向とオブジェクト指向の対比とも言える。

あるいはこの発表:

開発で苦しんでる様子を伝えてるけど、やっぱり処理の流れをどうコントロールするかという観点で設計してる感じがあって、それだとまぁ苦しいよねとなる。

一方でこの発表:

この発表では処理をちゃんとデータとして扱ってて、適切なインタフェースを切ることで部品化をうまくやってる。

そんな感じで、データベースの重力から抜け出すこと、フロー指向から抜け出すこと、そしてデザインパターンを学んで活用していくことがやっぱり重要よね。

今日はここまで!

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)みたいなメソッドを用意しておくといいのよね。 ドメイン層に都合のいいインタフェースを用意しておいて、それを実現する実装を外部から注入するんよ。 それをデータベース側の都合でインタフェースを用意して、ドメイン層側で苦労しているようでは、ドメイン駆動とはちょっと呼べないかなぁ。

デザインパターン

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

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

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

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

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

今日はここまで!

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

気づいたら4ヶ月も経ってたけど、OOC2024の動画を見て思ったことの続き。

関数型DDD

今回の発表で関数型に関した発表がいくつかあった:

関数型の考え方自体は局所的に使えることもあるので知っておいて損はないと思うけど、オブジェクト指向で知られたテクニックを使えばもっとよくなるところを、関数型の考え方に縛られてしまってツラい設計になってるんじゃないかというのが思うところ。 そのあたりを少し話していきたい。

代数的データ型

関数型の話でいつも出てくるのが代数的データ型。

これ、関数型特有の言語仕様と思われやすいけど、ぶっちゃけた話、直積型はC言語でいう構造体、直和型はC言語でいう共用体なので、命令型でも昔からあるデータ構造だったりする。 さらにいうと、共用体は別用途のデータ構造変換ではいまだに使われることがあるけど、インタフェースに対してプログラミングするテクニック(これは関数ポインタを使うので、「関数をオブジェクトとして扱う」という意味で関数型のアプローチに近い)が使われるようになって、ほとんど使われなくなったという歴史もあったり。 抽象構文木のように、木構造の各ノードの型の違いにまで気を付ける必要があるケースを除いて、代数的データ構造が使えないと困るというケースはほとんどない。 ただ、このインタフェースに対してプログラミングするというのを知らないと、かなり濫用されているイメージ。

たとえば、図形の面積を求めるプログラムを考えてみる。

代数的データ型を使ったアプローチをC言語やPythonで書くと、次のような感じになる:

#include <stdio.h>
#include <math.h>

// 円
typedef struct {
    float radius;
} circle_t;

// 長方形
typedef struct {
    float width;
    float height;
} rectangle_t;

// 図形の種類
typedef enum {
    CIRCLE,
    RECTANGLE,
} shape_kind_t;

// 直和型で複数の型を内包する型を表現する
typedef struct {
    shape_kind_t kind;  // 型の情報を持つ
    union {  // 共用体は複数のデータ構造を保持できる(同じメモリ領域が使われる)
        circle_t circle;
        rectangle_t rectangle;
    } data;
} shape_t;

// 面積の計算
float calc_area(shape_t* shape) {
    float area = 0.0;
    float radius = 0.0;
    float width = 0.0;
    float height = 0.0;

    // データ構造に応じて場合分け(直和型のアプローチ)
    switch (shape->kind) {
    case CIRCLE:
        radius = shape->data.circle.radius;
        area = radius * radius * M_PI;
        break;
    case RECTANGLE:
        width = shape->data.rectangle.width;
        height = shape->data.rectangle.height;
        area = width * height;
        break;
    }
    return area;
}

int main(void) {
    // 円の生成
    shape_t circle = {
        .kind = CIRCLE,
        .data = {.circle = {.radius = 10.0}},
    };

    // 長方形の生成
    shape_t rectangle = {
        .kind = RECTANGLE,
        .data = {.rectangle = {.width = 10.0, .height = 12.0}},
    };

    // 円の面積
    printf("circle: %f\n", calc_area(&circle));

    // 長方形の面積
    printf("rectangle: %f\n", calc_area(&rectangle));

    return 0;
}
from dataclasses import dataclass
from typing import Union
import math

# 円
@dataclass(frozen=True)
class Circle:
    radius: float

# 長方形
@dataclass(frozen=True)
class Rectangle:
    width: float
    height: float

# 図形
Shape = Union[Circle, Rectangle]

# 面積の計算
def calc_area(shape: Shape) -> float:
    # データ構造に応じて場合分け(直和型のアプローチ)
    if isinstance(shape, Circle):
        return shape.radius * shape.radius * math.pi
    if isinstance(shape, Rectangle):
        return shape.width * shape.height
    raise ValueError("type is invalid.")

# 円の生成
circle = Circle(10.0)

# 長方形の生成
rectangle = Rectangle(10.0, 12.0)

# 円の面積
print(f"circle: {calc_area(circle)}")

# 長方形の面積
print(f"rectangle: {calc_area(rectangle)}")

データ構造を複数用意して、そのORを受け付ける関数を用意し、型に応じて処理を切り分けるとなっている。 言語の違いで書き方に多少の差はあっても、慣れ親しんだ書き方という人は多いと思う。

ただ、この書き方は、扱うデータ構造が増減したときに、switch文を使っているところを全部書き直す必要があるという問題がある。 つまり、データ構造を扱うロジックがあちこちに散らばっているという問題がある。 これは変更が大変だし、変更の影響を調べるのも大変。

発表では、型でエラーが検出されるからいいと言っていたけど、正直いろんなファイルに渡って修正をしないといけないのはかなりツラい。

そこで、インタフェースに対してプログラミングするテクニックが生まれた。 これは関数をオブジェクトとして扱うことになるので、実はこの方が関数型的な書き方とも言える(C言語の方を見ると分かりやすいかも)。

インタフェースを使ったコードをC言語とPythonで書くと、次のようになる:

#include <stdio.h>
#include <math.h>

// 面積を計算する関数(のポインタ)を型として表現
typedef float (*calc_area_t)(void* data);

// 図形は面積を計算できる
typedef struct {
    calc_area_t calc_area;  // 関数をデータとして扱う
} shape_t;

// 面積の計算
float calc_area(shape_t* shape) {
    return shape->calc_area(shape);
}

// 円に関する定義 ----------

// データ構造
typedef struct {
    shape_t base;  // 図形のインタフェースを継承
    float radius;
} circle_t;

// 面積の計算
float circle_calc_area(void* data) {
    circle_t* circle = (circle_t*)data;
    float radius = circle->radius;
    return radius * radius * M_PI;
}

// データの初期化
void circle_init(circle_t* circle, float radius) {
    circle->base.calc_area = circle_calc_area;  // 使う関数をデータとして渡しておく
    circle->radius = radius;
}

// 長方形に関する定義 ----------

// データ構造
typedef struct {
    shape_t base;  // 図形のインタフェースを継承
    float width;
    float height;
} rectangle_t;

// 面積の計算
float rectangle_calc_area(void* data) {
    rectangle_t* rectangle = (rectangle_t*)data;
    float width = rectangle->width;
    float height = rectangle->height;
    return width * height;
}

// データの初期化
void rectangle_init(rectangle_t* rectangle, float width, float height) {
    rectangle->base.calc_area = rectangle_calc_area;  // 使う関数をデータとして渡しておく
    rectangle->width = width;
    rectangle->height = height;
}

int main(void) {
    // 円の生成
    circle_t circle;
    circle_init(&circle, 10.0);

    // 長方形の生成
    rectangle_t rectangle;
    rectangle_init(&rectangle, 10.0, 12.0);

    // 円の面積
    printf("circle: %f\n", calc_area(&circle.base));

    // 長方形の面積
    printf("rectangle: %f\n", calc_area(&rectangle.base));

    return 0;
}
from dataclasses import dataclass
from typing import Protocol
import math

# 図形は面積を計算できることをインタフェースで表現
class Shape(Protocol):
    def calc_area(self) -> float:
        ...

# 円
@dataclass(frozen=True)
class Circle(Shape):
    radius: float

    def calc_area(self) -> float:
        return self.radius * self.radius * math.pi

# 長方形
@dataclass(frozen=True)
class Rectangle(Shape):
    width: float
    height: float

    def calc_area(self) -> float:
        return self.width * self.height

# 円の生成
circle = Circle(10.0)

# 長方形の生成
rectangle = Rectangle(10.0, 12.0)

# 円の面積
print(f"circle: {circle.calc_area()}")

# 長方形の面積
print(f"rectangle: {rectangle.calc_area()}")

こう書くと、データ構造ごとにswitchする構造がなくなっているのが分かるかと思う。 また、各データ構造に対する処理がまとまっているのも分かるかと思う。 このおかげでロジックの確認をするときにそのデータ構造を使っている場所を全部探して確認するという手間が省ける。 また、データ構造が増減したとしても、そのデータ構造に関する部分だけ直せばいいので、修正の範囲が限定されて扱いやすくなる。

興味深いのは、上記の仕組みを実現するために関数をデータとして扱っているということ。 これは関数型でいう高階関数を使っているのに相当する。 さらには関数で使うデータをメンバとして保持しているわけだけど、これは関数の部分適用を行った状態に相当する。

だから、オブジェクト指向のオブジェクトを関数型的に捉えると、部分適用を行なった高階関数の集まりとも言えて、そういう意味で関数型を進めた先にあるのも実はオブジェクト指向に近いものになる。 Haskellの型クラスとかまさにそうだし。

そして、それを愚直に実装したのがC言語のコードだけど、これを自力で実装できるようになるのは大変で、それを書きやすくしてるのがクラスの機能だったりする。 Pythonのコードを見るとC言語のコードに比べてすごくスッキリしているのが分かるかと思う。 これで内部ではC言語と同じように関数をデータとして扱ってたりするんだけど、表面ではそれを意識せずに使うことができる。

その辺りを理解せずに「いまどきオブジェクト指向はダメ、時代は関数型」とか言いながら代数的データ構造でちまちまswitchをやってるようだと、オブジェクト指向としても関数型としても理解が浅いところがある。 そして、関数型でもそれなりに苦労して書かないといけないところを、オブジェクト指向なら比較的簡単に書けるので、わざわざ関数型で書かなくてもなぁとなってくるんよねぇ。

結局のところ、具体的なデータ構造を見てプログラミングしているのだと上記のようになってしまうので、『オブジェクト・ウォーズ』に書いたように、「データを軽視し、インタフェースを導入せよ」という、インタフェースを重視するステップに進む必要がある。

まだ書きたいことはあるんだけど、長くなったので一旦区切り。

今日はここまで!

Python製のタスクランナーkumadeをバージョンアップした。

Pythonでいい感じに使えるタスクランナーがなかったので自作したのがkumade。

今回、いくつかの機能を追加実装してバージョンアップした。

追加実装した機能

kumadeはデータ分析、アルゴリズム開発の実務でも使ってたりするんだけど、実際に使ってるといくつかほしい機能が出てきた。 今回実装したのはそんな機能。

  • Pythonモジュールのインポート容易化(0.2.0以降)
  • 設定値の実行時指定(0.2.0以降)
  • タスクの並列実行(0.3.0以降)

Pythonモジュールのインポート容易化

タスクはKumadefileに書いていくけど、ベタに全部書くとメンテナスが大変になる。 そこでコアな機能は別のファイルに書いておいて、インポートして使うことが考えられる。

ただ、そのときにちょっと厄介なのがパスの解決。 カレントディレクトリに依存せずにインポートできるようにするには、パスを追加しておく必要があった:

# Kumadefile.py

import sys
from pathlib import Path

# Kumadefile.pyのあるディレクトリをパスに追加
project_dir = Path(__file__).parent
sys.path.append(str(project_dir))

from some_module import some_function

...

このパスの追加は一手間だし、リンタとしてFlake8を使っているとE402という指摘を受けてしまう。

そこで、Kumadefileのあるディレクトリが自動的にパスに追加されるように変更した。 これによりインポートが簡単にできるようになっている:

# Kumadefile.py

# 0.2.0以降はパスの追加なしでインポート可能
from some_module import some_function

...

これでE402の指摘も出なくなっている。

設定値の実行時指定

タスクの挙動を実行時に微調整したいということがある。

簡単な例だと、単体テストを実行するときに冗長出力させるかどうかとか:

# 冗長出力しない場合は単にunittestを実行
$ python -m unittest
...........................................................................
----------------------------------------------------------------------
Ran 75 tests in 0.639s

OK

# 冗長出力したい場合は-vオプションをつけて実行
$ python -m unittest -v
test_create (tests.concurrent.test_concurrent_task_runner.TestConcurrentTaskRunner.test_create) ... ok
...(省略)...
test_set_default (tests.test_utility.TestUtility.test_set_default) ... ok

----------------------------------------------------------------------
Ran 75 tests in 0.659s

OK

一つの方法としては、それぞれタスクを用意するというのが考えられる:

@ku.task("test")
@ku.help("Run unittest.")
def test() -> None:
    subprocess.run(["python", "-m", "unittest"])

@ku.task("verbose_test")
@ku.help("Run unittest with verbose output.")
def verbose_test() -> None:
    subprocess.run(["python", "-m", "unittest", "-v"])

# 単にテストしたい場合は
# $ kumade test
# 冗長出力ありでテストしたい場合は
# $ kumade verbose_test
# を実行する

ただ、条件がいろいろあったりすると定義するタスクの数は組み合わせ的に増えていく可能性があるし、他のタスクから依存されて実行される場合には実行する内容を切り替えたりできない問題がある。

そこで、設定項目を定義しておいて、実行時に指定できるようにした。 この例だと以下のように書くことができるようになった:

# boolの設定項目を追加(デフォルト値はFalse)
ku.add_bool_config(
    "test_verbose",
    "Run unit test with verbose output if true.",
)

@ku.task("test")
@ku.help("Run unittest.")
def test() -> None:
    # 設定を取得し、値を参照して処理を行う
    config = ku.get_config()
    if config.test_verbose:
        subprocess.run(["python", "-m", "unittest", "-v"])
    else:
        subprocess.run(["python", "-m", "unittest"])

# 単にテストしたい場合は
# $ kumade test
# 冗長出力ありでテストしたい場合は
# $ kumade test_verbose=true test
# を実行する

感じとしてはMakefileで環境変数を指定して実行するのに似ている。 ただ、kumadeでは設定項目の型を指定できるようにしたので、参照時に変換を自前で行うのが不要になっていたり、タスク一覧の表示で設定項目の一覧も見れたりと、利便性が高まってる:

# -tオプションで設定項目とタスクの一覧を表示
$ kumade -t
Configuration items:
  test_verbose  # Run unit test with verbose output if true. (default: False)
  ...
Tasks:
  test                   # Run unittest.
  ...

タスクの並列実行

データ分析だと、インプットとなるファイルのダウンロード、機械学習での処理、処理結果の可視化などのタスクを依存関係で繋いでいく感じになるんだけど、処理する対象が多いと並列実行したいという要望が出てくる。 というか、実際に出てきた。

そこで、MakeやRakeと同様に-jオプションでタスクを並列実行できるようにした:

# 並列実行する場合は-jオプションで並列数を指定
$ kumade -j 3 some_task

並列実行するときに標準出力を使って場合、適切に排他処理をしないと出力が入り混じってしまうというのがあるけど、kumadeではワーカーごとの出力が分かるようにしている:

# カウントダウンするタスクの実行例
$ kumade -j 3 countdown
[Worker0] 5...
[Worker2] 4...
[Worker1] 3...
[Worker0] 2...
[Worker2] 1...
[Worker1] 0!

なので、単一プロセスで実行する場合も並列処理する場合も基本的にはKumadefileを書き換える必要がないようにしている。 もちろん、標準出力以外へのアクセスは排他されないので、必要に応じて対策が必要だけど。


ソースコードはGitHubで見れるので、興味ある人はぜひ。

今日はここまで!

未踏ジュニア2024年度成果報告会に行ってみた。

この時期毎年恒例になってきた未踏ジュニアの成果報告会。

今年も11/4(祝)に開催されていたので、行ってきてみた。

成果報告会@東京大学

ちなみに採択されたプロジェクト一覧は以下で、発表の様子は動画で見ることもできる。

生成AIの使い方の変化

去年の感想で、生成AIの躍進がすごかったと書いた。

これは今年もそうで、多くのプロジェクトで生成AIを含めAI技術が使われている感じだった。

ただ、その使い方に関して、去年とはけっこう違う印象を受けた。

去年は生成AIを使うことで何かしらのアウトプットを直接得るようなプロジェクトが多かったと思う。 動画や四コマを作らせてみたり、情報をまとめたコンテンツを作らせてみたり。 この場合、アウトプットを生み出す主体はAIであり、人はむしろそれをサポートする側で、AIのアウトプットのための環境を整えることが開発といった感じがあった。 主客逆転というか。

それが今年は、アウトプットや活動の主体はあくまで人で、AIはそのサポートにすぎないという使われ方に変わっていたように感じた。

たとえばこのプロジェクト:

LLMで英単語学習の穴埋め問題を作るアプリを作ったというものなんだけど、もっと直接的に考えれば、LLMでそのまま翻訳してくれれば、そもそも英単語なんて覚えなくていいとなりそう(個人的にはそうなってくれたら楽なんだけど・・・)。 でも、そうではなく、学習するのはあくまで人で、AIはそのサポート、練習役となって、問題を出してくれるというところに留まっている。

次の4つのプロジェクトも同じようなところがあった:

それぞれ、発音を学び練習するためのアプリ、音声を使った英語学習のアプリ、作文の書き方をサポートしてくれるアプリ、独学でのプログラミング学習をサポートしてくれるアプリという感じなんだけど、いずれもAIは学びをサポートするためのツールとして使われているだけで、実際に手を動かして学習するのは人となっている。

そりゃ、AIに文章を読ませることもできるし(実際Text-to-Speechとか性能上がってる)、英語だってAIがやってくれれば楽、作文もAIが書いてくれれば立派な文章になって、プログラミングだってAIに任せてしまえば学ばなくたっていいのかもしれない。 けど、学習はまぁたしかに大変かもしれないけど、やっぱりできることが増えることの喜びとか、手を動かして何かを作り出す面白さというのはあって、なんでもかんでもAIに任せればいいってもんじゃないとも思う(まぁ現実的に任せきれないというのもあるけど)。

「作文おたすけアプリ」を作った高橋さんの発表から引用:

AIを使っていることで大人の人達からは「上手な作文を書いてくれるアプリがいいのでは?」と何度か言われてしまいました。

大人になると私もそんな風に思うかもしれませんが、子どもの私は「自分の力で作文を上手に書けるようになりたい!」「アイデアがわいて、上手に書けるとうれしい」という気持ちの方が大事でした。

これはとても素敵な姿勢だと思う。

理学寄りのプロジェクト

個人的には理学寄りのプロジェクトも興味深かった。

NP困難な最適化問題を解くために、量子アニーリングマシンが研究されてたりするけど、制約を目的関数にペナルティとして与えるのではなく、(おそらく)実行可能な近傍解をうまく作り出すことで、効率的に解を探索できるような量子アニーリングマシンのシミュレータを作ったという感じっぽい。

このあたりは量子コンピュータを使わない普通のヒューリスティクスのアプローチとかもあるので(量子コンピュータでなければうまい近傍解を作ろうとするのは自然で多くの研究があるはず)、実際問題どれくらいいいのかはなんとも言えないんだけど、こういうのに取り組んでること自体がすごいので今後に期待。

生物学に特化した形のシミュレーション開発アプリという感じで、生物に関する現象をグラフィカルなインタフェースで数理モデル(微分方程式)として表現し、それを解くことでシミュレーションを実施、研究を進めていける感じっぽい。 ぜんぜん知らない分野なので、そういった研究方法もあるんだというのが興味深かった。

まぁ、数理最適化を実務で使おうとしたとき、どうやって最適化問題に落とし込めばいいのか分からないというのをよく聞くので、同様に、生物の現象をどうやって数理モデルに落とし込めばいいのか分からないという声は出てきそうな気はした。 たぶん物理とかで現象を微分方程式として表現する経験とかあれば、似た感覚でできるんだろうけど。 そのあたりをサポートするコンテンツも今後用意されるといいのかなぁ。

今日はここまで!

技術書典17オフラインに一般参加してきた。

先週末の11/3(日)に池袋で技術書典17のオフライン開催があった。

今回はサークル「いもあらい。」としてはオンライン参加のみ申し込んだので、オフライン会場にはひさびさに一般参加で行ってきた。

オフライン会場へ

サークル参加だと朝早くに起きて会場に行かないとだけど、一般参加だとお気楽で、ゆっくり起きて会場に向かった。 13時入場のチケットをゲットしていたけど、13時ちょうどくらいだと混むのは分かってたので、少し時間を置いて13時15分頃に会場へ。

技術書典17オフライン

予想通り、まったく並ぶことなくサクッと入れた。 不織布のバッグをもらって会場を回っていく。

会場はここ最近と同じ傾向で、いい感じに人が入ってた。 サークル参加だと時間を見つけてサクッと回るくらいしかできなかったけど、一般参加だとゆっくりと見て回れていい感じ。 オフラインだとやっぱり一覧性がいいよね。 そしてついつい紙の本を買ってしまうw

戦利品

オフライン会場で一回やってみたかったのが、戦利品棚に買った本を並べて記念撮影するというもの。

行ってみたら行列で4, 5人並んでたかな? いやー、人気コンテンツだw

しばらく待って自分の番になり、いそいそと買った本を並べてパシャリ。

戦利品棚で記念撮影

後ろに並んでる人もいたのでけっこう焦ったり、立てかけた本が滑ってパタンと倒れるのが何度かあったりで、なかなかうまく撮れなかったな(^^;

せっかくなので買った本をいくつか紹介。

ひかる!うごく!ミニチュアPC

3Dプリンタとかを使ってミニチュアPCを作る本。 LEDも組み込んでちゃんと画面が映るのがすごい。

会場に実物も飾ってあったけど、すごかった。

ミニチュアPCすごい

このほかにも別のブースではパンジャンドラムが転がってたりと、オフライン会場ならではの展示があるのは面白いよね。

ちょこっと使える和柄の描き方

和柄の描き方が説明されてる本で、これがすごく面白かった。 和柄とか意識して見たことなかったけど、いろんな柄が幾何的に捉えられていて、構造の組み合わせで実現されてるというのが分かってくる。 フルカラーでキレイだし。 もちろん実用としてもかなり使えそう。

ちなみにブースではイラストとかも一緒に飾られてて、キレイだったなぁ。

小説を書くAIの歴史

生成AI関連の本はいろいろあったけど、これはちょっと変わってて、AIに小説を書かせたり、あるいはAIの協力を得ながら小説を書くことに関して、著者の独自研究がまとまってる。 かなり昔からあるのかと思ったら意外とそんなでないらしく、へぇという感じだった。 このあたりも背景を推察すると面白いと思った。

Typst本

Typstに関する本は2冊あった。

Typstで組版された本、増えていくのかなぁ。 今後に期待。


オンラインはまだまだ続くので、他の本も見ていきたい。

ちなみにサークル「いもあらい。」の本もオンラインで頒布してるのでぜひ。

今日はここまで!