Lazy evaluation
⚠ שימו לב - העמקה

זהו מסוגי הפוסטים שבהם אני נכנס מאוד לעומק בנושא שבדרך כלל אני עובר עליו מהר כשאני מעביר שיעורים פרונטליים. בדרך כלל הנושא שלהם מסתכם בכמה שקופיות במצגת פאוור פוינט, לפעמים אפילו שקופית אחת.
הפוסט מסתיים ברשימת נקודות עיקריות - להבין אותן זה מספיק.
בפוסט הקודם הכרנו את טיפוס הנתונים boolean ואת הפעולות עליו - וגם (&&
), או (||
) ו-לא (!
). בפוסט הזה, אכיר לכם דבר חשוב שיש לדעת לגבי השניים הראשונים מהם.
מה שמשותף לשניהם הוא ששניהם פועלים על שני בוליאנים - אחד מימין ואחד משמאל, ושניהם מחזירים בוליאני. אבל יותר מכך - הביטו בטבלאות האמת שלהם:
וגם (&& ) |
או (|| ) |
---|---|
![]() |
![]() |
טבלאות אמת, שהן דבר שראינו בפוסט הקודם, אומרות לנו, בהינתן מה שנמצא בצד שמאל של סימן הפעולה ובצד ימין של הפעולה, מה תהיה התוצאה. לקרוא לדברים שעליהם פועלת הפעולה “צדדים” יוצא לי קצת מגושם, אז תרשו לי להכיר לכם את המונח המדויק - הביטויים עליהם פועלים הפעולות האלה נקראים אופרנדים (operands) - אני אשתמש במונח הזה לפוסט הקצר הזה אבל אשתדל שלא להצטרך אותו במקומות אחרים, אז לא חייבים לזכור את המונח.
אני מציג את טבלאות האמת בצורה קצת שונה כאן מאפשר בפעם הקודמת, כי בפעם הקודמת כל אופציה הייתה בשורה, והפעם אני משתמש בשורות בשביל אחד האופרנדים ובעמודות בשביל האופרנד האחר.
Lazy evaluation
אמנם הפעולות סימטריות, אבל בואו נחליט שבטבלאות האלה, העמודה קובעת את האופרנד שמשאל (הראשון) והשורה את האופרנד שמימין (השני). למשל, בשתי הטבלאות נביט בעמודה השמאלית ובשורה השניה. העמודה אומרת true
והשורה אומרת false
ולכן הם מייצגים את הביטויים:
true || false
true && false
והטבלה אומרת לנו שהתוצאה של ||
על true ו-false היא true, ושל &&
היא false.
בואו נסתכל רק על הטבלה של &&
בתור התחלה:
הביטו בעמודה הימנית, המייצגת את המקרה בו האופרנד הראשון הוא false. בשני המקרים, התשובה היא false. ולכן, מה ש-Java עושה כשהיא מטפלת בביטוי &&
הוא קודם לבדוק את האופרנד הראשון - ואם הוא false, היא לא טורחת לבדוק (לחשב) את האופרנד השני בכלל, ומיד קובעת שהתוצאה היא false.
זה עוזר למשל כאשר האופרנד השני עשוי לקחת זמן או משאבים לחישוב, או כאשר האופרנד השני הגיוני רק אם האופרנד הראשון הוא true. למשל, נניח שיש לי את החישוב הבא:
boolean canEditComment = isLoggedIn() && ownsComment(comment);
אמנם לא ראינו תחביר כזה ב-Java עדיין, עם הסוגריים, אבל נגיע אליו בהמשך, ועד אז, אתן יכולות להבין את הכוונה - מה שחשוב כרגע הוא שזה לא שם של משתנה, וזה דבר שמחשבים אותו רק כשמגיעים אליו בקוד (כלומר, כש-Java מגיע לשורה עם הסוגריים האלה). isLoggedIn()
יחזיר לנו true או false המייצג האם המשתמש מחובר למערכת, ו-ownsComment(comment)
יגיד לנו האם התגובה comment היא של המשתמש המחובר, אבל היא תיצור שגיאה אם המשתמש לא מחובר. וזו לא בעיה - אם האופרנד הראשון הוא false, אנחנו לא ננסה לבדוק האם ownsComment(comment)
, כי אנחנו כבר יודעים את התוצאה. אני קורא לזה “ביטוי && לא דורך על מוקשים”. אז אם אחד האופרנדים הגיוני רק אם האופרטור האחר הגיוני, אז האופרנד שתלוי באחר יהיה השני מביניהם.
גם ב-||
זה דומה, רק שכאן, התשובה זהה לשני המקרים של האופרנד השני, אם האופרנד הראשון הוא true. נביט בטבלה, בעמודה הימנית:
אם האופרנד הראשון הוא true, התוצאה של כל הביטוי היא true. ולכן, כש-Java רואה ביטוי ||
, היא קודם מחשבת את האופרנד הראשון, ואם הוא true, היא לא מחשבת את האופרנד השני בכלל, ופשוט מגיעה לתוצאה true. גם כאן, זה יכול לחסוך פעולות ארוכות, או פעולות שלא הגיוניות אם האופרנד הראשון הוא true:
boolean canMakePurchase = isAdult() || hasParentConsent();
הנה סיפור רקע כדי שזה יהיה הגיוני: אנחנו בונים מערכת כלשהי, נגיד פלטפורמת משחקים לטלפונים ניידים, שכולל קניות בתוך המשחק. מכיוון שראינו את הכתבות על ילדים שקנו סקינים באלפי דולרים מהאשראי של ההורים, בנינו מערכת שמאפשרת רכישה רק למי שהוא מבוגר, או למי שיש לו אישור הורים - ועבור אישור הורים, בנינו מערכת שפונה לטלפון של ההורה לבקש אישור. אבל אם המשתמש מבוגר, הניסון לקרוא ל-hasParentConsent()
ייצור שגיאה, כי לא רשום מבוגר אחראי עבור המשתמש. אם הדוגמה הזו הגיונית לכם, אתם יכולים לראות למה השורה הזו עושה את העבודה מעולה: עבור קטין, isAdult()
יהיה false ואז יורץ hasParentConsent()
, ואז התוצאה תהיה זו של hasParentConsent().
אם מדובר בבגיר, אז isAdult()
יהיה true, ואז לא נצטרך להריץ את hasParentConsent()
כי סיימנו.
בשני המקרים - ב-&&
וב-||
- ההגיון הזה נקרא “lazy evaluation”. כי הוא בדיוק כזה - lazy - עצלן, לא מגדיל ראש ולא עושה מה שלא חייב. זה נקרא גם short-cuircuiting, כי זה דומה לליצור קצר במעגל חשמלי, אני מניח? (או לחלופין, לליצור קיצור דרך במסלול כלשהו).
Greedy evaluation
אבל מה אם אנחנו בטוחות שאנחנו רוצות ששני הצדדים של הביטוי יחושבו בכל המקרים? הדבר הראשון לדעת על זה הוא ש… זה כמעט ולא קורה. קשה לי ליצור בכלל דוגמה שבה זה דרוש, אבל הנה אחת היפותטית, שככל הידוע לי לא קיימת, אבל נגיד.
נגיד שיש לנו על הרובוט מעלית שיכולה לעלות ולרדת, ונגיד שיש חיישן בתחתית המעלית שמופעל כאשר המעלית נמצאת בנקודה הכי תחתונה שלה. ונגיד שאנחנו רוצים בכל רגע לבדוק האם הכפתור A בשלט שמחזיק הנהג לחוץ, ואם כן, אז אנחנו רוצים לקחת את המעלית למטה, אבל רק אם החיישן אומר שיש עוד למעלית לאן לרדת, כלומר הוא לא לחוץ. עד כאן ה
נקודות עיקריות
- בוליאני - boolean - הוא טיפוס נתונים שמייצג תשובה לשאלת כן/לא
- ערכיו הם true (אמת - “כן”) ו-false (שקר - “לא”).
- בין השאר, ניתן לקבל בוליאנים בתור תוצאה של השוואות בין ביטויים מספריים, למשל
x < 3
אוy == 0
- בין בוליאנים יש שלוש פעולות עיקריות - וגם (
&&
), או (||
), לא (!
) - יש ביניהן סדר פעולות ומתרגלים אליו
דוגמה הגיונית לחלוטין. סיכום הביניים - אנחנו רוצים להזיז את המעלית רק אם מתקיים שני הבאים באותו זמן:
- הנהג לוחץ על כפתור A בשלט שלו - נגיד שכדי לבדוק את זה, מה שאנחנו עושים זה לכתוב
isAPressed()
- החיישן בתחתית המעלית אומר שהמעלית לא נמצאת בנקודה הכי תחתונה שלה - נגיד שאת זה אנחנו כותבים
!liftIsDown()
וזה אומר שאם אני רוצה להכניס למשתנה בוליאני את התוצאה של “האם להזיז את המעלית למטה”, זה יראה כך:
boolean takeLiftDown = isAPressed() && !liftIsDown();
עד כאן הדוגמה הזו באמת הגיונית (כלומר, לא בדיוק כך כותבים קוד לרובוט, אבל זה קרוב מספיק בהתחשב במה שלמדנו עד עכשיו, וזה מעביר את הנקודה שלי). עכשיו, בואו ניקח הנחה שהיא לא הגיונית: נגיד שיש בעיה בספריות שאחראיות על isAPressed()
ועל liftIsDown()
, ואם לא בודקים את זה לפחות כל כמה שניות, משהו משתבש והספריה כבר לא עובדת והתוצאות לא נכונות. אני מדגיש, ההנחה הזו לא הגיונית, אבל בואו נגיד שכן. אם זה היה המקרה, אז נגיד שבמשך כמה שניות הכפתור A לא נלחץ (שזה הגיוני), אז בכל אחת מהפעמים שחישבנו את התוצאה של הביטוי למעלה, האופרנד השמאלי היה false, ואז לא חוּשָב האופרנד השני, כלומר לא בדקנו האם המעלית למטה, וזה (בתסריט הלא הגיוני שלנו) אומר שעכשיו התוצאות של liftIsDown לא בהכרח תהיה נכונה ואז דברים לא יעבדו. מה עושים?
יש כמה תשובות אפשריות. הראשונה היא - greedy evaluation. יש אלטרנטיבה ל-&& שעושה את אותו דבר, אבל בלי lazy evalutaion - שני האופרנדים נבדקים גם אם השמאלי הוא false. מה שעושים הוא להחליף את ה-&& ב-& - אמפרסנד יחיד (כן, לסימן הזה קוראים אמפרסנד, ולא, לא צריך לזכור את זה):
boolean takeLiftDown = isAPressed() & !liftIsDown();
וכמו שיש && שהוא lazy ואת & שהוא greedy, גם ל-||
ה-lazy יש את |
שהוא lazy. והעניין הוא - שממש לא צריך לזכור את זה. כאמור, כמעט אין לזה שימוש, ולמעשה, אני מניח שרוב מתכנתי Java לא זוכרים שזה קיים ומה ההבדל. עדיף להשתמש רק ב-&& וב-||
, וברוב המקרים אנחנו לא צריכים greedy evaluation.
כשעשיתי קורס מבוא למדעי המחשב באוניברסיטה, היו דורשים מאיתנו להשתמש ב-&& וב-||
רק במקומות שבהם, כמו בדוגמאות למעלה, חישוב האופרנד הימני הוא בכלל לא הגיוני אם האופרנד השמאלי הוא false (במקרה של &&) או true (במקרה של ||
) - מה שקראתי לו “לא לדרוך על מוקשים”, ובכל מצב אחר, היינו צריכים להשתמש ב-& וב-|
. למה? כי זו הייתה ההנחיה, כנראה כי הם רצו שנבדיל בין המקרים שבהם יש “מוקש” למקרים שבהם אין. אבל במציאות, זו לא סיבה טובה מספיק להשתמש ב-& וב-|
, וצריך לעשות את זה רק בדוגמאות קיצוניות כמו הדוגמה הדמיונית שלנו.
וגם אם אנחנו צריכים בכל זאת, כמו בדוגמה שלנו, לחשב את שני הצדדים, יש לדעתי אופציה שהיא יותר טובה: שימוש במשתנים. כאן אין שום דבר חדש - אנחנו פשוט נכניס את התוצאה של כל אחד מהחישובים האלה למשתנה, ואז התוצאה בוודאות תחושב, ואז נעשה && בין המשתנים:
boolean driverWantsToLowerLift = isAPressed();
boolean canLowerLift = !liftIsDown();
boolean takeLiftDown = driverWantsToLowerLift && canLowerLift;
אני מקווה שברור למה זה פותר את הבעיה. למען האמת, אתן יכולות לשים לב שמספיק היה להוציא את האופרנד הימני למשתנה, כלומר הקוד הבא היה עובד באותה מידה:
boolean canLowerLift = !liftIsDown();
boolean takeLiftDown = isAPressed() && canLowerLift;
לדעתי להוציא את שניהם למשתנים זה יותר אסתטי אבל זה לגמרי עניין של העדפה.
בגלל העניין הזה לגבי זה שמתכנתים לא זוכרים לרוב את & ו-|
ומה ההבדל (וגם אתן לא צריכות לזכור), או לפחות שכנראה בגלל הסיבה הזו, כאשר אני כותב קוד עם & או |
ב-IntelliJ IDEA, אז תוסף שאני משתמש בו לשיפור איכות קוד (ארחיב על זה בפעם אחרת) מציע לי להחליף לאופציה שמתשמשת במשתנים:
רק עוד עניין אחד לגבי שתי הדוגמאות האלה - מי שקורא אותן לא בהכרח יבין למה צריך להשתמש ב-& (באפשרות הראשונה) או לשים את התוצאות במשתנים (באפשרות השניה). ואז, מאוד רצוי להשתמש בתגובה (הערה, קומנט - ראו כאן אם שכחתן), שבה מוסבר למה זה חייב להיות ככה - אחרת מישהו אחר (אגב, מישהו אחר עשוי להיות גם אתן של עוד שבוע) יחשוב שאפשר לשנות את הקוד. אם הולכים כל האופציה עם & או |
, רצוי גם להזכיר למי שקורא את הקוד מה ההבדל. הנה דוגמאות לקומנטים אפשריים בשתי האפשרויות:
// Due to a library problem, we must check
// both every time or a bug may happen.
// Using & instead of && (greedy evaluation)
// makes sure both sides are checked every time.
boolean takeLiftDown = isAPressed() & !liftIsDown();
// Due to a library problem, we must check
// both every time or a bug may happen.
// Using variables ensures we check both every time.
boolean driverWantsToLowerLift = isAPressed();
boolean canLowerLift = !liftIsDown();
boolean takeLiftDown = driverWantsToLowerLift && canLowerLift;
נקודות עיקריות
- בגלל המשמעות של &&, אם האופרנד השמאלי הוא false, אז התוצאה היא false בלי תלות באופרנד הימני. בדומה, ב-
||
אם האופרנד השמאלי הוא true אז התוצאה היא true בלי תלות באופרנד הימני. - בהתאם, Java חוסכת חישוב מיותר ומבצעת lazy evaluation
- זה לא רק עניין של חסכון בזמן אלא של “לא לדרוך על מוקשים” - מצבים שבו לא הגיוני לחשב את האופרנד הימני אם השמאלי מגיע לתוצאה מסוימת (זה מקרה נפוץ יחסית)
- אם ממש חייבים לחשב את שני הצדדים (מקרה נדיר), כדאי להשתמש בגרסאות ה-greedy שהן & ו-
|
, או (עדיף) להוציא למשתנה- בשתי האפשרויות חשוב להוסיף קומנט שמסביר למה היינו צריכים לחשב את שני הצדדים
Comments