فئات البيانات وتلميحات الأنواع
ما يكتبه المزخرف `@dataclass` نيابة عنك، لماذا نحتاج `field(default_factory=...)`، ميزات `frozen=True`، تلميحات الأنواع في الدوال، ولماذا لا تفعل التلميحات شيئاً وقت التشغيل بينما تفعل كل شيء مع الفاحص.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
عند كتابة صنف Book من الفصل السابق بالكامل، يبدو الكود بهذا الشكل:
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))Book('one', 'rafi', 300)
Trueثلاثة عشر سطراً لا تحتوي على أي قرار برمجي مبتكر. يتم تكرار اسم كل حقل ثلاث مرات كاملة — كمعامل في __init__، وفي سطر self.، وفي التابع __repr__. وإضافة حقل رابع تعني إضافته يدوياً في ثلاثة أماكن، وإذا نسيت أحدها فلن يظهر أي خطأ — بل سيكون التابع __repr__ ناقصاً بهدوء ودون تنبيه.
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))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، وتكرار نفس الكلمات ثلاث مرات — تنتهي جميعاً في لحظة واحدة.
وتُكتب التوابع العادية بنفس الطريقة السابقة تماماً:
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())OrderLine(name='pen', price=15.0, quantity=3)
51.75كما يمكن تحديد قيم افتراضية للحقول، مع بقاء قاعدة الفصل العشرين كما هي: الحقول ذات القيم الافتراضية يجب أن تأتي في النهاية.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int = 1
print(Book("one"))
print(Book("one", 300))Book(title='one', pages=1)
Book(title='one', pages=300)التلميحات لا تفعل شيئاً وقت التشغيل
يجب توضيح هذا الأمر مبكراً، لأن الاسم قد يوحي بعكس ذلك.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
b = Book("one", "three hundred")
print(b)
print(b.pages + 1)Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to strكتبنا pages: int ومع ذلك تم قبول نص عادي دون أي اعتراض! بايثون لا يفحص تلميحات الأنواع وقت التشغيل — بل يسجلها فقط كبيانات وصفية، ويترك مهمة الاستفادة منها لأدوات أخرى متخصصة.
الخطأ لم يظهر إلا في وقت لاحق، عندما حاول كود آخر إجراء عملية حسابية على تلك القيمة. وقبل ذلك تم تمرير القيمة وطباعتها وتخزينها وربما كتابتها في ملف!
وهنا تحديداً تتجلى قيمة أدوات فحص الأنواع (Type Checkers). برنامج mypy هو برنامج خارجي مستقل يفحص كودك دون تشغيله على الإطلاق:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]رقم السطر، وأي وسيط بالتحديد، وما القيمة الممررة وما النوع المتوقع — وكل هذا تم اكتشافه قبل أن تضغط مفتاح تشغيل البرنامج.
القاعدة في جملتين: التلميحات لا تفعل شيئاً أثناء التشغيل الفعلي، وتفعل كل شيء تحت فاحص الأنواع. كتابة التلميح هي عهد قاطع مع أداة الفحص ومع القارئ البشري، وليست للمفسر.
ويمكنك الاطلاع على التلميحات المسجلة مباشرة عبر بايثون:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}فخ القيمة الافتراضية القابلة للتعديل للمرة الثالثة!
في الفصل الحادي والعشرين التقينا به كمعامل افتراضي لدالة. وفي الفصل السادس والعشرين كسمة على مستوى الصنف. والآن يظهر كحقل في فئة بيانات — ولكن هذه المرة، يتصدى له بايثون بنفسه فوراً:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryلاحظ أنه لم يتم إنشاء أي كائن على الإطلاق — بل أُطلق الخطأ لحظة تعريف الصنف نفسها. لقد رأى مطورو بايثون هذا الفخ يتكرر كثيراً إلى حد أنهم قرروا رفضه صراحة ومنعه من الأساس هنا.
والطريقة الصحيحة هي تمرير مصنع افتراضي (Factory) — يحدد ما يجب إنشاؤه عند الطلب، بدلاً من تمرير كائن جاهز مسبقاً:
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)Shelf(name='a', books=['one'])
Shelf(name='b', books=[])العبارة field(default_factory=list) تعني: "استدعِ الدالة list() لإنشاء قائمة جديدة في كل مرة يُنشأ فيها كائن جديد". إنها تختصر أسطر if basket is None: basket = [] من الفصل الحادي والعشرين في سطر واحد أنيق لا مجال فيه للخطأ.
التجميد عبر frozen=True
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 = 5Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'حدث أمران مترابطان معاً في هذا المثال:
أصبح الكائن غير قابل للتعديل (Immutable). تم منع السطر p.x = 5 تماماً — تماماً كما هو الحال مع التوبلات.
وأصبح من الممكن وضعه داخل مجموعة (Set). هل تذكر معضلة الفصل السابق: كتابة __eq__ تحرم الكائن من دالة التجزئة (Hash). هنا، اندمجت النقطتان "المتساويتان" في عنصر واحد داخل المجموعة دون أي خطأ.
وبدون frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'والسبب يعود لما تعلمناه في الفصل السادس عشر: ما يمكن تعديله لا يمكن تجزئته (Hash) بأمان — فلو تغير الكائن بعد وضعه في مجموعة فلن تتمكن من العثور عليه في الذاكرة أبداً. والخاصية frozen=True تعلن بوضوح: "هذا الكائن لن يتغير أبداً"، وبناءً على ذلك يقبل بايثون تزويده بدالة تجزئة صالحة.
القاعدة بسيطة: جمّد أي كائن لا يُفترض أن تتغير خصائصه بعد إنشائه. هذا يمنحك التزاماً بالأمان، ويتيح لك استخدامه كعنصر في المجموعات أو كمفتاح في القواميس.
تلميحات الأنواع على الدوال
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]] تشرح بالتفصيل ما ستنتجه حلقة التكرار.
وإذا مررت لها نوعاً غير مطابق:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]وللتعبير عن قيمة "قد تكون موجودة وقد تكون معدومة"، نستخدم العامل |:
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"))pen
Noneوهنا تبرز الفائدة العظمى للتلميحات. فإذا حاولت استخدام النتيجة مباشرة دون التحقق من أنها ليست None:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]هذا التنبيه يمثل الخطأ الشهير AttributeError: 'NoneType' object has no attribute 'upper' — وهو الخطأ الأكثر شيوعاً في الممارسة العملية — ولكن الفارق هنا أنه كُشف قبل تشغيل البرنامج، ودون الحاجة لانتظار مدخلات معينة تتسبب في حدوثه أثناء عمل النظام!
من فئة البيانات إلى JSON
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))){'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializableأوضحت قائمة الفصل الخامس والعشرين أن الكائنات المخصصة لا يمكن تحويلها إلى JSON مباشرة. والدالة asdict تمثل الجسر الذي يحول فئة البيانات إلى قاموس، وتعمل حتى مع فئات البيانات المتداخلة بداخل بعضها.
مثال متكامل
إعادة بناء نظام الطلبات من الفصل السابق باستخدام فئات البيانات وتلميحات الأنواع:
"""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()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".
Step 4 of 6 — Predict
Check your understanding
A seven-line dataclass. What do the two lines print?
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- ABook(title='one', pages=300) True
- BBook(title='one', pages=300) False
- C<__main__.Book object at 0x...> False
- DBook('one', 300) True
No object is made, only the class is written. What happens?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- AA `ValueError` — while the class is being defined
- BNothing; the problem appears when the first object is made
- CNothing; every instance will share one list
- DA `TypeError`
frozen=True is set. What happens?
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print({p, Point(1, 2)})
p.x = 5- AA set of one prints, then a `FrozenInstanceError`
- BA set of two prints, then a `FrozenInstanceError`
- C`TypeError: unhashable type` — at the set
- DA set of one prints, then `p.x` becomes `5`
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
دورك الآن
أعد كتابة صنفي Book و Shelf من الفصل السادس والعشرين باستخدام فئات البيانات وتلميحات الأنواع.
صنف Book محمي بـ frozen=True بالحقول: title: str و author: str و pages: int، مع دالة __post_init__ تطلق ValueError إذا كان pages < 1. وصنف Shelf يحتوي على name: str و books: list[Book] = field(default_factory=list)، بالإضافة إلى التوابع: add، و total_pages() -> int، و longest() -> Book | None، و by_author(name: str) -> list[Book].
ثم ثبّت الأداة عبر pip install mypy، وشغّل mypy library.py، وصحح الكود حتى تختفي كافة التنبيهات تماماً.
ثم أجرِ هذه التجارب الست:
- مرر نصاً لحقل الصفحات — مثل
Book("one", "rafi", "300"). هل يعمل البرنامج عند تشغيله؟ وماذا يقولmypyعند فحصه؟ - استبدل
field(default_factory=list)في حقل الكتب بـ= []. متى يظهر الخطأ — لحظة إنشاء الكائن، أم قبل ذلك عند قراءة الصنف؟ - أنشئ نسختين متطابقتين من
Bookوضعهما في مجموعة (Set). كم عنصراً تحتوي المجموعة؟ ثم احذفfrozen=Trueوجرّب من جديد. - استخدم نتيجة
longest()مباشرة — مثلshelf.longest().title— على رف فارغ. ماذا يقول البرنامج عند تشغيله، وماذا كانmypyقد قال مسبقاً قبل التشغيل؟ - حوّل الرف عبر
asdict(shelf)، واكتبه باستخدامjson.dumps، ثم اقرأه مجدداً عبرjson.loads. هل الكائن الناتج فئة بيانات من نوعShelf؟ - استدعِ سمة باسم خاطئ — مثل
book.pagse. هل يكتشفهاmypy؟
التجربتان الأخيرتان توضحان حدود هذا الفصل وقوته الهائلة معاً: ما يعود من JSON هو قاموس عادي، وليس فئة بيانات — فالترجمة تسير في اتجاه واحد، ورحلة العودة هي مسؤوليتك البرمجية. ولكن الثغرة التي عجز الفصل السادس والعشرون عن سدها — وهي عدم اكتشاف الأخطاء الإملائية إلا وقت التشغيل — استطاع فاحص الأنواع سدها بالكامل قبل أن يبدأ الكود في العمل أصلاً.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz