תרגול מודרך: מדרישה מילולית למבנה מחלקות

מטרות השיעור
  • לעבור תהליך תכנון מלא — מטקסט דרישה ועד קוד עובד
  • לתעד החלטות תכנון — למה X הוא Class, למה Y שייך ל-Z
  • לשרטט קשרים בין Classes — has-a, is-a וכמות (אחד-לרבים)
  • לממש לפי התכנון, ולגלות ולתקן חורים בתכנון בזמן הבדיקה

מה נלמד?

זה שיעור סדנה. ניקח דרישה מילולית אחת, ונעבור יחד על כל התהליך:

  1. קריאה וסימון.
  2. החלטה על Classes.
  3. אחריות ונתונים לכל Class.
  4. קשרים בין ה-Classes.
  5. שלד קוד.
  6. מימוש.
  7. בדיקה, ותיקון התכנון.

למה צריך את זה?

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

הסבר מהבסיס

הדרישה

מערכת לחדר כושר “KodFit”

לחדר הכושר יש מנויים. לכל מנוי יש שם, מספר מנוי וסוג מנוי: Basic (עד 8 כניסות בחודש) או Premium (ללא הגבלה). Premium עולה 250 ש"ח לחודש ו-Basic 150.

בחדר הכושר יש שיעורים (יוגה, ספינינג…). לכל שיעור יש שם, מדריך, יום ושעה, ומספר מקומות מוגבל.

מנוי יכול להירשם לשיעור אם יש מקום, ואם לא עבר את מכסת הכניסות החודשית שלו. הרשמה נחשבת כניסה. מנוי יכול לבטל הרשמה, והכניסה חוזרת אליו.

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

תחביר

אין תחביר חדש. נשתמש בכל מה שלמדנו: Classes, __init__, properties, Encapsulation, Inheritance + Polymorphism, Composition ו-Exceptions משלנו.

דוגמת קוד ראשונה

שלב 1 — קריאה וסימון

שמות עצםפעליםכללים ומספרים
חדר כושר, מנוי, שם, מספר מנוי, סוג מנוי, Basic, Premium, שיעור, מדריך, יום ושעה, מקומות, הרשמה, כניסה, הכנסהלהירשם, לבטל, להציגBasic: עד 8 כניסות, 150 ש"ח. Premium: ללא הגבלה, 250 ש"ח. מקומות מוגבלים. הרשמה = כניסה. ביטול מחזיר כניסה

שלב 2 — מה הן ה-Classes?

מועמדהחלטהנימוק
מנוי✓ Class Memberזהות, נתונים, כללים (מכסה).
סוג מנוי✓ היררכיה: Plan → BasicPlan, PremiumPlanלכל סוג התנהגות שונה (מחיר, מגבלה), וזה בדיוק Polymorphism. אם יתווסף “Student” בעתיד, מוסיפים Class.
שיעור✓ Class GymClassזהות, נתונים, כלל (מקומות). (Class הוא שם בעייתי, ולכן GymClass.)
הרשמה✗ לא Class נפרדת (בינתיים)אין לה נתונים משלה מעבר ל"מי ואיפה". מספיק שהשיעור יחזיק את המנויים שלו.
חדר כושר✓ Class Gymמחזיקה את כל המנויים והשיעורים, ומתאמת ביניהם.
מדריך, יום ושעה, שםAttributesערכים, בלי התנהגות.
הכנסהMethodמחושבת מסך המחירים.

שלב 3 — אחריות ונתונים

Classאחריות (משפט אחד)נתוניםממשק
Planמגדיר מחיר ומגבלת כניסות לסוג מנוי—monthly_price(), visit_limit()
Memberמנהל את המכסה החודשית של מנוי אחדשם, מספר, plan, כניסות שנוצלוcan_visit(), use_visit(), return_visit()
GymClassמנהל את רשימת הנרשמים לשיעור אחדשם, מדריך, זמן, קיבולת, נרשמיםhas_room(), add(), remove(), is_registered()
Gymמתאם הרשמות ומספק דוחותמנויים, שיעוריםadd_member(), add_class(), register(), cancel(), schedule(), classes_of(), monthly_revenue()

החלטה חשובה: איפה נבדק “האם מותר להירשם”? יש שני כללים: מקום בשיעור (שייך ל-GymClass) ומכסה (שייכת ל-Member). Gym.register מתאמת: שואלת את שניהם, ורק אם שניהם מסכימים, מבצעת את השינויים בשניהם.

שלב 4 — קשרים

            Gym
           /    \
   has many      has many
      /              \
  Member  ◄────────  GymClass
     │   (class holds its registered members)
   has-a
     │
    Plan  ◄── is-a ── BasicPlan
          ◄── is-a ── PremiumPlan
  • Gym has many Member ו-GymClass → Composition (dicts פנימיים).
  • Member has a Plan → Composition. (מנוי הוא לא סוג של תוכנית!)
  • BasicPlan is a Plan → Inheritance + Polymorphism.
  • GymClass מחזיקה הפניות ל-Member, ולא העתקים.

שלב 5 — שלד

class Plan:
    def monthly_price(self): ...
    def visit_limit(self): ...

class BasicPlan(Plan): ...
class PremiumPlan(Plan): ...

class Member:
    def __init__(self, member_id, name, plan): ...
    def can_visit(self): ...
    def use_visit(self): ...
    def return_visit(self): ...

class GymClass:
    def __init__(self, class_id, name, coach, time, capacity): ...
    def has_room(self): ...
    def add(self, member): ...
    def remove(self, member): ...

class Gym:
    def register(self, member_id, class_id): ...
    def cancel(self, member_id, class_id): ...
    def schedule(self): ...
    def monthly_revenue(self): ...

פירוק הקוד שורה אחר שורה

רגע לפני המימוש — בדיקת התכנון מול הדרישה, שורה אחר שורה:

דרישהמי מטפל
“Basic עד 8 כניסות, Premium ללא הגבלה”BasicPlan.visit_limit() → 8. PremiumPlan.visit_limit() → None (ללא הגבלה).
“מחיר לפי סוג”monthly_price() פולימורפית.
“להירשם אם יש מקום ואם לא עבר מכסה”Gym.register → GymClass.has_room() + Member.can_visit().
“הרשמה = כניסה”register קורא ל-member.use_visit().
“ביטול מחזיר כניסה”cancel → class.remove() + member.return_visit().
“להציג שיעורים עם מקומות פנויים”Gym.schedule().
“שיעורים של מנוי”Gym.classes_of(member_id).
“הכנסה חודשית”Gym.monthly_revenue() ‏←‏ סכום member.plan.monthly_price().

כל שורה בדרישה “נוחתת” ב-Class ובמתודה. אין שורה בלי בית, ואין Class בלי סיבה.

דוגמה נוספת

שלב 6 — מימוש. week-3/gym.py:

class GymError(Exception):
    pass


class Plan:
    def monthly_price(self):
        raise NotImplementedError

    def visit_limit(self):
        raise NotImplementedError

    def name(self):
        return type(self).__name__.replace("Plan", "")


class BasicPlan(Plan):
    def monthly_price(self):
        return 150

    def visit_limit(self):
        return 8


class PremiumPlan(Plan):
    def monthly_price(self):
        return 250

    def visit_limit(self):
        return None


class Member:
    def __init__(self, member_id, name, plan):
        self.member_id = member_id
        self.name = name
        self.plan = plan
        self._visits_used = 0

    @property
    def visits_used(self):
        return self._visits_used

    def can_visit(self):
        limit = self.plan.visit_limit()
        return limit is None or self._visits_used < limit

    def use_visit(self):
        if not self.can_visit():
            raise GymError(self.name + " reached the monthly limit of " + str(self.plan.visit_limit()))
        self._visits_used = self._visits_used + 1

    def return_visit(self):
        if self._visits_used > 0:
            self._visits_used = self._visits_used - 1


class GymClass:
    def __init__(self, class_id, name, coach, time, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.class_id = class_id
        self.name = name
        self.coach = coach
        self.time = time
        self.capacity = capacity
        self._members = []

    @property
    def free_spots(self):
        return self.capacity - len(self._members)

    def has_room(self):
        return self.free_spots > 0

    def is_registered(self, member):
        return member in self._members

    def add(self, member):
        if self.is_registered(member):
            raise GymError(member.name + " is already in " + self.name)
        if not self.has_room():
            raise GymError(self.name + " is full")
        self._members.append(member)

    def remove(self, member):
        if not self.is_registered(member):
            raise GymError(member.name + " is not registered to " + self.name)
        self._members.remove(member)


class Gym:
    def __init__(self, name):
        self.name = name
        self._members = {}
        self._classes = {}

    def add_member(self, member):
        self._members[member.member_id] = member

    def add_class(self, gym_class):
        self._classes[gym_class.class_id] = gym_class

    def _member(self, member_id):
        if member_id not in self._members:
            raise GymError("no member " + str(member_id))
        return self._members[member_id]

    def _class(self, class_id):
        if class_id not in self._classes:
            raise GymError("no class " + str(class_id))
        return self._classes[class_id]

    def register(self, member_id, class_id):
        member = self._member(member_id)
        gym_class = self._class(class_id)
        if not member.can_visit():
            raise GymError(member.name + " reached the monthly limit")
        gym_class.add(member)
        member.use_visit()

    def cancel(self, member_id, class_id):
        member = self._member(member_id)
        self._class(class_id).remove(member)
        member.return_visit()

    def schedule(self):
        lines = []
        for gym_class in self._classes.values():
            lines.append(gym_class.time + " " + gym_class.name + " with " + gym_class.coach
                         + " - " + str(gym_class.free_spots) + " free")
        return lines

    def classes_of(self, member_id):
        member = self._member(member_id)
        return [c.name for c in self._classes.values() if c.is_registered(member)]

    def monthly_revenue(self):
        return sum(member.plan.monthly_price() for member in self._members.values())

שלב 7 — בדיקה. בסוף הקובץ:

def main():
    gym = Gym("KodFit")
    gym.add_member(Member(1, "Dana", PremiumPlan()))
    gym.add_member(Member(2, "Avi", BasicPlan()))
    gym.add_class(GymClass("Y1", "Yoga", "Noa", "Sun 08:00", 2))
    gym.add_class(GymClass("S1", "Spinning", "Tom", "Mon 19:00", 10))

    gym.register(1, "Y1")
    gym.register(2, "Y1")
    gym.register(1, "S1")

    attempts = [(1, "Y1"), (2, "Y9"), (9, "S1")]
    for member_id, class_id in attempts:
        try:
            gym.register(member_id, class_id)
        except GymError as error:
            print("Rejected:", error)

    gym.cancel(2, "Y1")
    for line in gym.schedule():
        print(line)
    print("Dana's classes:", gym.classes_of(1))
    print("Monthly revenue:", gym.monthly_revenue())


if __name__ == "__main__":
    main()

פלט:

Rejected: Dana is already in Yoga
Rejected: no class Y9
Rejected: no member 9
Sun 08:00 Yoga with Noa - 1 free
Mon 19:00 Spinning with Tom - 9 free
Dana's classes: ['Yoga', 'Spinning']
Monthly revenue: 400

מה הבדיקה גילתה? — תיקון תכנון

נבדוק את כלל המכסה עם מנוי Basic:

gym2 = Gym("Test")
avi = Member(2, "Avi", BasicPlan())
gym2.add_member(avi)
for i in range(9):
    gym2.add_class(GymClass("C" + str(i), "Class " + str(i), "Coach", "Tue", 5))
for i in range(9):
    try:
        gym2.register(2, "C" + str(i))
    except GymError as error:
        print("Rejected:", error)
print("Avi used", avi.visits_used, "visits")
Rejected: Avi reached the monthly limit
Avi used 8 visits

הכלל עובד. אבל שימו לב לחור: מה אם gym_class.add() הצליח, ו-member.use_visit() נכשל? אז המנוי רשום לשיעור, אבל בלי שנוצלה כניסה. במימוש שלנו זה לא קורה, כי בדקנו can_visit() לפני add. אבל זה מראה כלל חשוב: כשמתודה משנה שני אובייקטים, בודקים את כל התנאים לפני שמשנים משהו. בדיוק כמו ש-transfer_to בבנק קוראת ל-withdraw (שעלולה להיכשל) לפני deposit.

טעות נפוצה

הטעות 1: לדלג על שלבים 1–4 “כי ברור לי”. אחרי 200 שורות מגלים ש"הרשמה" הייתה צריכה להיות במקום אחר, וש-Member ו-GymClass “מכירים” זה את זה בצורה שגורמת לעדכונים כפולים. 15 דקות תכנון חוסכות שעות.

הטעות 2: ירושה במקום Composition — class PremiumMember(Member) נראה טבעי, אבל מה קורה כשמנוי משדרג מ-Basic ל-Premium? אי אפשר “להחליף Class” לאובייקט קיים. עם Composition (member.plan = PremiumPlan()) זו שורה אחת. מה שעשוי להשתנות במהלך חיי האובייקט ‏←‏ Composition.

הטעות 3: כלל שנבדק במקום הלא נכון. אם GymClass.add הייתה בודקת גם את המכסה של המנוי, השיעור היה צריך “להכיר” את חוקי התוכניות. כל Class בודקת את הכללים שלה, ו-Gym מתאמת.

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

תרגול

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

מה מתרגלים בדף הזה: כל הדרך מדרישה מילולית לקוד — בדוגמה של שירות השכרת קורקינטים. כל תרגיל הוא שלב אחד בתהליך: קודם תכנון, אחר כך Class אחרי Class.

בסוף הדף תדעו:

  • לפרק דרישה ל-Classes, כללים וקשרים לפני שכותבים קוד
  • לבנות היררכיה קטנה: Parent ו-Child שמשנה רק תעריף
  • לשים כל כלל ב-Class שמחזיקה את הנתונים שלו
  • לחבר כמה Classes למערכת אחת שעובדת

תרגיל 1: מדרישה לרשימת מחלקות

מה עושים: שלב התכנון — בלי קוד.

הדרישה:

“מערכת השכרת קורקינטים: משתמש שוכר קורקינט לנסיעה. לכל קורקינט מספר וסוללה באחוזים. אסור לשכור קורקינט עם פחות מ-20% סוללה, או קורקינט שכבר בשימוש. נסיעה עולה 5 ש"ח לפתיחה ועוד 1 ש"ח לדקה. קורקינט מהיר עולה 2 ש"ח לדקה.”

המשימה:

  1. אילו Classes יש כאן? לכל אחת — מה היא יודעת (Attributes).
  2. אילו כללים יש, ובאיזו Class כל כלל צריך לשבת?
  3. מה הקשרים ביניהן: מי מחזיק את מי (has-a), ומי סוג של מי (is-a)?

מה מגישים: רשימת Classes עם Attributes, רשימת כללים, ורשימת קשרים.

הצג פתרון
  1. Classes: User, Scooter, FastScooter, Ride, ו-RentalService שמנהל את כולם.
  2. כללים:
    • אין השכרה מתחת ל-20% סוללה.
    • אין השכרה של קורקינט שבשימוש.
    • מחיר נסיעה: 5 + דקות × תעריף לדקה (1 רגיל, 2 מהיר).
  3. קשרים:
    • FastScooter הוא סוג של Scooter (is-a) — רק התעריף שונה.
    • Ride מחזיקה משתמש וקורקינט (has-a).
    • RentalService מחזיק הרבה קורקינטים ומשתמשים.

תרגיל 2: Scooter ו-FastScooter

מה בונים: הקורקינט — ומחיר נסיעה שתלוי בסוג שלו.

מה לכתוב:

  1. Class בשם Scooter, עם מספר, אחוז סוללה, ו-in_use שמתחיל ב-False.
  2. ב-Scooter: Class Attribute בשם PER_MINUTE שערכו 1, ו-price שמקבלת דקות ומחזירה 5 ועוד תעריף לדקה כפול הדקות.
  3. Class בשם FastScooter שיורשת מ-Scooter, ומשנה רק את PER_MINUTE ל-2.

קוד לבדיקה:

print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))

הפלט הרצוי:

15
25
הצג פתרון
UNLOCK_FEE = 5


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def price(self, minutes):
        return UNLOCK_FEE + minutes * self.PER_MINUTE


class FastScooter(Scooter):
    PER_MINUTE = 2


print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))
15
25

self.PER_MINUTE קורא את הערך של ה-Class של האובייקט — ולכן FastScooter משתמש ב-2 בלי לדרוס את price בכלל.

תרגיל 3: הכללים במקום אחד

מה בונים: ממשיכים את Scooter — שני הכללים של ההשכרה, במקום אחד.

מה לכתוב:

  1. ב-Scooter מוסיפים unlock.
  2. אם הסוללה מתחת ל-20%: ValueError עם ההודעה הראשונה שבפלט.
  3. אם הקורקינט כבר בשימוש: ValueError עם ההודעה השנייה שבפלט.
  4. אחרת: מסמנים שהקורקינט בשימוש.

קוד לבדיקה:

s = Scooter(1, 15)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)

הפלט הרצוי:

error: battery too low: 15%
True
error: scooter 1 is in use
הצג פתרון
UNLOCK_FEE = 5
MIN_BATTERY = 20


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def price(self, minutes):
        return UNLOCK_FEE + minutes * self.PER_MINUTE

    def unlock(self):
        if self.battery < MIN_BATTERY:
            raise ValueError("battery too low: " + str(self.battery) + "%")
        if self.in_use:
            raise ValueError("scooter " + str(self.number) + " is in use")
        self.in_use = True


s = Scooter(1, 15)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
error: battery too low: 15%
True
error: scooter 1 is in use

תרגיל 4: נסיעה

מה בונים: נסיעה — שמחברת משתמש לקורקינט.

מה לכתוב:

  1. Class בשם Ride, שמקבלת שם של משתמש וקורקינט ומחזיקה אותם.
  2. ב-__init__ של Ride: קוראים ל-unlock של הקורקינט — הנסיעה נפתחת רק אם מותר.
  3. finish — מקבלת דקות, משחררת את הקורקינט ומחזירה את מחיר הנסיעה.

קוד לבדיקה:

scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)

הפלט הרצוי:

True
29
False
הצג פתרון
UNLOCK_FEE = 5
MIN_BATTERY = 20


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def price(self, minutes):
        return UNLOCK_FEE + minutes * self.PER_MINUTE

    def unlock(self):
        if self.battery < MIN_BATTERY:
            raise ValueError("battery too low: " + str(self.battery) + "%")
        if self.in_use:
            raise ValueError("scooter " + str(self.number) + " is in use")
        self.in_use = True


class FastScooter(Scooter):
    PER_MINUTE = 2


class Ride:
    def __init__(self, user, scooter):
        scooter.unlock()
        self.user = user
        self.scooter = scooter

    def finish(self, minutes):
        self.scooter.in_use = False
        return self.scooter.price(minutes)


scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)
True
29
False

Ride לא בודקת את הכללים בעצמה — היא קוראת ל-unlock, והכללים נשארים בתוך Scooter.

תרגיל 5: שירות ההשכרה

מה בונים: השירות כולו — מחזיק את כל הקורקינטים ויודע אילו פנויים.

מה לכתוב:

  1. Class בשם RentalService, שמחזיקה מילון של קורקינטים לפי מספר.
  2. add — מוסיפה קורקינט למילון.
  3. get — מקבלת מספר ומחזירה את הקורקינט.
  4. available — מחזירה רשימה של מספרי הקורקינטים שאפשר לשכור עכשיו: סוללה של 20% לפחות, ולא בשימוש.

קוד לבדיקה:

service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())

הפלט הרצוי:

[1]
הצג פתרון
UNLOCK_FEE = 5
MIN_BATTERY = 20


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def can_rent(self):
        return self.battery >= MIN_BATTERY and not self.in_use

    def unlock(self):
        if self.battery < MIN_BATTERY:
            raise ValueError("battery too low: " + str(self.battery) + "%")
        if self.in_use:
            raise ValueError("scooter " + str(self.number) + " is in use")
        self.in_use = True


class FastScooter(Scooter):
    PER_MINUTE = 2


class Ride:
    def __init__(self, user, scooter):
        scooter.unlock()
        self.user = user
        self.scooter = scooter


class RentalService:
    def __init__(self):
        self._scooters = {}

    def add(self, scooter):
        self._scooters[scooter.number] = scooter

    def get(self, number):
        return self._scooters[number]

    def available(self):
        result = []
        for number, scooter in self._scooters.items():
            if scooter.can_rent():
                result.append(number)
        return result


service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())
[1]

קורקינט 2 — סוללה נמוכה. קורקינט 3 — בשימוש. הוספנו ל-Scooter את can_rent(), כדי שהשירות ישאל את הקורקינט ולא יבדוק את הנתונים שלו בעצמו.

סיכום

  • תהליך התכנון: סימון ‏←‏ Classes ‏←‏ אחריות ונתונים ‏←‏ קשרים ‏←‏ שלד ‏←‏ מימוש ‏←‏ בדיקה.
  • כל שורה בדרישה צריכה “לנחות” ב-Class ובמתודה. כל Class צריכה סיבה.
  • התנהגות שונה לפי סוג ‏←‏ היררכיה + Polymorphism (Plan). משהו שעשוי להשתנות במהלך החיים ‏←‏ Composition (member.plan).
  • כל Class בודקת את הכללים שלה, ו-Class מתאמת (Gym) מחברת ביניהן.
  • כשפעולה משנה כמה אובייקטים: בודקים את כל התנאים לפני שמשנים משהו.
  • דרישה חדשה = עדכון התכנון. ותכנון טוב מאפשר להוסיף בלי לשבור.

דוקומנטציה רשמית