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

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

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

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 — отношения

```text
            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 — скелет

```python
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`:

```python
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 — тестирование. В конце файла:

```python
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()
```

**Вывод:**

```text
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:

```python
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")
```

```text
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, двойную запись и превышение квоты. Без них мы бы знали только, что работает «счастливый путь».

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

**Что вы отрабатываете на этой странице:** весь путь от словесного требования до кода — на примере сервиса проката самокатов. Каждое упражнение — один шаг процесса: сначала проектирование, затем класс за классом.

**К концу страницы вы будете уметь:**
- Разбивать требование на классы, правила и отношения до написания кода
- Строить маленькую иерархию: родителя и потомка, который меняет только тариф
- Помещать каждое правило в класс, который хранит его данные
- Соединять несколько классов в одну работающую систему

### Упражнение 1: От требования к списку классов

**Что вы делаете:** этап проектирования — без кода.

**Требование:**

> «Система проката самокатов: **пользователь** арендует **самокат** для **поездки**. У каждого самоката есть номер и заряд батареи в процентах. Нельзя арендовать самокат с зарядом меньше 20% или самокат, который уже используется. Поездка стоит 5 шекелей за разблокировку плюс 1 шекель в минуту. **Быстрый самокат** стоит 2 шекеля в минуту.»

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

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

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

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

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

**Что написать:**
1. Класс с именем `Scooter` с номером, процентом заряда и `in_use`, который начинается как `False`.
2. В `Scooter`: атрибут класса с именем `PER_MINUTE` со значением 1 и `price`, который получает минуты и возвращает 5 плюс тариф за минуту, умноженный на минуты.
3. Класс с именем `FastScooter`, который наследует `Scooter` и меняет только `PER_MINUTE` на 2.

**Проверочный код:**

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

**Желаемый вывод:**

```text
15
25
```

{{< solution >}}
```python
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))
```

```text
15
25
```

`self.PER_MINUTE` читает значение из класса **объекта** — поэтому `FastScooter` использует 2, вообще не переопределяя `price`.
{{< /solution >}}

### Упражнение 3: Правила в одном месте

**Что вы строите:** продолжение `Scooter` — два правила аренды в одном месте.

**Что написать:**
1. В `Scooter` добавьте `unlock`.
2. Если заряд ниже 20%: `ValueError` с первым сообщением из вывода.
3. Если самокат уже используется: `ValueError` со вторым сообщением из вывода.
4. Иначе: отметьте самокат как используемый.

**Проверочный код:**

```python
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)
```

**Желаемый вывод:**

```text
error: battery too low: 15%
True
error: scooter 1 is in use
```

{{< solution >}}
```python
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)
```

```text
error: battery too low: 15%
True
error: scooter 1 is in use
```
{{< /solution >}}

### Упражнение 4: Поездка

**Что вы строите:** поездку — которая соединяет пользователя с самокатом.

**Что написать:**
1. Класс с именем `Ride`, который получает имя пользователя и самокат и хранит их.
2. В `__init__` у `Ride`: вызовите `unlock` самоката — поездка открывается, только если это разрешено.
3. `finish` — получает минуты, освобождает самокат и возвращает цену поездки.

**Проверочный код:**

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

**Желаемый вывод:**

```text
True
29
False
```

{{< solution >}}
```python
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)
```

```text
True
29
False
```

`Ride` сам не проверяет правила — он вызывает `unlock`, и правила остаются внутри `Scooter`.
{{< /solution >}}

### Упражнение 5: Сервис проката

**Что вы строите:** весь сервис — он хранит все самокаты и знает, какие из них свободны.

**Что написать:**
1. Класс с именем `RentalService`, который хранит словарь самокатов по номерам.
2. `add` — добавляет самокат в словарь.
3. `get` — получает номер и возвращает самокат.
4. `available` — возвращает список номеров самокатов, которые можно арендовать прямо сейчас: заряд не меньше 20% и не используется.

**Проверочный код:**

```python
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())
```

**Желаемый вывод:**

```text
[1]
```

{{< solution >}}
```python
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())
```

```text
[1]
```

Самокат 2 — низкий заряд. Самокат 3 — используется. Мы добавили `can_rent()` в `Scooter`, чтобы сервис **спрашивал** самокат, а не проверял его данные сам.
{{< /solution >}}

<!-- exercises:end -->

## Итоги

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

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

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

- [A First Look at Classes](https://docs.python.org/3/tutorial/classes.html#a-first-look-at-classes)
- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables)

<!-- docs:end -->
