Open6

pythonのdataclassでADT(代数的だか抽象的データ型)

yoda keisukeyoda keisuke
from dataclasses import dataclass

@dataclass(frozen=True)
class Error:
    message: str

@dataclass(frozen=True)
class Age:
    value: int

# スマートコンストラクタ
    @staticmethod
    def create(value: int) -> "AgeResult": # 後方参照の問題で""で文字列扱いにする必要
        if value < 0:
            return Error("Age cannot be negative!")
        return Age(value)

@dataclass(frozen=True)
class Person:
    name: str
    age: Age

    @staticmethod
    def create(name: str, age: "AgeResult") -> "PersonResult":
        if isinstance(age, Error):
            return Error(f"Failed to create person {name}. Reason: {age.message}")
        return Person(name, age)

# Type aliases
AgeResult = Age | Error
PersonResult = Person | Error

result1 = Person.create("Alice", Age.create(25))
print(result1)

result2 = Person.create("Alice", Age.create(-5))
print(result2)
yoda keisukeyoda keisuke

直接生成を潰す上手いやり方が見つからない。
__new__をオーバーライドすることで目的は達せられるが、コンパイルタイムエラーではなくランタイムエラーでの検知になる。

そもそも__post_init__ をオーバーライドしてバリデーションを書くのがpython的には素直(?)だが、例外を投げることになる。(型で返しうるドメインエラーを明示、モデリング出来ない)https://zenn.dev/ymiz/articles/2109498f97cd34

from dataclasses import dataclass

@dataclass(frozen=True)
class Error:
    message: str

@dataclass(frozen=True)
class Age:
    value: int

    # プライベートコンストラクタ
    def __new__(cls, value: int) -> "AgeResult":
        # 直接のインスタンス生成を禁止
        raise ValueError("Use 'create' method to instantiate an Age")

    @staticmethod
    def create(value: int) -> "AgeResult":
        if value < 0:
            return Error("Age cannot be negative!")
        return super(Age, Age).__new__(Age, value)

@dataclass(frozen=True)
class Person:
    name: str
    age: Age

    # プライベートコンストラクタ
    def __new__(cls, name: str, age: "AgeResult") -> "PersonResult":
        # 直接のインスタンス生成を禁止
        raise ValueError("Use 'create' method to instantiate a Person")

    @staticmethod
    def create(name: str, age: "AgeResult") -> "PersonResult":
        if isinstance(age, Error):
            return Error(f"Failed to create person {name}. Reason: {age.message}")
        return super(Person, Person).__new__(Person, name=name, age=age)

# Type aliases
AgeResult = Age | Error
PersonResult = Person | Error

result1 = Person.create("Alice", Age.create(25))
print(result1)

result2 = Person.create("Alice", Age.create(-5))
print(result2)

# 以下のコードはエラーとなる
result3 = Person("Bob", -4)
print(result3)
yoda keisukeyoda keisuke
 def create(name: str, age: "AgeResult") -> "PersonResult":

後方参照とやらの問題で、型定義の中でその型を使用するには文字列にする必要がある。
typeAliasを定義しない場合、"Person | Error"のような書き方。
これがやや直感的では無いと感じる。

別の方法として、from future import annotation sを使用する方法がある。本番では利用しずらいと思う。

from __future__ import annotations
def create(name: str, age: AgeResult) -> PersonResult:
yoda keisukeyoda keisuke

__new__にバリデーションを書くアプローチ

from dataclasses import dataclass

@dataclass(frozen=True)
class Error:
    message: str

@dataclass(frozen=True)
class Age:
    value: int

    def __new__(cls, value: int):
        if value < 0:
            return Error("Age cannot be negative!")
        instance = super(Age, cls).__new__(cls)
        instance.__dict__['value'] = value
        return instance

# 使用例
result1 = Age(25)
print(result1)  # Age(value=25)

result2 = Age(-5)
print(result2)  # Error(message='Age cannot be negative!')
        instance = super(Age, cls).__new__(cls)
        instance.__dict__['value'] = value
  • この辺がpython特有のよくわからん書き方かつごちゃごちゃしている。
  • __new__をオーバーライドすることは一般的でなく推奨されない?[要出典]
yoda keisukeyoda keisuke

了解しました。このコードについての説明と背後にある必要性について詳しく説明します。

instance = super(Age, cls).__new__(cls)

この行は、Age クラスの新しいインスタンスを作成します。通常、__new__ はオブジェクトの新しいインスタンスを生成する役割を果たします。ここでは、super(Age, cls).__new__(cls) を使用して、object クラス(Age クラスの基底クラス)の __new__ メソッドを呼び出しています。これにより、Age の新しいインスタンスが生成され、そのインスタンスが instance に格納されます。

instance.__dict__['value'] = value

この行では、生成された instance の辞書(__dict__)を直接操作して、value 属性に値を設定しています。

なぜこのように書かざるをえなかったのか?

この方法は、__init__ メソッドを使用せずにオブジェクトの属性を初期化するためのものです。これは、__init__ が実行されるとすでにオブジェクトが生成されているため、この時点でエラーチェックを行ってもオブジェクトの生成を防ぐことはできません。しかし、__new__ メソッドを使用すると、オブジェクトが生成される前にエラーチェックを行うことができます。

この特定の実装では、もし値が負であれば、Error オブジェクトを直接返すことができるため、__new__ を使用しています。一方、正の値の場合は、Age のインスタンスを生成してその value 属性を設定する必要があります。

通常、dataclass を使用する場合、属性の設定は自動的に行われますが、__new__ をオーバーライドすると、この自動的な属性の設定が行われないため、手動で属性を設定する必要が出てきます。このため、上記のように __dict__ を直接操作して属性を設定しています。

このアプローチは、確かにPythonicではなく、かなり冗長です。そのため、通常はcreateメソッドのようなスマートコンストラクタのアプローチを取ることが推奨されます。

yoda keisukeyoda keisuke

スマートコンストラクタの結論: pydanicを使い、失敗時のリターンはResult型(自分で実装)のErrを返す

from pydantic import BaseModel, ValidationError
from typing import Any, Callable, TypeVar, Union

# 既存のResult型と関連関数
T = TypeVar('T')
E = TypeVar('E')
U = TypeVar('U')

@dataclass(frozen=True)
class Ok(T):
    value: T

    def bind(self, op: Callable[[T], 'Result[U, E]']) -> 'Result[U, E]':
        return op(self.value)

@dataclass(frozen=True)
class Err(E):
    error: E

    def bind(self, op: Callable[[Any], 'Result[U, E]']) -> 'Result[U, E]':
        return self

Result = Union[Ok[T], Err[E]]

def From(value: T) -> Result[T, Any]:
    return Ok(value)

# バリデーション関数
def validate_zip_code(zip_code: str) -> Result[str, str]:
    if len(zip_code) != 7:
        return Err("ZIPコードは7桁でなければなりません。")
    return Ok(zip_code)

def validate_item_id(item_id: str) -> Result[str, str]:
    if len(item_id) != 10:
        return Err("item_idは10桁でなければなりません。")
    if not item_id[:3].isalpha():
        return Err("item_idの最初の3文字はアルファベットでなければなりません。")
    return Ok(item_id)

def validate_quantity(quantity: int) -> Result[int, str]:
    if quantity <= 0:
        return Err("数量は1以上でなければなりません。")
    return Ok(quantity)

# Pydanticモデル
class CustomerAddress(BaseModel):
    zip_code: str
    prefecture: str
    detail: str

class Order(BaseModel):
    item_id: str
    quantity: int
    customer_address: CustomerAddress

# 注文処理関数
def process_order(data: dict) -> Result[Order, str]:
    try:
        # バリデーションを実行
        order = Order(**data)
        item_id_result = validate_item_id(order.item_id)
        quantity_result = validate_quantity(order.quantity)
        zip_code_result = validate_zip_code(order.customer_address.zip_code)

        # 結果をチェック
        if isinstance(item_id_result, Err) or isinstance(quantity_result, Err) or isinstance(zip_code_result, Err):
            return Err("バリデーションエラー")
        return Ok(order)
    except ValidationError as e:
        return Err(str(e))