תרגול מודרך: מדרישה מילולית למבנה מחלקות
- לעבור תהליך תכנון מלא — מטקסט דרישה ועד קוד עובד
- לתעד החלטות תכנון — למה X הוא Class, למה Y שייך ל-Z
- לשרטט קשרים בין Classes — has-a, is-a וכמות (אחד-לרבים)
- לממש לפי התכנון, ולגלות ולתקן חורים בתכנון בזמן הבדיקה
מה נלמד?
זה שיעור סדנה. ניקח דרישה מילולית אחת, ונעבור יחד על כל התהליך:
- קריאה וסימון.
- החלטה על Classes.
- אחריות ונתונים לכל Class.
- קשרים בין ה-Classes.
- שלד קוד.
- מימוש.
- בדיקה, ותיקון התכנון.
למה צריך את זה?
בפרויקט ה-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 ── PremiumPlanGymhas manyMemberו-GymClass→ Composition (dicts פנימיים).Memberhas aPlan→ Composition. (מנוי הוא לא סוג של תוכנית!)BasicPlanis aPlan→ 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 ש"ח לדקה.”
המשימה:
- אילו Classes יש כאן? לכל אחת — מה היא יודעת (Attributes).
- אילו כללים יש, ובאיזו Class כל כלל צריך לשבת?
- מה הקשרים ביניהן: מי מחזיק את מי (has-a), ומי סוג של מי (is-a)?
מה מגישים: רשימת Classes עם Attributes, רשימת כללים, ורשימת קשרים.
הצג פתרון
- Classes:
User,Scooter,FastScooter,Ride, ו-RentalServiceשמנהל את כולם. - כללים:
- אין השכרה מתחת ל-20% סוללה.
- אין השכרה של קורקינט שבשימוש.
- מחיר נסיעה: 5 + דקות × תעריף לדקה (1 רגיל, 2 מהיר).
- קשרים:
FastScooterהוא סוג שלScooter(is-a) — רק התעריף שונה.Rideמחזיקה משתמש וקורקינט (has-a).RentalServiceמחזיק הרבה קורקינטים ומשתמשים.
תרגיל 2: Scooter ו-FastScooter
מה בונים: הקורקינט — ומחיר נסיעה שתלוי בסוג שלו.
מה לכתוב:
- Class בשם
Scooter, עם מספר, אחוז סוללה, ו-in_useשמתחיל ב-False. - ב-
Scooter: Class Attribute בשםPER_MINUTEשערכו 1, ו-priceשמקבלת דקות ומחזירה 5 ועוד תעריף לדקה כפול הדקות. - 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
25self.PER_MINUTE קורא את הערך של ה-Class של האובייקט — ולכן FastScooter משתמש ב-2 בלי לדרוס את price בכלל.
תרגיל 3: הכללים במקום אחד
מה בונים: ממשיכים את Scooter — שני הכללים של ההשכרה, במקום אחד.
מה לכתוב:
- ב-
Scooterמוסיפיםunlock. - אם הסוללה מתחת ל-20%:
ValueErrorעם ההודעה הראשונה שבפלט. - אם הקורקינט כבר בשימוש:
ValueErrorעם ההודעה השנייה שבפלט. - אחרת: מסמנים שהקורקינט בשימוש.
קוד לבדיקה:
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: נסיעה
מה בונים: נסיעה — שמחברת משתמש לקורקינט.
מה לכתוב:
- Class בשם
Ride, שמקבלת שם של משתמש וקורקינט ומחזיקה אותם. - ב-
__init__שלRide: קוראים ל-unlockשל הקורקינט — הנסיעה נפתחת רק אם מותר. 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
FalseRide לא בודקת את הכללים בעצמה — היא קוראת ל-unlock, והכללים נשארים בתוך Scooter.
תרגיל 5: שירות ההשכרה
מה בונים: השירות כולו — מחזיק את כל הקורקינטים ויודע אילו פנויים.
מה לכתוב:
- Class בשם
RentalService, שמחזיקה מילון של קורקינטים לפי מספר. add— מוסיפה קורקינט למילון.get— מקבלת מספר ומחזירה את הקורקינט.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) מחברת ביניהן.
- כשפעולה משנה כמה אובייקטים: בודקים את כל התנאים לפני שמשנים משהו.
- דרישה חדשה = עדכון התכנון. ותכנון טוב מאפשר להוסיף בלי לשבור.
דוקומנטציה רשמית
- A First Look at Classes — מהגדרה למחלקה
- Class and Instance Variables — אילו נתונים שייכים לאובייקט