Comprehensions

הקדמה

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

List Comprehension

עיבוד רשימות

נתחיל במשימה פשוטה יחסית:
בהינתן רשימת שמות, אני מעוניין להפוך את כל השמות ברשימה ליווניים.
כידוע, אפשר להפוך כל שם ליווני על ידי הוספת ההברה os בסופו. לדוגמה, השם Yam ביוונית הוא Yamos.


In [ ]:
names = ['Yam', 'Gal', 'Orpaz', 'Aviram']

למה אנחנו מחכים? ניצור את הרשימה החדשה:


In [ ]:
new_names = []

נעבור על הרשימה הישנה בעזרת לולאת for, נשרשר לכל איבר "os" ונצרף את התוצאה לרשימה החדשה:


In [ ]:
for name in names:
    new_names.append(name + 'os')

כשהלולאה תסיים לרוץ, תהיה בידינו רשימה חדשה של שמות יוונים:


In [ ]:
print(new_names)

אם נסתכל על הלולאה שיצרנו, נוכל לזהות בה ארבעה מרכיבים עיקריים.

פירוק מרכיבי לולאת for ליצירת רשימה חדשה
שם המרכיב תיאור המרכיב דוגמה
ה־iterable הישן אוסף הנתונים המקורי שעליו אנחנו רצים. names
הערך הישן משתנה הלולאה. הלייזר שמצביע בכל פעם על ערך יחיד מתוך ה־iterable הישן. name
הערך החדש הערך שנרצה להכניס ל־iterable שאנחנו יוצרים, בדרך כלל מושפע מהערך הישן. name + 'os'
ה־iterable החדש ה־iterable שאנחנו רוצים ליצור, הערך שיתקבל בסוף הריצה. new_names

השתמשו ב־map כדי ליצור מ־names רשימת שמות יווניים באותה הצורה.

חשוב!
פתרו לפני שתמשיכו!

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


In [ ]:
new_names = map(lambda name: name + 'os', names)
print(list(new_names))

הטכניקה

list comprehension היא טכניקה שמטרתה לפשט את מלאכת הרכבת הרשימה, כך שתהיה קצרה, מהירה וקריאה.
ניגש לעניינים! אבל ראו הוזהרתם – במבט ראשון list comprehension עשוי להיראות מעט מאיים וקשה להבנה.
הנה זה בא:


In [ ]:
names = ['Yam', 'Gal', 'Orpaz', 'Aviram']
new_names = [name + 'os' for name in names]  # list comprehension
print(new_names)

הדבר הראשון שמבלבל כשנפגשים לראשונה עם list comprehension הוא סדר הקריאה המשונה:

  1. list comprehension מתחיל בפתיחת סוגריים מרובעים (ומסתיים בסגירתם), שמציינים שאנחנו מעוניינים ליצור רשימה חדשה.
  2. את מה שבתוך הסוגריים עדיף להתחיל לקרוא מהמילה for – נוכל לראות את הביטוי for name in names שאנחנו כבר מכירים.
  3. מייד לפני המילה for, נכתוב את ערכו של האיבר שאנחנו רוצים לצרף לרשימה החדשה בכל איטרציה של הלולאה.

נביט בהשוואת החלקים של ה־list comprehension לחלקים של לולאת ה־for:

השוואה בין יצירת רשימה בעזרת for ובעזרת list comprehension

list comprehension מאפשרת לשנות את הערך שנוסף לרשימה בקלות.
מסיבה זו, מתכנתים רבים יעדיפו את הטכניקה הזו על פני שימוש ב־map, שבה נצטרך להשתמש ב־lambda ברוב המקרים.

נתונה הרשימה numbers = [1, 2, 3, 4, 5].
השתמשו ב־list comprehension כדי ליצור בעזרתה את הרשימה [1, 4, 9, 16, 25].
האם אפשר להשתמש בפונקציה range במקום ב־numbers?

חשוב!
פתרו לפני שתמשיכו!

list comprehension הוא מבנה גמיש מאוד!
נוכל לכתוב בערך שאנחנו מצרפים לרשימה כל ביטוי שיתחשק לנו, ואפילו לקרוא לפונקציות.
נראה כמה דוגמאות:


In [ ]:
names = ['Johnny Eck', 'David Ben Gurion', 'Elton John']
reversed_names = [name[::-1] for name in names]
print(reversed_names)

In [ ]:
reversed_names = [int(str(number) * 9) for number in range(1, 10)]
print(reversed_names)

In [ ]:
places = (
    {'name': 'salar de uyuni', 'location': 'Bolivia'},
    {'name': 'northern lake baikal', 'location': 'Russia'},
    {'name': 'kuang si falls', 'location': 'Laos'},
)
places_titles = [place['name'].title() for place in places]
print(places_titles)

השתמשו ב־list comprehension כדי ליצור את הרשימה הבאה:
[(1, 2), (2, 3), (3, 4), (4, 5), (5, 6)].

חשוב!
פתרו לפני שתמשיכו!

תנאים

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


In [ ]:
names = ['Margaret Thatcher', 'Karl Marx', "Ze'ev Jabotinsky", 'Bertrand Russell', 'Fidel Castro']
long_names = []
for name in names:
    if len(name) > 12:
        long_names.append(name)

In [ ]:
long_names

השתמשו ב־filter כדי ליצור מ־names רשימת שמות ארוכים באותה הצורה.

חשוב!
פתרו לפני שתמשיכו!

נפרק את הקוד הקצר שיצרנו למעלה למרכיביו:

פירוק מרכיבי לולאת for עם התניה ליצירת רשימה חדשה
שם המרכיב תיאור המרכיב דוגמה
איפוס אתחול הרשימה לערך ריק. long_names = []
הלולאה החלק שעובר על כל האיברים ב־iterable הקיים ויוצר משתנה שאליו אפשר להתייחס. for name in names:
הבדיקה התניה שבודקת אם הערך עונה על תנאי מסוים. if len(name) > 12:
הוספה צירוף האיבר לרשימה החדשה, אם הוא עונה על התנאי שנקבע בבדיקה. long_names.append(name)

ונלמד איך מממשים את אותו הרעיון בדיוק בעזרת list comprehension:


In [ ]:
names = ['Margaret Thatcher', 'Karl Marx', "Ze'ev Jabotinsky", 'Bertrand Russell', 'Fidel Castro']
long_names = [name for name in names if len(name) > 12]
print(long_names)

נראה שוב השוואה בין list comprehension ללולאת for רגילה, הפעם עם תנאי:

השוואה בין יצירת רשימה בעזרת for ובעזרת list comprehension

גם כאן יש לנו סדר קריאה משונה מעט, אך הרעיון הכללי של ה־list comprehension נשמר:

  1. list comprehension מתחיל בפתיחת סוגריים מרובעים (ומסתיים בסגירתם), כדי לציין שאנחנו מעוניינים ליצור רשימה חדשה.
  2. את מה שבתוך הסוגריים עדיף להתחיל לקרוא מהמילה for – נוכל לראות את הביטוי for name in names שאנחנו כבר מכירים.
  3. ממשיכים לקרוא את התנאי, אם קיים כזה. רק אם התנאי יתקיים, יתווסף האיבר לרשימה.
  4. מייד לפני המילה for, נכתוב את ערכו של האיבר שאנחנו רוצים לצרף לרשימה בכל איטרציה של הלולאה.

אפשר לשלב את השיטות כדי לבנות בקלילות רשימות מורכבות.
נמצא את שמות כל הקבצים שהסיומת שלהם היא ".html":


In [ ]:
files = ['moshe_homepage.html', 'yahoo.html', 'python.html', 'shnitzel.gif']
html_names = [file.split('.')[0] for file in files if file.endswith('.html')]
print(html_names)

תרגיל ביניים: טיפול שורש

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

לדוגמה, עבור הארון ['100', '25.0', '12a', 'mEoW', '0'], החזירו [10.0, 5.0, 0.0].
עבור הארון ['Area51', '303', '2038', 'f00b4r', '314.1'], החזירו [17.4, 45.14, 17.72].
(מחקנו קצת ספרות אחרי הנקודה בשביל הנראות).

כתבו פונקציה בשם organize_closet שמקבלת רשימת ארון ומסדרת אותו.
תוכלו לבדוק את עצמכם באמצעות הפונקציה generate_closet שתיצור עבורכם ארון אסלי מהחנות של סנדל'ה.


In [ ]:
import random
import string


CHARACTERS = f'.{string.digits}{string.ascii_letters}'
WEIGHTS = [1] * len(f'.{string.digits}') + [0.05] * len(string.ascii_letters)


def generate_size(length):
    return ''.join(random.choices(CHARACTERS, weights=WEIGHTS, k=length))


def generate_closet(closet_size=20, shoe_size=4):
    return [generate_size(shoe_size) for _ in range(closet_size)]


generate_closet(5)

בפייתון, נהוג לכנות משתנה שלא יהיה בו שימוש בעתיד כך: _.
דוגמה טובה אפשר לראות בלולאה שב־generate_closet.

Dictionary Comprehension ו־Set Comprehension

מלבד list comprehension, קיימים גם set comprehension ו־dictionary comprehension שפועלים בצורה דומה.
הרעיון בבסיסו נשאר זהה – שימוש בערכי iterable כלשהו לצורך יצירת מבנה נתונים חדש בצורה קריאה ומהירה.
נראה דוגמה ל־dictionary comprehension שבו המפתח הוא מספר, והערך הוא אותו המספר בריבוע:


In [ ]:
powers = {i: i ** 2 for i in range(1, 11)}
print(powers)

בדוגמה למעלה חישבנו את הריבוע של כל אחד מעשרת המספרים החיוביים הראשונים.
משתנה הלולאה i עבר על כל אחד מהמספרים בטווח שבין 1 ל־11 (לא כולל), ויצר עבור כל אחד מהם את המפתח i, ואת הערך i ** 2.

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

בצורה דומה אפשר ליצור set comprehension:


In [ ]:
sentence = "99 percent of all statistics only tell 49 percent of the story."
words = {word for word in sentence.lower().split() if word.isalpha()}
print(words)
print(type(words))

התחביר של set comprehension כמעט זהה לתחביר של list comprehension.
ההבדל היחיד ביניהם הוא שב־set comprehension אנחנו משתמשים בסוגריים מסולסלים.
ההבדל בינו לבין dictionary comprehension הוא שאנחנו משמיטים את הנקודתיים והערך, ומשאירים רק את המפתח.

מצאו כמה מהמספרים הנמוכים מ־1,000 מתחלקים ב־3 וב־7 ללא שארית.
השתמשו ב־set comprehension.

חשוב!
פתרו לפני שתמשיכו!

Generator Expression

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

נכתוב generator פשוט שמניב עבורנו את אורכי השורות בטקסט מסוים:


In [ ]:
def get_line_lengths(text):
    for line in text.splitlines():
        if line.strip():  # אם השורה אינה ריקה
            yield len(line)


# לדוגמה
with open('resources/states.txt') as states_file:
    states = states_file.read()
print(list(get_line_lengths(states)))

חדי העין כבר זיהו את התבנית המוכרת – יש פה for, מייד אחריו if ומייד אחריו אנחנו יוצרים איבר חדש.
אם כך, generator expression הוא בסך הכול שם מפונפן למה שאנחנו היינו קוראים לו generator comprehension.
נמיר את הפונקציה get_line_lengths ל־generator comprehension:


In [ ]:
with open('resources/states.txt') as states_file:
    states = states_file.read()

line_lengths = (len(line) for line in states.splitlines() if line.strip())
print(list(line_lengths))

נעמוד על ההבדלים בין הגישות:

השוואה בין יצירת generator בעזרת פונקציה ובין יצירת generator בעזרת generator expression

כאמור, הרעיון דומה מאוד ל־list comprehension.
האיבר שנחזיר בכל פעם מה־generator בעזרת yield יהפוך ב־generator expression להיות האיבר שנמצא לפני המילה for.

שימו לב שה־generator expression שקול לערך המוחזר לנו בקריאה לפונקציית ה־generator.
זו נקודה שחשוב לשים עליה דגש: generator expression מחזיר generator iterator, ולא פונקציית generator.

נסתכל על דוגמה נוספת ל־generator expression שמחזיר את ריבועי כל המספרים מ־1 ועד 11 (לא כולל):


In [ ]:
squares = (number ** 2 for number in range(1, 11))
print(list(squares))

בדיוק כמו ב־generator iterator רגיל, אחרי שנשתמש באיבר לא נוכל לקבל אותו שוב:


In [ ]:
print(list(squares))

והפעלת next על generator iterator שכבר הניב את כל הערכים תקפיץ StopIterator:


In [ ]:
next(squares)

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


In [ ]:
sum(number ** 2 for number in range(1, 11))

בדוגמה שלמעלה ה־generator comprehension יצר את כל ריבועי המספרים מ־1 ועד 11, לא כולל.
הפונקציה sum השתמשה בכל ריבועי המספרים שה־generator הניב, וסכמה אותם.

לולאות מרובות

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


In [ ]:
dice_options = []
for first_die in range(1, 7):
    for second_die in range(1, 7):
        dice_options.append((first_die, second_die))

print(dice_options)

נוכל להפוך גם את המבנה הזה ל־list comprehension:


In [ ]:
dice_options = [(die1, die2) for die1 in range(1, 7) for die2 in range(1, 7)]
print(dice_options)

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

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


In [ ]:
dice_options = [
    (die1, die2, die3)
    for die1 in range(1, 7)
    for die2 in range(1, 7)
    for die3 in range(1, 7)
]
print(dice_options)

שבירת השורה בתא שלמעלה נעשתה מטעמי סגנון.
באופן טכני, מותר לרשום את ה־list comprehension הזה בשורה אחת.

צרו פונקציית generator ו־generator expression מהדוגמה האחרונה.

חשוב!
פתרו לפני שתמשיכו!

החסרון בדוגמה של קוביות הוא שאנחנו מקבלים בתוצאות גם את (1, 1, 6) וגם את (6, 1, 1) .
האם תוכלו לפתור בעיה זו בקלות?

חשוב!
פתרו לפני שתמשיכו!

נימוסין

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

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

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

סיכום

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

  • List Comprehensions
  • Dictionary Comprehensions
  • Set Comprehensions
  • Generator Expressions

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

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

תרגילים

הֲיִי שלום

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

לדוגמה:
עבור המשפט: Toto, I've a feeling we're not in Kansas anymore
החזירו את הרשימה: [5, 4, 1, 7, 5, 3, 2, 6, 7]

א אוהל, פ זה פייתון

כתבו פונקציה בשם get_letters שמחזירה את רשימת כל התווים בין a ל־z ובין A ל־Z.
השתמשו ב־list comprehension, ב־ord וב־chr.
הקפידו שלא לכלול את המספרים 65, 90, 97 או 122 בקוד שלכם.

חתול ארוך הוא ארוך

כתבו פונקציה בשם count_words שמקבלת כפרמטר טקסט, ומחזירה מילון של אורכי המילים שבו.
השתמשו ב־comprehension לבחירתכם (או ב־generator expression) כדי לנקות את הטקסט מסימנים שאינם אותיות.
לאחר מכן, השתמשו ב־dictionary comprehension כדי לגלות את אורכה של כל מילה במשפט.

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


In [ ]:
import string

text = """
You see, wire telegraph is a kind of a very, very long cat.
You pull his tail in New York and his head is meowing in Los Angeles.
Do you understand this?
And radio operates exactly the same way: you send signals here, they receive them there.
The only difference is that there is no cat.
"""

expected_result = {'you': 3, 'see': 3, 'wire': 4, 'telegraph': 9, 'is': 2, 'a': 1, 'kind': 4, 'of': 2, 'very': 4, 'long': 4, 'cat': 3, 'pull': 4, 'his': 3, 'tail': 4, 'in': 2, 'new': 3, 'york': 4, 'and': 3, 'head': 4, 'meowing': 7, 'los': 3, 'angeles': 7, 'do': 2, 'understand': 10, 'this': 4, 'radio': 5, 'operates': 8, 'exactly': 7, 'the': 3, 'same': 4, 'way': 3, 'send': 4, 'signals': 7, 'here': 4, 'they': 4, 'receive': 7, 'them': 4, 'there': 5, 'only': 4, 'difference': 10, 'that': 4, 'no': 2}

ואלה שמות

כתבו פונקציה בשם full_names, שתקבל כפרמטרים רשימת שמות פרטיים ורשימת שמות משפחה, ותרכיב מהם שמות מלאים.
לכל שם פרטי תצמיד הפונקציה את כל שמות המשפחה שהתקבלו.
ודאו שהשמות חוזרים כאשר האות הראשונה בשם הפרטי ובשם המשפחה היא אות גדולה.

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

לדוגמה:


In [ ]:
first_names = ['avi', 'moshe', 'yaakov']
last_names = ['cohen', 'levi', 'mizrahi']

# התנאים הבאים צריכים להתקיים
full_names(first_names, last_names, 10) == ['Avi Mizrahi', 'Moshe Cohen', 'Moshe Levi', 'Moshe Mizrahi', 'Yaakov Cohen', 'Yaakov Levi', 'Yaakov Mizrahi']
full_names(first_names, last_names) == ['Avi Cohen', 'Avi Levi', 'Avi Mizrahi', 'Moshe Cohen', 'Moshe Levi', 'Moshe Mizrahi', 'Yaakov Cohen', 'Yaakov Levi', 'Yaakov Mizrahi']