8  الكائنات

الكائن

الكائن

رأينا فيما تقدَّم طريقةً لتبعيض مسارات البرنامج لأجزاء تعمل على معطيَات محددة (الدالة / الإجراء)، بحيث يُمكن استدعاؤها بمعطيات مختلفة، ومرات متعددة. وكل جزء منها فيه سير للأوامر من بدايته حتى أحد النهايات بجملة الرجوع (return).

والآن نتعرَّف على طريقة لتبعيض البرنامج إلى برامج جزئية؛ لها مساراتها وأيضًا متغيرتها الخاصَّة. وتسمى هذه البرمجة الكائنية (Object-oriented Programming - OOP) حيث يحتفظ كل كائن بأمرين:

  1. الحال (State) وتمثِّلُها المتغيرات، والإجراءات التي تقرأ من تلك المتغيرات.
  2. الانتقال (Transition) وتمثِّلُها الإجراءات التي تكتب في تلك المتغيرات (أو تكتب في منافذ المخرجات)

وبالمثال يتضح المقال.

القائمة (list) يتم إنشاؤها بالقوسين المربعين []، وهذا من اختصارات بايثون. والأصل أن الكائن يتمُّ إنشاؤه بذكر نوعه مع القوسين () ليتم استدعاء الإجراء المُنشئ للكائن، على هذا النحو:

xs1 = []
xs2 = list()
print(xs1 == xs2)
True

فأما حال القائمة:

ويُمكِنُ قراءة (Read) الحال بالإجراءات التالية:

وأما الانتقال من حالٍ إلى حال، فيتم بأحد إجراءات الكتابة (Write):

وكل ما سبق عمليَّات تمثَّل بإجراءات، لكنَّ قد يحلّ محل اسم الإجراء عامل يدلُّ عليه:

وهذه الأسماء تهمُّنا عند تعريف أنواع بأنفسنا.

تعريف النوع

تأمل المثال التالي:

class Counter:
    def __init__(self, count):
        self.count = count
    
    def increment(self, by=1):
        self.count += by

نوع العداد

نوع العداد
  • الصنف (Class): Counter
  • الخصائص (Properties): هي كل ما تم تعيينه وإسنادُه إلى self وهي هنا: count فقط
  • الطرائق (Methods): وهي كل إجراء تم تعريفُه في داخل تعريف النوع، وهي هنا: increment فقط

أما إجراء __init__ (بشرطتين قبل وشرطتين بعد __) يرمز للكلمة (Initialization) وتعنى الإنشاء؛ ويتم استدعاؤُها فوْر ذِكر اسم النوع كدالة لإنشائه في نحو: Counter(0).

تتقدَّم self (نفس) كمعطى في الابتداء في جميع الأفعال؛ والإسنادُ إليها إسنادٌ لكائن مُضمَر أنشئ من هذا النوع.

إنشاء الكائنات

تتم كتابة اسم الصنف، ثم القوسان (عامل الاستدعاء) وبينهما المعطيات لطريقة الإنشاء __init__ على النحو التالي:

c1 = Counter(10)

وللوصول إلى خاصية أو طريقة ما فإننا نتسعمل عامل الوصول، النقطة (.) على النحو التالي:

print(c1.count)
c1.increment(2)
c1.increment(3)
print(c1.count)
10
15

وهذا معيَّن آخر من نفس الصنف:

c2 = Counter(0)

وكل واحد منهما له حال خاصَّة به:

print(c1.count)
print(c2.count)
15
0

عوامل الحاويات

وكي يتضح مفهوم الأصناف، فإننا سنمثل المجموعة الرياضية (Set) بأنفسنا، وإن كانت جودودة في بايثون أصلاً.

تسمي بايثون الحروف والعلامات المستعملة مع أنواع الجموع: عوامل الحاوية (Container Operators).

ونمثل بتعريف صنف المجموعة الرياضية، حيث تقبل:

  • العد: len(s) يُسمَّى فعله: __len__
  • العضويَّة: x in s يُسمَّى فعله: __contains__
  • الكر: for x in s يُسمَّى فعله: __iter__
  • الحذف: .remove()

ومن سماتها أن العنصر فيها لا يتكرر.

ومن طرائقها: منطق المجموعة الرياضية.

منطق المجموعة الرياضية

المجموعة في الرياضيات لها بعض المفاهيم المتعلقة بها وهي:

  • التقاطع والاتحاد والفرق، والفرق التماثلي
  • وكذلك تحقق: (الجزئية والشمول والانفاصل).

وتمثلها بايثون باستعمال القوسين المعقوفين ({}) على هذا النحو:

set1 = {1, 2, 3, 4, 5}
set2 =          {4, 5, 6, 7, 8}

وفيما يلي نشرح هذه العمليات، ثم ننتقل إلى كيفية تمثيل ذلك في بايثون.

العمليات على المجموعات

الاتحاد

اتحاد مجموعتين

اتحاد مجموعتين
set1.union(set2)
{1, 2, 3, 4, 5, 6, 7, 8}

التقاطع

تقاطع مجموعتين

تقاطع مجموعتين
set1.intersection(set2)
{4, 5}

الفرق

الفرق

الفرق
set1.difference(set2)
{1, 2, 3}
set2.difference(set1)
{6, 7, 8}

الفرق التماثلي

الفرق التماثلي

الفرق التماثلي
set1.symmetric_difference(set2)
{1, 2, 3, 6, 7, 8}

العلاقات بين المجموعات

الجزئية والشمول

الجزئية والشمول

الجزئية والشمول
A = {1, 2, 3}
B = {1, 2, 3, 4, 5, 6}

وهذا مثال لاستعمالها كما في الجدول:

print(A.issubset(B))
print(B.issuperset(A))
True
True

الانفصال

وأما الانفصال، فهو عدم وجود أدنى تقاطع بين المجموعتين:

C = {'Apple', 'Banana'}
print(C.isdisjoint(A))
print(C.isdisjoint(B))
True
True

تعريف المجموعة الرياضية

ونعرف المجموعة الرياضيَّة (Set) بأنفسنا لغرض تعليمي وإن كانت نوعًا أصليًّا في بايثون اسمه (set). ولاحظ أن التمثيل الداخلي (Internal Representation) الذي سنعتمد عليه هو: القائمة (list) الممثلة بالمتغيِّر: self.elements.

class Set:
    # طريقة الإنشاء
    def __init__(self, elements):
        self.elements = elements

    # طريقة التمثيل
    def __repr__(self):
        return f"Set({self.elements})"
    
    # طريقة العد تمررها كما هي
    def __len__(self):
        return len(self.elements)
    
    # طريقة العضويَّة تمررها كما هي
    def __contains__(self, x):
        return x in self.elements
    
    # طريقة التكرار تمررها كما هي
    def __iter__(self):
        return iter(self.elements)
    
    # طريقة الإضافة تتحقق أولاً من عدم وجود العنصر
    def add(self, x):
        if x not in self.elements:
            self.elements.append(x)
    
    # طريقة الحذف تتحقق أولاً من وجود العنصر
    def remove(self, x):
        if x in self.elements:
            self.elements.remove(x)
    
    # طريفة الاتحاد
    def union(self, other):
        result = []
        for x in self.elements + other.elements:
            if x not in result:
                result.append(x)
        return Set(result)
    
    # طريقة التقاطع
    def intersection(self, other):
        result = []
        for x in self.elements:
            if x in other.elements:
                result.append(x)
        return Set(result)
    
    # طريقة الفرق
    def difference(self, other):
        result = []
        for x in self.elements:
            if x not in other.elements:
                result.append(x)
        return Set(result)
    
    # طريقة الفرق التماثلي
    def symmetric_difference(self, other):
        d1 = self.difference(other)
        d2 = other.difference(self)
        return d1.union(d2)
    
    # طريقة الانفصال
    def isdisjoint(self, other):
        return len(self.intersection(other)) == 0
    
    # طريقة الجزئية
    def issubset(self, other):
        for x in self.elements:
            if x not in other.elements:
                return False
        return True
    
    # طريقة الشمول
    def issuperset(self, other):
        return other.issubset(self)

ولاحظ في المثال السابق عدة أمور:

الطريقة (__repr__) تعني التمثيل (Representation)، وهي تظهر حين نعرض الكائن مثلاً بأمر الطباعة print().

أن بعض الطرائق تغير التمثيل الداخلي (elements) مباشرةً، مثل: add و remove.

وبعض الطرائق يُنشئ نُسخة جديدة منه، ويجري العمليَّة عليه، ثم يُنشئ كائن مجموعة (Set) ويمرر إليه هذا التمثيل ويرجع به: return Set(result). والفائدة من هذه الحركة: هي إمكانيَّة استعمال هذه الطرائق ضمن طرائق أخرى. كما ترى في .symmetric_difference() و .isdisjoint(). فلو لم نقم بهذه الحركة، لم يمكن ذلك.

ثم الإنشاء والاستعمال على النحو التالي:

s1 = Set([1, 2, 3])
s2 = Set([3, 4, 5])
print(s1.union(s2))
print(s1.intersection(s2))
print(s1.difference(s2))
print(s2.difference(s1))
print(s1.symmetric_difference(s2))
Set([1, 2, 3, 4, 5])
Set([3])
Set([1, 2])
Set([4, 5])
Set([1, 2, 4, 5])
print(Set([1, 2]).isdisjoint(Set([3, 4])))
print(Set([1, 2]).issubset(Set([1, 2, 3])))
print(Set([1, 2, 3]).issuperset(Set([1, 2])))
True
True
True

تحديد عمل العوامل

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

القطعة الأولى:

[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__

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

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

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

وفي الجمع والطرح والضرب، يكون العائد متجهًا جديدًا هو حاصل العملية على أفراد العناصر المتقابلة بين المتجهين self و other. حيث يمثل الأوَّل (self) المتجَّه في الطرف الأيسر من العامل، والثاني (other) في الطرف الأيمن.

والآن يمكننا إنشاء متجهين ووضع العوامل بينهما:

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

v1 + v2
<4, 6>
v1 - v2
<-2, -2>
v1 * v2
<3, 8>

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

class Vector2D:
    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, Vector2D):
            return Vector2D(self.x + other.x, self.y + other.y)
        else:
            return Vector2D(self.x + other, self.y + other)
    
    def __sub__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.x - other.x, self.y - other.y)
        else:
            return Vector2D(self.x - other, self.y - other)
    
    def __mul__(self, other):
        if isinstance(other, Vector2D):
            return Vector2D(self.x * other.x, self.y * other.y)
        else:
            return Vector2D(self.x * other, self.y * other)

وهكذا يصبح التفاعل بين المتجَّه والعدد، وهما نوعان مختلفان (int و Vector):

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

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

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

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

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

العامل الإجراء
other + self __radd__
other - self __rsub__
other * self __rmul__

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

class Vector2D:
    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, Vector2D):
            return Vector2D(self.x + other.x, self.y + other.y)
        else:
            return Vector2D(self.x + other, self.y + other)

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

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

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

التخصيص بالوراثة

يُمكن جعل علاقة بين نوع ونوع. ومن ذلك الوراثة (Inheritence) وهي عمليَّة تخصيص (Sub-classing) بحيث يستمد النوع خصائصه وطرائقه من النوع الأعم.

ومثاله في بايثون أنواع الرقم:

flowchart BT
  Number[<b>رقم</b><br>Number]
  Integral[<b>كامل</b><br>Integral]
  Integral --> Number
  Real[<b>حقيقي</b><br>Real]
  Real --> Number
  float[<b>عشري</b><br>float]
  float --> Real
  Complex[<b>مركب</b><br>Complex]
  Complex --> Number
  int[<b>صحيح</b><br>int]
  int --> Integral
  bool[<b>منطقي</b><br>bool]
  bool --> Integral

ونمثل بمثال فتقول المربع نوع خاص من المستطيلات. وكذلك تقول: المستطيل نوع خاص من الأشكال. وبالتالي فإن علاقة المربَّع بالشكل هي علاقة تخصيص عام. على نحو هذا المثال:

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

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

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

وهذا تعريف صنف الشكل:

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

لاحظ استعمال المعدِّل (Decorator) @property (يعني: خاصيَّة) وهي تجعل طريقة الوصول لا تحتاج إلى قوسي استدعاء (()) كما هو الأصل. فبمجرد كتابة .perimeter فإن الطريقة تعمل لتأتيك بالنتيجة، وكأنها متغير. وهذا يستعمل في طرائق القراءة عادة، لا في طرائق الكتابة.

والأمر الثاني هو استعمالنا كلمة pass وهي مثل الفراغ؛ ليس لها عمل إلا إقناع مفسر بايثون أننا لم نترك هذا المكان بالخطأ. والسبب في ترك هذه الطريقة فارغة هو أن الأنواع المستمدة ستجريها وإن كانت هنا مهملة. إذ ليس ثمة شيء هو شكلٌ فقط، ولذلك نعتبر هذا النوع، نوعًا مُجرَّدًا (Abstract)، إذ لن نستعمله مباشرةً أبدًا، بل سنخصصه أولاً. فأول نوع سيرث منه هو المستطيل (Rectangle).

ولاحظ في Rectangle استعمال الإجراء الخاص super() وهو يشير إلى الموروث Shape؛ فيصير معنى الجملة ( super().__init__()) وكوْنها في أوَّل سطرٍ من جملة إجراء الإنشاء: الإنشاء فوق الإنشاء الموروث.

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

لاحظ القوسين الإضافيين حول المتغيرات المرسلة إلى الأب (super().__init__())، وهذا يعني أنها كلها ستعيَّن للمعطى الأوَّل كصفّ (tuple)، وهو نوع تسلسل مثل القائمة لكنه جامد لا يقبل التغيير.

أما المربع، فهو نوعٌ خاص من المستطيل:

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

وأما المثلث، فهو من الشكل:

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

والآن ستلاحظ إمكانية استعمال الشيئين المختلفين (المثلث والمستطيل) باعتبار المشترك بينهما (الشكل). ويتبين ذلك إذا كررنا عليهما في قائمة:

t = Triangle(10, 10, 10)
r = Rectangle(10, 20)
shapes = [t, r]

for sh in shapes:
    print(sh.__class__.__name__)
    print("Perimeter:", sh.perimeter)
    print("Area:", round(sh.area, 2))
    print('='*40)
Triangle
Perimeter: 30
Area: 43.3
========================================
Rectangle
Perimeter: 60
Area: 200
========================================

واستعمال instance.__class__.__name__ يعطي اسم النوع الذي ينتمي إليه الشيء.

لكنهما يفترقان في بعض الصفات إذ:

  • المستطيل له طول وعرض
  • المثلث له ثلاثة أضلاع

ويمكن فحص النوع باستعمال الإجراء isinstance(instance, class) لمعرفة ما إذا كان الشيء ينتمي إلى ذلك النوع أو لا.

for sh in shapes:
    if isinstance(sh, Rectangle):
        print(f"Sides: width={sh.width}, height={sh.height}")
    elif isinstance(sh, Triangle):
        print(f"Sides: a={sh.a}, b={sh.b}, c={sh.c}")
Sides: a=10, b=10, c=10
Sides: width=10, height=20

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

فهو في التعريف عام:

def show(shape):
    print(shape.__class__.__name__)
    print("Perimeter:", shape.perimeter)
    print("Area:", round(shape.area, 2))

وفي التمرير خاص:

x = Triangle(10, 10, 10)
show(x)
Triangle
Perimeter: 30
Area: 43.3
y = Rectangle(10, 20)
show(y)
Rectangle
Perimeter: 60
Area: 200

للمزيد راجع ملحق البرمجة الكائنية.