הבעיה עם תכנות מונחה עצמים
הגישה בתכנות שנקראת 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 שכאן יכולה לארגן בקלות משימות, אבל מה אם נרצה להשתמש באותו רעיון כדי לסדר דברים אחרים? או בשביל לענות על שאלות אחרות לגבי המשימות האלה (למשל, מי המשימה שהכי הרבה משימות תלויות בה? מהי שרשרת התלויות הארוכה ביותר?). ככל שנתקדם בשאלות אולי נגלה שמבנה הנתונים שבעצם היינו צריכים כאן הוא מבנה של גרף - ופעולת סידור המשימות היא רק אחת הפעולות שאנחנו רוצים לעשות עם מבנה נתונים זה. המעבר לגרף, אבל, יכריח אותנו לזרוק את כל הקוד שכבר כתבנו או לפחות לשנות אותו בצורה משמעותית.
תכנות מונחה עצמים קצת דומה לבניית בניין - כשהיסודות חזקים ומתאימים אנחנו עשויים להגיע מאוד גבוה, אבל בדרך כלל ככל שהמערכת חיה יותר זמן אנחנו מגלים צורך לשנות קוד כבר מהיסודות, וכל שינוי כזה כולל השלכות משמעותיות למערכת כולה.