Mutability

הגדרה

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

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

הערכים ה־Mutable שנכיר בקורס הם מעין "מכולות" שמכילות ערכים אחרים.
כרגע אנו מכירים סוג ערך אחד שהוא Mutable – רשימה.

כתובות של ערכים

ערכים

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

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


In [ ]:
print(9876543)

בשורה למעלה הגדרנו את הערך 9,876,543.
אף על פי שלא עשינו עליו פעולה מתוחכמת ולא שמרנו אותו במשתנה, פייתון תשמור את הערך הזה בזיכרון המחשב.
לערך 9,876,543 יש כתובת עכשיו.

בשורה הבאה "נקשור" את השם name לכתובת של הערך המספרי 12,345.
המשתנה name לא באמת "מכיל" את הערך 12,345, אלא רק מצביע על הכתובת שבה הערך 12,345 מאוחסן.


In [ ]:
name = 12345

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

אם ניזכר בדימוי הלייזר מהשיעורים הקודמים, עבור כל פעולת השמה בתוכנית, פייתון:

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

בדיקת כתובת של ערכים

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


In [ ]:
number = 100000
print("ID before: " + str(id(number)))
number = 123456
print("ID after: " + str(id(number)))

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


In [ ]:
number = 100000
print("ID before: " + str(id(number)))
number = number + 1
print("ID after: " + str(id(number)))

בדוגמה הגדלנו את ערך המשתנה number מ־100,000 ל־100,001.
חשוב לזכור שההגדלה מ־100,000 ל־100,001 לא באמת שינתה את הערך השמור במשתנה, אלא גרמה למשתנה להצביע לכתובת אחרת של ערך אחר.

בשורה הראשונה ביקשנו מ־number להצביע לערך 100,000, ולכן כשהרצנו את id(number) קיבלנו את הכתובת של הערך 100,000.
בשורה השנייה ביקשנו מ־number להצביע לערך 100,001, ולכן כשהרצנו את id(number) קיבלנו את הכתובת של הערך 100,001.

עבור שתי השורות הראשונות מודפסת הכתובת של הערך הראשון שיצרנו, 100,000.
עבור שתי השורות האחרונות מודפסת הכתובת של הערך השני שיצרנו, 100,001.

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


In [ ]:
print(f"ID of number ({number}): " + str(id(number)))
number2 = 100001
print(f"ID of number2 ({number2}): " + str(id(number2)))

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


In [ ]:
print("ID of number: " + str(id(number)))
number3 = number
print("ID of number2: " + str(id(number3)))

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

רשימה

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


In [ ]:
my_list = ['It\'s', 'never', 'enough']
print(f"id() of my_list ({my_list}) before:\n\t" + str(id(my_list)))
my_list[2] = 'lupus'
print(f"id() of my_list ({my_list}) after:\n\t" + str(id(my_list)))

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

סיכום ביניים

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

השלכות

רשימות

נעשה את הניסיון הבא:


In [ ]:
str1 = "Puns are the highest form of literature."
str2 = str1
str2 = str2 + "\n\t - Alfred Hitchcock"

print(str1)
print('-' * len(str1))
print(str2)

עם הידע החדש שצברנו, נוכל להגיד ש־str1 ו־str2 מצביעים למקומות שונים, בגלל ההשמה בשורה 3.

אבל ברשימות אפשר לשנות את הערכים גם בלי לבצע השמה. מה קורה אז?


In [ ]:
list1 = [2, 8, 20, 28, 50, 82]
list2 = list1
list2.append(126)

print(list1)
print('-' * len(str(list1)))
print(list2)

במקרה הזה, גרמנו ל־list2 להצביע לאותו מקום ש־list1 מצביעה עליו.
מהסיבה הזו, שינוי של list2 ישפיע גם על list1, ושינוי של list1 ישפיע גם על list2.


In [ ]:
print(id(list1))
print(id(list2))

כדי לבקש מפייתון לא להתנהג כך, צריך להגיד לה במפורש שאנחנו מעוניינים ביצירת רשימה חדשה.
ניתן לעשות את זה בקריאה לפעולה list.copy():


In [ ]:
list1 = [2, 8, 20, 28, 50, 82]
list2 = list1.copy()
list2.append(126)

print(list1)
print('-' * len(str(list1)))
print(list2)

פרמטרים של פונקציה

נגדיר פונקציה שמקבלת מחרוזת ומשרשרת לסופה את האות Z:


In [ ]:
def append_to_string(my_string):
    print('\t--- Inside the function now ---')
    print(f'\tFunction got value: {my_string}, with id: {id(my_string)}.')
    my_string = my_string + 'Z'
    print(f'\tChanged my_string to be {my_string}, with id: {id(my_string)}.')
    print('\t--- Finished to run the function now ---')

s = 'Hello'
print(f'Before calling the function: s = {s}, with id: {id(s)}.')
append_to_string(s)
print(f'After calling the function: s = {s}, with id: {id(s)}.')

מה קרה בפועל? למה המחרוזת לא השתנתה גם מחוץ לפונקציה?

הערך שהועבר לפרמטר של הפונקציה היה הכתובת של s, שעכשיו גם my_string מצביע עליו.
ברגע שביצענו את ההשמה my_string = my_string + 'Z', יצרנו באגף הימני של ההשמה ערך חדש, וביקשנו מלייזר חדש ששמו my_string להצביע לכתובת שלו.
המשתנה ששמו my_string מצביע כרגע לכתובת של ערך אחר, בזמן שהמשתנה s עדיין מצביע על הערך המקורי.

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

ננסה לעשות אותו דבר עם רשימה:


In [ ]:
def append_to_list(my_list):
    print('\t--- Inside the function now ---')
    print(f'\tFunction got value: {my_list}, with id: {id(my_list)}.')
    my_list = my_list + [126]
    print(f'\tChanged my_string to be {my_list}, with id: {id(my_list)}.')
    print('\t--- Finished to run the function now ---')

l = [2, 8, 20, 28, 50, 82]
print(f'Before calling the function: l = {l}, with id: {id(l)}.')
append_to_list(l)
print(f'After calling the function: l = {l}, with id: {id(l)}.')

ההתרחשות הייתה זהה למה שקרה עם מחרוזות!
זה קרה כיוון שגם פה דרסנו את my_list כך שיצביע לרשימה חדשה שיצרנו.
בצד ימין של ההשמה, יצרנו רשימה חדשה שמכילה את האיברים 2, 8, 20, 28, 50, 82, 126.
בעצם ההשמה ביקשנו מ־my_list שבתוך הפונקציה להפנות לכתובת של הרשימה החדשה.
ננסה להשתמש בפעולה של צירוף איבר חדש לרשימה, list.append(item), שעליה למדנו השבוע:


In [ ]:
def append_to_list(my_list):
    print('\t--- Inside the function now ---')
    print(f'\tFunction got value: {my_list}, with id: {id(my_list)}.')
    my_list.append(126)
    print(f'\tChanged my_string to be {my_list}, with id: {id(my_list)}.')
    print('\t--- Finished to run the function now ---')

l = [2, 8, 20, 28, 50, 82]
print(f'Before calling the function: l = {l}, with id: {id(l)}.')
append_to_list(l)
print(f'After calling the function: l = {l}, with id: {id(l)}.')

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

כתיבת פונקציה כראוי

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

הנה קטע הקוד מלמעלה בלי ההדפסות המסרבלות:


In [ ]:
def append_to_list(my_list):
    my_list.append(126)

l = [2, 8, 20, 28, 50, 82]
append_to_list(l)
print(l)

ננסה לרשום קוד זהה, רק שהפעם הפונקציה לא תערוך את המשתנה l:


In [ ]:
def append_to_list(my_list):
    list_copy = my_list.copy()  # גם יעבוד my_list = my_list.copy()
    list_copy.append(126)
    return list_copy

l = [2, 8, 20, 28, 50, 82]
new_l = append_to_list(l)  # l גם יעבוד, אבל יאבד את הערך של l = append_to_list(l)
print(l)
print(new_l)

לצורת כתיבה זו כמה יתרונות:

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

Tuple

הגדרה

ברוח השיעור על העובדה שרשימות הן Mutable, הגיע הזמן להכיר את "האח דל התקציב" שלהן: tuple.
סוג הנתונים tuple לא מרגש במיוחד – הוא למעשה סוג של רשימה שאי־אפשר לשנות. רשימה שהיא immutable, אם תרצו.

נגדיר משתנה מסוג tuple באמצעות סוגריים עגולים:


In [ ]:
animals = ('dog', 'fish', 'horse')

כמו ברשימה, ניתן לקבל איברים שנמצאים ב־tuple אם נפנה למיקום שלהם:


In [ ]:
first_animal = animals[0]
print(f"The first animal is {first_animal}")

ניסיון לשנות את ה־tuple לא יצליח, מן הסתם. Immutable, זוכרים?


In [ ]:
animals[1] = 'pig'

יצירת tuple ריק תיכתב כך:


In [ ]:
my_tuple = tuple()

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


In [ ]:
my_tuple = (4, )

תרגול: כתבו פונקצייה שמשתמשת ב־dir(), ומחזירה את כל הפעולות שיש ב־list ואין ב־tuple.
בדקו גם אילו פעולות יש ב־tuple ואין ב־list.
בשתי ההשוואות, התעלמו מפעולות ששמן מתחיל בתו קו תחתון.

שימושים

אם tuple מעניק לי פחות חופש פעולה, למה להשתמש בו מלכתחילה?

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

דוגמאות


In [ ]:
my_home = (35.027185, -111.022388)  # x, y
traingle_sides_length = (4, 5, 6)
possible_directions = ('UP', 'DOWN', 'LEFT', 'RIGHT')
students_and_age = [('Itamar', 50), ('Yam', '27'), ('David', 16)]  # רשימה של טאפלים

מונחים

כתובת
מקום במחשב שבו שמור ערך כלשהו. ערך לעולם לא יחליף את הכתובת שלו.
Immutable
ערך שלא ניתן לשנות.
Mutable
ערך שניתן לשנות.
Tuple
סוג משתנה. Immutable. דומה לרשימה בתכונותיו.