Практика с сопровождением: от словесного требования к структуре классов
- Пройти полный процесс проектирования — от текста требования до работающего кода
- Документировать проектные решения — почему X является классом, почему Y принадлежит Z
- Рисовать отношения между классами — has-a, is-a и количество (один ко многим)
- Реализовывать по проекту, а при тестировании находить и исправлять дыры в проекте
Что мы изучим?
Это урок-мастерская. Мы возьмём одно словесное требование и вместе пройдём весь процесс:
- Чтение и разметка.
- Решение о классах.
- Ответственность и данные для каждого класса.
- Отношения между классами.
- Скелет кода.
- Реализация.
- Тестирование и исправление проекта.
Зачем это нужно?
В проекте ООП, который вы построите позже, до написания кода нужен этап проектирования (Design). Этот урок — генеральная репетиция: ровно тот же процесс, на другой задаче, под руководством преподавателя.
Объяснение с основ
Требование
Система для спортзала «KodFit»
В спортзале есть участники. У каждого участника есть имя, номер участника и тип абонемента: Basic (до 8 посещений в месяц) или Premium (без ограничений). Premium стоит 250 шекелей в месяц, а Basic — 150.
В спортзале есть занятия (йога, спиннинг…). У каждого занятия есть название, тренер, день и время и ограниченное число мест.
Участник может записаться на занятие, если есть место и если он не исчерпал месячную квоту посещений. Запись считается посещением. Участник может отменить запись, и посещение к нему возвращается.
Система должна показывать: все занятия со свободными местами, занятия определённого участника и месячную выручку от всех участников.
Синтаксис
Нового синтаксиса нет. Мы используем всё, что изучили: классы, __init__, свойства, инкапсуляцию, наследование + полиморфизм, композицию и собственные исключения.
Первый пример кода
Шаг 1 — чтение и разметка
| Существительные | Глаголы | Правила и числа |
|---|---|---|
| Спортзал, участник, имя, номер участника, тип абонемента, Basic, Premium, занятие, тренер, день и время, места, запись, посещение, выручка | Записаться, отменить, показать | Basic: до 8 посещений, 150 шекелей. Premium: без ограничений, 250 шекелей. Ограниченные места. Запись = посещение. Отмена возвращает посещение |
Шаг 2 — какие будут классы?
| Кандидат | Решение | Обоснование |
|---|---|---|
| Участник | ✓ Класс Member | Идентичность, данные, правила (квота). |
| Тип абонемента | ✓ Иерархия: Plan → BasicPlan, PremiumPlan | У каждого типа разное поведение (цена, лимит), а это ровно полиморфизм. Если в будущем добавится «Student», добавят класс. |
| Занятие | ✓ Класс GymClass | Идентичность, данные, правило (места). (Class — проблемное имя, поэтому GymClass.) |
| Запись | ✗ Не отдельный класс (пока) | У неё нет собственных данных, кроме «кто и куда». Достаточно, чтобы занятие хранило своих участников. |
| Спортзал | ✓ Класс Gym | Хранит всех участников и занятия и координирует между ними. |
| Тренер, день и время, название | Атрибуты | Значения без поведения. |
| Выручка | Метод | Вычисляется из суммы цен. |
Шаг 3 — ответственность и данные
| Класс | Ответственность (одно предложение) | Данные | Интерфейс |
|---|---|---|---|
Plan | Определяет цену и лимит посещений типа абонемента | — | monthly_price(), visit_limit() |
Member | Управляет месячной квотой одного участника | Имя, номер, план, использованные посещения | 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 ── PremiumPlanGymимеет многоMemberиGymClass→ композиция (внутренние словари).MemberимеетPlan→ композиция. (Участник не является разновидностью плана!)BasicPlanявляетсяPlan→ наследование + полиморфизм.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(). |
Каждая строка требования «приземляется» в класс и метод. Ни одной строки без дома и ни одного класса без причины.
Ещё один пример
Шаг 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: наследование вместо композиции — class PremiumMember(Member) выглядит естественно, но что будет, когда участник повысит абонемент с Basic до Premium? Нельзя «сменить класс» существующего объекта. С композицией (member.plan = PremiumPlan()) это одна строка. То, что может меняться в течение жизни объекта, → композиция.
Ошибка 3: правило проверяется не в том месте. Если бы GymClass.add ещё и проверял квоту участника, занятию пришлось бы «знать» правила планов. Каждый класс проверяет свои правила, а Gym координирует.
Ошибка 4: не тестировать случаи ошибок. Проверка включала несуществующие ID, двойную запись и превышение квоты. Без них мы бы знали только, что работает «счастливый путь».
Практика
Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.
Что вы отрабатываете на этой странице: весь путь от словесного требования до кода — на примере сервиса проката самокатов. Каждое упражнение — один шаг процесса: сначала проектирование, затем класс за классом.
К концу страницы вы будете уметь:
- Разбивать требование на классы, правила и отношения до написания кода
- Строить маленькую иерархию: родителя и потомка, который меняет только тариф
- Помещать каждое правило в класс, который хранит его данные
- Соединять несколько классов в одну работающую систему
Упражнение 1: От требования к списку классов
Что вы делаете: этап проектирования — без кода.
Требование:
«Система проката самокатов: пользователь арендует самокат для поездки. У каждого самоката есть номер и заряд батареи в процентах. Нельзя арендовать самокат с зарядом меньше 20% или самокат, который уже используется. Поездка стоит 5 шекелей за разблокировку плюс 1 шекель в минуту. Быстрый самокат стоит 2 шекеля в минуту.»
Задание:
- Какие здесь классы? Для каждого — что он знает (атрибуты)?
- Какие есть правила и в каком классе должно находиться каждое правило?
- Каковы отношения между ними: кто хранит кого (has-a) и кто является разновидностью кого (is-a)?
Что сдать: список классов с атрибутами, список правил и список отношений.
Показать решение
- Классы:
User,Scooter,FastScooter,RideиRentalService, который управляет ими всеми. - Правила:
- Нельзя арендовать при заряде ниже 20%.
- Нельзя арендовать самокат, который используется.
- Цена поездки: 5 + минуты × тариф за минуту (1 обычный, 2 быстрый).
- Отношения:
FastScooterявляется разновидностьюScooter(is-a) — отличается только тариф.Rideхранит пользователя и самокат (has-a).RentalServiceхранит много самокатов и пользователей.
Упражнение 2: Scooter и FastScooter
Что вы строите: самокат — и цену поездки, которая зависит от его типа.
Что написать:
- Класс с именем
Scooterс номером, процентом заряда иin_use, который начинается какFalse. - В
Scooter: атрибут класса с именемPER_MINUTEсо значением 1 иprice, который получает минуты и возвращает 5 плюс тариф за минуту, умноженный на минуты. - Класс с именем
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 читает значение из класса объекта — поэтому 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: Поездка
Что вы строите: поездку — которая соединяет пользователя с самокатом.
Что написать:
- Класс с именем
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: Сервис проката
Что вы строите: весь сервис — он хранит все самокаты и знает, какие из них свободны.
Что написать:
- Класс с именем
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 — используется. Мы добавили can_rent() в Scooter, чтобы сервис спрашивал самокат, а не проверял его данные сам.
Итоги
- Процесс проектирования: разметка → классы → ответственность и данные → отношения → скелет → реализация → тестирование.
- Каждая строка требования должна «приземлиться» в класс и метод. Каждому классу нужна причина.
- Разное поведение по типу → иерархия + полиморфизм (Plan). То, что может меняться в течение жизни объекта, → композиция (member.plan).
- Каждый класс проверяет свои правила, а координирующий класс (Gym) их соединяет.
- Когда действие меняет несколько объектов: проверьте все условия, прежде чем что-либо менять.
- Новое требование = обновление проекта. А хороший проект позволяет добавлять, ничего не ломая.