الأصناف في Python: البرمجة الكائنية والوراثة وdataclasses بأمثلة عملية
الصنف في Python قالب يصف الحقول والطرائق، والكائن نسخة مبنية منه. يُنشأ بالكلمة class، وتهيئ الحقول الطريقة __init__، ويشير self إلى الكائن الحالي. وتتيح الوراثة إنشاء صنف فرعي يرث ويعدّل السلوك، ويختصر @dataclass كتابة الأصناف التي تحفظ البيانات، وتُفضَّل التركيبة على الوراثة العميقة.
الصنف في Python قالب يصف البيانات والسلوك، والكائن نسخة مبنية منه. فالصنف هو الرسم الهندسي، والكائن هو المنتج المصنوع وفقه. تقوم البرمجة الكائنية في Python على ثلاثة أشياء: الأصناف مع طرائقها، والوراثة، والطرائق الخاصة مثل __init__ و__repr__. وفيما يلي شرح لكل ذلك خطوة بخطوة، على مثال واحد يتكرر في الشرح هو تتبّع المصروفات.
لماذا نحتاج الأصناف إذا كانت القواميس موجودة
يمكن حفظ البيانات في قاموس: {"title": "قهوة", "amount": 250}. المشكلة أن القاموس لا يعرف المفاتيح الصحيحة. فخطأ مطبعي مثل expense.get("amout", 0) لا يسبب أي خطأ، بل تعود القيمة صفراً، فيبدأ التقرير بالكذب بصمت.
أما كائن الصنف فحقوله معلنة مسبقاً. الوصول إلى خاصية غير موجودة يسبب AttributeError فوراً، وPython الحديث يقترح الاسم الصحيح أيضاً. وإضافة إلى ذلك يجمع الصنف البيانات والعمليات عليها في مكان واحد: المصروف يحسب المبلغ مع الضريبة بنفسه، والتقرير يحسب مجموعه بنفسه.
كيف تنشئ صنفاً في Python
يُعرَّف الصنف بالكلمة class، ويُكتب اسمه بأسلوب CamelCase وفق PEP 8. أبسط مثال:
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
coffee = Expense("قهوة", 250)
taxi = Expense("تاكسي", 300)
print(coffee.title, coffee.amount) # قهوة 250
استدعاء Expense("قهوة", 250) ينشئ كائناً جديداً من الصنف. الكائنان coffee وtaxi من النوع نفسه، لكل منهما قيم حقوله الخاصة. ويمكن التحقق من الانتماء بـ isinstance(coffee, Expense) فتعيد True.
المُنشئ __init__: تهيئة الصنف
تسمى __init__ غالباً المُنشئ، والأدق أنها المُهيِّئ. فالكائن نفسه تنشئه الطريقة __new__، أما __init__ فتُستدعى بعدها مباشرة وتوزع الوسائط على الحقول. ولا يُشترط أن تعيد شيئاً (بل يجب أن تعيد None).
ويفيد __init__ في التحقق من المدخلات، حتى لا تنشأ في البرنامج كائنات خاطئة منذ البداية:
class Expense:
def __init__(self, title, amount):
if amount < 0:
raise ValueError("المبلغ لا يمكن أن يكون سالباً")
self.title = title
self.amount = amount
ما هو self في Python
self مرجع إلى الكائن نفسه الذي تعمل عليه الطريقة في تلك اللحظة. عندما تكتب coffee.with_tax() ينفّذ Python فعلياً Expense.with_tax(coffee)، ويمرر الكائن كأول وسيطة. ولهذا يأتي self أولاً في تعريف الطريقة، ولا يُكتب عند الاستدعاء.
self ليس كلمة مفتاحية، بل اصطلاح: من الناحية التقنية يمكن تسمية المعامل بأي اسم، لكن لا أحد يفعل ذلك. ومن الأخطاء الشائعة نسيان self في تعريف الطريقة، فيسقط الاستدعاء بـ TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given.
الطرائق والطرائق الخاصة
الطريقة دالة معرّفة داخل الصنف، ترى حقول الكائن عبر self:
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
def with_tax(self, rate=0.2):
return self.amount * (1 + rate)
def __repr__(self):
return f"Expense({self.title!r}, {self.amount})"
coffee = Expense("قهوة", 250)
print(coffee.with_tax()) # 300.0
print(coffee) # Expense('قهوة', 250)
بدون __repr__ سيظهر الكائن هكذا: <__main__.Expense object at 0x7f...>، وهذا غير مفيد للتصحيح. والطرائق التي تبدأ وتنتهي بشرطتين سفليتين تُسمى الطرائق الخاصة (dunder)، ويستدعيها Python بنفسه: __str__ للعرض المفهوم للمستخدم، و__eq__ للمقارنة بـ ==، و__len__ للدالة len(obj).
@classmethod وstaticmethod
الطريقة العادية تعمل مع الكائن. أما طريقة الصنف، المزينة بـ @classmethod، فتستقبل الصنف نفسه (cls) كأول وسيطة، وتُستخدم كثيراً كمُنشئ بديل. والطريقة الساكنة @staticmethod لا تستقبل الكائن ولا الصنف، فهي دالة عادية مرتبطة منطقياً بالصنف.
class Expense:
def __init__(self, title, amount):
self.title = title
self.amount = amount
@classmethod
def from_csv(cls, line):
title, amount = line.split(";")
return cls(title, float(amount))
@staticmethod
def is_valid_amount(value):
return value >= 0
e = Expense.from_csv("غداء;520")
print(e.amount) # 520.0
print(Expense.is_valid_amount(-1)) # False
المُدمِّر __del__
يملك Python الطريقة __del__ التي تُستدعى قبل إتلاف الكائن بواسطة جامع المهملات. لا تعتمد عليها لتحرير الموارد، لأن لحظة استدعائها غير مضمونة. استخدم للملفات والاتصالات مدير السياق with أو الطريقة الصريحة close().
خصائص الصنف وخصائص الكائن
الحقول التي تُعطى بـ self.name = ... تخص كائناً محدداً. أما المتغيرات المعلنة مباشرة في جسم الصنف فهي خصائص الصنف، وتشترك فيها كل الكائنات، وهكذا تُحفظ الثوابت عادةً.
class Expense:
CURRENCY = "RUB" # خاصية الصنف، ثابت
def __init__(self, title, amount):
self.title = title # خصائص الكائن
self.amount = amount
a = Expense("قهوة", 250)
b = Expense("شاي", 150)
print(a.CURRENCY, Expense.CURRENCY) # RUB RUB
الفخ الخطير هو الخاصية القابلة للتعديل على مستوى الصنف. إذا كتبت في جسم الصنف rows = [] وأضفت إليها عبر self.rows.append(...)، فستكون القائمة مشتركة بين كل الكائنات. أنشئ الحقول القابلة للتعديل دائماً داخل __init__.
لعرض حقول الكائن استخدم vars(obj) أو obj.__dict__، ولقراءة خاصية باسمها من نص استخدم getattr(obj, "amount").
خصائص محسوبة: @property
الخاصية property طريقة تُقرأ من الخارج كحقل عادي، دون أقواس. وهي مناسبة للقيم المحسوبة: لا داعي لتخزين مجموع التقرير منفصلاً، فلن تنسى تحديثه.
class Report:
def __init__(self):
self.rows = []
def add(self, expense):
self.rows.append(expense)
@property
def total(self):
return sum(e.amount for e in self.rows)
r = Report()
r.add(Expense("قهوة", 250))
r.add(Expense("تاكسي", 300))
print(r.total) # 550
إذا أردت التحكم في الكتابة فأضف مُعيِّناً (setter) بـ @total.setter. وبدونه يسبب الإسناد r.total = 0 الخطأ AttributeError.
وبخصوص التغليف: لا توجد حقول خاصة بالمعنى الصارم في Python. الشرطة السفلية الواحدة (_cache) اتفاق على «لا تلمس من الخارج»، أما الشرطتان (__secret) فتفعّلان تشويه الاسم (name mangling)، حتى لا يكتب وريث الصنف فوق الحقل بالخطأ.
وراثة الأصناف في Python
تتيح الوراثة إنشاء صنف فرعي من صنف أب: يرث الفرع كل الحقول والطرائق، ويعيد تعريف ما يحتاج إلى تغيير. يُكتب الصنف الأب بين قوسين.
class MonthlyReport(Report):
def __init__(self, month):
super().__init__() # استدعاء __init__ الأب
self.month = month
def add(self, expense): # إعادة تعريف الطريقة
print(f"[{self.month}] + {expense}")
super().add(expense) # الحفاظ على سلوك الأب
m = MonthlyReport("أكتوبر")
m.add(Expense("قهوة", 250)) # [أكتوبر] + Expense('قهوة', 250)
print(m.total) # 250 — الخاصية موروثة
تعيد super() كائناً وسيطاً لاستدعاء طرائق الأب. ونسيان super().__init__() في مُهيِّئ الصنف الفرعي خطأ كلاسيكي: لا ينشأ الحقل rows، ويسقط أول استدعاء لـ add بـ AttributeError.
للتحقق من علاقة الوراثة استخدم issubclass(MonthlyReport, Report). وترث كل الأصناف في Python 3 ضمنياً من object، ومنه تأتي الطرائق الأساسية مثل __repr__ و__eq__. ويدعم Python الوراثة المتعددة (class C(A, B))، وترتيب البحث عن الطرائق يظهر في C.__mro__.
الصنف المجرد في Python
الصنف المجرد يحدد واجهة: الطرائق التي يجب على الأصناف الفرعية تنفيذها. ولا يمكن إنشاء كائن منه. ويُستخدم لذلك الوحدة abc:
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, report): ...
class CsvExporter(Exporter):
def export(self, report):
return "\n".join(f"{e.title};{e.amount}" for e in report.rows)
Exporter() # TypeError: Can't instantiate abstract class Exporter ...
الصنف الفرعي الذي لا ينفّذ كل الطرائق المجردة يبقى مجرداً أيضاً، ويعطي عند إنشاء كائن منه الخطأ نفسه.
التركيب بدل الوراثة العميقة
بعد تعلم الوراثة تُغري الإنسان بناء سلالم طويلة: تراكم أولاً، ثم تراكم بالروبية، ثم بالروبية الضريبية، وهكذا لخمسة مستويات. لكن Python يكتب ذلك نادراً. فالوراثة تعبّر عن علاقة «هو نوع من»، أما التركيب فيعبّر عن علاقة «يحتوي على»: التقرير ليس قائمة من المصروفات، بل يحتويها في الحقل rows.
قاعدة عملية: إذا أردت فقط استخدام قدرة كائن آخر، فضعه في حقل ولا ترث منه. وكثيراً ما تكفي dataclass للبيانات، والمنطق يبقى في دوال عادية.
dataclass في Python: صنف بلا كود متكرر
للأصناف التي تحفظ البيانات في الأغلب، يُرهق كتابة __init__ و__repr__ و__eq__ يدوياً. فالمزخرف @dataclass من الوحدة dataclasses يولّدها من التلميحات المكتوبة للحقول:
from dataclasses import dataclass, field
@dataclass
class Expense:
title: str
amount: float
tags: list[str] = field(default_factory=list)
def __post_init__(self):
if self.amount < 0:
raise ValueError("المبلغ لا يمكن أن يكون سالباً")
e = Expense("قهوة", 250)
print(e) # Expense(title='قهوة', amount=250, tags=[])
print(e == Expense("قهوة", 250)) # True
التحقق من القيم الذي كان يُكتب في __init__ يُنقل إلى __post_init__، وهي تُستدعى مباشرة بعد المُهيِّئ الذي تولده dataclass. أما القيم الافتراضية القابلة للتعديل فتُعطى فقط بـ field(default_factory=list): الكتابة tags: list = [] ترفضها dataclass وتعطي ValueError.
frozen وslots
المعامل frozen=True يجعل الكائن غير قابل للتعديل، وslots=True (منذ Python 3.10) ينشئ __slots__ فيوفر الذاكرة. ومحاولة تعديل حقل في كائن مجمّد تسبب FrozenInstanceError. للحصول على كائن بقيمة جديدة تُنشأ نسخة:
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)
class Expense:
title: str
amount: float
e = Expense("قهوة", 250)
# e.amount = 300 -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2) # Expense(title='قهوة', amount=250) Expense(title='قهوة', amount=300)
ومنذ Python 3.13 تفعل الدالة العامة copy.replace() الشيء نفسه. وكائنات dataclass المجمّدة يمكن وضعها في المجموعات واستخدامها مفاتيح للقواميس، لأن لها __hash__.
جديد الأصناف: Self وoverride وslots
للتلميحات الخاصة بالأصناف أداتان مفيدتان. الرمز typing.Self (منذ Python 3.11، PEP 673) يعني «سيُعاد كائن من الصنف نفسه»، وهذا يعمل حتى في الطرائق الموروثة. والمزخرف @override (منذ Python 3.12، PEP 698) يعلّم طريقة يجب أن تعيد تعريف طريقة في الأب: إذا أخطأت في الاسم، فسيخبرك mypy أو pyright بأن الطريقة الأساسية غير موجودة.
from typing import Self, override
class Report:
def __init__(self):
self.rows = []
def add(self, e) -> Self:
self.rows.append(e)
return self # يمكن استدعاؤها بالتسلسل
class MonthlyReport(Report):
@override
def add(self, e) -> Self:
return super().add(e)
وفي Python 3.15 أُزيل قيد ضيق: يُسمح للأصناف الفرعية من tuple بـ __slots__ غير فارغة، ويمكن ذكر __dict__ و__weakref__ في __slots__ لأي صنف. أما في 3.14 فكان هذا التصريح عند وريث الصف يسبب TypeError: nonempty __slots__ not supported for subtype of 'tuple'.
أخطاء شائعة مع الأصناف
| الخطأ | العَرَض | الحل |
|---|---|---|
نسيان self في الطريقة | TypeError: ... takes 0 positional arguments but 1 was given | أول معامل في الطريقة هو دائماً self |
حقل بلا self. في __init__ | تختفي القيمة بعد __init__ | اكتب self.amount = amount |
| قائمة كخاصية للصنف | تتسرب البيانات بين الكائنات | أنشئ القوائم في __init__ أو عبر default_factory |
نسيان super().__init__() | AttributeError على حقول الأب | استدعِ مُهيِّئ الأب |
| استدعاء الخاصية بأقواس | TypeError: 'int' object is not callable | اقرأ الخاصية بلا أقواس: r.total |
الخلاصة
البرمجة الكائنية في Python تبدأ ببساطة: الصنف يصف الحقول والطرائق، و__init__ يملأ الحقول، وself يشير إلى الكائن الحالي. تتيح الوراثة وsuper() توسيع السلوك، ويحدد الصنف المجرد الواجهة، وتحسب @property الحقول المحسوبة. وللأصناف التي تحمل البيانات استخدم @dataclass، وفي الهياكل المعقدة فضّل التركيب على الوراثة. وقد شُرحت الأصناف وdataclasses عملياً في دورة بايثون، على مثال متتبّع المصروفات.
الأسئلة الشائعة
ما الفرق بين الصنف والكائن في Python؟
الصنف وصف للنوع: ما الحقول والطرائق التي يملكها. أما الكائن أو النسخة فيُنشأ باستدعاء الصنف، مثل Expense("قهوة", 250)، ويحمل قيماً محددة لحقوله.
هل يجب كتابة __init__ في كل صنف؟
لا. إذا لم تُعرَّف __init__ فيُستخدم الموروث من object الذي لا يقبل وسائط. وفي dataclass يولّدها المزخرف.
ما الفرق بين __str__ وَ__repr__؟
__repr__ تمثيل واضح للمطور والمصحح، أما __str__ فهي العرض المفهوم للمستخدم في print وf-strings. وإذا لم تكن __str__ معرّفة يستخدم Python الـ __repr__.
متى أختار dataclass ومتى الصنف العادي؟
الـ dataclass مناسب حين يحمل الصنف البيانات في الأساس ونريد __init__ و__repr__ والمقارنة جاهزة. والصنف العادي أنسب حين تكون منطق الإنشاء معقداً أو السلوك أهم من البيانات.
هل توجد حقول خاصة فعلاً في Python؟
لا توجد خصوصية صارمة. الشرطة السفلية الواحدة في بداية الاسم اتفاق بأنه لا يُستخدم من الخارج، والشرطتان تفعلان تشويه الاسم الذي يحمي الحقل من الكتابة فوقه في الوريث.
يُشرح هذا الموضوع بالتفصيل مع التطبيق العملي في دورة «Python 3.15: от новичка до профи».
الانتقال إلى الدورة