-->

יום ראשון, 5 במרץ 2017

על העיקרון הפתוח-סגור (ארכיטקטורה)

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

העיקרון הפתוח-סגור אומר ש:

Software Entities (classes, modules, functions, etc.)
should be open for extension, but closed for modification.

העיקרון הפתוח-סגור (בקיצור: OCP, שזה ה Open-Closed Principle), הוא חלק ממערכת ה S.O.L.I.D - מערכת כללים לבחינת איכות של עיצוב (Design) תוכנה שהגדיר הדוד בוב (Robert C. Martin) בתחילת שנות ה 2000.

כמו עקרונות אחרים במערכת, ואולי יותר מכולם - ה OCP איננו ברור כ"כ במבט ראשון.
גם לא במבט שני.
  • מה המשמעות של "סגור" ו"פתוח"?
  • מה זה Extension? הורשה?     (תשובה: לא בדיוק)
  • מה זה modification? שינוי קוד? שינוי State?

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


לא עזרתם בהרבה! מקור: http://www.tomdalling.com


בגרסה היותר ידידותית, העיקרון אומר משהו כזה:
  • כתבו את הקוד כך, שרכיבי התוכנה (מחלקות, מודולים, פונקציות, וכו') לא ישתנו לאחר שסיימו לכתוב אותם - להלן: "Closed for modification".
  • תוספת של פונקציונליות - יש להוסיף ברכיבי תוכנה חדשים (עוד מחלקות, עוד מודולים, או עוד פונקציות) - להלן "Open for Extension".

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


---------

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


שאלות ראשונות


מדוע לא לשנות קוד שכבר נכתב?

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

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


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

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


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


מדוע "הרחבה" של קוד היא בסדר?




בשלב הראשון אנו כותבים את ה "Basic Functionality". הקוד עובד ומתייצב עם הזמן.
כאשר אנחנו נדרשים להרחיב את הפונקצינליות אנו מוסיפים את תוספות 1, 2, ואז 3.

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

כמו כן - הרחבה תכופה תאפשר לכל איזור קוד להיות קטן ופשוט מספיק בכדי להבין אותו: "Scalability" של כתיבת קוד.

חשוב מאוד לציין: הרחבה איננה בהכרח הורשה. אמנם השתמשתי בסמנטיקה שמקובלת לתיאור הורשה ("Generalization") - אבל אנו כבר יודעים שהורשה היא חרב פיפיות (ראו הרצאה). עדיף להשתמש בכלי אחר. הכלי המוצלח ביותר ל OCP הוא Interface: ממשק שמי שרוצה להרחיב את המערכת - צריך לממש אותו.



מדוע עדיף לנו שבאגים יופיעו באיזור החדש של הקוד, ולא באיזור הישן?

זה לא בדיוק מה שנאמר.
חלק גדול מהיתרון של יישום OCP הוא שהקוד שלנו מחולק ל"חתיכות" קטנות של קוד שלא מסתבכות יותר מדי. קטן => פשוט (או יותר נכון: גדול => מסובך).

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


כיצד ממשים OCP בפועל?


יש לי מערכת. אני רוצה ליישם את העקרון הנפלא של OCP. כיצד עושים זאת?


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

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

אני בונה את המנגנון כך: Broker שמקבל את הבקשה ואז מעביר את רשימת ההתאמות האפשריות לרכיב "Base Condition" - הרכיב ידרג את ההתאמות מהטובה ביותר, להכי פחות טובה.

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


בעתיד אוכל להרחיב את המנגנון להתחשב בעוד תנאים, תנאי 2, תנאי 3, וכו'. כל התנאים עובדים לפי אותו Interface* כך שבעזרת ריבוי צורות פשוט - אני יכול להרחיב את המודול בעוד תנאים, מבלי לשנות שורת קוד בודדות בקוד שכבר כתבתי.

מנגנון ה Broker קורא מקובץ קונפיגורציה - באיזה סדר להפעיל את התנאים.

ובאמת לאחר כחצי שנה המנגנון נראה כך:


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

בפבואר הוספתי את Condition 2 - ועד אפריל הוא התייצב בצורה טובה.
במרץ הוספתי את Condition 3 - ועד מאי הוא התייצב בצורה טובה.
במאי הוספתי את Condition 4 - ועד יולי הוא התייצב בצורה טובה.

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

ביישום הספציפי הזה, ה Broker בנוי כך שהוא יכול להכניס את התנאים החדשים לפעולה בצורה הדרגתית:
כהוספתי את Condition 2 בתחילת פבואר - הוא פעל במוד בדיקה, על 100 נסיעות בלבד ביום.
באמצע פבואר כבר שיניתי את הקונפיגורציה שיעבוד על 3000 נסיעות ביום (בכדי לבדוק טוב יותר), ורק בסוף פבואר, לאחר שתוקנו עיקרי הבאגים - הפעלתי אותו על כלל המערכת.

ה Design הזה כמובן הוא לא "מושלם" ואני לא ממליץ להעתיק אותו כי הוא מופיע כדוגמה לעיקרון חשוב של תכנון מערכות. הוא מתאים לצרכים מאוד ספציפיים.
ככל שיהיו יותר תנאים של Matching שלא משתלבים ב Pattern הנ"ל - הוא ילך ויסתבך. אין לו תוכנית ל "חריגים לא מוכרים" (כי הם לא מוכרים, דאא) ויהיה צורך לבצע חשיבה מחדש ברגע שיגיעו כאלו.

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

איכות המבנה / העיצוב של ה Matching System (או כל מודול אחר) תלוי בעיצוב הראשוני, ומידה לא פחותה - ברמת ההחלטות וההתאמות שידרשו עם הזמן.


ספקות לגבי OCP


ה OCP הוגדר בצורה לא ברורה. את זה כבר הזכרנו. היו כמה מאמרים שהתפרסמו בביקורת הזו (למשל: Say "No" to the Open/Closed pattern של Marco Cecconi ו The Open-Closed Principle, in review של Jon Skeet)

במאמר מאוחר של הדוד בוב מ 2013 הוא סיפק את ההגדרה הבאה ל OCP:

What it means is that you should strive to get your code into a position such that, when behavior changes in expected ways, you don't have to make sweeping changes to all the modules of the system. Ideally, you will be able to add the new behavior by adding new code, and changing little or no old code.

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


ספק מרכזי שיש לגבי OCP, הוא האם התקדמות בטכניקות של הנדסת תוכנה בשנים האחרונות - שינו משהו לגבי הנכונות שלו?

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

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

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

בדוגמה קלאסית ל OCP אוהבים לתת את הדוגמה שהמתודה DrawAllShapes איננה מקיימת את ה OCP. גם מבלי לראות את המתודה - זה מסתמן בבירור משמה: כל פעם שיהיה במערכת Shape חדש - נצטרך להוסיף שם קוד.

האם בהכרח זהו קוד שכדאי להימנע ממנו?
לא בטוח. אם במשך 5 שנים של אורך חיי במערכת יצטברו במתודה 3 או 4 צורות - לא נראה לי שזה יהיה קוד רע. בואו לא נהיה פאנטיים. האם חשוב להוסיף final על כל משתנה בג'אווה? אפשר - אבל ה impact המעשי הוא כנראה באמת קטן.

אם המערכת שלנו תתרחב ל 10 צורות, וכמו המתודה DrawAllShapes יש מתודות כמו EditAllShapes, ResizeAllShapes ועוד - אז ברור לי שבסיס הקוד שלי הולך וגדל ללא היכר. זה מקרה ברור בו כן היינו רוצים ליישם את ה OCP.
בסיס קוד גדול יותר => קוד שקשה יותר לשלוט בו => קוד שקשה יותר לשנות / יותר באגים.

ברור לחלוטין שלא ניתן, ולא כדאי, להגיע ל 100% יישום של OCP במערכת.
בניגוד להוספת "final" למשתנים בג'אווה, פרקטיקה שמוסיפה מעט ערך אבל גם העלות שלה שולית - OCP משמעה כתיבת עוד קוד ולקיחת "סיכונים" בעצם הגדרת ההפשטה (למשל: ה Condition בדוגמה למעלה).
כאשר דרישה חדשה משתלבת יפה בהפשטה שיצרנו - השגנו ייתרון, אך כל דרישה חדשה שלא תתאים להפשטה - תקשה ותסבך את המערכת.

OCP מתבטא ב Patterns כמו Plug-ins, State, Strategy, ועוד. אולי גם Composite ו Delegate.
זוהי רשימה קלאסית של Patterns שאנשים נטו ונוטים לעשות בהם שימוש מוגזם: השימוש עשוי להרגיש "טוב" (המערכת שלנו היא "כמו בספרים"), אך בפועל ייתכן ויצרנו מערכת יותר מסובכת ויותר יקרה לשינוי / תחזוקה.

OCP הוא במיטבו כאשר ממשים אותו במידה, אבל מהי המידה הנכונה?


מקור: https://www.slideshare.net/PaulBlundell2/open-closedprinciple-kata


קרייג לרמן, ו PV


ובכן, אם לא הכרתם עד עכשיו - אז שווה להכיר:
במקביל לדוד בוב שהגדיר את עקרונות ה S.O.L.I.D, עבד בחור קנדי בשם קרייג לרמן על מערכת חוקים מאוד מאוד דומה בשם GRASP. המערכת של לרמן הרבה יותר פשוטה להבנה! מה שהופך אותה לעדיפה בעיני.

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

העיקרון המקביל של GRASP ל OCP נקרא Predicted Variations או בקיצור PV. והוא מוגדר כך:

Identify points of predicted variation and create a stable interface around them


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

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

We can prioritize our goals and strategies as follows:
1. We wish to save time and money, reduce the introduction of new defects, and reduce the pain and suffering inflicted on overworked developers.
2. To achieve this, we design to minimize the impact of change.
3. To minimize change impact, we design with the goal of low coupling.
4. To design for low coupling, we design for PVs.

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


אנסה לתמצת את הרעיונות במילים שלי:
  1. זהו במערכת מקומות שבהם אתם צופים תוספת של קוד, ונסו להכין ממשקים ידועים להרחבות הללו.
  2. בעצם מדובר ב"ניהול סיכונים": הסבירות שאכן המערכת תתרחב לשם (תזכורת = בני-אדם נוטים לבצע הערכת יתר בציר הזה)  X  כמה קוד וסיבוכיות אתם צופים שתתווסף.
  3. עבור שינויים קלים (שורות קוד בודדות) - הערך ביישום OCP הוא גם נמוך. נסו לכוון למקומות משמעותיים.
  4. קלעתם טוב - השגתם ערך; לא קלעתם טוב - השקעתם זמן מיותר. זה בעצם העניין כאן.
  5. אפשר לנקוט בגישה אגי'לית: כשאתם רואים שאכן המערכת מתפתחת לכיוון מסוים, בצעו Refactoring והוסיפו ממשק מתאים / PV. אם יש לכם בדיקות יחידה - אין סיבה לפחד מ Refactoring.


אני מניח שאם GRASP הייתה השיטה השלטת - הפוסט שלי היה מתקצר בחצי: המטאפורה של Predicated Variations הרבה יותר קולעת מה Open-Closed Principle (סליחה, הדוד בוב).

לא מצאתי את הציוץ של הבחור שהציע: "בואו נחליף את OCP ב PV - ונתחיל להשתמש ב SPLID".


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


----

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


תגובה 1: