Трек «Инженерное программирование» · Модуль 2 · Неделя 5

Введение в паттерны. Порождающие паттерны

Зачем паттерны · Factory Method · Abstract Factory · Builder · Singleton и Prototype · Python-специфика: dataclass и фабрики

2 ч теории. Далее — семинар и ДЗ task-05.

Паттерны — не магия, а словарь проверенных решений: вы уже писали их, просто не называли по имени.

Проект модуля: консольная «Платёжная система»

> pay 1000 card Комиссия 2%: 20. Итого к списанию: 1020. > pay 500 wallet Комиссия 0%: 0. Итого к списанию: 500. > fee 1000 Комиссия: 20. > history pay card 1000, комиссия 20 pay wallet 500, комиссия 0 > exit

После перезапуска history восстанавливается из хранилища.

Это результат модуля: 3+ паттерна, тесты ≥ 70%, mypy, README с обоснованием выбора каждого паттерна.

Какие паттерны, по-вашему, здесь спрятаны? (гипотезы — вслух, узнаем на неделях 5–8)

Не открывая конспект: как в «Университете» создавались курсы разных типов?

Подсказка-варианты: в конструкторе? в трёх местах? в одном месте?

Пауза 10–15 секунд. Типичная картина: if kind == "lecture": return Lecture(...) встречается в нескольких местах — запомните, вернёмся на Слайде 8.

К концу лекции вы сможете…

В конце — «Проверь себя» (Слайд 27): это сигнал, где повторить, а не экзамен.

Паттерн — типовое решение типовой задачи, а не готовый код

Чем паттерн отличается от готовой библиотеки? (библиотеку подключил — и пользуешься; паттерн — схема, которую реализуешь сам)

Три класса GoF: порождающие / структурные / поведенческие

Порождающие как создавать объекты Factory Method Abstract Factory Builder Singleton Prototype → неделя 5 Структурные как компоновать классы/объекты Adapter Facade Proxy Decorator Composite → неделя 6 Поведенческие как распределять ответственность Strategy Observer Command Template Method State Iterator → неделя 7
Порождающие: неделя 5 · Структурные: неделя 6 · Поведенческие: неделя 7 — карта модуля: сегодня левая колонка, далее средняя и правая.
Назовите по одному паттерну каждого класса GoF (вопрос из ТЗ).
Стоп-вопрос

Паттерны — словарь решений и общий язык команды; злоупотребление — когда код стал сложнее

Как понять, что паттерн в коде — злоупотребление? (нельзя назвать боль; код стал сложнее; классы ничего не решают)

Боль Factory Method: один и тот же выбор размазан по трём местам

# регистрация курса if kind == "lecture": course = Lecture(title) elif kind == "lab": course = Lab(title) # расписание (тот же выбор) if kind == "lecture": c = Lecture(title) elif kind == "lab": c = Lab(title)

Боль: добавили тип seminar — правим все места; вызывающий код знает конкретные классы; выбор не покрыт тестами.

регистрация курса создание Lecture/Lab расписание тот же выбор отчёт снова тот же if if kind == ... повторён в трёх местах
Три места проекта указывают на один и тот же фрагмент if kind == ...: добавили seminar — правим все три.
Что придётся сделать, чтобы добавить тип seminar? (править все места с if kind == ... — это и есть боль, которую решает фабрика)

CourseFactory через classmethod — единая точка создания

class Course: ... class Lecture(Course): ... class Lab(Course): ... class Practice(Course): ... class CourseFactory: @classmethod def create(cls, kind: str, **kwargs) -> Course: if kind == "lecture": return Lecture(**kwargs) if kind == "lab": return Lab(**kwargs) if kind == "practice": return Practice(**kwargs) raise ValueError(f"Неизвестный тип курса: {kind!r}")
c = CourseFactory.create("lecture", title="АиСД") print(type(c).__name__) Lecture

Правило: выбор варианта — в одном месте; вызывающий код не знает конкретный класс; неизвестный тип — ValueError, а не None.

Что вернёт CourseFactory.create("seminar", ...) до добавления ветки? (сейчас — ValueError; после добавления — Seminar без правок вызывающего кода)

Factory Method: когда применять / когда не применять

Когда применять

  • несколько вариантов объекта;
  • выбор по данным/конфигу;
  • логику создания надо централизовать и покрыть тестами.

Когда не применять

  • один класс без вариантов — фабрика добавляет слой без пользы;
  • вариант один — достаточно конструктора.

Типичная ошибка: фабрика превращается в функцию-гигант с 20 ветками; return None вместо ValueError (разберём на Слайде 25).

Сколько веток в фабрике — сигнал, что пора пересмотреть выбор? (нет магического числа; признак — фабрика растёт быстрее, чем добавляются реальные типы)

Abstract Factory — согласованные семейства «очно/дистанционно»

OfflineFactory очно OnlineFactory дистанционно FormatFactory Protocol create_lesson() create_material() создаёт создаёт создаёт создаёт ClassroomLesson PaperMaterial ZoomLesson VideoMaterial
Клиент работает только с FormatFactory и всегда получает согласованный набор: очное занятие + бумажный материал ИЛИ онлайн-занятие + видео-материал — семейства не смешиваются.
Почему нельзя просто два раза вызвать Factory Method? (ничто не гарантирует согласованность набора — Abstract Factory гарантирует)

FormatFactory как Protocol: клиент работает с интерфейсом

class Lesson: ... class Material: ... class ClassroomLesson(Lesson): ... class ZoomLesson(Lesson): ... class PaperMaterial(Material): ... class VideoMaterial(Material): ... class FormatFactory(Protocol): def create_lesson(self) -> Lesson: ... def create_material(self) -> Material: ...
class OfflineFactory: def create_lesson(self) -> Lesson: return ClassroomLesson() def create_material(self) -> Material: return PaperMaterial()
class OnlineFactory: def create_lesson(self) -> Lesson: return ZoomLesson() def create_material(self) -> Material: return VideoMaterial()
factory: FormatFactory = OnlineFactory() lesson = factory.create_lesson() print(type(lesson).__name__) ZoomLesson

Правило: клиент объявляет factory: FormatFactory и не знает, какая конкретная фабрика подставлена; переключение семейства — одна строка.

Что изменится в клиенте, если заменить OnlineFactory() на OfflineFactory()? (ничего — клиент работает с интерфейсом)

Abstract Factory: когда применять / когда не применять; отличие от Factory Method

Когда применять

  • реальные семейства объектов, которые обязаны сочетаться;
  • переключение семейства целиком (конфиг, среда).

Когда не применять

  • одно семейство без вариаций — хватит Factory Method;
  • «семейства» придуманы искусственно — лишняя абстракция.

Factory Method vs Abstract Factory: метод создаёт один тип; абстрактная фабрика — семейство согласованных объектов.

Типичная ошибка: строят Abstract Factory там, где нет ни одного второго семейства.

Чем Factory Method отличается от Abstract Factory? (один тип vs семейство; метод vs набор методов фабрики)

Боль Builder: конструктор с 6+ аргументами

course = Course( "АиСД", # title "Петров", # teacher 30, # limit "пн 10:00", # schedule ["лекция 1", "лекция 2"], # materials True, # is_elective )

Боль: 15-аргументный конструктор нечитаем; легко перепутать места ("Петров" и 30); часть параметров опциональна, часть собирается по шагам; валидация размазана по вызывающему коду.

Что здесь можно перепутать? (места аргументов; читаемость падает, ошибки — на этапе выполнения, валидации нет)

CourseBuilder: пошаговая сборка с валидацией в build()

class Teacher: ... class Course: def __init__(self, title: str, teacher: Teacher | None, limit: int) -> None: self.title = title self.teacher = teacher self.limit = limit class CourseBuilder: def __init__(self) -> None: self._title = "" self._teacher: Teacher | None = None self._limit = 30 def with_title(self, title: str) -> "CourseBuilder": self._title = title return self def with_teacher(self, teacher: Teacher) -> "CourseBuilder": self._teacher = teacher return self def with_limit(self, limit: int) -> "CourseBuilder": self._limit = limit return self def build(self) -> Course: if not self._title: raise ValueError("Название курса обязательно") return Course(self._title, self._teacher, self._limit) course = CourseBuilder().with_title("АиСД").with_teacher(Teacher()).build() print(course.title) АиСД

Правило: сеттеры возвращают self — работают цепочки; __init__ принимает готовые проверенные данные; build() валидирует полноту.

Что вернёт CourseBuilder().build() без названия? (ответ: ValueError: Название курса обязательно)

Builder: когда применять / когда не применять

Когда применять

  • много опциональных параметров;
  • несколько «рецептов» сборки одного объекта (курс с расписанием и без; курс с материалами и без).

Когда не применять

  • 3–5 параметров — @dataclass со значениями по умолчанию проще и читаемее;
  • билдер ради билдера — шум.

Типичная ошибка: билдер без валидации в build() (объект собирается неполным); сеттеры возвращают None вместо self.

Когда Builder избыточен? (3–5 параметров, нет «рецептов» — хватит @dataclass)

Singleton — антипаттерн в Python: модуль — естественный синглтон

# config.py — синглтон «из коробки» DEBUG = False DATABASE_URL = "sqlite:///university.db" # в другом модуле import config print(config.DATABASE_URL) sqlite:///university.db

Акцент: модуль уже решил задачу — класс Singleton через __new__ не нужен.

Когда модуля достаточно? (разделяемое неизменяемое состояние: конфиг, константы, логгер)

Чем вреден Singleton: глобальное мутируемое состояние

Как сделать код тестируемым, если конфиг — глобальный объект, который можно поменять из любого места? (передавать объект явно — предвестник DI, М3)
Предскажите результат

Prototype: b = a — та же ссылка; что изменится в оригинале?

class CoursePlan: def __init__(self, title: str, lessons: list[str]) -> None: self.title = title self.lessons = lessons a = CoursePlan("АиСД", ["Лекция 1"]) b = a # «копия»? b.lessons.append("Лекция 2") print(a.lessons)
Не запуская: что напечатает print(a.lessons)? Присваивание копирует или связывает с тем же объектом?

copy.deepcopy — независимая копия вложенных структур

import copy class CoursePlan: def __init__(self, title: str, lessons: list[str]) -> None: self.title = title self.lessons = lessons plan = CoursePlan("АиСД", ["Лекция 1"]) plan_copy = copy.deepcopy(plan) # вложенный список тоже скопирован plan_copy.lessons.append("Лекция 2") # оригинал не изменится print(plan.lessons) print(plan_copy.lessons) ['Лекция 1'] ['Лекция 1', 'Лекция 2']

Правило: copy.copy — поверхностная (вложенные объекты общие); copy.deepcopy — глубокая (рекурсивно копирует вложенные структуры).

Когда применять

  • создание объекта дорого/сложно;
  • нужны независимые копии;
  • «эталон» переиспользуется как шаблон.

Когда не применять

  • простые объекты — обычный конструктор;
  • если копии не должны быть независимыми — разделяемое состояние может быть осознанным.
Чем copy.copy отличается от copy.deepcopy? (поверхностная — вложенные объекты общие; глубокая — рекурсивно копирует)

@dataclass(frozen=True) + classmethod-фабрики заменяют целый порождающий паттерн

from dataclasses import dataclass @dataclass(frozen=True) class Student: name: str group: str @classmethod def from_str(cls, line: str) -> "Student": name, group = line.split(",") return cls(name.strip(), group.strip()) s = Student.from_str("Иван, 101") print(s) print(s == Student("Иван", "101")) Student(name='Иван', group='101') True

Правило: @dataclass даёт __init__, __repr__, __eq__; frozen=True — неизменяемость; field(default_factory=list) — для изменяемых значений по умолчанию; classmethod-фабрика (from_str) — именованный альтернативный конструктор, в простых случаях заменяющий Factory Method без класса-фабрики.

Когда не применять: ручной __init__/__repr__ там, где хватит dataclass; фабричный класс там, где хватит classmethod.

Почему frozen=True полезен? (неизменяемость: объект не мутируется случайно; можно как ключ в dict/set)
Live-coding

Live-coding: выносим создание курсов в CourseFactory

Задание: на коде проекта «Университет» централизуйте создание курсов разных типов.

  1. Найдите ветвление if kind == ... в своём коде (боль из Слайда 8).
  2. Напишите CourseFactory.create(kind, **kwargs) с ValueError на неизвестный тип.
  3. Замените одно место вызова.

Ожидаемый результат:

CourseFactory.create("lecture", title="X") # → Lecture CourseFactory.create("seminar", title="X") # → ValueError
Повторите за лектором: почему ValueError, а не None, для неизвестного типа? (ошибка должна быть видна в точке создания)
Стоп-вопрос

Стоп-вопрос: Factory Method vs Abstract Factory

Чем Factory Method отличается от Abstract Factory? Приведите пример, когда Abstract Factory — лишняя абстракция.

Эталон: FM создаёт один тип через один метод; AF — согласованные семейства через набор методов; AF лишняя, если второго семейства нет.

Пауза. Если правильных ответов меньше половины — вернёмся к схеме Слайда 11.
Live-coding

Live-coding: dataclass-модель студента + classmethod-фабрика from_str

Задание: замените ручную модель студента на @dataclass(frozen=True) и добавьте фабрику from_str(line).

«Данные без шума»: 5 строк dataclass заменяют ~15 строк ручного __init__/__repr__.

@dataclass(frozen=True) class Student: name: str group: str @classmethod def from_str(cls, line: str) -> "Student": name, group = line.split(",") return cls(name.strip(), group.strip())
Какой порождающий паттерн заменяет from_str? (Factory Method в простой форме)
Разбор ошибки

return None вместо ValueError — ошибка «уезжает» от места вызова

class CourseFactory: @classmethod def create(cls, kind: str, **kwargs) -> Course | None: if kind == "lecture": return Lecture(**kwargs) return None # ошибка: неизвестный тип «проглочен» course = CourseFactory.create("seminar", title="X") print(course.title) # AttributeError: 'NoneType' object has no attribute 'title' AttributeError: 'NoneType' object has no attribute 'title'

Разбор: ошибка проявляется далеко от фабрики — в месте обращения к course.title; по traceback непонятно, где создан None. raise ValueError сообщает об ошибке сразу, в точке создания, с понятным сообщением.

Почему raise ValueError лучше, чем return None? (ошибка видна в точке создания, а не «уезжает» к месту использования)

Типичные ошибки недели 5

  1. Паттерн «для галочки»: код стал сложнее, боль не названа.
  2. Функция-гигант — фабрика с 20 ветками; иерархия фабрик там, где хватит одного classmethod.
  3. return None вместо ValueError на неизвестный тип.
  4. Abstract Factory без второго семейства; путаница FM/AF.
  5. Builder без валидации в build(); сеттеры возвращают None вместо self.
  6. Singleton через __new__ с мутируемым состоянием; copy.copy вместо copy.deepcopy; ручной __init__ вместо dataclass; мутируемое значение по умолчанию без default_factory.
Отметьте ошибки, которые вы уже встречали в своём коде «Университета»: каждая задача семинара «ловит» одну из них.
Проверь себя

Проверь себя: 4 вопроса перед семинаром

  1. Что такое паттерн и чем он отличается от готовой библиотеки?
  2. Назовите по одному паттерну каждого из трёх классов GoF.
  3. Как понять, что паттерн в коде — злоупотребление?
  4. Чем Factory Method отличается от Abstract Factory? Почему Singleton в Python — антипаттерн и чем его заменить? Когда Builder уместен, а когда — просто @dataclass? Чем copy.copy отличается от copy.deepcopy?
2 минуты письменно (можно в чат); сверить с залом; эталоны — в решения-5.md.

Связка с проектом: этап недели 5 «Платёжной системы»

class PaymentError(Exception): ... class Payment: def __init__(self, amount: float) -> None: self.amount = amount class CardPayment(Payment): ... class WalletPayment(Payment): ... class PaymentFactory: @classmethod def create(cls, kind: str, amount: float) -> Payment: if kind == "card": return CardPayment(amount) if kind == "wallet": return WalletPayment(amount) raise PaymentError(f"Неизвестный тип платежа: {kind!r}")

Кастомные исключения и фабрика — база, на которую на неделях 6–8 лягут Repository, Strategy и Command.

Какой паттерн здесь? (Factory Method через classmethod — буквально CourseFactory, только для платежей)

Сегодня вы научились…

Всё, что обещали в начале (Слайд 4), — сделали?
Рефлексия

One-minute paper: главное + один вопрос

Это сигнал для семинара: что разобрать подробнее.

Что дальше: семинар недели 5, ДЗ task-05, анонс недели 6

Готовы? Откройте семинар-5.md и выполните базовый уровень.