יסודות: גרסאות בתוכנה

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

גִּרְסָאוּת (Versioning) היא טכניקה שנועדה לצמצם מורכבות של "העולם" הנובעת משינויים, בלתי-תלויים, של אלמנטים שונים המשפיעים על המערכת.

כשאנחנו מתחזקים מערכת-תוכנה לאורך זמן, החלקים השונים שלה עוברים שינויים מגוונים: שינויים בקוד המערכת, בספריות צד/שלישי (היום: בעיקר Open Srouce), או שינויים בחומרה ובסביבת הריצה עליה המערכת רצה (מערכת הפעלה, Virtual Machine, ענן, וכו').

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

החוקים הלא כתובים של הגרסאות

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

יש כמה כללים מקובלים לגבי גרסאות:

  • Immutability – שינוי ברכיב בעל הגרסה (להלן: הרכיב), יוביל לשינוי הגרסה. זה לא תמיד נכון, אבל זו הציפיה המקובלת.
    • Git Hash (סוג של גרסה), למשל, נובע מתוך התוכן, כך שכל שינוי קוד, אפילו לא משמעותי (למשל: הוספת שורת רווח) – יתבטא בשינוי ב Hash.
    • אין דבר שמונע מכותב ספריית צד-שלישי לשנות את הקוד, מבלי לעדכן את הגרסה. זו לא הציפיה – אך זה יכול לקרות (אולי יש היום כלי שמתריעים בפני כזה מצב, אני לא מכיר).
  • Orderability – יש דרך מובחנת להבחין איזו גרסה חדשה יותר מגרסה אחרת. לרוב הגרסה ממוספרת כמספר, אז מספר גבוה יותר – גרסה מאוחרת יותר.
    • פעם היה מקובל שמספר גרסה ביטא את גודל ההתקדמות. למשל: אחרי Windows NT 3.1 הגיעה גרסאת 3.5 בכדי לבטא שזה שינוי משמעותי. כנ"ל ב MacOS.
    • כמה הפצות של לינוקס משתמשות בתאריך ההפצה בפורמט YY-MM כגרסה. אז 16.10 אכן מאוחרת יותר מ 16.04, אבל לפעמים מפתיע שזו גרסה גדולה, ולא עדכון קטנטן, שיכול להשתמע מהמספרים העשרוניים. כמו כן, אין גרסאות אמצע. אין גרסה 16.05.
    • GNU בחרו בגרסאות במספרים מאוד גדולים. למשל: 5001, 5002. לא ברור לי למה, או מדוע אני נטפל דווקא למערכות הפעלה…
    • אנשי שיווק לא תמיד מקפידים על כללי ה Orderability בגרסאות. נסו לסדר את הגרסאות השונות ע"פ הסדר הנכון: Xbox One, Xbox 360, Xbox X, Xbox S, Xbox one S, ו Xbox one X.
נשמע כמו טקסט של ד"ר סוס…

Semantic Versioning

בכדי להימנע מבלבול ובלאגן בגרסאות, התעשייה נצמדה לקונבנציה פשוטה וברורה של גרסאות שנקראת Semantic Versioning. עם הזמן המבנה הזה תוקנן – והיום הוא תקן סטנדרטי (אפילו קיים בעברית). כדאי להכיר אותו:

  • שינויי Patch – מבטיחים תאימות לפנים (forward-compatibility) ולאחור (backward-compatibility). אתם יכולים לשנות לעבור מגרסה 1.4.1 ל 1.4.3 של ספרייה הלוך ושוב – ולצפות ששוב התנהגות לא "תשבר" לכם.
    • כמובן שגרסה מבטאת שינוי ברכיב. ההתנהגות הצפויה אמורה לא להשתנות, אבל אולי "באג" נוסף בגרסה 1.4.1 ושינה את ההתנהגות הצפויה. התיקון שלו בגרסה 1.4.3 החזיר את ההתנהגות בפועל להתנהגות הצפויה. כלומר: גרסת Patch לא תשנה את ההתנהגות הצפויה, אך היא עלולה לשנות את ההתנהגות בפועל (בעקבות באגים).
  • שינויים Minor מבטיחים תאימות לאחור, אך לא תאימות לפנים. כלומר: אם הקוד שלי עבד עם גרסה 1.2.1, הוא צפוי לעבוד גם עם גרסה 1.3.5 – אך לא להיפך. אני לא יכול לצפות שקוד שעבד עם גרסה 1.3.5 יעבוד עם גרסה 1.2.1.
    • הסיבה העיקרית לזה היא שגרסאת Minor משמשת לתוספות התנהגות, למשל: APIs חדשים.
    • זו גם הסיבה מדוע כלי ניהול-תלויות הנתקלים בקונפליקט בגרסאות minor – בוחרים את הגרסה המאוחרת יותר בצורה אוטומטית. זו הציפיה מ"חוזה" הגרסה. נדבר על זה בהמשך.
  • שינויים Major לא מבטיחים שום סוג של תאימות. במעבר מגרסה 1.3.5 לגרסה 2 כלשהי (למשל 2.0.1) – מצופה ממני לקרוא בקפדנות את ה release notes, ולבצע את כל הבדיקות הנחוצות אם ההתנהגות העדכנית מתאימה לי. גרסאות Major שמורות לשינויי התנהגות, שכל מערכת "חייה" צריכה לעשות מדי-פעם.
    • כמובן שאם אני רוצה לשמור על קהל הלקוחות שלי אנסה לצמצם שינויים כלשהם, גם בגרסאות Major – למינימום. לפעמים השינויים בגרסאת Major הם גדולים מדי, והלקוחות שלי פשוט ייעצרו מלעדכן גרסאות. למשל: שינויים שוברי-התנהגות בפייטון 3 עיכבו את הקהילה כעשור, בממוצע, באימוץ החידושים.

ל Semantic Versioning יש כמה הרחבות, שקצת יותר פתוחות לפשרנות:

  • מקף אחרי מספר הגרסה – הוא מקום לתאר Pre-Release
    • ההגדרה המסורתית היא ש Alpha הוא שלב בתוכנה שעדיין מוסיפים / חסרים פיצ'רים עיקריים, בעוד ב Beta כל הפיצ'רים שם – רק לא עובדים עד הסוף.
      • כיום ההגדרה הרבה פחות מדויקת, ולא פעם נטען שמוצרים משוחררים (GA = General Availability), כשבעצם ברמת האיכות הם מתאימים יותר לשלב בטא. בחודשים אחרי השחרור הרשמי – יתבצעו התיקונים. הגישה הזו מאוד מתיישרת עם רעיונות ה Lean Startup.
    • אין גאמה😃, אבל אחרי שלב הבטא נראה לא פעם RC – קיצור של Release Candidate. המשמעות: המוצר כמעט מוכן, ורוצים לבדוק אותו ממש לפני שחרור. לרוב יהיו כמה RCs שימוספרו: RC1, RC2, וכו'.
    • לפעמים משתמשים במספר 0 (אפס) כ Major version לתאר pre-release. למשל: 0.3.1.
    • "SNAPSHOT" כ pre-release הוא סימן מקובל בעולם ה JVM שאנחנו מעדכנים את הרכיב, בלי לעדכן את מספר הגרסה. כלומר: תמיד הגרסה תישאר 1.0.0-SNAPSHOT, למרות שהקוד השתנה. שימוש זה הוא רק בזמן פיתוח – ולא לגרסאות ששוחררו "לעולם".
  • סימן + אחרי ה pre-release הוא מקום סטנדרטי להוספת Build metadata.
    • זה יכול להיות מספר סידורי של הבילד (עולה בכל פעם שעושים Build למוצר השלם). למשל: 3601. המספר גבוה כי בונים כמה פעמים ביום.
    • זה יכול להיות ערבוב גם של מספרים ואותיות, כל מידע אינפורמטיבי שיעזור למפתחים שמדבגים, בעיקר.
    • לא פעם משתמשים ב build number כב Patch version. למשל: 2.13.3601, כאשר 3601 הוא מספר ה build.

גרסאות של ספריות

אני מניח שכל מפתח בימנו, משתמש בהרבה ספריות: בעיקר Open Source וספריות פנימיות של הארגון. לכל ספרייה יש גרסה, ואנחנו מקפידים מדי פעם לעדכן את הגרסאות כדי:

  • ליהנות מקוד חדש יותר (הנחה סמויה: טוב יותר).
  • להימנע מקפיצות גדולות (יאמרו: "קפיצת הדרך" + ג'יהאד עולמי). אם לא עדכנו ספרייה כשנתיים, ייתכן והפערים המצטברים יהיו קשים לגישור ופתאום נאלץ להשקיע עבודה רבה, לא מתוכננת, כדי לעדכן את הקוד שלנו שיעבוד עם הגרסה החדשה של הספרייה, לה אנחנו נזקקים מסיבות כאלו או אחרות.
  • אבטחה: חולשות בספריות מתגלות ומתפרסמות, ולעתים ניתן לנצל אותן לתקוף את המערכת שמשתמשת בהן. עדכון תדיר של ספריות – יפחית בצורה משמעותית את הסיכון הזה.

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

כאשר שינוי הגרסה הוא minor או patch – אנחנו לא צופים אי-תאימות, אבל היא יכולה לקרות, בשל באג או בגלל שכותב הספרייה לא צפה שתהיה אי-תאימות.

כאשר שינוי הגרסה הוא major – עלינו לצפות לחוסר תאימות (לקורא היטב את ה release notes ולבדוק היטב את המערכת לאחר העדכון) – אך פעמים רבות, חוסר התאימות לא תשפיע עלינו ספציפית, כי אנחנו לא משתמשים בחלקי הספרייה ששינו את התאימות שלהם.

בעיה שהייתה נפוצה בעבר, הוא כאשר ספריות הותקנו במערכת ההפעלה לשימוש כל האפליקציות (aka DLL Hell or Jar Hell). אפליקציה א' השתמשה בספרייה X בגרסה 1.0.0, אבל אז הותקנה אפליקציה ב' שהתקינה את ספרייה X בגרסה 2.0.0 (דרסה את 1.0.0) – וכך גרמה לאפליקציה א' להפסיק לעבוד.

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

בעיה שנותרה רלוונטית, היא בעיית "תלויות היהלום" (Diamond dependency) בין ספריות בהן משתמשת אותה האפליקציה/מערכת:

למשל ב Case 1 המערכת שלנו משתמשת, טרנזיטיבית, בשתי גרסאות שונות של Library C: גרסה 3.1.0 וגרסה 3.2.0.

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

  • Shared Libraries – כל המערכת / אפליקציה תשתמש בגרסה יחידה לכל ספרייה (להלן: ספרייה C).
    • יתרון אחד הוא Deployable קטן יותר של המערכת. במערכות גדולות יש לעתים מאות תלויות טרנזיטיביות, ולא נדיר למצוא אותה ספרייה ב 10 גרסאות שונות ויותר. לעתים ההבדל בין Shared Libraries ל Isolated Libraries יכול להגיע ל Deployable גדול פי 2-3 כאשר אנחנו משתמשים ב Isolated Libraries.
    • Deployable קטן יותר – משמע פחות זיכרון (RAM) שנדרש. פחות הכפלה של Singleton classes (למשל: או State שיושב על הגדרות המחלקות, ה Classes). אם ישנו בספרייה Cache – יהיה מאגר אחד לכל האפליקציה, ולא כמה Caches כפולים, ע"פ גרסאת הספרייה המדויקת.
    • בשפות JVM ה Class Loader יסרב לטעון שתי מחלקות עם אותו השם (נניח: מגרסאות שונות של ספרייה C). בשפות Strongly Typed אחרות, ייתכן ויהיה אפשר – אך בזמן ריצה מבני נתונים לא תואמים (עם אותו השם, אבל למשל טיפוסים שונים המגיעים מגרסאות שונות של הספרייה) – יגרמו לשגיאות בזמן ריצה, שגיאות שלעתים מאוד קשה לשחזר ולתקן.
    • גם בשפות שאינן Strongly typed צפויות בעיות, הם יצוצו מאוחר יותר – ויהיו קשות יותר למציאה. כאשר המערכת משתמשת לסירוגין פעם אחת באויבקט של גרסה 3.1.0 ולעיתים של גרסה 3.2.0 (ואין type safety לשים לב להבדל) – יכולים לצוץ באגים קשים ומבלבלים.
  • Isolated Libraries – כל ספרייה נארזת עם ספריות המשנה שלה – בגרסה שהיא ביקשה (ובדקה). אמנם ה Deployable שלנו יהיה גדול יותר, ויצרוך יותר זיכרון – אך לא נצטרף לפתור קונפליקטים של גרסאות, כמו: "באיזו גרסה של Library C עלינו להשתמש". ספרייה B תשתמש בגרסה 3.1.0 וספרייה A תשתמש בגרסה 3.2.0.
    • אמנם חסכנו התמודדות עם קונפליקטים בטווח הקצר, אבל עדיין עם אובייקטים בזיכרון, של ספרייה C בגרסאותיה השונות יעברו לסירוגין בקוד של ספריות A ו B – צפויים באגים מוזרים וקשים לאיתור במערכת.

למרות שאפשר לבחור ב Isolated Libraries, מקובל הרבה יותר לבחור ב Shared Libraries גם בשל החיסכון בזיכרון, אבל בעיקר בכדי להתמודד עם קונפליקטים בין גרסאות של ספריות מוקדם ככל האפשר ("Fail Fast") – בשלב ה Build וכמה שפחות בזמן ריצה בפרודקשיין.

על ה JVM למשל, נראה:

  • Isolated Libraries כ Fat Jar (נקרא גם Uber Jar) – כלומר אריזה של כל הספריות התלויות כ jars מקוננים בתוך jar יחיד (להלן: כל אפליקציה מספקת את כל הספריות שהיא זקוקה להן, ולא מניחה שהן מותקנות כבר במערכת ההפעלה).
  • Shared Libraries כ Shadow Jar – כאשר אנחנו אורזים לתוך jar גדול (ולכן מתבלבלים לעתים כאן עם השם Uber Jar) את כל הספריות שנדרשות – אבל עותק אחד מכל אחת. בתהליך יצירת ה Shadow Jar ייתכן וישונה ה bytecode של הספריות בכדי לתאום ל package name יחיד.

אפשר גם להשתמש בגישה מעורבת בה יש Shadow Jar של ספריות משותפות, שנבחרו ידניות להיות כאלו, הנארז בתוך Uber Jar בו כל שאר הספריות יכולות להיות בגרסאות כפולות. כלי ה Build מוודא שאין ספריות שהן גם Shared וגם כפולות. הגישה הזו פחות נפוצה לדעתי – מעולם לא נתקלתי ארגון שהשתמש בה.

נחזור לתרשים למעלה: איך פותרים את הקונפליקט?

ב Case 1 – לרוב כלי ה build יפתור את הקונפליקט אוטומטית (למשל בעולם ה JVM: מייבן וגריידל יפתרו אוטומטית, Ivy – לפי דעתי יזרוק שגיאה וידרוש התערבות ידנית). ההנחה כאן היא שחוקי הגרסאות הסמנטית נשמרים – ולכן שינוי גרסה שהוא minor, הוא backward compatible – ולכן בטוח לקחת את הגרסה המאוחרת יותר. * ההנחה הזו לרוב עובדת, אבל לא תמיד.

שימו לב ש Case 2 נראה קצת שונה (הוא רק "חצי יהלום") – אבל בעצם לוגית הוא נחשב "תלות יהלום" לכל דבר ועניין. אם אנו מחזיקים ספריות משותפות לאפליקציה – עדיין יש לבחור באיזו גרסה לבחור.

שווה לציין שמנהלי תלויות שונים עשויים להתנהג בצורה שונה. למשל:

  • במייבן הכלל המוביל הוא "Nearest first", אז ב Case 2 תבחר גרסה 3.1.0 של ספרייה C כי היא רק "קשת אחת" מהאפליקציה.
  • בגריידל הכלל המוביל הוא "Latest first" ולכן תמיד תיבחר גרסה 3.2.0 של ספרייה C (כלל יותר הגיוני ועקבי).
    • במייבן אגב, אם המרחק שווה (כמו ב Case 1), תבחר הגרסה של התלות שהופיעה ראשונה בקובץ ה POM.XML. כלומר: אם כתבנו את התלות ב Library A קודם – אז תבחר גרסה 3.2.0 של ספרייה C, ואם כתבנו קודם את התלות של Library B קודם – אז תבחר גרסה 3.1.0 של ספרייה C. כלומר: סידור קובץ ה POM.XML (למשל: לפי א"ב) – עלול לשבור לכם את האפליקציה. מאוד מבלבל.

בואו נבחן מקרה קשה מעט יותר. מה נעשה כאשר הקונפליקט בין הגרסאות הוא בין Major versions?

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

בכל מקרה, יהיו מקרים בהם הגרסה נבחרה אוטומטית (ואז הבילד נכשל בקומפיליציה/בדיקות) או שהמפתח התערב ידנית – אבל הקוד לא עובד: ספרייה B דורשת את גרסה 4.1.0 וספרייה A לא מסוגלת לעבוד עם גרסה 4.1.0. מה עושים אז? האם אנחנו חייבים להחליף את השימוש בספרייה A או ספרייה B (מקרה הקיצון 😱) לספריות חלופיות – או שיש לנו ברירה אחרת?

  • ברירה נפוצה אחת היא לעשות downgrade לספרייה B. הרי לפני העדכון האחרון שלה, היא עבדה עם גרסה 3 כלשהי, והמערכת עבדה. כלומר: נדחה את העדכון של B עד שספרייה A תתמוך בגרסה 4 של ספרייה C. לעתים זה יכול לקחת שנה ויותר – וזו בהחלט פשרה.
  • עוד ברירה נפוצה, וזו שלפעמים בה פותחים, הוא לנסות ולהכריח את ספרייה B לעבוד עם גרסה ישנה יותר של Library C. אולי 3.2.0, ואולי גרסה מאוחרת יותר 3.4.3 (האחרונה בגרסה 3, למשל).
    • קיים סיכון שהקומפילציה תצליח, אבל רק לאחר שבועות נגלה בעיות ב Production. אם יש לנו סט בדיקות מקיף, וניטור טוב של פרודקשיין – האופציה הזו הופכת ליותר רלוונטית. ניסוי וטעייה.
דוגמה לנעילת ("force") גרסה ספציפית של תלות ב Gradle. במקרה הזה נעלנו טווח בין 3.9 עד 4.0 (לא כולל).
בגרדייל יש אפילו הגדרת because כתיעוד להסביר מדוע הנעילה נעשתה.
שווה לציין ש Gradle סט יכולות רחב, ויש מספר דרכים שונות לבצע נעילות של גרסה של ספרייה.
  • אפשרות קיצונית יותר אך אפשרית הוא לעבור להשתמש בספרייה אחרת (במקום ספרייה A או ספרייה B). האפשרות הזו סבירה יותר ככל שהספרייה קטנה יותר, והתלות שלנו בה קלה יותר.

סיכום

סקרנו את הבסיס של ניהול גרסאות, בדגש על גרסאות של ספריות שהמערכת שלנו תלויה בהן. גִּרְסָאוּת רלוונטית גם לפרוטוקולים, APIs, נתונים גולמיים, ועוד.

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

לפעמים תיקון באג, יקלקל לנו את המערכת. טעות נפוצה היא לחשוב שהוספת Validations בקוד הם backward compatible changes – אבל קריאות שעד לפני השינוי עברו – יתחילו להיכשל. כלומר: Validation נוסף הוא דוגמה חמקמקה לשינוי שסמנטית אינו Backward compatible בהכרח.

לעתים השבירה בין גרסאות היא אפילו יותר חמקמקה – ותלויה בשילוב בין שתי ספריות. למשל: שתי הספריות מגדירות משתנה גלובאלי בשם זהה – שדורס אחד את השני (בעיקר בשפות דינאמיות), או אולי שומרות הגדרה במשתנה סביבה באותו השם. תוספת תמימה לחלוטין של ספרייה X שנראית לחלוטין backward compatible יכולה להתנגש עם ספרייה אחרת Y – ולגרום לקונפליקט.

שווה להזכיר לרגע את האירוע בו מתכנת JavaScript הסיר את הספרייה הפצפונת left-pad ממנהל החבילות NPM – ושבר את האינטרנט, בתור תזכורת לכמה תלויות קיימות בין ספריות שאנחנו לא מודעים אליהן. כל פעם שאני נכנס בפרויקט לתיקיית ה cache של ה package manager ורואה בכמה ספריות המערכת תלויה בפועל – אני מתפלא.

שיהיה בהצלחה!

לינקים רלוונטיים:

איך לנצח את הסיבוכיות?

בתואר שני במדעי המחשב, היה קורס חובה בשם ״סיבוכיות״. עסקו בו במושגים כמו DTime, NP-Hard, NP-Complete או PSPACE. בעצם, סיווגו בעיות קשות מדי לקטוגריות ואבחנו אבחנות שונות לגביהן.

זו בהחלט גישה של מדעני תוכנה. תאורטית.
מהנדסי תוכנה אמורים לזהות מהר כשמשהו מסובך מדי – ו״לשבור״ לפתרון פשוט יותר (עם ה tradeoffs האפשריים). זה לא דבר שנלמד כמעט בשום אוניברסיטה (מלבד CS190 באוניברסיטת סטנפורד, אולי?) – אבל זו בהחלט התמודדות יומיומית חשובה של מהנדסי תוכנה. ראוי שזה יהיה קורס חובה לכל בוגר מדעי המחשב (בתואר ראשון) — וראוי בהחלט לפוסט!
סיבוכיות בתוכנה היא ריקבון. היא מזיקה – והיא נוטה, באופן טבעי, רק לגדול ולהחמיר.
באופן אירוני חלק מאנשי התוכנה גאים בה, ומקדמים סיבוכיות – וזה דבר שראוי שיפסק. לא פעם ראיתי מהנדסים שנפעמים מהקוד הסופר-מסובך שכתבו, כראיה לאינטלגנציה או יכולת גבוהה (לכאורה) שהם בורכו בה. בפועל הם חוגגים משהו שממש מזיק למערכת, וחשוב להעמיד דברים במקומם. זה התפקיד של כולנו.
יש לבני-אדם נטייה טבעית (?) להתפעם מהמסובך, מהבלתי-מובן – וכתיבת קוד מסובך היא דרך מוכחת של אנשי-תוכנה להרשים אנשים אחרים. בכדי להצליח לכתוב תוכנה טובה, באופן מקצועי – מובילי הדעה בארגון צריכים לקדם תרבות המאדירה קוד הפשוט – ומדירה קוד המסובך. זה לא יקרה מעצמו.
הגאווה בקוד שצריך לגרד בראש ולקרוא אותו כמה פעמים בכדי להבין אותו – צריכה להיכחד. היא מזיקה לארגון ומזיקה למערכת.
בתוכנה יש שני סוגי סיבוכיות: סיבוכיות נחוצה (essential complexity) וסיבוכיות מקרית (accidental complexity). את הראשונה ראוי למתן (אפרט בהמשך) ואת השנייה יש להכחיד.

Generics, למשל, עלולים בקלות להוסיף סיבוכיות, ויש מקום להעריך את מי שמצליח להימנע משימוש בהם. אני בטוח שזה counter-intuitive למפתחים צעירים.

זיהוי סיבוכיות

שלב ראשון בטיפול בסיבוכיות – הוא הזיהוי שלה.
סיבוכיות היא כל מה שמקשה על הבנה או שינוי של קוד.
  • אלגוריתם מורכב = סיבוכיות
  • ריבוי תלויות = סיבוכיות
  • קוד המפוזר באזורים שונים במערכת = סיבוכיות
  • קוד בלתי צפוי (פונקציות ״calc״ שמעדכנת ערך של האובייקט בבסיס הנתונים) = סיבוכיות.
  • שימוש בסגנון / קונבנציות / ספריות לא מקובלות = סיבוכיות
  • יישום Design Pattern מדהים היכן שאינו באמת נדרש = סיבוכיות
  • וכדומה….
קוד שקשה (/מסוכן) לשנות אותו – הוא מסובך. קוד שקל לשנות אותו – הוא פשוט.
שאלה: כיצד ניתן להוסיף קוד, לקוד מסובך – ולפשט אותו?
תשובה ראשונה: בדיקות יחידה מפשטות את הקוד. הן מכריחות קוד (אם נכתבו נכון) להיות מודולורי יותר והן מבארות את הקוד, מוסיפות ביטחון לשינויים בו. הוספנו קוד – אך פישטנו אותו.
תשובה שנייה: קוד קצר ומחוכם (clever) הוא הרבה פעמים מסובך יותר מקוד ארוך יותר אך אינטואיטיבי וקל להבנה. פחות שורות קוד הן לא תמיד מדד לפשטות של תוכנה.
כלים, כמו IDE, עוזרים לפשט את הקוד (במעט).
העיקרון האובייקטיבי לסיבוכיות של קוד
אם דיי אנשים חושבים שהקוד שלכם הוא מסובך – אז הוא מסובך.
לא תמיד אנשים יאמרו לכם במפורש ״זה קוד מסובך וקשה לעקוב אחריו״. לפעמים הביטוי הוא אנשים שלא מפסיקים לשאול שאלות על הקוד ומה מתרחש בו. ביטוי אחר הוא שאנשים פעם אחר פעם עושים שינויים לא הגיוניים בקוד ו/או לא משתמשים בו נכון.
זו נקודה שלא תמיד הצלחתי להעביר בקלות, לכותבי הקוד המסובך.
״אבל אני מבין אותו״, ״צריך לעבוד איתו כמה שבועות ואז זה נהיה מובן״, ״אני לא מסכים – לי הוא נראה פשוט״ — הם טיעונים חלשים.
יש אנשים מאוד אינטליגנטים שקל להם לנווט בקוד מסובך, במיוחד קוד שלהם – ולכן קשה להם להכיר במסובכות של הקוד. כאן יש מקום גדול לקהילה מסביב לתת את הדין אם הקוד פשוט או לא.
הקוד הוא לא אישי. לא מדובר באופן שאני רוצה לסדר את חדר השינה (הפרטי) שלי. הקוד הוא חלק ממערכת וארגון – וחשוב מאוד שהוא נגיש לכלל האנשים. ״אבל אני מבין את הקוד״ – הוא פשוט טיעון לא מספיק טוב!
קיים גם הטיעון: ״זה נראה כמו סינית, אבל אם תלמד סינית – זה היה פשוט״ ואני מקבל אותו, לפעמים. לפעמים חשוב להבין את הדומיין / ההקשר בכדי לקרוא קוד, ואז קוד שנראה מסובך – עשוי להפוך לפשוט.
האם באמת הקוד קשה להבנה שינוי בגלל הבנה עמוקה שנדרשת בדומיין – או גם מישהו שמבין את הדומיין עלול להסתבך איתו? האם מתכנת מנוסה שמכיר את הדומיין צריך לקרוא את הקוד יותר מפעם אחת בכדי להבין אותו?
Code Review ו/או Code Inspection הם תהליכים יעילים לאיתור סיבוכיות בקוד.
זיהוי סיבוכיות בקוד לאחר מעשה
לפעמים אנו מבינים סיבוכיות של קוד רק תוך כדי עבודה על שינוי:
  • שינוי קונספטואלי אחד (״מעתה הרשה לפעולה להתרחש 3 פעמים, ולא רק 2״) – דורש שינויים במספר (גדול) של מקומות בקוד.
    • השאיפה תמיד בקוד היא ששינוי קונספטואלי אחד – ידרוש שינוי מאזור ממוקד אחד בקוד.
  • Design Weight – הקוד גורם לנו לעומס קוגניטיבי ומאלץ אותנו זכור ולקשר נקודות, מידע שקשה ״להחזיק״ בראש מבלי לאבד פרטים ולהזדקק לחזור ולקרוא בקוד. קוד מסובך הוא כמו ספר בפיסיקה, שכל פעם צריך לחזור לפרק הקודם ולהיזכר ולהבין מחדש במה בדיוק מדובר. קוד פשוט הוא כמו עיתון שניתן בכל רגע לקפוץ לכל עמוד ופסקה – ובקלות להבין במה מדובר.
  • תוך כדי שינוי שנראה פשוט בתחילה, אנו מגלים עוד ועוד נקודות שיש לקחת בחשבון. לעולם איננו יודעים בבטחון אם סיימנו 50% עבודה או רק 10% עבודה. יותר גרוע: כאשר ככל שאנחנו מתקדמים, הבטחון שלנו מתי וכיצד יראה הסוף – הולכים ומתרחקים.
קשה לי להאמין שיש מפתח שלא מכיר את הסימפטומים הנ״ל.
פישוט קוד, והימנעות מסיבוכיות מיותרת היא מלאכה חשובה, מעשית, ונדרשת – ובעצם מה שמבדיל בין אנשי תוכנה טובים – למצוינים.
בספר “A Philosophy of Software Design”, שהוא הטריגר לפוסט (מזמן רציתי לכתוב על הנושא, אבל הייתי זקוק לטריגר שכזה) – וגם חלק מהפוסט מבוסס עליו – מאפיינים דמות בשם ״הוריקן טקטי״ (לקחתי לי חופש תרגום מ Tactical Tornado) של מפתח החולף על פני המערכת, תוך כדי שהוא מספק פיצ׳רים במהירות, אבל פיצ׳רים שסגורים רק ב 80% – ובסופו של דבר דורשים עוד עבודה ו/או מסבכים את המערכת. המנהלים לעתים רואים אותם כגיבורים, ״למה אין לנו עוד מפתחים שמספקים תוצאות כל-כך מהר?!״ – אבל המפתחים שנתקלים בקוד, ועוקבים אחרי הדינמיקה שמתרחשת בפועל – מבינים במה מדובר.
עוד דמות חשובה היא ה״ארמגדון רב-החזון״, מישהו שמאמץ מירב אדיר של טכניקות הנדסיות (Patterns, כלים, טכניקות, ספריות) שאינן מתאימות או נדרשות לבעיה – ומשאיר מערכת סבוכה ללא צורך. הוא לא מהיר – אבל מזיק באותה המידה, ואולי אפילו יותר.
מאוד קשה לאפיין ולמצוא מדדים אובייקטיבים וקלים-לקריאה המעידים עד כמה פשוטה או מורכבת מערכת נתונה. רבים ניסו – בלי הצלחה מרובה, ואין לי לצערי בשורה חדשה. אני יכול רק להציע את Genchi Genbutsu – ללכת לשטח, להיכנס לפרטים, לטעום ולהבין במה מדובר – זו הטכניקה המעשית היחידה שאני מכיר, אם כי היא איננה scalable.

אז מה עושים?

זה החלק הקשה. חפשו קצת באינטרנט – בטח תמצאו. יאללה ביי!
(זה מה שמתחשק לי לומר, אבל מרגיש לי קצת לא אחראי)
—-
לפעמים נדמה שיש מגוון כלים לפשטות של תוכנה – ופשוט צריך ליישם אותם:
קונבנציות, Linting, תהליך Design Review, דפוסי עיצוב או SOLID, וכו׳.
אני לא יודע להעריך כמה שנות אדם (רבות!) בעולם התוכנה מושקעות ביישום טכניקות ל״פשטות מובטחת״. יש מאחוריהן המון רצון וכוונה טובה – אבל הן לרוב לא העיקר. סדר בקוד הוא חשוב, אבל שמירה על קונבנציות היא לרוב רק קצה הקרחון.
אין לי פתרונות קסם, אבל אנסה לציין כמה כללי אצבע מעשיים לשיפור הפשטות בקוד.
צמצום שימוש בכלים מתקדמים
 
הורשה, Threads / co-routines, Generics, ועוד – הם כולם כלים ראויים שיש להם מקום במערכות פשוטות. עדיף לנסות להשתמש בהם בשימוש הבסיסי ביותר: הורשה בעומק של 1, Generics של פרמטר אחד, וכו׳.
פעמים רבות אנשים נוטים להאמין שהקוד יהיה טוב יותר אם נשתמש יותר ב״כלים מתקדמים״, וכך יש שרשראות הורשה ארוכות, Thread המקצרים זמנים בלתי-מדידים (מרוב שהם קטנים), או תסבוכת של טיפוסי Generics.
נסו להשתמש בכלים מתקדמים פחות. השאירו את הכלים המתקדמים כברירה אחרונה – מתי שהברירות האחרות אינן טובות דיין. נסו להשתמש כמה שיותר בכלים פשוטים, ומובנים היטב, שקשה מאוד להסתבך איתם.
האם תאבדו את ההזדמנות להרשים אנשים מסוימים ב״יכולתכם הגבוהות?״ – כנראה שכן. אני מקווה שבארגון שלכם, אלו הם לא האנשים הנכונים להרשים.
פיזור הסיבוכיות
כשיש ביטוי גדול ומסובך בקוד, כדאי לשבור אותו לכמה קטעים קטנים יותר עם הפסקות. תנו לקוראים שלכם לנשום, ולארגן את החשיבה. למשל:
היא פונקציה קצרה וממוקדת, ועוד בסגנון פונקציונלי שנחשב לפשוט וקל יותר לקריאה.
בכל זאת, היא מעמיסה על הקורא – ולא ברור בכלל שכל מפתח מנוסה ״יחזיק״ בראש את משמעות הפונקציה, ויזהה באג פוטנציאלי בקריאה יחידה של הקוד. הייתי אומר אפילו שזה understatement. זה לא קוד טוב, כי הוא סבוך מדי.
מתכנת שנתקל בכזו פונקציה לא צריך לומר ״אני לא חכם מספיק – אעבור את זה ואשתוק״. עליו/עליה לומר: ״זיהיתי קוד מסובך. אפשט אותו (או מקסימום אבקש מכותב הקוד לפשט אותו״. רק כך משפרים את המערכת!
הנה אותו קוד כשהוא ״מפורק״ ליחידות קטנות יותר, עם משתנה בכל פעם שמגדיר את השלב שאליו הגענו:
הוא עדיין לא פשוט לטעמי, אבל הרבה יותר נשלט / נסבל. הוא ראוי לעוד סיבוב של פישוט.
העיקרון הזה נכון בכל רזולוציה: פונקציה, תת flow, או flow: אם המציאות מסובכת, חשוב ליצור נקודות ״עצירה״ להבנה / בקרה / debug של המצב. להפריד מורכבות, ככל שניתן, לפנוקציות ומחלקות משנה – וכך לצמצם את ריכוז הסיבוכיות.
״החוכמה״ בליצור ביטוי קצר, תמציתי, ובלתי קריא – היא ״חכמת טיפשים״.
הכמסה של סיבוכיות
זוכרים שדיברנו על סיבוכיות נחוצה וסיבוכיות מקרית. גם אם נצליח להסיר את כל הסיבוכיות המקרית – עדיין נשאר עם סיבוכיות נחוצה. מה נעשה איתה?
ליבת ביקוע גרעיני היא רכיב מסוכן בתחנת-כח גרעינית. מסוכן – אבל נחוץ. אז מה עושים?
האם ביקרתם פעם בכור גרעיני וראיתם ליבות ביקוע גרעיניות מפוזרות מסביב כמו פחי-אשפה? אני מניח שלא.
את ליבות הביקוע הגרעיניות מבודדים. מקשים את הגישה אליהן. מבקרים אותן היטב.
זה גם מה שנכון לעשות עם הסיבוכיות-הנחוצה של המערכת שלנו – לבודד, להרחיק, ולהגן בפניה.
כלומר:
  • לא מערבבים קוד ״מסובך״ וקוד ״סתם״. את הקוד המסובך מפרידים למחלקות נפרדות, אולי חבילות נפרדות.
  • מתכננים את ה Flow במערכת כך, שיהיו כמה שפחות שינויים נדרשים בקוד המסובך, וכמה שיותר בקוד הפשוט.
  • מציבים תמרורי אזהרה. שמות ורמזים – שיפחיתו את החשק של אנשים לחבר את הקוד שלהם לקוד המסובך.
  • יוצרים ממשקים ברורים ופשוטים – שיפחיתו את הצורך של אנשים להיכנס לקוד המסובך.
  • בודקים את הקוד המסובך ביתר שאת – כך שאם ישנם שינויים — עדיין נוכל לבצע אותם בבטחה.
System Decomposition
באופן כללי, אני מחלקים את המערכת לתתי-רכיבים (components). אנו עושים זו משתי סיבות השלובות זו-בזו:
  • פירוק לרכיבים (decomposition) – המאפשרים ארגון הקוד ביחידות קטנות יותר, בעלות הכמסה, כאשר הפעולה מול הרכיב היא רק דרך ממשק מוגדר היטב.
  • מודולריזציה (modularization) – חלוקת המערכת לרכיבים/מודולים הניתנים לשימוש חוזר ולהחלפה ע״פ הצורך.
אני רוצה להדגיש את המשמעות הראשונה, ולכן אצמד למינוחים ״רכיבים״ ו ״פירוק לרכיבים״.
האופן בו אנחנו מפרקים את המערכת לרכיבים משמעותית מאוד לסיבוכיות שאנו ניצור. הנה שני אופנים אפשריים:
מתוך הספר A Philosophy of Software Design. תודה לתומר רוטשילד שהכיר לי את המטאפורה היפה הזו, ואת הספר.
כאשר אנחנו מחלקים את המערכת שלנו לרכיבים עמוקים, אזי אנו מחביאים הרבה מימוש (Functionality) משאר המערכת מאחורי ממשק רזה. השגנו הכמסה גבוהה – מה שעוזר גם לצמצם קשרים במערכת – ולהגיע למערכת פשוטה יותר. פחות קשרים, יותר הכמסה – זה מצוין לפשטות המערכת.
כאשר אנחנו מחלקים את המערכת שלנו לרכיבים רדודים (הרבה ממשק, כאשר הרכיב עצמו מוסיף מעט מאוד לוגיקה) אזי אנחנו מוסיפים בכל ממשק מעט מאוד ערך. התוצאה תהיה שכל flow יצטרך לעבור דרך הרבה רכיבים (שכל אחד עושה מעט) שמספקים מעט מאוד הכמסה. אנו נדרש לכן גם ליותר קשרים בין רכיבים. המצב הזה כבר לא רחוק מאוד ממצב בו כל הפונקציות וה state במערכת הם גלובאליים – מצב שלכולנו ברור שהוא מאוד לא בריא.
הנה פונקציה לדוגמה שלא מוסיפה הרבה ערך:
זו לא בהכרח פונקציה לא טובה. העניין הוא במאסה. אם המערכת שלנו מלאה ברכיבים / מחלקות המלאות בפונקציות שכל פעם עושות רק מעט – אזי אנו יוצרים סיבוכיות של המערכת. סיבוכיות של ריבוי רכיבים וקשרים.
דוגמה המופיעה בספר היא ממשק קריאת הקובץ בגירסאות המוקדמות של ג׳אווה:
האם מישהו מכם זוכר שהוא אי פעם נדרש לפתוח קובץ ללא BufferedInputStream? או הרכיב Streams אחרים?
מנגנון ה Streams של ג׳אווה מוזכר לעתים רבות כדוגמה מוצלחת למודולריות – היכולת להרכיב רכיבים קטנים זה על גבי זה בכדי להשיג פונקציונליות מורכבת יותר (ברוח הפילוסופיה של יוניקס)
השאלה שעולה היא: כמה מהמודולריות הזו באמת נדרשת בגישה לקובץ? האם פונקציה סטנדרטית אחת שעושה את הכל (רכיב עמוק) – לא הייתה מפשטת את הקוד שלנו יותר?
דווקא ביוניקס, יש ממשק עמוק לגישה לקבצים: הפונקציה ()open מאפשרת בפעולה אחת לפתוח קובץ, מתוך path מורכב שניתן לתאר בשורה אחת, ויש optional flags בכדי לשלוט בכמה חלופות שימושיות (למשל: לפתוח קובץ לקריאה בלבד).
סה״כ נראה שהממשק העמוק של יוניקס, מוביל לקוד פשוט יותר וקריא יותר, מסט הממשקים הרדודים של ג׳אווה.
הכל תלוי כמובן בסט הפעולות הנפוצות – בקריאה מקובץ יש דפוס פשוט ונפוץ, אבל במקומות בהם יש גיוון גדול יותר – דווקא לגישה המודולריות יהיה כנראה יתרון. בכל מקרה, אין מניעה לספק גם וגם: ממשק עמוק ופשוט – למירב הצרכים, וסט של ״כלים מתקדמים״ בדמות ממשקים רדודים ומודולריים – ״להרכבה עצמית״ המספקת צרכים ייחודיים יותר.
היופי של המטאפורה של ״הרכיבים העמוקים/רדודים״ בעיני, היא שהיא מוסיפה מימד מאוד חשוב לחלוקת המערכת לרכיבים, בצורה שקל מאוד לזכור ולאפיין. רבים מאתנו מיישמים את ההמלצה הטובה לחלק את המערכת לרכיבים, ואף רכיבים קטנים – אך האם אנו מחלקים אותה לרכיבים עמוקים או רדודים?
בפוסט שלי על המודל האנמי – ניסיתי בדיוק לתאר את הבעיה ברכיבים רדודים, מבלי שהייתה לי את המטאפורה הזו לשימוש.
ממשקים פשוטים הם חשובים אפילו יותר מקוד פשוט – כי הם מחביאים את המורכבות משאר המערכת.

סיכום

אמרתי זאת פעמים רבות, ואמשיך לומר זאת: בזמן שאנו עסוקים בהתרגשות ב״למידת הספרייה / הטכנולוגיה החדשה והמתקדמת ביותר״ – רוב הסיכויים שאת השיפורים המשמעותיים ביכולות ובתפוקות שלנו נוכל לעשות מהתמקדות בעקרונות בסיסיים, לא נוצצים – אבל מאוד חשובים.
אני מנסה לאזן במעט את תרבותנו המקדשת את ״החדש-ביותר״, תרבות שגוזלת הרבה מזמננו וכוחותינו למקומות לא יעילים.
כולם מסכימים שפשטות היא טובה, אבל מעטים באמת חותרים אליה בצורה יעילה ומעטים אפילו יותר מצליחים להרבות בה. אני מקווה שהפוסט הזה יגע בכמה לבבות בתעשיית התוכנה, ויעזור לנו להתמקד טוב יותר – בעיקר.
לינקים רלוונטיים
סיבוכיות: מקבלים את זה בחינם – פוסט עבר (קצר) בנושא.
Defining Errors Out Of Existence – פוסט של תומר רוטשילד, על רעיון נוסף מהספר ״A Philosophy of Software Design״

הפילוסופיה של יוניקס

ראשית כל, בואו ונשאל: למה שתעניין אותנו הפילוסופיה של יוניקס? – פילוסופיה של חבורת אנשים מזוקנים משנות ה 60 וה 70 של המאה הקודמת?!

כלומר: אלוהים ישמור! למה שנתעניין במשהו בן 50 שנה? הרי אנחנו בתרבות שמקדשת את החדש-ביותר. כל מה שקרה לפני הקורונה – נראה היום כבר רחוק וכמעט לא-רלוונטי.

אפשר לטעון שיוניקס היא מערכת ההפעלה הטובה ביותר (הנדסית) שנכתבה אי-פעם. אפשר לטעון שהפילוסופיה של יוניקס הכילה את עקרונות ה SOLID, מיקרו-שירותים, Lean, ו Devops culture כבר בשנות ה-70, רעיונות שעדיין פופולריים גם היום – מה שעשוי בהחלט להקל עלינו את ההתעניינות בפילוסופיה הזו.

(מעניין לציין שעקרונות ה SOLID עצמם הם ממש עתיקים, רובם משנות השבעים ומנוסחים בצורה כבדה וארכאית – אבל אין מה שפרזנטור כריזמטי ודעתן לא יכול להפוך לזהב של המאה ה-21)

אז מה היא בדיוק הפילוסופיה של יוניקס?

– בפילוסופיה, כמו בפילוסופיה – אין דבר כזה שאפשר לתמצת במשפט אחד.

קיצור (אגרסיבי) של הפילוסופיה של יוניקס

לפילוסופיה של יוניקס יש כמה גרסאות: שנוסוחו בזמנים שונים וע״י אנשים שונים. בגרסה המרחיבה ביותר ישנם 17 כללים של הנדסת תוכנה.

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

Make each program do one thing well. To do a new job, build afresh rather than complicate old programs by adding new “features.”

מילות המפתח שצצות כאן הן: מודולריות, פשטות, Single Responsibility, העקרון הפתוח-סגור, ו Lean.

ארכיטקטורת הבסיס של לינוקס היא ארכיטקטורה של קרנל:

  • ליבה (kernel) קטנה, אמינה, יעילה מוגנת ובדוקה היטב.
  • סט גדול של אפליקציות קטנטנות המאפשרות את הפיצ׳רים השונים. הן יכולות להגיע ממקורות שונים, פחות אמינים – כי הליבה מגינה על מערכת ההפעלה.
כלומר: ה״ליבה״ (להלן core) וה״הרחבות״ (להלן extensions) מייצגות איכויות שונות בתכלית – המשלימות זו את זו: ה core הוא קבוע, קטן, מנוהל היטב וה extensions מאפשרים בחירה ומגוון גדול.

ע״פ הפילוסופיה של יוניקס, התוכנות המרכיבות את ה extensions הן קטנות וממוקדות. תוכנה פשוטה קל לכתוב, וסביר יותר שתהיה פשוטה ואמינה – שיקוף עמוק של הרוח הרעיונית של יוניקס.

אבל….

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

Expect the output of every program to become the input to another, as yet unknown, program.

פונקציונליות עשירה יותר, מורכבת ע״י ״הרכבה״ של אפליקציות פשוטות בזו על זו.

cat foo.txt | wc -l

האפליקציה הראשונה, cat (קיצור של concatenate) מחברת תוכן של מספר קבצים ושולחת אותם ל standard output. ניתן להשתמש ב cat עם קובץ יחיד – שאותו היא פשוט תשלח ל standard output – וזה השימוש הנפוץ שלה, שאנו גם משתמשים בו.

אנו משתמשים מכאן ב Pipe (אם אתם לא יודעים מה זה pipe – לכו מיד וקראו!), על מנת להעביר את הפלט לאפליקציה אחרת, wc (קיצור של word count [א]) ורק היא תשלח את התוצאה שלה ל standard output.

תכנון ה pipe ע״י Doug McIlroy בשנת 1964. קצר, פשוט, ענייני.

wc מקבלת פרמטרים (במקרה שלנו l-) המאפשרים לה לפעול טיפה אחרת, ובעצם לספור שורות.

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

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

  • שטף של בתים (לא ביטים), בד״כ ב ASCII. כלומר: טקסט.
  • אפשר שיהיה לו סוף – בדמות EOF.
  • n\ סימן להפרדה בין רשומות (או ״שורות״)
  • +[t\ ] (כמות כלשהי של טאבים או רווחים) – הפרדה אפשרית בין שדות.

כל תוספת ״תחכום״ / ״מורכבות״ קטנה לממשק – הייתה משבשת לחלוטין את המודל. לו McIlory היה קצת יותר ״מתוחכם״ – יש סיכוי שהרעיון היה נכשל. רק הפשטות המוחלטת באמת עובדת כאן.

למשל bat, אפליקציה הדומה ל cat (הנה עוד דוגמה כיצד שמות לא מוצלחים מדרדרים אותנו. מכיוון ש cat נשמע כמו חתול, בחרו בחיה אחרת ויצרו שם שלא מסביר את עצמו.) המאפשרת syntax highlighting של קוד, כבר עובדת הרבה פחות טוב.

בגלל הסימנים הנוספים שהיא מוסיפה לטקסט בכדי להשיג את התסדיר של ה Syntax highlighting – היא כבר לא תעבוד טוב עם wc (או הרבה אפליקציות אחרות). היא חטאה לממשק הפשוט של ה Pipe.

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

פשטות אמיתית דורשת המון משמעת, כמו שנאמר: ״Simplicity is the ultimate sophistication״.

סיכום

אז מה היא הפילוסופיה של יוניקס, ולמה היא חשובה?

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

״Build a prototype as soon as possible. Most users looking for a new piece of software won't know what they really want until they see it, so requirements documents are often misleading about the users' real needs. The Unix design philosophy makes prototyping a central part of the methodology: give the user something, anything, up-front to criticise, and work from there.״

אתם מאמינים שהיא נכתבה במיליניום הקודם? אז מי המציא את ה Lean-Startup? אריק ריס או מפתחי היוניקס?!

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

למשל: כל מודל הרכיבים (components) של RactJS או +Angular2 – מרגיש כמו שידור חוזר של הפילוסופיה של יוניקס: רכיבים פשוטים, לשימוש חוזר, שניתן לזרוק בקלות אם הם כבר לא רלוונטיים, וממשק פשוט ככל האפשר.

לפני כעשור ועוד עבדתי עם מודלים אחרים של רכיבי UI, בעיקר Java Portlets או כל מיני תקנים של widgets ו gadgets אחרים. הממשק היה כמעט תמיד מורכב בהרבה – והקשה מאוד על קהילה רחבה לתרום למודלים הללו. זה לא היה פשוט.

כאשר אני מתכנן רכיבים ב React/Angular אני עדיין יכול לעשות אותם גדולים ומורכבים (״רכיב Alpha 16 יספק את כל צורכי המערכת בטבלאות חיפוש, לכל האובייקטים, בשנים הקרובות״) או קטנים ופשוטים.

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

שיהיה בהצלחה!

—-

[א] הערת אגב: עם הכבוד הרב שאני רוחש לפילוסופיה של יוניקס – אני עדיין מאמין ששמות מאוד קצרים הם טעות גדולה של יוניקס שמחירה מודגם שוב ושוב. שמות מאוד קצרים קשה יותר לזכור, וקל מאוד לבלבל בינהם / לפספס את המשמעות.
יוניקס הייתה טיפה מוצלחת יותר אם ל cat היו קוראים concat, ול wc היו קוראים wordcount (או פשוט count – מכיוון שיש לה הרבה אופציות ספירה שונות), ול man היו קוראים manual. הקנאות לקיצור הרגה את הקריאות ביוניקס.


—-

לינקים רלוונטיים

סיכום החוקים של הפילוסופיה של יוניקס. אפשר לקרוא ולהנות.

כמה מילים על הפילוסופיה של יוניקס מספר מ 1995. מופיע בוויקי המקורי – תמציתי ומעניין.

Refactoring 2020

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

הספר Refactoring טבע כמה מושגי יסוד שהושרשו עמוק בתעשייה כמו Refactoring ,Code smells, או Testability של קוד. החלק החשוב בספר הוא לא קטלוג ה Refactoring (הטכני), אלא 4 הפרקים הראשונים העוסקים בכמה רעיונות מאוד חשובים בפיתוח תוכנה:

  • בדיקות-יחידה (Unit-Tests), היו אז עוד רעיון חדשני ולא כל-כך מוכר.
    • שווה לציין שיותר ממה שבדיקות יחידה פותרים באגים, הם מאפשרים לעשות Refactoring במהירות ובביטחון.
  • הרעיון שהשלב הראשון בפתירת באג, היא לכתוב בדיקה שתיכשל – ואז לגרום לבדיקה לעבור (מה שיוכיח שהבאג תוקן, ויוודא שאין רגרסיה והוא חוזר).
  • הרעיון שניתן לשנות/לשפר Design של מערכת קיימת אחרי שהקוד נכתב.
    • הרעיון הזה תומך ומחזק רעיונות כמו Minimum Viable Products (בקיצור: MVP) ו YAGNI – רעיונות סופר-חשובים ומשמעותיים.
    • הרעיון הזה, לצערי, לא חלחל לכל קצוות התעשייה עד היום.
  • הרעיון ש Refactoring צריך להיות חלק מובנה מתהליך פיתוח תוכנה, כמו ש Delivery הוא כזה. כמובן שבלי delivery אין פיצ׳רים ללקוחות, בעוד refactoring ניתן לדחות ולדחות עד ה Rewrite הבלתי נמנע.
    • בספר פאוולר מקדיש חלק ל״איך לשכנע את המנהל״ לבצע Refactoring. עצה אחת היא שהמנהל לא יאהב את רעיון ה Refactoring, אבל אם האלטרנטיבה היא Rewrite – אז הוא יעדיף אותה. עצה שניה היא פשוט לא לספר למנהל – אלא פשוט לעשות. הדילמה הזו רלוונטית גם היום.

האם רעיון ה Refactoring באמת הצליח?

ברור שברמת השפה המדוברת, "Refactoring" הוא הצלחה גדולה. "Refactoring" הפך למונח מקובל, ולנושא נפוץ לשיחה. מעט מאוד ספרים השפיעו כך על השפה המדוברת בעולם התוכנה.
האם באמת אנו פועלים כך ש Refactoring הוא חלק בסיסי מהעבודה?
לא פעם, ולא פעמיים הייתי בדיונים בהם המפתחים מבקשים ממנהלי המוצר זמן ל Refactoring.
מנהלי המוצר הם אולי המנהלים החדשים, אבל אם Refactoring הוא תהליך בסיסי של פיתוח תוכנה, מדוע אנו מבקשים רשות לעשות אותו? מדוע אנחנו בכלל מפרידים אותו בכתיבת טסטים, תיעוד קוד, ביצוע Design, למידה (education) וכו׳ – תהליכים אחרים שגם הם צורכים זמן ניכר?

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

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

למה? הרי כתיבת בדיקות היא פעולה שגרתית (לרובנו), והרעיון נשמע נכון – אז למה כתיבת הבדיקה היא לא הדבר הראשון שאנו עושים כשאנו מתחילים לעבוד על באג?
  • כדי לכתוב בדיקה לבאג, אנחנו צריכים לדעת לשחזר אותו. החקירה כיצד בדיוק היא משתחזר יכולה לארוך זמן רב, והיא מוציאה אותנו מה context של כתיבת בדיקה. עד שמצאנו את הבעיה, והתיקון קטן – הנטייה האנושית היא ״כבר לגמור עם זה״.
  • לא כל מצב במערכת ניתן לשחזר בעזרת בדיקת יחידה.
    • עדיין אנחנו כותבים יותר מדי בדיקות אינטגרציה לקוד שניתן להפריד ולכתוב לו בדיקות יחידה – וזה חבל.
    • בדיקות אינטגרציה הן לא קלות לכתיבה, במיוחד לא במצבי-קיצון, וכך פעמים רבות אנו מוותרים על המאמץ לנסות ולכתוב בדיקה שמשחזרת את הבאג.
Software Death Spiral
ביצוע Refactoring אמור להחזיר אותנו מעט לאחור – ולהאריך את קץ המערכת…
האם חשוב לעשות Refactoring?
האם חשוב לעירייה לפתח את העיר עליה היא אמונה? לסלול כבישים, לשנות ייעוד של מבנים, ולשנות חוקים בתוכנית הבניה העירונית (תב״ע)?
המציאות משתנה לאורך הזמן, ולכן גם הצרכים. אם לא נשנה את העיר שלנו – היא לא תתאים לצרכים המפתחים שלנו. אם אין תעסוקה, אין חניה, או אין תחבורה מספיקה – גם עיר מצוינת יכולה להתדרדר ולהפוך לעיר גרועה. קשה לי לחשוב על עיר גרועה שהפכה למצוינת, ללא השקעה משמעותית. זה כנראה לא קורה.
כנ״ל לגבי המערכת שלנו: הביזנס עובר שינויים, ההבנה שלנו על הביזנס מתפתחת – והמערכת חייבת להתעדכן בכדי להישאר רלוונטית. כאשר אנו מנסים לעשות שינויים במערכת, בתוך Design שלא תומך בשינויים הללו – השינויים הם קשים וארוכים לביצוע.
מכיוון שלא ניתן ליצור Design שיתאים לכל הצרכים, ה Design של המערכת חייב להתעדכן לאורך זמן. האלטרנטיבה השנייה היא לסחוב עוד ועוד סרבול ואטיות בשינויים – עד ה Rewrite הבלתי נמנע.
אין לי שום הוכחה מספרית, אך הקונצנזוס הרחב בקרב המומחים בעולם התוכנה, הוא שהרבה יותר יעיל לבצע Refactoring הדרגתי – מכתיבה מחדש של התוכנה. כמובן שבנקודה מסוימת בזמן – כל מערכת תצטרך לעבור Rewrite.
אפשר לומר, ככלל אצבע מאוד לא מדויק ש Rewrite אחרי שנתיים או פחות – הוא כישלון, ו Rewrite לאחר חמש שנים – הוא הצלחה.
כמובן שהעניין תלוי בגורמים שונים: עד כמה משמעותיים היו השינויים בביזנס? עד כמה השקענו ב Refactoring במהלך חיי המערכת? עד כמה המערכת מורכבת. לא ניתן לקבוע מדד אוניברסלי לאורך חיי מערכת.
בכל זאת Refactoring הוא תהליך של הארכת חיי המערכת – והוא תהליך חיוני לארגונים שרוצים להיות יעילים.

אז למה לא לעשות Refactoring?

אין הרבה מפתחים שנגד Refactoring, וכנראה שאין מומחים / מובילי דעת-קהל בעולם התוכנה שנגד Refactoring, ובכל זאת, כאשר מעלים להנהלה (מנהלים, מנהלי מוצר, מנהלי פרויקטים) את הרעיון ״לבצע Refactoring״ ישנה הסתייגות, והרבה פעמים זה לא קורה.

למה?

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

״תנו לנו 3 שבועות, נבצע Refactoring באזור ההוא – ואז הכנסה של פיצ׳רים תהיה קלה בהרבה!״

אבל המנהלים, שהם אמונים על ובקיאים בתמונת הפרויקט, רואים לעתים ש:

  • ה Refactoring קצת הסתבך וארך בעצם כמעט 5 שבועות.
  • פיצ`רים לא תמיד יותר מהירים לאחר ה Refactoring, לפחות לא משמעותית: ״כן, ה Refactoring שיפר מהירות של פיצ`רים מסוג z, אבל זה פי׳צר מסוג y…״
  • ה Refactoring עלול לגרום לרגרסיות במערכת ו/או באגים נוספים.
באמת, ממעוף הציפור, ומבלי להכיר את הפרטים – Refactoring אכן עשוי להישמע כדבר שרצוי להימנע ממנו, ולדחות אותו.
מי צודק? המנהלים או אנשי-התוכנה?

למה Refactoring נכשלים?

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

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

האם אתם לא רגילים לחשוב על מערכות בזמן חיים? נסו את זה – זו תורם. קל יותר לחשוב על אורך חיים של תת מערכת, למשל: מיקרו-שירות.

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

אם אני מעריך שמערכת תחייה עוד שנתיים, ויש בעיה ב Design של המערכת שגורם לי פעם בחודש לבלות כחצי יום של הסתבכות (נניח: כל פעם שנוגעים באזור מסוים) – אזי השקעה של עד שישה ימים שתפתור את הבעיה, היא בגדר הצלחה. השעה של שבועיים (10 ימים) למשל – כבר באזור הפחות מוצלח.

כמובן שמדד ההצלחה הוא דיי קשוח. ברור שיש יתרונות נוספים ל Refactoring (מוצלח מבחינה הנדסית) שאי אפשר ממש למדוד:

  • התחושה הטובה של המהנדסים שעובדים עם הקוד.
  • המסר הפנימי: אנחנו משפרים קוד, ולא חיים בבבלאגן. אל תקבלו את הבלאגן.
  • צמצום טעויות ובאגים – אי אפשר למדוד כמה פיצ׳רים נוספים עקומים נבנו – כי היה חסר Design נכון מלכתחילה.
  • הכרות טובה יותר של האנשים עם הקוד. Refactoring הוא דרך מעולה להבין את הקוד לעומק – ומכאן גם את המערכת.
מהצד השני חשוב לזכור ש:
  • מהנדסים מאומנים לזהות Code Smells, להבין כיצד הקוד היה צריך להראות – ואז לרצות לשפר את הקוד. טוב מאוד שכך (!!), אבל זה גם אומר שיהיה רצון לבצע בקוד גם שיפורים בעלי עלות/תועלת נמוכה מאוד בראייה מעשית.
  • לא כל Refactoring מייצר קוד טוב יותר. יש סיכון שה Refactoring יוביל לעיצוב מערכת פחות מוצלח, יהיה ניתן להבין את זה רק לאורך זמן – ואז כבר יהיה מאוחר מדי בכדי להתחרט.
  • Refactoring לא עוזבים באמצע. לפעמים אנו מתחילים Refactoring של שבוע – שאורך שבועיים ואף שלושה שבועות.
  • אין גבול לזמן שניתן לעשות בו Refactoring. זכיתי להזדמנות הנדירה לחזות ב Refactoring של צוות קטן לאורך חצי שנה. השינויים שנעשו באמת היו משמעותיים – שינו את מודל הליבה של המערכת למודל שונה למדי. סידרו הרים של קוד.
    לרוע המזל, לא נראה שמשהו מהותי באמת השתפר. זה היה שונה, אבל קשה מאוד היה לומר שזה היה טוב יותר.

יאללה, המלצות (סיכום)

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

האם מפסיקים לעשות Refactoring ומתכננים כבר ב Schedule מועד ל Rewrite לכל חלקי המערכת?
האם פשוט צריך להחליף את ההנהלה? להחליט שימי ראשון מוקדשים ל Refactoring בלבד?

הנה כמה המלצות קונקרטיות, ממיטב ניסיוני:

  • Refactoring גדול = סיכון גדול, Refactoring קטן = סיכון קטן. 
    • דוגמה קלאסית ל Refactoring עם ROI חיובי ברור הוא Rename לאובייקט מרכזי במערכת שהשם שלו מטעה / פשוט לא נכון. ההשקעה היא במסגרת הדקות – התמורה היא ודאית.
    • נסו ליצור תרבות של Refactoring קטנים עליהם לא צריך לספר למנהל (כפי שהציע פאוולר), ולא צריך לשנות בשבילם Schedule של פיתוח. סיכוי טוב שה ROIs יהיה מוצלח, וגם תהליכי ה Code Review יוכלו לספק בקרה ופידבק לגבי הנכונות ההנדסית של ה Refactoring הללו.
  • כאשר אתם עושים Refactoring גדול, התייחסו אליו כמו כל פיצ׳ר גדול ומסוכן אחר:
    • עשו בקרה לעיצוב המוצע. למשל: Design Review.
    • נסו ברצינות לפרק אותו לשלבים קטנים יותר / לבצע MVP שיספק פידבק מוקדם יותר עד כמה השינוי מוצלח/יעיל.
    • נסו לצמצם אותו ולחשוב: מה אפשר להוריד מהמודל היפה והאלגנטי הזה שמוצע. על אלו פרטים ניתן לוותר – בלי לאבד הרבה מהערך. מניסיון – זה עובד.
  • הכי מסוכן זה Refactoring גדול של מישהו שקיבל לאחרונה אחריות על קוד. למשל: חודש-חודשיים אחרי שצוות מקבל קוד מצוות אחר, הוא ״מיואש״ כבר מהקוד – ודורש לבצע Refactoring גדול.
    • יש בכך יתרון: תוך Refactoring כזה, הקוד בהדרגה "יהפוך לשל הצוות": הוא יהיה מוכר יותר והצוות ירגיש שלם יותר איתו. Refactoring היא דרך מעולה להבין קוד לעומק.
    • הסיכון הגדול הוא שהצוות לא מבין את הדומיין טוב מספיק עדיין, וסיכון גדול שהוא יבצע שינוי ל Design שאינו טוב יותר.
    • נסו למנוע / לצמצם עבודת Refactoring גדולה של מישהו שלא עבד עם הקוד חצי שנה לפחות. כן עודדו לעשות כמה שיותר Refactoring קטנים (כמה שעות כ״א, למשל). זה כלל האצבע שלי.
  • אל תוותרו על Refactoring, גם אם זה קצת מורכב:
    • Refactoring הוא אוויר לנשימה למערכת. מערכות דורשות חמצן והתחדשות – ותהליך ה Refactoring מספק להן את זה.
    • לא פחות חשוב: Refactoring הוא אויר לנשימה למהנדסים שעובדים על המערכת.
      • זה ממש רע לקבל, ולהתרגל לעיוותים וחוסרי-הגיון בקוד – זו דרך טובה להפוך למהנדסים פחות טובים.
      • Refactoring הוא גם רענון שכלי למפתח (שאנו לא רוצים ״שיתייבש״): עבודה הנדסית שקצת שונה לפעמים מבוספת פיצ'רים למערכת.
      • Refactoring עוזר לקבל רמת עומק גבוהה יותר עם הקוד שמתחזקים, במיוחד לאורך זמן (״הנה המשמעות של מצב A, מול המשמעות של מצב B״)
שיהיה בהצלחה!

על פרדיגמות תכנות, ומה ניתן ללמוד מהן בשנת 2019? – חלק ג׳

פרדיגמת התכנות פונקציונלי

פרדיגמת התכנות הפונקציונלי (Functional Programming, בקיצור: FP) קדמה דווקא לפרדיגמה מונחית-העצמים, אך מכיוון שהפופולריות שלה נסקה דווקא בעשור האחרון – רבים נוטים לחשוב שהיא חדשה.

פרדיגמת ה FP, בצורות כאלו ואחרות קיימת משנת 1957. בשנות ה-60, בשל כך שצרכה יותר זיכרון ומשאבים (שהיו אז מאוד מוגבלים) – היא הייתה בגדר רעיון מעניין, אך לא מעשי. בשנות ה-80 היא כמעט הפכה למיינסטרים – אך שפות כמו Basic ו C, שהיו מבוססות על תכנות פרוצדורלי, הפכו פופולאריות וקצת השכיחו אותה.

מה שהחזיר אותה לזירה לפני עשור בערך, היא הדאגה מריבוי ה cores במעבדים. רבים חזו שיידרש שינוי פרדיגמות משמעותי בעולם התוכנה – הרי עשרות שנים היו רגילים לחשוב בעיקר על Execution Thread יחיד, ופתאום אנו עוברים לעולם מרובה-cores (תכנות HPC, ו UI – כן חשבו על כמה threads, כבר לאורך שנים רבות).

אני זוכר את הדיבורים, לפני עשור או עשור וחצי, על כך שאוטוטו – לא נוכל לכתוב קוד יותר כפי שכתבתנו. שכל מתכנת יצטרך להתמחות ב Fork-Join או Patterns דומים של מקביליות, אחרת הקוד לא יהיה יעיל. אחד התוצרים של הדאגה הזו הייתה להחזיר את התכנות הפונקציונלי לשיח בתעשייה: כאשר עובדים עם מבני-נתונים שהם Immutable – קל הרבה יותר לכתוב קוד שירוץ בצורה מקבילית.

עשור עבר, מספר ה cores במעבדים אכן גדל, (אם כי בקצב מתון מהמדובר) – ובעצם עבור רוב המתכנתים מעט מאוד השתנה. ישנם מעבדים עם 20+ ליבות, אך הם משרתים בעיקר בצד השרת (ולא כ Desktop App) – ושם ריבוי cores משמש לשירות הרבה בקשות במקביל – כל אחד על thread. זה המצב ברוב הגדול של המקרים.

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

לפעמים אנו נוטים להסתכל על FP ו OO כבלעדיים (mutual exclusive) – כאילו עלינו לבחור רק בפרדיגמה אחת מהשתיים. יתרה מכך: לפעמים אפילו מתחיל דיון ״מי יותר טוב?״ דיונים שהרבה פעמים כוללים אי-דיוקים משמעותיים, לכל הפחות. זו גישה נפסדת ומטופשת: יש לנו כאן שני רעיונות טובים שמשתלבים היטב – ולנסות להפריד אותם, ולהוקיע אחד מהם – היא דרך טובה להיות פחות מקצועיים ויותר ילדותיים. די כבר עם ״תכנות פונקציונלי יותר חכם ומתקדם מ OO״ או ״תכנות FP הוא אקדמי ולא מעשי״. זה מטופש!

ניתן לראות ששפות OO פופולריות רבות – הולכות ומאמצות רעיונות FP. אפשר לציין את סקאלה, קוטלין, או ג׳אווה 8 – שם רעיונות ה FP זוכים לתהודה ותשומת-לב, אבל שימו לב שרעיונות דומים אומצו גם בשפות כמו רובי, פייטון, וג׳אווהסקריפט כבר לפני עשור או שניים – והשתלבו היטב עם רעיונות OO.

גם בשפות ״פונקציונליות טהורות״ (כלומר: שפות בהן ניתן דגש רב על רעיונות של FP) – יש אלמנטים של OO. למשל, ב Haskell יש הכמסה ברמת המודול (מודול לרוב הוא בגודל של קובץ בודד, והוא מקביל במידה מסוימת למחלקה בשפות OO) – וזו תוספת חשובה מאוד לשפה. ב Haskell יש גם ריבוי-צורות (Polymorphism), מן הסתם ברמת הפונקציה – שזה המבנה העיקרי בשפה.

הכמסה + ריבוי-צורות, ובלי הורשה (השנויה במחלוקת) – אפשר לרגע להתבלבל ולחשוב ש Haskell היא בכלל שפת OO מודרנית?

בקיצור: אין סיבה שלא נאמץ גם ספרות וגם שירה – הם לא מתחרים זה בזה, ולא צריך באמת לבחור. כנ"ל לגבי OO ו FP.

אז מה שפות FP חידשו לנו?

Functional Style

לפעמים אוהבים להתייחס ל FP כענף של התכנות הדקלרטיבי. האם זה נכון?

ובכן, קשה להשוות FP לשפות דקלרטיביות כמו SQL. ב SQL אנו מתארים כוונה עם מעט מאוד הַכוונה – ו"מישהו" דואג שהיא תתבצע. בשפות פונקציונלית מתארים כוונה עם הרבה מאוד הַכוונה – וברור מאוד כיצד הדברים עומדים להתבצע. אני חושב שיש אכן טעם לעשות הפרדה בין Imperative Style לבין Declarative Style לבין Functional Style – שהוא משהו באמצע.

אפשר לומר שמקום לומר למעבד מה לעשות צעד אחרי צעד (אימפרטיבי) – ב FP אנחנו אומרים לו מה לעשות שלב אחרי שלב 😀

ב Functional Style מחליפים (בגדול) משפטי if במשפטי filter, לולאות במשפטי map או רקורסיות, ובמקום להשתמש במשתנה result שיצבור את התשובה של הפונקציה – משתמשים ב fold.

הרבה פעמים קוד שנכתב ב Functional Style הוא פשוט וקריא יותר – מהמקבילה האימפרטיבית (ולכן כ"כ הרבה שפות אמצו את הסגנון הזה, כסגנון חלופי). בפעמים אחרות, דווקא הסגנון האימפרטיבי יהיה קצר / פשוט / מובן יותר – ולכן רק מעט שפות אמצו את ה Functional Style – כסגנון יחיד.

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

לפעמים עולה אמירה ש״סגנון פונקציונלי הוא למתכנתים מנוסים יותר/בלבד״ – זו לרוב אינדיקציה שהשתמשנו ב Functional Style בצורה מתוחכמת ולא פשוטה, וזה בדיוק הזמן לפשט בעזרת סגנון אימפרטיבי (או כל אופן אחר שהקוד יהיה פשוט וקל יהיה להבין אותו). מתכנתים מנוסים וחכמים כותבים קוד פשוט – לא קוד מורכב.

שרבטתי דוגמה לפונקציה, שכנראה תהיה פשוטה יותר לכתיבה ב Imperative Style.
אפשר (כמובן) לכתוב אותה גם ב Functional Style – אבל בסיכוי גדול הקוד יהיה מסובך (״מתוחכם״) וקשה יותר להבנה:

הוספתי הערות מטא, שמציגות אלו אלמנטים בפונקציה יהיה מורכב יותר לבטא ב Functional Style.
אני מקווה שההערות לא מקשות מדי על קריאת הקוד…

Immutability

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

Immutability משמע שהערכים ומבני הנתונים שקבענו לא ישתנו מרגע שנוצרו. מנקודת מבט של שפות אימפרטיביות, זה אומר שכל הערכים / השדות בשפה הם final, const, או איך שזה נקרא בשפה הספציפית.

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

דוגמה משפת קוטלין בה למבני-נתונים (Data Classes) יש פונקציה מובנה בשם copy אותה יוצר המהדר, המאפשר בקלות להעתיק את מבנה-הנתונים תוך כדי שינוי ערכי שדות מסוימים. במקרה הזה, העתקנו את אובייקט ה Payment – אך שינינו את שדה התאריך.

שפות פונקציונליות מספקות לרוב סט של מבני-נתונים שהם Immutable, והגדרת משתנים (ערכים / שדות שיכולים להשתנות) – הם היוצאים מהכלל.

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

Immutability גורר שכל שינוי state כולל העתקה – ויש לכך מחיר בבצועים, ולפעמים מחיר ב boilerplate code – תלוי בשפה, אך בעיקר כאשר עושים שינויים עמוקים במבני-נתונים מורכבים ומקוננים.

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

No Side Effects

רעיון מרכזי ב FP הוא רעיון ה "Pure Functions", פונקציות טהורות – עם אפס השפעה על הסביבה.

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

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

עד כאן – תכנות / הנדסת תוכנה. הצורך בפונקציות צפויות הוא צורך ליבה, ואנשי-תוכנה מנוסים מקפידים מאוד לברור את שמות הפונקציות – כדי לתאם ציפיות בצורה הטובה ביותר האפשרית (POLA). למשל: תחילית try לשם פונקציה שעשויה לא לבצע את הפעולה.

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

בפרדיגמת ה OO כלי משמעותי למניעת הפתעות ממש מאותו הסוג הוא Encapsulation: את ה state הפנימי של האובייקט ניתן לשנות רק במסלולים ״המקובלים״, מה שמצמצם משמעותית הפתעות. זה חשוב מאוד – אבל לא תמיד מספיק. הפתרון של FP הוא לחתור לכך שכמעט כל הפונקציות במערכת יהיו pure functions – פונקציות שבשום אופן לא משנות משהו מחוץ ל scope הפנימי שלהן, ולא מסתמכות על שום דבר מעבר לארגומנטים שנשלחו להן – וכך אנו מגבירים מאוד את הסיכוי שהן יהיו צפויות. הפעלה של Pure Function, מספר פעמים ובזמנים שונים – תמיד תציג אותה תוצאה, כאשר נשלחים לה אותם הארגומנטים.

איך בכלל אפשר ש"רוב הפונקציות במערכת לא ישנו state כלשהו?" הפתרון הוא ב Immutability: הפונקציה מקבלת מבנה-נתונים, אך לא מבצעת עליו שינוי. היא יוצרת עותק שלו – ומחזירה עותק שיש עליו שינוי.

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

האם זה לא Functional Decomposition? אותו דפוס שלילי שדיברנו עליו בפוסט הקודם?
ישנם הבדלים: Functional Decomposition קלאסי פעל על State גלובלאי בו ביצעו כל הזמן שינויים – וזה מאוד לא צפוי.
כן יש פה עניין של חלוקת אחריות מפוזרת ולא ברורה בפוטנציה, ולכן תראו הרבה פעמים שמערכות המושפעות מ FP עדיין משתמשות בחלוקה למודולים (או מחלקות, בשפות OO+FP) והקפדה על סידור הגיוני של המערכת.
אם אני לא יכול למצוא בקלות קוד שעושה משהו, סביר שאכתוב אותו מחדש => כפילות קוד.

בקיצור: בהחלט יש מה להיזהר כאשר מאמצים למערכת "ארכיטקטורה מבוססת pure functions ומבני-נתנים", ולנסות לא להיגרר ל Functional Decomposition. את זה עושים בעיקר ע״י ארגון של הקוד ע״פ נושאים ותחומי אחריות – היכן שעקרונות ה OO יכולים בהחלט לעזור.

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

  • מאוד טבעי לכתוב להן בדיקות יחידה. כלל חשוב שהתווסף ב TDD עם השנים הוא לחלץ ״לוגיקה עסקית טהורה״ לפונקציות / מחלקות נפרדות – כדי לכתוב בדיקות-יחידה סופר-יעילות, וללא Mocks. זה בדיוק מה שקורה ב Pure Functions.
  • פשטות במקביליות. כאשר כותבים קוד מקבילי, state משותף הוא נקודת כאב מרכזית: אנחנו צריכים לנהל אותו, לנעול אותו, וגם להימנע מהשלכות שליליות של locks כגון deadlocks ופגיעה במקביליות – וזה לא קל. דווקא קל מאוד למקבל פונקציות טהורות – אם כי לא תמיד זה מוביל לביצועים הטובים ביותר (בשל ההעתקות הרבות הנדרשות, ובשל אי-ניצול של memory locality).
  • Memoization – אם פונקציות הן טהורות, אזי ניתן בקלות ובבטחה לעשות caching לתוצאות החישוב. אם פונקציה היא טהורה, ומבני-הנתונים הם Immutable אז ניתן להחליף את הקריאה לפונקציה בתוצאת החישוב המוכנה מראש – מבלי לשנות בכלל את התוכנה. העיקרון הזה נקרא גם referential transparency. כמובן ש caching הוא לא תמיד יעיל, למשל – אם יש פרמוטציות אפשריות רבות להפעלת הפונקציה.

כמה שאלות ותשובות:

  • האם באמת אפשר לכתוב מערכת רק מפונקציות "טהורות"? לא. כל מערכת חייבת Input/Output בכדי שתהיה לה משמעות. כלומר: יש את הבעיה הקטנה שמה שלקוחות המערכת צריכים ממנה – הם side effects. אז אי אפשר בלי side effects.
    • ״במערכות על טהרת ה FP״ מנסים למקסם את אחוז הפונקציות הטהורות במערכת. כאשר מדובר ב batch processing (סוג בעיה שהרבה פעמים מפתחים עם FP) – אז פשוט אפשר להעביר את ה state בין הפונקציות מההתחלה עד הסוף – הוא לרוב מספיק קטן בכדי לא לאבד עליו שליטה / ששכפול שלו יהיה Overhead גדול מדי.
      • גישה אחת, היא ליצור Queues שיקבלו הוראות על שינוי state (למשל: שינוי בבסיס הנתונים) ואז באמת יהיו כמה פונקציות, שמאוד ברור מי הן – שרק מבצעות "side effects". שולפות הודעות מה Queue – ומבצעות את השינויים.
        • Actors הוא מודל מקביליות שתומך בגישה הזו.
      • גישה אחרת, היא לאגור את ה state המשתנה במעין ״טרנזקציה״: הפונקציות יתרמו לשינוי state, אבל הוא לא יחול על לנקודה מאוד ברורה בקוד ובזמן. הגישה הזו עוזרת להתמודד עם עניינים כמו מקביליות / racing conditions של עדכון ה state או state מורכב שקשה לעדכן ע״י הודעות.
    • שווה לציין שלא רבות המערכות שקמות ״על טהרת ה FP״. זה עובד נחמד ב Batch Processing (היכן שזה פשוט) – אבל זה יכול בקלות להסתבך.
    • גישה שנראה לי שהולכת ותופסת תאוצה היא פשוט לסמן בצורה ברורה (coding conventions, annotations, וכו׳ – למשל IO Monand) אלו פונקציות אינן טהורות (impure) וכך לתאם ציפיות. אלו הפונקציות להיזהר מהן. כמובן שלא תמיד אפשר לכתוב מערכת שרוב הפונקציות בה הן impure ואולי יותר נפוץ, בגישה OO+FP מעורבת, לסמן פונקציות שהן pure – על מנת שיהיה אפשר לסמוך עליהן יותר.
  • באם באמת כל הפונקציות שלא משנות state / ניגשות לנתונים חיצוניים – הן טהורות? לא בדיוק. למשל: כתיבת הודעת לוג מתוך פונקציה – הופכת אותה "רשמית" ללא טהורה. גם כאן, הפרגמטיות היא במידתיות. מבחינתי, אפשר להחשיב גם פונקציות עם side effects זניחים כ "פונקציות טהורות". אם נתפלסף, אזי גם לפונקציה שמחברת שני משתנים יכול להיות side effect. למשל: הוא גורמת לעדכון caches בתוך ה CPU. בואו לא נגזים.
  • מדוע אומרים שפונקציות אסינכרוניות הן לא טהורות?  נראה לי שזה ענין של קורולציה. בד"כ מפעילים פונקציות א-סינכרוניות עבור פעולות I/O (שזה side effect ברור), ולכן בדרך כלל הן לא טהורות. לא נראה לי שיש משהו "לא טהור" בפונקציה מעצם כך שהיא מורצת בצורה א-סינכרונית.

Function Composition

בגישת ה FP מדברים על "higher order functions״ כלומר – פונקציות שמקבלות פונקציות בתור פרמטר – על מנת להרכיב פונקציות מורכבות יותר.

למשל: רוב הפונקציות המאפשרות Functional Style הן צורה פשוטה של Function Composition. הפונקציות map או filter מקבלות פונקציה (״למבדה״) שבעזרתן הן עושות את הפעולה.

זה נחמד, שימושי, ומועיל. אפשר כך לפרק פונקציה מורכבת לפונקציות פשוטות יותר, זה מאפשר קוד Reuse, וזה מאוד תומך ב״דרך החשיבה הפונקצינלית״ שזו סוג של חשיבה מתמטית. הרי Function Composition (כמו רעיונות שונים של FP) – הוא רעיון שמגיע ישירות מהמתמטיקה.

Function Composition הוא גם מקור לצמיחת סיבוכיות, ופיזור קוד.

אין לי דוגמה לשלוף, אז שוב שרבטתי משהו מהיר (ושוב: בשפת קוטלין):

הנה דוגמה ל pure function שמצליחה להיות לא צפויה ולא ברורה.
כבר אמרנו שבכל כלי טוב – ניתן להשתמש בצורה גרועה?

קריאת קוד הפונקציה לא מלמד אותי מה היא עושה – הכל תלוי בפונקציות ששולחים לה כארגומנטים.
גם אם אגש מה call-site (הנקודה בקוד בה מפעילים את הפונקציה) ואראה מה שולחים לה –  לא תהיה ברורה התוצאה, כי הקשר בין customerFilter ו selectionLogic הוא לא ברור-מאליו, בטח לא עם ״התוספות״ ששתלנו, כמו בדיקת ה balance ואם הלקוח פעיל.

האם מישהו כותב פונקציות כאלו?!
כן. זה קורה. מתוך אהבה ל FP אנשים מזהים דפוסים חוזרים בקוד – ומוציאים אותם ל composer functions שאמנם חסכו כמה שורות קוד כפולות – אך הפכו את הקוד למאתגר להבנה ותחזוקה.

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

טיפוסים גמישים

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

השורה הזו מגדירה מבנה נתונים חדש של עץ בינארי בשפת Haskell.
הלוגיקה של הכנסה / הוצאה / חיפוש – דורשת עוד עבודת קוד ניכרת, אבל המבנה עצמו קיים.
חשבו כמה קוד צריך כדי לבצע את אותה ההגדרה בשפה verbose כמו ג׳אווה…

עוד טיפוס שימושי מאוד בשפות פונקציונליות הוא ה Enum. ה״פילוסופיה של FP״ היא לא להשאיר דברים ליד המקרה בזמן הריצה. לא להשתמש ב nulls, ולא לעבוד עם exceptions – הקוד שנכתב צריך לכסות את כל המקרים בזמן הקומפילציה.
על Haskell נאמר ש״אם הקוד מתקמפל – אזי התוכנה עובדת״ – בדיוק בשל אימוץ הגישה הזו.

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

סיכום

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

אומר זאת שוב: אין עניין של בחירה ב OO או FP. שמעתי דיונים כאלו כמה פעמים – והם פשוט מטופשים. זה כמעט כמו לדון מה יותר טוב: HashTable או Vector/ArrayList?
את שניכם אתם רוצים שיהיו בסט הכלים שלכם, וכל דיון צריך להיות למקרה הספציפי והבעיה שאתם מנסים לפתור. יתרה מכך, השילוב ביניהם – הוא בד״כ האופציה הטובה ביותר. דיון רלוונטי הוא מתי, ועד כמה להשתמש בכל אחד.

פרדיגמת ה FP מציבה כמה רעיונות רבי-עוצמה, ששימוש מושכל בהם – יכול מאוד לתרום להתמודדות עם מורכבות של מערכות. התמודדות שרובנו חווים – כל יום.

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

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

שיהיה בהצלחה!