הבעיה עם תכנות מונחה עצמים

05/12/2018

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

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

1. סדר פעולות בבוקר

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

1. get dressed (3, 6)
2. eat breakfast (5)
3. wash your face (6)
4. put on your shoes (1, 6)
5. prepare breakfast (6)
6. get out of bed ()

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

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

class Task
  attr_accessor :text, :done, :depends_on
  def initialize(text)
    self.text = text
    self.done = false
    self.depends_on = []
  end

  def perform
    return if done
    self.depends_on.each {|task| task.perform}
    self.done = true
    puts text
  end
end

עכשיו כל מה שצריך בשביל לגלות את הרשימה שלנו הוא להפוך אותה לרשימה של Task ולהפעיל למשל עם הקוד הבא:

tasks = {
  1 => Task.new('get dressed'),
  2 => Task.new('eat breakfast'),
  3 => Task.new('wash your face'),
  4 => Task.new('put on your shoes'),
  5 => Task.new('prepare breakfast'),
  6 => Task.new('get out of bed'),
}

tasks[1].depends_on = [tasks[3], tasks[6]]
tasks[2].depends_on = [tasks[5]]
tasks[3].depends_on = [tasks[6]]
tasks[4].depends_on = [tasks[1], tasks[6]]
tasks[5].depends_on = [tasks[6]]

tasks.each {|id, task| task.perform }

וזה עובד אבל מרגיש קצת מעייף. ואם תנסו לרוץ על הקלט בלולאה כדי ליצור אוטומטית את אוביקטי ה Task תגלו שאתם צריכים שתי לולאות: הראשונה כדי לזהות איזה משימות בכלל יש, ואז מעבר נוסף כדי להגדיר את התלויות.

2. חלוקה אחרת למחלקות

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

בואו ננסה עכשיו להוסיף מחלקה חדשה בשם Organiser:

class Organiser
  attr_reader :tasks
  def initialize
    @tasks = {}
  end

  def add_task(id, text, depends_on_ids)
    @tasks[id] = Task.new(text)
    @tasks[id].depends_on = depends_on_ids
  end

  def print_list

    @tasks.transform_values {|task| task.depends_on = task.depends_on.map {|id| @tasks[id] }}
    @tasks.each {|id, task| task.perform }
  end
end

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

organizer = Organiser.new
input = <<END
1. get dressed (3, 6)
2. eat breakfast (5)
3. wash your face (6)
4. put on your shoes (1, 6)
5. prepare breakfast (6)
6. get out of bed ()
END

input.each_line do |line|
  _, id, text, dependencies = *line.match(/(\d+)\. ([\w\s]+) \(([\d\s,]*)\)/)
  next if id.nil?
  organizer.add_task(id, text, dependencies.split(/\D+/))  
end

organizer.print_list

3. מסקנות ובעיות

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

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

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