12  الأنواع

تتخذ بايثون نموذجًا في البرمجة يسمى البرمجة الشيئية؛ فالمتغيرات أسماء تشير لأشياء: فالرقم شيء، والنص شيء، والقائمة شيء، والمصفوفة شيء، وهلم جرا.

والشيء هو ما تُسنَدُ إليه: صفات (وهي المتغيرات) وأفعال؛ ويندرج تحت نوع ما.

وأشملُ نوعٍ في بايثون هو الشيء (Object).

12.1 تعريف النوع

يُعرَّف النوع بالكلمة class ويُبتدأُ غالبًا بتعريف فعل الإنشاء __init__ ليتم تعيين الصفات فيه بالإسناد للاسم self الذي يشير إلى الشيء المعين الذي يتم إنشاؤه الآن.

أما تعريف الأفعال فكالأفعال مثل def move بزيادة self في الابتداء.

تأمل المثال:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def move(self, dx, dy):
        self.x += dx
        self.y += dy

والآن نستطيع إنشاء معيَّنات من هذا النوع ونمرر القيم x, y بحسب ما هو موجود في الفعل المخصص للإنشاء: __init__ على النحو التالي:

p1 = Point(3, 4)
p2 = Point(7, 1)

نستعمل حرف النقطة . بعد اسم المعيَّن للإشارة لصفةٍ أو فعل، نحو: p1.x:

x_diff = abs(p1.x - p2.x)
y_diff = abs(p1.y - p2.y)
print(f'X difference is {x_diff} and Y difference is {y_diff}.')
X difference is 4 and Y difference is 3.

وهذا مثال لطلب الفعل: p1.move()

p1.move(4, 4)
print(p1.x, p1.y)
7 8

لو أردنا طباعة النقطة، كيف تظهر؟

print(p1)
<__main__.Point object at 0x7f23789e0170>

لتخصيص طريقة عرض الشيء، بحيث لو طبعناه أو ذكرناه في آخر السطر يظهر البشكل الذي نريده، يمكن تغيير الفعل المخصص __repr__ أي: التمثيل .. هكذا:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def move(self, dx, dy):
        self.x += dx
        self.y += dy
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

والآن إن عرفنا نقطة جديدة، ووضعناها على السطر لوحدها ، ستظهر لنا الإحداثيات، لا عنوانها الذاكري:

p = Point(3, 4)
p.move(7, 6)
p
Point(10, 10)

الفعل الثابت هو ما يُسنَدُ للنوع نفسه لا للأعيان. ويتم تثبيت الفعل بعلامة المزيِّن @staticmethod. لاحظ عدم وجود self في الفعل الجديد distance لأنه ثابت:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def move(self, dx, dy):
        self.x += dx
        self.y += dy
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
                
    @staticmethod
    def distance(p1, p2):
        return ((p1.x - p2.x)**2 + (p1.y - p2.y)**2)**0.5

ويتم استدعاؤه بذكر اسم النوع والنقطة .:

a = Point(0, 1)
b = Point(1, 0)

Point.distance(a, b)
1.4142135623730951

كذلك يُجعل المتغير من النوع الثابت بتعيينه بمحاذاة غيره من الأفعال نحو ما فعلنا هنا بالمتغير distance_type. ولاحظ استعماله في الفعل distance في جملة if-else من غير استعمال self لأننا لا نشير إلى معيَّن:

class Point:
    distance_type = 'euclidean'

    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def move(self, dx, dy):
        self.x += dx
        self.y += dy
    
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
    
    @staticmethod
    def distance(p1, p2):
        if Point.distance_type == 'euclidean':
            return ((p1.x - p2.x)**2 + (p1.y - p2.y)**2)**0.5
        elif Point.distance_type == 'manhattan':
            return abs(p1.x - p2.x) + abs(p1.y - p2.y)

12.2 التغليف (Encapsulation)

فكرة التغليف في البرمجة الشيئية هي إبعاد التفاصيل عن المستفيد.

في المثال التالي لا نريد للمستفيد أن يعدِّل على الرصيد balance إلا عن طريق الفعل deposit الذي يضمن أن الزيادة تكون موجبة.

ولاحظ أن استعمال __balance عرفٌ بين المبرمجين في بايثون لنقول أن الصفة مخفيَّة عن المستفيد، وأنه يجب أن لا يغيرها مباشرةً.

أما المزيِّن @property في الفعل balance فإنه يرجع بقيمة الصفة لا بالصفة نفسها. وذلك ليمنع التعديل عليها.

class Account:
    def __init__(self, name, balance):
        self.name = name
        self.__balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("must be positive")
        self.__balance += amount
        print('time of deposit:', '2027-07-07')

    @property
    def balance(self):
        return self.__balance
a1 = Account('Adam', 100)
a1.balance
100
a1.deposit(100)
a1.balance
time of deposit: 2027-07-07
200

ولو حاولت التغيير مباشرة فلن تستطيع (من غير الشرطتين السفليتين):

a1.balance = 1000
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[14], line 1
----> 1 a1.balance = 1000

AttributeError: property 'balance' of 'Account' object has no setter

12.3 تصنيف الأنواع

تصنف الأنواع في البرمجة بطريقتين:

  • تصنيف اسمي: حيث يتم التصريح بأن النوع فلان، يستمد من النوع فلان (وقد يتعدد). وتتم بالوراثة (Inheritance)
  • تصنيف عملي: حيث يكون اعتبار التصنيف بحسب أفعال النوع. وتتم بالواجهة (Interface) وهو نوعٌ من التركيب (Composition)

12.4 التصنيف الاسمي: الوراثة (Inheritance)

الوراثة: أن يندرج النوع تحت نوعٍ آخر؛ فهو يستمد منه ويزيد عليه صفةً أو فعلاً (أو أكثر).

وهو اسميٌّ لأن المعتبر هو أسماء الأنواع؛ فتقول:

  • الشكل هو ما كان له محيط
  • المستطيل شكلٌ (إذًا له محيط) و فوق ذلك فإنه له: طولًا وعرضًا ومساحة
  • والمثلث شكلٌ (إذًا له محيط) و فوق ذلك فإنه له: ثلاثةَ أضلاعٍ ومساحة
  • أما المربع فهو مستطيل (إذًا له محيط لأن المستطيل شكل، وله طول وعرض ومساحة): لكن عرضه وطوله متساويان

وهذه شجرة التوارث للأنواع المذكورة:

flowchart BT
  Shape
  Rectangle -- "is a" --> Shape
  Square -- "is a" --> Rectangle
  Triangle -- "is a" --> Shape

ويستعمل الإجراء super() لإعمال إنشاء المُستمدِّ منه في __init__. وتذكر أن علامة النجمة * في وصف متغيرات الإجراء (*sides) تجعل عدد عناصره لا محدودة.

class Shape:
    def __init__(self, *sides):
        self.sides = sides
    
    @property
    def perimeter(self):
        return sum(self.sides)

class Rectangle(Shape):
    def __init__(self, width, height):
        super().__init__(width, height, width, height)
    
    @property
    def width(self):
        return self.sides[0]
    
    @property
    def height(self):
        return self.sides[1]
    
    @property
    def area(self):
        return self.width * self.height
    
class Triangle(Shape):
    def __init__(self, a, b, c):
        super().__init__(a, b, c)

    @property
    def a(self):
        return self.sides[0]
    
    @property
    def b(self):
        return self.sides[1]
    
    @property
    def c(self):
        return self.sides[2]
    
    @property
    def area(self):
        s = self.perimeter / 2
        return (s * (s - self.a) * (s - self.b) * (s - self.c))**0.5
    

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

نعين مثلثًا ونطبع على الشاشة:

x = Triangle(10, 10, 10)
print("Triangle")
print("Perimeter:", x.perimeter)
print("Area:", round(x.area, 2))
print(f"Sides: a={x.a}, b={x.b}, c={x.c}")
Triangle
Perimeter: 30
Area: 43.3
Sides: a=10, b=10, c=10

نعين مستطيلًا ونطبع على الشاشة:

y = Rectangle(10, 20)
print("Rectangle")
print("Perimeter:", y.perimeter)
print("Area:", round(y.area, 2))
print(f"Sides: width={y.width}, height={y.height}")
Rectangle
Perimeter: 60
Area: 200
Sides: width=10, height=20

وتجد في شجرة المجموعات شكل 1 أن المجموعات المتغيرة نوع مخصص من الأنواع الجامدة:

  • القائمة (list) مخصصة من التسلسل المتغير (MutableSequence) الذي هو من نوع التسلسل (Sequence).
  • المجموعة (set) مخصصة من المجموعة المتغيرة (MutableSet) التي هي من نوع المجموعة (Set).
  • القاموس (dict) مخصص من القاموس المتغير (MutableMapping) الذي هو من نوع القاموس (Mapping).

يفضل كثير من الممارسين التصنيف العملي على التصنيف الاسمي في أغلب الأحيان. لمرونته التي نحتاجها في التطوير المستمر للبرنامج، بينما التصنيف الاسمي جامد، وسرعانما تُخرَق حدوده عند التغيير. وكثيرٌ مما ينظَّرُ له في التصنيف الاسمي يمكن تحقيقه بجملة if-elif-else أو جملة match-case وتنتهي القصة. ولذلك يجب استعماله في حالات قليلة يكون الاستمداد فيها من حيثية محددة: مثل التغيُّر (Mutability) كما في الثلاثة السابقة.

12.5 التصنيف العملي: التركيب (Composition)

ومثاله في نفس شجرة المجموعات شكل 1 هو نوع الجمع (Collection)، حيث له ثلاثة اعتبارات:

  1. كونه يقبل العضويَّة: x in s وفعله هو: __contains__ يصنف أنه حاوٍ (Container)
  2. كونه يقبل التكرار: for x in s وفعله هو: __iter__ يصنف أنه متوالي (Iterable)
  3. كونه يقبل العد: len(s) وفعله هو: __len__ يصنف أنه محجَّم (Sized)

وكذلك المتسلسلة (Sequence) تقبل العكس، وفعلها المخصص هو: __reversed__؛ فبهذا الاعتبار هي من النوع القابل للعكس (Reversible).

فمثلاً: أيُّ شيءٍ يعرِّفُ الفعل len(sized)__ -> int__ فإنه ينتمي لنوع ذوات الحجم (Sized)، ونمثل لذلك بنوع المتجه في فضاء ثنائي الأبعاد حيث يتكون من عنصرين:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __len__(self):
        return 2

v1 = Vector(10, 20)
len(v1)
2

ولاحظ عدم وجود ذكر للنوع Sized البتة، بل المُعتَبَرُ وجود الفعل __len__ حتى ينطبق التصنيف.

ويكثر فيه المجرَّداتُ ذات الفعل الواحد أو الفعلين. لأننا نريد أشياء كثيرة تنتمي بحسب ما يكون فيها من أفعال.

12.6 المعاملات (Operators)

تأمل التالي وتوقَّع النتيجة وعلل إجابتك. ما هي نتيجة:

الأول:

[1, 2, 3] + [4, 5, 6]

الثاني:

[1, 2, 3] * [4, 5, 6]

الثالث:

[1, 2, 3] * 5

الرابع:

[1, 2, 3] - 3

كل الذي سبق، قد تم تعريفه في بايثون لهذه الأنواع التي تراها بالتحديد عن طريق أفعال مخصصة. وإليك هذا الجدول للمعاملات المخصصة:

مثال معامله الفعل
self + other __add__
self - other __sub__
self * other __mul__
self / other __truediv__
self // other __floordiv__
self % other __mod__
self ** other __pow__

(وانظر توثيق بايثون لمحاكاة العمليات الرقمية).

فنستطيع تعريف نوع متجَّه على النحو التالي.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    def __mul__(self, other):
        return Vector(self.x * other.x, self.y * other.y)

والآن يمكننا إنشاء متجهين وفعل العمليات المخصصة:

v1 = Vector(1, 2)
v2 = Vector(3, 4)

v1 + v2
Vector(4, 6)
v1 - v2
Vector(-2, -2)
v1 * v2
Vector(3, 8)

ماذا لو أردنا إضافة عمليات بين المتجه والعدد، نحو: v1 + 3? يتطلب ذلك إضافة شرط لفحص النوع، وهو isinstance كالتالي:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    
    def __add__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        else:
            return Vector(self.x + other, self.y + other)
    
    def __sub__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x - other.x, self.y - other.y)
        else:
            return Vector(self.x - other, self.y - other)
    
    def __mul__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x * other.x, self.y * other.y)
        else:
            return Vector(self.x * other, self.y * other)

والآن يمكننا فعل العمليات المخصصة:

v1 = Vector(1, 2)
v1 + 3
Vector(4, 5)

لكن لاحظ أنك لو وضعت العدد أولاً فسيظهر خطأ:

3 + v1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[25], line 1
----> 1 3 + v1

TypeError: unsupported operand type(s) for +: 'int' and 'Vector'

هذا لأن عملية الجمع الآن لا تنظر في نوع العدد (int) ولا تجد فيه قبولاً للمتجه (فقد عرفناه للتو). ولحل هذه المشكلة توفر بايثون لكل فعل مخصص مقابل يبدأ بحرف r على النحو التالي:

المعامل الفعل
other + self __radd__
other - self __rsub__
other * self __rmul__

نعدل الفعل بحيث نضيف إليه المقابل:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    
    def __add__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        else:
            return Vector(self.x + other, self.y + other)

    def __radd__(self, other):
        return self + other

والآن كلاهما يعمل بشكل صحيح:

v1 = Vector(1, 2)
3 + v1
Vector(4, 5)
v1 + 3
Vector(4, 5)

12.7 الإجمال ثم التفصيل

ويُحبَّذُ في الإنشاء الإجمالُ ثم التفصيل. ونبين ذلك في تعريف الأفعال، وينطبق كذلك في تعريف الأنواع:

def min_max(numbers: list[float]) -> tuple[float, float]:
    """Return the minimum and maximum values in the list."""
    pass
  • كتابة اسم معبِّرٍ عن وظيفة الفعل، مع كتابة نوع ما يقبل ونوع ما ينتج
    • وهي جُملة التعريف: def
  • كتابة وصف -باللغة الإنجليزية- يحدد سلوكه العام بناءً على عوامله يصف ما يَقبل وما يُنتج (إذْ لغة البرمجة عاجزة عن بيان كل ما يحصل)
    • وهو النص الملحَقُ: docstring (ويكونُ أولَّ شيء فيه قبل أي أمر، وهو المحاط بـ: """) والقاعدة العامة فيه: ألا يصف إلا ما يفيد المستفيد من الفعل. أما التفاصيل التي تفيد مطوِّر الفعل فإنها تكون تعليقات بعلامة # في ثناياه. فالذي يظهر عند المساعدة help(min_max) هو النص الملحق، لا التعليقات.
  • بعد ذلك نكتب الاختبارات التي بمجموعها تصف سلوك الفعل من الخارج
    • ونستعمل لها جملة التوكيد: assert
    • ولن تكون حقيقيةً لأن تفاصيل الفعل لم تُكتب بعد، لذا قد تستعمل كلمة pass لإرضاء المترجم إلى ذلك الحين
  • وبعد ذلك نأتي لكتابة التفاصيل: التي هي قطعة الكود داخل الفعل.

وهكذا تظهر المساعدة، مثلما لو وضعت المؤشر على اسم الفعل (فإن غالب المحررات تُظهر لك شيئًا):

help(min_max)
Help on function min_max in module __main__:

min_max(numbers: list[float]) -> tuple[float, float]
    Return the minimum and maximum values in the list.

لاحظ أن الظاهر هو ماهية الفعل لا كيفية عمله. وهذا هو المهم.

ثم نكتب اختبارات تصف السلوك الذي نريده منه:

# assert min_max([10, 20, 30, 40, 50]) == (10, 50)
# assert min_max([50, 40, 30, 20, 10]) == (10, 50)
# assert min_max([10, 10, -900, 10, 10]) == (-900, 10)

ثم الآن نعدل الفعل الذي كتبناه:

  • تستعمل التعليقات المبتدأة بحرف #لتبيين ما قد يُشكل من التفاصيل
  • ذكر النوع tuple[float, float] يعني أن الفعل يرجع بزوج، لا بقيمة واحدة (وهذا يسمَّى نوع الصف، وسيأتي الحديث عنه)
def min_max(numbers: list[float]) -> tuple[float, float]:
    """Return the minimum and maximum values in the list."""

    if len(numbers) == 0:
        return None, None
    
    # Assign the first element to minimum and maximum
    minimum = numbers[0]
    maximum = numbers[0]
    
    for num in numbers:
        if num < minimum:
            minimum = num
        # elif is used because maximum will never be less than minimum
        elif num > maximum:
            maximum = num
    return minimum, maximum

ونشغل الاختبارات، ونتوقع أن لا يظهر منها خطأ:

assert min_max([10, 20, 30, 40, 50]) == (10, 50)
assert min_max([50, 40, 30, 20, 10]) == (10, 50)
assert min_max([10, 10, -900, 10, 10]) == (-900, 10)
assert min_max([]) == (None, None)

راجع ثوثيق بايثون في العوامل الخاصة للمزيد.