Парадигмы разработки Парадигмы программирования: карта, история и как они связаны
0%

Парадигмы программирования: карта, история и как они связаны

Парадигмы программирования: карта, история и как они связаны

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

Каждая парадигма отбирает у вас какую-то свободу. Структурное программирование отобрало goto. ООП отобрало прямой доступ к чужим полям. Функциональное отобрало присваивание. Модель акторов отобрала разделяемую память. И каждый раз этот обмен окупался: из-за того, что вы не можете сделать что-то, целый класс ошибок становится невыразимым, а рассуждать о коде становится возможно локально — глядя на кусок, а не на всю программу.

Эта статья — вход в трек. Она отвечает на четыре вопроса: что такое парадигма строго, а не на уровне лозунгов; по каким осям парадигмы реально различаются; откуда они взялись и почему появлялись именно в таком порядке; и как они уживаются в одной кодовой базе — потому что в проде чистых парадигм не бывает.

Часть 1. Что такое парадигма на самом деле

Определение через ограничения, а не через возможности

Термин ввёл Роберт Флойд в Тьюринговской лекции 1978 года «The Paradigms of Programming», позаимствовав слово у Томаса Куна. Флойд говорил о парадигме как о способе видеть задачу: динамическое программирование, рекурсивный спуск, поиск с возвратом — для него это тоже парадигмы, только мелкого масштаба.

Самая полезная современная формулировка принадлежит Роберту Мартину («Clean Architecture», глава 3): парадигмы не добавляют возможностей, а отнимают их.

Парадигма Что отнимает Что даёт взамен
Структурная неограниченный goto декомпозицию на блоки, доказуемость по Хоару
Объектно-ориентированная прямые указатели на функции и поля инверсию зависимостей, полиморфные границы модулей
Функциональная присваивание (мутацию) ссылочную прозрачность, безопасную параллельность
Акторы / CSP разделяемую память отсутствие гонок по построению
Декларативная управление порядком выполнения оптимизацию и переносимость решения

Проверьте эту оптику на себе: любая «продвинутая» техника, которую вы считаете полезной, почти наверняка формулируется как запрет. «Не мутируй входные аргументы», «не лезь в поля другого объекта», «не блокируй поток», «не смешивай I/O с логикой». Парадигма — это просто запрет, доведённый до уровня языка и системный настолько, что нарушить его сложнее, чем соблюсти.

Три вопроса, ответы на которые задают парадигму

Чтобы отличить парадигмы друг от друга без болтовни, достаточно спросить три вещи:

  1. Что является единицей композиции? Инструкция? Процедура? Объект? Функция? Правило? Поток событий? Тип?
  2. Где живёт состояние и кто имеет право его менять? Глобальная память? Поле объекта? Нигде (значения неизменяемы)? Внутри процесса, недоступного другим?
  3. Кто определяет порядок выполнения? Программист явно? Планировщик? Порядок данных? Механизм вывода (резолюция)? Граф зависимостей?

Всё остальное — синтаксис. Например, ООП и модель акторов очень похожи по ответу на (1) и (2) — это не случайность: Алан Кэй прямо говорил, что «ООП для меня означает только обмен сообщениями, локальное удержание и защиту состояния и позднее связывание» (письмо 2003 года). Различие между Java и Erlang — в ответе на (3): синхронный вызов метода против асинхронного сообщения.

Обратите внимание: ветви пересекаются. Реактивное программирование — декларативное по духу, но живёт поверх событий; акторы — императивные внутри и асинхронные снаружи. Это не дефект классификации, а свойство предмета: парадигмы не образуют разбиение, они образуют пространство с осями.

Все парадигмы вычислительно эквивалентны — и это важно

Тезис Чёрча — Тьюринга гарантирует: всё, что можно вычислить на машине Тьюринга (императивно), можно вычислить в лямбда-исчислении (функционально) и наоборот. Никакая парадигма не даёт вам новых вычислимых функций.

Значит, выбор парадигмы — это не про мощность, а про выразительность: сколько кода и какого качества нужно, чтобы выразить намерение, и что можно доказать о результате, не запуская его. Формально это уловил Маттиас Феллейзен в работе «On the Expressive Power of Programming Languages» (1991): язык A выразительнее языка B, если перевод из B в A локален (заменяем конструкцию на конструкцию), а обратный перевод требует глобальной перестройки программы. Именно поэтому добавление исключений или call/cc — это качественный скачок, а добавление синтаксического сахара — нет. Практический вывод: спорить «что мощнее, ООП или ФП» бессмысленно; осмысленный вопрос — «какие ошибки в этой конкретной задаче я хочу сделать невыразимыми».

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

История парадигм — это история борьбы со сложностью, где каждый следующий шаг решает проблему, созданную предыдущим успехом.

Логика этой ленты проста, если читать её как цепочку «проблема → ограничение»:

  • Неструктурный императив быстро упирался в «код-спагетти». Ответ — теорема Бёма — Якопини (1966): любую программу можно выразить последовательностью, ветвлением и циклом. Дейкстра сделал из неё практический манифест.
  • Структурный код перестал масштабироваться по данным: процедур много, глобальных структур тоже, кто кого меняет — неясно. Ответ — Simula/Smalltalk: связать данные с операциями и закрыть их. Так родилась инкапсуляция.
  • ООП дало иерархии и связность, но принесло скрытое изменяемое состояние, размазанное по графу объектов. Стало больно в многопоточности. Ответ — ФП и акторы: либо запретить мутацию, либо запретить общий доступ.
  • ФП и акторы решили корректность, но потребовали новых способов описывать «что должно быть». Ответ — декларативные DSL и реактивность: SQL, HTML, Terraform, потоки событий.

Джон Бэкус, автор Fortran, в Тьюринговской лекции 1977 года «Can Programming Be Liberated from the von Neumann Style?» сформулировал главный диагноз императивного стиля: программа — это последовательность присваиваний, проталкивающих слова через «бутылочное горлышко фон Неймана» между процессором и памятью, и потому мышление программиста тоже становится пословным. Через тридцать лет этот же аргумент вернулся, но уже по другой причине — многоядерность.

Часть 3. Одна задача — шесть парадигм

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

Императивно-процедурно (Python)

def revenue_by_repeat_customers(orders):
    """Явный цикл, явные аккумуляторы, явный порядок шагов."""
    totals = {}      # клиент -> сумма
    counts = {}      # клиент -> число заказов
    for order in orders:
        cid = order["customer_id"]
        totals[cid] = totals.get(cid, 0) + order["amount"]
        counts[cid] = counts.get(cid, 0) + 1
    result = []
    for cid, total in totals.items():
        if counts[cid] > 1:
            result.append((cid, total))
    result.sort(key=lambda pair: pair[1], reverse=True)
    return result

Единица композиции — процедура. Состояние — локальные словари, которые мутируются в цикле. Порядок задан программистом. Сложность: O(n) на проход + O(k log k) на сортировку, где k — число клиентов; память O(k).

Объектно-ориентированно (Python)

from dataclasses import dataclass

@dataclass
class CustomerAccount:
    """Состояние инкапсулировано: менять его можно только методом apply."""
    customer_id: str
    total: int = 0
    order_count: int = 0

    def apply(self, order) -> None:
        self.total += order.amount
        self.order_count += 1

    @property
    def is_repeat(self) -> bool:
        return self.order_count > 1

class RevenueLedger:
    def __init__(self) -> None:
        self._accounts: dict[str, CustomerAccount] = {}

    def register(self, order) -> None:
        acc = self._accounts.setdefault(
            order.customer_id, CustomerAccount(order.customer_id))
        acc.apply(order)   # позднее связывание: apply может быть переопределён наследником

    def repeat_revenue(self) -> list[tuple[str, int]]:
        repeats = (a for a in self._accounts.values() if a.is_repeat)
        return sorted(((a.customer_id, a.total) for a in repeats),
                      key=lambda p: p[1], reverse=True)

Единица композиции — объект. Выигрыш не в скорости (та же O(n + k log k)), а в границе: RevenueLedger можно подменить другой реализацией, а правило «повторный клиент» живёт в одном месте. Цена — состояние размазано по объектам и мутабельно.

Функционально (Python, но в стиле ФП)

from functools import reduce

def revenue_fp(orders):
    """Ни одного присваивания в изменяемую структуру: только преобразования значений."""
    def merge(acc, order):
        cid = order["customer_id"]
        prev_total, prev_count = acc.get(cid, (0, 0))
        # возвращаем НОВЫЙ словарь: старый не трогаем
        return {**acc, cid: (prev_total + order["amount"], prev_count + 1)}

    aggregated = reduce(merge, orders, {})
    return sorted(((cid, t) for cid, (t, c) in aggregated.items() if c > 1),
                  key=lambda p: -p[1])

Честное предупреждение: этот код — учебный. {**acc, ...} копирует словарь на каждом шаге, поэтому сложность деградирует до O(n·k) по времени и создаёт O(n·k) мусора. Именно из-за этого в настоящих функциональных языках используют не копирование, а персистентные структуры данных со структурным разделением (HAMT в Clojure и Scala), где «обновление» стоит O(log₃₂ n) ≈ константа и переиспользует почти всё дерево. Подробнее — в статье про структуры данных; теорию персистентности заложил Окасаки в «Purely Functional Data Structures». Мораль: парадигма не отменяет анализ сложности — наивная неизменяемость на плоских словарях это ловушка, а не добродетель.

Декларативно (SQL)

-- Мы описываем ЧТО хотим. Порядок соединений, выбор индексов,
-- параллелизм и стратегию агрегации выбирает планировщик.
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 1
ORDER BY total DESC;

Пять строк вместо пятнадцати — и на объёмах, не влезающих в память, СУБД сама выберет hash aggregate или sort aggregate, распараллелит скан и использует индекс. Плата известна каждому, кто ловил внезапный seq scan: вы не контролируете план и вынуждены влиять на него косвенно.

Логически (Prolog)

% Факты
order(o1, alice, 100).
order(o2, alice, 250).
order(o3, bob,   400).

% Правило: total(Customer, Total) истинно, если Total — сумма сумм заказов Customer
total(C, Total) :-
    findall(A, order(_, C, A), Amounts),
    Amounts = [_, _|_],           % список длиннее одного элемента — повторный клиент
    sum_list(Amounts, Total).

% Запрос: ?- total(C, T).  → C = alice, T = 350.

Здесь нет ни цикла, ни вызова функции: мы описали отношение, а машина вывода ищет подстановки, делающие его истинным. Та же программа умеет работать «в обратную сторону» — свойство, которого нет ни у одной из предыдущих версий.

Реактивно / dataflow (TypeScript, RxJS-подобно)

// Поток заказов приходит во времени; результат — тоже поток, обновляющийся сам.
const repeatRevenue$ = orders$.pipe(
  scan((acc, o) => {
    const prev = acc[o.customerId] ?? { total: 0, count: 0 };
    return { ...acc, [o.customerId]: { total: prev.total + o.amount, count: prev.count + 1 } };
  }, {} as Record<string, { total: number; count: number }>),
  map(acc => Object.entries(acc)
      .filter(([, v]) => v.count > 1)
      .map(([id, v]) => [id, v.total] as const)
      .sort((a, b) => b[1] - a[1])),
  distinctUntilChanged(shallowEqual),   // не дёргаем подписчиков зря
);

Изменилась не логика, а модель времени: раньше вход был готовым списком, теперь — бесконечной последовательностью. Именно эта разница — «данные как значение» против «данные как поток» — отделяет ФП от реактивного программирования.

Спектр парадигм: от «как считать» к «что верно»

Часть 4. Оси, по которым парадигмы реально различаются

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

Полный набор осей, по которым стоит классифицировать любую парадигму:

Ось Крайности Что это меняет на практике
Состояние изменяемое ↔ неизменяемое возможность рассуждать локально, стоимость конкурентности
Эффекты свободные ↔ явные в типе тестируемость без моков, предсказуемость
Единица композиции процедура / объект / функция / правило / поток что вы переиспользуете и подменяете
Связывание раннее (компиляция) ↔ позднее (рантайм) скорость vs гибкость и расширяемость
Порядок выполнения задан программистом ↔ выведен рантаймом контроль производительности vs лаконичность
Типизация статическая ↔ динамическая, номинальная ↔ структурная какие ошибки ловятся до запуска
Модель времени значение ↔ поток ↔ процесс как выражается «то, что меняется»

Полезная привычка: описывая архитектурное решение, называйте не парадигму, а позицию по этим осям. «Ядро — неизменяемое, эффекты явные, связывание позднее на границе портов» несёт информации в разы больше, чем «мы пишем в ФП-стиле». Свести оси к главному компромиссу помогает следующая карта:

Из квадранта видно главное: гарантии и контроль над машиной почти всегда обмениваются друг на друга, и единственная зона, где удаётся получить оба, — языки с продвинутыми системами типов вроде Rust, которые платят за это порогом входа и скоростью разработки. Никакой «лучшей» точки не существует; существует уместная точка для конкретного модуля.

Часть 5. Как парадигмы уживаются в одной кодовой базе

Чистых парадигм в проде нет. Java с 8-й версии функциональна, Haskell умеет мутировать в ST, Go даёт и структуры с методами, и каналы, а React объединил dataflow, неизменяемость и компонентную композицию. Вопрос не «какую выбрать», а «где провести границы».

Приём №1: функциональное ядро, императивная оболочка

Самый переносимый способ смешивать парадигмы, популяризованный Гэри Бернхардтом в докладе «Boundaries» (2012): вся логика — чистые функции над неизменяемыми данными; всё, что связано со временем, I/O и внешним миром, вытеснено в тонкий слой снаружи.

Functional core, imperative shell

# ЯДРО: чистое. Никаких БД, часов, сети. Тестируется без моков.
from dataclasses import dataclass

@dataclass(frozen=True)
class Cart:
    items: tuple[tuple[str, int], ...] = ()
    promo: str | None = None

@dataclass(frozen=True)
class Priced:
    total: int
    commands: tuple[str, ...]      # что оболочка должна сделать во внешнем мире

def price(cart: Cart, catalog: dict[str, int], is_weekend: bool) -> Priced:
    """Решение принимается здесь. Побочных эффектов нет — только новое значение."""
    base = sum(catalog[sku] * qty for sku, qty in cart.items)
    discount = 10 if (cart.promo == "WEEKEND" and is_weekend) else 0
    total = base * (100 - discount) // 100
    cmds = ("audit:priced",) + (("notify:discount",) if discount else ())
    return Priced(total=total, commands=cmds)

# ОБОЛОЧКА: грязная и тупая. Только сбор фактов и исполнение команд.
def handle_checkout(request, db, clock, bus):
    cart = db.load_cart(request.cart_id)                 # эффект: чтение
    catalog = db.load_prices(sku for sku, _ in cart.items)
    result = price(cart, catalog, clock.now().weekday() >= 5)   # чистое решение
    db.save_total(request.cart_id, result.total)          # эффект: запись
    for cmd in result.commands:
        bus.publish(cmd)                                  # эффект: публикация
    return {"total": result.total}

Что здесь получилось: price тестируется таблицей входов-выходов без единого мока и переносится в другой рантайм целиком. handle_checkout содержит ноль условий, поэтому его почти нечем сломать. Ровно эта идея лежит в основе гексагональной архитектуры и портов-адаптеров.

Приём №2: разные парадигмы — разным слоям

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

Приём №3: состояние как явный автомат

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

TRANSITIONS = {
    ("draft", "submit"): "placed",
    ("placed", "payment_confirmed"): "paid",
    ("placed", "cancel"): "cancelled",
    ("paid", "warehouse_dispatched"): "shipped",
    ("shipped", "courier_confirmed"): "delivered",
}

def step(state: str, event: str) -> str:
    """O(1). Ошибка «оплатили отменённый заказ» невозможна структурно."""
    try:
        return TRANSITIONS[(state, event)]
    except KeyError:
        raise IllegalTransition(f"{state} -/-> {event}")

Здесь сошлись три парадигмы: таблица переходов — декларативная, step — чистая функция, а место хранения текущего состояния — императивное. Каждая делает то, что умеет лучше всего.

Часть 6. Типичные ошибки

Карго-культ ООП. Классы OrderManager, DataHelper, UtilsService — это процедурный код, переодетый в классы. Признак: у объекта нет инвариантов, только геттеры и сеттеры (анемичная модель). Инкапсуляция без инвариантов бесполезна.

ФП-фанатизм без структур данных. Копирование коллекций «ради неизменяемости» превращает O(n) в O(n²), как в примере выше. Неизменяемость окупается только вместе с персистентными структурами или с копированием на границе, а не в горячем цикле.

Наследование вместо композиции. Иерархии глубже двух уровней почти всегда оказываются неверными через год. Джошуа Блох («Effective Java», item 18) формулирует прямо: наследуйтесь только там, где отношение «является» устойчиво и класс спроектирован под наследование и задокументирован. Об этом же — весь трек принципов проектирования.

«Всё есть акторы» / «всё есть потоки». Если задача — синхронное вычисление, актор добавляет к ней сериализацию сообщений, потерю стек-трейсов и невозможность прочитать поток управления. Асинхронность — стоимость, которую платят за изоляцию и параллелизм, а не бесплатный бонус. Симметрично: декларативность там, где нужен контроль, работает ровно до момента, когда планировщик выберет неудачный план или движок правил зациклится — убедитесь, что вам есть чем контроль вернуть (индексы, подсказки, материализация).

Спор о парадигмах вместо разговора об ограничениях. «Давай сделаем функционально» — не аргумент. «Давай сделаем расчёт чистым, чтобы прогонять на нём property-based тесты и переиспользовать в батче» — аргумент.

Часть 7. Как это выглядит в проде

  • WhatsApp на Erlang: два десятка инженеров и сотни миллионов пользователей. Работает не «магия ФП», а конкретное ограничение — процессы не разделяют память, поэтому падение одного не портит состояние других, а supervisor-дерево перезапускает поддерево. См. «Designing for Scalability with Erlang/OTP».
  • React: UI как чистая функция состояния, view = f(state). Именно неизменяемость позволяет сравнивать деревья по ссылке и делать эффективную реконсиляцию; хуки — это dataflow-граф зависимостей.
  • Kubernetes и Terraform: вы описываете желаемое состояние, контроллер выполняет цикл сверки. Это чистая декларативность плюс императивный reconciler внутри — та же схема «декларативное ядро, императивная оболочка», только на уровне инфраструктуры. См. трек data engineering.
  • Spark и Kafka Streams: пользователь пишет dataflow-граф преобразований, движок сам решает, где шардировать, где переставить фильтр перед джоином. Rust: система владения — ограничение уровня парадигмы, запрещающее одновременное существование мутабельной ссылки и любой другой; плата за отсутствие гонок данных — порог входа.
  • LINQ в C# (трек): один и тот же лямбда-код исполняется либо в памяти, либо транслируется в SQL — декларативное описание переносимо между исполнителями. Горутины и каналы в Go (трек): CSP как основной способ структурировать конкурентность в мейнстримном языке.

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

Часть 8. Карта трека

Дальше трек идёт от самых старых парадигм к самым составным. Каждая статья самодостаточна, но читать по порядку выгоднее: аргументы следующей опираются на болевые точки предыдущей.

Полезные соседние треки: паттерны проектирования (словарь ООП-решений), принципы проектирования (SOLID и что от него осталось), DDD (моделирование домена), Elixir (акторы и ФП в бою), TypeScript (структурная типизация и реактивность).

Мини-итог

  1. Парадигма — это дисциплина ограничений, а не набор возможностей: она отнимает право и взамен даёт гарантию. Все парадигмы вычислительно эквивалентны, поэтому выбор — про выразительность и проверяемость, а не про мощность.
  2. Различия сводятся к ответам на три вопроса: единица композиции, владелец состояния, распорядитель порядка выполнения.
  3. История парадигм — цепочка ответов на проблемы, порождённые предыдущим успехом: спагетти → структурность, глобальное состояние → инкапсуляция, скрытая мутация → чистота и изоляция, ручное управление → декларативность.
  4. В проде выигрывает не чистота, а границы: чистое ядро, тонкая императивная оболочка, декларативные DSL там, где движок умнее вас, и явные автоматы там, где состояние неизбежно.
  5. Прежде чем спорить о парадигме, назовите класс ошибок, который вы хотите сделать невыразимым. Если такого класса нет — спор беспредметен.

Источники

  • Robert W. Floyd. The Paradigms of Programming, ACM Turing Award Lecture, 1978 — dl.acm.org
  • John Backus. Can Programming Be Liberated from the von Neumann Style?, 1978 — dl.acm.org
  • E. W. Dijkstra. Go To Statement Considered Harmful, CACM, 1968 — PDF
  • Matthias Felleisen. On the Expressive Power of Programming Languages, 1991 — ScienceDirect
  • Peter Van Roy, Seif Haridi. Concepts, Techniques, and Models of Computer Programming — лучшая книга о парадигмах как о едином пространстве; постер с картой парадигм
  • Robert C. Martin. Clean Architecture, гл. 3–6 (парадигмы как ограничения); Chris Okasaki. Purely Functional Data Structures (цена неизменяемости)
  • C. A. R. Hoare. Communicating Sequential Processes, 1978 — PDF; Carl Hewitt et al. A Universal Modular ACTOR Formalism, 1973
  • Alan Kay о том, что он имел в виду под ООП — письмо, 2003
  • Gary Bernhardt. Boundariesdestroyallsoftware.com; Martin Fowler, AnemicDomainModel

Что дальше

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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов