nikkie-ftnextの日記

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

dataclassを使って、Pythonで値オブジェクトを実装する

はじめに

アタイも挑戦したい、管理人みたいに1
ジョゼと虎と魚たち』めっちゃいいですよね? nikkieです。

タイトルの値オブジェクトとは、値を扱うための専用クラス2です。
これを「Python@dataclasses.dataclassを使って書いたらどうなるのか」手を動かしました。
この記事は、分かったことの備忘録です。

おことわり:「今の自分が書いたらこんな感じかな」というのをまとめました。
技術的な誤りや伸びしろにお気づきの際は、コメントやTwitter @ftnext までお知らせいただけると大変ありがたいです。

目次

「アタイ」ヒロインならぬ、「アタイ」オブジェクトについてです

経緯

先日TECH StreetさんのPythonエンジニア勉強会Pythonのobjectについて10分話しました。

紹介した例は、値オブジェクトやファーストクラスコレクションを意識して実装しています。
準備の中で『現場で役立つシステム設計の原則』を参照したところ、「意識しているけど、値オブジェクトやファーストクラスコレクションにはできていないなー」と、目の前に広がる圧倒的な伸びしろを感じました。
そこで、LTとは別に実装を突き詰める機会を設けました。
今回は、値オブジェクト編のアウトプットです。

値オブジェクトとは

※『現場で役立つシステム設計の原則』の1章を参考にしています

値オブジェクトの実装は「基本データ型のインスタンス変数を1つか2つ持つだけの小さなクラス」3です。
基本データ型をインスタンス変数としてラップして、独自の型(クラス)を作ると捉えています。
値オブジェクトを使う目的は、業務のニーズに合っていない基本データ型の代わりに、業務のニーズに合わせた値の範囲を使うためと理解しました。

値オブジェクトの利点はいくつも紹介されています。
イチオシはこちら、関数の引数がめちゃくちゃ分かりやすくなりますね!

def amount(unit_price: Money, quantity: Quantity) -> Money:
    if quantity.is_discountable():
        return discount(unit_price, quantity)
    return unit_price.multiply(quantity.value)

1章の「『型』を使ってコードを分かりやすく安全にする」のJavaのコードをPythonで書き直してみました。
第1引数がMoney型、第2引数がQuantity型という型ヒントが分かりやすいと思います。

値オブジェクトの実装要件

  • 基本データ型を属性として1つか2つ持つ
    • 👉 簡単な例で練習したいので、1つにします
  • 不変
    • 👉 @dataclass.dataclass()frozen引数にTrueを指定します
    • ドキュメントによると「frozen: 真 (デフォルト値は False) の場合、フィールドへの代入は例外を生成します。 これにより読み出し専用の凍結されたインスタンスを模倣します。」4
  • 完全コンストラク
    • 「オブジェクトの生成時に、オブジェクトの状態を完全に設定してしまう」5
    • コンストラクタで、業務のニーズに合わせた値の範囲か検証します
    • 👉 @dataclass.dataclass()では__init__が自動で作られますが、完全コンストラクタにするために自分で書くことにしました

値オブジェクトの例:競走馬名

TECH StreetさんでのLTの題材にした競走馬名を表すクラスを例に選びました。

※競走馬の名前を値オブジェクトにするというのは、業務アプリケーションからかけ離れていると思います。
今回の取り組みは「値オブジェクトの実装方法」を見つける練習なので、これでいきます🐴

日本における馬名登録のルール6

  • カタカナ9文字以内7
  • 「ヰ」・「ヱ」は使用できない
  • 2文字以上9文字以内

馬の名は。値オブジェクト RaceHorseName 🐴

完成したコード

from __future__ import annotations

import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import ClassVar


@dataclass(frozen=True)
class RaceHorseName:
    """カタカナの馬名を表すクラス"""

    value: str
    MIN_LENGTH: ClassVar[int] = 2
    MAX_LENGTH: ClassVar[int] = 9
    INVALID_CHARACTERS: ClassVar[Iterable[str]] = ("ヰ", "ヱ")

    def __init__(self, value: str):
        # 完全コンストラクタの実装例
        length = len(value)
        if length < self.MIN_LENGTH:
            raise ValueError(f"不正: {self.MIN_LENGTH} 文字未満")
        if length > self.MAX_LENGTH:
            raise ValueError(f"不正: {self.MAX_LENGTH} 文字超")
        if not self.is_katakana_only(value):
            raise ValueError("不正: カタカナではない文字")
        if self.does_include_invalid_characters(value):
            raise ValueError(f"不正: 使用禁止文字 {self.INVALID_CHARACTERS} を含む")
        object.__setattr__(self, "value", value)

    @staticmethod
    def is_katakana_only(string: str) -> bool:
        return bool(re.fullmatch(r"[\u30A0-\u30FF]+", string))

    @classmethod
    def does_include_invalid_characters(cls, string: str) -> bool:
        return any(c in string for c in cls.INVALID_CHARACTERS)

不変なことの確認

>>> rice = RaceHorseName("ライスシャワー")
>>> rice.value = "ミホノブルボン"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 4, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'value'

frozen=Trueにより、ラップした値を変更できません

frozen=True を dataclass() デコレータに渡すことで、不変性の模倣はできます。 このケースでは、データクラスは __setattr__() メソッドと __delattr__() メソッドをクラスに追加します。 これらのメソッドは起動すると FrozenInstanceError を送出します。8

完全コンストラクタであることの確認

  • 2文字未満:ラ
  • 9文字超:トッテモライスシャワー
  • カタカナではない文字を含む:ライスshower
  • 使えないカタカナ:ラヰスシャワー

いずれもValueErrorが送出されます

>>> RaceHorseName("らイスシャワー")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: 不正: カタカナではない文字

実装ポイント

frozen=Trueと完全コンストラク

イニシャライザ(__init__)でvalue属性に値を代入する必要があります。
これは以下の書き方をする必要がありました。

object.__setattr__(self, "value", value)

frozen=True を使うとき、実行する上でのわずかな代償があります: __init__() でフィールドを初期化するのに単純に割り当てることはできず、 object.__setattr__() を使わなくてはなりません。9

イニシャライザでself.value = valueと書くと、以下が出てしまいます(frozenにしたのでごもっともなのですが、けっこう焦りました)。

dataclasses.FrozenInstanceError: cannot assign to field 'value'

これは object.__setattr__()10で回避できました。

dataclassのクラス変数

typing.ClassVarを初めて知りました。

クラス変数であることを示す特別な型構築子です。11

フィールドが ClassVar の場合、フィールドとは見なされなくなり、データクラスの機構からは無視されます。12

dataclassのクラス変数の理解があやふやだと分かって、いくつか手を動かしました。
型ヒントをつけなければクラス変数 なんですね。

@dataclass
class C:
    a: int
    b: int = 0
    d = "Class var"
>>> c = C(108)
>>> c
C(a=108, b=0)
>>> c.d
'Class var'
>>> C.d
'Class var'

クラス変数dに型ヒントを付けるときにClassVarを使うという理解です。
これで型チェッカーの支援を受けられるとのこと13
クラス変数の扱いをさらに深めたければ、@dataclasses.dataclassの実装を見ることになりそうです。

脱線:すべてカタカナのチェック

最後に値オブジェクトとは別の話題を。
文字列がすべてカタカナかの判定は https://note.nkmk.me/python-re-regex-character-type/#_7 を参考にしています。

Future works

今回書いたコードは暫定解(よりよい方法で棄却されうる)と思っているので、今後試したい事項を挙げておきます:

終わりに

値オブジェクト、dataclassfrozen=Trueを知っていた14ので、ぱぱっと実装できるかと思いきや、意外と時間がかかりました。
完全コンストラクタの実装で、値の検証に使う変数やメソッドをクラスに持たせていき、「競走馬名をクラスで表現できた!」と満足しています😃

次は、ファーストクラスコレクション編です!(執筆時期は未定です)


  1. アタイつながりで持ってきました😝 https://youtu.be/P5LLhoKfI1E?t=36

  2. 増田 亨『現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法』 (Japanese Edition) 1章 (Kindle の位置No.687). Kindle 版.

  3. 『現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法』 (Kindle の位置No.689-690)

  4. https://docs.python.org/ja/3/library/dataclasses.html#dataclasses.dataclass

  5. 『現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法』 (Kindle の位置No.735-736)

  6. 馬名登録のルール、けっこう面白かったです。「オレハマッテルゼ」なんてあるんですね🤣 類似しているとNGだそうですが、「チョウカイテイオー」は却下で、「ナイキシャトル」は採用🤔

  7. 「アルファベットの馬名と合わせて登録する」そうですが、今回は考えません。だって、ウマ娘、カタカナの印象が強いじゃないですか!

  8. https://docs.python.org/ja/3/library/dataclasses.html#frozen-instances

  9. https://docs.python.org/ja/3/library/dataclasses.html#frozen-instances

  10. https://docs.python.org/ja/3/reference/datamodel.html#object.__setattr__ (URL末尾の__setattr__が太字になってしまいます😢)

  11. https://docs.python.org/ja/3/library/typing.html#typing.ClassVar

  12. https://docs.python.org/ja/3/library/dataclasses.html#class-variables

  13. Here ClassVar is a special class defined by the typing module that indicates to the static type checker that this variable should not be set on instances. ref: https://www.python.org/dev/peps/pep-0526/#class-and-instance-variable-annotations

  14. hoboroさんのトークで知りました。詳しくは イベントレポート | オンラインで開催された #pycon_shizu 、私の知らないPythonがいくつもありました! - nikkie-ftnextの日記