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

Парадигмы и компромиссы

процедурная · ООП · функциональная · чистая функция · компромиссы и Big O

2 ч теории. На семинаре — одна задача в трёх парадигмах.

Начнём с кода, который вы, возможно, уже писали — и который никто не может прочитать через месяц.

Функциональный стиль — это стиль работы с данными

Как в вашем CLI-проекте выглядит конвейер данных? (load → analyze → report)

Однострочник-«спагетти», который никто не читает

from functools import reduce rows = [{"s": 1500}, {"s": 700}, {"s": 2000}] total = reduce(lambda acc, r: acc + r["s"], filter(lambda r: r["s"] > 1000, rows), 0) print(total) 3500

Антипример — «так нельзя»: краткость ценой читаемости.

Что здесь плохо? Кто сможет объяснить эту строку через месяц?

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

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

Парадигма — способ мышления о решении задачи, набор абстракций и правил

Какую парадигму вы использовали в С1? (процедурную — неосознанно)

Процедурный стиль: последовательность шагов, явное состояние, порядок вызовов важен

rows = [{"s": 1500}, {"s": 700}, {"s": 2000}] total = 0 for r in rows: if r["s"] > 1000: total += r["s"] print(total) 3500

Свойства: программа — последовательность шагов и функций, изменяющих состояние (total); порядок вызовов важен; явная мутация.

Что здесь является состоянием? (переменная total)

Процедурный стиль: плюсы — простота, минусы — состояние «размазано»

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

Вывод: процедурный стиль — база для коротких скриптов и последовательностей шагов; для больших систем состояние лучше организовывать (ООП) или убирать (функциональный стиль).

Для какой задачи процедурный стиль естественен, а для какой начинает мешать?

ООП (напоминание): состояние и поведение в объекте; полное изучение — С2

class ExpenseAnalyzer: def __init__(self, rows): self.rows = rows def total_by_category(self, limit): result = {} for r in self.rows: if r["sum"] > limit: cat = r["category"] result[cat] = result.get(cat, 0) + r["sum"] return result rows = [ {"category": "food", "sum": 1500}, {"category": "transport", "sum": 700}, {"category": "fun", "sum": 2000}, ] print(ExpenseAnalyzer(rows).total_by_category(1000)) {'food': 1500, 'fun': 2000}

Свойства: состояние (self.rows) и поведение (методы) собраны в объекте; инкапсуляция скрывает детали; переиспользование через наследование и полиморфизм — в С2, модуль 1.

Что здесь состояние, а что поведение? (состояние — self.rows; поведение — total_by_category)

Чистая функция: результат зависит только от аргументов, нет побочных эффектов

def add_one(xs): return [x + 1 for x in xs] # чистая: не меняет xs def add_one_bad(xs): for i in range(len(xs)): xs[i] += 1 # побочный эффект: мутирует xs return xs nums = [1, 2, 3] print(add_one(nums)) print(nums) nums2 = [1, 2, 3] print(add_one_bad(nums2)) print(nums2) [2, 3, 4] [1, 2, 3] [2, 3, 4] [2, 3, 4]

Определение: чистая функция — результат зависит только от аргументов; нет побочных эффектов (не читает/пишет внешнее состояние).

Правило: чистые функции легче тестировать — для одного входа всегда один выход, не зависящий от порядка вызовов.

Почему чистую функцию легче тестировать? (нет скрытого состояния, детерминированный результат)

Неизменяемость: преобразования создают новые значения, данные не мутируют

nums = [3, 1, 2] print(sorted(nums)) print(nums) [1, 2, 3] [3, 1, 2]

Правило: в функциональном стиле данные не мутируют — преобразования создают новые значения (sorted вернул новый список, исходный не изменился).

Предскажите: «Что выведет print(nums) после sorted(nums)?» — пауза, затем вывод.

Какая функция из С1 мутирует список? (list.sort() — в отличие от sorted())

Функции высшего порядка: map/filter принимают функцию; lambda — короткая функция

prices = [100, 1500, 700, 2000] big = list(filter(lambda p: p > 1000, prices)) print(big) print(list(map(lambda p: p * 2, [1, 2, 3]))) [1500, 2000] [2, 4, 6]

Определения: функции высшего порядка — принимают/возвращают функции; filter(f, xs) оставляет элементы, где f истинно; map(f, xs) преобразует каждый элемент; lambda — безымянная короткая функция.

Замечание: filter/map возвращают итераторы — оборачиваем в list() для печати.

Что вернёт filter(lambda x: x > 10, [5, 15, 20]) до list()? (итератор, не список)

Цикл с мутацией vs map+filter: тот же результат, другой стиль

# процедурно: цикл + append big = [] for r in expenses: if r["sum"] > 1000: big.append(r)
# функционально: filter без мутации big = list(filter(lambda r: r["sum"] > 1000, expenses))

Оба дают одинаковый результат ✓: [{'category': 'food', 'sum': 1500}, {'category': 'fun', 'sum': 2000}]

Вывод: результат один, но во втором варианте нет изменяемого состояния big и нет append — проще рассуждать и тестировать.

Какой вариант короче? Какой легче тестировать? (короче — правый; тестировать — оба, но правый детерминированнее)

Одна задача в трёх парадигмах: процедурно, ООП, функционально

Задача: «расходы больше 1000, сгруппировать по категории, сумма».

ПарадигмаКак устроеноФрагмент
Процедурнаяцикл, мутация result, явное состояниеfor r in rows: if r["sum"] > 1000: ...
ООПсостояние в объекте, методExpenseAnalyzer(rows).total_by_category(1000)
Функциональнаячистые функции, композиция без мутацииfilter(...) + sorted(...) + groupby(...) + sum(...)

Результат одинаковый ({'food': 1500, 'fun': 2000}); различаются состояние, мутация и способ композиции.

Полные три решения — на семинаре недели 13 (Задача 02).

Какая строка самая короткая? Какая самая явная по состоянию? (функциональная короче; процедурная явнее)

reduce и генераторы списков — функциональные инструменты

from functools import reduce nums = [1, 2, 3, 4] print(reduce(lambda a, b: a + b, nums)) print([x * x for x in nums if x % 2 == 0]) 10 [4, 16]

Правило: reduce(f, xs) сворачивает список в одно значение; генератор списков [expr for x in xs if cond] — декларативный фильтр+преобразование.

Предостережение: reduce с вложенными lambda быстро становится нечитаемым (Слайд 3) — используйте, когда действительно сворачиваете.

Что вернёт [x * x for x in range(5) if x % 2 == 1]? (ответ: [1, 9])

Стоп-вопрос: что такое чистая функция и почему её легче тестировать?

  1. Что такое чистая функция? Почему чистые функции легче тестировать?
  2. В чём разница между filter(lambda ...) и циклом с if и append? Когда какой вариант уместен?

Эталон: чистая — результат зависит только от аргументов, нет побочных эффектов; filter — декларативнее и без мутации, цикл — привычнее и понятнее новичку; уместен тот, что читается лучше в контексте.

Пауза 10–15 секунд. Если правильных ответов меньше половины — вернёмся к Слайду 9 (чистая функция).

Когда какая парадигма уместна; Python — мультипарадигмальный язык

ЗадачаПарадигма/стиль
обработка потоков данных, конвейеры, расчёты без состоянияфункциональный
моделирование сущностей с инвариантами и поведениемООП
короткие скрипты, последовательности шаговпроцедурный
комбинирование подходов в одном проектемультипарадигмальный

Python — мультипарадигмальный: можно комбинировать в одном проекте.

Выбор парадигмы — инженерное решение, а не вкус: решение принимается под задачу и обосновывается.

Какая парадигма уместна для модели «Банковский счёт с инвариантом “сумма ≥ 0”»? (ООП — есть инвариант и поведение)

Live-coding: пишем функциональное решение с нуля — без мутации входных данных

Задача: дано expenses (список словарей), вернуть суммы по категориям для расходов > 1000, не мутируя вход.

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

{'food': 1500, 'fun': 2000}

Порядок написания:

  1. over = filter(lambda r: r["sum"] > 1000, expenses) — фильтрация;
  2. by_cat = sorted(over, key=lambda r: r["category"]) — сортировка для groupby;
  3. from itertools import groupby — группировка;
  4. {cat: sum(r["sum"] for r in group) for cat, group in groupby(...)} — словарь.

Намеренная ошибка: забыть list() вокруг filter — получим итератор; разберём, почему печатается адрес объекта.

Повторите на своих данных: зачем нужна сортировка перед groupby?

Компромиссы: инженер никогда не получает «всё сразу»

ОсьПример
время vs памятьCounter (память) vs вложенный цикл (время)
читаемость vs производительностьоптимизированный, но непонятный код
простота vs гибкостьабстракции «на всякий случай»
скорость разработки vs качестводолг: тесты, ревью, документация

Вывод: компромисс — это осознанный выбор с обоснованием, а не случайность; инженер выбирает ось под контекст задачи.

Какой компромисс вы уже принимали в С1? (например, словарь вместо списка — память за скорость)

Время vs память: Counter вместо вложенного цикла — O(n) вместо O(n²)

from collections import Counter words = ["a", "b", "a", "c", "a", "b"] print(Counter(words)) counts = {} for w in words: counts[w] = counts.get(w, 0) + 1 print(counts) Counter({'a': 3, 'b': 2, 'c': 1}) {'a': 3, 'b': 2, 'c': 1}

Big O: Counter — один проход + хэш-таблица, O(n) по времени и O(k) по памяти; наивный «сравни каждую пару» — O(n²) по времени. Counter платит памятью за скорость.

Правило: «O(n) по времени часто значит O(n) по памяти» — показывать на примере кэша и словаря.

Где здесь память, а где время? (словарь внутри Counter — память; быстрый поиск — время)

Читаемость vs производительность: измеряй timeit, не угадывай

import timeit t1 = timeit.timeit("sum(x for x in range(1000))", number=10000) t2 = timeit.timeit("sum(range(1000))", number=10000) print("генератор:", round(t1, 4)) print("встроенный sum:", round(t2, 4)) генератор: 0.6686 встроенный sum: 0.1954

Вывод — на моей машине, Python 3.11.2; ориентировочно.

Правило: «оптимизированный, но непонятный код дороже в поддержке»; преждевременная оптимизация без timeit — ошибка; оптимизируем только там, где реально узкое место.

Что плохого в «оптимизации» кода, который работает мгновенно? (портят читаемость без выигрыша)

Простота vs гибкость: абстракции «на всякий случай» вредны

# антипример: класс без состояния — «набор функций в обёртке» class Calc: def add(self, a, b): return a + b def mul(self, a, b): return a * b

Правило: добавлять абстракции заранее «на всякий случай» вредно; класс без состояния и поведения — это модуль с функциями, замаскированный под ООП. Гибкость нужна, когда она решает реальную задачу.

Что лучше: класс Calc или модуль с функциями add/mul? (модуль — проще и честнее)

Порядок: корректность → читаемость → измерение (timeit) → оптимизация узкого места

корректность код работает на всех тестах читаемость код понятен коллеге без пояснений измерение timeit / профилирование, найти узкое место оптимизация только узкого места, с повторным измерением
Сначала корректность и читаемость, потом измеренная производительность; оптимизация только там, где реально узкое место.
Что вы делаете, если код корректен, но медленный? (сначала измеряем, потом оптимизируем)

Стоп-вопрос: какие компромиссы вы принимаете, выбирая словарь вместо списка?

  1. Какие компромиссы вы принимаете, когда выбираете словарь вместо списка? Приведите пример «время vs память».
  2. Почему «всё через классы» — это ошибка? Когда ООП действительно уместен?

Эталон: словарь — быстрый поиск по ключу (время), но больше памяти, чем список; «всё через классы» — ошибка, когда нет состояния и поведения; ООП уместен при сущностях с инвариантами.

Пауза 10–15 секунд. Если правильных ответов меньше половины — вернёмся к Слайду 18 (оси компромиссов).

Так нельзя: «всё через классы» — класс без состояния и поведения

# «всё через классы» — антипример class Stats: def __init__(self, rows): self.rows = rows def filter_big(self, limit): return [r for r in self.rows if r["sum"] > limit] def total(self, rows): return sum(r["sum"] for r in rows)

Разбор по методу «сначала нарушение»:

  1. «Что здесь плохо?»total не использует self — это не метод, а функция; класс нужен только из-за «модно»;
  2. «Что лучше?» — модуль с функциями filter_big(rows, limit) и total(rows); класс уместен, когда есть инвариант (например, «сумма ≥ 0») и поведение вокруг него.

Модуль с функциями — 6 строк, класс — 9 строк без выигрыша.

Найдите в своём коде класс без состояния (если есть).

Пять ошибок недели 13, которые вы совершите на семинаре

  1. «всё через классы, потому что модно» — классы без состояния и поведения, по сути набор функций;
  2. злоупотребление однострочниками ради краткости — reduce(lambda ...) в три вложенных вызова, которые никто не читает;
  3. преждевременная оптимизация — «оптимизируют» код, который работает мгновенно, не измеряя (timeit), и портят читаемость;
  4. мутация входных данных в функции (побочный эффект) — тесты и вызовы зависят от порядка;
  5. непонимание, что компромисс — осознанный выбор с обоснованием, а не случайность.
Какая из ошибок чаще всего ломает тесты? (№ 4 — мутация входных данных)

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

  1. Что такое чистая функция? Почему чистые функции легче тестировать?
  2. В чём разница между filter(lambda ...) и циклом с if и append? Когда какой вариант уместен?
  3. Какие компромиссы вы принимаете, когда выбираете словарь вместо списка? Приведите пример «время vs память».
  4. Почему «всё через классы» — это ошибка? Когда ООП действительно уместен?
2 минуты письменно, затем короткий разбор; эталоны — в решения-13.md.

Проект модуля: план рефакторинга + рефакторинг одной функции в функциональном стиле

Какую функцию своей утилиты вы выберете для рефакторинга? (статистику или фильтрацию)

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

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

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

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

Что дальше: семинар недели 13, ДЗ refactor-week13.py, анонс недели 14

Готовы? Откройте семинар-13.md, задачи 2.1–2.5 — по своему уровню.