# Объекты в работе: состояние, сокрытие данных, свойства и полноценный класс<no value>

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

Урок построен как одна история, в том же порядке, что и в презентации. Каждая глава опирается на предыдущую:

1. **Глава 1** — объекты и состояние: много объектов, меняющееся состояние и объект, который хранит объект
2. **Глава 2** — сокрытие данных: любой код может испортить данные, поэтому мы прячем их за интерфейсом
3. **Глава 3** — проверка: простой синтаксис поля и проверка при каждом присваивании с `@property`
4. **Глава 4** — полноценный класс: соединяем всё в один класс с ответственностью, правилами и интерфейсом

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

## Глава 1: Объекты и состояние — много объектов и меняющееся состояние

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

- Как управлять **множеством объектов**: списки, поиск, фильтрация и сортировка.
- Как **состояние** (State) объектов меняется на протяжении программы.
- **Объект, который хранит другой объект** (например, заказ, который хранит клиента).
- **Ссылки**: два имени для одного объекта и два объекта.

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

Настоящая система — это не один объект. Это **сотни объектов**, которые общаются друг с другом: клиенты, заказы, товары. Нужно уметь найти клиента с ID 7, все неоплаченные заказы и обновить склад, когда заказ отправлен. А ещё — понимать, что происходит, когда вы меняете объект, который «держат» несколько частей системы.

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

#### Объекты в списке

Список может хранить объекты точно так же, как числа или словари. Всё, что мы изучили в темах Python, работает:

| Действие | Со словарями (темы Python) | С объектами |
|---|---|---|
| Доступ к полю | `item["price"]` | `item.price` |
| Сортировка | `sorted(items, key=lambda i: i["price"])` | `sorted(items, key=lambda i: i.price)` |
| Фильтрация | `if item["price"] > 100` | `if item.price > 100` |
| Действие | `deposit(account, 50)` | `account.deposit(50)` |

#### Состояние во времени

**Состояние** = значения атрибутов сейчас. Объект «живёт» на протяжении программы, и его состояние меняется через методы: заказ создаётся в состоянии `"new"`, переходит в `"paid"`, а затем в `"shipped"`. Каждый объект проходит путь **отдельно**.

#### Объект, который хранит объект

Атрибут может хранить **любое значение**, включая **другой объект**:

```python
order.customer = dana      # the order "knows" its customer
```

`order.customer` **является** объектом `Customer`, поэтому можно продолжить: `order.customer.name`.

#### Ссылки — важное напоминание

Вспомните из тем Python: **переменная — наклейка на объекте**. Это верно и для наших собственных объектов:

```text
dana ──────────────►┌──────────────┐
order1.customer ───►│ Customer     │   ONE object, three names
order2.customer ───►│ name: "Dana" │
                    └──────────────┘
```

Если Дана меняет адрес (`dana.city = "Tel Aviv"`), **оба заказа** сразу видят новый адрес. Они хранят не **копию**, а **ссылку** на ту же Дану. Обычно именно это и нужно: в системе **одна Дана**.

{{< box "analogy" "Аналогия: контакт в телефоне" >}}
В нескольких группах WhatsApp есть «Дана». Когда Дана меняет фото профиля, оно обновляется **во всех** группах, потому что группы хранят не **фотографию** Даны, а **ссылку** на настоящую Дану.
{{< /box >}}

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

```python
objects = [ClassName(...), ClassName(...)]

for obj in objects:                       # loop
    obj.method()

found = None
for obj in objects:                       # search
    if obj.attr == target:
        found = obj
        break

matching = [obj for obj in objects if obj.attr > 10]   # filter (list comprehension)

ordered = sorted(objects, key=lambda obj: obj.attr)    # sort

holder.other = some_object                # an attribute that holds another object
holder.other.attr                         # chain through it
```

**Списковое включение** (List Comprehension, третья строка снизу) — короткий способ построить отфильтрованный список. Оно равносильно циклу:

```python
matching = []
for obj in objects:
    if obj.attr > 10:
        matching.append(obj)
```

Используйте ту версию, которая для вас читается лучше. Обе правильные.

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

`week-3/team.py`:

```python
class Player:
    def __init__(self, name, score=0):
        self.name = name
        self.score = score

    def add_points(self, points):
        self.score = self.score + points

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

team = [Player("Dana"), Player("Avi"), Player("Noa")]

team[0].add_points(30)
team[1].add_points(15)
team[2].add_points(40)
team[0].add_points(20)

for player in team:
    print(player)

leader = max(team, key=lambda player: player.score)
print("Leader:", leader.name)

ranking = sorted(team, key=lambda player: player.score, reverse=True)
for place, player in enumerate(ranking, start=1):
    print(place, player)
```

**Вывод:**

```text
Dana: 50
Avi: 15
Noa: 40
Leader: Dana
1 Dana: 50
2 Noa: 40
3 Avi: 15
```

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

| Код | Объяснение |
|---|---|
| `team = [Player("Dana"), ...]` | Список из 3 **отдельных** объектов, у каждого свой `score`. |
| `team[0].add_points(30)` | `team[0]` — объект Player. Вызываем его метод. Меняется только **его** состояние. |
| `team[0].add_points(20)` | Снова тот же объект. Его очки складываются до 50. |
| `max(team, key=lambda p: p.score)` | Находит объект с наибольшим счётом и возвращает **весь объект**. |
| `sorted(..., reverse=True)` | **Новый** отсортированный список. Сам `team` не изменился. Но объекты **те же самые**, отличается только их порядок в новом списке. |

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

**Клиенты и заказы — объекты, которые хранят объекты, и меняющееся состояние.** `week-3/orders.py`:

```python
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city

class Order:
    VALID_STATUSES = ["new", "paid", "shipped"]
    next_number = 1

    def __init__(self, customer, total):
        self.number = Order.next_number
        Order.next_number = Order.next_number + 1
        self.customer = customer
        self.total = total
        self.status = "new"

    def advance(self):
        position = Order.VALID_STATUSES.index(self.status)
        if position == len(Order.VALID_STATUSES) - 1:
            raise ValueError("order " + str(self.number) + " is already shipped")
        self.status = Order.VALID_STATUSES[position + 1]

    def __str__(self):
        return ("Order #" + str(self.number) + " | " + self.customer.name + " (" + self.customer.city
                + ") | " + str(self.total) + " NIS | " + self.status)

dana = Customer("Dana", "Haifa")
avi = Customer("Avi", "Eilat")

orders = [Order(dana, 120), Order(avi, 80), Order(dana, 300)]

orders[0].advance()
orders[0].advance()
orders[2].advance()

dana.city = "Tel Aviv"

for order in orders:
    print(order)

dana_orders = [order for order in orders if order.customer is dana]
print("Dana's total:", sum(order.total for order in dana_orders))

not_shipped = [order.number for order in orders if order.status != "shipped"]
print("Not shipped yet:", not_shipped)
```

**Вывод:**

```text
Order #1 | Dana (Tel Aviv) | 120 NIS | shipped
Order #2 | Avi (Eilat) | 80 NIS | new
Order #3 | Dana (Tel Aviv) | 300 NIS | paid
Dana's total: 420
Not shipped yet: [2, 3]
```

**Разбор:**

- `Order(dana, 120)` — заказ **хранит ссылку** на объект `dana`. Он не копирует имя и город.
- `self.customer.name` — **цепочка**: от заказа к клиенту, а от него к имени клиента.
- `dana.city = "Tel Aviv"` — **одно** изменение, и **оба** заказа Даны показывают новый город. Если бы мы сохранили `self.city = customer.city` в `__init__`, заказы «застряли» бы с Хайфой.
- `advance()` — простой **конечный автомат** (State Machine): new → paid → shipped, и нельзя перепрыгнуть или вернуться. Каждый заказ продвигается **отдельно**.
- `order.customer is dana` — здесь `is` **правильный**: мы действительно спрашиваем «это **тот же объект**?» (а не «у него то же имя?», ведь может быть два клиента по имени Дана).
- `sum(order.total for order in dana_orders)` — сумма в одну строку (генераторное выражение, Generator Expression, как в `max` из тем Python).

### Итоги

- Объекты в списке работают как любое значение: перебор, поиск, `sorted`/`max` с `key=lambda obj: obj.attr`.
- Каждый объект хранит **своё состояние**, которое меняется через его методы.
- Атрибут может хранить **другой объект** (`order.customer`), и к нему обращаются цепочкой: `order.customer.name`.
- Это **ссылка**, а не копия. Изменение объекта видно всем, кто его хранит.
- `b = a` = ещё одно имя, а не новый объект. `is` проверяет «тот же объект».
- Изменение состояния «снаружи» обходит правила. В следующей главе: инкапсуляция.

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

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

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

<!-- docs:end -->

## Глава 2: Сокрытие данных — инкапсуляция

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

- Что такое **инкапсуляция** (Encapsulation) и **зачем** она существует.
- **Публичный интерфейс** (Public Interface) и внутренняя **реализация** (Implementation).
- Соглашения Python: **`_protected`** и **`__private`**.
- Что такое **искажение имён** (Name Mangling).

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

До сих пор мы писали методы с хорошими проверками, например `withdraw`, которая проверяет, что денег достаточно. Но:

```python
account = BankAccount("Dana", 100)
account.balance = -1_000_000
```

**Одна строка обошла все правила.** Ни ошибки, ни лога. Тщательно написанный `withdraw` просто не выполнился. В настоящей системе, с десятками разработчиков и сотнями файлов, **кто-нибудь** напишет такую строку (может быть, вы сами через месяц, «просто для проверки»).

Ещё одна проблема: допустим, мы решили хранить баланс в **агорот** (копейках, `balance_cents`), чтобы избежать проблем с округлением. Если в 30 местах кода обращаются к `account.balance` напрямую, все они сломаются. Если бы они пользовались методами, мы бы изменили **только методы**.

Инкапсуляция решает обе проблемы.

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

#### Что такое инкапсуляция?

**Инкапсуляция** (Encapsulation) = **упаковка** данных вместе с кодом, который с ними работает, и **сокрытие** внутренних деталей, чтобы внешний мир работал только через **определённый интерфейс**.

Две идеи:

1. **Защита правил (инвариантов)** — чувствительные данные меняются **только** через методы, которые проверяют, что они остаются допустимыми. «Баланс не отрицательный», «оценка от 0 до 100», «статус движется только вперёд».
2. **Сокрытие реализации** — тому, кто пользуется классом, нужно знать, **что** он делает, а не **как**. Так можно менять «как», не ломая того, кто пользуется.

#### Публичный интерфейс и реализация

| | Публичный интерфейс | Реализация |
|---|---|---|
| Что | Методы и атрибуты, которые **предназначены** для использования снаружи | Данные и внутренние методы, благодаря которым интерфейс работает |
| Пример в банковском счёте | `deposit()`, `withdraw()`, `get_balance()` | Как хранится баланс, список операций, вспомогательные проверки |
| Стабильность | Меняется редко. От него зависят другие | Можно свободно менять |

{{< box "analogy" "Аналогия: банкомат" >}}
У банкомата есть **публичный интерфейс**: экран, клавиши, щель для карты, отверстие для купюр. Можно «снять 200», и банкомат **проверяет** код, баланс и дневной лимит. **Сейф внутри** (реализация) заперт. Если бы кто угодно мог открыть сейф и взять деньги напрямую, все проверки потеряли бы смысл. И банк может заменить механизм подсчёта внутри так, что вы не заметите, потому что интерфейс остаётся тем же.
{{< /box >}}

#### Как это делает Python: соглашения

В языках вроде Java есть настоящая «блокировка» (`private`). **В Python абсолютной блокировки нет.** Есть **соглашения**, которые уважают все разработчики на Python:

| Запись | Название | Смысл | Что делает Python |
|---|---|---|---|
| `name` | Public (публичный) | Часть интерфейса. Можно использовать снаружи | Ничего |
| `_name` | Protected (соглашение) | **«Внутреннее, не трогать снаружи»** | **Ничего**. Это только сообщение разработчикам. VS Code также не предложит его в автодополнении |
| `__name` | Private (искажение имён) | **«Очень внутреннее»** | **Меняет имя** за кулисами, чтобы обращение снаружи «по ошибке» не сработало |

**Философия Python:** «Мы все здесь взрослые люди». Язык доверяет вам. Подчёркивание говорит «это не для вас». Кто всё равно трогает — берёт ответственность на себя.

#### Искажение имён (Name Mangling)

Когда вы пишете `self.__balance` внутри класса `BankAccount`, Python на самом деле **меняет** имя на `_BankAccount__balance`. Поэтому `account.__balance` снаружи падает с `AttributeError`. Это не безопасность (обратиться всё ещё можно через `account._BankAccount__balance`), но это предотвращает доступ **по ошибке** и делает намерение очень ясным.

**На этом курсе:** мы в основном будем использовать **`_name`** (одно подчёркивание) — самый принятый способ в настоящем коде на Python. С `__name` знакомимся, чтобы знать, что он делает, когда встретим его.

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

```python
class ClassName:
    def __init__(self, value):
        self.public_value = value      # public: part of the interface
        self._internal = []            # protected by convention: "don't touch from outside"
        self.__secret = 0              # private: name-mangled to _ClassName__secret

    def public_method(self):           # public interface
        self._helper()
        return self.__secret

    def _helper(self):                 # internal helper method
        ...
```

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

`week-3/encapsulated_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

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

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance

account = BankAccount("Dana", 100)
account.deposit(50)
account.withdraw(30)
print("Balance:", account.get_balance())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Rejected:", error)
print("Balance still:", account.get_balance())
```

**Вывод:**

```text
Balance: 120
Rejected: insufficient funds
Balance still: 120
```

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

| Код | Объяснение |
|---|---|
| `self.owner` | **Публичный**. Можно читать, и нет особого правила, которое нужно защищать. |
| `self._balance` | **Protected (соглашение)**. Подчёркивание говорит каждому разработчику: «баланс напрямую не меняют». |
| `deposit` / `withdraw` | **Публичный интерфейс для изменений**: **единственный** достойный способ изменить баланс. Каждое изменение проходит проверку. |
| `get_balance()` | **Геттер**: метод, который возвращает значение для чтения. Снаружи баланс можно **читать**, но нельзя **менять** напрямую. |
| `account.withdraw(1000)` не проходит | И баланс **не изменился**. Правило соблюдено. |

**А если кто-то всё же напишет `account._balance = -999`?** Python его не остановит. Но подчёркивание превращает это в **явную, заметную ошибку** в коде: каждый, кто делает Code Review, сразу увидит «трогает `_balance` снаружи — нельзя». Именно в этом цель.

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

**`__private` и искажение имён в действии, и внутренний вспомогательный метод.** `week-3/secure_vault.py`:

```python
class Vault:
    MAX_ATTEMPTS = 3

    def __init__(self, code):
        self.__code = code
        self._failed_attempts = 0
        self.is_locked = False

    def open(self, code):
        if self.is_locked:
            return "Vault is locked"
        if self._check_code(code):
            self._failed_attempts = 0
            return "Vault opened"
        self._failed_attempts = self._failed_attempts + 1
        if self._failed_attempts >= Vault.MAX_ATTEMPTS:
            self.is_locked = True
            return "Wrong code - vault is now LOCKED"
        return "Wrong code (" + str(self._failed_attempts) + "/" + str(Vault.MAX_ATTEMPTS) + ")"

    def _check_code(self, code):
        return code == self.__code

vault = Vault("1234")
print(vault.open("0000"))
print(vault.open("1234"))

try:
    print(vault.__code)
except AttributeError as error:
    print("AttributeError:", error)

print("Mangled name:", vault._Vault__code)
```

**Вывод:**

```text
Wrong code (1/3)
Vault opened
AttributeError: 'Vault' object has no attribute '__code'
Mangled name: 1234
```

**Разбор:**

- `self.__code` — внутри класса вы пишете `__code`, а Python на самом деле хранит его как `_Vault__code`.
- `vault.__code` снаружи → `AttributeError`. «По ошибке» к нему не обратиться.
- `vault._Vault__code` → работает! **Искажение имён — не безопасность**. Настоящий пароль никогда не хранят в коде (и уж точно не открытым текстом). Здесь это лишь пример для демонстрации.
- `_check_code` — **внутренний вспомогательный метод**. Он часть реализации, а не интерфейса. Завтра его можно заменить зашифрованной проверкой, и никакой внешний код не сломается, потому что снаружи никто не должен его вызывать.
- `is_locked` — публичный, для чтения. (Снаружи в него можно и записать, и это маленькая «дыра». В следующей главе увидим, как сделать атрибут только для чтения с помощью `@property`.)

### Итоги

- **Инкапсуляция** = данные + код, который с ними работает, вместе, с открытием только **интерфейса**.
- **Зачем:** (1) защита правил: изменения только через методы, которые проверяют. (2) сокрытие реализации: можно менять «как», не ломая того, кто пользуется.
- `name` = публичный. **`_name`** = «внутреннее, не трогать» (соглашение, самое распространённое). `__name` = искажение имён (`_Class__name`).
- Python не блокирует. Соглашения делают намерение ясным и предотвращают ошибки. Это **не** безопасность.
- Возвращайте **копию** внутреннего списка/словаря, а не оригинал.
- Не прячьте всё подряд. Прячьте, когда есть правило или деталь реализации.

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

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

- [Private Variables](https://docs.python.org/3/tutorial/classes.html#private-variables)

<!-- docs:end -->

## Глава 3: Проверка — геттеры, сеттеры и `@property`

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

- Что такое **геттер** (Getter) и **сеттер** (Setter).
- **Проверку данных** (Validation): проверку каждого нового значения **до** его сохранения.
- **`@property`**: способ Python делать это элегантно.
- **Атрибут только для чтения** и **вычисляемый атрибут**.

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

В предыдущей главе мы защитили `_balance`, потому что у него были естественные методы (`deposit`, `withdraw`). А как быть с данными, которые **должны** меняться напрямую, только **в рамках правил**?

- **Возраст** пользователя: его можно обновить, но не на (-5) и не на 300.
- **Цена** товара: её можно изменить, но не на отрицательную.
- **Email**: его можно обновить, но только на адрес с `@`.

Если `age` — обычный атрибут, кто угодно может написать `user.age = -5`. Если его спрятать (`_age`), нужен **упорядоченный** способ читать и менять его. Это и есть геттер и сеттер.

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

#### Геттер и сеттер — идея

- **Геттер** — метод, который возвращает значение внутренних данных. `get_age()`.
- **Сеттер** — метод, который получает новое значение, **проверяет его** и сохраняет, только если оно допустимо. `set_age(new_age)`.

Так у класса **полный контроль**: каждое чтение и каждая запись проходят через него.

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

#### Проблема с `get_` и `set_` в Python

Это работает, но громоздко:

```python
user.set_age(user.get_age() + 1)
```

По сравнению с:

```python
user.age = user.age + 1
```

Кроме того, если сначала `age` был обычным атрибутом и в 50 местах кода написано `user.age`, а потом вы решили, что нужна проверка, придётся поменять все 50 мест на `get_`/`set_`.

#### `@property` — решение Python

**`@property`** позволяет определить метод, который **снаружи выглядит как обычный атрибут**:

- Когда вы пишете `user.age`, вызывается **метод-геттер**.
- Когда вы пишете `user.age = 30`, вызывается **метод-сеттер** с проверкой.

Код, который пользуется классом, пишет `user.age`, как обычно, но за кулисами каждое обращение проходит через проверки. **Лучшее из обоих миров.**

`@property` — это **декоратор**, ровно то, что мы изучили в уроке о декораторах: строка, начинающаяся с `@` над `def`, которая «оборачивает» функцию дополнительным поведением. Здесь достаточно знать шаблон `@property`. (В темах бэкенда и баз данных FastAPI будет использовать декораторы вроде `@app.get(...)`.)

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

**Версия 1 — классические методы геттер/сеттер (идея):**

```python
class ClassName:
    def __init__(self, value):
        self._value = None
        self.set_value(value)          # validation also on creation

    def get_value(self):
        return self._value

    def set_value(self, new_value):
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
```

**Версия 2 — `@property` (принятый способ в Python):**

```python
class ClassName:
    def __init__(self, value):
        self.value = value             # goes through the setter below!

    @property
    def value(self):                   # getter: runs on  obj.value
        return self._value

    @value.setter
    def value(self, new_value):        # setter: runs on  obj.value = x
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
```

- У обоих методов **одинаковое имя** (`value`) — имя публичного «атрибута».
- Настоящие данные хранятся под **другим** именем, с подчёркиванием (`_value`).
- `@property` — над геттером. `@value.setter` (имя свойства + `.setter`) — над сеттером.
- **Только для чтения:** определите только `@property`, без сеттера.

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

**Идея — классические геттер и сеттер.** `week-3/user_classic.py`:

```python
class User:
    MIN_AGE = 0
    MAX_AGE = 120

    def __init__(self, name, age):
        self.name = name
        self._age = None
        self.set_age(age)

    def get_age(self):
        return self._age

    def set_age(self, new_age):
        if not isinstance(new_age, int):
            raise TypeError("age must be an int, got " + type(new_age).__name__)
        if new_age < User.MIN_AGE or new_age > User.MAX_AGE:
            raise ValueError("age must be between 0 and 120, got " + str(new_age))
        self._age = new_age

dana = User("Dana", 24)
dana.set_age(dana.get_age() + 1)
print(dana.name, dana.get_age())

for bad_value in [-5, "thirty"]:
    try:
        dana.set_age(bad_value)
    except (ValueError, TypeError) as error:
        print("Rejected:", error)

print("Age unchanged:", dana.get_age())
```

**Вывод:**

```text
Dana 25
Rejected: age must be between 0 and 120, got -5
Rejected: age must be an int, got str
Age unchanged: 25
```

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

| Код | Объяснение |
|---|---|
| `self._age = None`, а затем `self.set_age(age)` | Даже при создании возраст проходит **через сеттер**, поэтому нельзя создать пользователя с недопустимым возрастом. Проверка **в одном месте**. |
| `get_age()` | Геттер: возвращает внутреннее значение. |
| `set_age(new_age)` | Сеттер: сначала проверяет **тип** (`isinstance`), затем **диапазон**, и только потом сохраняет. |
| `type(new_age).__name__` | Имя типа чистым текстом (`"str"`) вместо `<class 'str'>`. |
| `dana.set_age(dana.get_age() + 1)` | Работает, но громоздко. Это мотивация для `@property`. |
| `Age unchanged: 25` | Недопустимые значения **не сохранились**. |

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

**Та же идея с `@property`, плюс атрибут только для чтения и вычисляемый атрибут.** `week-3/product_property.py`:

```python
class Product:
    def __init__(self, sku, name, price):
        self._sku = sku
        self.name = name
        self.price = price

    @property
    def sku(self):
        return self._sku

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        if not isinstance(new_price, (int, float)):
            raise TypeError("price must be a number")
        if new_price <= 0:
            raise ValueError("price must be positive, got " + str(new_price))
        self._price = new_price

    @property
    def price_with_vat(self):
        return round(self._price * 1.17, 2)

laptop = Product("A1", "Laptop", 3000)
print(laptop.sku, laptop.name, laptop.price, laptop.price_with_vat)

laptop.price = laptop.price * 0.9
print("After discount:", laptop.price, laptop.price_with_vat)

try:
    laptop.price = -100
except ValueError as error:
    print("Rejected:", error)

try:
    laptop.sku = "HACKED"
except AttributeError as error:
    print("Rejected:", error)

try:
    Product("A2", "Ghost", 0)
except ValueError as error:
    print("Cannot create:", error)
```

**Вывод:**

```text
A1 Laptop 3000 3510.0
After discount: 2700.0 3159.0
Rejected: price must be positive, got -100
Rejected: property 'sku' of 'Product' object has no setter
Cannot create: price must be positive, got 0
```

**Разбор:**

- `laptop.price` — **выглядит** как атрибут, но на самом деле запускает метод-геттер. Без скобок!
- `laptop.price = laptop.price * 0.9` — совершенно естественный синтаксис, но он проходит через сеттер с проверками.
- `self.price = price` **в `__init__`** — это тоже проходит через сеттер. Поэтому `Product("A2", "Ghost", 0)` падает уже при создании. Одна проверка для обоих случаев.
- `sku` — `@property` **без сеттера** → **только для чтения**. Код товара не должен меняться после создания. Попытка изменить его выбрасывает `AttributeError`.
- `price_with_vat` — **вычисляемое свойство**: у него нет собственных данных, и оно вычисляется из `_price` при каждом обращении. Всегда актуально (помните `description`, которое устарело? Вот ещё более элегантное решение).
- `name` — остаётся обычным атрибутом, потому что на нём нет правила. **Не добавляйте свойство без причины.**

### Итоги

- **Геттер** возвращает внутренние данные. **Сеттер** получает значение, **проверяет** его и сохраняет. Так класс контролирует каждое чтение и запись.
- В Python используют **`@property`** (геттер) и **`@name.setter`** (сеттер): снаружи это выглядит как обычный атрибут.
- Настоящие данные хранятся под именем с подчёркиванием (`_price`). То же самое имя вызывает рекурсию.
- В `__init__` пишите `self.price = price`, чтобы и создание проходило через проверку.
- Свойство **без сеттера** = только для чтения. **Вычисляемое** свойство = всегда актуально.
- Не добавляйте свойство без правила. Начинайте с обычного атрибута и повышайте, когда нужно.

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

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

- [property()](https://docs.python.org/3/builtins/functions.html#property)

<!-- docs:end -->

## Глава 4: Полноценный класс — ответственность, правила и интерфейс

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

До сих пор мы изучали **части**: `__init__`, методы, атрибуты класса, инкапсуляцию и свойства. Теперь соединим их все в один **рабочий процесс**: как построить полноценный хороший класс с нуля.

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

Настоящая трудность ООП не в синтаксисе, а в **решениях**: за что отвечает класс? Что открыть? Что скрыть? Какие правила он обязан соблюдать? Новички, которые сразу бросаются писать код, получают класс, который делает всё, открывает всё и нарушает правила. Фиксированный процесс это предотвращает.

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

#### Процесс — 6 шагов

| Шаг | Вопрос | Результат |
|---|---|---|
| **1. Ответственность** | Что делает этот класс, **одним предложением**? | Предложение |
| **2. Данные** | Что ему нужно **помнить**? | Список атрибутов |
| **3. Публичный интерфейс** | Что внешнему миру нужно **делать** с ним и **знать** о нём? | Список методов и свойств |
| **4. Правила (инварианты)** | Что **всегда должно оставаться верным**? | Список правил |
| **5. Реализация** | Как данные хранятся внутри? Какие вспомогательные методы? | Код |
| **6. Тестирование** | Соблюдено ли каждое правило? А граничные случаи? | Проверочный скрипт |

**Инвариант** (Invariant) = условие, которое должно быть верным **в каждый момент** жизни объекта. Например, «баланс ≥ 0» или «число записанных студентов ≤ числа мест». Каждый метод, который меняет состояние, должен оставлять все инварианты верными.

**«Одно предложение» — проверка на SRP:** если в предложении нужно «и ещё... и ещё...», это, вероятно, два класса.

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

Нового синтаксиса нет. Мы объединим всё, что изучили:

```python
class Name:
    CLASS_CONSTANT = ...                 # shared rules / constants

    def __init__(self, ...):             # validation through properties
        ...

    @property                            # read access / read-only / computed
    def value(self): ...

    def command(self, ...):              # changes state, keeps invariants
        ...

    def query(self): ...                 # returns info, changes nothing

    def _helper(self): ...               # internal

    def __str__(self): ...               # human-readable
```

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

**Задача:** система записи на курсы. Построим класс с именем `Course` по 6 шагам.

**Шаг 1 — ответственность:** «`Course` управляет записью на один курс: кто записан, сколько мест осталось и лист ожидания.»

**Шаг 2 — данные:** код курса, название, максимальное число мест, список записанных студентов, лист ожидания.

**Шаг 3 — публичный интерфейс:**

| Член интерфейса | Вид | Описание |
|---|---|---|
| `code`, `title`, `capacity` | свойства (только для чтения) | Данные курса |
| `enroll(student)` | Сеттер | Записывает. Если мест нет, ставит студента в лист ожидания. Возвращает `"enrolled"` или `"waitlisted"` |
| `drop(student)` | Сеттер | Отменяет запись. Место занимает первый из листа ожидания |
| `is_enrolled(student)` | Геттер | |
| `available_seats` | вычисляемое свойство | |
| `is_full` | вычисляемое свойство | |
| `roster()` | Геттер | Копия списка записанных студентов |
| `__str__` | Отображение | |

**Шаг 4 — правила:**

1. Число записанных студентов ≤ `capacity`, **всегда**.
2. Студент не записывается дважды и не бывает одновременно записанным и ожидающим.
3. `capacity` — положительное целое число.
4. Когда освобождается место и есть ожидающие студенты, проходит **первый** в очереди (FIFO, First In First Out).

**Шаг 5 — реализация.** `week-3/course.py`:

```python
class Course:
    MAX_CAPACITY = 500

    def __init__(self, code, title, capacity):
        if not isinstance(capacity, int) or capacity <= 0 or capacity > Course.MAX_CAPACITY:
            raise ValueError("capacity must be a whole number between 1 and " + str(Course.MAX_CAPACITY))
        self._code = code
        self._title = title
        self._capacity = capacity
        self._enrolled = []
        self._waitlist = []

    @property
    def code(self):
        return self._code

    @property
    def title(self):
        return self._title

    @property
    def capacity(self):
        return self._capacity

    @property
    def available_seats(self):
        return self._capacity - len(self._enrolled)

    @property
    def is_full(self):
        return self.available_seats == 0

    def is_enrolled(self, student):
        return student in self._enrolled

    def enroll(self, student):
        self._ensure_not_registered(student)
        if self.is_full:
            self._waitlist.append(student)
            return "waitlisted"
        self._enrolled.append(student)
        return "enrolled"

    def drop(self, student):
        if student in self._waitlist:
            self._waitlist.remove(student)
            return
        if student not in self._enrolled:
            raise ValueError(student + " is not registered to " + self._code)
        self._enrolled.remove(student)
        if self._waitlist:
            next_student = self._waitlist.pop(0)
            self._enrolled.append(next_student)

    def roster(self):
        return list(self._enrolled)

    def waitlist(self):
        return list(self._waitlist)

    def _ensure_not_registered(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered to " + self._code)

    def __str__(self):
        return (self._code + " " + self._title + " | " + str(len(self._enrolled)) + "/"
                + str(self._capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))
```

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

| Часть | Как она служит проекту |
|---|---|
| Проверка в `__init__` | Правило 3: нельзя создать курс с недопустимым `capacity`. |
| `_enrolled`, `_waitlist` (внутренние) | Инкапсуляция: никто снаружи не может сделать `append` и обойти правило 1. |
| `code`, `title`, `capacity` как свойства без сеттера | Только для чтения: данные курса не меняются после создания. |
| `available_seats`, `is_full` вычисляются | Нет дублирующихся данных, которые могут рассинхронизироваться. Они всегда выводятся из `_enrolled`. |
| `enroll` | Правило 2 (через `_ensure_not_registered`) и правило 1 (проверка `is_full` до добавления). |
| `drop` | Правило 4: `pop(0)` = первый, кто встал в очередь, выходит первым. |
| `roster()` / `waitlist()` возвращают **копию** | Тот, кто получил список, не может изменить внутренний. |
| `_ensure_not_registered` | Внутренний вспомогательный метод: одно правило в одном месте. |
| `__str__` | Читаемый снимок для отладки и печати. |

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

**Шаг 6 — тестирование.** Скрипт, который проверяет каждое правило, включая граничные случаи. В конце `course.py`:

```python
def main():
    course = Course("PY101", "Intro to Python", 2)

    print(course.enroll("Dana"))
    print(course.enroll("Avi"))
    print(course.enroll("Noa"))
    print(course.enroll("Tom"))
    print(course)

    try:
        course.enroll("Dana")
    except ValueError as error:
        print("Rule 2 holds:", error)

    course.drop("Avi")
    print("After Avi dropped:", course.roster(), "| waitlist:", course.waitlist())

    leaked = course.roster()
    leaked.append("Hacker")
    print("Roster after outside append:", course.roster())

    try:
        course.drop("Ghost")
    except ValueError as error:
        print("Drop unknown:", error)

    try:
        Course("BAD1", "Broken", 0)
    except ValueError as error:
        print("Rule 3 holds:", error)

    print("Seats:", course.available_seats, "| full?", course.is_full)

if __name__ == "__main__":
    main()
```

**Вывод:**

```text
enrolled
enrolled
waitlisted
waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 2
Rule 2 holds: Dana is already registered to PY101
After Avi dropped: ['Dana', 'Noa'] | waitlist: ['Tom']
Roster after outside append: ['Dana', 'Noa']
Drop unknown: Ghost is not registered to PY101
Rule 3 holds: capacity must be a whole number between 1 and 500
Seats: 0 | full? True
```

**Что доказала проверка:**

- Правило 1: никогда не больше 2 записанных студентов. Третий и четвёртый попали в лист ожидания.
- Правило 2: Дана не записалась дважды.
- Правило 4: когда Ави ушёл, прошла **Ноа** (первая в очереди), а Том остался в листе ожидания.
- Инкапсуляция: `append` к копии не изменил настоящий список.
- Правило 3: курс с 0 мест не был создан.

### Итоги

- **Процесс:** ответственность (одно предложение) → данные → публичный интерфейс → правила (инварианты) → реализация → тестирование.
- **Инвариант** = то, что всегда должно быть верным. Каждый сеттер должен сохранять их все.
- Внутренние данные скрыты (`_x`). Фиксированные данные — свойства только для чтения. Производная информация — вычисляемые свойства.
- Возвращайте **копии** внутренних коллекций. Одно правило → один вспомогательный метод (`_ensure_...`).
- Проверяйте каждое правило и граничные случаи, а не только «счастливый путь».

<!-- 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 -->

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

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

**Что вы отрабатываете на этой странице:** список объектов, объект, который хранит другой объект, сокрытие данных (инкапсуляция), свойство с проверкой и полноценный класс с правилами.

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

### Упражнение 1: Список игроков

**Что вы строите:** таблицу игроков в игре — и находите лидера.

**Что написать:**
1. Класс с именем `Player`. `__init__` получает имя, а счёт начинается с 0.
2. `add_points` — получает количество очков и добавляет их к счёту.
3. `__str__` — возвращает имя и счёт, например `Dana: 50`.
4. После проверочного кода: переберите список, найдите игрока с наибольшим счётом и напечатайте его.

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

```python
players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)
```

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

```text
Leader: Dana: 50
```

{{< solution >}}
```python
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

    def add_points(self, points):
        self.score = self.score + points

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


players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

leader = players[0]
for player in players:
    if player.score > leader.score:
        leader = player
print("Leader:", leader)
```

```text
Leader: Dana: 50
```
{{< /solution >}}

### Упражнение 2: Объект, который хранит объект

**Что вы строите:** клиента и заказы — когда несколько заказов хранят **одного и того же** клиента.

**Что написать:**
1. Класс с именем `Customer` с именем и городом.
2. Класс с именем `Order` с клиентом (объектом `Customer`) и суммой.
3. Создайте одного клиента и два заказа, которые получают один и тот же объект клиента.
4. Напечатайте город через оба заказа, смените город клиента на Tel Aviv и напечатайте снова.

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

```text
Haifa Haifa
Tel Aviv Tel Aviv
```

**Чему это учит:** заказ хранит не копию клиента, а ссылку на него.

{{< solution >}}
```python
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city


class Order:
    def __init__(self, customer, total):
        self.customer = customer
        self.total = total


dana = Customer("Dana", "Haifa")
order1 = Order(dana, 120)
order2 = Order(dana, 80)
print(order1.customer.city, order2.customer.city)

dana.city = "Tel Aviv"
print(order1.customer.city, order2.customer.city)
```

```text
Haifa Haifa
Tel Aviv Tel Aviv
```

Заказы хранят не **копию** клиента, а **ссылку** на него. Изменение клиента видно из каждого заказа, который его хранит.
{{< /solution >}}

### Упражнение 3: Защищённый BankAccount

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

**Что написать:**
1. Класс с именем `BankAccount` с владельцем и балансом. Храните баланс в `_balance` — подчёркивание говорит «внутреннее, не трогать снаружи».
2. `deposit` — добавляет сумму.
3. `withdraw` — снимает сумму. Если денег не хватает: `ValueError` с сообщением из вывода.
4. `get_balance` — возвращает баланс.

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

```python
account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
```

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

```text
error: not enough money
150
```

{{< solution >}}
```python
class BankAccount:
    def __init__(self, owner, balance=0):
        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("not enough money")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance


account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
```

```text
error: not enough money
150
```
{{< /solution >}}

### Упражнение 4: Сеттер, который проверяет

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

**Что написать:**
1. Класс с именем `User`, `__init__` которого получает возраст и сохраняет его через свойство.
2. Геттер с `@property` с именем `age` — возвращает возраст, сохранённый в `_age`.
3. Сеттер с `@age.setter` — проверяет, что возраст от 0 до 120. Если нет: `ValueError` с сообщением из вывода.
4. Обратите внимание: присваивание в `__init__` тоже проходит через проверку.

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

```python
user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
```

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

```text
31
error: age must be 0-120
31
```

{{< solution >}}
```python
class User:
    def __init__(self, age):
        self.age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0 or value > 120:
            raise ValueError("age must be 0-120")
        self._age = value


user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
```

```text
31
error: age must be 0-120
31
```

- `self.age = age` в `__init__` **проходит через сеттер** — поэтому новый объект тоже проверяется.
- Значение сохраняется в `_age`, а свойство открывает его так, будто это обычный атрибут.
{{< /solution >}}

### Упражнение 5: Запись на курс

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

**Что написать:**
1. Класс с именем `Course` с кодом курса, названием и вместимостью, а также двумя внутренними списками: записанные и ожидающие.
2. `enroll` — получает имя студента:
   - если он уже записан или ожидает: `ValueError` с сообщением из вывода;
   - если есть место: добавляет его в записанные и возвращает `"enrolled"`;
   - иначе: добавляет его в лист ожидания и возвращает `"waitlisted"`.
3. `__str__` — код, название, сколько записано из вместимости и сколько ожидает.

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

```python
course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
```

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

```text
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
```

{{< solution >}}
```python
class Course:
    def __init__(self, code, name, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.code = code
        self.name = name
        self.capacity = capacity
        self._enrolled = []
        self._waitlist = []

    def enroll(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered")
        if len(self._enrolled) < self.capacity:
            self._enrolled.append(student)
            return "enrolled"
        self._waitlist.append(student)
        return "waitlisted"

    def __str__(self):
        return (self.code + " " + self.name + " | " + str(len(self._enrolled)) + "/"
                + str(self.capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))


course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
```

```text
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
```
{{< /solution >}}

## Мини-проект: Склад магазина

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

**Чему вы научитесь в проекте:**
- Скрывать данные (инкапсуляция): менять их только через методы, которые проверяют
- Использовать свойство: с проверяющим сеттером, только для чтения и вычисляемое
- Строить класс, который хранит список других объектов
- Сортировать объекты по вычисляемому значению

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

**Часть А — товар (`Product`)**

**Шаг 1 — создание.** `__init__` получает название, цену и количество (по умолчанию 0). Название сохраняется в `_name`, количество — в `_quantity`, а цена проходит через сеттер `price`.

**Шаг 2 — цена.** `price` — свойство с сеттером, который проверяет, что цена больше 0. Иначе: `ValueError`.

**Шаг 3 — поля только для чтения.** `name` и `quantity` — свойства без сеттера: снаружи их можно читать, но нельзя менять.

**Шаг 4 — изменение количества.** Количество меняется только через два метода:
- `add_stock` — добавляет количество. Если сумма не положительная: `ValueError`.
- `sell` — убавляет количество. Если на складе не хватает: `ValueError` с названием товара.

**Шаг 5 — вычисляемое значение и отображение.** `total_value` — вычисляемое свойство: цена, умноженная на количество. `__str__` показывает товар, как в выводе.

**Часть Б — склад (`Inventory`)**

**Шаг 6 — внутренний список.** `Inventory` хранит внутренний список с именем `_products`.

**Шаг 7 — добавление и поиск.** `add` добавляет товар. Если уже есть товар с таким же названием: `ValueError`. `find` получает название и возвращает товар или `None`.

**Шаг 8 — отчёты.** `by_value` возвращает копию списка товаров, отсортированную по `total_value` от наибольшей к наименьшей. `total_value` склада — вычисляемое свойство: сумма стоимостей всех товаров.

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

```text
price must be positive
amount must be positive
not enough <product name>
<product name> already exists
```

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

```python
shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
```

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

```text
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
```

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

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

{{< solution >}}
```python
class Product:
    def __init__(self, name, price, quantity=0):
        self._name = name
        self.price = price
        self._quantity = quantity

    @property
    def name(self):
        return self._name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("price must be positive")
        self._price = value

    @property
    def quantity(self):
        return self._quantity

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

    def sell(self, amount):
        if amount > self._quantity:
            raise ValueError("not enough " + self._name)
        self._quantity = self._quantity - amount

    @property
    def total_value(self):
        return self._price * self._quantity

    def __str__(self):
        return (self._name + ": " + str(self._quantity) + " x " + str(self._price)
                + " = " + str(self.total_value))


class Inventory:
    def __init__(self):
        self._products = []

    def add(self, product):
        if self.find(product.name) is not None:
            raise ValueError(product.name + " already exists")
        self._products.append(product)

    def find(self, name):
        for product in self._products:
            if product.name == name:
                return product
        return None

    def by_value(self):
        return sorted(self._products, key=lambda p: p.total_value, reverse=True)

    @property
    def total_value(self):
        total = 0
        for product in self._products:
            total = total + product.total_value
        return total


shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
```

```text
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
```

- `Inventory` **хранит** объекты `Product` (объект, который хранит объекты), а `by_value` возвращает **новый** список — `sorted` не трогает внутренний список.
- `_name`, `_price`, `_quantity` скрыты. Снаружи количество меняется только через `add_stock` и `sell`, которые проверяют правила.
- У `price` есть сеттер, который проверяет, — в том числе в `__init__`. `name` и `quantity` — только для чтения, а `total_value` вычисляется при каждом чтении.

**Бонус:**

```python
    def low_stock(self, limit):
        names = []
        for product in self._products:
            if product.quantity < limit:
                names.append(product.name)
        return names
```

(Метод внутри `Inventory`.) С данными выше `shop.low_stock(4)` возвращает `['Bag']`.
{{< /solution >}}

<!-- exercises:end -->
