
Полная версия:
Глеб Зайцев ООП на Python. 10 небольших моделей с объяснениями
- + Увеличить шрифт
- - Уменьшить шрифт

Глеб Зайцев
ООП на Python. 10 небольших моделей с объяснениями
Прежде чем создавать класс
Сначала обещание, потом class
Объектно-ориентированное программирование начинается не с поиска существительных, из которых можно сделать классы. Полезнее спросить: какая информация должна сохраняться между действиями, кто вправе её менять и что после изменения обязано оставаться верным? Класс собирает ответы в одном месте, но не придумывает их за программиста. Если вычисление получает всё необходимое в аргументах и возвращает результат, обычная функция может быть лучшим решением.
В этой книге десять небольших моделей. В каждой есть конкретная ситуация, договорённость о поведении, работающая программа и изменение требования. Решение упражнения дано целиком, чтобы не приходилось угадывать, какие строки оставить от предыдущей версии. Цель не в том, чтобы запомнить десять заготовок. Важно научиться объяснять, почему данные принадлежат именно этому объекту и какие действия поддерживают его правила.
Что нужно знать заранее
Предполагается, что вы уже пишете функции, используете списки и словари, понимаете цикл for, условие if, импорт и обработку исключения через try/except. Синтаксис класса объясняется по ходу, но это не первое знакомство с Python. Аннотация вида name: str обозначает ожидание автора кода, а не автоматически выполняемую проверку. Там, где модель обещает отвергать неверный ввод, соответствующая проверка написана явно.
Класс — описание нового типа объектов. Вызов имени класса создаёт экземпляр, а __init__ задаёт его начальное состояние. Метод — функция, вызываемая через объект; её первый параметр self получает этот экземпляр. Выражение self.items обращается к данным конкретного объекта. Имя с одним подчёркиванием, например _items, означает внутреннюю деталь по соглашению, а не недоступный сейф: клиент программы технически может нарушить это соглашение.
Как читать лабораторную
До запуска выпишите обещания программы: допустимый ввод, возвращаемое значение, возможную ошибку и состояние после неё. Слово «инвариант» в практикуме означает правило, которое верно после создания объекта и после каждого успешно завершённого публичного действия. Например, если модель хранит границы диапазона, нижняя граница не должна становиться больше верхней. Проверить одно число недостаточно, когда правило связывает несколько полей.
Прочитайте объяснение выбора, затем запустите полную программу и сравните вывод. После этого добавьте отдельные проверки из того же раздела. Они ничего не печатают при успехе; нарушенное ожидание вызывает AssertionError. Эти assert проверяют примеры, но не заменяют проверки пользовательского ввода в самой модели. Не запускайте учебные проверки с параметром -O: он отключает assert.
Попробуйте упражнение без решения. Меняется одно содержательное требование: например, кому принадлежат данные или что считается одинаковым объектом. После сравнения кода прочитайте объяснение и раздел о более простом подходе. Если требование исчезло, класс тоже может стать лишним. Сокращение ненужной модели — полезное решение, а не проигрыш в количестве использованных возможностей языка.
Окружение и независимые запуски
Программы рассчитаны на Python 3.12; фактические проверки этого издания выполнялись на CPython 3.12.14 под macOS. Сторонних пакетов нет. Каждую программу сохраняйте в отдельный новый файл model.py внутри собственной учебной папки. Не называйте файлы json.py, dataclasses.py или именем другого импортируемого модуля: такой файл может заслонить стандартную библиотеку.
В терминале перейдите в папку с файлом и запустите python3 model.py; если ваша установка предоставляет команду python, используйте python model.py. В Windows часто доступна команда py -3.12 model.py. Это варианты запуска, а не отчёт о тестировании на всех операционных системах. Демо и решение упражнения — две полные версии: не вставляйте вторую вслед за первой в один файл. Для нового варианта используйте новую папку или новое имя.
Данные находятся в памяти и приведены в книге. Программы не обращаются к сети, не создают аккаунты и не меняют ваши документы. Если в лабораторной обсуждается JSON, речь идёт о строке с учебными данными, а не о загрузке файла неизвестного происхождения. Важные ограничения входа указаны в контракте соответствующей модели.
Код в электронной читалке
Каждая логическая строка кода имеет номер и разделитель │. Начальные пробелы показаны точками ·: четыре точки соответствуют четырём пробелам. Номера, разделитель и точки отступа не входят в программу. Такой вид позволяет заметить потерянный отступ даже в читалке, которая схлопывает пробелы. Визуальный перенос длинной строки не создаёт новую логическую строку; ориентируйтесь на номера.
Можно перепечатать короткий пример, убрав служебную левую часть и заменив только начальные точки пробелами. Проверьте последовательность номеров и не соединяйте несколько разных блоков: их нумерация начинается заново. Кавычки должны оставаться прямыми, а символы внутри строковых литералов — неизменными. Во внутреннем тексте FB2 пробелы неразрывные; конкретная читалка может преобразовать их в обычные. Если неразрывные пробелы остались при копировании, перед запуском их тоже нужно заменить обычными. В конце книги дан необязательный помощник для переноса одного скопированного блока; он отказывается перезаписывать существующий файл.
Выбрать модель
1. Размер карточки как проверенное значение
2. Каталог не путает название с личностью экспоната
3. Чек-лист владеет своими отметками
4. Доска разрешает только предметные изменения
5. Статья состоит из блоков, но не является блоком
6. Таблички говорят на одном языке
7. Раунд принимает ответы, функция считает очки
8. Перенос между двумя витринами
9. Зажим выдан только на время блока
10. Макет отдельно, JSON отдельно
Десять моделей и изменения требований
Модель 1. Размер карточки как проверенное значение
Ситуация и обещания
Отделить значение от изменяемого объекта и собрать связанные правила размера в одном месте.
Для макета небольшого стенда нужны размеры карточек в миллиметрах. Один участок программы вычисляет площадь, другой поворачивает карточку, третий сравнивает два варианта. Если передавать везде отдельные width и height, каждый участок вынужден заново вспоминать, допустим ли ноль и в каком порядке идут числа.
Само вычисление площади ещё не требует класса: функция area(width, height) вполне достаточна. Здесь размер используется повторно как одна проверенная величина. Поворот должен создавать другой размер, а не незаметно менять тот, который уже получил соседний участок программы.
Контракт моделиШирина и высота — точные int больше нуля; bool, дроби и другие типы вызывают TypeError, неположительные числа — ValueError.
CardSize хранит миллиметры; area() возвращает целую площадь в квадратных миллиметрах.
Обычное присваивание полям запрещено: frozen dataclass вызывает FrozenInstanceError. Поля содержат только неизменяемые числа.
rotated() возвращает новый CardSize с переставленными сторонами. Исходник не меняется, даже если стороны равны.
Равенство сравнивает поля в их порядке: 90×60 равно 90×60, но не 60×90.
В упражнении padding — точный int не меньше нуля; полезные стороны должны остаться положительными. Отказ при создании нового размера не меняет существующий.
Почему такая граница объекта
Объект-значение узнают по содержимому, а не по личному номеру. Два отдельно созданных размера 90×60 взаимозаменяемы в вычислении площади. У такой модели нет действия «переименовать именно этот размер»; если стороны изменились, получилось другое значение.
Декоратор dataclass создаёт обычные служебные методы по объявленным полям, в том числе инициализацию, представление и сравнение. frozen=True запрещает обычное изменение полей после создания. Это удобная договорённость для клиентского кода, не защита от намеренного вмешательства средствами Python.
Аннотация int объясняет намерение, но сама не проверяет аргумент. __post_init__ вызывается после присваивания полей сгенерированным конструктором и явно проверяет их. Условие type(value) is int выбрано сознательно: bool является подклассом int, однако True не является размером по нашему контракту.
Мы храним две стороны, но не площадь: её легко получить из сторон, и отдельное поле пришлось бы поддерживать согласованным. Метод rotated тоже ничего не исправляет внутри self. Он пользуется уже существующим правилом создания нового CardSize, поэтому результат проходит тот же входной контроль.
Можно было оставить кортеж и набор функций. Тогда вызывающий код должен помнить, что означает каждый элемент и какие пары уже проверены. Класс полезен здесь не количеством методов, а возможностью передать один размер с ясными правилами; для одноразового умножения это преимущество исчезает.
Полная программа
001│from dataclasses import dataclass
002│
003│
004│@dataclass(frozen=True)
005│class CardSize:
006│····width: int
007│····height: int
008│
009│····def __post_init__(self):
010│········for value in (self.width, self.height):
011│············if type(value) is not int:
012│················raise TypeError("размер должен быть int")
013│············if value <= 0:
014│················raise ValueError("размер должен быть положительным")
015│
016│····def area(self):
017│········return self.width * self.height
018│
019│····def rotated(self):
020│········return CardSize(self.height, self.width)
021│
022│
023│card = CardSize(90, 60)
024│turned = card.rotated()
025│print(card.area())
026│print((turned.width, turned.height))
027│print(card == CardSize(90, 60), card == turned)
028│print((card.width, card.height))
Разбор исполнения
Ожидаемый вывод5400
(60, 90)
True False
(90, 60)
Конструктор получает 90 и 60, проверяет оба числа и выпускает пригодное значение. area() умножает их и печатает 5400. Ни вычисление площади, ни последующее сравнение не нуждаются в повторной проверке положительности: обычный публичный интерфейс не позволяет испортить стороны.
turned содержит новый размер 60×90. Поэтому сравнение с отдельно созданным 90×60 даёт True, а с turned — False. Площадь у этих ориентаций одинаковая, но равенство площади не означает равенства размера: ширина и высота имеют разные роли.
Последняя строка снова показывает исходные стороны. Проверки добавляют квадрат 1×1: поворот такого размера равен исходнику по значению, но всё равно возвращает другой экземпляр. Это различие между == и is важно именно для обещанного результата операции, а не для арифметики.
Проверки ниже добавьте после полной программы. При успехе они ничего не печатают; ожидаемый вывод остаётся тем же. Не используйте режим -O.
Проверки исходной модели
001│from dataclasses import FrozenInstanceError
002│
003│
004│def reject(error, action):
005│····try:
006│········action()
007│····except error:
008│········pass
009│····else:
010│········raise AssertionError("ожидался отказ")
011│
012│
013│a = CardSize(90, 60)
014│b = CardSize(90, 60)
015│assert a == b and a is not b
016│assert a.area() == 5400
017│assert a.rotated() == CardSize(60, 90)
018│assert a != CardSize(60, 90)
019│square = CardSize(1, 1)
020│assert square.area() == 1
021│assert square.rotated() == square
022│assert square.rotated() is not square
023│reject(TypeError, lambda: CardSize(True, 60))
024│reject(TypeError, lambda: CardSize(90, 60.0))
025│reject(ValueError, lambda: CardSize(0, 60))
026│reject(ValueError, lambda: CardSize(90, -1))
027│reject(FrozenInstanceError, lambda: setattr(a, "width", 1))
028│assert (a.width, a.height) == (90, 60)
029│assert (b.width, b.height) == (90, 60)
Меняем требование
Добавьте единый внутренний отступ padding со значением по умолчанию 0. Он входит в значение размера и сохраняется при повороте. Метод usable() должен вернуть CardSize полезной области с нулевым отступом. Область 90×60 с отступом 5 равна 80×50; отступ 30 недопустим. Сохраните смысл area() как внешней площади.
Подсказка 1
Решение упражнения 1
Когда проще без классаЕсли нужно один раз узнать площадь печати, функция usable_area(width, height, padding) с теми же проверками проще. Она не требует от читателя изучать создание экземпляров и правила равенства. Объект окупается, когда проверенный размер передают между несколькими операциями и важно не перепутать его части.
Модель не учитывает единицы других систем, округление, масштаб, физические допуски и ограничения печатного оборудования. frozen не обещает защиту от намеренного обхода; здесь неизменяемость достаточна ещё и потому, что поля — целые числа, а не вложенные списки. Конечные проверки подтверждают перечисленные примеры, но не все возможные размеры.
К списку моделей
Модель 2. Каталог не путает название с личностью экспоната
Ситуация и обещания
Разделить стабильный идентификатор и изменяемое описание, не создавая лишний класс для каждой записи.
На учебной выставке есть два разных макета с названием «Мост». Координатор сначала хранит словарь название → описание, но вторая запись вытесняет первую. Попытка запретить повтор названия решает проблему контейнера ценой неверного предметного правила: одинаковые подписи действительно допустимы.
Введём короткие стабильные ID, например A и B. Название можно уточнить после расстановки, не меняя ID. Каталог живёт в течение подготовки: к нему обращаются для добавления, получения и переименования, поэтому единые проверки удобнее держать у владельца записей.
Контракт моделиID и название — точные str, содержащие хотя бы один непробельный символ; другой тип даёт TypeError, пустой или пробельный текст — ValueError.
Строки сохраняются без нормализации: регистр, пробелы по краям и написание значимы. Одинаковые названия разных ID допустимы.
add() добавляет новый ID; повтор ID вызывает ValueError и не заменяет прежнюю запись. Успешные add() и rename() возвращают None.
rename() меняет только название существующего ID. get() и rename() для допустимого, но неизвестного ID вызывают KeyError.
get() возвращает кортеж (ID, название), snapshot() — кортеж таких пар в порядке добавления. Это неизменяемые снимки из строк, не живые записи.
При отказе состояние не меняется; отдельные каталоги независимы. В упражнении find_ids() возвращает все ID точного названия в порядке добавления, для отсутствующего названия — ().
Почему такая граница объекта
ID отвечает на вопрос «какой именно экспонат», а название — «что сейчас написано на его карточке». Название может совпасть с чужим и может измениться; использовать его ключом значило бы смешать два разных обещания. Словарь по ID напрямую выражает нужную уникальность.
Каталог — изменяемый объект: после rename() обращение к тому же экземпляру показывает новое описание. Не нужно делать изменяемый Exhibit для каждого значения словаря. Пока запись состоит только из ID и строки названия, пара неизменяемых строк достаточно точно описывает её наружу.
Методы add и rename сначала проверяют аргументы и существование ключа, а затем выполняют одно присваивание. Поэтому предусмотренный отказ не оставляет половину изменения. Это локальное обещание о последовательном вызове в памяти, не транзакция с базой данных или между несколькими процессами.
snapshot() превращает текущее содержимое словаря в кортеж пар. После переименования старый снимок не обновится: строки неизменяемы, а новая строка заменяет значение в словаре. Если бы внутри пары лежал список, одного внешнего кортежа для такого обещания было бы недостаточно.
Общая функция checked_text не владеет состоянием и не заслуживает собственного класса. Она повторно используется для ID и названия, у которых сейчас совпадает правило текста. Это не утверждение, что их предметный смысл одинаков: будущий формат ID можно выделить в отдельную проверку.
Полная программа
001│def checked_text(value):
002│····if type(value) is not str:
003│········raise TypeError("ожидалась строка")
004│····if not value.strip():
005│········raise ValueError("текст не может быть пустым")
006│····return value
007│
008│
009│class ExhibitCatalog:
010│····def __init__(self):
011│········self._titles = {}
012│
013│····def add(self, item_id, title):
014│········checked_text(item_id)
015│········checked_text(title)
016│········if item_id in self._titles:
017│············raise ValueError("ID уже есть")
018│········self._titles[item_id] = title
019│
020│····def rename(self, item_id, title):
021│········checked_text(item_id)
022│········checked_text(title)
023│········if item_id not in self._titles:
024│············raise KeyError(item_id)
025│········self._titles[item_id] = title
026│
027│····def get(self, item_id):
028│········checked_text(item_id)
029│········return (item_id, self._titles[item_id])
030│
031│····def snapshot(self):
032│········return tuple(self._titles.items())
033│
034│
035│catalog = ExhibitCatalog()
036│catalog.add("A", "Мост")
037│catalog.add("B", "Мост")
038│old = catalog.get("A")
039│catalog.rename("A", "Арка")
040│print(old)
041│print(catalog.get("A"))
042│print(catalog.snapshot())
Разбор исполнения
Ожидаемый вывод('A', 'Мост')
('A', 'Арка')
(('A', 'Арка'), ('B', 'Мост'))
Обе операции add проходят: каталог следит за уникальностью A и B, а не строки «Мост». Вызов get для ID A создаёт пару с прежним названием. Она становится наблюдаемым описанием экспоната в этот момент, но не возможностью редактировать каталог в обход его правил.
Переименование присваивает по ключу A новую строку. Первая строка вывода остаётся ('A', 'Мост'), вторая уже содержит «Арка». ID не менялся, новая запись в конце каталога не создавалась. Это сохраняет связь экспоната с любой другой учебной информацией, которая ссылается на A.
Снимок целого каталога показывает A перед B. Словарь сохраняет порядок вставки; изменение значения существующего ключа не переносит ключ в конец. Проверки отвергают повторное добавление, неизвестный ID и неверный текст, затем сверяют всё состояние с буквальным ожидаемым кортежем.
Проверки ниже добавьте после полной программы. При успехе они ничего не печатают; ожидаемый вывод остаётся тем же. Не используйте режим -O.
Проверки исходной модели
001│def reject(error, action):
002│····try:
003│········action()
004│····except error:
005│········pass
006│····else:
007│········raise AssertionError("ожидался отказ")
008│
009│
010│a, b = ExhibitCatalog(), ExhibitCatalog()
011│assert a.snapshot() == ()
012│assert a.add("A", "Мост") is None
013│a.add("B", "Мост")
014│saved = a.snapshot()
015│assert saved == (("A", "Мост"), ("B", "Мост"))
016│assert a.rename("A", "Арка") is None
017│assert a.get("A") == ("A", "Арка")
018│assert a.get("B") == ("B", "Мост")
019│assert saved == (("A", "Мост"), ("B", "Мост"))
020│reject(ValueError, lambda: a.add("A", "Замена"))
021│reject(ValueError, lambda: a.rename("A", " " * 2))
022│reject(KeyError, lambda: a.rename("X", "Башня"))
023│reject(KeyError, lambda: a.get("X"))
024│reject(TypeError, lambda: a.add(1, "Круг"))
025│reject(TypeError, lambda: a.rename("A", None))
026│reject(ValueError, lambda: a.add("", "Круг"))
027│reject(TypeError, lambda: a.get([]))
028│try:
029│····saved[0][1] = "X"
030│except TypeError:
031│····pass
032│else:
033│····raise AssertionError("пара должна быть неизменяемой")
034│assert a.snapshot() == (("A", "Арка"), ("B", "Мост"))
035│assert b.snapshot() == ()
036│b.add("A", "Круг")
037│assert b.get("A") == ("A", "Круг")
038│assert a.get("A") == ("A", "Арка")
Меняем требование
Добавьте find_ids(title): точный поиск всех ID с этим названием в порядке добавления. После двух записей A и B с названием «Мост» результат равен ('A', 'B'); после переименования A в «Арка» — ('B',). Старые операции и разрешение одинаковых названий должны сохраниться.
Подсказка 2
Решение упражнения 2
Когда проще без классаЕсли записи уже готовы и больше не редактируются, кортеж пар и функция поиска будут короче каталога. Даже при редактировании функции над словарём допустимы, если словарь принадлежит одному небольшому участку программы. Класс становится полезнее, когда несколько вызывающих участков должны соблюдать одинаковые правила и не получать прямой доступ к изменяемому словарю.
Каталог не создаёт ID, не хранит данные между запусками, не объединяет изменения разных пользователей и не ускоряет поиск отдельным индексом. Одинарное подчёркивание обозначает внутренний словарь по соглашению, но не скрывает его технически. Проверки охватывают указанные отказы и порядок, а не любые ошибки внешней системы.





