الأصناف والكائنات — جمع البيانات والسلوك معاً
الكلمة المفتاحية `class` والدالة `__init__` والمعامل `self`، الطرق (methods)، التنسيق النصي عبر `__repr__`، المقارنة باستخدام `__eq__`، سمات الصنف مقابل سمات الكائن، ومتى نتجنب استخدام الأصناف.
كيف تريد أن تتعلم؟
المشكلة التي نقوم بحلها
حتى الآن، اعتدنا على الاحتفاظ بالخصائص المتعددة لكيان واحد داخل قاموس (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'يجب أن نقول الأمر بصدق: الصنف لا يكتشف هذا الخطأ مبكراً. فالخطأ AttributeError، مثل KeyError تماماً، لا يظهر إلا عندما يُنفَّذ السطر. وإذا كان مخفياً داخل شرط، فقد يظهر هو أيضاً لأول مرة بعد شهر.
إذن أين الفائدة؟ في ثلاثة مواضع.
- رسالة الخطأ تقول أكثر. فهي تذكر اسم الصنف —
'OrderLine' object— فتعرف على أي نوع من الكائنات بُحث عن الاسم الخاطئ. - ما يجب أن يكون موجوداً مكتوب في مكان واحد. قراءة
__init__تُظهر السمات التي يملكها كلOrderLine. أما القاموس فلا توجد فيه قائمة كهذه. - المحرر (editor) يستطيع المساعدة. يقرأ المحرر الصنف ويقترح أسماء السمات بعد أن تكتب
line.، وكثير من المحررات تضع خطاً تحت الاسم غير المعروف — قبل تشغيل الكود أصلاً. أما مع مفاتيح القاموس فلا يستطيع المحرر ذلك عادة.
وتذكر الحد أيضاً: الخطأ الإملائي لا يُكتشف عند الكتابة. فكما أن order["nmae"] = 5 ينشئ مفتاحاً جديداً بصمت، فإن line.nmae = 5 ينشئ سمة جديدة بصمت.
ويمكنك الاطلاع على محتويات الكائن الداخلية مباشرة:
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 0x79b769f03830>الرقم الظاهر في النهاية هو عنوان في الذاكرة، ويتغير مع كل تشغيل للبرنامج — سترى رقماً مختلفاً على شاشتك. وهو لا يقدم أي فائدة تُذكر أثناء تنقيح الأخطاء.
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 يحيط النصوص بعلامات اقتباس واضحة — لنفس السبب الذي رأيناه في الفصل 24.
مقارنة التساوي — السلوك الافتراضي ليس ما تريده
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 يعني "الكائن ذاته عينه" — وهو التمييز الذي رأيناه في الفصل 21، مطبقاً هنا على نوع من صنعك.
تنبيه مهم: كتابة الطريقة __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سلّتان منفصلتان، لكن القائمة واحدة.
هل تذكر هذا؟ إنه فخ القيم الافتراضية القابلة للتعديل من الفصل 21، لكن في مكان آخر. السطر 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 على مستوى الصنف، وهذا صحيح — فهو واحد لجميع السلال، ولا ننوي تغييره. لكن لاحظ أن بايثون لا يمنعك من تغييره: كتابة Basket.TAX_RATE = 0.2 لا تُطلق أي خطأ. الاسم المكتوب بالأحرف الكبيرة كلها مجرد عُرف يقول للقارئ "عامل هذا كثابت ولا تغيّره". القاعدة في سطر واحد: الثوابت توضع على مستوى الصنف، وكل ما هو متغير وقابل للتعديل يُنشأ داخل __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__ داخل الصنف.
الخطوة ٤ من ٦ — التوقع
تحقّق من فهمك
استُدعي shout() دون أي وسيط (argument). ماذا يحدث؟
class OrderLine:
def __init__(self, name):
self.name = name
def shout():
return "hello"
line = OrderLine("pen")
print(line.shout())- Aخطأ `TypeError` — يأخذ واحداً، وأُعطي صفراً
- Bخطأ `TypeError` — يأخذ صفراً، وأُعطي واحداً
- Cخطأ `AttributeError`
- Dيُطبع `hello`
سلّتان منفصلتان، في كل منهما عنصر واحد. ماذا يُطبع؟
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'] []
- B['pen'] ['pen', 'bag']
- C['pen'] ['bag']
- D['pen', 'bag'] ['pen', 'bag']
الكائنان (objects) يحملان الاسم نفسه. ماذا يُطبع؟
class OrderLine:
def __init__(self, name):
self.name = name
a = OrderLine("pen")
b = OrderLine("pen")
print(a == b)
print(a == a)- AFalse True
- Bخطأ `TypeError`
- CFalse False
- DTrue True
الإجابة تتطلب حساباً
سجّل الدخول للتحقق من إجاباتك
الأسئلة موجودة أعلاه، والتفكير في إجاباتها ذهنياً هو الجزء المهم. سجّل الدخول لرؤية الإجابات والتفسيرات والتلميحات ذات المستويات الثلاثة.
دورك الآن
اكتب صنفين:
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__)— ماذا تجد بداخله؟
التجربة الأخيرة مهمة للغاية لأنها توضح حدود الصنف في بايثون. الاسم الخاطئ يُطلق خطأ عند القراءة، لا عند الكتابة — فإضافة سمة جديدة مسموحة دائماً في بايثون. وفي الفصل القادم، سنتعلم كيف تساعدنا تلميحات الأنواع وأدوات الفحص الثابت في سد هذه الثغرة أيضاً.
الخطوة ٦ من ٦
التحدي — اختبار الفصل
عشرة أسئلة متدرجة من السهل إلى الصعب. الأسئلة الأخيرة صعبة عن قصد.
سجّل الدخول لأداء الاختبار