null

Введение в архитектуру ПО. Часть 2: Три парадигмы и сила ограничений

Введение

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

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

Ответ начинается гораздо ниже уровня диаграмм и слоёв. Он начинается в коде.

Архитектура не существует отдельно от кода - она из него собирается. Границы между компонентами проводятся не в документации, а вызовами функций и объявлениями интерфейсов. Поэтому, прежде чем обсуждать принципы проектирования и паттерны, стоит разобраться с тем, из чего вообще состоит наш инструментарий. А состоит он из трёх парадигм программирования, каждая из которых была открыта более полувека назад и с тех пор не менялась.

Самое интересное в них - то, как именно они работают. Не так, как обычно предполагают.

Немного истории: как программы стали данными

Основы того, чем мы занимаемся каждый день, заложил Алан Тьюринг. Программируемые машины придумывали и до него, но именно Тьюринг в 1938 году сформулировал ключевую идею: программа - это данные. Не особая сущность, не механизм, а обычная информация, которую можно хранить, передавать и обрабатывать так же, как любую другую.

К середине 1940-х он уже писал программы для реальных машин. И если сегодня посмотреть на его код, мы, приложив некоторое усилие, его прочитаем: там есть циклы, ветвления, присваивания, подпрограммы, стеки. Всё то же самое, чем мы пользуемся сейчас. Разница в том, что записывалось это двоичным кодом.

Дальше история пошла быстро. В конце 1940-х появились ассемблеры - они избавили программистов от ручного перевода инструкций в машинные коды. В 1951 году Грейс Хоппер создала первый компилятор A-0 и ввела в оборот сам термин «компилятор». В 1953 году появился Fortran, за ним COBOL, PL/I, C, Pascal, C++, Java - и далее непрерывным потоком до сегодняшнего дня.

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

Что такое парадигма и почему это не то же самое, что язык

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

Разницу между языком и парадигмой легко почувствовать на практике. Можно писать вполне объектно-ориентированный код на чистом C - через структуры и таблицы указателей на функции, именно так устроены многие драйверы и ядро Linux. И точно так же можно писать на Java или C# в откровенно процедурном стиле: класс с одним статическим методом на тысячу строк - это не объектно-ориентированное программирование, сколько бы ключевых слов из учебника там ни встретилось.

Язык - это набор возможностей. Парадигма - это дисциплина их применения.

Для тех, кто только начинает: представьте, что вам выдали полный набор столярных инструментов. Язык - это то, какие именно инструменты лежат в ящике. Парадигма - это принятая в мастерской практика: например, никогда не забивать шурупы молотком, даже если это быстрее и физически возможно. Инструмент никуда не делся, вы просто договорились им так не пользоваться.

Для тех, у кого есть опыт: отсюда следует практический вывод. Переход на новый язык сам по себе не улучшает архитектуру. Команда, писавшая запутанный код на PHP, напишет ровно такой же запутанный код на Go или Kotlin, если не поменяется дисциплина. Меняет ситуацию не синтаксис, а то, от чего команда сознательно отказалась.

Всего парадигм три: структурное программирование, объектно-ориентированное и функциональное. И у них есть общая особенность, которую редко замечают.

Главный парадокс: парадигмы ничего не дают

Присмотритесь к тому, что делает каждая из трёх парадигм.

Структурное программирование ограничивает прямую передачу управления - забирает у нас безусловные переходы.

Объектно-ориентированное программирование ограничивает косвенную передачу управления - забирает свободное обращение с указателями на функции.

Функциональное программирование ограничивает присваивание - забирает возможность менять значение в любой момент.

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

Это звучит контринтуитивно, но именно в этом их ценность. Парадигмы говорят нам не столько что делать, сколько чего не делать. Они сужают пространство возможных решений - и тем самым делают код предсказуемым.

Аналогия здесь простая. Правила дорожного движения не расширяют возможности автомобиля: он и без них способен ехать по встречной полосе. Правила именно что запрещают, и благодаря этому дорожное движение вообще существует как система, а не как набор случайных траекторий. Ценность ограничения - в предсказуемости, которую оно создаёт для всех остальных участников.

В коде происходит то же самое. Когда я открываю незнакомый модуль, написанный в рамках знакомой мне дисциплины, я заранее знаю, чего там точно не будет. Не будет прыжка из середины одной функции в середину другой. Не будет тихой мутации объекта, который я передал третьей стороне. Это резко снижает объём того, что мне нужно держать в голове - ту самую когнитивную нагрузку, о которой шла речь в первой части.

Обратите внимание и на исторический факт: все три парадигмы были открыты в промежутке примерно между 1958 и 1968 годами. За прошедшие с тех пор десятилетия не появилось ни одной новой. И это, скорее всего, не случайность: отнимать больше нечего. Три парадигмы забрали переходы, свободные указатели на функции и неограниченное присваивание - то есть все три способа, которыми программа может выйти из-под контроля.

Разберём каждую подробно. В этой части мы остановимся на первой - структурном программировании. Она кажется самой очевидной и именно поэтому чаще всего применяется формально, без понимания сути.

Структурное программирование: как один спор изменил профессию

Эдсгер Вибе Дейкстра родился в Роттердаме в 1930 году, пережил бомбардировки города и оккупацию Нидерландов, а в 1948 году окончил школу с лучшими оценками по математике, физике, химии и биологии. В 1952 году, в двадцать один год, он устроился в Математический центр Амстердама и стал, по сути, первым программистом в стране.

Есть один эпизод, который хорошо передаёт дух той эпохи. В 1957 году Дейкстра женился, а голландские правила того времени требовали указать в документах профессию. Он написал «программист» - и документы не приняли, потому что о такой профессии никто не слышал. Пришлось указать «физик-теоретик».

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

Именно в такой среде Дейкстра сформулировал наблюдение, которое до сих пор определяет нашу профессию. Программирование - слишком сложная работа для человеческого мозга без вспомогательной дисциплины. В программе любого практического размера деталей больше, чем человек способен удержать. Достаточно упустить одну - и код, который выглядит работающим, откажет в самом неожиданном месте.

Дейкстра искал способ доказывать правильность программ математически - выстроить для кода что-то вроде евклидовой иерархии из аксиом, теорем и следствий. В процессе он обнаружил интересную вещь.

Неограниченные переходы - инструкция goto - ломали саму возможность такого доказательства. Когда управление может прыгнуть в произвольную точку, программу невозможно разложить на независимые части: каждый фрагмент связан со всеми остальными непредсказуемым образом. Принцип «разделяй и властвуй» перестаёт работать.

Но не все переходы вели себя так. Дейкстра заметил, что «безвредные» случаи применения goto укладываются в несколько простых схем - те, что мы знаем как if/then/else и do/while. Модули, построенные только на таких конструкциях, разбирались на доказуемые части без проблем.

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

Так родилось структурное программирование.

В 1968 году Дейкстра опубликовал в журнале CACM своё знаменитое письмо о вреде оператора goto, и профессиональное сообщество взорвалось. Спор шёл около десяти лет - с письмами в редакции, резкими возражениями и не менее резкой поддержкой. Закончился он тем, что Дейкстра просто победил: языки эволюционировали, goto постепенно исчез, и сегодня большинство языков его либо не имеет вовсе, либо жёстко ограничивает границами одной функции.

Как это выглядит на практике

Вот фрагмент в духе того кода, против которого Дейкстра выступал. Псевдокод намеренно упрощён:

 i = 0;
    total = 0;
loop:
    if (i >= n) goto done;
    if (items[i].valid == 0) goto next;
    if (items[i].amount < 0) goto error;
    total = total + items[i].amount;
next:
    i = i + 1;
    goto loop;
error:
    log("bad amount");
    goto next;
done:
    return total;

Формально здесь всё работает. Но попробуйте ответить на вопрос: при каких условиях управление попадает на метку next? Придётся глазами просканировать весь фрагмент в поисках всех переходов. А в реальном коде тех лет такой фрагмент занимал не пятнадцать строк, а несколько сотен.

То же самое в структурном виде:

int total = 0;
for (int i = 0; i < n; i++) {
    if (!items[i].valid) {
        continue;
    }
    if (items[i].amount < 0) {
        log("bad amount");
        continue;
    }
    total += items[i].amount;
}
return total;

Разница не в количестве строк - оно почти одинаковое. Разница в том, что теперь у фрагмента есть чёткая граница. Я вижу тело цикла целиком и знаю: управление войдёт сюда сверху и выйдет снизу, других вариантов нет. Мне не нужно проверять, не прыгает ли сюда кто-то ещё из соседнего модуля.

Именно это свойство и есть суть парадигмы: у каждого блока кода один вход и один выход.

Современный goto, который никто так не называет

Вот здесь начинается самое важное, и об этом обычно молчат. Считается, что тема закрыта: goto в языке нет, значит, структурное программирование соблюдается автоматически. Это не так.

Ключевое слово исчезло, а само явление - нет. Неограниченная передача управления просто сменила форму. Вот несколько её современных обличий, которые я регулярно встречаю в рабочих проектах.

Исключения как штатный поток управления

Когда исключение бросается не при аварии, а как обычный способ вернуть результат из глубины стека вызовов - это ровно тот же прыжок в неизвестное место, только легализованный синтаксисом:

function findDiscount(user) {
  if (user.isVip) {
    throw new DiscountFound(0.2);   // это не ошибка, это возврат значения
  }
  // ...
}

Читатель функции findDiscount не может понять по её сигнатуре, что она вообще-то возвращает результат таким способом. Обработчик находится где-то на три уровня выше по стеку. Это метка goto, до которой сорок минут поиска по проекту.

Флаги состояния, управляющие ходом выполнения

Классика:

let shouldSkip = false;
let hasError = false;

for (const order of orders) {
  if (hasError) { break; }
  if (shouldSkip) { shouldSkip = false; continue; }
  // сто строк логики, где-то внутри которой
  // выставляются shouldSkip и hasError
}

Формально здесь только for, if, break и continue. Фактически - конечный автомат, спрятанный в переменных, и предсказать его поведение можно только выполнением в голове.

Глубокая вложенность

Она не нарушает правил формально, но убивает то самое свойство «один вход, один выход», ради которого всё затевалось:

function handleOrder(order) {
  if (order) {
    if (order.items.length > 0) {
      if (order.customer.isActive) {
        if (hasStock(order)) {
          if (paymentAllowed(order)) {
            // основная логика прячется здесь, на шестом уровне
          }
        }
      }
    }
  }
}

Хорошая новость в том, что лечится это одинаково - декомпозицией и ранними возвратами:

function handleOrder(order) {
  const violation = validateOrder(order);
  if (violation) {
    return Result.rejected(violation);
  }

  reserveStock(order);
  return Result.accepted(createInvoice(order));
}

function validateOrder(order) {
  if (!order.items.length)        return 'EMPTY_ORDER';
  if (!order.customer.isActive)   return 'INACTIVE_CUSTOMER';
  if (!hasStock(order))           return 'OUT_OF_STOCK';
  if (!paymentAllowed(order))     return 'PAYMENT_BLOCKED';
  return null;
}

Функция handleOrder теперь читается за пять секунд и умещается на экране целиком. Проверки собраны в одном месте, и добавление новой не требует трогать основной сценарий. Это ровно та низкая связанность и высокая согласованность, о которых мы говорили в первой части - только выраженная не в терминах модулей, а в терминах пяти строк кода.

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

Функциональная декомпозиция: почему это работает

Из структурного программирования следует важное практическое свойство - возможность рекурсивно разбивать программу на части.

Большая задача раскладывается на несколько функций верхнего уровня. Каждая из них - на функции уровнем ниже. И так далее, пока не дойдём до фрагментов, которые целиком укладываются в голову. Именно на этом фундаменте в конце 1970-х и в 1980-х выросли дисциплины структурного анализа и структурного проектирования, которые продвигали Эд Йордан, Ларри Константин, Том Демарко и Меилир Пейдж-Джонс.

Работает это не потому, что мелкие функции - это красиво. А потому, что у каждой части появляется определённый контракт: что она принимает на входе, что возвращает на выходе. Пока управление входит в блок только сверху и покидает его только снизу, такой контракт существует. Как только появляется свободный переход - контракта нет, и рассуждать о части в отрыве от целого больше невозможно.

Простой пример из практики. Функция расчёта стоимости заказа на четыреста строк - это одно утверждение, которое либо верно, либо нет, и проверить его можно только целиком. Та же логика, разложенная на расчёт базовой суммы, применение скидок, начисление налога и учёт доставки - это четыре независимых утверждения. Каждое проверяется отдельно, и при ошибке в итоговой сумме вы сразу знаете, где искать.

Для специалистов с опытом: отсюда же растёт и практическая ценность метрики цикломатической сложности. Она измеряет количество независимых путей исполнения через функцию - то есть, по сути, количество случаев, которые придётся разобрать, чтобы убедиться в её правильности. Это не абстрактный показатель для отчётности, а прямая оценка того, сколько усилий потребует проверка кода. Функцию со сложностью 3 держит в голове любой; функцию со сложностью 25 не держит никто, включая её автора через две недели после написания.

Доказательства, которых не случилось

У истории структурного программирования есть неожиданный финал, и он важнее самой истории.

Мечта Дейкстры не сбылась. Евклидову иерархию доказанных теорем для программ так и не построили. Формальное доказательство правильности каждой функции оказалось настолько трудоёмким, что практическая индустрия от него отказалась. Сегодня формальная верификация применяется, но в узких областях, где цена ошибки исключительна: авионика, медицинское оборудование, криптографические примитивы. В обычной разработке её нет.

Но отказ от доказательств не означал отказа от строгости. Просто строгость пришла из другой дисциплины.

Разница между математикой и наукой принципиальна. Математика доказывает истинность утверждений. Наука так не умеет: невозможно доказать второй закон Ньютона. Можно провести множество экспериментов, измерить результат с любой точностью, но всегда остаётся вероятность, что следующий эксперимент покажет границы применимости закона - собственно, с ньютоновской механикой ровно это и произошло.

Наука работает наоборот: она пытается опровергнуть. Утверждение, которое устояло после множества добросовестных попыток его опровергнуть, мы считаем истинным - до тех пор, пока не появится опровержение.

Разработка программного обеспечения устроена по научному принципу, а не по математическому. И у Дейкстры есть на этот счёт афоризм, который стоит помнить любому, кто пишет тесты: «Тестирование показывает присутствие ошибок, а не их отсутствие».

Тестами нельзя доказать, что программа правильная. Можно доказать только обратное - что она неправильная. Всё, что даёт нам зелёная сборка после достаточных усилий, - это обоснованная уверенность в том, что программа работает достаточно правильно для наших целей.

И вот здесь замыкается вся логика этой части. Опровергать можно только то, что поддаётся разбору на части. Программу, в которой управление скачет произвольным образом, нельзя признать правильной ни при каком количестве тестов, потому что вы не можете изолировать проверяемое утверждение. Структурное программирование нужно нам ровно для того, чтобы получить тестируемые единицы.

Тестируемость - это архитектурное свойство

Из сказанного следует вывод, который стоит отдельного разговора: если что-то трудно протестировать, проблема почти никогда не в тестах. Проблема в структуре.

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

function isSubscriptionExpired(userId) {
  const user = db.query('SELECT * FROM users WHERE id = ?', userId);
  return user.expires_at < new Date();
}

 

Здесь три строки и одна простая мысль. Но чтобы её проверить, нужна база данных с подготовленными записями, а ещё нужно как-то подменить системное время - иначе тест на «подписка истекает завтра» сломается ровно завтра. В итоге вокруг трёх строк вырастает инфраструктура на сотню строк, которая работает медленно и ломается по не связанным с логикой причинам.

Теперь то же самое, но с разделением решения и получения данных:

// Чистая функция: решение. Никаких обращений вовне.
function isSubscriptionExpired(expiresAt, now) {
  return expiresAt < now;
}

// Оболочка: получение данных и побочные эффекты.
async function checkUserSubscription(userId, clock) {
  const user = await users.findById(userId);
  return isSubscriptionExpired(user.expiresAt, clock.now());
}

Проверка решения теперь не требует ничего:

test('подписка считается истёкшей на следующий день после даты окончания', () => {
  const expiresAt = new Date('2026-01-01T00:00:00Z');
  const now       = new Date('2026-01-02T00:00:00Z');

  expect(isSubscriptionExpired(expiresAt, now)).toBe(true);
});

Ни базы, ни моков, ни зависимости от календаря. Тест выполняется за доли миллисекунды и падает только тогда, когда логика действительно сломана.

Заметьте, что мы сделали. Мы не «написали тесты» - мы изменили структуру кода так, чтобы его вообще было о чём спрашивать. Время и база данных превратились из скрытых зависимостей в явные параметры. Это ровно то, чем занимается архитектор, только в масштабе одной функции: отделяет то, что решает, от того, что общается с внешним миром.

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

Что из этого следует для архитектуры

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

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

Объектно-ориентированное программирование через полиморфизм даёт механизм пересечения архитектурных границ - оно отвечает за разделение компонентов и за возможность менять реализацию, не трогая тех, кто ею пользуется.

Функциональное программирование накладывает ограничения на то, где живут данные и кто имеет право их менять - оно отвечает за управление состоянием.

Функциональность, разделение компонентов, управление данными. Три парадигмы, три опоры, никаких совпадений.

Заключение

Если собрать главное из этого материала, получится следующее.

Парадигмы - это не про то, что язык вам позволяет, а про то, от чего вы сознательно отказались. Смена языка не улучшает архитектуру; меняет ситуацию только смена дисциплины.

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

Отсутствие ключевого слова goto в языке не означает соблюдения структурного программирования. Исключения в роли возврата значений, флаги-переключатели и глубокая вложенность создают ровно ту же проблему, ради решения которой всё затевалось.

Функциональная декомпозиция ценна не сама по себе, а тем, что превращает одно большое непроверяемое утверждение в набор маленьких проверяемых.

Разработка ближе к науке, чем к математике. Мы не доказываем правильность - мы не смогли доказать неправильность и считаем это достаточным основанием. Отсюда и роль тестов: они не гарантия, а инструмент опровержения.

Трудность тестирования - это не свойство тестов, а свойство архитектуры. Если что-то невозможно проверить без поднятия половины системы, проблема находится в коде, а не в инструментах.

Спасибо, что дочитали до конца.

В следующей части мы разберём вторую парадигму - объектно-ориентированное программирование. Поговорим о том, чем оно на самом деле является, почему полиморфизм оказался главным архитектурным изобретением за пятьдесят лет и как с его помощью развернуть направление зависимостей между модулями в нужную сторону.

Следите за продолжением.

Вперед