יסודות התכנות בשפת Python · פרק 9 — טיפוסי נתונים מורכבים⌂📚🏠
פרק 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, וכל מתכנת מבין אותה בלי קשר לשפת התכנות שלו.

תרשים מחלקה: מלבן בן שלושה תאים: בראשון שם המחלקה, בשני תכונותיה, ובשלישי פעולותיה.
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 נותן תכונה שלו. לרוב הנתונים מגיעים מבחוץ, ולכן בונים את העצמים בלולאה, טאפל (Tuple) אחר טאפל:

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المرجع
תרשים UMLUML diagramمخطّط UML
תרשים מחלקה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) עם הפעולה הבונה המקורית, ששומרת רק את ההפניה, היינו מקבלים מעגל חדש עם אותו מרכז ישן.