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

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

25/06/2022

ביום 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

וזה בדיוק מה שהחלק השני מדבר עליו.

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