# Классы и объекты: от проблемы к полноценному классу<no value>

## В этом уроке

Урок построен как одна история, в том же порядке, что и в презентации. Мы начинаем с маленькой банковской системы на словарях и функциях, видим, где она ломается, и в каждой главе добавляем один инструмент, который решает одну проблему:

1. **Глава 1** — зачем нужно ООП: проблемы процедурного кода
2. **Глава 2** — класс и объект: шаблон и экземпляры
3. **Глава 3** — `__init__` и `self`: объект, который рождается полноценным
4. **Глава 4** — методы: объект не только знает, но и делает
5. **Глава 5** — общие данные: атрибуты класса
6. **Глава 6** — что становится классом: от словесного требования к классам
7. **Глава 7** — назад в банк: всё вместе

В конце урока — практика и **мини-проект**, который связывает все главы.

## Глава 1: Зачем нужно ООП — процедурный код и объекты

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

Вся эта часть курса посвящена **ООП** — **объектно-ориентированному программированию** (Object-Oriented Programming). Прежде чем написать хоть одну строку кода в стиле ООП, ответим на самый важный вопрос: **зачем?**

В этой главе:

- Напишем маленькую систему **без** ООП — тем способом, который уже знаем.
- Увидим, **где** она начинает становиться проблемной.
- Поймём **идею** ООП как решения — пока без синтаксиса.

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

Всё, что мы изучили до сих пор, работает. Любую программу можно написать с переменными, словарями, списками и функциями. Это называется **процедурным программированием** (Procedural Programming): **данные** с одной стороны, **функции**, которые над ними действуют, — с другой.

Но когда система растёт до десятков видов «вещей» и сотен функций, начинаются проблемы. ООП — способ, которым строятся почти все большие системы. И инструменты, которыми вы будете пользоваться (FastAPI, библиотеки Python), тоже написаны в стиле ООП. Чтобы их читать и использовать, нужно его понимать.

### Объяснение с основ

#### Проблема: система банковских счетов — без ООП

Построим маленькую систему управления банковскими счетами тем, что уже знаем:

```python
account1 = {"owner": "Dana", "balance": 1000}
account2 = {"owner": "Avi", "balance": 500}

def deposit(account, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    account["balance"] = account["balance"] + amount

def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("insufficient funds")
    account["balance"] = account["balance"] - amount

deposit(account1, 200)
withdraw(account2, 100)
print(account1, account2)
```

```text
{'owner': 'Dana', 'balance': 1200} {'owner': 'Avi', 'balance': 400}
```

Работает! Так в чём проблема? Давайте увеличим систему.

#### Проблема 1: кто угодно может «трогать» данные

```python
account1["balance"] = -999999
account2["balnce"] = 100
```

- Первая строка **обходит** все проверки, которые мы написали в `withdraw`. Отрицательный баланс без всякой проверки.
- Во второй строке опечатка. Создан новый ключ `"balnce"`, и никакой ошибки нет (мы видели это в теме структур данных и функций).

**Правила** (не снимать больше, чем есть, положительная сумма) существуют, только если все помнят, что нужно пользоваться функциями. Ничто их к этому **не принуждает**.

#### Проблема 2: данные и действия разбросаны

Каждое действие со счётом — отдельная функция, которая получает словарь: `deposit(account, ...)`, `withdraw(account, ...)`, `transfer(...)`, `monthly_fee(...)`, `print_statement(...)`. В настоящей системе это **десятки функций** в нескольких файлах. Тот, кто получил словарь счёта, не знает, какие действия с ним существуют и где они.

#### Проблема 3: нет определённой «структуры»

Как выглядит счёт? Что **обязано** в нём быть? Возможно, в одном месте кода кто-то создал `{"owner": ..., "balance": ...}`, а в другом — `{"name": ..., "money": ...}`. Нет **одного** места, которое определяет: «это банковский счёт, вот его поля, а вот его действия».

#### Проблема 4: похожие, но разные виды

Добавили «сберегательный счёт»: как обычный, но с процентами и без возможности снять ниже 1000. Теперь `withdraw` должна проверять: `if account["type"] == "savings": ... elif ...`. И с каждым новым видом счёта **все** функции наполняются `if`.

#### Идея ООП

**ООП организует код вокруг «вещей» (объектов), а не вокруг действий.** Каждая «вещь» **содержит вместе**:

- Свои **данные**: **атрибуты** (Attributes). Для счёта: владелец, баланс.
- **Действия**, которые с ней можно делать: **методы** (Methods). Для счёта: внести, снять.

```text
PROCEDURAL                          OBJECT-ORIENTED
data:      account dict             ┌──────────────────────────┐
functions: deposit(account, x)      │ BankAccount              │
           withdraw(account, x)     │   data:    owner, balance│
           print_statement(account) │   actions: deposit()     │
                                    │            withdraw()    │
"functions act ON data"             │            statement()   │
                                    └──────────────────────────┘
                                    "the object knows its own data
                                     and its own actions"
```

И как это решает проблемы?

| Проблема | Решение ООП | Изучим в... |
|---|---|---|
| Кто угодно трогает данные | Объект **защищает** свои данные, и изменения проходят через его действия (**инкапсуляция**, Encapsulation) | Следующем уроке |
| Данные и действия разбросаны | Всё **внутри** класса. `account.` + Tab в VS Code показывает всё, что можно сделать | Сегодня |
| Нет определённой структуры | **Один класс** точно определяет, какие данные есть у каждого счёта | Сегодня |
| Похожие виды | **Наследование** и **полиморфизм**: сберегательный счёт «наследует» обычный и меняет только то, что отличается | Уроках о наследовании и полиморфизме |

#### Шаблон и конкретная вещь

Самая базовая идея ООП уже существует в реальном мире:

| Шаблон (Class) | Конкретные вещи, созданные по нему (Objects) |
|---|---|
| **Проект** квартиры | Квартира Даны на 3-м этаже, квартира Ави на 5-м этаже |
| **Анкета** «данные студента» | Анкета, которую заполнила Дана, анкета, которую заполнил Ави |
| **Шаблон** производства машин на конвейере | Красная машина с номером 12-345-67 |
| **Понятие** «банковский счёт» | Счёт Даны с 1200 шекелей, счёт Ави с 400 шекелей |

- У всех квартир, построенных по одному проекту, **одинаковая структура** (комнаты, кухня), но **разные значения** (цвет стен, кто живёт).
- Изменение квартиры Даны **не меняет** квартиру Ави.
- Сам проект **не является квартирой**. В проекте жить нельзя.

{{< box "analogy" "Главная аналогия для всей части ООП" >}}
**Класс (Class) = шаблон для производства машин.** Он определяет, что у каждой машины есть цвет, модель и номер (**атрибуты**) и что каждая машина умеет ехать, тормозить и сигналить (**методы**).
**Объект (Object) = одна конкретная машина**, произведённая по шаблону: красная, модель 2024, номер 12-345-67. По нему можно произвести тысячи машин, у каждой свой цвет и номер.
{{< /box >}}

### Синтаксис

Класс мы пока не пишем. В следующей главе изучим полный синтаксис. Но вот «вкус», чтобы увидеть, куда мы идём. Та же банковская система в стиле ООП:

```python
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance = self.balance - amount

account1 = BankAccount("Dana", 1000)
account1.deposit(200)
print(account1.balance)
```

**Не пытайтесь сейчас понять каждое слово** (`class`, `__init__`, `self`) — каждому из них дальше в уроке посвящена целая глава. Обратите внимание только на одно: **данные (`owner`, `balance`) и действия (`deposit`, `withdraw`) находятся в одном месте**, и вызывают их **через** объект: `account1.deposit(200)`.

### Первый пример кода

Вы всё время пользовались объектами, сами того не зная! В Python **каждое значение — объект** со своими данными и методами:

```python
name = "dana cohen"
print(name.title())
print(name.upper())
print(type(name))

numbers = [3, 1, 2]
numbers.append(5)
numbers.sort()
print(numbers)
print(type(numbers))
```

**Вывод:**

```text
Dana Cohen
DANA COHEN
<class 'str'>
[1, 2, 3, 5]
<class 'list'>
```

### Разбор кода построчно

| Код | Что это в ООП |
|---|---|
| `name = "dana cohen"` | Создан **объект** типа `str`. Его данные — символы. |
| `name.title()` | Вызов **метода** `title` объекта. Он «умеет» работать со **своим** текстом. |
| `type(name)` → `<class 'str'>` | **Класс** (шаблон) объекта — `str`. |
| `numbers.append(5)` | Метод объекта `list`, который меняет **его собственные данные**. |
| `<class 'list'>` | `list` — тоже класс. Каждый созданный вами список — объект класса `list`. |

**Ключевой момент:** `str` и `list` — **классы**, которые Python написал для нас. В этой части мы научимся писать **собственные классы**: `BankAccount`, `Student`, `Product`, подходящие для нашей задачи.

### Ещё один пример

**Ещё одна процедурная проблема: система студентов.** Возьмите следующий код и попробуйте сами найти проблемы.

```python
student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def add_grade(student, grade):
    student["grades"].append(grade)

def average(student):
    return sum(student["grades"]) / len(student["grades"])

add_grade(student1, 100)
student2["grades"].append(500)
print(average(student1))
print(average(student2))
print(average(student3))
```

```text
91.66666666666667
285.0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero
```

**Проблемы:**

1. `student3` создан с **опечаткой** (`"nmae"`). Нет места, которое определяет, что «должно» быть у студента.
2. `student2["grades"].append(500)` **обошёл** `add_grade`. Даже если бы `add_grade` проверяла, что оценка от 0 до 100, проверка бы не сработала. Среднее 285!
3. `average` падает на студенте без оценок. И тот, кто пользуется словарём студента, должен **знать**, что эта функция существует, и не забыть обработать этот случай.

В ООП `Student` будет одним классом с **определённой структурой** (имя и оценки, **всегда**), с методом `add_grade`, который будет **единственным способом** добавить оценку (и проверит её), и с методом `average`, который умеет обращаться с пустым списком. **Правила находятся рядом с данными, которые они защищают.**

### Итоги

- **Процедурное программирование**: данные (словари) и функции отдельно. Работает, но по мере роста системы данные открыты, действия разбросаны и нет обязательной структуры.
- **ООП**: организуйте код вокруг **вещей**. Каждый объект хранит **данные** (атрибуты) и **действия** (методы) вместе.
- **Класс** (Class) = шаблон (проект здания, шаблон производства). **Объект** (Object) = конкретная вещь, созданная по нему.
- В Python **каждое значение — объект**: `str` и `list` — классы, а `"hi".upper()` — вызов метода.
- ООП — **способ мышления**: какие вещи существуют, что они знают и что делают. Не всё обязано быть классом.

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

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

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)

<!-- docs:end -->

## Глава 2: Класс и объект — определяем шаблон и создаём по нему объекты

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

- Что такое **класс** (Class) и что такое **объект** (Object, его также называют **экземпляром** — Instance).
- Что означает слово **`class`**.
- **Синтаксис** определения класса.
- Как **создать объект** из класса.
- Почему каждый объект **независим**.

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

В предыдущей главе мы увидели, что проблема «словарь + функции» в том, что нет **определённой структуры** и нет одного места, которое собирает данные и действия. Класс — инструмент, который создаёт эту структуру. Всё остальное (`__init__`, `self`, методы) строится на нём.

### Объяснение с основ

#### Что такое класс?

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

- Класс **не** содержит данных конкретной вещи. Это только **определение**.
- Из одного класса можно создать **сколько угодно вещей**.
- Определяя класс, вы фактически создаёте **новый тип** в Python, точно как `str` или `list`.

#### Что такое объект (экземпляр)?

**Объект** (Object) — **конкретная вещь, созданная из класса**. У него есть **собственные** данные.

**Экземпляр** (Instance) — синоним: «объект **класса** `Student`» = «**экземпляр** `Student`». Действие создания объекта из класса называется **созданием экземпляра** (Instantiation).

| | Класс | Объект / экземпляр |
|---|---|---|
| Что это | Шаблон, определение | Конкретная вещь |
| Сколько | **Один** на каждый вид | **Сколько угодно** |
| Данные | Определяет, **какие** поля будут | Хранит конкретные **значения** |
| Пример | `Student` | Дана, Ави, Ноа |
| Аналогия | Шаблон производства машин | Красная машина с номером 12-345-67 |

#### Слово `class`

`class` — **зарезервированное слово** (Keyword) в Python, как `def`, `if` и `for`. Оно говорит Python: «здесь начинается определение **нового вида**». После него идёт **имя** класса, а затем — **тело** с отступом.

#### Имена классов

По PEP 8 имена классов пишутся в **PascalCase**: каждое слово начинается с заглавной буквы, без подчёркиваний. `Student`, `BankAccount`, `ShoppingCart`. Это отличается от функций и переменных (`snake_case`), и так сразу видно, что перед вами класс.

### Синтаксис

```python
class ClassName:
    # class body - indented
    pass

my_object = ClassName()
```

| Часть | Объяснение |
|---|---|
| `class` | Зарезервированное слово: «начинается определение класса». |
| `ClassName` | Имя класса в PascalCase. |
| `:` | Обязательно, как после `def` или `if`. |
| Тело с отступом | То, что включает шаблон (в следующих главах: `__init__` и методы). |
| `pass` | «Ничего не делать». Тело класса (или функции) не может быть пустым, поэтому `pass` — заполнитель. |
| `ClassName()` | **Создание объекта**: класс вызывают **со скобками**, как функцию. Получается новый объект. |

### Первый пример кода

`week-3/first_class.py`:

```python
class Person:
    pass

dana = Person()
avi = Person()

print(dana)
print(avi)
print(type(dana))
print(dana is avi)
print(isinstance(dana, Person))
```

**Вывод:**

```text
<__main__.Person object at 0x000001F4A3B2C1D0>
<__main__.Person object at 0x000001F4A3B2C2E0>
<class '__main__.Person'>
False
True
```

### Разбор кода построчно

| Строка | Объяснение |
|---|---|
| `class Person:` | Определяет **новый вид** с именем `Person`. Ни один человек ещё не создан. |
| `pass` | Шаблон пока пуст. |
| `dana = Person()` | **Создание экземпляра**: создан новый объект типа `Person`, и имя `dana` указывает на него. |
| `avi = Person()` | **Ещё один** объект, полностью отдельный. |
| `print(dana)` | У объекта нет «содержимого» для показа, поэтому Python печатает описание по умолчанию: тип и адрес в памяти (`0x...`). У вас адрес будет другим. |
| Два разных адреса | Два отдельных объекта в RAM. |
| `type(dana)` | `<class '__main__.Person'>`. Класс объекта — `Person`, определённый в главном файле (`__main__`). |
| `dana is avi` | `False`: это не один и тот же объект (вспомните `is`!). |
| `isinstance(dana, Person)` | `True`: «является ли `dana` экземпляром `Person`?» |

### Ещё один пример

**Добавляем данные объекту — и видим, почему так делать не стоит.** К объекту можно прикрепить **атрибут** снаружи, через точку:

```python
class Product:
    pass

laptop = Product()
laptop.name = "Laptop"
laptop.price = 3500

mouse = Product()
mouse.name = "Mouse"
mouse.price = 80

print(laptop.name, laptop.price)
print(mouse.name, mouse.price)

mouse.price = 70
print("After update:", mouse.price, "| laptop unchanged:", laptop.price)
```

**Вывод:**

```text
Laptop 3500
Mouse 80
After update: 70 | laptop unchanged: 3500
```

**Разбор:**

- `laptop.name = "Laptop"` — создаёт **атрибут** с именем `name` **у объекта `laptop`**. Точка означает «у»: `laptop.name` = «имя ноутбука».
- У каждого объекта **свои** атрибуты. Изменение `mouse.price` не трогает `laptop`.
- **Это уже лучше словаря:** `laptop.price` читается лучше, чем `laptop["price"]`.

**Но здесь большая проблема.** Что гарантирует, что у каждого `Product` есть `name` и `price`?

```python
keyboard = Product()
keyboard.nmae = "Keyboard"
print(keyboard.name)
```

```text
AttributeError: 'Product' object has no attribute 'name'
```

- Опечатка (`nmae`) создала другой атрибут.
- Мы забыли определить `price`, и предупреждения нет, пока кто-нибудь не попытается его использовать.
- Это **ровно проблема словаря** из предыдущей главы! Пустой класс всё ещё не определяет **структуру**.

**Решение: сам класс должен определять, какие данные обязаны быть у каждого объекта, и заполнять их в момент создания.** Это роль **`__init__`** — тема следующей главы.

### Итоги

- **Класс** (Class) = шаблон, определяющий **новый вид**. **Объект / экземпляр** (Object / Instance) = конкретная вещь, созданная по нему.
- Синтаксис: `class Name:` + тело с отступом (или `pass`). Имена в **PascalCase**.
- **Создание объекта**: `obj = Name()`, **со скобками**. Без скобок — это сам класс.
- Каждый объект независим: `a is b` → `False`. Атрибуты одного не влияют на другой.
- `type(obj)` возвращает класс. `isinstance(obj, Name)` проверяет принадлежность.
- Добавление атрибутов «снаружи» работает, но **не определяет структуру**. Поэтому нужен `__init__`.

<!-- 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 Objects](https://docs.python.org/3/tutorial/classes.html#class-objects)

<!-- docs:end -->

## Глава 3: `__init__` и `self` — объект, который рождается полноценным

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

- Что такое **`__init__`** — функция, которая выполняется автоматически при создании объекта.
- Что такое **конструктор** (Constructor).
- Что такое **`self`** — слово, которое встречается в ООП повсюду.
- **Атрибуты экземпляра** (Instance Attributes): данные каждого объекта.

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

В предыдущей главе мы добавляли атрибуты «снаружи»: `laptop.name = "Laptop"`. Мы увидели, что это опасно: можно забыть поле, сделать опечатку, и нет обязательной структуры. Мы даже написали вспомогательную функцию `make_book`, чтобы гарантировать, что у каждой книги есть `title` и `pages`.

**`__init__` — это именно тот `make_book`, только внутри класса.** Он гарантирует, что **каждый** объект создаётся **инициализированным**: со всеми полями и со значениями — с самого первого момента.

### Объяснение с основ

#### `__init__` — метод инициализации

**`__init__`** (произносится «дандер инит», **d**ouble **under**score **init**) — специальная функция, определённая **внутри** класса. Она:

- **Вызывается автоматически** в момент создания нового объекта: `Student(...)`.
- **Инициализирует** объект, то есть кладёт в него начальные данные.
- **Ничего не возвращает** (`return` не нужен).

Имя с двумя подчёркиваниями с каждой стороны обозначает **специальный метод** Python (его называют **Dunder Method** или **Magic Method**). Python ищет именно это имя и вызывает его сам. Вы никогда не пишете `obj.__init__()` сами.

#### Конструктор

**Конструктор** (Constructor) = код, который выполняется при построении нового объекта. В Python эту роль играет `__init__`, поэтому его обычно называют «конструктором». (Если быть технически точным: само построение, выделение объекта в памяти, происходит на шаг раньше, а `__init__` «лишь» заполняет его. Для целей курса `__init__` = конструктор.)

#### `self` — «этот объект»

**`self`** — **первый параметр** каждого метода в классе, включая `__init__`. Он указывает на **конкретный объект**, с которым метод работает прямо сейчас.

Зачем он нужен? Класс — **один шаблон**, но из него создаётся **много** объектов. Когда код в `__init__` говорит «сохрани имя», ему нужно знать, **в каком объекте** сохранить. `self` — ответ: «в объекте, который создаётся **сейчас**».

```python
self.name = name
```

Читайте так: «**в** этом объекте (`self`) создай атрибут с именем `name` и положи в него значение, полученное в параметре `name`».

- `self.name` — **атрибут** объекта. Он остаётся, пока существует объект.
- `name` (отдельно) — **параметр** `__init__`: **локальная** переменная (Local, вспомните область видимости!), которая исчезает в конце `__init__`.

**Откуда берётся `self`?** **Python передаёт его автоматически!** Когда вы пишете `Student("Dana", 24)`, вы передаёте 2 аргумента, а `__init__` получает 3: Python добавляет новый объект как `self` в начало.

{{< box "analogy" "Аналогия: регистрационная анкета" >}}
Класс — это **пустая анкета** с полями: «Имя: ____ Возраст: ____». `__init__` — **сотрудник регистратуры**, который заполняет новую анкету для каждого пришедшего студента. `self` — **«анкета, которая сейчас лежит на столе»**: сотрудник пишет «Имя: Дана» **на этой анкете**, а не на всех анкетах в шкафу. Завтра придёт Ави, будет заполнена **другая** анкета, но сотрудник снова назовёт её «анкетой на столе» (`self`).
{{< /box >}}

#### Что происходит в строке `dana = Student("Dana", 24)`

1. Python **создаёт новый пустой объект** типа `Student` в памяти.
2. Python **вызывает `__init__`** и передаёт ему: `self` = новый объект, `name = "Dana"`, `age = 24`.
3. `__init__` выполняет `self.name = name`, то есть `name` нового объекта = `"Dana"`. И то же самое для `age`.
4. `__init__` завершается. Локальные параметры исчезают, а атрибуты остаются на объекте.
5. Python **возвращает объект**, и имя `dana` указывает на него.

#### Атрибуты экземпляра

**Атрибуты экземпляра** (Instance Attributes) = данные, которые принадлежат **конкретному объекту**. У каждого объекта свои значения. Их определяют в `__init__` через `self.something = ...`.

### Синтаксис

```python
class ClassName:
    def __init__(self, param1, param2):
        self.attribute1 = param1
        self.attribute2 = param2
        self.attribute3 = "default value"   # attributes do not have to come from parameters

obj = ClassName(arg1, arg2)    # Python calls __init__(obj, arg1, arg2) for you
obj.attribute1                 # read an instance attribute
```

- `__init__` пишется **внутри** класса, с отступом.
- `self` — **всегда первый** в списке параметров.
- При вызове `self` **не передают**.
- Параметрам можно давать значения по умолчанию, как в любой функции: `def __init__(self, name, age=18):`.

### Первый пример кода

`week-3/student.py`:

```python
class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.grades = []

dana = Student("Dana", 24)
avi = Student("Avi", 31)

print(dana.name, dana.age, dana.grades)
print(avi.name, avi.age, avi.grades)

dana.grades.append(95)
print("Dana's grades:", dana.grades)
print("Avi's grades:", avi.grades)
```

**Вывод:**

```text
Dana 24 []
Avi 31 []
Dana's grades: [95]
Avi's grades: []
```

### Разбор кода построчно

| Строка | Объяснение |
|---|---|
| `class Student:` | Определение шаблона. |
| `def __init__(self, name, age):` | Конструктор. `self` = создаваемый объект. `name`, `age` = то, что передают при создании. |
| `self.name = name` | Атрибут `name` **у объекта** со значением из параметра. |
| `self.age = age` | То же для возраста. |
| `self.grades = []` | Атрибут, который не приходит из параметра: **каждый** студент начинает с **собственного** пустого списка оценок. |
| `dana = Student("Dana", 24)` | Python: создаёт объект, вызывает `__init__(new_obj, "Dana", 24)` и возвращает его. |
| `avi = Student("Avi", 31)` | Второй объект. `__init__` выполняется **снова**, с `self` = **второй** объект. |
| `dana.grades.append(95)` | Обращение к списку **Даны** и добавление в него. |
| `avi.grades` остаётся `[]` | Каждый `__init__` создал **новый** список, поэтому общего нет. (Сравните с багом `def f(cart=[])` из урока о функциях!) |

**Теперь структура гарантирована:** нельзя создать `Student` без имени и возраста, и у каждого студента всегда есть `grades`.

### Ещё один пример

**Product со значениями по умолчанию, вычисляемым атрибутом и первой проверкой.** `week-3/product.py`:

```python
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative, got " + str(price))
        self.name = name
        self.price = price
        self.quantity = quantity
        self.in_stock = quantity > 0

laptop = Product("Laptop", 3500, 4)
cable = Product("USB Cable", 25)

print(laptop.name, laptop.price, laptop.quantity, laptop.in_stock)
print(cable.name, cable.price, cable.quantity, cable.in_stock)

try:
    broken = Product("Ghost", -10)
except ValueError as error:
    print("Could not create product:", error)
```

**Вывод:**

```text
Laptop 3500 4 True
USB Cable 25 0 False
Could not create product: price cannot be negative, got -10
```

**Разбор:**

- `quantity=0` — значение по умолчанию. `Product("USB Cable", 25)` получает количество 0.
- `self.in_stock = quantity > 0` — атрибут, **вычисляемый** из данных: `True` или `False`.
- **Проверка в конструкторе:** `__init__` **отказывается** создавать товар с отрицательной ценой и выбрасывает `ValueError` (вспомните `raise`!). Недопустимый товар **не может существовать** в системе. Это ровно «держать правила рядом с данными», о чём мы говорили в первой главе.
- Когда `__init__` выбрасывает ошибку, объект **не создаётся**, и `broken` не получает значения.

#### Person — третий пример, для закрепления

```python
class Person:
    def __init__(self, first_name, last_name, city="Unknown"):
        self.first_name = first_name
        self.last_name = last_name
        self.city = city
        self.full_name = first_name + " " + last_name

noa = Person("Noa", "Levi", "Haifa")
tom = Person("Tom", "Cohen")
print(noa.full_name, "-", noa.city)
print(tom.full_name, "-", tom.city)
```

```text
Noa Levi - Haifa
Tom Cohen - Unknown
```

### Итоги

- **`__init__`** = конструктор: вызывается **автоматически** при создании объекта и инициализирует его данные.
- **`self`** = конкретный объект, с которым метод работает прямо сейчас. **Всегда первый параметр**, и Python передаёт его сам.
- **`self.x = x`**: `self.x` = атрибут, сохранённый на объекте. `x` = локальный параметр, который исчезает.
- `obj = Class(a, b)` → Python создаёт объект, вызывает `__init__(obj, a, b)` и возвращает объект.
- В `__init__` могут быть: значения по умолчанию, вычисляемые атрибуты и **проверка с `raise`**, чтобы недопустимый объект вообще не создавался.

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

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

- [Class Objects — __init__](https://docs.python.org/3/tutorial/classes.html#class-objects)
- [object.__init__](https://docs.python.org/3/reference/datamodel.html#object.__init__)

<!-- docs:end -->

## Глава 4: Методы — действия объекта

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

- Что такое **метод** (Method).
- Как его **определить** и как **вызвать**.
- Три вида методов по тому, что они делают: **вычисляют**, **меняют состояние**, **печатают**.
- Метод, который вызывает **другой метод**.
- **`__str__`**: как объект «представляет себя».

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

До сих пор у наших объектов были только **данные**. Но идея ООП — **данные + действия** вместе. В первой главе мы увидели, что правила (например, «нельзя снять больше баланса») должны находиться **рядом с данными**. Методы — это место, где живут эти правила.

И есть открытая проблема из предыдущей главы: `description` машины вычислялось один раз в `__init__` и устарело, когда цвет изменился. Метод вычисляет **заново каждый раз**.

### Объяснение с основ

#### Что такое метод?

**Метод** (Method) = **функция, определённая внутри класса**, которая действует на **конкретный объект** этого класса.

| | Функция (обычная) | Метод |
|---|---|---|
| Где определена | На верхнем уровне файла | **Внутри** класса |
| Как вызывать | `function_name(args)` | `obj.method_name(args)` |
| С чем работает | С тем, что ей передали | Со **своим объектом** (`self`) и с тем, что ему передали |
| Первый параметр | Любой | **`self`** |

Методами вы уже пользовались: `"hi".upper()`, `numbers.append(5)`. Сегодня пишем **свои**.

#### `self` в методах

Точно как в `__init__`: когда вы вызываете `dana.add_grade(90)`, Python автоматически передаёт `dana` как `self`. Внутри метода `self.grades` = **оценки Даны**. Если вызвать `avi.add_grade(80)`, выполнится тот же самый метод, но `self` будет Ави.

```text
dana.add_grade(90)   →   Student.add_grade(dana, 90)
                                            ↑     ↑
                                          self  grade
```

Левая строка — то, что пишем мы. Правая — то, что на самом деле делает Python.

#### Три вида методов (по тому, что они делают)

| Вид | Что делает | Пример |
|---|---|---|
| **Геттер** (Getter) | **Вычисляет и возвращает** значение из данных, ничего не меняя | `average()`, `is_passing()` |
| **Сеттер** (Setter) | **Меняет состояние** объекта | `add_grade(90)`, `deposit(100)` |
| **Отображение** (Display) | Печатает / возвращает текст для показа | `print_report()`, `__str__()` |

Полезное различие из чистого кода: хороший метод обычно **либо** геттер, **либо** сеттер, но не оба сразу (SRP!).

#### Состояние

**Состояние** (State) = значения всех атрибутов объекта **в определённый момент**. Счёт с балансом 1000 находится в определённом состоянии. После `deposit(200)` — в **новом** состоянии. Методы-сеттеры — **упорядоченный** способ менять состояние.

#### `__str__` — как объект выглядит при печати

Помните, что `print(dana)` печатал `<__main__.Student object at 0x...>`? Если определить в классе специальный метод с именем **`__str__`** (снова Dunder!), который возвращает строку, **`print` и `str()` будут использовать его** для показа объекта.

### Синтаксис

```python
class ClassName:
    def __init__(self, ...):
        self.data = ...

    def getter_method(self):             # returns something, changes nothing
        return ... self.data ...

    def setter_method(self, value):      # changes the object's state
        self.data = ...

    def uses_other_method(self):
        return self.getter_method() * 2  # call another method on the same object

    def __str__(self):                   # used by print() and str()
        return "readable text about " + str(self.data)

obj = ClassName(...)
obj.setter_method(5)           # no self in the call
result = obj.getter_method()
print(obj)                     # calls __str__ automatically
```

- Каждый метод **с отступом внутри** класса, и `self` — **первый**.
- Между методами — одна пустая строка (PEP 8).
- Внутри метода к данным **и другим методам** обращаются через `self.`.

### Первый пример кода

`week-3/student_methods.py`:

```python
PASSING_GRADE = 60

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        if grade < 0 or grade > 100:
            raise ValueError("grade must be between 0 and 100, got " + str(grade))
        self.grades.append(grade)

    def average(self):
        if not self.grades:
            return 0
        return sum(self.grades) / len(self.grades)

    def is_passing(self):
        return self.average() >= PASSING_GRADE

    def __str__(self):
        return self.name + " (average " + str(round(self.average(), 1)) + ")"

dana = Student("Dana")
dana.add_grade(90)
dana.add_grade(75)

avi = Student("Avi")
avi.add_grade(50)

print(dana)
print(avi)
print("Dana passing?", dana.is_passing())
print("Avi passing?", avi.is_passing())
```

**Вывод:**

```text
Dana (average 82.5)
Avi (average 50.0)
Dana passing? True
Avi passing? False
```

### Разбор кода построчно

| Код | Вид | Объяснение |
|---|---|---|
| `def add_grade(self, grade):` | **Сеттер** | Меняет состояние: добавляет в `self.grades`. И сначала **проверяет** правило (0–100). |
| `def average(self):` | **Геттер** | Вычисляет по данным **объекта** и возвращает. Ничего не меняет. Обрабатывает пустой список (баг из первой главы!). |
| `def is_passing(self):` | **Геттер** | Вызывает **другой метод** того же объекта: `self.average()`. |
| `def __str__(self):` | **Отображение** | Возвращает строку. `print(dana)` вызывает его автоматически. |
| `dana.add_grade(90)` | | Python: `Student.add_grade(dana, 90)`. `self` = dana. |
| `print(avi)` | | Python: `print(avi.__str__())`, то есть `"Avi (average 50.0)"`. |

**Важный момент:** коду, который **использует** класс (`dana.add_grade(...)`), не нужно знать, **как** хранятся оценки (в списке), и не нужно самому проверять диапазон. За это отвечает класс.

### Ещё один пример

**Банковский счёт — проблема из первой главы решена.** `week-3/bank_account.py`:

```python
class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive, got " + str(amount))
        self.balance = self.balance + amount
        self.transactions.append("deposit " + str(amount))

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive, got " + str(amount))
        if amount > self.balance:
            raise ValueError("insufficient funds: balance " + str(self.balance) + ", requested " + str(amount))
        self.balance = self.balance - amount
        self.transactions.append("withdraw " + str(amount))

    def transfer_to(self, other_account, amount):
        self.withdraw(amount)
        other_account.deposit(amount)

    def statement(self):
        print("--- Statement for", self.owner, "---")
        for transaction in self.transactions:
            print(" ", transaction)
        print("  Balance:", self.balance)

    def __str__(self):
        return "Account of " + self.owner + ": " + str(self.balance) + " NIS"

dana = BankAccount("Dana", 1000)
avi = BankAccount("Avi")

dana.deposit(200)
dana.transfer_to(avi, 300)

try:
    avi.withdraw(5000)
except ValueError as error:
    print("Rejected:", error)

print(dana)
print(avi)
dana.statement()
```

**Вывод:**

```text
Rejected: insufficient funds: balance 300, requested 5000
Account of Dana: 900 NIS
Account of Avi: 300 NIS
--- Statement for Dana ---
  deposit 200
  withdraw 300
  Balance: 900
```

**Разбор:**

- `transfer_to(self, other_account, amount)` — метод, который получает **другой объект** как параметр. Он **не** меняет балансы напрямую. Он использует `self.withdraw` и `other_account.deposit`, и так все проверки выполняются автоматически. **DRY!**
- Если `withdraw` не удался (не хватает денег), он выбрасывает ошибку **до** `deposit`, поэтому деньги «из воздуха» не появляются.
- `self.transactions` — внутренняя история, которую обновляет каждое действие. Тому, кто пользуется счётом, не нужно помнить о записи операции.
- `statement()` — метод **отображения**. `__str__` даёт короткое описание для `print`.
- **Сравните с процедурной версией из первой главы:** те же действия, но теперь есть **одно** место, которое определяет, что такое счёт и что с ним разрешено делать.

### Итоги

- **Метод** = функция внутри класса. Определяйте его с `self` первым, а вызывайте `obj.method(args)` **без** `self`.
- `obj.method(x)` равносильно `Class.method(obj, x)`. Так `self` «знает», с каким объектом работать.
- Внутри метода — `self.attribute` и `self.other_method()`.
- **Геттер** возвращает и не меняет. **Сеттер** меняет состояние (и проверяет правила).
- **`__str__`** возвращает строку, и `print(obj)` использует её.
- Производную информацию лучше вычислять в методе, а не хранить как атрибут, который может устареть.

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

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

- [Method Objects](https://docs.python.org/3/tutorial/classes.html#method-objects)

<!-- docs:end -->

## Глава 5: Общие данные — атрибут экземпляра и атрибут класса

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

- **Атрибут** и **метод**: как решить, что есть что.
- **Атрибут экземпляра** (Instance Attribute): данные **каждого объекта** отдельно (то, что мы делали до сих пор).
- **Атрибут класса** (Class Attribute): данные **класса**, общие для всех объектов.
- Когда использовать каждый и какие есть ловушки.

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

Допустим, есть класс `Product`, и все товары облагаются одинаковым НДС: 17%. Где хранить НДС?

- Как атрибут экземпляра (`self.vat = 0.17`)? Тогда будет **тысяча копий** одного и того же числа, и если НДС изменится, придётся обновлять тысячу объектов.
- Как глобальную константу в начале файла? Можно, но она «далеко» от класса, к которому относится.

А если хочется знать, **сколько всего товаров было создано**? Ни один товар этого «не знает». Это информация о **виде в целом**. Атрибуты класса решают оба случая.

### Объяснение с основ

#### Атрибут или метод?

| | Атрибут | Метод |
|---|---|---|
| Что это | **Данные**, что объект **знает / хранит** | **Действие**, что объект **делает / вычисляет** |
| Часть речи | Существительное / прилагательное | **Глагол** |
| Примеры | `name`, `price`, `balance`, `is_active` | `deposit()`, `add_grade()`, `total()` |
| Доступ | `obj.price` | `obj.total()` **со скобками** |

**Вопросы, которые помогают решить:**

1. **Это данные, которые хранятся, или что-то, что вычисляется?** Хранимые данные → атрибут. Вычисляемое из других данных → обычно метод (мы это видели: `description`, которое устарело).
2. **Меняет ли это объект?** Каждое упорядоченное изменение → метод (команда).
3. **Нужны ли правила при изменении?** Если да, изменение идёт через метод (и мы углубимся в это в уроке об инкапсуляции).

#### Атрибут экземпляра

Определяется через **`self.x = ...`**, обычно в `__init__`. **У каждого объекта своя копия** со своим значением.

#### Атрибут класса

Определяется **прямо в теле класса**, вне любого метода, **без `self`**:

```python
class Product:
    VAT_RATE = 0.17       # class attribute - one shared value
    count = 0             # class attribute

    def __init__(self, name):
        self.name = name  # instance attribute - per object
```

- Есть **только одна копия**, которая принадлежит самому классу.
- **Все объекты** её видят.
- Обращаются к нему через класс (`Product.VAT_RATE`, рекомендуемый способ) или через объект (`laptop.VAT_RATE`, работает для чтения).

{{< box "analogy" "Аналогия: класс в школе" >}}
**Атрибуты экземпляра** = то, что каждый ученик хранит у себя: имя, оценки, тетрадь. **Атрибуты класса** = то, что висит на классной доске и общее для всех: имя классного руководителя, номер кабинета и сколько учеников в классе. Когда меняется учитель, доску обновляют **один раз**, а не в тетради каждого ученика.
{{< /box >}}

#### Как Python находит атрибут

Когда вы пишете `obj.x`, Python ищет:

1. **Сначала на объекте** (атрибуты экземпляра).
2. **Если не найдено — на классе** (атрибуты класса и методы).
3. Не найдено → `AttributeError`.

(Обратите внимание: это похоже на LEGB из урока об области видимости — поиск по слоям.) Отсюда и ловушка в типичной ошибке.

### Синтаксис

```python
class ClassName:
    SHARED_CONSTANT = 100        # class attribute (constant style: UPPER_CASE)
    counter = 0                  # class attribute (changes, shared)

    def __init__(self, value):
        self.value = value                     # instance attribute
        ClassName.counter = ClassName.counter + 1   # update the class attribute through the CLASS

    def uses_constant(self):
        return self.value * ClassName.SHARED_CONSTANT

ClassName.SHARED_CONSTANT        # read through the class
ClassName.counter
```

### Первый пример кода

`week-3/products_shared.py`:

```python
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)

laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)

print(laptop.name, laptop.price_with_vat())
print(mouse.name, mouse.price_with_vat())
print("Products created:", Product.created_count)

Product.VAT_RATE = 0.18
print("After VAT change:", laptop.price_with_vat(), mouse.price_with_vat())
```

**Вывод:**

```text
Laptop 1170.0
Mouse 58.5
Products created: 2
After VAT change: 1180.0 59.0
```

### Разбор кода построчно

| Код | Вид | Объяснение |
|---|---|---|
| `VAT_RATE = 0.17` | **Атрибут класса** | Одно значение для всех товаров. Заглавные буквы = «константа» (соглашение). |
| `created_count = 0` | **Атрибут класса** | Общий счётчик. Начинается с 0, когда класс определяется. |
| `self.name`, `self.price` | **Атрибуты экземпляра** | Разные у каждого товара. |
| `Product.created_count = Product.created_count + 1` | | Каждый `__init__` обновляет счётчик **класса**, поэтому он считает все объекты. |
| `Product.VAT_RATE` в методе | | Чтение атрибута класса. |
| `Product.VAT_RATE = 0.18` | | **Одно** изменение, и **все** товары затронуты сразу, потому что все читают из одного места. |

### Ещё один пример

**Класс с ограничением мест и автоматическим уникальным идентификатором.** `week-3/students_ids.py`:

```python
class Student:
    SCHOOL_NAME = "Kodkod 6"
    MAX_STUDENTS = 3
    next_id = 1
    all_students = []

    def __init__(self, name):
        if len(Student.all_students) >= Student.MAX_STUDENTS:
            raise ValueError("class is full - cannot add " + name)
        self.name = name
        self.student_id = Student.next_id
        Student.next_id = Student.next_id + 1
        Student.all_students.append(self)

    def __str__(self):
        return "#" + str(self.student_id) + " " + self.name + " (" + Student.SCHOOL_NAME + ")"

for name in ["Dana", "Avi", "Noa", "Tom"]:
    try:
        Student(name)
    except ValueError as error:
        print("Error:", error)

for student in Student.all_students:
    print(student)
print("Next id will be:", Student.next_id)
```

**Вывод:**

```text
Error: class is full - cannot add Tom
#1 Dana (Kodkod 6)
#2 Avi (Kodkod 6)
#3 Noa (Kodkod 6)
Next id will be: 4
```

**Разбор:**

- `next_id` — счётчик, который **выдаёт уникальный идентификатор** каждому студенту. Каждый объект хранит **свой** номер в атрибуте экземпляра `student_id`, а общий счётчик продвигается вперёд.
- `all_students` — **общий список**, в котором класс «регистрирует» каждый созданный объект. `append(self)` = «добавь **этот** объект в список».
- `MAX_STUDENTS` — правило, действующее для **всего** класса. Проверка выполняется **до** присвоения ID, поэтому Том не «сжёг» номер.
- `Student(name)` в цикле без сохранения в переменную: объект не теряется, потому что зарегистрирован в `all_students`.

{{< box "tip" "Когда да, а когда нет" >}}
Атрибуты класса отлично подходят для **констант** (`VAT_RATE`, `MAX_STUDENTS`) и счётчиков. Общий список «всех объектов» — хороший инструмент для демонстрации, но в настоящих проектах коллекцию обычно лучше вести в отдельном классе (например, `School`, который хранит список студентов). Это мы изучим в уроках о проектировании ООП (композиция, Composition).
{{< /box >}}

### Итоги

- **Атрибут** = что объект знает (существительное). **Метод** = что он делает (глагол, со скобками). Вычисляемая информация → метод.
- **Атрибут экземпляра** (`self.x` в `__init__`) = у каждого объекта своё значение.
- **Атрибут класса** (в теле класса, без `self`) = **одна** общая копия. Подходит для констант и счётчиков.
- Python ищет `obj.x` сначала на объекте, а затем на классе.
- Меняйте атрибут класса **только через имя класса**. `obj.X = ...` создаёт новый атрибут экземпляра.
- Список/словарь «для каждого объекта» → в `__init__`, **а не** в теле класса.

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

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

- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables)

<!-- docs:end -->

## Глава 6: Что становится классом — от словесного требования к классам

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

До сих пор **мы говорили вам**, какие классы писать. Теперь переходим на другую сторону: **решаете вы**.

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

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

Программа курса подчёркивает: цель — **понимать** ООП, а не только уметь скопировать класс. На работе никто не скажет вам «напиши класс с именем `Order` с такими полями». Вы получите **требование**: «нужна система, в которой клиенты заказывают товары...», — и вам придётся самим превратить его в структуру классов.

Хорошее проектирование в начале экономит **недели** исправлений. Плохое приводит к одному огромному классу или к десяткам пустых классов, которые ничего не делают.

### Объяснение с основ

#### Инструмент 1: анализ существительных и глаголов

Простая и эффективная техника: прочитайте требование и отметьте:

| В тексте | Кандидат в |
|---|---|
| Важные **существительные** («клиент», «заказ», «товар») | **Классы** или **атрибуты** |
| **Прилагательные** и свойства («цена», «дата», «активный») | **Атрибуты** |
| **Глаголы** («заказывает», «платит», «отменяет») | **Методы** |
| **Правила** («нельзя...», «не более...») | **Проверки / инварианты** (Validation / Invariants) |

**Это отправная точка, а не ответ.** Не каждое существительное становится классом.

#### Инструмент 2: четыре вопроса — это класс?

| # | Вопрос | Если ответ «да» |
|---|---|---|
| 1 | **Есть ли у него идентичность и собственные данные?** (у каждого разные значения) | Склоняется к классу |
| 2 | **Есть ли у него поведение или правила?** (действия, проверки) | Сильно склоняется к классу |
| 3 | **Есть ли несколько его экземпляров?** (много клиентов, много заказов) | Склоняется к классу |
| 4 | **Это всего лишь одно значение, без всякого поведения?** («цвет», «имя») | **Атрибут**, а не класс |

{{< box "analogy" "Аналогия" >}}
«**Машина**» — у неё есть свой номер (идентичность), она едет и тормозит (поведение), и машин много. ✓ Класс. «**Цвет машины**» — это всего лишь значение («красный») без собственных действий. Атрибут машины. «**Двигатель**» — зависит! В простой игре — атрибут (`engine_size = 1.6`). В гараже, который ремонтирует двигатели, — класс (у него есть серийный номер, история обслуживания и состояние). **Одно и то же понятие — разное решение в зависимости от того, что нужно системе.**
{{< /box >}}

#### Инструмент 3: простой атрибут или отдельный класс?

Решающий вопрос: **нужно ли хранить об этой вещи больше одного куска информации или применять к ней правила?**

| Понятие | Простой атрибут, когда... | Отдельный класс, когда... |
|---|---|---|
| Адрес | Достаточно одного текста: `"Herzl 10, Haifa"` | Нужны отдельно город, улица и индекс, фильтрация по городу и проверка индекса |
| Цена | Одно число | Нужны валюта, конвертации и округление по валюте |
| Оценка | Число | Нужны ещё дата, название экзамена, вес и апелляция |

**Эмпирическое правило:** начинайте **просто** (с атрибута). Когда значение «разрастается» и получает больше полей и правил, превращайте его в класс.

#### Инструмент 4: какому классу принадлежит метод?

**Золотое правило: метод идёт в класс, который хранит нужные ему данные.** (Это называется «Information Expert».)

- **«Посчитать сумму заказа»** → нужны позиции заказа → **`Order.total()`**.
- **«Есть ли товар в наличии?»** → нужно количество товара → **`Product.is_in_stock()`**.
- **«Сколько клиент потратил в этом году?»** → нужны заказы клиента → **`Customer`** (если он хранит свои заказы) или класс, который управляет всеми заказами.

**Тревожный сигнал:** метод в классе A, который в основном состоит из `other.x`, `other.y`, `other.z`, → вероятно, принадлежит классу `other`.

### Синтаксис

Нового синтаксиса нет. Результат этого этапа — **проект**, который можно записать как «скелет» кода, с `pass` и docstring:

```python
class Order:
    """A customer's order: items, status and total."""

    def __init__(self, customer):
        self.customer = customer      # has-a: the order holds its customer
        self.items = []
        self.status = "new"

    def add_item(self, product, quantity):
        pass

    def total(self):
        pass
```

Такой скелет позволяет думать о **структуре** до написания логики.

### Первый пример кода

**Требование:** «Система для кофейни. **Клиенты** заказывают **напитки** из **меню**. У каждого напитка есть **название**, **размер** (small/large) и **цена**. Большой напиток стоит на 3 шекеля дороже. **Заказ** может включать несколько напитков, и у него есть **сумма**. Клиент может **отменить** заказ, пока он не **приготовлен**.»

**Шаг А — разметка:**

- **Существительные:** клиент, напиток, меню, заказ, название, размер, цена, сумма.
- **Глаголы:** заказывать, включать, отменять, приготовлен.
- **Правила:** большой = +3. Отмена только до приготовления.

**Шаг Б — четыре вопроса:**

| Кандидат | Идентичность/данные? | Поведение? | Несколько экземпляров? | Решение |
|---|---|---|---|---|
| Напиток | ✓ название, размер, цена | ✓ расчёт цены по размеру | ✓ | **Класс `Drink`** |
| Заказ | ✓ позиции, статус | ✓ сумма, отмена, приготовление | ✓ | **Класс `Order`** |
| Клиент | ✓ имя | Немного | ✓ | Здесь: **атрибут** (имя) у Order. В требовании нет для него поведения. |
| Меню | Список напитков | Возможно, поиск | **Одно** | Здесь: простой **dict**. Если добавятся правила, станет классом. |
| Размер | Значение («small»/«large») | — | — | **Атрибут** Drink |
| Цена | Число | — | — | **Атрибут** + вычисляющий метод |
| Сумма | Производная | — | — | **Метод** `Order.total()` |

**Шаг В — методы («кто хранит данные?»):**

- `price()` → Drink, потому что Drink знает базовую цену и размер.
- `total()` → Order, потому что Order хранит напитки.
- `cancel()`, `mark_prepared()` → Order, потому что Order хранит статус.

**Шаг Г — код.** `week-3/coffee_shop.py`:

```python
LARGE_EXTRA = 3

class Drink:
    def __init__(self, name, base_price, size="small"):
        if size not in ("small", "large"):
            raise ValueError("size must be small or large, got " + size)
        self.name = name
        self.base_price = base_price
        self.size = size

    def price(self):
        if self.size == "large":
            return self.base_price + LARGE_EXTRA
        return self.base_price

class Order:
    def __init__(self, customer_name):
        self.customer_name = customer_name
        self.drinks = []
        self.status = "open"

    def add(self, drink):
        if self.status != "open":
            raise ValueError("cannot add drinks to a " + self.status + " order")
        self.drinks.append(drink)

    def total(self):
        return sum(drink.price() for drink in self.drinks)

    def mark_prepared(self):
        self.status = "prepared"

    def cancel(self):
        if self.status == "prepared":
            raise ValueError("cannot cancel - the order was already prepared")
        self.status = "cancelled"

MENU = {"espresso": 9, "latte": 14, "tea": 8}

order = Order("Dana")
order.add(Drink("latte", MENU["latte"], "large"))
order.add(Drink("espresso", MENU["espresso"]))
print(order.customer_name, "pays", order.total())

order.mark_prepared()
try:
    order.cancel()
except ValueError as error:
    print("Rejected:", error)
```

**Вывод:**

```text
Dana pays 26
Rejected: cannot cancel - the order was already prepared
```

### Разбор кода построчно

| Проектное решение | Как это выглядит в коде |
|---|---|
| Напиток = класс | `class Drink` с данными (название, цена, размер) и поведением (`price`). |
| Размер = атрибут с правилом | `self.size` с проверкой в `__init__`. |
| «Большой = +3» принадлежит напитку | `Drink.price()`, потому что напиток знает свой размер. |
| Заказ = класс | `class Order` с позициями и статусом. |
| Клиент = атрибут (пока) | `self.customer_name`, строка. |
| «Отмена только до приготовления» | Проверка в `Order.cancel()`. |
| Меню = dict | `MENU`. У него нет поведения, поэтому класс не нужен. |
| Сумма = метод | `total()` вычисляет по `drinks` и не хранится как данные, которые могут устареть. |

### Ещё один пример

**Когда требование меняется — клиент становится классом.** Добавилось требование: «У клиентов есть **клубная карта**: каждая покупка приносит балл за каждые 10 шекелей, а при 50 баллах полагается бесплатный напиток.»

Теперь у клиента есть **данные** (баллы), **поведение** (накопление, списание) и **правила** (50 баллов). По четырём вопросам это уже **класс**:

```python
POINTS_PER_SHEKEL = 0.1
FREE_DRINK_POINTS = 50

class Customer:
    def __init__(self, name):
        self.name = name
        self.points = 0

    def earn_points(self, amount_paid):
        self.points = self.points + int(amount_paid * POINTS_PER_SHEKEL)

    def has_free_drink(self):
        return self.points >= FREE_DRINK_POINTS

    def redeem_free_drink(self):
        if not self.has_free_drink():
            raise ValueError(self.name + " has only " + str(self.points) + " points")
        self.points = self.points - FREE_DRINK_POINTS

dana = Customer("Dana")
for payment in [120, 260, 140]:
    dana.earn_points(payment)
print(dana.name, "has", dana.points, "points. Free drink?", dana.has_free_drink())
dana.redeem_free_drink()
print("After redeeming:", dana.points)
```

**Вывод:**

```text
Dana has 52 points. Free drink? True
After redeeming: 2
```

И в структуре: `Order` теперь хранит `self.customer` (объект Customer) вместо `customer_name`, а при оплате заказа вызывает `self.customer.earn_points(self.total())`. **Одно и то же понятие, «клиент», начиналось как атрибут и стало классом, когда требования выросли.** Это естественно, и это правильный рабочий процесс.

### Итоги

- **Существительные** → кандидаты в классы/атрибуты. **Глаголы** → методы. **Правила** → проверки.
- **Класс** — когда есть идентичность + данные, **поведение или правила** и несколько экземпляров. Одно значение без поведения → **атрибут**.
- Одно и то же понятие может быть атрибутом в одной системе и классом в другой. Начинайте просто и повышайте, когда требования растут.
- **Метод принадлежит классу, который хранит нужные ему данные** (Information Expert).
- Иногда «действие» (кредит, заказ) на самом деле **класс**, когда у него есть собственные данные и логика.
- Не класс для каждого существительного и не «божественный класс» (God Class), который делает всё.

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

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

- [Random Remarks](https://docs.python.org/3/tutorial/classes.html#random-remarks)

<!-- docs:end -->

## Глава 7: Назад в банк — всё вместе

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

Мы возвращаемся к банковской системе из начала урока и строим её заново — на этот раз со всем, что изучили в предыдущих главах.

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

Каждая глава добавляла один инструмент и решала одну проблему. Здесь видно, как все они работают **вместе** в одном классе, — и именно это вы сделаете самостоятельно в мини-проекте.

### Банк — со всем, что мы изучили

```python
class BankAccount:
    accounts_opened = 0                  # shared by all accounts

    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self.owner = owner               # per account
        self.balance = balance
        BankAccount.accounts_opened = BankAccount.accounts_opened + 1

    def deposit(self, amount):           # setter - changes the state
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def __str__(self):
        return self.owner + ": " + str(self.balance)

dana = BankAccount("Dana", 1000)
dana.deposit(200)
print(dana)                              # Dana: 1200
```

| Часть кода | Из какой главы |
|---|---|
| `class BankAccount` — один шаблон для всех счетов | Глава 2 |
| `__init__` с проверкой — каждый счёт рождается полноценным и допустимым | Глава 3 |
| `deposit` — сеттер, который защищает правила | Глава 4 |
| `__str__` — как счёт выглядит при печати | Глава 4 |
| `accounts_opened` — счётчик, общий для всех счетов | Глава 5 |

Четыре проблемы из первой главы решены: нельзя создать счёт с отрицательным балансом, данные и действия находятся вместе, у структуры одно определение, и сам класс следит за соблюдением правил.

### Итоги

- **Класс** — шаблон, а **объект** — экземпляр, построенный по нему, — с собственными данными.
- **`__init__`** выполняется при создании и гарантирует, что объект рождается полноценным; **`self`** — текущий объект.
- **Методы** — это действия: **геттер** возвращает информацию, **сеттер** меняет состояние и проверяет правила, а **`__str__`** решает, что показывает `print`.
- **Атрибут класса** — одно значение, общее для всех объектов, — например, счётчик.

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

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

- [Python Tutorial — Classes](https://docs.python.org/3/tutorial/classes.html)

<!-- docs:end -->

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

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

**Что вы отрабатываете на этой странице:** зачем нужно ООП, создание класса с `__init__` и проверкой значений, методы (геттер и сеттер) и `__str__`, данные, общие для всего класса, и проектирование — что является классом, что атрибутом, а что методом.

**К концу страницы вы будете уметь:**
- Распознавать, когда данные и действия лучше объединить в один объект
- Писать полноценный класс: `__init__`, методы и `__str__`
- Использовать атрибут класса как счётчик, общий для всех объектов
- Превращать словесное описание в список классов, атрибутов и методов

### Упражнение 1: Найдите проблемы

**Что вы делаете:** читаете существующий код и находите в нём проблемы — без написания кода.

**Код:**

```text
student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def average(student):
    return sum(student["grades"]) / len(student["grades"])
```

**Задание:** найдите три проблемы, которые возникают из-за того, что данные (словари) и действия (функция) разделены. Для каждой проблемы напишите одно предложение: в чём проблема и в какой строке она проявляется.

**Что сдать:** три проблемы, по одному предложению на каждую.

{{< solution >}}
1. **Опечатка создаёт новый ключ** — у `student3` написано `"nmae"` вместо `"name"`. Нет определённой структуры, которая бы это поймала, и проблема обнаружится, только когда кто-то попробует `student3["name"]`.
2. **Нет правил** — любой код может положить в `grades` `-50` или `"abc"`, и никто не проверит.
3. **Данные и действия разбросаны** — `average` — отдельная функция, которая получает словарь и не знает, студент ли это вообще. А с пустым списком (`student3`) она упадёт с `ZeroDivisionError`.

В ООП `Student` один раз определяет, **какие данные** у него есть и **какие действия** он умеет выполнять, — всё вместе.
{{< /solution >}}

### Упражнение 2: Проверка при создании

**Что вы строите:** товар в магазине, который нельзя создать с отрицательной ценой.

**Что написать:**
1. Класс с именем `Product`.
2. `__init__`, который получает название, цену и количество. Если количество не передано — оно равно 0.
3. Если цена отрицательная: выбросить `ValueError` с сообщением, которое есть в выводе, и ничего не сохранять.
4. Иначе: сохранить три значения на объекте через `self`.

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

```python
pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
```

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

```text
Pen 5 0
error: price cannot be negative
```

{{< solution >}}
```python
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative")
        self.name = name
        self.price = price
        self.quantity = quantity


pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
```

```text
Pen 5 0
error: price cannot be negative
```

Проверка в `__init__` гарантирует, что объекта `Product` с недопустимой ценой не будет никогда.
{{< /solution >}}

### Упражнение 3: Student с методами и __str__

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

**Что написать:**
1. Класс с именем `Student`. `__init__` получает имя и создаёт пустой список оценок.
2. `add_grade` — получает оценку и добавляет её в список. Это сеттер: он меняет состояние.
3. `average` — возвращает среднее или 0, если оценок ещё нет. Это геттер: он только возвращает.
4. `__str__` — возвращает имя и среднее точно как в последней строке вывода.

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

```python
dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
```

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

```text
0
82.5
Dana (average 82.5)
```

{{< solution >}}
```python
class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        self.grades.append(grade)

    def average(self):
        if len(self.grades) == 0:
            return 0
        return sum(self.grades) / len(self.grades)

    def __str__(self):
        return self.name + " (average " + str(self.average()) + ")"


dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
```

```text
0
82.5
Dana (average 82.5)
```

`add_grade` — **сеттер**, он меняет состояние. `average` — **геттер**, он возвращает информацию, ничего не меняя. `print` вызывает `__str__` автоматически.
{{< /solution >}}

### Упражнение 4: Общий счётчик

**Что вы строите:** товар, который знает свою цену с НДС, — и класс, который считает, сколько товаров создано.

**Что написать:**
1. Класс с именем `Product` с `__init__`, который получает название и цену.
2. Два атрибута класса — данные, принадлежащие всему классу, а не одному объекту: `VAT_RATE` со значением 0.17 и `created_count`, который начинается с 0.
3. Каждый раз, когда создаётся товар, увеличивайте `created_count` на 1.
4. `price_with_vat` — возвращает цену с НДС.

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

```python
laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
```

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

```text
2
1170.0
```

{{< solution >}}
```python
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)


laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
```

```text
2
1170.0
```

- `name` и `price` принадлежат каждому товару отдельно — **атрибуты экземпляра**.
- `VAT_RATE` и `created_count` общие для всех товаров — **атрибуты класса**.
- Счётчик обновляется через `Product.created_count`, а не через `self`.
{{< /solution >}}

### Упражнение 5: Существительные и глаголы

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

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

> «В кофейне **клиенты** заказывают **напитки** разных размеров. У каждого напитка есть **базовая цена**, а большой напиток стоит на 3 шекеля дороже. Клиент **накапливает баллы** за каждый заказ и может их **списать**.»

**Задание:** постройте таблицу из трёх столбцов: кандидат, что это (класс, атрибут или метод) и почему.

**Подсказка:** существительные — кандидаты в классы или атрибуты. Глаголы — кандидаты в методы.

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

{{< solution >}}
| Кандидат | Что это | Почему |
|---|---|---|
| Клиент (`Customer`) | **Класс** | Существительное с данными (имя, баллы) и поведением (накапливает, списывает) |
| Напиток (`Drink`) | **Класс** | Существительное с данными (базовая цена, размер) и правилом (большой стоит дороже) |
| Базовая цена, размер | **Атрибуты** `Drink` | Простые значения, которые описывают напиток |
| Баллы | **Атрибут** `Customer` | Простое число |
| Накапливает баллы, списывает | **Методы** `Customer` | Глаголы — действия с данными клиента |
| Итоговая цена | **Метод** `Drink` | Вычисляется из цены и размера |
{{< /solution >}}

## Мини-проект: Маленькая библиотека

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

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

**Что построить — шаг за шагом:**

**Шаг 1 — класс и общий счётчик.** Напишите класс с именем `Book` с атрибутом класса `total_books`, который начинается с 0. Он будет считать, сколько книг создано.

**Шаг 2 — создание книги.** `__init__` получает название, автора и число экземпляров и сохраняет их. В начале все экземпляры свободны — сохраните их также в `available`. Каждая новая книга увеличивает `total_books` на 1. Если число экземпляров меньше 1 — выбросьте `ValueError` и не создавайте книгу.

**Шаг 3 — выдача и возврат (сеттеры).**
- `borrow` — забирает один свободный экземпляр. Если свободных экземпляров нет: `ValueError`.
- `return_book` — возвращает один экземпляр. Если все экземпляры уже в библиотеке: `ValueError`.

**Шаг 4 — проверка (геттер).** `is_available` возвращает `True`, если есть хотя бы один свободный экземпляр, иначе `False`.

**Шаг 5 — отображение.** `__str__` возвращает название, автора и сколько экземпляров свободно из скольких, как в выводе.

**Сообщения об ошибках — именно такие:**

```text
copies must be at least 1
no copies available
all copies are already in
```

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

```python
books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
```

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

```text
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
```

**К концу проекта вы будете уметь:**
- Переводить описание «вещи» из мира — книги — в класс с данными и действиями
- Держать правила внутри объекта, чтобы он не мог попасть в недопустимое состояние
- Понимать, почему книга Empty не посчитана счётчиком: ошибка была выброшена до увеличения

**Бонус:** добавьте класс с именем `Library`, который хранит список книг, с `add_book`, который добавляет книгу, и `available_books`, который возвращает только книги, которые можно взять прямо сейчас.

{{< solution >}}
```python
class Book:
    total_books = 0

    def __init__(self, title, author, copies):
        if copies < 1:
            raise ValueError("copies must be at least 1")
        self.title = title
        self.author = author
        self.copies = copies
        self.available = copies
        Book.total_books = Book.total_books + 1

    def borrow(self):
        if self.available == 0:
            raise ValueError("no copies available")
        self.available = self.available - 1

    def return_book(self):
        if self.available == self.copies:
            raise ValueError("all copies are already in")
        self.available = self.available + 1

    def is_available(self):
        return self.available > 0

    def __str__(self):
        return (self.title + " by " + self.author + " ("
                + str(self.available) + "/" + str(self.copies) + " available)")


books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
```

```text
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
```

- `total_books` — **атрибут класса**: один счётчик для всех книг. Книга `Empty` не была создана (ошибка выброшена до увеличения), поэтому счётчик равен 2.
- `borrow` и `return_book` — **сеттеры**: они меняют состояние и проверяют правила **до** изменения.
- `is_available` — **геттер**: возвращает информацию, ничего не меняя.

**Бонус:**

```python
class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def available_books(self):
        result = []
        for book in self.books:
            if book.is_available():
                result.append(book)
        return result


library = Library()
for book in books:
    library.add_book(book)
for book in library.available_books():
    print(book)
```

```text
Clean Code by Martin (2/3 available)
```
{{< /solution >}}

<!-- exercises:end -->
