البرمجة بلغة بايثون · الفصل 9 — أنواع المعطيات المركبةטיפוסי נתונים מורכבים · יסודות התכנות בשפת Python⌂📚🏠
الفصل 9

أنواع المعطيات المركبة

טיפוסי נתונים מורכבים
· 15 ساعات تعليمية

الفئة Sensor التي كتبناها في الفصل 8 تصف حسّاسا واحدا، وكل صفاتها أعداد وجمل نصية. لكن الأشياء الحقيقية مبنية من أشياء أخرى: الجهاز فيه بطارية، والدائرة لها مركز، ولوحة الدائرة الكهربائية تحمل ثنائيات باعثة للضوء، والمحطة تجمع حسّاسات كثيرة. في هذا الفصل نبني فئة من فئات أخرى: نجعل كائنا صفة في كائن، نجمع كائنات في قائمة، ونرسم ذلك كله في مخطّط قبل أن نكتب الشيفرة. وفي آخر الفصل تصبح المحطة نفسها كائنا يحمل قائمة حسّاساتها.

في هذا الفصل:
  • نعرّف النوع المركّب، ونميّز بين ما يكون صفة بسيطة وما يستحق فئة خاصة به.
  • ننشئ كائنات صفاتها كائنات، ونصل إلى ما بداخلها بسلسلة من النقاط.
  • نعرف متى تشير صفتان إلى الكائن نفسه، ونمنع ذلك أو نستفيد منه.
  • نقرأ مخطّط UML ونرسمه، وننتقل منه إلى الشيفرة ومن الشيفرة إليه.
  • نعمل على قائمة من الكائنات، حتى لو كانت من أنواع مختلفة.
  • نكتب فئة تحمل قائمة من الكائنات، وتخدمها بعمليّات الإضافة والبحث والعدّ.

9.1 تعريف النوع المركّب – הגדרה של טיפוס מורכב

نكتب فئة تصف جهازا يعمل على بطارية، ونريد أن نعرف كم ساعة يعمل. أول ما يخطر لنا هو أن نضع كل شيء في فئة واحدة:

class Device:
    def __init__(self, name, watts, volts, amp_hours):
        self.name = name
        self.watts = watts
        self.volts = volts
        self.amp_hours = amp_hours

لكن volts وamp_hours لا يصفان الجهاز، بل يصفان بطاريته. وإذا ركّبنا في الجهاز بطارية أخرى، فعلينا أن نغيّر صفتين معا. وإذا احتجنا إلى طاقة البطارية في فئة أخرى، كررنا الحساب هناك. البطارية شيء قائم بذاته، فنعطيها فئة خاصة بها، ونجعل الجهاز يحوي بطارية، وهذا هو الاحتواء (הכלה):

النوع المركّب (טיפוס מורכב): فئة تكون إحدى صفاتها على الأقل كائنا من فئة أخرى كتبناها نحن. هكذا نبني نوعا جديدا من أنواع موجودة.
class Battery:
    def __init__(self, volts, amp_hours):
        self.volts = volts
        self.amp_hours = amp_hours

    def energy(self):
        return self.volts * self.amp_hours

class Device:
    def __init__(self, name, watts, battery):
        self.name = name
        self.watts = watts
        self.battery = battery

    def hours(self):
        return round(self.battery.energy() / self.watts, 1)

pack = Battery(12, 7)
fan = Device("Fan", 6, pack)
print(fan.name, fan.hours())
print(fan.battery.volts)
المُخرَج
Fan 14.0
12

الصفة battery تتلقى كائنا كاملا، لا عددا. والتعبير self.battery.energy() يعمل على مرحلتين: self.battery هو الكائن Battery، وenergy() تُستدعى عليه. الجهاز لا يعرف كيف تُحسب الطاقة، بل يطلبها من البطارية، وكل فئة تتولى ما يخصها.

fan: Devicename"Fan"watts6batterypack: Batteryvolts12amp_hours7
الصفة battery لا تحوي البطارية نفسها، بل مرجعا إليها
المرجع (הפניה): ما يُحفظ فعلا في المتغيّر أو في الصفة عندما تكون قيمته كائنا: دلالة على مكان الكائن في الذاكرة، وليس نسخة منه. لهذا كان الأمر b = a في الفصل 6 يعطي القائمة نفسها اسما آخر.

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

fan.battery = Battery(24, 20)
print(fan.hours(), pack.volts)

المُخرَج: 80.0 12

ومتى نكتب فئة جديدة؟ نسأل: هل هو قيمة واحدة تصف الكائن، أم شيء له أجزاء وعمل خاص به؟ فقدرة الجهاز عدد من النوع float، أما بطاريته فلها جهد وسعة وتحسب طاقتها، ولذلك هي من النوع Battery.

ملاحظة: كل قيمة في بايثون هي كائن، حتى "Fan"، كما رأينا في البند 2.3. الجديد هنا أن الفئة الداخلية من كتابتنا نحن.
خطأ شائع: المُفسّر لا يفحص نوع ما نمرّره. إذا كتبنا Device("Fan", 6, 12) فوضعنا عددا مكان البطارية، فلن يعترض أحد عند إنشاء الكائن. الخطأ AttributeError يظهر لاحقا عند استدعاء hours()، بعيدا عن سببه. لذلك نبحث في مثل هذا الخطأ عن المكان الذي أنشأنا فيه الكائن، لا عن السطر الذي توقف عنده البرنامج.
سؤال 9.1: ماذا يطبع البرنامج التالي، بعد الفئتين السابقتين؟
radio = Device("Radio", 4, Battery(6, 4))
print(radio.hours())
print(radio.battery.energy())
radio.battery.volts = 12
print(radio.hours())
سؤال 9.2: نكتب فئة لمقياس جهد رقمي. أي مما يلي يكون صفة بسيطة، وأيها يستحق فئة خاصة به؟
(أ) مدى القياس
(ب) شاشة لها عدد خانات وتعرض قيمة
(ج) اسم الشركة
(د) مجسّان، لكل منهما لون وطول ومقاومة

9.2 الكائنات كقيم للصفات: إنشاء كائنات من نوع مركّب واستعمالها – עצמים כערכי תכונות: יצירת ושימוש בעצמים מטיפוס מורכב

الدائرة لها مركز ونصف قطر. نبدأ بالجزء، نقطة في المستوى: لها عمليّة تحرّكها، وعمليّة تحسب بُعدها عن نقطة أخرى حسب فيثاغورس، والوسيط other فيها هو نفسه كائن من الفئة Point. أما في الفئة Circle فالصفة center كائن من الفئة Point، والصفة radius عدد من النوع float:

d = (x1 − x2)2 + (y1 − y2)2
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 distance(self, other):
        dx = self.x - other.x
        dy = self.y - other.y
        return (dx ** 2 + dy ** 2) ** 0.5

    def __str__(self):
        return "(" + str(self.x) + ", " + str(self.y) + ")"
class Circle:
    def __init__(self, center, radius):
        self.center = center
        self.radius = radius

    def move_to(self, x, y):
        self.center.x = x
        self.center.y = y

    def contains(self, point):
        return self.center.distance(point) <= self.radius

    def __str__(self):
        c = str(self.center)
        return "center " + c + ", radius " + str(self.radius)
c = Circle(Point(3, 4), 2.5)
print(c)
print(c.center)
print(c.center.x)
print(c.center.distance(Point(0, 0)))

المُخرَج: center (3, 4), radius 2.5 , (3, 4) , 3 , 5.0

نقرأ التعبير c.center.x من اليسار إلى اليمين، وكل نقطة تعني: ندخل إلى ما قبلها. والفئة Circle لا تعرف كيف تُكتب النقطة، فتستدعي str(self.center) وتتولى Point ذلك. وكذلك contains تسأل النقطة عن البُعد ولا تحسبه بنفسها.

تدريب سريع — سلسلة النقاط، مع c = Circle(Point(3, 4), 2)
1c.radius
2c.center.y
3str(c.center)
4c.contains(Point(3, 5))
الإجابات: 1 2 2 4 3 (3, 4) 4 True

تغيير المركز:

نبدأ من c = Circle(Point(3, 4), 2.5)، وننفذ الأوامر التالية بالتتابع، ونطبع الدائرة بعد كل أمر:

الأمرالمُخرَجما يحدث
c.center.x = 10center (10, 4), radius 2.5يغيّر صفة داخل كائن النقطة
c.center.move(1, -2)center (11, 2), radius 2.5يطلب من النقطة أن تتحرك
c.move_to(0, 0)center (0, 0), radius 2.5يطلب من الدائرة، وهي تغيّر نقطتها. وهذه أوضح طريقة لمن يستعمل الفئة
c.center = Point(7, 7)center (7, 7), radius 2.5يضع في الصفة مرجعا إلى كائن جديد، والنقطة القديمة لا تتغيّر
مثال 9.1
وحدة اتصال لاسلكية في الموقع (0, 0) يصل إرسالها إلى 40 m، فمنطقة تغطيتها دائرة. نفحص هل تصل إلى عقدة قياس، ثم ننقل الوحدة.
الحل:
gateway = Circle(Point(0, 0), 40)
node = Point(30, 30)
print(gateway.contains(node))
gateway.move_to(10, 10)
print(gateway.contains(node))

المُخرَج: False , True

لم نكتب حساب البُعد في الفئة Circle، بل استعملنا ما كتبناه في Point.

صفتان تشيران إلى الكائن نفسه:

خطأ شائع: نُنشئ نقطة واحدة ونمرّرها إلى دائرتين، ونظن أن لكل دائرة مركزها. لكن self.center = center تحفظ المرجع فقط، فتصبح للدائرتين نقطة واحدة، وإذا حرّكنا مركز إحداهما تحرّك مركز الأخرى:
p = Point(0, 0)
a = Circle(p, 5)
b = Circle(p, 8)
a.center.x = 3
print(a.center, b.center, p)
print(a.center is b.center)

المُخرَج: (3, 0) (3, 0) (3, 0) , True

المعامل is يسأل: هل هذان كائن واحد؟ والعلاج أن ننشئ لكل دائرة نقطة خاصة بها، Circle(Point(0, 0), 5)، أو أن تصنع الدائرة نسختها الخاصة في عمليّة البناء، فلا يهم عندها ما يُمرَّر إليها:

class Circle:
    def __init__(self, center, radius):
        self.center = Point(center.x, center.y)
        self.radius = radius

والأوامر نفسها تعطي الآن: (3, 0) (0, 0) (0, 0) , False

a: Circlecenterradius5b: Circlecenterradius8: Pointx3y0: Pointx0y0
لكل دائرة نقطتها: التغيير يبقى في مكانه
a: Circlecenterradius5b: Circlecenterradius8: Pointx3y0
مرجعان وكائن واحد: التغيير عبر أحدهما يظهر في الآخر
خطأ شائع: المعامل == لا يقارن صفات الكائنين ما دامت الفئة لا تطلب ذلك، بل يسأل عن الكائن نفسه. فالتعبير Point(1, 2) == Point(1, 2) يعطي False. لذلك نقارن الصفات واحدة واحدة.

وليست المشاركة خطأ دائما. إنذار يراقب حسّاسا Sensor من الفصل 8، ويجب أن يرى ما يراه الحسّاس. نبني إنذارين على البطارية نفسها، تحذيرا عند 12.2 V وفصلا للحمل عند 11.8 V من الفصل 3. نحدّث الحسّاس مرة واحدة، فيرى الإنذاران القراءة الجديدة معا. فالحسّاس شيء واحد في العالم، أما مركزا دائرتين منفصلتين فشيئان:

class Alarm:
    def __init__(self, sensor, limit):
        self.sensor = sensor
        self.limit = limit

    def active(self):
        return self.sensor.value() < self.limit

batt = Sensor("Battery", "V", 4)
warn = Alarm(batt, 12.2)
cut = Alarm(batt, 11.8)
batt.read(620)
print(batt, warn.active(), cut.active())
batt.read(590)
print(batt, warn.active(), cut.active())

المُخرَج: Battery: 12.12 V True False , Battery: 11.53 V True True

سؤال 9.3: ماذا يطبع البرنامج التالي؟
p = Point(1, 1)
a = Circle(p, 2)
b = Circle(Point(1, 1), 2)
p.x = 9
print(a.center.x, b.center.x)
print(a.center is p, b.center is p)

9.3 مخطّط UML – תרשים UML

عندما تكثر الفئات، يصعب علينا أن نرى بناء البرنامج من الشيفرة. مخطّط الفئات صورة لهذا البناء: أي فئات لدينا، ماذا في كل منها، وكيف ترتبط. نرسمه قبل الكتابة لنفكر، وبعدها لنشرح. ولغة الرسم هذه موحّدة عالميا، واسمها UML، ويفهمها كل مبرمج مهما كانت لغته.

مخطّط الفئة (תרשים מחלקה): مستطيل من ثلاث خانات: في الأولى اسم الفئة، وفي الثانية صفاتها، وفي الثالثة عمليّاتها.
Point+ x : float+ y : float+ move(dx: float, dy: float)+ __str__() : strاسم الفئةالصفاتالعمليّاتمحدِّد الوصول
الخانات الثلاث في مخطّط الفئة Point

يُكتب كل سطر بطريقة ثابتة. النوع يأتي بعد نقطتين، ولا نكتب self أبدا، لأنه موجود في كل عمليّة:

في المخطّطفي الشيفرةمعناه
+ x : floatself.x = xصفة من النوع float
+ move(dx: float, dy: float)def move(self, dx, dy):عمليّة لها وسيطان
+ value() : floatdef value(self):عمليّة تعيد عددا
+ step : floatstep = 5 / 1023خط تحت السطر: متغيّر فئة

والإشارة في أول السطر هي محدِّد الوصول (הרשאת גישה). + تعني عامّ (ציבורי): يصل إليه كل من يملك الكائن. و- تعني خاصّ (פרטי): للاستعمال داخل الفئة فقط. في بايثون لا يوجد منع حقيقي، بل اتفاق بين المبرمجين: الاسم الذي يبدأ بخط سفلي _ نعدّه خاصا، فلا نلمسه من الخارج.

مثال 9.2
المصهر يحترق إذا تجاوز التيار قيمته، ويبقى محترقا، ولا يجوز لأحد أن يعيده من الخارج. لذلك نجعل حالته خاصة، ونعطي عمليّة تقرؤها فقط.
الحل:
class Fuse:
    def __init__(self, amps):
        self.amps = amps
        self._blown = False

    def carry(self, current):
        if current > self.amps:
            self._blown = True

    def is_blown(self):
        return self._blown

f = Fuse(2)
f.carry(3)
f.carry(0.5)
print(f.is_blown())
المُخرَج
True
Fuse+ amps : float- _blown : bool+ __init__(amps: float)+ carry(current: float)+ is_blown() : bool

لو كتب أحد f._blown = False لعمل الأمر، لكن الخط السفلي والإشارة - يقولان له إنه يخالف تصميم الفئة. وفي بقية الأمثلة نترك الصفات عامة، حتى تبقى قصيرة.

الخطوط بين الفئات:

الخط بين مستطيلين يعني أن كائنا من الفئة الأولى يعرف كائنا من الثانية، أي أن فيه صفة تشير إليه. ونوع الخط يقول كيف يعرفه، والأرقام على طرفيه هي التعدّدية (ריבוי): الرقم المكتوب قرب فئة يقول كم كائنا منها يقابل كائنا واحدا من الفئة الأخرى.

في المخطّطالاسممعناهفي الشيفرة
خط وسهم مفتوحالارتباط (שיוך)يعرفه ويستعمله، وذلك الكائن قائم بذاته وقد يخدم غيرهالكائن يُمرَّر من الخارج، ويبقى عند من أنشأه
خط ومعيّن ممتلئ عند الكلّالتركيب (הרכבה)هو جزء منه: لا يخدم كائنا آخرالكلّ يُنشئ الجزء بنفسه، أو ينسخه، أو يستقبل كائنا جديدا لا يحمله غيره
الرمزمعناهمثال
1واحد بالضبطلكل دائرة مركز واحد
0..1واحد أو لا شيءجهاز قد لا تكون فيه بطارية
*أي عدد، حتى صفرلوحة تبدأ فارغة ثم تحمل ثنائيات
1..*واحد على الأقلسلسلة فيها مقاومة واحدة على الأقل
Circle+ center : Point+ radius : float+ move_to(x, y)+ contains(p: Point) : bool+ __str__() : strPoint+ x : float+ y : float+ move(dx, dy)+ __str__() : str11center
الدائرة تركّب نقطة، وهذا يصدق بعد أن صارت عمليّة البناء تنسخ النقطة
ملاحظة: وهناك نوع ثالث، التجميع (קיבוץ)، ورمزه معيّن فارغ، لكلّ يجمع أجزاء تبقى موجودة بعده، وقد يحملها كلّ آخر أيضا. سنلقاه مرة واحدة، في سلسلة المقاومات في البند 9.5.
مثال 9.3
نرسم المخطّط لبرنامج Device وBattery من البند 9.1.
الحل:

نأخذ من كل فئة صفاتها وعمليّاتها. البطارية تُمرَّر من الخارج ويمكن نقلها إلى جهاز آخر، فنرسم ارتباطا. السهم يقول إن الجهاز يعرف البطارية، والبطارية لا تعرف الجهاز:

Device+ name : str+ watts : float+ battery : Battery+ hours() : floatBattery+ volts : float+ amp_hours : float+ energy() : float0..11battery
مثال 9.4
نكتب الشيفرة من المخطّط: منظّم حرارة Thermostat يشغّل مروحة Fan. المعيّن يقول إن المروحة جزء منه، ولذلك يُنشئها بنفسه في عمليّة البناء.
الحل:
Thermostat+ target : float+ fan : Fan+ __init__(target: float)+ update(temp: float)Fan+ speed : int+ __init__()+ set_speed(speed: int)11fan
class Fan:
    def __init__(self):
        self.speed = 0

    def set_speed(self, speed):
        self.speed = speed

class Thermostat:
    def __init__(self, target):
        self.target = target
        self.fan = Fan()

    def update(self, temp):
        if temp > self.target:
            self.fan.set_speed(1)
        else:
            self.fan.set_speed(0)

t = Thermostat(30)
for temp in [28, 32]:
    t.update(temp)
    print(temp, t.fan.speed)
المُخرَج
28 0
32 1

أعطانا المخطّط الأسماء والأنواع والبناء، ولم يقل شيئا عن عمل update: القواعد تأتي من المسألة.

خطأ شائع: نضع التعدّدية عند الطرف الخطأ. في لوحة تحمل ثنائيات كثيرة، تُكتب * قرب LED، لأنها تقول كم منها في اللوحة الواحدة. ونرسم أحيانا سهما من الجزء إلى الكلّ وليس في الشيفرة صفة كهذه: النقطة لا تعرف أي دائرة تستعملها.
سؤال 9.4: نقرأ المخطّط التالي.
(أ) كم مفتاحا يمكن أن يكون في اللوحة؟
(ب) هل يستطيع المفتاح أن يعرف لوحته؟
(ج) من يُنشئ كائنات Switch حسب المعيّن؟
(د) ما نوع عناصر switches؟
Panel+ name : str+ switches : list+ add(pin: int)+ pressed() : intSwitch+ pin : int+ closed : bool+ press()+ release()11..*switches

9.4 قائمة من الكائنات – רשימה של עצמים

القائمة من الفصل 6 تحفظ أي قيمة، والكائن قيمة. وكل ما تعلمناه عن المؤشّر والحلقة for يبقى كما هو، والجديد هو النقطة بعد العنصر: parts[1] يعطي كائنا، وparts[1].ohms يعطي صفة فيه. وفي أغلب الأحيان تأتي المعطيات من الخارج، فنبني الكائنات في حلقة، صفًّا ثابتا بعد صف:

class Resistor:
    def __init__(self, name, ohms, watts):
        self.name = name
        self.ohms = ohms
        self.watts = watts

    def power(self, amps):
        return round(amps ** 2 * self.ohms, 3)

    def __str__(self):
        return self.name + ": " + str(self.ohms) + " ohm"

data = [("R1", 220, 0.25),
        ("R2", 1000, 0.25),
        ("R3", 47, 0.5)]
parts = []
for name, ohms, watts in data:
    parts.append(Resistor(name, ohms, watts))
print(len(parts))
print(parts[1], parts[1].ohms)
for r in parts:
    print(r)
المُخرَج
3
R2: 1000 ohm 1000
R1: 220 ohm
R2: 1000 ohm
R3: 47 ohm
: Resistorname"R1"ohms220watts0.25: Resistorname"R2"ohms1000watts0.25: Resistorname"R3"ohms47watts0.5012parts
في خانات القائمة مراجع، والكائنات نفسها في مكان آخر من الذاكرة
مثال 9.5
المقاومات الثلاث موصولة على التوالي ويمر فيها تيار 20 mA. نطبع التي تتجاوز قدرتها P = I2 R القدرة المسموحة لها، ثم نجد أكبر مقاومة.
الحل:
amps = 0.02
count = 0
for r in parts:
    if r.power(amps) > r.watts:
        print(r.name, "overloaded:", r.power(amps), "W")
        count += 1
print("Overloaded:", count)

big = parts[0]
for r in parts:
    if r.ohms > big.ohms:
        big = r
print("Largest:", big)
big.watts = 1
print(parts[1].watts)

المُخرَج: R2 overloaded: 0.4 W , Overloaded: 1 , Largest: R2: 1000 ohm , 1

هذه خوارزميات الفصل 6 نفسها. الفرق أن big يحفظ مرجعا إلى الكائن كله، فنطبعه بعد الحلقة أو نغيّره: غيّرنا عبر big، فظهر التغيير في parts[1].

طباعة القائمة: __str__ أم __repr__؟:

خطأ شائع: كتبنا __str__، فصار print(leds[0]) يعمل كما نحب. ثم طبعنا القائمة كلها، فخرجت عناوين بدل الأوصاف:
class Led:
    def __init__(self, pin):
        self.pin = pin

    def __str__(self):
        return "LED on pin " + str(self.pin)

leds = [Led(13), Led(12)]
print(leds[0])
print(leds)
المُخرَج
LED on pin 13
[<__main__.Led object at 0x000001F2A4C8>, <__main__.Led object at 0x000001F2A4C8>]

العنوان يختلف من كائن إلى كائن ومن تشغيل إلى تشغيل، وعلى حاسوبنا سيظهر بأرقام أخرى. وقد ثبّتناه في الكتاب كي لا يتغيّر المُخرَج، ولذلك يُظهر الكائنان هنا العدد نفسه. والسبب أن __str__ هو الوصف للقارئ، وهو ما يطلبه print وstr. أما القائمة فتعرض عناصرها بالوصف التقني __repr__، وإذا لم نعرّفه حصلنا على الوصف الافتراضي الذي فيه العنوان. نعرّفه في الفئة:

def __repr__(self):
    return "repr" + str(self.pin)

وهذه الحالات الثلاث بتشغيل حقيقي، مع led = Led(13). وإذا لم نعرّف __str__ استعمل print الوصف __repr__ بدلا منه، والعكس لا يصح:

ما عرّفناه في الفئةprint(led)print([led])
__str__str13[<__main__.Led object at 0x000001F2A4C8>]
__repr__repr13[repr13]
كلاهماstr13[repr13]

أنواع مختلفة في قائمة واحدة:

في القائمة نفسها يمكن أن نضع كائنات من فئات مختلفة. لكل فئة __str__ خاص بها. الأمر في الحلقة واحد، والنتيجة حسب نوع الكائن:

class Resistor:
    def __init__(self, name, ohms):
        self.name = name
        self.ohms = ohms

    def __str__(self):
        return self.name + ": " + str(self.ohms) + " ohm"

class Capacitor:
    def __init__(self, name, uf):
        self.name = name
        self.uf = uf

    def __str__(self):
        return self.name + ": " + str(self.uf) + " uF"

parts = [Resistor("R1", 220), Capacitor("C1", 100)]
for part in parts:
    print(part)
المُخرَج
R1: 220 ohm
C1: 100 uF

هذا هو التحميل الزائد (העמסה) كما يفيدنا هنا: عمليّة باسم واحد، ولكل فئة كتابتها، والمُفسّر يختار حسب الكائن الذي بين يديه، وفي الفصل 11 نسمّي هذا باسمه الدقيق: تعدّد الأشكال. وقد رأينا الفكرة نفسها في الإشارة +، التي تجمع الأعداد وتصل الجمل النصية. وبدون ذلك يجب أن تعرف الحلقة كل الأنواع، وكل نوع جديد يعني تعديلها:

for part in parts:
    if type(part) == Resistor:
        print(part.name + ": " + str(part.ohms) + " ohm")
    elif type(part) == Capacitor:
        print(part.name + ": " + str(part.uf) + " uF")
خطأ شائع: التكرار [Led(0)] * 3 لا ينشئ ثلاثة كائنات، بل ثلاثة مراجع إلى كائن واحد. والقصّ good[:] ينشئ قائمة جديدة، لكن عناصرها هي الكائنات نفسها: حذف عنصر من النسخة لا يمس الأصل، أما تغيير كائن عبر النسخة فيظهر في الأصل:
class Led:
    def __init__(self, pin):
        self.pin = pin
        self.lit = False

    def __repr__(self):
        return str(self.pin) + ":" + str(self.lit)

bad = [Led(0)] * 3
bad[0].lit = True
print(bad)

good = []
for pin in range(3):
    good.append(Led(pin))
spare = good[:]
spare[1].lit = True
spare.pop()
print(good)
print(len(good), len(spare))
المُخرَج
[0:True, 0:True, 0:True]
[0:False, 1:True, 2:False]
3 2
سؤال 9.5: ماذا يطبع البرنامج التالي؟
class Cell:
    def __init__(self, volts):
        self.volts = volts

cells = [Cell(3.7), Cell(3.6), Cell(3.9)]
low = cells[0]
for c in cells:
    if c.volts < low.volts:
        low = c
low.volts = 4.2
print(cells[1].volts)
print(cells[0].volts + cells[2].volts)

9.5 قائمة من الكائنات كصفة في فئة – רשימה של עצמים כתכונה במחלקה

حتى الآن كانت القائمة متغيّرا في البرنامج، وكانت الحلقات منثورة حوله. الخطوة التالية أن نضعها داخل كائن يملكها، ويقدم عمليّات تعمل عليها. مثالنا سلسلة مقاومات موصولة على التوالي:

+−R1100R2200R3300VI
دائرة التوالي الكهربائية التي تمثلها الفئة Chain
R = R1 + R2 + R3
I = VR
Vk = I · Rk
class Resistor:
    def __init__(self, name, ohms):
        self.name = name
        self.ohms = ohms

    def __repr__(self):
        return self.name + "=" + str(self.ohms)
class Chain:
    def __init__(self, volts):
        self.volts = volts
        self.parts = []

    def add(self, resistor):
        self.parts.append(resistor)

    def total(self):
        t = 0
        for r in self.parts:
            t += r.ohms
        return t

    def current(self):
        return self.volts / self.total()

    def drops(self):
        i = self.current()
        for r in self.parts:
            print(r.name, round(i * r.ohms, 2), "V")
c = Chain(12)
c.add(Resistor("R1", 100))
c.add(Resistor("R2", 200))
c.add(Resistor("R3", 300))
print(c.parts)
print(c.total(), "ohm", c.current(), "A")
c.drops()
print(c.parts[1].ohms)

المُخرَج:
[R1=100, R2=200, R3=300]
600 ohm 0.02 A
R1 2.0 V
R2 4.0 V
R3 6.0 V
200

تُهيَّأ القائمة فارغة في عمليّة البناء، وتملؤها add، وتمر عليها total وdrops. والمستعمل يتعامل مع السلسلة، ولا يحتاج إلى أن يعرف كيف تُحفظ مقاوماتها. والسطر الأخير يبيّن مسار الوصول كله: كائن، ثم قائمة، ثم عنصر، ثم صفة.

: Resistorname"R1"ohms100: Resistorname"R2"ohms200: Resistorname"R3"ohms300012c: Chainvolts12parts
مسار التعبير c.parts[1].ohms
Chain+ volts : float+ parts : list+ add(r: Resistor)+ total() : float+ current() : float+ drops()Resistor+ name : str+ ohms : float+ __repr__() : str*1..*parts
مخطّط السلسلة. المعيّن فارغ، أي تجميع: المقاومات تُمرَّر إلى السلسلة من الخارج، وقد تحملها سلسلة أخرى أيضا
خطأ شائع: تُكتب القائمة داخل __init__، بالصيغة self.parts = []. إذا كتبناها في جسم الفئة كانت متغيّر فئة، كما تعلمنا في الفصل 8، فتشترك فيها كل الكائنات:
class Chain:
    parts = []

    def add(self, ohms):
        self.parts.append(ohms)

a = Chain()
b = Chain()
a.add(100)
print(b.parts)
المُخرَج
[100]
خطأ شائع: في عمليّات الفئة نكتب self.parts وليس parts وحدها، وإلا توقف البرنامج لأن هذا الاسم غير معروف. ومن الخارج لا نمر على الكائن نفسه: for r in c: يوقف البرنامج، لأن القائمة في الصفة وليست هي الكائن.
مثال 9.6
لوحة أردوينو Board تحمل ثنائيات باعثة للضوء LED. لا يجوز أن يكون ثنائيان على الطرف نفسه، ولذلك تفحص add ذلك قبل الإضافة، وتعيد True أو False.
الحل:
class LED:
    def __init__(self, pin, color):
        self.pin = pin
        self.color = color
        self.on = False

    def toggle(self):
        self.on = not self.on

    def __repr__(self):
        return self.color + ":" + str(self.on)
class Board:
    def __init__(self, name):
        self.name = name
        self.leds = []

    def led_at(self, pin):
        for led in self.leds:
            if led.pin == pin:
                return led
        return None

    def add(self, led):
        if self.led_at(led.pin) is None:
            self.leds.append(led)
            return True
        return False

    def __str__(self):
        return self.name + " " + str(self.leds)
uno = Board("Uno")
print(uno.add(LED(13, "red")))
print(uno.add(LED(13, "blue")))
uno.add(LED(12, "green"))
uno.led_at(13).toggle()
print(uno)
print(uno.led_at(9))

المُخرَج:
True
False
Uno [red:True, green:False]
None

Board+ name : str+ leds : list+ led_at(pin: int) : LED+ add(led: LED) : bool+ __str__() : strLED+ pin : int+ color : str+ on : bool+ toggle()+ __repr__() : str1*leds

العمليّة add تعتمد على led_at ولا تعيد كتابة البحث. وled_at(13) تعيد الكائن نفسه الذي في القائمة، ولذلك أضاء الثنائي الموجود في اللوحة. وعندما لا تجده تعيد None، وإذا قرأنا منها صفة بلا فحص توقف البرنامج بالخطأ AttributeError، فنفحص أولا بالصيغة led is None. والتعبير str(self.leds) يعرض القائمة بالوصف __repr__، وهذا سبب تعريفنا له في LED.

سؤال 9.6: الفئتان Resistor وChain كما في بداية البند. ماذا يطبع البرنامج التالي؟
c = Chain(6)
c.add(Resistor("A", 10))
c.add(Resistor("B", 20))
d = Chain(6)
d.add(c.parts[0])
d.parts[0].ohms = 30
print(c.total(), d.total())
print(len(c.parts), len(d.parts))
▶تجربة حية — مختبر البنى المتداخلةبرامج المختبر الجاهزة تعرض قوائم بداخلها قوائم وقواميس، طبقة فوق طبقة، وترسم المسار إلى كل عنصر، وهو المعنى نفسه الذي في c.parts[1].ohms. وآخرها برنامج فئة بداخلها كائن من فئة أخرى، يرسم المسار c.center.x صفةً بعد صفة، وتطلب فيه __str__ الجملة النصية للكائن الداخلي بالدالة str، كما في هذا الفصل. ونستطيع أن نلصق في محرّره برامج هذا الفصل وننفذها خطوة بعد خطوة.
⚠
المختبر غير متاح الآن. المادة في هذا الفصل كاملة من دونه — نواصل القراءة، ونعود إلى التجربة لاحقا أو نفتحها مباشرة من الرابط الذي تحت الإطار.
https://fahed-lab.com/electronics/python_basics#compound
نفتح المختبر في صفحة مستقلة ↗ fahed-lab.com/electronics/python_basics#compound
مشروع الكتاب: محطة القياس · المرحلة 9 — فئة المحطة

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

Station+ cutoff : float+ name : str+ sensors : list+ __init__(name: str)+ add(sensor: Sensor)+ find(name: str) : Sensor+ alarm_count() : int+ report()Sensor+ step : float+ name : str+ unit : str+ scale : float+ raw : int+ __init__(name: str, unit: str, scale: float)+ read(raw: int)+ value() : float+ __str__() : str1*sensors
لكل محطة أي عدد من الحسّاسات، ولكل حسّاس محطة واحدة

الفئة Sensor هي نفسها التي كتبناها في الفصل 8، ونضعها في الملف قبل الفئة Station:

class Station:
    cutoff = 11.8

    def __init__(self, name):
        self.name = name
        self.sensors = []

    def add(self, sensor):
        self.sensors.append(sensor)

    def find(self, name):
        for s in self.sensors:
            if s.name == name:
                return s
        return None

    def alarm_count(self):
        count = 0
        for s in self.sensors:
            if s.unit == "V" and s.value() < Station.cutoff:
                count += 1
        return count

    def report(self):
        print("Station", self.name)
        for s in self.sensors:
            print(" ", s)
        print("Alarms:", self.alarm_count())
st = Station("Roof")
st.add(Sensor("Battery", "V", 4))
st.add(Sensor("Backup", "V", 4))
st.add(Sensor("Temp", "C", 100))
st.find("Battery").read(620)
st.find("Backup").read(585)
st.find("Temp").read(65)
st.report()
print(st.find("Fan"))

المُخرَج:
Station Roof
Battery: 12.12 V
Backup: 11.44 V
Temp: 31.77 C
Alarms: 1
None

الحسّاسات مختلفة، و__str__ في الفئة يجعل التقرير يطبع كلا منها بأمر واحد. وحد فصل الحمل 11.8 V من الفصل 3 قيمة واحدة لكل المحطات، فهو متغيّر فئة. ونعدّ الإنذار لحسّاسات الجهد فقط، لأن حرارة منخفضة ليست بطارية فارغة. والأمر st.find("Battery").read(620) يعمل لأن find تعيد الكائن نفسه الذي في القائمة، والسطر الأخير يبيّن ما يعود عندما لا يوجد الحسّاس.

نجرّب بأنفسنا: نضيف إلى Station عمليّة active_count تعيد عدد الحسّاسات التي قرأت شيئا، أي أن raw لها غير صفر. وفي الفصل 10 ستصل القراءات عبر المنفذ التسلسلي من أردوينو بدل أن نكتبها في البرنامج.

أهمّ أفكار الفصل:

ملخّص أوامر الفصل:

الأمرعمله
self.part = partصفة تحفظ مرجعا إلى كائن من فئة أخرى
self.part = Part(x, y)الكائن يُنشئ جزءه بنفسه: تركيب
a.b.c a.b.f()الوصول عبر كائن داخلي
a is b x is Noneهل هما كائن واحد، وهل لا يوجد كائن
items = [Part(1), Part(2)]قائمة من الكائنات
for item in items:المرور على الكائنات
items[i].xصفة في عنصر من القائمة
self.items = []قائمة كصفة، تُهيَّأ في عمليّة البناء
def __repr__(self):الوصف الذي يظهر عند طباعة القائمة
self._xصفة خاصة، باتفاق بين المبرمجين

قاموس المصطلحات – מילון מונחים:

بالعربيةبالعبرية (كما تظهر في الامتحان)English
النوع المركّبטיפוס מורכבCompound type
الاحتواءהכלהContainment
المرجعהפניהReference
مخطّط UMLתרשים UMLUML diagram
مخطّط الفئةתרשים מחלקהClass diagram
محدِّد الوصولהרשאת גישהAccess modifier
عامّציבוריPublic
خاصّפרטיPrivate
الارتباطשיוךAssociation
التركيبהרכבהComposition
التجميعקיבוץAggregation
التعدّديةריבויMultiplicity
التحميل الزائدהעמסהOverloading
الإجابات النهائية لأسئلة الفصل
9.1 6.0 , 24 , 12.0
9.2 (أ) و(ج) صفتان بسيطتان، لأن كلا منهما قيمة واحدة. (ب) و(د) تستحقان فئة، لأن لكل منهما أجزاء أو عملا خاصا به.
9.3 9 1 , True False
9.4 (أ) واحد على الأقل. (ب) لا، لا توجد في Switch صفة تشير إلى اللوحة. (ج) اللوحة نفسها. (د) كائنات من الفئة Switch.
9.5 4.2 , 7.6
9.6 50 30 , 2 1

أسئلة للتمرين:

المستوى الأول — أساسي
1.
الفئتان Point وCircle كما في البند 9.2. ماذا يطبع البرنامج؟
c = Circle(Point(1, 2), 3)
c.center.move(2, 2)
print(c)
print(c.center.x + c.radius)
c.center = Point(0, 0)
print(c.contains(Point(3, 0)))
الإجابات: center (3, 4), radius 3 , 6 , True
2.
نكتب الفئتين اللتين في المخطّط. __str__ في الطرف يعيد مثلا pin 2 INPUT. ننشئ زرا ونغيّر نمط طرفه عبر الزر.
Button+ label : str+ pin : Pin+ __str__() : strPin+ number : int+ mode : str+ __str__() : str11pin
الإجابات:
class Pin:
    def __init__(self, number, mode):
        self.number = number
        self.mode = mode

    def __str__(self):
        return "pin " + str(self.number) + " " + self.mode

class Button:
    def __init__(self, label, pin):
        self.label = label
        self.pin = pin

    def __str__(self):
        return self.label + " on " + str(self.pin)

b = Button("Start", Pin(2, "INPUT"))
b.pin.mode = "OUTPUT"
print(b)
يطبع: Start on pin 2 OUTPUT
3.
نرسم مخطّط UML للفئتين التاليتين.
class Motor:
    def __init__(self, volts):
        self.volts = volts

class Robot:
    def __init__(self, name):
        self.name = name
        self.motors = []

    def add(self, motor):
        self.motors.append(motor)

    def count(self):
        return len(self.motors)
الإجابات: الروبوت يحمل أي عدد من المحرّكات، وكل محرّك جزء من روبوت واحد، فنرسم تركيبا.
Robot+ name : str+ motors : list+ __init__(name: str)+ add(motor: Motor)+ count() : intMotor+ volts : float+ __init__(volts: float)1*motors
4.
يطبع البرنامج التالي عناوين بدل وصف المرحّلات. لماذا؟ نصلحه.
class Relay:
    def __init__(self, pin):
        self.pin = pin

    def __str__(self):
        return "Relay on " + str(self.pin)

relays = [Relay(4), Relay(5)]
print(relays)
الإجابات: القائمة تعرض عناصرها بالوصف __repr__، وهو غير معرّف. نغيّر اسم العمليّة، فيعمل عندها print(relays[0]) أيضا:
class Relay:
    def __init__(self, pin):
        self.pin = pin

    def __repr__(self):
        return "Relay on " + str(self.pin)

relays = [Relay(4), Relay(5)]
print(relays)
print(relays[0])
يطبع: [Relay on 4, Relay on 5] , Relay on 4
5.
ماذا يطبع البرنامج، ولماذا؟
class Cell:
    def __init__(self, volts):
        self.volts = volts

class Pack:
    def __init__(self, cell):
        self.cell = cell

c = Cell(3.7)
p1 = Pack(c)
p2 = Pack(c)
p3 = Pack(Cell(3.7))
p1.cell.volts = 3.2
print(p2.cell.volts, p3.cell.volts, c.volts)
الإجابات: يطبع: 3.2 3.7 3.2. الكائنان p1 وp2 يشيران إلى الخلية نفسها c، أما p3 فله خلية خاصة به.
المستوى الثاني — متوسط
6.
سلك على لوحة يصل بين نقطتين. نكتب الفئة Wire التي تحمل نقطتين من الفئة Point، ولها length() وresistance()، على فرض أن مقاومة السلك 0.02 Ω لكل وحدة طول.
الإجابات:
class Wire:
    ohm_per_m = 0.02

    def __init__(self, start, end):
        self.start = start
        self.end = end

    def length(self):
        return self.start.distance(self.end)

    def resistance(self):
        return round(self.length() * Wire.ohm_per_m, 3)

w = Wire(Point(0, 0), Point(6, 8))
print(w.length(), w.resistance())
w.end.move(-6, 0)
print(w.length(), w.resistance())
يطبع: 10.0 0.2 , 8.0 0.16
7.
نكتب فئة Bank لمقاومات موصولة على التوازي، لها قائمة مقاومات وعمليّة total حسب القانون:
1R = 1R1 + 1R2 + ...
الإجابات:
class Resistor:
    def __init__(self, ohms):
        self.ohms = ohms

class Bank:
    def __init__(self):
        self.parts = []

    def add(self, r):
        self.parts.append(r)

    def total(self):
        s = 0
        for r in self.parts:
            s += 1 / r.ohms
        return round(1 / s, 2)

b = Bank()
b.add(Resistor(100))
b.add(Resistor(100))
print(b.total())
b.add(Resistor(50))
print(b.total())
يطبع: 50.0 , 25.0
8.
نضيف إلى الفئة Board من البند 9.5 عمليّة remove(pin) تحذف الثنائي الموجود على الطرف، وتعيد True أو False. نستعمل led_at ولا نكتب البحث من جديد.
الإجابات:
def remove(self, pin):
    led = self.led_at(pin)
    if led is None:
        return False
    self.leds.remove(led)
    return True
في لوحة فيها ثنائيان على الطرفين 13 و12، يعطي حذف 9 ثم 13 وبعدهما طباعة اللوحة: False , True , Uno [green:False]
9.
لدينا نوعان من الأحمال، ولكل منهما طريقة لحساب القدرة: مصباح (P = VI) وسخّان (P = V2/R). نجمعها في قائمة واحدة، ونحسب القدرة الكلية بحلقة لا تفحص النوع.
الإجابات:
class Lamp:
    def __init__(self, volts, amps):
        self.volts = volts
        self.amps = amps

    def watts(self):
        return self.volts * self.amps

class Heater:
    def __init__(self, volts, ohms):
        self.volts = volts
        self.ohms = ohms

    def watts(self):
        return self.volts ** 2 / self.ohms

loads = [Lamp(12, 0.5), Heater(12, 24), Lamp(12, 1)]
total = 0
for load in loads:
    total += load.watts()
print(total, "W")
يطبع: 24.0 W. لكل فئة عمليّة watts خاصة بها، كما فعلنا مع __str__.
المستوى الثالث — تحدٍّ
10.
نضيف إلى Station عمليّة sort_by_value() ترتّب الحسّاسات تصاعديا حسب القيمة بالفرز الفقاعي من الفصل 6. عند التبديل، ماذا ينتقل فعلا في القائمة؟
الإجابات:
def sort_by_value(self):
    n = len(self.sensors)
    for i in range(n - 1):
        for j in range(n - 1 - i):
            a = self.sensors[j]
            b = self.sensors[j + 1]
            if a.value() > b.value():
                self.sensors[j] = b
                self.sensors[j + 1] = a
تنتقل المراجع فقط، والكائنات لا تتحرك ولا تُنسخ. في محطة فيها الحسّاسان Battery وBackup، يصبح أول عنصر بعد الفرز: Backup: 11.44 V
11.
شبكة محطات: نكتب الفئة Network التي تحمل قائمة من الفئة Station، ولها total_alarms(). نرسم مخطّط الفئات الثلاث.
الإجابات:
class Network:
    def __init__(self):
        self.stations = []

    def add(self, station):
        self.stations.append(station)

    def total_alarms(self):
        total = 0
        for st in self.stations:
            total += st.alarm_count()
        return total
في شبكة من محطتين، فيها إنذاران في المحطة Yard، يطبع: 2
Network+ stations : list+ add(st: Station)+ total_alarms() : intStation+ name : str+ sensors : list+ alarm_count() : intSensor+ name : str+ raw : int+ value() : float1*stations1*sensors
12.
نريد أن يكون مركز الدائرة في نقطة الأصل إذا لم يُحدَّد. نكتب center=Point(0, 0). ماذا يطبع البرنامج، ولماذا؟ نصلحه.
class Circle:
    def __init__(self, radius, center=Point(0, 0)):
        self.center = center
        self.radius = radius

a = Circle(5)
b = Circle(8)
a.center.move(3, 4)
print(b.center)
الإجابات: يطبع: (3, 4). القيمة الافتراضية تُنشأ مرة واحدة، عند تعريف العمليّة، فتتشارك الدائرتان نقطة واحدة. نجعلها None، وننشئ النقطة داخل العمليّة:
class Circle:
    def __init__(self, radius, center=None):
        if center is None:
            center = Point(0, 0)
        self.center = center
        self.radius = radius

a = Circle(5)
b = Circle(8)
a.center.move(3, 4)
print(a.center, b.center)
يطبع: (3, 4) (0, 0)
13.
نضيف إلى Circle عمليّة copy() تعيد دائرة جديدة مستقلة تماما عن الأصلية. نثبت بالتشغيل أن تحريك النسخة لا يحرّك الأصل.
الإجابات:
def copy(self):
    twin = Point(self.center.x, self.center.y)
    return Circle(twin, self.radius)
a = Circle(Point(1, 1), 4)
b = a.copy()
b.center.move(5, 5)
print(a.center, b.center)
print(a is b, a.center is b.center)
يطبع: (1, 1) (6, 6) , False False. لو كتبنا Circle(self.center, self.radius) مع عمليّة البناء الأصلية، التي تحفظ المرجع فقط، لحصلنا على دائرة جديدة لها المركز القديم نفسه.