pythonのdataclassでADT(代数的だか抽象的データ型)
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)
直接生成を潰す上手いやり方が見つからない。
__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)
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:
__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__をオーバーライドすることは一般的でなく推奨されない?[要出典]
了解しました。このコードについての説明と背後にある必要性について詳しく説明します。
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
メソッドのようなスマートコンストラクタのアプローチを取ることが推奨されます。
スマートコンストラクタの結論: 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))