Polymorphism: ממשק משותף, התנהגות שונה

מטרות השיעור
  • להסביר מהו Polymorphism במילים פשוטות ובדוגמה
  • לכתוב קוד שעובד על אובייקטים מסוגים שונים דרך ממשק משותף, בלי לבדוק את הסוג
  • להבין למה Polymorphism מחליף שרשראות של if/elif לפי סוג
  • להכיר את Duck Typing — Polymorphism בלי ירושה משותפת

מה נלמד?

  • מהו Polymorphism (רב-צורתיות).
  • ממשק משותף (Common Interface): אותה מתודה, התנהגות שונה לכל סוג.
  • למה זה מחליף את ה-if type == ....
  • Duck Typing: הגרסה הגמישה של Python.

למה צריך את זה?

נניח שמערכת תשלומים צריכה לחייב לקוח, והלקוח יכול לשלם בכרטיס אשראי, ב-PayPal או בהעברה בנקאית. בלי Polymorphism:

def pay(method, amount):
    if method["type"] == "credit":
        print("Charging card", method["card"], amount)
    elif method["type"] == "paypal":
        print("Redirecting", method["email"], "to PayPal for", amount)
    elif method["type"] == "bank":
        print("Waiting for transfer of", amount, "to account", method["account"])

ומה קורה כשמוסיפים Apple Pay? צריך להוסיף elif כאן, וגם בפונקציה של החזרים, וגם בדוח החודשי, וגם בבדיקת העמלות… כל פונקציה שמטפלת בתשלומים מכילה את אותה שרשרת if. שוכחים אחד, ויש באג.

ביום הראשון של השבוע זו הייתה “בעיה 4”. Polymorphism הוא הפתרון שלה.

הסבר מהבסיס

מהו Polymorphism?

Polymorphism (מיוונית: “צורות רבות”) = היכולת לעבוד עם אובייקטים מסוגים שונים דרך אותו ממשק, כשכל אובייקט מבצע את הפעולה בדרך שלו.

במילים פשוטות: אותה קריאה (method.pay(100)) ‏←‏ התנהגות שונה, לפי סוג האובייקט. הקוד שקורא לא צריך לדעת איזה סוג יש לו ביד.

אנלוגיה: שלט רחוק אוניברסלי

לשלט יש כפתור “הפעלה”. לוחצים עליו מול טלוויזיה, והיא נדלקת. מול מזגן, הוא מתחיל לקרר. מול רמקול, הוא מתחיל לנגן. אותו כפתור (ממשק), פעולה שונה לכל מכשיר. ואתם, שמחזיקים את השלט, לא צריכים לדעת איך כל מכשיר עובד מבפנים. מגיע מכשיר חדש? אם הוא “מבין” את הכפתור, השלט עובד איתו בלי שום שינוי.

איך זה עובד: ממשק משותף

  1. מגדירים ב-Parent את הממשק: אילו מתודות יש לכל הסוגים (pay, refund, fee).
  2. כל Child דורסת (Override) את המתודות בדרך שלה.
  3. הקוד שמשתמש עובד מול הממשק: method.pay(amount), ולא מול הסוגים.

כבר ראינו את זה! ב-describe() של Employee, שקראה ל-self.monthly_cost() והגיעה לגרסה הנכונה של מנהל או מתמחה. וב-describe() של Shape שעבד לכל צורה.

היתרון העיקרי: Open/Closed

עם Polymorphism, הוספת סוג חדש (Apple Pay) = כתיבת Class חדשה אחת. אף שורה בקוד הקיים שמשתמש בתשלומים לא משתנה. המערכת פתוחה להרחבה וסגורה לשינוי, עיקרון חשוב בתכנון תוכנה.

Duck Typing

ב-Python, Polymorphism לא מחייב ירושה. Python לא בודקת “האם האובייקט יורש מ-PaymentMethod?”. היא רק מנסה לקרוא למתודה. אם המתודה קיימת, זה עובד.

“If it walks like a duck and quacks like a duck, it’s a duck.” (אם זה הולך כמו ברווז ומגעגע כמו ברווז, זה ברווז.)

Duck Typing = “לא משנה מה אתה, משנה מה אתה יודע לעשות”. כבר השתמשנו בזה: len() עובדת על str, list, dict ו-set, כי לכולם יש “אורך”. for עובד על כל מה שאפשר לעבור עליו.

ובכל זאת, ירושה מ-Parent משותף היא הדרך המומלצת כשמתכננים ממשק במכוון: היא מתעדת מה הממשק, ומאפשרת קוד משותף.

תחביר

class Base:
    def action(self):                    # the common interface
        raise NotImplementedError("subclasses must implement action()")


class TypeA(Base):
    def action(self):
        return "A does it this way"


class TypeB(Base):
    def action(self):
        return "B does it another way"


for item in [TypeA(), TypeB()]:          # polymorphic code:
    print(item.action())                 # one call, many behaviors, no if/elif

raise NotImplementedError ב-Parent = “כל Child חייבת לממש את המתודה הזו”. אם Child שכחה, מקבלים שגיאה ברורה ברגע הקריאה.

דוגמת קוד ראשונה

מערכת התשלומים, עם Polymorphism. week-3/payments.py:

class PaymentMethod:
    def __init__(self, owner):
        self.owner = owner

    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")

    def fee(self, amount):
        return 0


class CreditCard(PaymentMethod):
    def __init__(self, owner, last_digits):
        super().__init__(owner)
        self.last_digits = last_digits

    def pay(self, amount):
        return "Charged " + str(amount) + " to card ****" + self.last_digits

    def fee(self, amount):
        return round(amount * 0.02, 2)


class PayPal(PaymentMethod):
    def __init__(self, owner, email):
        super().__init__(owner)
        self.email = email

    def pay(self, amount):
        return "Sent " + str(amount) + " via PayPal from " + self.email


class BankTransfer(PaymentMethod):
    def pay(self, amount):
        return "Waiting for a bank transfer of " + str(amount) + " from " + self.owner


def checkout(method, amount):
    total = amount + method.fee(amount)
    print(method.pay(total), "| fee:", method.fee(amount))


methods = [CreditCard("Dana", "4321"), PayPal("Avi", "avi@mail.com"), BankTransfer("Noa")]
for method in methods:
    checkout(method, 100)

פלט:

Charged 102.0 to card ****4321 | fee: 2.0
Sent 100 via PayPal from avi@mail.com | fee: 0
Waiting for a bank transfer of 100 from Noa | fee: 0

פירוק הקוד שורה אחר שורה

קודהסבר
class PaymentMethodהממשק: כל אמצעי תשלום חייב pay, ויש לו fee (ברירת מחדל 0).
raise NotImplementedError“מתודה מופשטת”: מגדירה ממשק, בלי מימוש.
CreditCard.pay, PayPal.pay, BankTransfer.payכל אחד מממש את pay בדרך שלו.
CreditCard.feeOverride: עמלה של 2%. האחרים יורשים את ה-0 של ה-Parent.
def checkout(method, amount):הקוד הפולימורפי: לא בודק את הסוג, לא מכיל if. רק קורא ל-method.fee() ול-method.pay().
הלולאהאותה פונקציה בדיוק, שלוש התנהגויות שונות.

דוגמה נוספת

הוספת סוג חדש — בלי לגעת בקוד קיים. ואז Duck Typing.

class ApplePay(PaymentMethod):
    def __init__(self, owner, device):
        super().__init__(owner)
        self.device = device

    def pay(self, amount):
        return "Paid " + str(amount) + " with Apple Pay on " + self.device

    def fee(self, amount):
        return 1


checkout(ApplePay("Tom", "iPhone 15"), 100)
Paid 101 with Apple Pay on iPhone 15 | fee: 1

checkout לא השתנתה בכלל. הוספנו Class, והיא עובדת. בגרסת ה-if/elif היינו צריכים לערוך כל פונקציה שמטפלת בתשלומים.

Duck Typing — אובייקט שלא יורש מ-PaymentMethod:

class GiftCard:
    def __init__(self, code):
        self.code = code

    def pay(self, amount):
        return "Redeemed " + str(amount) + " from gift card " + self.code

    def fee(self, amount):
        return 0


checkout(GiftCard("GIFT-2026"), 100)
Redeemed 100 from gift card GIFT-2026 | fee: 0

GiftCard לא יורשת מ-PaymentMethod, אבל יש לה pay ו-fee, ולכן checkout עובדת איתה. זה Duck Typing. (בפרויקט אמיתי עדיף בכל זאת שתירש, כדי שהממשק יהיה מתועד ומפורש.)

עוד דוגמה מוכרת: הודעות

class Notification:
    def __init__(self, recipient):
        self.recipient = recipient

    def send(self, text):
        raise NotImplementedError


class EmailNotification(Notification):
    def send(self, text):
        return "Email to " + self.recipient + ": " + text


class SmsNotification(Notification):
    def send(self, text):
        return "SMS to " + self.recipient + ": " + text[:20]


def notify_all(channels, text):
    for channel in channels:
        print(channel.send(text))


notify_all([EmailNotification("dana@mail.com"), SmsNotification("050-1234567")],
           "Your order #1042 has shipped and will arrive tomorrow")
Email to dana@mail.com: Your order #1042 has shipped and will arrive tomorrow
SMS to 050-1234567: Your order #1042 has

לכל ערוץ כללים משלו (SMS קוצץ ל-20 תווים), ו-notify_all לא יודעת ולא אכפת לה.

טעות נפוצה

הטעות 1: לבדוק סוג במקום להשתמש ב-Polymorphism.

def checkout(method, amount):
    if isinstance(method, CreditCard):
        ...
    elif isinstance(method, PayPal):
        ...

זה מחזיר אותנו בדיוק לבעיה המקורית, רק עם Classes. אם מוצאים את עצמכם כותבים if isinstance(...) על Classes שלכם כדי להחליט איך לבצע פעולה, זה סימן שהפעולה צריכה להיות מתודה שכל Class מממשת.

הטעות 2: לשכוח לממש מתודה מופשטת.

class Cash(PaymentMethod):
    pass


checkout(Cash("Rina"), 50)
NotImplementedError: subclasses must implement pay()

וזה טוב: השגיאה ברורה ומצביעה בדיוק על מה שחסר. בלי ה-raise ב-Parent, הייתה מתקבלת התנהגות שקטה ושגויה, או None במקום מחרוזת.

הטעות 3: ממשק לא אחיד בין הסוגים.

class CreditCard(PaymentMethod):
    def pay(self, amount, cvv):
        ...

עכשיו checkout (שקוראת method.pay(total)) נכשלת רק על כרטיסי אשראי. כל המימושים צריכים לקבל את אותם פרמטרים. מידע שספציפי לסוג (כמו CVV) שייך ל-__init__ או ל-Attribute של אותו סוג.

הטעות 4: לחשוב ש-Polymorphism מחייב ירושה. ב-Python לא (Duck Typing). אבל ירושה מ-Base משותף מתעדת את הממשק, ומונעת טעויות. העדיפו אותה כשמתכננים.

תרגול

התרגילים מבוססים רק על מה שנלמד בשיעור הזה ובשיעורים שלפניו. נסו לבד, ורק אז פתחו את הפתרון.

מה מתרגלים בדף הזה: פולימורפיזם — אותה קריאה לכמה סוגים, כשכל סוג מתנהג אחרת; הוספת סוג חדש בלי לשנות קוד קיים; Duck Typing ו-NotImplementedError.

בסוף הדף תדעו:

  • להחליף שרשרת if לפי סוג במתודה משותפת שכל סוג מממש
  • להוסיף סוג חדש בלי לגעת בקוד שמשתמש בו
  • להסביר מה זה Duck Typing
  • לחייב Children לממש מתודה בעזרת NotImplementedError

תרגיל 1: במקום שרשרת if

מה בונים: אמצעי תשלום בקופה — בלי לבדוק בקוד מה הסוג של כל אחד.

הקוד שמחליפים:

def pay(method, amount):
    if method["type"] == "credit":
        return "Charging card " + str(amount)
    elif method["type"] == "paypal":
        return "Redirecting to PayPal " + str(amount)

מה לכתוב:

  1. Class בסיס בשם PaymentMethod, עם pay שמקבלת סכום וזורקת NotImplementedError.
  2. שני Children: CreditCard ו-PayPal. כל אחד דורס את pay ומחזיר את המשפט שלו מהפלט.
  3. פונקציה בשם checkout שמקבלת אמצעי תשלום וסכום, וקוראת ל-pay — בלי if על הסוג.

קוד לבדיקה:

for method in [CreditCard(), PayPal()]:
    print(checkout(method, 100))

הפלט הרצוי:

Charging card 100
Redirecting to PayPal 100
הצג פתרון
class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")


class CreditCard(PaymentMethod):
    def pay(self, amount):
        return "Charging card " + str(amount)


class PayPal(PaymentMethod):
    def pay(self, amount):
        return "Redirecting to PayPal " + str(amount)


def checkout(method, amount):
    return method.pay(amount)


for method in [CreditCard(), PayPal()]:
    print(checkout(method, 100))
Charging card 100
Redirecting to PayPal 100

checkout לא יודעת ולא צריכה לדעת איזה סוג תשלום זה — היא רק קוראת ל-pay. כל סוג מממש אותה אחרת. זה Polymorphism.

תרגיל 2: סוג חדש — בלי לגעת ב-checkout

מה בונים: ממשיכים את הקופה — מוסיפים אמצעי תשלום חדש ועמלה.

מה לכתוב:

  1. Child חדש בשם ApplePay, עם pay שמחזירה את המשפט שלו מהפלט.
  2. ב-PaymentMethod מוסיפים fee שמקבלת סכום ומחזירה 0 — ברירת מחדל לכולם.
  3. ב-CreditCard דורסים את fee: עמלה של 2% מהסכום.
  4. מעדכנים את checkout כך שתחזיר גם את העמלה, כמו בפלט.

קוד לבדיקה:

for method in [CreditCard(), PayPal(), ApplePay()]:
    print(checkout(method, 100))

הפלט הרצוי:

Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0
הצג פתרון
class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError("subclasses must implement pay()")

    def fee(self, amount):
        return 0


class CreditCard(PaymentMethod):
    FEE_PERCENT = 2

    def pay(self, amount):
        return "Charging card " + str(amount)

    def fee(self, amount):
        return amount * CreditCard.FEE_PERCENT / 100


class PayPal(PaymentMethod):
    def pay(self, amount):
        return "Redirecting to PayPal " + str(amount)


class ApplePay(PaymentMethod):
    def pay(self, amount):
        return "Paid with Apple Pay " + str(amount)


def checkout(method, amount):
    return method.pay(amount) + " | fee: " + str(method.fee(amount))


for method in [CreditCard(), PayPal(), ApplePay()]:
    print(checkout(method, 100))
Charging card 100 | fee: 2.0
Redirecting to PayPal 100 | fee: 0
Paid with Apple Pay 100 | fee: 0

ApplePay נוסף בלי לשנות אף שורה ב-checkout. PayPal ו-ApplePay יורשים את fee של ה-Parent, ו-CreditCard דורס אותה.

תרגיל 3: דמויות במשחק

מה בונים: קרב בין דמויות במשחק — כשלכל סוג דמות כוח התקפה אחר.

מה לכתוב:

  1. Class בסיס בשם Character, עם שם וחיים, ו-attack_power שזורקת NotImplementedError.
  2. Warrior — כוח ההתקפה שלו תמיד 15.
  3. Mage — כוח ההתקפה שלו 10, ועוד 10 אם יש לו יותר מ-50 חיים.
  4. פונקציה בשם attack שמקבלת תוקף ונתקף: מורידה לנתקף חיים לפי כוח התוקף, ומדפיסה משפט כמו בפלט.

קוד לבדיקה:

conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)

הפלט הרצוי:

Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90
הצג פתרון
class Character:
    def __init__(self, name, health):
        self.name = name
        self.health = health

    def attack_power(self):
        raise NotImplementedError("subclasses must implement attack_power()")


class Warrior(Character):
    def attack_power(self):
        return 15


class Mage(Character):
    STRONG_FROM = 50

    def attack_power(self):
        if self.health > Mage.STRONG_FROM:
            return 20
        return 10


def attack(attacker, target):
    power = attacker.attack_power()
    target.health = target.health - power
    print(attacker.name, "hits", target.name, "for", power)


conan = Warrior("Conan", 100)
merlin = Mage("Merlin", 60)
attack(conan, merlin)
attack(merlin, conan)
print(merlin.name, merlin.health)
print(conan.name, conan.health)
Conan hits Merlin for 15
Merlin hits Conan for 10
Merlin 45
Conan 90

אחרי שקונאן תקף, למרלין נשארו 45 חיים — מתחת ל-50 — ולכן הכוח שלו ירד ל-10. attack לא יודעת כלום על הכללים של כל דמות.

תרגיל 4: Duck Typing

מה עושים: קוראים קוד ומסבירים — בלי לכתוב קוד.

הקוד:

class Duck:
    def speak(self):
        return "Quack"

class Robot:
    def speak(self):
        return "Beep"

def make_it_speak(thing):
    return thing.speak()

המשימה:

  1. Duck ו-Robot לא יורשים מאותו Parent. האם make_it_speak תעבוד עם שניהם? למה?
  2. הסבירו במשפט אחד מה זה Duck Typing.
הצג פתרון
  1. כן. make_it_speak רק קוראת ל-speak() — היא לא בודקת את הסוג. כל אובייקט שיש לו speak מתאים.
  2. Duck Typing: “אם זה הולך כמו ברווז ומגעגע כמו ברווז — זה ברווז”. ב-Python מה שקובע הוא שהמתודה קיימת, לא מאיזה Class האובייקט. Class בסיס משותף עדיין שימושי — כדי להגדיר את הממשק במקום אחד ולתת התנהגות ברירת מחדל — אבל הוא לא חובה.

תרגיל 5: NotImplementedError

מה בונים: צורות שכל אחת יודעת לחשב את השטח שלה — ו-Parent שמחייב את זה.

הקוד:

class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")

class Square(Shape):
    def __init__(self, side):
        self.side = side

המשימה:

  1. מה יקרה בקריאה לשטח של Square(3)? למה זה דווקא טוב?
  2. תקנו את Square כך שתחשב שטח.
  3. הוסיפו Circle שמקבלת רדיוס, ומחשבת שטח עם math.pi, מעוגל לשתי ספרות.
  4. הדפיסו בלולאה את השטח של ריבוע עם צלע 3 ועיגול עם רדיוס 3.

הפלט הרצוי:

9
28.27
הצג פתרון
  1. תיזרק NotImplementedError: subclasses must implement area(). זה טוב: הטעות מתגלה מיד ובבירור — מי שכתב את Square שכח לממש את area. בלי זה הייתה מתקבלת תוצאה שגויה בשקט.
import math


class Shape:
    def area(self):
        raise NotImplementedError("subclasses must implement area()")


class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return round(math.pi * self.radius ** 2, 2)


for shape in [Square(3), Circle(3)]:
    print(shape.area())
9
28.27

סיכום

  • Polymorphism = אותה קריאה, התנהגות שונה לפי סוג האובייקט. הקוד הקורא לא צריך לדעת את הסוג.
  • בונים ממשק משותף ב-Parent (עם raise NotImplementedError למתודות חובה), וכל Child מממשת בדרכה.
  • מחליף שרשראות if isinstance / if type ==. סוג חדש = Class חדשה, בלי לשנות קוד קיים.
  • מתודה ב-Parent שקוראת self.x() מגיעה לגרסה של ה-Child (Template Method).
  • Duck Typing: ב-Python מספיק שלאובייקט יש את המתודה. ירושה משותפת עדיין מומלצת כתיעוד.
  • כל המימושים מקבלים אותם פרמטרים.

דוקומנטציה רשמית