א.10 — שוויון, זהות והעתקה

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

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

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

השמה אינה העתקה

כשמשייכים רשימה למשתנה נוסף, שני השמות מפנים לאותה רשימה. לכן שינוי דרך אחד מהם נראה גם דרך האחר:

a = [1, 2]
b = a
b[1] = 3
print(a)

פלט

[1, 3]

השינוי נעשה דרך b, אבל ההדפסה של a מראה [1, 3]: יש כאן רשימה אחת בלבד ושני שמות שמפנים אליה. b = a קושרת שם נוסף לאותה רשימה. לעומת זאת, b = [7, 8] הייתה קושרת את b לרשימה אחרת, בלי לשנות את a.

שוויון לעומת זהות

איך נדע אם שני שמות מפנים לאותה רשימה או לשתי רשימות שרק במקרה מכילות אותם ערכים? פייתון מבחינה בין שתי שאלות שונות. שוויון (equality) בודק אם התוכן זהה; זהות (identity) בודקת אם מדובר באותו אובייקט ממש. לשם כך משתמשים ב־== להשוואת תוכן לפי כללי הטיפוס, וב־is לבדיקת זהות.

a = [1, 2]
b = [1, 2]
c = a
print(a == b)
print(a is b)
print(a is c)

פלט

True
False
True

a ו־b הן שתי רשימות נפרדות עם אותו תוכן, ולכן a == b נותן True אבל a is b נותן False. לעומת זאת c הוא שם נוסף לאותה רשימה של a, ולכן a is c נותן True. ברוב המקרים השאלה שמעניינת אותנו היא שוויון תוכן: השוואת מספרים ומחרוזות נעשית ב־==. מול None משתמשים ב־is None, כפי שראינו בפרק א.7.

העתקה רדודה

כשרוצים עותק אמיתי ולא רק שם נוסף, משתמשים ב־copy(). אבל צריך לדעת מה בדיוק היא מעתיקה. copy() יוצרת רשימה חיצונית חדשה. אם הרשימה מכילה רשימות, איבריה הפנימיים עדיין משותפים; לכן היא נקראת העתקה רדודה (shallow copy):

rows = [[1, 2], [3, 4]]
other = rows.copy()
rows[0][1] = 5
print(other)
print(rows is other)

פלט

[[1, 5], [3, 4]]
False

rows is other נותן False: אלה שתי רשימות חיצוניות שונות. ובכל זאת השינוי rows[0][1] = 5 מופיע גם ב־other, כי שתי הרשימות החיצוניות מכילות את אותן רשימות פנימיות. עבור רשימה שאיבריה הם מספרים או מחרוזות, העתקה רדודה מספיקה.

העתקה עמוקה

כשהרשימה מכילה רשימות, כמו לוח משחק שבנוי משורות, העתקה רדודה אינה מספיקה. העתקה עמוקה (deep copy) מאפשרת ליצור בדוגמה עותק נפרד גם של הרשימות הפנימיות. לשם כך נייבא את copy — מודול, כלומר אוסף קוד — ונשתמש בפעולה deepcopy שהוא מספק:

import copy
rows = [[1, 2], [3, 4]]
other = copy.deepcopy(rows)
rows[0][1] = 5
print(rows)
print(other)

פלט

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

הפעם השינוי ב־rows אינו מופיע ב־other: גם הרשימות הפנימיות הועתקו. זו הדרך לשמור את מצב הלוח מהדוגמה שבפתיחת הפרק. הכתיב copy.deepcopy פירושו "הפעולה deepcopy מתוך המודול copy"; בפרק א.8 ראינו כתיב חלופי, from copy import deepcopy, שמאפשר לקרוא לה בשמה בלבד.

העברת רשימה לפונקציה

מה קורה כשמעבירים רשימה כארגומנט לפונקציה? בפרק א.7 ראינו שהשמה לפרמטר מספרי אינה משנה את המשתנה שמחוץ לפונקציה. עם רשימה המצב שונה, מאותה סיבה שראינו לאורך הפרק: הפרמטר מפנה לאובייקט שהועבר. שינוי איבריו נראה אצל הקורא; השמה של רשימה אחרת לפרמטר משנה רק את הקישור המקומי.

def add_item(items):
    items.append(3)

def replace_items(items):
    items = [9]

numbers = [1, 2]
add_item(numbers)
replace_items(numbers)
print(numbers)

פלט

[1, 2, 3]

add_item הוסיפה את 3 לרשימה עצמה, ולכן ההוספה נראית ב־numbers. לעומת זאת replace_items יצרה רשימה חדשה [9] וקשרה אליה את השם המקומי items בלבד; numbers נשאר מפנה לרשימה המקורית. כלומר, פונקציה יכולה לשנות את תוכן הרשימה שקיבלה, אך לא להחליף אותה אצל הקורא.

זו גם הסיבה להימנע מרשימה משתנה כברירת מחדל לפונקציה (למשל def f(items=[])): אובייקט ברירת המחדל נוצר פעם אחת ומשותף לכל הקריאות, כך שאיברים שנוספו בקריאה אחת יופיעו גם בקריאה הבאה. כשנדרש אוסף חדש לכל קריאה, משתמשים ב־None כברירת מחדל ויוצרים את הרשימה בתוך הגוף.