dataclass وتلميحات الأنواع
ما يكتبه المزخرف `@dataclass` نيابة عنك، لماذا نحتاج `field(default_factory=...)`، ميزات `frozen=True`، تلميحات الأنواع في الدوال، ولماذا لا تفعل التلميحات شيئاً أثناء تشغيل البرنامج بينما هي كل شيء لأداة فحص الأنواع.
كيف تريد أن تتعلم؟
المشكلة التي نقوم بحلها
إذا كتبنا الصنف 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ثلاثة عشر سطراً، وليس فيها قرار حقيقي واحد. اسم كل حقل (field) مكتوب مرة بعد مرة — كمعامل في __init__، وفي سطر self.، وفي __repr__، وفي __eq__.
إضافة حقل رابع تعني إضافته في كل هذه الأماكن. وإذا نسيت واحداً منها فلن يظهر أي خطأ — بل ستبقى __repr__ أو __eq__ ناقصة بصمت.
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 - شرح أن التلميحات لا تفعل شيئاً أثناء تشغيل البرنامج، لكنها تفعل الكثير لأداة فحص الأنواع
- إرسال
dataclassإلى JSON باستخدامasdict
ما تحتاج معرفته أولاً: الأصناف.
ما الذي تكتبه @dataclass نيابة عنك
مقابل الأسطر السبعة السابقة تحصل على:
- دالة
__init__تأتي معاملاتها بترتيب الحقول نفسه - طريقة
__repr__تعرض كل حقل مع اسمه - طريقة
__eq__تعتبر كائنين متساويين عندما تتطابق كل حقولهما
ثلاث مشكلات من الفصل السابق — <object at 0x...>، وa == b التي تعطي False، وتكرار الأسماء نفسها مرة بعد مرة — تُحلّ معاً.
وتُكتب الطرق (methods) كما كانت تماماً:
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ويمكن إعطاء قيم افتراضية أيضاً. والقاعدة هي نفسها في الفصل 20: الحقول ذات القيم الافتراضية تأتي في النهاية.
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، ومع ذلك دخلت سلسلة نصية (string) دون أي مشكلة. بايثون لا يتحقق من التلميحات. إنه يخزّنها فقط؛ واستخدامها مهمة جهة أخرى.
ظهر الخطأ بعد ذلك بكثير، عندما حاول الكود إجراء حساب بتلك القيمة. وفي الطريق طُبعت القيمة — وفي برنامج حقيقي ربما سُجّلت في السجل أيضاً، أو كُتبت في ملف.
وهنا يأتي دور أداة فحص الأنواع (type checker). mypy برنامج منفصل يقرأ الكود دون تشغيله. احفظ الكود السابق باسم main.py وشغّل mypy main.py:
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'>}القيمة الافتراضية القابلة للتعديل — للمرة الثالثة
في الفصل 21 كانت في الوسيط الافتراضي لدالة. وفي الفصل 26 كانت في سمة على مستوى الصنف. والآن في dataclass — وهذه المرة يوقفها بايثون بنفسه:
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 = [] من الفصل 21 نفسه، في سطر واحد ودون فرصة للخطأ.
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'حدث هنا أمران معاً، وهما مرتبطان.
لم يعد الكائن قابلاً للتغيير. توقّف p.x = 5 بخطأ — تماماً كما في الصف (tuple).
وأصبح الكائن قابلاً للوضع في مجموعة (set). تذكّر المشكلة الأخيرة في الفصل السابق: كتابة __eq__ تُزيل التجزئة (hash). هنا أصبحت النقطتان “المتساويتان” عنصراً واحداً في المجموعة، دون أي خطأ.
بدون frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'السبب يأتي من الفصل 16: ما يمكن أن يتغير ليس آمناً للتجزئة. فلو تغيّر بعد وضعه في مجموعة، لما أمكن العثور عليه بعد ذلك.
frozen=True تقول “هذا لن يتغير”، ولذلك يقبل بايثون أن يعطيه تجزئة.
القاعدة بسيطة: جمّد أي كائن لا يُفترض أن يتغير بعد إنشائه. تحصل بذلك على ضمان، ومعه إمكانية استخدامه في مجموعة أو كمفتاح في قاموس (dictionary).
التلميحات على الدوال
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 resultlist[str] وdict[str, float] وtuple[str, float] — بهذه الطريقة يمكنك أن تقول أيضاً ما بداخلها، وهذا ما يجعل التلميحات مفيدة.
كتابة list وحدها تقول فقط “قائمة (list)”. أما list[tuple[str, float]] فتقول ما الذي ستحصل عليه الحلقة في كل دورة.
أعطِها شيئاً خاطئاً — لنفترض أن السطر 9 من main.py هو totals("pen") — فيقول mypy:
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 — لنفترض أن السطر 8 أصبح print(find(["pen"], "pen").upper()) — فيقول mypy:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]هذا في الحقيقة هو AttributeError: 'NoneType' object has no attribute 'upper' — من أكثر الأخطاء ظهوراً في العمل الفعلي. لكنه هنا يُكتشف قبل تشغيل البرنامج، ودون الحاجة إلى المدخل الذي يُنتج None.
من dataclass إلى 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تذكّر القائمة في الفصل 25: الكائنات التي تصنعها بنفسك لا تدخل JSON مباشرة. asdict هي الجسر. وهي تحوّل أيضاً كائنات dataclass الموجودة بداخلها إلى قواميس.
مثال متكامل
الطلب نفسه من الفصل السابق، مبنياً هذه المرة بـdataclass والتلميحات.
"""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 يعني أن كميته واحد على الأقل.
أحدهما frozen والآخر لا — وهذا مقصود. لا يُفترض أن يتغير السطر بعد إنشائه. لكن الطلب تُضاف إليه أسطر باستمرار. والسؤال في كل مرة واحد: هل يتغير هذا الشيء بعد إنشائه؟
الرقم 1 هو الدليل على أن التجميد نجح. أصبح كائنا OrderLine المتطابقان عنصراً واحداً في المجموعة.
لو شغّلنا السطر نفسه مع OrderLine من الفصل السابق لطُبع 2. ولن يظهر أي خطأ: ذلك الصنف لم تكن فيه __eq__، فبقيت له التجزئة الافتراضية، لكن كائنين منفصلين لا يتساويان أبداً — فاحتفظت المجموعة بالاثنين.
list[OrderLine] ليست زينة. منها تعرف أداة فحص الأنواع أن line.total() صحيحة. ولو كتبت line.totl() لاكتشفتها — دون تشغيل الكود.
"Order" بين علامتي اقتباس. عندما يُكتب الاسم Order في توقيع الطريقة add، لا يكون تعريف الصنف قد اكتمل بعد — فالاسم لم يوجد بعد. وعندما يكون بين علامتي اقتباس، لا يبحث عنه بايثون في تلك اللحظة.
في بايثون 3.12، وهو الإصدار الذي تستخدمه هذه الدورة، هناك طريقتان لاستخدام اسم الصنف نفسه في تلميح داخل ذلك الصنف: ضع الاسم بين علامتي اقتباس، أو اكتب from __future__ import annotations في أعلى الملف.
حالات الخطأ الشائعة
ValueError: mutable default ... use default_factory كُتب = [] أو = {}. استخدم field(default_factory=list).
TypeError: non-default argument follows default argument وُضع حقل له قيمة افتراضية قبل حقل إلزامي. قاعدة الفصل 20 نفسها: الحقول الإلزامية أولاً.
dataclasses.FrozenInstanceError محاولة تعيين قيمة في كائن frozen=True. إذا احتجت إلى تغيير، فإن dataclasses.replace(obj, x=5) تعطي كائناً جديداً.
TypeError: unhashable type لا يمكن وضع dataclass في مجموعة دون frozen=True.
دخلت قيمة من نوع خاطئ ولم يحدث شيء هذا متوقع — التلميحات لا تفعل شيئاً أثناء تشغيل البرنامج. شغّل mypy.
mypy لا ينظر إلى ملفي أعطه مسار الملف — mypy main.py. وإذا ظهر سيل من الرسائل في المرة الأولى، فابدأ بـmypy --ignore-missing-imports.
TypeError: Object of type X is not JSON serializable أرسل asdict(obj).
NameError — اسم الصنف نفسه داخل الصنف ضع الاسم بين علامتي اقتباس: -> "Order". أو اكتب from __future__ import annotations في أعلى الملف.
الخطوة ٤ من ٦ — التوقع
تحقّق من فهمك
صنف dataclass من سبعة أسطر. ماذا يطبع السطران؟
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- A<__main__.Book object at 0x...> False
- BBook(title='one', pages=300) True
- CBook(title='one', pages=300) False
- DBook('one', 300) True
لم يُنشأ أي كائن، كُتب الصنف فقط. ماذا يحدث؟
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- Aلا شيء؛ ستظهر المشكلة عند إنشاء أول كائن
- Bخطأ `ValueError` — أثناء تعريف الصنف نفسه
- Cخطأ `TypeError`
- Dلا شيء؛ ستتشارك كل النسخ قائمة واحدة
ضُبط frozen=True. ماذا يحدث؟
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- Aتُطبع مجموعة من عنصر واحد، ثم `FrozenInstanceError`
- Bتُطبع مجموعة من عنصرين، ثم `FrozenInstanceError`
- C`TypeError: unhashable type` — عند إنشاء المجموعة
- Dتُطبع مجموعة من عنصر واحد، ثم يصبح `p.x` هو `5`
الإجابة تتطلب حساباً
سجّل الدخول للتحقق من إجاباتك
الأسئلة موجودة أعلاه، والتفكير في إجاباتها ذهنياً هو الجزء المهم. سجّل الدخول لرؤية الإجابات والتفسيرات والتلميحات ذات المستويات الثلاثة.
دورك الآن
أعد كتابة Book وShelf من الفصل 26، هذه المرة باستخدام dataclass.
يكون 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، وأصلح الكود حتى لا تظهر أي رسالة.
ثم ست تجارب:
- أعطِ
pagesسلسلة نصية —Book("one", "rafi", "300"). هل يعمل البرنامج؟ وماذا يقولmypy؟ - استبدل
field(default_factory=list)فيbooksبـ= []. متى يظهر الخطأ — عند إنشاء كائن، أم قبل ذلك؟ - أنشئ كتابين متطابقين من
Bookوضعهما في مجموعة. كم عنصراً يبقى؟ ثم احذفfrozen=Trueوجرّب مرة أخرى. - استخدم نتيجة
longest()مباشرة —shelf.longest().title— مع رف فارغ. ماذا يقول البرنامج، وماذا كانmypyقد قال مسبقاً؟ - نفّذ
asdict(shelf)واكتبه بـjson.dumps، ثم أعده بـjson.loads. هل ما عاد هوShelf؟ - اقرأ سمة باسم مكتوب خطأً —
book.pagse. هل يكتشفهاmypy؟
التجربتان الأخيرتان تُظهران حدود هذا الفصل وقوته معاً. ما يعود من JSON هو قاموس، لا dataclass — الترجمة تسير في اتجاه واحد، وطريق العودة عليك أن تكتبه بنفسك. لكن الثغرة التي لم يستطع الفصل 26 سدّها — الاسم الخاطئ لا يُكتشف إلا عند التشغيل — تسدّها أداة فحص الأنواع قبل تشغيل الكود أصلاً.
الخطوة ٦ من ٦
التحدي — اختبار الفصل
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
سجّل الدخول لأداء الاختبار