الفصل 27

فئات البيانات وتلميحات الأنواع

ما يكتبه المزخرف `@dataclass` نيابة عنك، لماذا نحتاج `field(default_factory=...)`، ميزات `frozen=True`، تلميحات الأنواع في الدوال، ولماذا لا تفعل التلميحات شيئاً وقت التشغيل بينما تفعل كل شيء مع الفاحص.

36 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

عند كتابة صنف Book من الفصل السابق بالكامل، يبدو الكود بهذا الشكل:

python
class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __repr__(self):
        return f"Book({self.title!r}, {self.author!r}, {self.pages!r})"

    def __eq__(self, other):
        return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)


a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))
text
Book('one', 'rafi', 300)
True

ثلاثة عشر سطراً لا تحتوي على أي قرار برمجي مبتكر. يتم تكرار اسم كل حقل ثلاث مرات كاملة — كمعامل في __init__، وفي سطر self.، وفي التابع __repr__. وإضافة حقل رابع تعني إضافته يدوياً في ثلاثة أماكن، وإذا نسيت أحدها فلن يظهر أي خطأ — بل سيكون التابع __repr__ ناقصاً بهدوء ودون تنبيه.

python
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    author: str
    pages: int


a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))
text
Book(title='one', author='rafi', pages=300)
True

سبعة أسطر فقط، وكُتب اسم كل حقل مرة واحدة فحسب.

في نهاية هذا الدرس ستكون قادراً على

  • كتابة فئة بيانات باستخدام المزخرف @dataclass وشرح ما ينشئه تلقائياً
  • استخدام field(default_factory=...) وتوضيح سبب الحاجة الماسة إليه
  • بيان الميزات التي يمنحها الخيار frozen=True
  • كتابة تلميحات الأنواع (Type Hints) على الدوال — مثل list[str] و dict[str, float] و int | None
  • فهم حقيقة أن تلميحات الأنواع لا تفعل شيئاً أثناء التشغيل بينما تصنع فارقاً جوهرياً تحت أدوات الفحص
  • تحويل فئة البيانات إلى JSON باستخدام دالة asdict

المتطلبات السابقة: الأصناف والكائنات — جمع البيانات والسلوك معاً.


ما الذي يكتبه @dataclass نيابة عنك؟

مقابل تلك الأسطر السبعة البسيطة، يولد لك بايثون تلقائياً:

  • دالة __init__ تتبع معاملاتها حقول الصنف بنفس الترتيب
  • تابع __repr__ يعرض كل حقل مع اسمه بوضوح تام
  • تابع __eq__ يعتبر كائنين متساويين عندما تتطابق جميع حقولهما

ثلاث مشكلات رئيسية من الفصل السابق — طباعة <object at 0x...> الغامضة، وخيبة أمل a == b التي كانت تعطي False، وتكرار نفس الكلمات ثلاث مرات — تنتهي جميعاً في لحظة واحدة.

وتُكتب التوابع العادية بنفس الطريقة السابقة تماماً:

python
from dataclasses import dataclass


@dataclass
class OrderLine:
    name: str
    price: float
    quantity: int

    def total(self) -> float:
        return round(self.price * self.quantity * 1.15, 2)


line = OrderLine("pen", 15.0, 3)
print(line)
print(line.total())
text
OrderLine(name='pen', price=15.0, quantity=3)
51.75

كما يمكن تحديد قيم افتراضية للحقول، مع بقاء قاعدة الفصل العشرين كما هي: الحقول ذات القيم الافتراضية يجب أن تأتي في النهاية.

python
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    pages: int = 1


print(Book("one"))
print(Book("one", 300))
text
Book(title='one', pages=1)
Book(title='one', pages=300)

التلميحات لا تفعل شيئاً وقت التشغيل

يجب توضيح هذا الأمر مبكراً، لأن الاسم قد يوحي بعكس ذلك.

python
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    pages: int


b = Book("one", "three hundred")
print(b)
print(b.pages + 1)
text
Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to str

كتبنا pages: int ومع ذلك تم قبول نص عادي دون أي اعتراض! بايثون لا يفحص تلميحات الأنواع وقت التشغيل — بل يسجلها فقط كبيانات وصفية، ويترك مهمة الاستفادة منها لأدوات أخرى متخصصة.

الخطأ لم يظهر إلا في وقت لاحق، عندما حاول كود آخر إجراء عملية حسابية على تلك القيمة. وقبل ذلك تم تمرير القيمة وطباعتها وتخزينها وربما كتابتها في ملف!

وهنا تحديداً تتجلى قيمة أدوات فحص الأنواع (Type Checkers). برنامج mypy هو برنامج خارجي مستقل يفحص كودك دون تشغيله على الإطلاق:

text
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int"  [arg-type]

رقم السطر، وأي وسيط بالتحديد، وما القيمة الممررة وما النوع المتوقع — وكل هذا تم اكتشافه قبل أن تضغط مفتاح تشغيل البرنامج.

القاعدة في جملتين: التلميحات لا تفعل شيئاً أثناء التشغيل الفعلي، وتفعل كل شيء تحت فاحص الأنواع. كتابة التلميح هي عهد قاطع مع أداة الفحص ومع القارئ البشري، وليست للمفسر.

ويمكنك الاطلاع على التلميحات المسجلة مباشرة عبر بايثون:

python
def shout(text: str) -> str:
    return text.upper()


print(shout("pen"))
print(shout.__annotations__)
text
PEN
{'text': <class 'str'>, 'return': <class 'str'>}

فخ القيمة الافتراضية القابلة للتعديل للمرة الثالثة!

في الفصل الحادي والعشرين التقينا به كمعامل افتراضي لدالة. وفي الفصل السادس والعشرين كسمة على مستوى الصنف. والآن يظهر كحقل في فئة بيانات — ولكن هذه المرة، يتصدى له بايثون بنفسه فوراً:

python
from dataclasses import dataclass


@dataclass
class Shelf:
    books: list = []
text
ValueError: mutable default <class 'list'> for field books is not allowed: use default_factory

لاحظ أنه لم يتم إنشاء أي كائن على الإطلاق — بل أُطلق الخطأ لحظة تعريف الصنف نفسها. لقد رأى مطورو بايثون هذا الفخ يتكرر كثيراً إلى حد أنهم قرروا رفضه صراحة ومنعه من الأساس هنا.

والطريقة الصحيحة هي تمرير مصنع افتراضي (Factory) — يحدد ما يجب إنشاؤه عند الطلب، بدلاً من تمرير كائن جاهز مسبقاً:

python
from dataclasses import dataclass, field


@dataclass
class Shelf:
    name: str
    books: list[str] = field(default_factory=list)


a = Shelf("a")
b = Shelf("b")
a.books.append("one")

print(a)
print(b)
text
Shelf(name='a', books=['one'])
Shelf(name='b', books=[])

العبارة field(default_factory=list) تعني: "استدعِ الدالة list() لإنشاء قائمة جديدة في كل مرة يُنشأ فيها كائن جديد". إنها تختصر أسطر if basket is None: basket = [] من الفصل الحادي والعشرين في سطر واحد أنيق لا مجال فيه للخطأ.

التجميد عبر frozen=True

python
from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


p = Point(1, 2)
print(p)
print({p, Point(1, 2)})
p.x = 5
text
Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'

حدث أمران مترابطان معاً في هذا المثال:

أصبح الكائن غير قابل للتعديل (Immutable). تم منع السطر p.x = 5 تماماً — تماماً كما هو الحال مع التوبلات.

وأصبح من الممكن وضعه داخل مجموعة (Set). هل تذكر معضلة الفصل السابق: كتابة __eq__ تحرم الكائن من دالة التجزئة (Hash). هنا، اندمجت النقطتان "المتساويتان" في عنصر واحد داخل المجموعة دون أي خطأ.

وبدون frozen:

python
from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


print({Point(1, 2)})
text
TypeError: unhashable type: 'Point'

والسبب يعود لما تعلمناه في الفصل السادس عشر: ما يمكن تعديله لا يمكن تجزئته (Hash) بأمان — فلو تغير الكائن بعد وضعه في مجموعة فلن تتمكن من العثور عليه في الذاكرة أبداً. والخاصية frozen=True تعلن بوضوح: "هذا الكائن لن يتغير أبداً"، وبناءً على ذلك يقبل بايثون تزويده بدالة تجزئة صالحة.

القاعدة بسيطة: جمّد أي كائن لا يُفترض أن تتغير خصائصه بعد إنشائه. هذا يمنحك التزاماً بالأمان، ويتيح لك استخدامه كعنصر في المجموعات أو كمفتاح في القواميس.

تلميحات الأنواع على الدوال

python
def totals(rows: list[tuple[str, float]]) -> dict[str, float]:
    result: dict[str, float] = {}
    for name, amount in rows:
        result[name] = result.get(name, 0.0) + amount
    return result

تعبيرات مثل list[str] و dict[str, float] و tuple[str, float] — تسمح لك بتحديد ما يكمن في باطن هذه الهياكل، وهذا ما يمنح التلميحات قوتها الحقيقية. فكلمة list وحدها تقول فقط "قائمة ما"، بينما list[tuple[str, float]] تشرح بالتفصيل ما ستنتجه حلقة التكرار.

وإذا مررت لها نوعاً غير مطابق:

text
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]"  [arg-type]

وللتعبير عن قيمة "قد تكون موجودة وقد تكون معدومة"، نستخدم العامل |:

python
def find(names: list[str], wanted: str) -> str | None:
    for name in names:
        if name == wanted:
            return name
    return None


print(find(["pen"], "pen"))
print(find(["pen"], "bag"))
text
pen
None

وهنا تبرز الفائدة العظمى للتلميحات. فإذا حاولت استخدام النتيجة مباشرة دون التحقق من أنها ليست None:

text
main.py:8: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]

هذا التنبيه يمثل الخطأ الشهير AttributeError: 'NoneType' object has no attribute 'upper' — وهو الخطأ الأكثر شيوعاً في الممارسة العملية — ولكن الفارق هنا أنه كُشف قبل تشغيل البرنامج، ودون الحاجة لانتظار مدخلات معينة تتسبب في حدوثه أثناء عمل النظام!

من فئة البيانات إلى JSON

python
import json
from dataclasses import dataclass, asdict


@dataclass
class Book:
    title: str
    pages: int


print(asdict(Book("one", 300)))
print(json.dumps(asdict(Book("one", 300))))
print(json.dumps(Book("one", 300)))
text
{'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializable

أوضحت قائمة الفصل الخامس والعشرين أن الكائنات المخصصة لا يمكن تحويلها إلى JSON مباشرة. والدالة asdict تمثل الجسر الذي يحول فئة البيانات إلى قاموس، وتعمل حتى مع فئات البيانات المتداخلة بداخل بعضها.


مثال متكامل

إعادة بناء نظام الطلبات من الفصل السابق باستخدام فئات البيانات وتلميحات الأنواع:

python
"""The order of chapter twenty-six, rebuilt with dataclasses and hints."""

import json
from dataclasses import dataclass, field, asdict

TAX_RATE = 0.15


@dataclass(frozen=True)
class OrderLine:
    """Frozen: a line never changes once made, so it can live in a set."""

    name: str
    price: float
    quantity: int

    def __post_init__(self) -> None:
        if self.quantity < 1:
            raise ValueError(f"quantity must be at least 1: {self.quantity}")

    def total(self) -> float:
        return round(self.price * self.quantity * (1 + TAX_RATE), 2)


@dataclass
class Order:
    """Not frozen: lines are added over time."""

    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, name: str, price: float, quantity: int) -> "Order":
        self.lines.append(OrderLine(name, price, quantity))
        return self

    def total(self) -> float:
        return round(sum(line.total() for line in self.lines), 2)

    def largest(self) -> OrderLine | None:
        if not self.lines:
            return None
        return max(self.lines, key=lambda line: line.total())


def main() -> None:
    order = Order("rafi")
    order.add("pen", 15.0, 3)
    order.add("bag", 850.0, 1)
    order.add("ink", 120.0, 2)

    for line in order.lines:
        print(f"{line.name:<6} {line.total():>9.2f}")

    print(f"{'total':<6} {order.total():>9.2f}")
    print()
    print(order.largest())
    print(json.dumps(asdict(order)))

    print(len({OrderLine("pen", 15.0, 3), OrderLine("pen", 15.0, 3)}))

    try:
        order.add("clip", 5.0, 0)
    except ValueError as err:
        print("rejected:", err)


if __name__ == "__main__":
    main()
text
pen        51.75
bag       977.50
ink       276.00
total    1305.25

OrderLine(name='bag', price=850.0, quantity=1)
{"customer": "rafi", "lines": [{"name": "pen", "price": 15.0, "quantity": 3}, {"name": "bag", "price": 850.0, "quantity": 1}, {"name": "ink", "price": 120.0, "quantity": 2}]}
1
rejected: quantity must be at least 1: 0

خمسة أمور بالغة الأهمية:

التابع __post_init__ هو المكان المخصص للتحقق من صحة البيانات. بما أن @dataclass يتولى كتابة __init__ تلقائياً، فلا يوجد مكان لوضع شروط التحقق — وهنا يأتي دور __post_init__ الذي يعمل فور تعيين الحقول مباشرة. وبهذا يبقى وعد الفصل السابق قائماً: امتلاك كائن OrderLine يعني بالضرورة أن كميته تساوي واحداً على الأقل.

أحد الصنفين مجمد والآخر غير مجمد، عن قصد. فعنصر الطلب لا يُفترض أن يتغير بعد إنشائه، بينما الطلب نفسه يستمر في استقبال عناصر جديدة. والسؤال هو نفسه دائماً: هل يتغير الكائن بعد إنشائه أم لا؟

الرقم 1 هو الدليل القاطع على نجاح التجميد. اندمج كائنان متطابقان من OrderLine في عنصر واحد داخل المجموعة — وفي الفصل السابق كان نفس السطر سيطلق خطأ TypeError.

تحديد list[OrderLine] ليس مجرد شكل جمالي. بفضله يعرف فاحص الأنواع أن line.total() تابع صحيح وموجود، وبفضله سيكتشف الخطأ الإملائي line.totl() فوراً — دون تشغيل الكود.

اسم "Order" وُضع بين علامتي اقتباس. داخل التابع add، عند الإشارة إلى النوع Order، لم يكن الصنف قد اكتمل تعريفه بعد في مفسر بايثون — فالاسم لم يكن موجوداً رسمياً في النطاق بعد. ووضعه بين علامتي اقتباس يسمح لبايثون بحله لاحقاً. والإشارة إلى صنف من داخل نفسه تتطلب دائماً هذا الأسلوب في إصدارات بايثون القياسية.


حالات الخطأ الشائعة

ValueError: mutable default ... use default_factory تمت كتابة = [] أو = {} كقيمة افتراضية لحقل. استخدم field(default_factory=list) بدلاً من ذلك.

TypeError: non-default argument follows default argument تم وضع حقل له قيمة افتراضية قبل حقل إلزامي. تنطبق قاعدة الفصل العشرين: الحقول الإلزامية أولاً دائماً.

dataclasses.FrozenInstanceError تمت محاولة تعديل سمة في كائن محمي بـ frozen=True. إذا أردت تعديلاً، استخدم dataclasses.replace(obj, x=5) لإنتاج كائن جديد بالقيم المعدلة.

TypeError: unhashable type فئة بيانات لا تحتوي على frozen=True، فلا يمكن وضعها داخل مجموعة (Set) أو استخدامها كمفتاح في قاموس.

مررت نوعاً خاطئاً ولم يظهر أي خطأ أثناء التشغيل هذا متوقع تماماً — فالتلميحات لا تفرض قيوداً وقت التشغيل. شغّل أداة الفحص mypy.

أداة mypy لا تفحص ملفي حدد المسار الصريح للملف — mypy main.py. وإذا واجهت سيلاً من التنبيهات حول المكتبات الخارجية، ابدأ بالأمر mypy --ignore-missing-imports.

TypeError: Object of type X is not JSON serializable مرر الكائن عبر دالة التحويل: asdict(obj).

NameError عند استخدام اسم الصنف الخاص بي داخله ضع الاسم بين علامتي اقتباس كنص: -> "Order".