Dataclasses
ปัญหา boilerplate
หัวข้อที่มีชื่อว่า “ปัญหา boilerplate”Class ที่เก็บ data มักต้องการ __init__, __repr__ และ __eq__ — ทั้งหมดเขียนด้วยมือด้วย logic ที่เหมือนกัน: copy constructor argument ไปใส่ self.attr, format attribute เป็น string, เทียบทีละ attribute
@dataclass สร้างทั้งหมดนี้ให้จาก type annotation ระดับ class
from dataclasses import dataclass
@dataclassclass Point: x: float y: floatPython สร้าง:
__init__(self, x: float, y: float)ที่ตั้งค่าself.xและself.y__repr__ที่ return"Point(x=1.0, y=2.0)"__eq__ที่เทียบทุก field
ค่า default
หัวข้อที่มีชื่อว่า “ค่า default”Scalar default ทำงานเหมือน keyword argument:
@dataclassclass Config: host: str = "localhost" port: int = 8080 debug: bool = Falsefield(default_factory=list) และ error ตัวจริงของ mutable default
หัวข้อที่มีชื่อว่า “field(default_factory=list) และ error ตัวจริงของ mutable default”คุณใช้ object mutable (list, dict) เป็น default ตรงๆ ไม่ได้ — Python จะแชร์ instance เดียวข้ามทุก object
@dataclass ไม่ใช่แค่เตือนเรื่องนี้ มัน ปฏิเสธการสร้าง class เลย:
@dataclassclass BadInventory: name: str tags: list[str] = [] # mutable default -- dataclass refuses this at CLASS-DEFINITION timeแค่ define BadInventory ก็ raise ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory แล้ว — ก่อนที่คุณจะสร้าง instance ด้วยซ้ำ นี่เข้มงวดกว่า mutable default argument ของ function ธรรมดา (สอนในโมดูล Functions) ที่ทำงานเงียบๆ จนกว่า shared state จะกัดคุณ — field ของ dataclass เปลี่ยน mistake แบบเดียวกันให้เป็น error ที่ดังทันที วิธีแก้คือ field(default_factory=...):
from dataclasses import dataclass, field
@dataclassclass Inventory: name: str tags: list[str] = field(default_factory=list) quantity: int = 0field ยังรับ:
| Parameter | ผลลัพธ์ |
|---|---|
default_factory | ถูกเรียกโดยไม่มี argument เพื่อสร้าง default ของแต่ละ instance |
repr=False | ไม่รวม field นี้ใน __repr__ |
compare=False | ไม่รวมใน __eq__ (และ ordering method ถ้าคุณตั้ง order=True ด้วย) |
init=False | ไม่รวมใน __init__; ตั้งค่าเองใน __post_init__ |
frozen=True + object.__setattr__ ใน __post_init__
หัวข้อที่มีชื่อว่า “frozen=True + object.__setattr__ ใน __post_init__”frozen=True ทำให้ dataclass immutable: มันสร้าง __setattr__ และ __delattr__ ที่ raise FrozenInstanceError เมื่อพยายาม assign และยังสร้าง __hash__ ให้ด้วย ทำให้ frozen instance ใช้เป็น dict key หรืออยู่ใน set ได้
@dataclass(frozen=True)class Color: red: int green: int blue: intred.red = 128 raise FrozenInstanceError: cannot assign to field 'red' ยืนยันแล้วบน 3.14 นั่นเป็นปัญหาทันทีที่คุณต้องการคำนวณ field ที่ได้มา(derived)ครั้งเดียวใน __post_init__ — การ assign ธรรมดาตรงนั้นก็ถูกบล็อกเช่นกัน โดยตั้งใจ ทางออกคือ object.__setattr__ ซึ่ง bypass __setattr__ ของ dataclass เอง ใช้อย่างตั้งใจแค่ครั้งเดียวตอน construction:
@dataclass(frozen=True, slots=True)class Color: red: int green: int blue: int hex: str = field(init=False)
def __post_init__(self) -> None: # frozen blocks self.hex = ...; bypass it deliberately, once, during construction object.__setattr__(self, "hex", f"#{self.red:02x}{self.green:02x}{self.blue:02x}")slots=True
หัวข้อที่มีชื่อว่า “slots=True”การเพิ่ม slots=True (3.10+) สร้าง __slots__ ให้ class ตัดการมี __dict__ ต่อ instance ออกไป Instance ใช้หน่วยความจำน้อยลงและการเข้าถึง attribute เร็วขึ้นเล็กน้อย แต่คุณจะเพิ่ม attribute ที่ไม่ได้ประกาศเป็น field ไม่ได้อีกต่อไป และ multiple inheritance กับ class __slots__ อื่นจะถูกจำกัดมากขึ้น Color ด้านบนรวม frozen=True กับ slots=True — คู่ที่พบบ่อยสำหรับ value object เล็กๆ ที่ใช้จำนวนมาก
order=True และ kw_only
หัวข้อที่มีชื่อว่า “order=True และ kw_only”order=True สร้าง __lt__, __le__, __gt__, __ge__ จากลำดับ field — ถ้าไม่มีมัน < และเพื่อนจะ raise TypeError บน dataclass instance สองตัว kw_only=True บังคับให้ทุก field ต้องส่งแบบ keyword ซึ่งมีประโยชน์เมื่อ dataclass มีหลาย field ที่ type เดียวกันซึ่งลำดับ positional สับสนง่าย:
@dataclass(order=True, kw_only=True)class Version: major: int minor: int patch: int = 0
v1 = Version(major=1, minor=2)v2 = Version(major=1, minor=3)print(v1 < v2) # True -- compares (major, minor, patch) tuples in field orderVersion(1, 2) — เรียกแบบ positional — raise TypeError: Version.__init__() takes 1 positional argument but 3 were given; ทั้ง uvx mypy --strict และ uvx pyright ก็ปฏิเสธการเรียกนี้แบบ static เช่นกัน (Too many positional arguments for "Version" / Expected 0 positional arguments) — type checker จับการละเมิด kw_only ได้ก่อนที่คุณจะรันโค้ดด้วยซ้ำ
asdict deep-copy nested dataclass
หัวข้อที่มีชื่อว่า “asdict deep-copy nested dataclass”dataclasses.asdict แปลง dataclass (รวม nested dataclass) เป็น dict ธรรมดาแบบ recursive — และมัน deep-copy ไปด้วยระหว่างทาง ดังนั้นการแก้ผลลัพธ์จึงไม่กระทบ original เลย:
snapshot = asdict(person)snapshot["address"]["city"] = "Paris" # mutating the COPYprint(person.address.city) # original untouched -- asdict deep-copies nested dataclassesนี่ตรงข้ามกับพฤติกรรม aliasing ที่การเข้าถึง attribute ธรรมดาให้คุณ — person.address return object Address เดียวกัน แต่ asdict(person)["address"] เป็น dict อิสระ
eq=True โดย default ทำให้ instance unhashable — frozen/unsafe_hash แก้ได้
หัวข้อที่มีชื่อว่า “eq=True โดย default ทำให้ instance unhashable — frozen/unsafe_hash แก้ได้”@dataclass สร้าง __eq__ โดย default (eq=True) และ — เหมือน class ที่เขียนด้วยมือทุกประการ — การสร้าง __eq__ ทำให้ __hash__ เป็น None Dataclass ธรรมดาที่ไม่ frozen จึง unhashable:
@dataclassclass Plain: x: int y: int
hash(Plain(1, 2)) # TypeError: unhashable type: 'Plain'มีสองวิธีเปิดกลับ: frozen=True สร้าง __hash__ ที่ถูกต้องจาก field (ปลอดภัย เพราะ frozen instance เปลี่ยนไม่ได้หลัง hash ถูกคำนวณ) หรือ unsafe_hash=True สร้างให้บน dataclass ที่ mutable — ที่ชื่อว่า “unsafe” เพราะไม่มีอะไรห้ามคุณแก้ field หลังจากเอา instance ไปเป็น dict key ซึ่งทำให้ hash table ภายในของ dict เสียหาย
ตารางเลือกระหว่าง dataclass, NamedTuple, TypedDict, pydantic
หัวข้อที่มีชื่อว่า “ตารางเลือกระหว่าง dataclass, NamedTuple, TypedDict, pydantic”ทั้งสี่อธิบาย “ถุงของ field ที่มีชื่อ” แต่แก้ปัญหาต่างกัน:
@dataclass | NamedTuple | TypedDict | pydantic BaseModel | |
|---|---|---|---|---|
| Object ตอน runtime | Class instance ธรรมดา | tuple subclass (index ได้, unpack ได้) | dict ธรรมดา — ไม่มี runtime type เลย | Class instance ธรรมดา |
| Mutable | ได้ เว้นแต่ frozen=True | ไม่ได้ — tuple immutable | แล้วแต่ที่ dict อนุญาต | ได้ เว้นแต่ตั้งค่าให้ frozen |
| Runtime validation | ไม่มี — annotation ไม่ถูกบังคับใช้ | ไม่มี | ไม่มี | มี — parse และ coerce ตอน construct |
| เหมาะกับ | Value/record class ทั่วไปที่มี method | Data รูปทรงตายตัวที่ต้องทำตัวเหมือน tuple ด้วย | รูปทรง ของ dict ที่คุณมีอยู่แล้ว (เช่น JSON ที่ parse มา) | Input ที่ไม่น่าเชื่อถือ (API request, config file) ที่ต้อง validate จริง |
ใช้ @dataclass เป็นตัวเลือกหลัก; NamedTuple เมื่อความเข้ากันได้กับ tuple (unpack, index) สำคัญ; TypedDict เมื่อคุณกำลังอธิบาย dict ที่คุณไม่ได้ควบคุมการสร้าง; pydantic เมื่อ data มาจากภายนอกโปรแกรมและต้อง validate จริงๆ ไม่ใช่แค่ annotate
__match_args__ และ pattern matching
หัวข้อที่มีชื่อว่า “__match_args__ และ pattern matching”@dataclass สร้าง __match_args__ จากลำดับ field ทำให้ instance ใช้กับ match/case แบบ positional pattern ได้เลย:
match p: case Point2(x=0, y=0): return "origin" case Point2(x=x, y=y): return f"({x}, {y})"Point2.__match_args__ คือ ('x', 'y') — นั่นคือสิ่งที่ทำให้ case Point2(x, y): (แบบ positional) ทำงานได้เลย นอกเหนือจากรูปแบบ keyword ที่แสดงข้างต้น
เพิ่ม method เอง
หัวข้อที่มีชื่อว่า “เพิ่ม method เอง”Dataclass ก็ยังเป็น class ธรรมดา — เพิ่ม method อะไรก็ได้ที่ต้องการ:
@dataclassclass Point: x: float y: float
def distance_from_origin(self) -> float: return (self.x ** 2 + self.y ** 2) ** 0.5Demo รันได้จริง
หัวข้อที่มีชื่อว่า “Demo รันได้จริง”from dataclasses import dataclass, field, asdict, FrozenInstanceError
@dataclassclass Point: x: float y: float
def distance_from_origin(self) -> float: return float((self.x**2 + self.y**2) ** 0.5)
@dataclassclass Inventory: name: str tags: list[str] = field(default_factory=list) quantity: int = 0
def add_tag(self, tag: str) -> None: self.tags.append(tag)
p = Point(3.0, 4.0)print(p)print(p.distance_from_origin())
item = Inventory("Widget")item.add_tag("sale")item.add_tag("clearance")print(item)
try:
@dataclass class BadInventory: name: str tags: list[str] = [] # mutable default -- dataclass refuses this at CLASS-DEFINITION time
except ValueError as e: print(f"ValueError: {e}")
@dataclass(frozen=True, slots=True)class Color: red: int green: int blue: int hex: str = field(init=False)
def __post_init__(self) -> None: # frozen blocks self.hex = ...; bypass it deliberately, once, during construction object.__setattr__(self, "hex", f"#{self.red:02x}{self.green:02x}{self.blue:02x}")
red = Color(255, 0, 0)print(red)print(red.hex)try: red.red = 128except FrozenInstanceError as e: print(f"FrozenInstanceError: {e}")
print(hash(red) == hash(Color(255, 0, 0)))
@dataclass(order=True, kw_only=True)class Version: major: int minor: int patch: int = 0
v1 = Version(major=1, minor=2)v2 = Version(major=1, minor=3)print(v1 < v2)print(sorted([v2, v1], key=lambda v: (v.major, v.minor, v.patch)))
try: Version(1, 2) # kw_only=True -- positional args are rejectedexcept TypeError as e: print(f"TypeError: {e}")
@dataclassclass Address: city: str
@dataclassclass Person: name: str address: Address
person = Person("Ada", Address("London"))snapshot = asdict(person)snapshot["address"]["city"] = "Paris" # mutating the COPYprint(person.address.city) # original untouched -- asdict deep-copies nested dataclassesprint(snapshot["address"]["city"])
@dataclassclass Plain: x: int y: int
try: hash(Plain(1, 2))except TypeError as e: print(f"TypeError: {e}")
@dataclass(unsafe_hash=True)class UnsafeHashable: x: int y: int
print(hash(UnsafeHashable(1, 2)) == hash(UnsafeHashable(1, 2)))
@dataclassclass Point2: x: float y: float
def describe(point: object) -> str: match point: case Point2(x=0, y=0): return "origin" case Point2(x=x, y=y): return f"({x}, {y})" case _: return "not a point"
print(describe(Point2(0, 0)))print(describe(Point2(1, 2)))print(Point2.__match_args__)Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart LR Ann["class body: name: type = default"] --> Gen["@dataclass reads __annotations__"] Gen --> Init["generates __init__"] Gen --> Repr["generates __repr__"] Gen --> Eq["generates __eq__ (eq=True by default)"] Eq -.->|"sets, unless frozen/unsafe_hash"| Hash["__hash__ = None"] Gen -->|"frozen=True"| Setattr["generates __setattr__/__delattr__ that raise"]
@dataclass ไม่ใช่ syntax วิเศษ — มันเป็น class decorator ธรรมดาที่รันครั้งเดียวตอน class-definition มันอ่าน __annotations__ ของ class (บรรทัด name: type) และการเรียก field(...) แล้วสร้าง method ของ Python ธรรมดา (__init__, __repr__, __eq__ และอื่นๆ แล้วแต่ flag) ลงบน class โดยตรง เหมือนกับที่คุณเขียนเองด้วยมือทุกประการ นั่นคือเหตุผลที่ mutable default raise ทันทีตอน definition — decorator ตรวจ default ระหว่างสร้าง __init__ ก่อนที่ instance จะมีอยู่เลย — และเหตุผลที่ frozen=True กับ eq=True ทำงานร่วมกัน: decorator ตัวเดียวกันตัดสินใจทั้งคู่ ทำให้มันสร้าง __hash__ ที่สอดคล้องกันได้แทนที่จะให้คุณต้อง reconcile เอง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
@dataclass (default) | ได้ __init__/__repr__/__eq__ ฟรี | Instance unhashable เว้นแต่เพิ่ม frozen หรือ unsafe_hash |
field(default_factory=...) | ทุก instance ได้ mutable default เป็นของตัวเอง | เขียนยาวกว่า default แบบ literal เล็กน้อย |
frozen=True | Immutable, hashable, ปลอดภัยเป็น dict key | Field ที่ต้องการคำนวณหลัง __init__ ต้องใช้ object.__setattr__ |
slots=True | หน่วยความจำน้อยลง เข้าถึง attribute เร็วขึ้น | เพิ่ม attribute แบบ dynamic ไม่ได้; multiple inheritance ถูกจำกัดมากขึ้น |
order=True | ได้ </<=/>/>= ฟรีจากลำดับ field | การเทียบ dataclass ที่รูปทรงไม่เกี่ยวข้องกันก็แค่เทียบ tuple ของค่า — ไม่มีการตรวจเชิงความหมาย |
ใช้ pydantic BaseModel แทน | Runtime validation และ coercion จริง | มี dependency เพิ่ม และเป็น object ที่หนักกว่า dataclass ธรรมดา |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- Dataclass ที่มี list field ต้องการแค่ default เหมือน field อื่น
tags: list = []ไม่ได้แชร์ state เงียบๆ เหมือน mutable default ของ function —@dataclassraiseValueErrorทันทีที่ class body รัน ก่อนจะมี instance ใดๆ frozen=Trueแปลว่าตั้งค่า field ที่ไหนก็ไม่ได้เลย รวมถึงใน__post_init__object.__setattr__(self, name, value)คือทางออกที่มีเอกสารรองรับ ใช้ตั้งค่า field ที่ได้มาแค่ครั้งเดียวตอน construction- Dataclass ทุกตัวใช้เป็น dict key ได้ ได้เฉพาะถ้ามัน hashable — และโดย default (
eq=True,frozen=False) มันไม่ใช่ ต้องใช้frozen=Trueหรือunsafe_hash=Trueเพื่อเปิดใช้ asdictแค่ returnvars(instance)มัน convert nested dataclass เป็น dict แบบ recursive และ deep-copy field ที่ mutable ดังนั้นการแก้ผลลัพธ์จึงไม่กระทบ instance เดิม- NamedTuple, TypedDict และ pydantic BaseModel ใช้แทนกันได้กับ dataclass เลือกตัวที่พิมพ์สั้นที่สุด พวกมันต่างกันที่ mutability, runtime identity และมีอะไร validate data หรือไม่ — ตัวเลือกที่ถูกต้องขึ้นกับว่า data มาจากไหนและคุณจะเอาไปทำอะไรต่อ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ dataclass ที่ generate มา ตรวจสิ่งเหล่านี้:
- Field ที่มี mutable default แบบ literal (
tags: list = [],meta: dict = {}) แทนที่จะเป็นfield(default_factory=...)— โค้ดนี้ไม่รันเลยด้วยซ้ำ@dataclassraiseValueErrorทันที - Class
frozen=Trueที่มีself.field = valueธรรมดาอยู่ที่ไหนก็ตามนอกเหนือจากobject.__setattr__ที่ตั้งใจใน__post_init__— นั่นคือFrozenInstanceErrorที่รอเกิดขึ้น - Dataclass ที่ใช้เป็น dict key หรือสมาชิก set โดยไม่มี
frozen=True/unsafe_hash=True— default เป็น unhashable,TypeErrorตอนใช้งาน unsafe_hash=Trueบน dataclass ที่ field ถูกแก้หลัง construction — hash ของ object เปลี่ยนได้ขณะอยู่ใน set หรือ dict ทำให้ lookup เสียหายแบบเงียบๆ- โครงสร้าง nested-dataclass ที่ถูก convert ด้วย
vars()หรือสร้าง dict เองแทนasdict()ทำให้ขาด deep-copy semantics และรั่ว reference ไปยัง nested object เดิม
🐛 หา bug ในโค้ดนี้
from dataclasses import dataclass, field
@dataclassclass Order: items: list = field(default_factory=list) tags: list = [] # meant as "no tags by default"
def add_tag(self, tag: str) -> None: self.tags.append(tag)เฉลย
tags: list = [] เป็น mutable default แบบ literal บน dataclass field — @dataclass ปฏิเสธการสร้าง class เลย ยืนยันแล้ว: การ import module นี้ raise ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory ที่บรรทัด class Order: ก่อนที่ Order จะถูกสร้าง instance ด้วยซ้ำ แก้แบบเดียวกับที่ items ด้านบนทำถูกต้องอยู่แล้ว: tags: list = field(default_factory=list)