חמש דוגמאות ל Type Hints מעניינים בפייתון

30/04/2026

כתיבת Type Hints היא אחד הרעיונות שמתחילים פשוט ואז מסתבכים כשפוגשים קוד אמיתי. זאת הסיבה שלקח לפייתון כמה שנים עד שהגיעו לתחביר Type Hints שעובד ומשמח את רוב המפתחים. בואו נראה 5 מקרים מאתגרים ואת הפתרון שלהם במערכת הטיפוסים של פייתון.

1. העברת פרמטרים As-Is

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

def retry_on_failure(func):
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception:
            print("Retrying...")
            return func(*args, **kwargs)
    return wrapper

@retry_on_failure
def say_my_name(name: str):
    print(name)

say_my_name('demo')

הבעיה היא שהפעלת הדקורטור מוחקת את ה Type Hint של הפונקציה. דקורטור גורם להחלפת הפונקציה בגרסה שהדקורטור מחזיר, ו wrapper היא פונקציה גנרית שיכולה לקבל כל פרמטר.

בשביל שזה יעבוד wrapper צריכה להגדיר את אותו Type Hint כמו הפונקציה say_my_name, אבל הדקורטור הוא גנרי, איך יודעים מה הפרמטרים שהוא צפוי לקבל?

הטיפוס ParamSpec נועד בדיוק למקרים האלה. טיפוס זה נכנס בפייתון 3.10 ומייצג אוסף פרמטרים כללי ש"תופס" פרמטרים של פונקציה קיימת. בואו נראה את הפתרון המתוקן:

from typing import Callable, TypeVar, ParamSpec

P = ParamSpec('P')
T = TypeVar('T')

# ParamSpec captures the exact arguments (*args, **kwargs) of the wrapped function.
def retry_on_failure(func: Callable[P, T]) -> Callable[P, T]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        try:
            return func(*args, **kwargs)
        except Exception:
            print("Retrying...")
            return func(*args, **kwargs)
    return wrapper

עכשיו ברור לפייתון מה החתימה של הפונקציה wrapper וחתימה זו תלווה את הפונקציה גם בחזרה מהדקורטור, כך שקוד כזה ייפול בבדיקת טיפוסים:

@retry_on_failure
def say_my_name(name: str):
    print(name)

say_my_name(10)

2. בדיקת קיום פונקציה בלי לחייב ירושה

נתונה פונקציה שמקבלת רשימה של דברים שלכולם יש מאפיין או מתודה משותפת. בשפה מקומפלת קלאסית היינו דורשים שכל הדברים "ירשו" ממחלקת בסיס מסוימת שתגדיר את המאפיין המשותף. פייתון מעולם לא חייבה זאת, כלומר הקוד הבא הוא פייתון תקין וטבעי לגמרי:

class ItemA:
    def __init__(self):
        self.price = 10

class ItemB:
    def __init__(self):
        self.price = 5

def total_price(items):
    return sum(item.price for item in items) * 1.18

print(total_price([ItemB(), ItemA(), ItemA(), ItemA()]))

אבל מה עושים כשרוצים להוסיף כאן Type Hints? לא נרצה להגדיר בצורה מפורשת בפונקציה שהיא מקבלת רק דברים מסוג ItemA או ItemB כי מחר מישהו יוסיף קלאס חדש עם מאפיין price. גם לא נרצה לשנות את ההגדרה של ItemA או ItemB ולהתחיל להגדיר עבורם היררכיית ירושה חדשה. אנחנו רוצים פשוט להגדיר שהפונקציה מקבלת רשימה של דברים שיש להם מאפיין price.

בדיוק בשביל זה נולד בפייתון 3.8 המושג Protocol. פרוטוקול מתאר מבנה של קלאס, בלי לשנות את הקוד של אותו הקלאס ומאפשר כתיבת פונקציות גנריות שמקבלות דברים שמתאימים למבנה. הדוגמה שלנו תיראה כך:

from typing import Protocol

class ItemA:
    def __init__(self):
        self.price = 10

class ItemB:
    def __init__(self):
        self.price = 5

class HasPrice(Protocol):
    @property
    def price(self) -> float:
        ...

def total_price(items: list[HasPrice]):
    return sum(item.price for item in items) * 1.18

print(total_price([ItemB(), ItemA(), ItemA(), ItemA()]))

עכשיו אם ננסה להעביר משהו שאין לו price בתור אחד הפריטים mypy מיד יזהה את הטעות.

3. ערכי החזר מסוגים שונים

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

def same(x):
    return x

בלי בדיקות טיפוסים קל לראות איך אנחנו משתמשים בפונקציה הזאת לא נכון, לדוגמה:

x = same("hello")
print(x + 5)

הטיפוס של פונקציית הזהות מעניין כי היא צריכה להחזיר בדיוק את מה שהיא קיבלה. הדרך בפייתון לחבר בין כמה טיפוסים על אותה הגדרה נקראת TypeVar וזה נראה כך:

G = TypeVar("G")
def same(x: G) -> G:
    return x

x = same("hello")
print(x + 5)

עכשיו בודק הטיפוסים של פייתון כבר ידע להגיד ש x שחוזר מ same("hello") הוא מטיפוס מחרוזת ולכן אי אפשר לחבר לו 5.

אבל TypeVar לבד לא מספיק, ופה דברים מתחילים להסתבך. ראינו את ParamSpec שמאפשר להבין איזה פרמטרים עוברים בשביל לחבר אותם לפונקציות שאנחנו מחזירים. אבל אם מה שאנחנו מחזירים זה tuple אז נצטרך מנגנון אחר. שימו לב לקוד הבא:

T = TypeVar("T")

def add_item(item: T, args):
    return (item, *args)

v = add_item(4, [1, 'a', 'b'])
print(len(v[0]))

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

את args אי אפשר להגדיר בתור רשימה גנרית או tuple גנרי. אנחנו רוצים להיות יותר מדויקים ולהגיד שאנחנו מחזירים בדיוק את מה שקיבלנו. לאיבר הראשון אין אורך אבל לשני ולשלישי כבר יש.

המבנה TypeVarTuple נועד בדיוק בשביל המקרים האלה. הפתרון נראה כך והוא מחייב שינוי חתימה:

from typing import TypeVarTuple, TypeVar, Any

Ts = TypeVarTuple("Ts")
T = TypeVar("T")

def add_item(item: T, *args: *Ts) -> tuple[T, *Ts]:
    return (item, *args)

v = add_item(4, 1, 'a', 'b')
print(len(v[0]))

משתנה TypeVarTuple מגדיר את סוגי המשתנים בתוך Tuple על בסיס הפרמטרים שפונקציה קיבלה. המגבלה שלו היא שחייבים להשתמש בו על פרמטרים עם כוכבית, ולכן הייתי צריך לשנות את חתימת הפונקציה ולהפוך את המשתנה היחיד ל *args.

עכשיו בודק הטיפוסים מזהה את הבעיה בהדפסת האורך של v[0] אבל יתן לי להדפיס את האורך של v[2].

4. שרשור מתודות על אוביקט בירושה

נמשיך לדוגמה הבאה ונכתוב מחשבון:

class Calc:
    def __init__(self):
        self.value = 0

    def add(self, n: int):
        self.value += n
        return self

c = Calc()
c.add(10).add(20).add(30)
print(c.value)

איזה כיף שאפשר להמשיך את ה add ולשרשר עוד קריאות! זה קורה בגלל שאני מחזיר את self. בשביל להוסיף טיפוסים אני יכול לסמן את הפונקציה בתור כזו שמחזירה Calc וזה יעבוד:

    def add(self, n: int) -> Calc:
        self.value += n
        return self

אבל אז אני אסתבך בירושה. שימו לב לקוד הבא:

class Calc:
    def __init__(self):
        self.value = 0

    def add(self, n: int) -> Calc:
        self.value += n
        return self

class AdvancedCalc(Calc):
    def sub(self, n: int) -> AdvancedCalc:
        self.value -= n
        return self

c = AdvancedCalc()
c.add(10).sub(15).add(30)
print(c.value)

הקריאה ל add הפכה את המשתנה מ AdvancedCalc ל Calc עבור בודק הטיפוסים כיוון שזה מה ש add מחזירה. גישה טובה יותר תגיד לפייתון שהפונקציה add מחזירה את אותו טיפוס של self - אם התחלנו עם Calc זה מה שנחזיר, ואם התחלנו עם AdvancedCalc נחזיר את AdvancedCalc. הקוד משתמש בטיפוס בשם Self, עוד חידוש של פייתון 3.11:

from typing import Self

class Calc:
    def __init__(self):
        self.value = 0

    def add(self, n: int) -> Self:
        self.value += n
        return self

class AdvancedCalc(Calc):
    def sub(self, n: int) -> Self:
        self.value -= n
        return self

c = AdvancedCalc()
c.add(10).sub(15).add(30)
print(c.value)

5. טיפוסים רקורסיביים

טיפוס רקורסיבי הוא טיפוס שאחד השדות בו מכיל משהו מאותו הטיפוס. דוגמה קלאסית היא JSON. הגישה הקלאסית להגדרת טיפוסים כאלה השתמשה במרכאות כדי לדחות את שערוך הטיפוס בתוך החלק הרקורסיבי כלומר:

JsonValue = Union[int, float, str, bool, None, Dict[str, 'JsonValue'], List['JsonValue']]

def parse_api_response(payload: JsonValue):
    pass

גרסאות חדשות יותר של פייתון מאפשרות לוותר על המרכאות באמצעות הגדרת Type Alias:

type JsonValue = Union[int, float, str, bool, None, Dict[str, JsonValue], List[JsonValue]]

פייתון עשתה התקדמות מרשימה בשנים האחרונות באפשרות הגדרת הטיפוסים והיום אפשר להשתמש ב Type Hints כדי להגדיר טיפוסים בהרבה מאוד מקרים. נכון, אין לפייתון אין הדינמיות של טייפסקריפט, אבל לא כל שפת טיפוסים צריכה להיות מסוגלת להריץ Doom.