nikkie-ftnextの日記

イベントレポートや読書メモを発信

ファーストクラスコレクションのPythonでの実装例を見て考えたこと

はじめに

京都リサーチパークにて開催のYAPC::Kyotoでノベルティを受け取りし参加者の1人、nikkieです。

読書会駆動でミノ駆動本(『良いコード/悪いコードで学ぶ設計入門』)を読んでいます。
7章ではファーストクラスコレクションが紹介されます。
Pythonで実装する上で参考になりそうな先駆者の例が見つかりました。
それを見て考えたことをアウトプットします。

目次

ファーストクラスコレクション

ファーストクラスコレクション(First Class Collection)とは、コレクションに関連するロジックをカプセル化する設計パターンです。(ミノ駆動本 Kindle版 p.233)

私がファーストクラスコレクションを知ったのは『ThoughtWorksアンソロジー』がきっかけです。
5章の「オブジェクト指向エクササイズ」の中で紹介されています。

実装としては、コレクションだけをメンバ変数に持つクラスです(『ThoughtWorksアンソロジー』 5.2.9)。
コレクションを操作するメソッドをファーストクラスコレクションのクラスに持たせます。

ミノ駆動本と相性がいいとされる増田本(『現場で役立つシステム設計の原則』)では、ファーストクラスコレクションはコレクションオブジェクトとも呼ばれます。
語感から値オブジェクト1のコレクション版というような印象を受けますね。

Pythonでファーストクラスコレクション、先駆者による例

増田本のコードをPythonで実装した例が見つかりました。
アウトプットしていただき、ありがとうございます!

今回考察するのは以下の箇所です。

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Customer:
    id: int


@dataclass(frozen=True)
class Customers:
    customers: list[Customer]

    def add(self, customer: Customer) -> Customers:
        return Customers(self.customers.append(customer))

    def as_list(self) -> tuple[Customer]:
        return tuple(self.customers)
  • Python 3.10.9 で動かしています
  • Python 3.9以降、typing.Listの代わりに組み込み型のlistで型ヒントできるようになったので、そちらに置き換えました(tupleも同様です)

なるほどと思った点

dataclassにする

  • @dataclassはなるほどな〜と思いました
    • ファーストクラスコレクションには必須ではないと思うのですが、ファーストクラスコレクション同士の一致なども判定できるようになりますし、今の私には妥当そうに思えます
  • 増田本では「不変」スタイルを勧めているので、frozen=Trueの指定も納得です
    • コレクションの要素(Customer)も不変にしておく

変更提案したい点

大きく2点です

  1. addメソッドの実装(コードの振る舞いについて)
  2. as_listという命名(コードの構造について)

1-1. appendの返り値はNoneなのです

addメソッドについてです。

Customers(self.customers.append(customer))

self.customersはリストなので、リストのappendメソッドを呼び出しています。
そしてappendメソッドの返り値をCustomersに渡していますね。
https://docs.python.org/ja/3/tutorial/datastructures.html#more-on-lists

実はappendメソッドの返り値はNoneです。
これはappendメソッドがリストをインプレースに変換するからです。
要素を追加した新しいリストを返すわけではありません。

対話モードで確認しましょう。

>>> l = [1, 2]
>>> l.append(3)
>>> print(l.append(4))  # appendの返り値を出力するとNone
None
>>> l
[1, 2, 3, 4]

CustomersクラスのaddではCustomers(None)が返っています。

1-2. ファーストクラスコレクションが持つコレクションは変更しないほうがいいのではないか

appendメソッドによって、customers属性(リストを指す)が変更されています。

例を出します(コードをスクリプトに書き、python -i script.pyスクリプトを読み込んで対話モードに入ります)

>>> empty = Customers([])
>>> empty
Customers(customers=[])

空のCustomersを作りました。
@dataclassにより、表示がわかりやすいですね!

Customerインスタンスaddしてみます

>>> c1 = Customer(1)
>>> add_one = empty.add(c1)
>>> add_one  # appendの返り値がNoneのため
Customers(customers=None)
>>> empty
Customers(customers=[Customer(id=1)])

空だったCustomers(変数emptyが指す)が変わっています!
増田本が強調している不変から外れてしまっていますね。

2. as_listメソッドが返すのはtuple

ファーストクラスコレクションが持つコレクションを不変にして返す実装(asList)が増田本にあります。
これをPythonに置き換えてas_listtupleを返す実装です。

たしかにtupleappendメソッドがないなど、ユーザは勝手に書き換えられません。
振る舞いとしてはこれでよさそうですが、as_listという命名はユーザが誤解しそうなので変えたいなと思いました(as_tupleでしょうか)。

なお、JavaUnmodifiableListPythonではtupleなのかという点には議論の余地があると思います。
FAQのなぜタプルとリストという別のデータ型が用意されているのですか?を参照すると

  • タプルは「型が異なっても良い関連するデータの小さな集合
  • リストは「全て同じ型の可変数のオブジェクトを持ち、それらが一つ一つ演算される傾向にあ」る

タプルとリストでデザイン上の意図が異なるので、「変更できないlisttupleである」とは私はみなしていないです

なお、依存が追加されるというデメリットはありますが、frozenlistを使うというのも1つの手かと思います。

提案実装

以上をまとめて、今の私は以下のファーストクラスコレクションの実装を提案します。

  • ファーストクラスコレクション自体を不変にしたく、addメソッドでは新しいファーストクラスコレクションを作って返す
  • ファーストクラスコレクションが持つコレクションを返すメソッドはas_tupleとした(ただし暫定解)

終わりに

なかなかよさそうな例が見つかったと感じ、特に手を動かさずに読書会に持ち寄ったのですが、読書会でワイワイ読む中で変更提案したい点が見つかり、アウトプットしました。
インプレースなメソッドなど、私も最初は間違えましたね(手痛い失敗をしたから身に着いたというのもあります)

ファーストクラスコレクションの実装はもう少し考えてみようと思います