Практика с сопровождением: от словесного требования к структуре классов

Цели урока
  • Пройти полный процесс проектирования — от текста требования до работающего кода
  • Документировать проектные решения — почему X является классом, почему Y принадлежит Z
  • Рисовать отношения между классами — has-a, is-a и количество (один ко многим)
  • Реализовывать по проекту, а при тестировании находить и исправлять дыры в проекте

Что мы изучим?

Это урок-мастерская. Мы возьмём одно словесное требование и вместе пройдём весь процесс:

  1. Чтение и разметка.
  2. Решение о классах.
  3. Ответственность и данные для каждого класса.
  4. Отношения между классами.
  5. Скелет кода.
  6. Реализация.
  7. Тестирование и исправление проекта.

Зачем это нужно?

В проекте ООП, который вы построите позже, до написания кода нужен этап проектирования (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 ── PremiumPlan
  • Gym имеет много 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 шекеля в минуту.»

Задание:

  1. Какие здесь классы? Для каждого — что он знает (атрибуты)?
  2. Какие есть правила и в каком классе должно находиться каждое правило?
  3. Каковы отношения между ними: кто хранит кого (has-a) и кто является разновидностью кого (is-a)?

Что сдать: список классов с атрибутами, список правил и список отношений.

Показать решение
  1. Классы: User, Scooter, FastScooter, Ride и RentalService, который управляет ими всеми.
  2. Правила:
    • Нельзя арендовать при заряде ниже 20%.
    • Нельзя арендовать самокат, который используется.
    • Цена поездки: 5 + минуты × тариф за минуту (1 обычный, 2 быстрый).
  3. Отношения:
    • FastScooter является разновидностью Scooter (is-a) — отличается только тариф.
    • Ride хранит пользователя и самокат (has-a).
    • RentalService хранит много самокатов и пользователей.

Упражнение 2: Scooter и FastScooter

Что вы строите: самокат — и цену поездки, которая зависит от его типа.

Что написать:

  1. Класс с именем Scooter с номером, процентом заряда и in_use, который начинается как False.
  2. В Scooter: атрибут класса с именем PER_MINUTE со значением 1 и price, который получает минуты и возвращает 5 плюс тариф за минуту, умноженный на минуты.
  3. Класс с именем 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 читает значение из класса объекта — поэтому 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. Класс с именем 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. Класс с именем 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 — используется. Мы добавили can_rent() в Scooter, чтобы сервис спрашивал самокат, а не проверял его данные сам.

Итоги

  • Процесс проектирования: разметка → классы → ответственность и данные → отношения → скелет → реализация → тестирование.
  • Каждая строка требования должна «приземлиться» в класс и метод. Каждому классу нужна причина.
  • Разное поведение по типу → иерархия + полиморфизм (Plan). То, что может меняться в течение жизни объекта, → композиция (member.plan).
  • Каждый класс проверяет свои правила, а координирующий класс (Gym) их соединяет.
  • Когда действие меняет несколько объектов: проверьте все условия, прежде чем что-либо менять.
  • Новое требование = обновление проекта. А хороший проект позволяет добавлять, ничего не ломая.

Официальная документация