ข้ามไปยังเนื้อหา

Dataclasses

Class ที่เก็บ data มักต้องการ __init__, __repr__ และ __eq__ — ทั้งหมดเขียนด้วยมือด้วย logic ที่เหมือนกัน: copy constructor argument ไปใส่ self.attr, format attribute เป็น string, เทียบทีละ attribute @dataclass สร้างทั้งหมดนี้ให้จาก type annotation ระดับ class

from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float

Python สร้าง:

  • __init__(self, x: float, y: float) ที่ตั้งค่า self.x และ self.y
  • __repr__ ที่ return "Point(x=1.0, y=2.0)"
  • __eq__ ที่เทียบทุก field

Scalar default ทำงานเหมือน keyword argument:

@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False

คุณใช้ object mutable (list, dict) เป็น default ตรงๆ ไม่ได้ — Python จะแชร์ instance เดียวข้ามทุก object @dataclass ไม่ใช่แค่เตือนเรื่องนี้ มัน ปฏิเสธการสร้าง class เลย:

@dataclass
class 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
@dataclass
class Inventory:
name: str
tags: list[str] = field(default_factory=list)
quantity: int = 0

field ยังรับ:

Parameterผลลัพธ์
default_factoryถูกเรียกโดยไม่มี argument เพื่อสร้าง default ของแต่ละ instance
repr=Falseไม่รวม field นี้ใน __repr__
compare=Falseไม่รวมใน __eq__ (และ ordering method ถ้าคุณตั้ง order=True ด้วย)
init=Falseไม่รวมใน __init__; ตั้งค่าเองใน __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: int

red.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 (3.10+) สร้าง __slots__ ให้ class ตัดการมี __dict__ ต่อ instance ออกไป Instance ใช้หน่วยความจำน้อยลงและการเข้าถึง attribute เร็วขึ้นเล็กน้อย แต่คุณจะเพิ่ม attribute ที่ไม่ได้ประกาศเป็น field ไม่ได้อีกต่อไป และ multiple inheritance กับ class __slots__ อื่นจะถูกจำกัดมากขึ้น Color ด้านบนรวม frozen=True กับ slots=True — คู่ที่พบบ่อยสำหรับ value object เล็กๆ ที่ใช้จำนวนมาก

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 order

Version(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 ได้ก่อนที่คุณจะรันโค้ดด้วยซ้ำ

dataclasses.asdict แปลง dataclass (รวม nested dataclass) เป็น dict ธรรมดาแบบ recursive — และมัน deep-copy ไปด้วยระหว่างทาง ดังนั้นการแก้ผลลัพธ์จึงไม่กระทบ original เลย:

snapshot = asdict(person)
snapshot["address"]["city"] = "Paris" # mutating the COPY
print(person.address.city) # original untouched -- asdict deep-copies nested dataclasses

นี่ตรงข้ามกับพฤติกรรม aliasing ที่การเข้าถึง attribute ธรรมดาให้คุณ — person.address return object Address เดียวกัน แต่ asdict(person)["address"] เป็น dict อิสระ

@dataclass สร้าง __eq__ โดย default (eq=True) และ — เหมือน class ที่เขียนด้วยมือทุกประการ — การสร้าง __eq__ ทำให้ __hash__ เป็น None Dataclass ธรรมดาที่ไม่ frozen จึง unhashable:

@dataclass
class 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 เสียหาย

ทั้งสี่อธิบาย “ถุงของ field ที่มีชื่อ” แต่แก้ปัญหาต่างกัน:

@dataclassNamedTupleTypedDictpydantic BaseModel
Object ตอน runtimeClass 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 ทั่วไปที่มี methodData รูปทรงตายตัวที่ต้องทำตัวเหมือน tuple ด้วยรูปทรง ของ dict ที่คุณมีอยู่แล้ว (เช่น JSON ที่ parse มา)Input ที่ไม่น่าเชื่อถือ (API request, config file) ที่ต้อง validate จริง

ใช้ @dataclass เป็นตัวเลือกหลัก; NamedTuple เมื่อความเข้ากันได้กับ tuple (unpack, index) สำคัญ; TypedDict เมื่อคุณกำลังอธิบาย dict ที่คุณไม่ได้ควบคุมการสร้าง; pydantic เมื่อ data มาจากภายนอกโปรแกรมและต้อง validate จริงๆ ไม่ใช่แค่ annotate

@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 ที่แสดงข้างต้น

Dataclass ก็ยังเป็น class ธรรมดา — เพิ่ม method อะไรก็ได้ที่ต้องการ:

@dataclass
class Point:
x: float
y: float
def distance_from_origin(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
from dataclasses import dataclass, field, asdict, FrozenInstanceError
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self) -> float:
return float((self.x**2 + self.y**2) ** 0.5)
@dataclass
class 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 = 128
except 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 rejected
except TypeError as e:
print(f"TypeError: {e}")
@dataclass
class Address:
city: str
@dataclass
class Person:
name: str
address: Address
person = Person("Ada", Address("London"))
snapshot = asdict(person)
snapshot["address"]["city"] = "Paris" # mutating the COPY
print(person.address.city) # original untouched -- asdict deep-copies nested dataclasses
print(snapshot["address"]["city"])
@dataclass
class 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)))
@dataclass
class 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__)
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 เป็น class decorator ที่อ่าน annotation ครั้งเดียวแล้วเขียน method ลงบน class

@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=TrueImmutable, hashable, ปลอดภัยเป็น dict keyField ที่ต้องการคำนวณหลัง __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 — @dataclass raise ValueError ทันทีที่ 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 แค่ return vars(instance) มัน convert nested dataclass เป็น dict แบบ recursive และ deep-copy field ที่ mutable ดังนั้นการแก้ผลลัพธ์จึงไม่กระทบ instance เดิม
  • NamedTuple, TypedDict และ pydantic BaseModel ใช้แทนกันได้กับ dataclass เลือกตัวที่พิมพ์สั้นที่สุด พวกมันต่างกันที่ mutability, runtime identity และมีอะไร validate data หรือไม่ — ตัวเลือกที่ถูกต้องขึ้นกับว่า data มาจากไหนและคุณจะเอาไปทำอะไรต่อ

ก่อนเชื่อ dataclass ที่ generate มา ตรวจสิ่งเหล่านี้:

  • Field ที่มี mutable default แบบ literal (tags: list = [], meta: dict = {}) แทนที่จะเป็น field(default_factory=...) — โค้ดนี้ไม่รันเลยด้วยซ้ำ @dataclass raise ValueError ทันที
  • 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
@dataclass
class 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)

@dataclass สร้าง dunder method ไหนให้อัตโนมัติ?
ทำไมเขียน tags: list = [] เป็น default ของ dataclass field ไม่ได้?
frozen=True เพิ่มอะไรให้ dataclass?
field(repr=False) ใช้ทำอะไร?
บรรทัดสุดท้ายเกิดอะไรขึ้น?
@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
p.x = 5.0
เกิดอะไรขึ้น?
@dataclass(order=True, kw_only=True)
class Version:
    major: int
    minor: int

Version(1, 2)
โค้ดนี้พิมพ์อะไร?
person = Person("Ada", Address("London"))
snapshot = asdict(person)
snapshot["address"]["city"] = "Paris"
print(person.address.city)
คุณกำลัง model data ที่มาจาก JSON ของ API ภายนอกที่ไม่น่าเชื่อถือและต้อง validate ก่อนใช้ ตัวไหนเหมาะที่สุด?