Python Data Classes: เทคนิคขั้นสูงที่เหนือกว่าแค่การลดโค้ดซ้ำซ้อน

Python DataClasses Beyond the Boilerplate

บทนำ

นักพัฒนาส่วนใหญ่มองว่า Python dataclasses เป็นเพียงทางลัดในการหลีกเลี่ยงการเขียน dunder methods ซ้ำๆ อย่าง __init__ และ __repr__ ซึ่งเมื่อมองแวบแรก มันดูเหมือนเป็นวิธีง่ายๆ ในการเขียนโค้ดให้น้อยลงและทำงานได้เร็วขึ้นเท่านั้น

ในทางปฏิบัติ dataclasses ถูกออกแบบมาเพื่อลดโค้ดซ้ำซ้อน (boilerplate) ในคลาสที่เน้นจัดการข้อมูล (data-focused classes) โดยยังคงรักษาพฤติกรรมของคลาสให้ชัดเจนและควบคุมได้ แทนที่จะต้องเขียนตรรกะสำหรับการกำหนดค่าเริ่มต้น (initialization), การเปรียบเทียบ (comparison) และการแสดงค่า (representation) ด้วยตัวเอง คุณเพียงแค่กำหนด field ต่างๆ แล้วให้ Python สร้างเมธอดมาตรฐานให้โดยอัตโนมัติ ซึ่งยังคงความยืดหยุ่นในการปรับแต่งพฤติกรรมได้ตามต้องการ

บทความนี้จะพาคุณไปไกลกว่าพื้นฐาน โดยเน้นที่คุณสมบัติเชิงปฏิบัติที่คุณจะได้ใช้ในโปรเจกต์จริง ได้แก่ การปรับแต่งพฤติกรรมของ field ด้วย field(), การใช้ __post_init__() เพื่อตรวจสอบความถูกต้องและคำนวณค่า, รวมถึงการสร้างคลาสที่แก้ไขไม่ได้ (immutable) และประหยัดหน่วยความจำด้วย frozen=True และ slots=True เมื่ออ่านจบ คุณจะรู้วิธีใช้ dataclasses ในแบบที่เหนือกว่าแค่การกำจัดโค้ดซ้ำซ้อน

คุณสามารถรับโค้ดได้ที่ GitHub

การสร้างคลาสพื้นฐาน

ตลอดบทความนี้ เราจะใช้ตัวอย่างระบบติดตามการจัดส่ง (shipment-tracking) หากเราเขียนโดยไม่ใช้ dataclasses โค้ดจะมีหน้าตาประมาณนี้:

class Shipment:
    def __init__(self, tracking_id, origin, destination, weight_kg, priority):
        self.tracking_id = tracking_id
        self.origin = origin
        self.destination = destination
        self.weight_kg = weight_kg
        self.priority = priority
 
def __repr__(self):
        return (
            f"Shipment(tracking_id={self.tracking_id!r}, origin={self.origin!r}, "
            f"destination={self.destination!r}, weight_kg={self.weight_kg!r}, "
            f"priority={self.priority!r})"
        )
 
def __eq__(self, other):
        if not isinstance(other, Shipment):
            return NotImplemented
        return (
            self.tracking_id == other.tracking_id
            and self.origin == other.origin
            and self.destination == other.destination
            and self.weight_kg == other.weight_kg
            and self.priority == other.priority
        )

การเขียนแบบนี้มีความยาวมากกว่า 30 บรรทัด ทั้งที่ไม่มีตรรกะเฉพาะของโดเมน (domain-specific logic) เลย ทุกเมธอดมีไว้เพียงเพื่อสนับสนุนการสร้างออบเจกต์ การเปรียบเทียบ และการแสดงค่าเท่านั้น

เมื่อเปลี่ยนมาใช้ decorator @dataclass ฟังก์ชันการทำงานเดิมจะกระชับลงเหลือเพียง:

from dataclasses import dataclass
 
@dataclass
class Shipment:
    tracking_id: str
    origin: str
    destination: str
    weight_kg: float
    priority: str

@dataclass จะอ่านแต่ละ attribute ที่มีการระบุประเภท (annotated attribute) และสร้างเมธอด __init__, __repr__ และ __eq__ ให้โดยอัตโนมัติ แม้ว่า annotation จะเป็นเพียง type hints ที่ Python ไม่ได้บังคับใช้ในขณะรันไทม์ แต่ decorator จะใช้ข้อมูลเหล่านี้เพื่อกำหนดว่า field ใดเป็นของคลาสและเรียงลำดับอย่างไร

การควบคุม Fields ด้วย field()

ฟังก์ชัน field() คือหัวใจสำคัญของการปรับแต่งที่เหนือกว่า syntax ปกติ ช่วยให้คุณกำหนดค่าแต่ละ field แยกกันได้ เช่น การกำหนดค่าเริ่มต้น (defaults), การยกเว้น field จากการเปรียบเทียบ หรือการซ่อนจาก object representations

การใช้ Default Factories

ข้อควรระวังใน Python คือการใช้ mutable default values เช่น List หรือ Dictionary เป็นค่าเริ่มต้นโดยตรง เพราะทุก instance จะแชร์ออบเจกต์เดียวกัน dataclasses แก้ปัญหานี้ด้วยการบังคับให้ใช้ default_factory แทน

from dataclasses import dataclass, field
from datetime import datetime
 
@dataclass
class Shipment:
    tracking_id: str
    origin: str
    destination: str
    weight_kg: float
    priority: str = "standard"
    route_stops: list[str] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.utcnow)

อาร์กิวเมนต์ default_factory รับค่าเป็น callable ที่จะถูกเรียกทุกครั้งที่มีการสร้าง instance ใหม่ ช่วยให้แต่ละ Shipment ได้รับ list ของตัวเอง และป้องกันปัญหาการแชร์สถานะ (shared mutable state) ระหว่างออบเจกต์

การยกเว้น Fields จาก repr และ eq

บาง field มีไว้เพื่อใช้งานภายในเท่านั้น และไม่ควรนำมาเปรียบเทียบความเท่ากันหรือแสดงใน log ให้ดูรก คุณสามารถควบคุมสิ่งนี้ได้ผ่าน repr=False และ compare=False เพื่อให้การเปรียบเทียบหรือการเก็บ log โฟกัสเฉพาะข้อมูลที่สำคัญจริงๆ

@dataclass
class Shipment:
    tracking_id: str
    origin: str
    destination: str
    weight_kg: float
    priority: str = "standard"
    route_stops: list[str] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.utcnow)
    _internal_notes: str = field(default="", repr=False, compare=False)

การใช้ __post_init__() สำหรับการตรวจสอบความถูกต้องและ Field ที่ได้จากการคำนวณ

แม้ __init__() จะจัดการกำหนดค่าเริ่มต้นให้ แต่ออบเจกต์มักต้องการการตรวจสอบความถูกต้อง (validation) หรือค่าที่คำนวณจาก field อื่นเพิ่มเติม เมธอด __post_init__() จะถูกเรียกทันทีหลังจาก __init__() ทำงานเสร็จสิ้น จึงเป็นจุดที่เหมาะสมที่สุดสำหรับการจัดการเหล่านี้

ตัวอย่างการตรวจสอบน้ำหนักและประเภทลำดับความสำคัญ:

from dataclasses import dataclass, field
 
VALID_PRIORITIES = {"economy", "standard", "express", "critical"}
 
@dataclass
class Shipment:
    tracking_id: str
    origin: str
    destination: str
    weight_kg: float
    priority: str = "standard"
    route_stops: list[str] = field(default_factory=list)
 
def __post_init__(self):
        if self.weight_kg <= 0:
            raise ValueError(f"weight_kg must be positive, got {self.weight_kg}")
        if self.priority not in VALID_PRIORITIES:
            raise ValueError(f"priority must be one of {VALID_PRIORITIES}, got {self.priority!r}")

หากการตรวจสอบล้มเหลว การสร้างออบเจกต์จะหยุดลงทันที ช่วยให้มั่นใจได้ว่าแอปพลิเคชันของคุณจะไม่ทำงานกับออบเจกต์ที่ไม่ถูกต้อง นอกจากนี้คุณยังสามารถใช้ field(init=False) ร่วมกับ __post_init__() เพื่อคำนวณค่าอัตโนมัติ เช่น ค่าขนส่ง (freight_cost) จากน้ำหนักและระดับความสำคัญ ทำให้ข้อมูลซิงค์กันอยู่เสมอ

การสร้าง Immutable Dataclasses

การตั้งค่า frozen=True ใน decorator จะทำให้อินสแตนซ์กลายเป็นแบบ immutable หรือแก้ไขไม่ได้หลังจากสร้างเสร็จ การพยายามเปลี่ยนค่าจะทำให้เกิด FrozenInstanceError ทันที ซึ่งมีประโยชน์มากสำหรับออบเจกต์ที่ไม่ควรถูกเปลี่ยนแปลง และยังทำให้ออบเจกต์สามารถทำ hash ได้ (hashable) เพื่อนำไปใช้เป็นคีย์ใน dictionary หรือเก็บใน set ได้ด้วย

from dataclasses import dataclass
 
@dataclass(frozen=True)
class RouteSegment:
    from_hub: str
    to_hub: str
    distance_km: float
    carrier: str

การลดการใช้หน่วยความจำด้วย slots=True

ในกรณีที่ต้องจัดการออบเจกต์จำนวนมหาศาล ออบเจกต์ Python มาตรฐานจะมี overhead จากการเก็บ attribute ไว้ใน __dict__ ตั้งแต่ Python 3.10 คุณสามารถลดการใช้หน่วยความจำได้โดยใช้ slots=True ซึ่งจะเปลี่ยนวิธีเก็บข้อมูลให้มีประสิทธิภาพมากขึ้น

@dataclass(slots=True)
class ShipmentRecord:
    tracking_id: str
    origin: str
    destination: str
    weight_kg: float
    priority: str

จากการทดสอบ Slotted instance สามารถลดการใช้หน่วยความจำจากหลักร้อยไบต์เหลือเพียงไม่กี่สิบไบต์ ซึ่งจะเห็นผลชัดเจนมากเมื่องานของคุณต้องเก็บออบเจกต์นับหมื่นรายการไว้ในหน่วยความจำพร้อมกัน

การสรุปภาพรวมทั้งหมด

นี่คือคลาส Shipment ฉบับพร้อมใช้งานจริง (production-ready) ที่รวมทุกเทคนิคเข้าด้วยกัน:

from dataclasses import dataclass, field
from datetime import datetime
 
FREIGHT_RATE_PER_KG = {
    "economy": 1.20,
    "standard": 1.85,
    "express": 3.40,
    "critical": 6.00
}
 
@dataclass(slots=True)
class Shipment:
    tracking_id: str
    origin: str
    destination: str
    weight_kg: float
    priority: str = "standard"
    route_stops: list[str] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.utcnow)
    freight_cost: float = field(init=False)
    _audit_tag: str = field(default="", repr=False, compare=False)
 
def __post_init__(self):
        if self.weight_kg <= 0:
            raise ValueError(f"weight_kg must be positive, got {self.weight_kg}")
 
if self.priority not in FREIGHT_RATE_PER_KG:
            raise ValueError(f"Invalid priority: {self.priority!r}")
 
self.freight_cost = round(self.weight_kg * FREIGHT_RATE_PER_KG[self.priority], 2)

สิ่งที่น่าศึกษาต่อ

หากต้องการขยายขีดความสามารถขึ้นไปอีก คุณอาจลองศึกษา dacite สำหรับการสร้าง dataclass จาก dictionary, marshmallow-dataclass สำหรับการทำ serialization หรือ Pydantic dataclasses หากต้องการระบบตรวจสอบข้อมูลที่ซับซ้อนขึ้น

Source: KDnuggets
ดูแลงานแปลและเรียบเรียงโดย SirilukP

ความคิดเห็น (0)

เข้าสู่ระบบเพื่อร่วมแสดงความเห็น

สมัครสมาชิก

มาเป็นคนแรกที่แสดงความเห็นกันเลยโบร