א.13 — פונקציות מתקדמות

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

בפרק א.7 למדנו להגדיר פונקציות ולקרוא להן. בפרק זה נגלה תכונה חשובה של פייתון: פונקציה היא ערך, בדיוק כמו מספר או מחרוזת. אפשר לשמור פונקציה במשתנה, ואפשר להעביר אותה כפרמטר לפונקציה אחרת. התכונה הזו מאפשרת לכתוב פונקציה כללית אחת, כמו „עבור על הרשימה והפעל פעולה על כל איבר”, ולהחליט בכל קריאה איזו פעולה תתבצע. נלמד לכתוב פונקציות קצרות באמצעות lambda, נשתמש בפונקציה כמפתח למיון, ונכיר את הפונקציות המובנות map, filter ו־zip, שכולן מקבלות פונקציה או רצף ומטפלות בלולאה בעצמן.

מחברות האוניברסיטה הפתוחה — 4-פונקציות; 6-שוויון וזהות

פונקציה היא ערך

כשכותבים def double(value):, פייתון יוצרת אובייקט פונקציה ושומרת אותו תחת השם double. השם עצמו הוא משתנה שמצביע על הפונקציה, ולכן אפשר להעתיק אותו למשתנה אחר. ההבדל בין double ל־double(5) חשוב: בלי סוגריים מתייחסים לפונקציה עצמה כערך; עם סוגריים קוראים לה ומקבלים את התוצאה. בדוגמה נשמור את הפונקציה במשתנה חדש ונקרא לה דרכו:

def double(value):
    return 2 * value

twice = double
print(twice(5))

פלט

10

twice ו־double הם שני שמות לאותה פונקציה. אם אפשר לשמור פונקציה במשתנה, אפשר גם למסור אותה כארגומנט. הפונקציה הבאה מקבלת פונקציה בשם func וערך, ומפעילה את func פעמיים על הערך. בתוך הגוף func מתנהג כמו כל פונקציה: קוראים לו עם סוגריים.

def apply_twice(func, value):
    return func(func(value))

print(apply_twice(double, 3))

פלט

12

הקריאה apply_twice(double, 3) מחשבת double(double(3)): תחילה 6 ואחר כך 12. שימו לב שהעברנו double בלי סוגריים; לו כתבנו double(3), היינו מעבירים את המספר 6 במקום את הפונקציה.

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

def square(value):
    return value * value

def apply_to_all(func, values):
    results = []
    for value in values:
        results.append(func(value))
    return results

print(apply_to_all(double, [1, 2, 3]))
print(apply_to_all(square, [1, 2, 3]))

פלט

[2, 4, 6]
[1, 4, 9]

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

פונקציית lambda

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

add = lambda x, y: x + y

def mul(x, y):
    return x * y

print(add(2, 3))
print(mul(2, 3))

פלט

5
6

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

print(apply_to_all(lambda value: value + 1, [1, 2, 3]))

פלט

[2, 3, 4]

lambda מתאימה רק לביטוי יחיד. פונקציה שכוללת כמה שורות, תנאים או לולאה, או פונקציה שמשתמשים בה בכמה מקומות, כותבים עם def ונותנים לה שם ברור.

מיון עם sort ו־sorted

בפרק א.8 הכרנו את sort, שממיינת את הרשימה במקומה, ואת sorted, שמחזירה רשימה ממוינת חדשה ומשאירה את המקור כפי שהיה. שתיהן מקבלות את הפרמטר reverse=True למיון מהגדול לקטן:

numbers = [1, 2, 3, 4]
numbers.sort(reverse=True)
print(numbers)

numbers = [1, 2, 3, 4]
ordered = sorted(numbers, reverse=True)
print(ordered)
print(numbers)

פלט

[4, 3, 2, 1]
[4, 3, 2, 1]
[1, 2, 3, 4]

בשורה האחרונה numbers נשארה כפי שהייתה, כי sorted בנתה רשימה חדשה.

מיון לפי מפתח — key

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

pairs = [(3, 4), (1, 2), (3, 3), (0, 5), (5, 0)]
pairs.sort()
print(pairs)

פלט

[(0, 5), (1, 2), (3, 3), (3, 4), (5, 0)]

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

pairs = [(3, 4), (1, 2), (3, 3), (0, 5), (5, 0)]
pairs.sort(key=lambda pair: pair[0] + pair[1])
print(pairs)

פלט

[(1, 2), (0, 5), (5, 0), (3, 3), (3, 4)]

הפונקציה שב־key אינה משווה בין שני איברים; היא מקבלת איבר אחד ומחזירה מספר, ופייתון משווה בין המספרים. כששני מפתחות שווים, המיון שומר על הסדר המקורי: הזוגות (0, 5) ו־(5, 0), שסכומם 5, נשארו בסדר שבו הופיעו ברשימה. אפשר לשלב key עם reverse, וכמובן אפשר למסור גם פונקציה שהוגדרה ב־def:

def pair_sum(pair):
    return pair[0] + pair[1]

pairs = [(3, 4), (1, 2), (3, 3), (0, 5), (5, 0)]
pairs.sort(key=pair_sum, reverse=True)
print(pairs)

פלט

[(3, 4), (3, 3), (0, 5), (5, 0), (1, 2)]

תרגיל: מיון תאריכים

תאריך נתון כטאפל (יום, חודש, שנה). נמיין רשימת תאריכים מהמוקדם למאוחר. ההשוואה הרגילה של טאפלים תשווה קודם את היום, וזה שגוי: (12, 8, 1968) צריך לבוא לפני (1, 1, 2020). הפתרון הוא להמיר כל תאריך למספר יחיד שבו השנה משפיעה יותר מהחודש, והחודש יותר מהיום:

dates = [(12, 8, 2020), (12, 8, 1968), (13, 8, 2020), (12, 9, 2020)]
date_key = lambda date: date[0] + date[1] * 100 + date[2] * 10000
dates.sort(key=date_key)
print(dates)

פלט

[(12, 8, 1968), (12, 8, 2020), (13, 8, 2020), (12, 9, 2020)]

התאריך (12, 8, 2020) הופך למספר 20200812, כמו כתיבת התאריך בפורמט שנה־חודש־יום. מיון המספרים האלה הוא מיון כרונולוגי.

תרגיל: מיון לפי ספרת האחדות

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

numbers = [42, 224, 210, 417, 561, 999, 22]
numbers.sort(key=lambda number: number % 10)
print(numbers)

פלט

[210, 561, 42, 22, 224, 417, 999]

42 ו־22 קיבלו את אותו מפתח, 2, ולכן נשארו בסדר המקורי ביניהם.

הפונקציה map

map מקבלת פונקציה ורצף, ומפעילה את הפונקציה על כל איבר ברצף: בדיוק מה שעשתה apply_to_all שכתבנו, אבל מובנה בשפה. ההבדל היחיד הוא בצורת התוצאה. map אינה בונה רשימה מראש, אלא מחזירה איטרטור — Iterator: אובייקט שמספק את התוצאה הבאה בכל פעם שמבקשים אותה. מבקשים תוצאה אחת באמצעות next, ואפשר גם לסרוק אותו ב־for. כדי לקבל את כל התוצאות כרשימה רגילה, הופכים את האיטרטור לרשימה באמצעות list, למשל list(map(double, numbers)); זו הדרך שבה נציג את התוצאות בהמשך הפרק.

def double(value):
    return 2 * value

numbers = [3, -3, 2, 0, 10, -11]
results = map(double, numbers)
print(next(results))
print(list(results))

פלט

6
[-6, 4, 0, 20, -22]

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

print(list(map(lambda value: 2 * value, numbers)))

פלט

[6, -6, 4, 0, 20, -22]

הפונקציה filter

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

numbers = [3, -3, 2, 0, 10, -11]
positive = filter(lambda value: value > 0, numbers)
print(list(positive))

פלט

[3, 2, 10]

שילוב של filter ו־map עושה את מה שעשה ה־list comprehension שהכרנו בפרק א.8: הביטוי [2 * value for value in numbers if value > 0] מסנן את החיוביים ומכפיל אותם בשורה אחת. שתי הדרכים שקולות; הביטוי בסוגריים המרובעים קריא יותר לחישוב קצר, ו־map ו־filter נוחות כשהפונקציה כבר קיימת.

הפונקציה zip

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

x_values = [1, 2, 3]
y_values = [4, 5, 6]
print(list(zip(x_values, y_values)))

פלט

[(1, 4), (2, 5), (3, 6)]

השימוש הנפוץ ב־zip הוא סריקה של שתי רשימות מקבילות בלולאה אחת, בעזרת פירוק הטאפל למשתנים שלמדנו בפרק א.9:

names = ["Dana", "Yoav", "Noa"]
grades = [90, 85, 77]
for name, grade in zip(names, grades):
    print(name, grade)

פלט

Dana 90
Yoav 85
Noa 77