الفصل 26

الأصناف والكائنات — جمع البيانات والسلوك معاً

الكلمة المفتاحية `class` والدالة `__init__` والمعامل `self`، التوابع، التنسيق النصي عبر `__repr__`، المقارنة باستخدام `__eq__`، سمات الصنف مقابل سمات الكائن، ومتى نتجنب استخدام الأصناف.

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

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

حتى الآن، اعتدنا على الاحتفاظ بالخصائص المتعددة لكيان واحد داخل قاموس (Dictionary).

python
order = {"name": "pen", "price": 15.0, "quantity": 3}

print(order["nmae"])
text
KeyError: 'nmae'

تم التقاط الخطأ، وهذا أمر جيد — ولكن فقط لحظة تشغيل ذلك السطر بالتحديد. فإذا كان السطر مخفياً داخل تفرع شرطي، فقد لا يظهر الخطأ إلا بعد شهر كامل من تشغيل الكود في بيئة العمل!

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

python
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())
text
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، فستقرأ رسالة خطأ تبدو غريبة في البداية:

python
class OrderLine:
    def __init__(self, name):
        self.name = name

    def shout():
        return "hello"


line = OrderLine("pen")
print(line.shout())
text
TypeError: OrderLine.shout() takes 0 positional arguments but 1 was given

"أُعطي وسيط واحد" — مع أن الأقواس كانت فارغة تماماً! ما تم تمريره خلسة في الواقع هو الكائن line نفسه تلقائياً، والتابع shout لم يترك مكاناً لاستقباله.

أما التابع __init__ فهو تابع عادي أيضاً لكنه يحمل اسماً خاصاً: يستدعيه بايثون فور إنشاء الكائن مباشرة. وبداخله، تقوم السطور مثل self.name = name بتعيين السمات (Attributes) للكائن الجديد.

اكتشاف الخطأ الإملائي فوراً

python
class OrderLine:
    def __init__(self, name):
        self.name = name


line = OrderLine("pen")
print(line.nmae)
text
AttributeError: 'OrderLine' object has no attribute 'nmae'

الفرق بين هذا وبين KeyError في القواميس دقيق ولكنه جوهري: يمكن لأي مفتاح أن يدخل في القاموس، لذا فإن كتابة order["nmae"] = 5 لم تكن لتطلق أي تحذير — بل كانت ستنشئ مفتاحاً جديداً بصمت. ومع أنه يمكن إضافة سمات للكائنات أيضاً، إلا أن قراءة __init__ توضح بصرامة ما يُفترض وجوده هناك.

ويمكنك الاطلاع على محتويات الكائن الداخلية مباشرة:

python
class OrderLine:
    def __init__(self, name):
        self.name = name


line = OrderLine("pen")

print(type(line))
print(isinstance(line, OrderLine))
print(line.__dict__)
text
<class '__main__.OrderLine'>
True
{'name': 'pen'}

توضح الخاصية __dict__ أن هناك قاموساً حقيقياً يعيش في جوف الكائن. فالصنف لم يلغِ القاموس — بل منحه اسماً وهوية وبنية واضحة.

التابع __repr__ — ما يظهر عند الطباعة

بدون كتابة التابع __repr__:

python
class OrderLine:
    def __init__(self, name):
        self.name = name


print(OrderLine("pen"))
text
<__main__.OrderLine object at 0x00000213E88A9DF0>

الرقم الظاهر في النهاية هو عنوان في الذاكرة، ويتغير مع كل تشغيل للبرنامج. وهو لا يقدم أي فائدة تُذكر أثناء تنقيح الأخطاء.

python
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])
text
OrderLine(name='pen', price=15.0)
[OrderLine(name='pen', price=15.0), OrderLine(name='pen', price=15.0)]

السطر الثاني يكشف الفائدة الكبرى: فهو يعمل داخل القوائم أيضاً. فعند طباعة قائمة، يستدعي بايثون التابع __repr__ لكل عنصر بداخلها، ولا يستدعي __str__ أبداً. ولهذا السبب تحديداً، يُعد __repr__ هو التابع الأول والأهم في الكتابة.

والعرف المتبع هو إرجاع نص يشبه كود بايثون الذي استخدم لإنشاء الكائن. واستخدام !r يحيط النصوص بعلامات اقتباس واضحة — لنفس السبب الذي بيّناه في الفصل الرابع والعشرين.

مقارنة التساوي — السلوك الافتراضي ليس ما تريده

python
class OrderLine:
    def __init__(self, name):
        self.name = name


a = OrderLine("pen")
b = OrderLine("pen")

print(a == b)
print(a == a)
text
False
True

الكائنان يحملان البيانات ذاتها، ومع ذلك كانت نتيجة == خطأ (False)! لأن السلوك الافتراضي للتساوي في بايثون هو مطابقة الهوية (Identity) — حيث يتصرف == تماماً مثل المعامل is، متسائلاً: "هل هذا هو نفس الكائن تماماً في الذاكرة؟".

وتغيير هذا السلوك يتطلب منا تحديد معنى التساوي صراحة:

python
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)
text
True
False

الآن أصبح == يعني "متشابهان في الخصائص" وأصبح is يعني "الكائن ذاته عينه" — وهو التمييز الذي تعلمناه في الفصل الثاني عشر، مطبقاً هنا على كائناتك الخاصة.

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

سمات الصنف مقابل سمات النسخة

python
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)
text
['pen', 'bag']
['pen', 'bag']
True

سلّتان منفصلتان، وقائمة واحدة مشتركة!

هل تذكر هذا؟ إنه فخ القيم الافتراضية القابلة للتعديل من الفصل الحادي والعشرين في ثوب جديد. السطر items = [] الموجود مباشرة تحت سطر class يُنفذ مرة واحدة فقط، عند إنشاء الصنف نفسه في الذاكرة — وليس عند إنشاء كل كائن. لذا فإن القائمة تنتمي للصنف ككل، وتشترك فيها جميع الكائنات المنشأة منه.

والحل يحمل نفس البنية أيضاً: كل ما يخص الكائن المنفرد، يجب بناؤه داخل __init__.

python
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)
text
['pen']
['bag']
0.15 0.15

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

متى نتجنب كتابة صنف؟

بعد تعلم الأصناف، يميل المبرمج في البداية إلى تحويل كل فكرة إلى صنف. إليك بعض المؤشرات على أن الصنف زائد عن الحاجة:

الصنف يحتوي على تابع واحد فقط بجانب __init__. هذه دالة عادية تم شطرها إلى نصفين دون أي مبرر.

الصنف لا يحتوي على أي توابع إطلاقاً، بل بيانات مجردة فقط. مفهوم dataclass في الفصل القادم صُمم تحديداً لهذا الغرض بأسطر أقل بكثير.

الكائن لا يتغير ويحتوي على قيمة واحدة. توبل أو قيمة عادية تكفي تماماً.

يستحق الصنف مكانه عندما تنتمي الحالة والسلوك لبعضهما بحق: بيانات تتغير، وبعض العمليات التي تعمل دائماً على تلك البيانات.


مثال متكامل

python
"""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()
text
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__ داخل الصنف.