📦 Репозиторий занятия 76

Урок 76. Абстрактные классы и пользовательские исключения

Как работать с репозиторием

Каждая карточка говорит, о чём файл, что он выводит и что в нём искать. Код виден прямо здесь: его можно скопировать одной кнопкой, скачать файл или открыть его целиком.

Маршрут изучения

  1. Прочитайте описание: по нему уже понятно, о чём файл и что он выведет.
  2. Предскажите вывод: сравните своё предположение со строкой «Что выводит».
  3. Запустите: скачайте файл или скопируйте код кнопкой и выполните его у себя.
  4. Измените: поменяйте одно условие или значение и объясните новый результат.

Файлы: рекомендуемый порядок

1
MarkdownРазбор концепции53 строк

Зачем нужны абстрактные классы и методы — обоснование до синтаксиса

less_39___abstract_class__custom_Exceptions/theory_01__abstract_classes_and_methods__rationale.md

Чисто концептуальный файл без кода классов: объясняет ЗАЧЕМ нужны абстрактные классы, прежде чем показывать КАК их писать (реализация — в следующем файле). Перечисляет четыре причины: единый интерфейс для наследников (все Dog/Cat/Bird обязаны иметь make_sound()), гибкость и расширяемость (новый наследник автоматически работает со старым кодом), защита от создания «недопустимых» объектов с нереализованными методами, и возможность сочетать в одном классе готовый код и обязательные для реализации методы.

  • Причина 1: единый интерфейс — animals = [Dog(), Cat()]; animal.make_sound()
  • Причина 2: гибкость — новый наследник (Bird) работает с существующим кодом без изменений
  • Причина 3: защита от ошибок — нельзя случайно создать объект с недоделанными методами
  • Причина 4: комбинация готового кода и обязательного интерфейса в одном классе
  • Итоговый список из 4 пунктов
Показать начало файла (53 строк всего)
## Зачем нужны абстрактные классы и методы?

### 1. Обеспечивают единый интерфейс

Абстрактный класс задаёт «контракт» для всех наследников.

* Пример: если у нас есть разные виды животных (`Dog`, `Cat`, `Bird`), у всех есть метод `make_sound()`.
* Абстрактный класс `Animal` гарантирует, что любой новый класс-наследник тоже будет иметь метод `make_sound()`.

**Плюс:** мы можем обращаться к объектам через общий тип:

```python
animals = [Dog(), Cat()]
for animal in animals:
    animal.make_sound()  # Не важно, кто это — все обязаны иметь make_sound
```

---

### 2. Позволяют писать более гибкий и расширяемый код

* Мы создаём общую структуру один раз (абстрактный класс).
* Любой новый тип наследника может расширять или менять поведение, не ломая остальной код.

**Пример:** добавили новый класс `Bird`, он автоматически работает с кодом, который работает с `Animal`.

---

### 3. Защищают от неправильного использования

* Нельзя случайно создать объект абстрактного класса, где некоторые методы ещё не реализованы.
* Это предотвращает ошибки ещё на этапе написания кода.

---

### 4. Позволяют комбинировать реализацию и интерфейс

* Абстрактный класс может содержать 
  * и **частично готовый код** (обычные методы) 
  * и **обязательные методы для реализации** (абстрактные).
* Это экономит повторное написание кода и делает проект более поддерживаемым.

---

### Резюме

**Абстрактные классы нужны для того, чтобы:**

1. Обеспечить единый интерфейс для всех наследников.
2. Сделать код расширяемым и гибким.
3. Предотвратить создание «недопустимых» объектов.
4. Частично реализовать общую функциональность.
Проверьте себя: Что конкретно произойдёт при попытке создать объект нового класса-наследника Bird(Animal), если Bird забыл реализовать обязательный метод make_sound()?
2
MarkdownРазбор концепции70 строк

Абстрактные классы и методы: реализация через ABC и @abstractmethod

less_39___abstract_class__custom_Exceptions/theory_02__abstract_classes_and_methods__implementation.md

Даёт конкретный синтаксис абстрактного класса: наследование от ABC (из модуля abc) и декоратор @abstractmethod над методом-заглушкой. На примере Animal(ABC) с абстрактным make_sound() и обычным sleep() показывает, что Animal() напрямую создать нельзя, а наследники Dog и Cat обязаны реализовать make_sound(), получая при этом бесплатно унаследованный sleep(). Завершается четырьмя формальными правилами: абстрактный класс нельзя инстанцировать, наследник обязан реализовать все абстрактные методы (иначе тоже станет абстрактным), допускаются и обычные методы, абстрактные методы задают обязательный интерфейс.

  • class Animal(ABC): @abstractmethod def make_sound(self): pass
  • def sleep(self): — обычный метод с готовой реализацией внутри абстрактного класса
  • a = Animal() — закомментировано, вызвало бы ошибку
  • class Dog(Animal)/Cat(Animal) — реализуют make_sound(), наследуют sleep()
  • Четыре формальных правила абстрактных классов и методов

Что выводит: dog.make_sound() печатает 'Гав-гав!', dog.sleep() печатает 'Сплю...', cat.make_sound() печатает 'Мяу!' (комментарии в самом файле, код напрямую не запускался как отдельный .py).

Показать начало файла (70 строк всего)
### 1. Абстрактный класс

**Абстрактный класс** — это класс, который **не предназначен для непосредственного создания объектов**,  
а служит как шаблон для других классов. 

Он может содержать:

* **Абстрактные методы** — методы без реализации (только сигнатура).
* **Обычные методы** — с реализацией, которые могут использовать наследники.

**Когда использовать:**

* Когда есть общая структура/поведение для группы классов, но конкретная реализация некоторых методов различается.

---

#### Пример

```python
from abc import ABC, abstractmethod

class Animal(ABC):  # Наследуемся от ABC, чтобы сделать класс абстрактным
    @abstractmethod
    def make_sound(self):  # Абстрактный метод
        pass

    def sleep(self):  # Обычный метод с реализацией
        print("Сплю...")

# Нельзя создать объект абстрактного класса
# a = Animal()  # Ошибка!
```

---

### 2. Абстрактный метод

**Абстрактный метод** — это метод, который объявлен, но **не имеет реализации**. 

Любой класс-наследник обязан **реализовать этот метод**, иначе он тоже станет абстрактным.

### Пример реализации

```python
class Dog(Animal):
    def make_sound(self):  # Реализация абстрактного метода
        print("Гав-гав!")

class Cat(Animal):
    def make_sound(self):
        print("Мяу!")

dog = Dog()
dog.make_sound()  # Выведет "Гав-гав!"
dog.sleep()       # Выведет "Сплю..."

cat = Cat()
cat.make_sound()  # Выведет "Мяу!"
```

…
Проверьте себя: Что произойдёт при попытке выполнить `class Bird(Animal): pass` (без переопределения make_sound()) и затем `Bird()`?
3
MarkdownРазбор концепции70 строк

Пользовательские исключения: наследование от Exception для конкретной логики программы

less_39___abstract_class__custom_Exceptions/theory_03__custom_exceptions.md

Объясняет, зачем создавать собственные классы исключений вместо использования стандартных ValueError/IndexError/KeyError: это делает код понятнее (сразу видно, что пошло не так), позволяет точечно ловить конкретные ошибки вместо всего подряд через Exception, и облегчает расширение проекта новыми типами ошибок. На примере InsufficientFundsError(Exception) с собственным __init__, который сохраняет balance и amount и формирует сообщение через super().__init__(...), показана полноценная схема: raise внутри withdraw() и перехват через except InsufficientFundsError as e.

  • class MyCustomError(Exception): pass — минимальное пользовательское исключение
  • class InsufficientFundsError(Exception) с __init__(self, balance, amount)
  • super().__init__(f'Недостаточно средств...') — формирование текста ошибки
  • def withdraw(balance, amount): raise InsufficientFundsError(...) при amount > balance
  • try/except InsufficientFundsError as e: print('Ошибка:', e)
  • Рекомендация: наследоваться от близкого по смыслу встроенного исключения (ValueError/RuntimeError)

Что выводит: 'Ошибка: Недостаточно средств: баланс 100, попытка снять 150' — вывод примера withdraw(100, 150), пойманного через except InsufficientFundsError.

Показать начало файла (70 строк всего)
### 1. Что такое пользовательские исключения?

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

* Стандартные исключения: `ValueError`, `IndexError`, `KeyError` и т.д.
* Пользовательские: создаются для конкретной логики программы, 
  * например `InsufficientFundsError` в банковском приложении.

**Зачем нужны:**

1. Делают код более **понятным и читаемым** — сразу видно, что пошло не так.
2. Позволяют **точно ловить ошибки**, а не перехватывать всё подряд через `Exception`.
3. Поддерживают **расширяемость** — мы можем добавлять новые типы ошибок по мере роста проекта.

---

### 2. Как создавать пользовательские исключения?

В Python пользовательские исключения создаются через наследование от встроенного класса `Exception`(или его потомков):

```python
# Создание пользовательского исключения
class MyCustomError(Exception):
    """Ошибка, специфичная для моего приложения"""
    pass
```

* Обычно добавляют docstring, чтобы описать назначение исключения.
* Можно добавлять свои атрибуты и методы, если нужно передавать больше информации.

---

## 3. Использование пользовательских исключений

```python
class InsufficientFundsError(Exception):
    """Ошибка при недостатке средств на счете"""
    def __init__(self, balance, amount):
        super().__init__(f"Недостаточно средств: баланс {balance}, попытка снять {amount}")
        self.balance = balance
        self.amount = amount

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

# Пример использования
try:
    new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
    print("Ошибка:", e)
```

**Вывод:**

```
Ошибка: Недостаточно средств: баланс 100, попытка снять 150
…
Проверьте себя: Зачем InsufficientFundsError сохраняет self.balance и self.amount как атрибуты, если сообщение об ошибке уже полностью сформировано строкой в super().__init__()?
4
MarkdownРазбор концепции104 строк

__iter__ и __next__: как написать собственный итерируемый класс

less_39___abstract_class__custom_Exceptions/theory_04__iter__next__as_magic_methods.md

Раскрывает магические методы итерации по отдельности: __iter__() обычно возвращает сам объект (return self) и инициализирует состояние обхода (например, текущий указатель), а __next__() содержит логику получения следующего элемента, хранит позицию и бросает StopIteration при исчерпании. На примере MyRange(start, end) реализован класс, ведущий себя как range при использовании в for. В конце — эквивалентный, но более компактный вариант через генераторную функцию gen_f с yield, с подтверждением через isinstance, что генератор одновременно Iterable, Iterator и Generator.

  • class MyRange: __iter__(self): self.current = self.start; return self
  • __next__(self): проверка self.current >= self.end → StopIteration, иначе возврат и инкремент
  • for number in MyRange(1, 5): print(number) — работает как встроенный range
  • Как это работает: for сначала вызывает __iter__(), затем многократно __next__()
  • Альтернатива через генератор: def gen_f(start, end) с yield — то же самое проще
  • isinstance(g, Iterable/Iterator/Generator) — все три True для генератора

Что выводит: Цикл for number in MyRange(1, 5) печатает 1, 2, 3, 4 (комментарий в файле указывает именно этот вывод). Пример с генератором печатает True, True, True для isinstance проверок Iterable/Iterator/Generator.

Показать начало файла (104 строк всего)
### 1. Что такое магические методы итерации

Основные магические методы для итерации:

#### 1.1. `__iter__()`

* Обычно **возвращает сам объект-итератор** (`return self`), если класс реализует и `__iter__`, и `__next__` одновременно.
* Инициализирует необходимые атрибуты для начала итерации, например указатель на текущий элемент.

#### 1.2. `__next__()`

Содержит основную логику итератора:

1. **Логика получения следующего элемента** — как определить, что возвращать на текущем шаге.
2. **Ссылка на коллекцию или данные**, по которым происходит итерация (или генерация элемента).
3. **Указатель на текущую позицию** — чтобы знать, какой элемент возвращать следующим.
4. **Вызов `StopIteration`** — когда элементы исчерпаны, чтобы остановить цикл `for` или другую итерацию.

---

#### Таким образом:

* `__iter__()` → подготовка и возврат итератора.
* `__next__()` → шаг итерации: вернуть элемент или остановить итерацию.

---

### 2. Зачем нужны магические методы итерации

1. Позволяют **создавать свои последовательности** и коллекции, по которым можно проходить в цикле `for`.
2. Делают объекты **совместимыми с Python-функциями**, которые работают с итерируемыми объектами (`sum()`, `list()`, `any()`, `all()`).
3. Упрощают **ленивые вычисления**, когда элементы создаются по мере необходимости (например, бесконечные генераторы).

---

### 3. Пример реализации итератора

Создадим класс, который итерируется по диапазону чисел:

```python
class MyRange:
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        # начинает сначала при повторном вызове iter()
        self.current = self.start
        return self

    def __next__(self):
        if self.current >= self.end:
            raise StopIteration  # Конец итерации
        value = self.current
        self.current += 1
        return value

# Использование
for number in MyRange(1, 5):
    print(number)
…
Проверьте себя: Почему __iter__() в MyRange заново устанавливает self.current = self.start, а не оставляет его как есть?
5
MarkdownРазбор концепции92 строк

Разделение Iterable и Iterator: почему MyRange из предыдущего файла спроектирован неправильно

less_39___abstract_class__custom_Exceptions/theory_05__the_better_iteratior.md

Указывает на архитектурную проблему предыдущего примера: если __iter__ и __next__ реализованы в одном классе MyRange, объект одновременно и представляет коллекцию данных, и хранит состояние прохода по ней — это нарушает принцип разделения ответственности и не позволяет иметь два независимых обхода одновременно. Решение: MyRange.__iter__() возвращает НОВЫЙ отдельный объект MyRangeIterator(start, end), который и держит состояние current — теперь my_range остаётся чисто Iterable (не Iterator), а полученный через iter() объект — чисто Iterator.

  • Проблема исходного MyRange: __iter__ и __next__ в одном классе, isinstance(my_range, Iterator) — True (плохо)
  • Улучшение: MyRange.__iter__() возвращает MyRangeIterator(self.start, self.end)
  • MyRangeIterator: собственный __init__/__iter__(return self)/__next__ с состоянием current
  • isinstance(my_range, Iterable) True, isinstance(my_range, Iterator) False — теперь корректно
  • my_range_iter = iter(my_range); isinstance(my_range_iter, Iterator) — True
  • while True + next(my_range_iter) + except StopIteration: print('Done!')

Что выводит: Для исходного MyRange: isinstance(my_range, Iterable) → True, isinstance(my_range, Iterator) → True (это и есть архитектурная проблема), затем 1,2,3,4 по циклу for. Для улучшенного варианта: isinstance(my_range, Iterable) → True, isinstance(my_range, Iterator) → False; isinstance(my_range_iter, Iterable) → True, isinstance(my_range_iter, Iterator) → True; затем 1,2,3,4 и 'Done!'.

Показать начало файла (92 строк всего)
### 1. Недостатки предыдущего примера


```python
from typing import Iterable, Iterator

class MyRange:
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        # обновляет значение старта при перезапуске с помощью iter()
        self.current = self.start
        return self

    def __next__(self):
        if self.current >= self.end:
            raise StopIteration  # Конец итерации
        value = self.current
        self.current += 1
        return value


my_range = MyRange(1, 5)
print(isinstance(my_range, Iterable))
print(isinstance(my_range, Iterator))


for number in my_range:
    print(number)

```

Экземпляр класса `MaRange` является итератором, так как содержит методы `__iter__()` и `__next__()`.

Но это нарушает **принцип разделение ответственности**, когда объект одновременно представляет собой
* и набор данных 
  * (должен быть итерируемым и создавать новые итераторы)
* и процесс обхода 
  * (должен быть итератором и не должен перезапускаться)  



### 2. Улучшенный вариант итератора

Разделяем между собой `Iterator` и `Iterable`:

```python
from typing import Iterable, Iterator

class MyRange:
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        return MyRangeIterator(self.start, self.end)


…
Проверьте себя: Что стало бы возможным после разделения на MyRange и MyRangeIterator, чего нельзя было сделать с исходным «двойным» MyRange — например, если понадобится пройти по одному и тому же диапазону дважды одновременно?