ריפקטורינג ותכנות מונחה עצמים (או: הסיבה שתכנות מונחה עצמים לא עובד)
ביום 7 של Advent Of Code האחרון קיבלו את פנינו חבורה של סרטנים שניסו להגן עלינו מלוויתן תוקפני. בשביל לעשות את זה הם היו צריכים למצוא מקום טוב לעמוד בו.
1. חלק ראשון
הקלט של התרגיל היה רשימת המיקומים של הסרטנים על ציר אופקי בלבד, למשל:
16,1,2,0,4,2,7,1,2,14
ואנחנו צריכים למצוא להם מקום טוב לעמוד בו. המקום הכי טוב בשבילם יהיה זה שהכי קל לכולם להגיע אליו, כלומר שסכום הצעדים שכל הסרטנים צריכים לעשות כדי להגיע ליעד יהיה הכי קטן.
אינטואיציה אחת לפתור את התרגיל הזה למי שרוצה ללכת בגישה מונחית עצמים היא לכתוב מחלקה בשביל כל הסרטנים האלה. ברובי כתבתי משהו כזה:
class CrabsSwarm
def initialize(filename)
@crabs = File.read(filename).split(',').map(&:to_i)
end
def fuel_needed_to_reach(target)
@crabs.map {|e| (e - target).abs }.sum
end
def solve
target = (@crabs.min..@crabs.max).min_by {|target| fuel_needed_to_reach(target) }
fuel_needed_to_reach(target)
end
end
יש אגב עוד המון חלוקות אפשריות למחלקות ואולי בכלל לא חייבים מחלקות. זה לא חשוב בשביל הסיפור שלנו. תראו מה קורה בחלק השני.
2. חלק שני - משנים את החישוב
למרות שמצאתי מקום ממש טוב בשביל הסרטנים (על קלט הדוגמה זו היתה משבצת 2), הם לא הסכימו ללכת לשם. ההסבר בתרגיל היה שהצוללת של כל סרטן לא צורכת דלק בצורה לינארית אלא בסידרה חשבונית. כלומר בשביל לעבור מרחק של 3 נקודות הסרטן צריך להשתמש ב 1+2+3 כלומר 6 יחידות דלק.
עם הנתונים החדשים נשלחנו למצוא נקודה חדשה אליה יוכלו הסרטנים להתקבץ. הבעיה, שלמרות שבחלק הראשון חשבתי שחילקתי נכון את התרגיל למחלקה ופונקציות, כשאני מגיע להרחיב את אותה מחלקה בחלק השני אני נאלץ לכתוב:
class CrabsSwarmPart2 < CrabsSwarm
def fuel_needed_to_reach(target)
@crabs.map {|e| (0..(e - target).abs).sum }.sum
end
end
בום. ה map מהחלק הראשון מופיע שוב גם בחלק השני. זה לא נראה חשוב ובגלל זה בהרבה פרויקטים עוצרים כאן. אבל בעולם האמיתי זאת יכולה להיות בעיה.
3. מה אני לומד מהדרישה החדשה
הבעיה פה היא שבמקום לחפש מה אני לומד מהדרישה החדשה, אני ממשיך להתעקש להתאים את הדרישה החדשה לסכימה שכבר כתבתי. לאורך זמן ככל שיותר דרישות יופיעו ולא יתאימו לסכימה שחשבתי עליה מראש, מימוש כל דרישה נוספת הופך ליותר ויותר קשה.
האלטרנטיבה היא לשאול - מה אני לומד מהדרישה החדשה? במקרה של הסרטנים אני לומד שיש סיכוי טוב שהם ירצו להמשיך להחליף כלי תחבורה בעתיד, ושכלי תחבורה סרטניים שונים ביניהם בצריכת הדלק שלהם. זאת הנקודה ששווה לי להתאמץ ולשכתב חלק מהקוד כדי לתת יותר משקל לדרישה החדשה. הנה הניסיון השני שלי:
class CrabsSwarm
def initialize(filename)
@crabs = File.read(filename).split(',').map(&:to_i)
end
def energy(from, to)
(from - to).abs
end
def fuel_needed_to_reach(target)
@crabs.map {|e| energy(e, target) }.sum
end
def solve
target = (@crabs.min..@crabs.max).min_by {|target| fuel_needed_to_reach(target) }
fuel_needed_to_reach(target)
end
end
נכון, קוד יותר ארוך, אבל גם יותר קל להרחבה בנקודה הנכונה. פיתרון החלק השני יהיה בסך הכל שינוי של הפונקציה energy
:
class CrabsSwarmPart2 < CrabsSwarm
def energy(from, to)
(0..(from - to).abs).sum
end
end
וזה בדיוק מה שהחלק השני מדבר עליו.
ליישום אותו מהלך בעולם האמיתי יש השלכות: זה אומר שאנחנו מוכנים לעדכן גם דברים בסיסיים במערכת בשביל לממש פיצ'רים חדשים, ושאין קוד חשוב מכדי לשנות אותו. גישה כזאת משאירה את כל קוד המערכת "חי" ועוזרת לייצר מערכות שמותאמות לדרישות האמיתיות והעדכניות של החיים.