2020-05-30

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

בתואר שני במדעי המחשב, היה קורס חובה בשם ״סיבוכיות״. עסקו בו במושגים כמו 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״








9 תגובות:

  1. פוסט לעיניין!

    השבמחק
  2. ממש אהבתי את הפוסט :) תודה רבה !!

    השבמחק
  3. אנונימי1/6/20 09:10

    מתוך The Zen of Python :
    Simple is better than complex.
    Complex is better than complicated.

    השבמחק
  4. פוסט מעולה! אהבתי את הדוגמאות!

    השבמחק
  5. כרגיל נהדר וחשוב. תודה

    השבמחק
  6. אנונימי2/10/20 20:56

    מצוין

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

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

      מחק
  8. תודה רבה לכולם על התגובות!

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

    השבמחק