برای ساخت مدل داده تمیز و قابل نگهداری، ساده‌ترین راه در پایتون استفاده از dataclass است: فیلدها را با تایپ مشخص تعریف می‌کنید، @dataclass خودکار سازنده، مقایسه و نمایش شی را می‌سازد و با __post_init__ اعتبارسنجی انجام می‌دهید. با default_factory از خطای مقادیر پیش‌فرض قابل تغییر دور می‌مانید و با frozen مدل‌های تغییرناپذیر می‌سازید. اینها هسته استفاده حرفه‌ای از dataclass در پایتون هستند.

dataclass چیست و کی به درد می‌خورد؟

dataclass یک decorator در پایتون است که روی کلاس‌های معمولی اعمال می‌شود و متدهای تکراری مثل __init__، __repr__، __eq__ و در صورت نیاز مرتب‌سازی را خودکار تولید می‌کند. هرجا «داده ساخت‌یافته» دارید (مدل دامنه، DTO، پیکربندی، پیام رویداد)، dataclass کد شما را کوتاه‌تر و خواناتر می‌کند، بدون این که از امکانات کلاس‌های عادی (متدها، ارث‌بری، تایپ هینت) محروم شوید.

الگوی پایه با یک مثال کوتاه

هدف: تعریف یک مدل ساده با مقدار پیش‌فرض و نمایش خوانا.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int = 0  # مقدار پیش فرض

p = Person("Ali", 30)
print(p)           # Person(name='Ali', age=30)
print(p.name)      # Ali

بدون نوشتن سازنده، شی ساخته شد؛ __repr__ خوانا تولید شد؛ برابر بودن دو شخص با مقایسه فیلدها انجام می‌شود.

ساخت یک «مدل داده تمیز»: اصول و الگوی پیشنهادی

یک مدل تمیز فقط اسم فیلدها نیست؛ باید رفتار، اعتبارسنجی و ایمنی را هم پوشش دهید.

۱) فیلدهای صریح با تایپ هینت

بدون تایپ هینت، کارایی dataclass نصف می‌شود. تایپ‌ها هم خوانایی را بالا می‌برند هم در ابزارهای استاتیک مانند mypy/pyright کمک می‌کنند.

۲) اجتناب از پیش‌فرض‌های قابل تغییر

به‌جای list یا dict خالی به‌عنوان پیش‌فرض، از default_factory استفاده کنید.

from dataclasses import dataclass, field
from typing import List

@dataclass
class TagSet:
    article_id: int
    tags: List[str] = field(default_factory=list)  # درست: نمونه جدید برای هر شی

۳) اعتبارسنجی در __post_init__

__post_init__ بعد از __init__ اجرا می‌شود؛ بهترین جا برای کنترل ورودی است.

from dataclasses import dataclass, field
from datetime import date

@dataclass(frozen=True)
class User:
    id: int
    email: str
    joined_at: date = field(default_factory=date.today)
    is_active: bool = True

    def __post_init__(self):
        if "@" not in self.email:
            raise ValueError("invalid email")

با frozen=True شی تغییرناپذیر می‌شود. تا وقتی فقط «اعتبارسنجی» می‌کنید نیازی به تغییر فیلد نیست، اما اگر normalization می‌خواهید باید از object.__setattr__ استفاده کنید.

    def __post_init__(self):
        normalized = self.email.strip().lower()
        if "@" not in normalized:
            raise ValueError("invalid email")
        # چون کلاس frozen است:
        object.__setattr__(self, "email", normalized)

۴) متدهای دامنه‌ای؛ محاسبه را بیرون از سازنده نگه دارید

به‌جای پر منطق کردن سازنده، محاسبات را در ویژگی‌ها یا متدها نگه دارید.

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
    amount: int   # به ریال
    currency: str = "IRR"

    def with_tax(self, percent: float) -> "Money":
        taxed = int(self.amount * (1 + percent))
        return Money(taxed, self.currency)

یک مثال عملی: مدل سفارش ساده با اعتبارسنجی و سریال‌سازی

سناریو: سفارش فروشگاهی با اقلام تو در تو، تخفیف و محاسبه مجموع.

from dataclasses import dataclass, field, asdict
from typing import List

@dataclass(frozen=True)
class Item:
    name: str
    price: int   # ریال
    qty: int = 1

    def __post_init__(self):
        if self.price < 0:
            raise ValueError("price cannot be negative")
        if self.qty < 1:
            raise ValueError("qty must be >= 1")

@dataclass
class Order:
    id: str
    items: List[Item] = field(default_factory=list)
    discount: float = 0.0  # بین 0 و 1

    def __post_init__(self):
        if not (0.0 <= self.discount < 1.0):
            raise ValueError("discount must be in [0,1)")

    def add_item(self, item: Item) -> None:
        self.items.append(item)

    @property
    def total(self) -> int:
        subtotal = sum(i.price * i.qty for i in self.items)
        return int(subtotal * (1 - self.discount))

    def to_dict(self) -> dict:
        # asdict به صورت بازگشتی تمام dataclass های تو در تو را تبدیل می‌کند.
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict) -> "Order":
        raw_items = data.get("items", [])
        items = [Item(**it) for it in raw_items]
        return cls(id=data["id"], items=items, discount=data.get("discount", 0.0))

# استفاده
order = Order(id="A-1001", discount=0.1)
order.add_item(Item("Book", price=200_000, qty=2))
order.add_item(Item("Pen", price=30_000))
print(order.total)           # 387000
print(order.to_dict())       # {'id': 'A-1001', 'items': [...], 'discount': 0.1}

روش بررسی نتیجه: مجموع را دستی حساب کنید و با total مقایسه کنید؛ تلاش برای ثبت قیمت منفی باید خطا دهد؛ سریال‌سازی dict باید ساختار منظم و قابل JSON داشته باشد.

ترفندها و نکات رفتاری مهم dataclass

  • برابری و هش: اگر eq=True و frozen=True باشند، hash به‌طور ایمن قابل تولید است و شی می‌تواند کلید دیکشنری یا عضو set باشد. اگر شی قابل تغییر است، روی hash تکیه نکنید.
  • مرتب‌سازی: با order=True مقایسه بر اساس ترتیب فیلدها انجام می‌شود. اگر فقط برخی فیلدها باید مقایسه شوند، برای بقیه از field(compare=False) استفاده کنید.
  • پنهان کردن فیلدها در نمایش: field(repr=False) فیلدهای حساس (مثل توکن) را از __repr__ حذف می‌کند.
  • خروج از سازنده: field(init=False) برای مقادیری که در __post_init__ پر می‌شوند مناسب است (مثل slug یا cache).
  • metadata: می‌توانید با field(metadata={“unit”: “IRR”}) متادیتا ذخیره کنید؛ برای ابزارهای بیرونی یا اعتبارسنجی سفارشی مفید است.
  • slots و صرفه‌جویی حافظه: اگر مفسر شما از پارامتر slots در dataclass پشتیبانی کند، با slots=True خصیصه‌ها در __slots__ تعریف می‌شوند و مصرف حافظه کاهش می‌یابد. قبل از استفاده، سازگاری نسخه پایتون پروژه را بررسی کنید.
  • ارث‌بری: dataclass از ارث‌بری پشتیبانی می‌کند، اما ترتیب فیلدها در کلاس‌های فرزند مهم است. فیلدهای بدون پیش‌فرض باید قبل از فیلدهای دارای پیش‌فرض بیایند.

الگوی تبدیل به dict/JSON و نکات سریال‌سازی

asdict به شکل بازگشتی dataclass ها را به dict تبدیل می‌کند و برای JSON کافی است. توجه کنید:

  • ویژگی‌ها و property ها به dict نمی‌آیند؛ فقط فیلدها سریال می‌شوند. اگر لازم است، دستی اضافه کنید.
  • انواع غیرقابل JSON (مثل date) را قبل از json.dumps به str تبدیل کنید یا در __post_init__/متد سریال‌سازی، تبدیل انجام دهید.
import json
from dataclasses import asdict, dataclass, field
from datetime import date

@dataclass
class Profile:
    id: int
    birthday: date = field(default_factory=date.today)

    def to_json(self) -> str:
        d = asdict(self)
        d["birthday"] = d["birthday"].isoformat()
        return json.dumps(d, ensure_ascii=False)

print(Profile(1).to_json())  # {"id": 1, "birthday": "2026-08-30"}

اشتباهات رایج و راه‌حل آنها

  • پیش‌فرض‌های قابل تغییر مشترک می‌شوند: به‌جای tags=[] از field(default_factory=list) استفاده کنید.
  • توقع «تبدیل خودکار» انواع: dataclass داده را نگه می‌دارد، تبدیل/اعتبارسنجی با شماست. در __post_init__ تبدیل کنید (مثلا str به int).
  • استفاده از hash روی مدل‌های قابل تغییر: ممکن است رفتار مجموعه‌ها را خراب کند. اگر لازم دارید، مدل را frozen کنید یا hash سفارشی ایمن بسازید.
  • مرتب‌سازی ناخواسته: فعال کردن order=True روی فیلدهایی که نباید مقایسه شوند می‌تواند باگ بسازد. فیلدهای غیرمرتبط را compare=False کنید.
  • تغییر در __post_init__ روی کلاس frozen: مستقیم انتساب نکنید؛ از object.__setattr__ استفاده کنید.

چه زمانی سراغ گزینه دیگری برویم؟

  • ساختار بسیار سبک و فقط تاپل‌مانند: namedtuple جواب می‌دهد و ذاتا تغییرناپذیر است.
  • اگر صرفا «دیکشنری تایپ‌شده» می‌خواهید برای تبادل داده با کتابخانه‌ها: TypedDict مناسب تایپ‌هینت است.
  • برای اعتبارسنجی قوی، تبدیل نوع از ورودی‌های متنی/شبکه و پیام خطای غنی: از کتابخانه‌های تخصصی اعتبارسنجی/مدل‌سازی داده استفاده کنید.

چک لیست اجرایی سریع

  1. فیلدها را با تایپ دقیق بنویسید؛ نام‌ها را معنادار انتخاب کنید.
  2. برای list/dict/set از default_factory استفاده کنید.
  3. اعتبارسنجی و normalization را در __post_init__ پیاده کنید.
  4. اگر شی باید کلید دیکشنری باشد، frozen=True را فعال کنید.
  5. برای سریال‌سازی، asdict و تبدیل انواع خاص (مثل date) را پیاده کنید.
  6. فیلدهای حساس را با repr=False از نمایش عمومی حذف کنید.
  7. در صورت نیاز به حافظه کمتر، قابلیت slots را در نسخه سازگار فعال کنید.

گام بعدی چیست؟

یکی از مدل‌های فعلی پروژه را انتخاب کنید و آن را به dataclass تبدیل کنید. از default_factory و __post_init__ استفاده کنید، تستی برای برابری اشیا و سریال‌سازی بنویسید و سپس در صورت نیاز سراغ frozen و (در نسخه سازگار) slots بروید. این تغییر کوچک، یکدستی و کیفیت مدل‌های داده شما را بالا می‌برد.