الأصناف والكائنات — جمع البيانات والسلوك معاً
الكلمة المفتاحية `class` والدالة `__init__` والمعامل `self`، التوابع، التنسيق النصي عبر `__repr__`، المقارنة باستخدام `__eq__`، سمات الصنف مقابل سمات الكائن، ومتى نتجنب استخدام الأصناف.
- 1المشكلة
- 2الفهم
- 3أمثلة محلولة
- 4التوقع
- 5التطبيق
- 6التحدي
المشكلة التي نقوم بحلها
حتى الآن، اعتدنا على الاحتفاظ بالخصائص المتعددة لكيان واحد داخل قاموس (Dictionary).
order = {"name": "pen", "price": 15.0, "quantity": 3}
print(order["nmae"])KeyError: 'nmae'تم التقاط الخطأ، وهذا أمر جيد — ولكن فقط لحظة تشغيل ذلك السطر بالتحديد. فإذا كان السطر مخفياً داخل تفرع شرطي، فقد لا يظهر الخطأ إلا بعد شهر كامل من تشغيل الكود في بيئة العمل!
والمشكلة لا تقتصر على الأخطاء الإملائية فحسب. إذ يمكن وضع أي مفتاح داخل القاموس، ولا يوجد في أي مكان في الكود ما يحدد صراحة الحقول التي ينبغي أن يحتوي عليها عنصر الطلب. فالدالة التي تحسب الإجمالي موجودة في مكان آخر، ولا أحد يتحقق من أن القاموس يحتوي على جميع الحقول المطلوبة قبل استدعائها. البيانات في مكان، والعمليات التي تُجرى عليها في مكان آخر، والرابط بينهما لا يوجد إلا في عقلك أنت.
class OrderLine:
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
def total(self):
return round(self.price * self.quantity, 2)
line = OrderLine("pen", 15.0, 3)
print(line.name)
print(line.total())pen
45.0الصنف (Class) هو بمثابة قالب أو نموذج مجسم: يحدد بوضوح ما يحتويه هذا النوع من الكيانات، وما هي العمليات والتصرفات الممكنة معه.
في نهاية هذا الدرس ستكون قادراً على
- كتابة
classوشرح دور كل من__init__والمعاملself - كتابة التوابع (Methods) وشرح الفارق بينها وبين الدوال العادية
- منح الكائن شكلاً نصياً قابلاً للطباعة باستخدام
__repr__ - التمييز بين سمات الصنف (Class attributes) وسمات النسخة (Instance attributes) وتجنب فخ شهير
- جعل كائنين قابلين للمقارنة باستخدام
__eq__ - معرفة الحالات التي يكون فيها إنشاء الصنف غير مرغوب فيه
المتطلبات السابقة: ملفات JSON و CSV.
ما هو المعامل self في حقيقته؟
المعامل self ليس كلمة سحرية — بل هو مجرد المعامل الأول في تعريف التابع، وما يستقر بداخله تلقائياً هو الكائن نفسه الذي تم استدعاء التابع من خلاله.
كتابة line.total() تعني لبايثون: "شغّل التابع total التابع للصنف OrderLine، ومرر له الكائن line في مكان المعامل self". ولهذا السبب فإن كتابة self.price بالداخل تعطي سعر ذلك العنصر المحدد تحديداً.
وإن نسيت كتابة self، فستقرأ رسالة خطأ تبدو غريبة في البداية:
class OrderLine:
def __init__(self, name):
self.name = name
def shout():
return "hello"
line = OrderLine("pen")
print(line.shout())TypeError: OrderLine.shout() takes 0 positional arguments but 1 was given"أُعطي وسيط واحد" — مع أن الأقواس كانت فارغة تماماً! ما تم تمريره خلسة في الواقع هو الكائن line نفسه تلقائياً، والتابع shout لم يترك مكاناً لاستقباله.
أما التابع __init__ فهو تابع عادي أيضاً لكنه يحمل اسماً خاصاً: يستدعيه بايثون فور إنشاء الكائن مباشرة. وبداخله، تقوم السطور مثل self.name = name بتعيين السمات (Attributes) للكائن الجديد.
اكتشاف الخطأ الإملائي فوراً
class OrderLine:
def __init__(self, name):
self.name = name
line = OrderLine("pen")
print(line.nmae)AttributeError: 'OrderLine' object has no attribute 'nmae'الفرق بين هذا وبين KeyError في القواميس دقيق ولكنه جوهري: يمكن لأي مفتاح أن يدخل في القاموس، لذا فإن كتابة order["nmae"] = 5 لم تكن لتطلق أي تحذير — بل كانت ستنشئ مفتاحاً جديداً بصمت. ومع أنه يمكن إضافة سمات للكائنات أيضاً، إلا أن قراءة __init__ توضح بصرامة ما يُفترض وجوده هناك.
ويمكنك الاطلاع على محتويات الكائن الداخلية مباشرة:
class OrderLine:
def __init__(self, name):
self.name = name
line = OrderLine("pen")
print(type(line))
print(isinstance(line, OrderLine))
print(line.__dict__)<class '__main__.OrderLine'>
True
{'name': 'pen'}توضح الخاصية __dict__ أن هناك قاموساً حقيقياً يعيش في جوف الكائن. فالصنف لم يلغِ القاموس — بل منحه اسماً وهوية وبنية واضحة.
التابع __repr__ — ما يظهر عند الطباعة
بدون كتابة التابع __repr__:
class OrderLine:
def __init__(self, name):
self.name = name
print(OrderLine("pen"))<__main__.OrderLine object at 0x00000213E88A9DF0>الرقم الظاهر في النهاية هو عنوان في الذاكرة، ويتغير مع كل تشغيل للبرنامج. وهو لا يقدم أي فائدة تُذكر أثناء تنقيح الأخطاء.
class OrderLine:
def __init__(self, name, price):
self.name = name
self.price = price
def __repr__(self):
return f"OrderLine(name={self.name!r}, price={self.price!r})"
line = OrderLine("pen", 15.0)
print(line)
print([line, line])OrderLine(name='pen', price=15.0)
[OrderLine(name='pen', price=15.0), OrderLine(name='pen', price=15.0)]السطر الثاني يكشف الفائدة الكبرى: فهو يعمل داخل القوائم أيضاً. فعند طباعة قائمة، يستدعي بايثون التابع __repr__ لكل عنصر بداخلها، ولا يستدعي __str__ أبداً. ولهذا السبب تحديداً، يُعد __repr__ هو التابع الأول والأهم في الكتابة.
والعرف المتبع هو إرجاع نص يشبه كود بايثون الذي استخدم لإنشاء الكائن. واستخدام !r يحيط النصوص بعلامات اقتباس واضحة — لنفس السبب الذي بيّناه في الفصل الرابع والعشرين.
مقارنة التساوي — السلوك الافتراضي ليس ما تريده
class OrderLine:
def __init__(self, name):
self.name = name
a = OrderLine("pen")
b = OrderLine("pen")
print(a == b)
print(a == a)False
Trueالكائنان يحملان البيانات ذاتها، ومع ذلك كانت نتيجة == خطأ (False)! لأن السلوك الافتراضي للتساوي في بايثون هو مطابقة الهوية (Identity) — حيث يتصرف == تماماً مثل المعامل is، متسائلاً: "هل هذا هو نفس الكائن تماماً في الذاكرة؟".
وتغيير هذا السلوك يتطلب منا تحديد معنى التساوي صراحة:
class OrderLine:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
a = OrderLine("pen")
b = OrderLine("pen")
print(a == b)
print(a is b)True
Falseالآن أصبح == يعني "متشابهان في الخصائص" وأصبح is يعني "الكائن ذاته عينه" — وهو التمييز الذي تعلمناه في الفصل الثاني عشر، مطبقاً هنا على كائناتك الخاصة.
تنبيه مهم: كتابة التابع __eq__ تجعل الكائن غير قابل للتجزئة (Unhashable) افتراضياً، فلا يمكن وضعه داخل مجموعة (Set) أو استخدامه كمفتاح لقاموس. وسيقدم لنا الفصل القادم مفهوم dataclass الذي يتولى هذين الأمرين في سطر واحد.
سمات الصنف مقابل سمات النسخة
class Basket:
TAX_RATE = 0.15
items = []
def add(self, name):
self.items.append(name)
a = Basket()
b = Basket()
a.add("pen")
b.add("bag")
print(a.items)
print(b.items)
print(a.items is b.items)['pen', 'bag']
['pen', 'bag']
Trueسلّتان منفصلتان، وقائمة واحدة مشتركة!
هل تذكر هذا؟ إنه فخ القيم الافتراضية القابلة للتعديل من الفصل الحادي والعشرين في ثوب جديد. السطر items = [] الموجود مباشرة تحت سطر class يُنفذ مرة واحدة فقط، عند إنشاء الصنف نفسه في الذاكرة — وليس عند إنشاء كل كائن. لذا فإن القائمة تنتمي للصنف ككل، وتشترك فيها جميع الكائنات المنشأة منه.
والحل يحمل نفس البنية أيضاً: كل ما يخص الكائن المنفرد، يجب بناؤه داخل __init__.
class Basket:
TAX_RATE = 0.15
def __init__(self):
self.items = []
def add(self, name):
self.items.append(name)
a = Basket()
b = Basket()
a.add("pen")
b.add("bag")
print(a.items)
print(b.items)
print(a.TAX_RATE, b.TAX_RATE)['pen']
['bag']
0.15 0.15بقي الثابت TAX_RATE على مستوى الصنف، وهو تصرف سليم تماماً — فقيمة الضريبة ثابتة لا تتغير وهي متطابقة لجميع السلال. القاعدة في سطر واحد: الثوابت توضع على مستوى الصنف، وكل ما هو متغير وقابل للتعديل يُنشأ داخل __init__.
متى نتجنب كتابة صنف؟
بعد تعلم الأصناف، يميل المبرمج في البداية إلى تحويل كل فكرة إلى صنف. إليك بعض المؤشرات على أن الصنف زائد عن الحاجة:
الصنف يحتوي على تابع واحد فقط بجانب __init__. هذه دالة عادية تم شطرها إلى نصفين دون أي مبرر.
الصنف لا يحتوي على أي توابع إطلاقاً، بل بيانات مجردة فقط. مفهوم dataclass في الفصل القادم صُمم تحديداً لهذا الغرض بأسطر أقل بكثير.
الكائن لا يتغير ويحتوي على قيمة واحدة. توبل أو قيمة عادية تكفي تماماً.
يستحق الصنف مكانه عندما تنتمي الحالة والسلوك لبعضهما بحق: بيانات تتغير، وبعض العمليات التي تعمل دائماً على تلك البيانات.
مثال متكامل
"""An order built from two small classes."""
TAX_RATE = 0.15
class OrderLine:
"""One item on an order: what it is, what it costs, how many."""
def __init__(self, name, price, quantity):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.name = name
self.price = price
self.quantity = quantity
def total(self):
"""Price for this line, tax included."""
return round(self.price * self.quantity * (1 + TAX_RATE), 2)
def __repr__(self):
return f"OrderLine({self.name!r}, {self.price!r}, {self.quantity!r})"
class Order:
"""A named collection of lines, and the questions you ask about it."""
def __init__(self, customer):
self.customer = customer
self.lines = []
def add(self, name, price, quantity):
self.lines.append(OrderLine(name, price, quantity))
return self
def total(self):
return round(sum(line.total() for line in self.lines), 2)
def largest(self):
"""The most expensive line, or None for an empty order."""
if not self.lines:
return None
return max(self.lines, key=lambda line: line.total())
def __repr__(self):
return f"Order({self.customer!r}, {len(self.lines)} lines)"
def main():
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)
print("largest:", order.largest())
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
Order('rafi', 3 lines)
largest: OrderLine('bag', 850.0, 1)
rejected: quantity must be at least 1: 0أربعة أمور جديرة بالدراسة:
__init__ يتحقق من صحة البيانات. مع الشرط quantity < 1، لا يتم إنشاء الكائن المعيب على الإطلاق. وهذه هي أعظم ميزة يقدمها الصنف: مجرد امتلاكك لكائن OrderLine يعني بالضرورة أن كميته تساوي واحداً على الأقل — فلا يحتاج أي جزء آخر في البرنامج إلى إعادة التحقق من ذلك. والقاموس لا يمكنه تقديم هذا الضمان.
الصنف Order لا يعلم كيفية حساب سعر السطر الفردي. إنه يستدعي ببساطة line.total() فحسب. فكيفية تطبيق الضريبة وحساب السعر هي شأن خاص بـ OrderLine، وأي تعديل مستقبلي في حسابها سيتم في مكان واحد فقط.
كلا الصنفين يمتلكان التابع __repr__، ومخرجات largest() توضح قيمته الكبيرة. فبدونه، كان السطر الأخير سيطبع عنوان ذاكرة غامضاً لا يقدم أي معنى.
التابع add يعيد الكائن نفسه (return self). هذا يتيح ربط الاستدعاءات متتالية مثل order.add(...).add(...). لم نستخدمها هنا عمداً — فامتلاك الميزة شيء والإفراط في استخدامها في كل سطر شيء آخر.
حالات الخطأ الشائعة
TypeError: ... takes 0 positional arguments but 1 was given نسيت إضافة self كمعامل أول في تعريف التابع.
AttributeError: 'X' object has no attribute 'y' خطأ إملائي في اسم السمة، أو أن self.y لم تُعيّن أصلاً داخل __init__. يمكنك طباعة print(obj.__dict__) لترى ما هو موجود فعلياً داخل الكائن.
TypeError: X() takes no arguments لم تقم بتعريف دالة __init__، أو أخطأت في هجاء اسمها (مثل كتابة __int__، وتذكر وجود شرطتين سفليتين في كل جانب).
جميع الكائنات تشترك في قائمة واحدة القائمة معرفة على مستوى الصنف مباشرة. انقلها إلى داخل دالة __init__.
كائنان يحملان نفس البيانات ومع ذلك == تعطي False المقارنة الافتراضية تقارن الهوية في الذاكرة. اكتب التابع __eq__ — أو استخدم dataclass كما سنتعلم في الفصل القادم.
بعد كتابة __eq__ أصبح وضع الكائن في مجموعة (Set) مستحيلاً كتابة __eq__ تحذف دالة التجزئة (Hash) الافتراضية. استخدام dataclass(frozen=True) يوفر لك الميزتين معاً.
طباعة الكائن تظهر <__main__.X object at 0x...> لم تقم بتعريف التابع __repr__ داخل الصنف.
Step 4 of 6 — Predict
Check your understanding
shout() is called with no arguments. What happens?
class OrderLine:
def __init__(self, name):
self.name = name
def shout():
return "hello"
line = OrderLine("pen")
print(line.shout())- AA `TypeError` — it takes zero, and one was given
- B`hello` prints
- CA `TypeError` — it takes one, and zero were given
- DAn `AttributeError`
Two separate baskets, one item each. What is printed?
class Basket:
items = []
def add(self, name):
self.items.append(name)
a = Basket()
b = Basket()
a.add("pen")
b.add("bag")
print(a.items)
print(b.items)- A['pen', 'bag'] ['pen', 'bag']
- B['pen'] ['bag']
- C['pen'] ['pen', 'bag']
- D['pen', 'bag'] []
The two objects hold the same name. What is printed?
class OrderLine:
def __init__(self, name):
self.name = name
a = OrderLine("pen")
b = OrderLine("pen")
print(a == b)
print(a == a)- AFalse True
- BTrue True
- CFalse False
- DA `TypeError`
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 — يُبنى من title و author و pages. ويطلق استثناء ValueError إذا كانت الصفحات أقل من 1. زوّده بتابع __repr__، وتابع __eq__ يعتبر كتابين متساويين إذا تطابق العنوان واسم الكاتب.
Shelf — يجمع الكتب، ويقدم التوابع: add(book)، و total_pages()، و longest()، و by_author(name) التي ترجع قائمة بالكتب العائدة لذلك الكاتب.
ثم أجرِ هذه التجارب الخمس:
- انقل القائمة
books = []فيShelfخارج دالة__init__وضعها تحت سطرclassمباشرة. أنشئ رفين منفصلين، وأضف كتاباً إلى أحدهما، ثم اطبع محتوى الرف الآخر. ماذا تلاحظ؟ - احذف التابع
__repr__واطبع قائمة كتب. إلى أي مدى أصبح تنقيح الأخطاء أصعب؟ - احذف
__eq__وقارن بين كتابين لهما نفس العنوان والمؤلف. - احذف المعامل
selfمن أحد التوابع ثم استدعه. كم وسيطاً تقول رسالة الخطأ إنه تم تمريره؟ - أنشئ كائناً من
Bookواكتبbook.pagse = 100(بخطأ إملائي). هل يظهر خطأ؟ ثم اطبعprint(book.__dict__)— ماذا تجد بداخله؟
التجربة الأخيرة مهمة للغاية لأنها توضح حدود الصنف في بايثون. الأخطاء الإملائية تُكتشف عند القراءة، لا عند الكتابة — فإضافة سمة جديدة مسموحة دائماً في بايثون. وفي الفصل القادم، سنتعلم كيف تساعدنا تلميحات الأنواع وأدوات الفحص الثابت في سد هذه الثغرة أيضاً.
Step 6 of 6
التحدي — the chapter quiz
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
Sign in to take the quiz