Императивная и процедурная парадигмы
Если вы когда-нибудь писали x = x + 1, вы писали императивный код. Это настолько привычно, что кажется не парадигмой, а
«просто программированием». Именно поэтому её стоит разобрать первой и всерьёз: императивный стиль — это не отсутствие выбора,
а вполне конкретная модель вычислений со своими законами, своей алгеброй рассуждений и своей ценой. Понимая её границы, вы
будете осмысленнее применять всё остальное — и ООП, и
функциональное программирование. Общая карта парадигм — в обзоре трека:
Парадигмы программирования: карта, история и как они связаны.
Интуиция: рецепт против заказа в ресторане
Есть два принципиально разных способа получить результат.
Императивный (рецепт). «Возьми кастрюлю. Налей два литра воды. Поставь на огонь. Дождись кипения. Засыпь макароны. Вари восемь минут. Слей воду.» Вы описываете последовательность действий, каждое из которых меняет состояние мира. Смысл программы — в том, как она работает; результат — побочный эффект от выполнения шагов.
Декларативный (заказ). «Пасту, пожалуйста.» Вы описываете желаемый результат, а как его достичь — забота исполнителя. Так работают SQL, Prolog, HTML, CSS, regexp-движки; подробно — в статье Декларативное и логическое программирование.
Ключевое слово императивной парадигмы — изменяемое состояние (mutable state). Программа здесь — это машина с памятью, а исполнение — траектория этой машины по пространству состояний.
# Императивно: описываем шаги и явно меняем состояние
total = 0
for x in numbers:
total = total + x # состояние total меняется на каждой итерации
# Декларативно: описываем, что такое сумма
total = sum(numbers)
Обе строчки дают одно число. Но в первом варианте существует момент времени, когда total равно «сумме первых трёх
элементов» — промежуточное, наблюдаемое, отлаживаемое, ломаемое состояние. Во втором такого момента концептуально нет.
Откуда это взялось: машина фон Неймана
Императивная парадигма — не чья-то стилистическая прихоть, а прямое отражение архитектуры реального железа. В модели фон Неймана есть память (ячейки с адресами), процессор и счётчик команд, а вычисление — это цикл «прочитать инструкцию → изменить память или регистры → перейти к следующей инструкции». Отсюда прямое соответствие:
| Понятие языка | Что это на железе |
|---|---|
| переменная | именованная ячейка памяти |
присваивание x = e |
запись в ячейку (mov, str) |
последовательность ; |
инкремент счётчика команд |
if / while |
условный и безусловный переход (jmp, jz) |
| вызов процедуры | call: push адреса возврата, переход |
| массив | непрерывный блок памяти + арифметика адресов |
Именно поэтому императивный код так хорошо оптимизируется: компилятору почти нечего «переводить» — он раскладывает ваши
намерения по регистрам, а не изобретает стратегию исполнения. Джон Бэкус, автор FORTRAN, в Тьюринговской лекции 1978 года
назвал это «бутылочным горлышком фон Неймана» и призвал уходить от парадигмы присваивания
(Can Programming Be Liberated from the von Neumann Style?) — что забавно,
учитывая, что именно он этот стиль и канонизировал. Убедиться в соответствии можно буквально: dis.dis(lambda x: x + 1)
в Python выдаёт LOAD_FAST x / LOAD_CONST 1 / BINARY_OP + / STORE_FAST x — «прочитать ячейку, посчитать, записать в ячейку»
(docs.python.org/3/library/dis.html).
Краткая история: как императивщина взрослела
1. Структурное программирование (1968)
Ранний императивный код был «спагетти»: произвольные GOTO в любую точку программы. Эдсгер Дейкстра в письме
Go To Statement Considered Harmful сформулировал главную мысль:
человек хорошо рассуждает о статическом тексте программы, но плохо — о её динамическом развёртывании во времени. Значит,
конструкции языка должны быть такими, чтобы позиция в тексте программы почти однозначно определяла состояние вычисления.
Теоретический фундамент дала теорема Бёма — Якопини (1966): любую вычислимую функцию можно выразить комбинацией всего трёх
управляющих конструкций (структурная теорема).
Важная деталь: у каждого блока ровно один вход и один выход. Именно это свойство позволяет рассуждать о программе
композиционально — разбирать её по частям, не держа в голове всю картину целиком. Кнут позже написал взвешенный ответ —
Structured Programming with go to Statements (Computing Surveys, 1974), где показал:
догма вредна, ранний выход из цикла и goto cleanup в обработке ошибок бывают честнее, чем флаги и вложенность. Поэтому в ядре
Linux goto err_free; — не ересь, а официальный стиль (kernel.org coding style).
2. Процедуры как единица абстракции
Процедурная парадигма — это императивная парадигма плюс декомпозиция на процедуры (функции, подпрограммы). Разница
принципиальная: императивность отвечает на вопрос «как устроено вычисление», процедурность — «как устроена структура программы».
Процедура даёт три вещи: абстракцию (имя вместо тела: sort(items) вместо двадцати строк перестановок), повторное
использование (одна реализация, много точек вызова) и локальность (свои переменные, невидимые снаружи).
3. Модульность и сокрытие информации (1972)
Дэвид Парнас в классической статье On the Criteria To Be Used in Decomposing Systems into Modules показал, что делить программу нужно не по шагам обработки, а по решениям, которые могут измениться: каждый модуль прячет одно проектное решение. Эта идея старше ООП, живёт в чистом C и лежит в основе всей современной архитектуры — от пакетов Go до bounded context в DDD.
Модель состояния: как рассуждать о императивной программе строго
Императивная программа — это функция из состояний в состояния. Состояние σ — отображение имён в значения; каждая инструкция преобразует σ. Формальный аппарат дал Тони Хоар в статье An Axiomatic Basis for Computer Programming (CACM, 1969). Основная конструкция — тройка Хоара:
{P} S {Q}
«Если перед выполнением S верно предусловие P, то после выполнения S верно постусловие Q». Правило присваивания задаётся
подстановкой: { Q[x := e] } x := e { Q } — читается справа налево: чтобы после x := e выполнялось Q, до него должно
выполняться Q, в котором x заменён на e. А для цикла нужен инвариант — утверждение, истинное перед циклом и
сохраняющееся каждой итерацией. Это не академическая экзотика: инвариант цикла — самый практичный инструмент отладки
императивного кода. Разберём бинарный поиск.
BINARY-SEARCH(A, key):
lo := 0
hi := length(A) // полуинтервал [lo, hi)
// ИНВАРИАНТ: если key есть в A, то его индекс лежит в [lo, hi)
while lo < hi:
mid := lo + (hi - lo) / 2 // без переполнения
if A[mid] < key: lo := mid + 1
else: hi := mid
// ВЫХОД: lo == hi, интервал пуст либо указывает на кандидата
if lo < length(A) and A[lo] == key: return lo
return NOT_FOUND
def binary_search(a: list[int], key: int) -> int:
"""Индекс key в отсортированном a или -1.
Время O(log n) — интервал hi-lo делится пополам на каждой итерации.
Память O(1) — три целочисленные переменные, никаких новых списков.
"""
lo, hi = 0, len(a)
while lo < hi: # Инвариант: если key есть, он в срезе a[lo:hi]
mid = lo + (hi - lo) // 2 # то же, что (lo+hi)//2, но без переполнения в C/Java
if a[mid] < key: lo = mid + 1 # a[mid] мал — отбрасываем левую половину
else: hi = mid # a[mid] может быть ответом — оставляем в интервале
return lo if lo < len(a) and a[lo] == key else -1
Знаменитый баг (lo + hi) // 2 переполнял int в Java и держался в JDK около девяти лет —
история от Джошуа Блоха. Мораль: в императивном коде корректность держится на инвариантах, а не на интуиции, и рассуждать о них надо явно.
Присваивание и его цена: алиасинг
Самое опасное свойство императивной модели — то, что два имени могут указывать на одну ячейку. Это называется алиасингом, и он ломает локальное рассуждение о коде: чтобы понять, что делает функция, нужно знать, кто ещё держит ссылку на её аргументы.
def add_item(item, basket=[]): # КЛАССИКА: список создан ОДИН раз при определении функции
basket.append(item) # add_item("хлеб") → ['хлеб']; add_item("молоко") → ['хлеб','молоко']
return basket # состояние пережило вызов, хотя выглядит локальным
def normalize(rows: list[dict]) -> list[dict]:
for r in rows:
r["email"] = r["email"].strip().lower() # мутируем структуру вызывающей стороны!
return rows # «вроде бы новый» результат, но исходник изменён
Второй случай — источник целого класса багов, которые не воспроизводятся в юнит-тестах: там объекты всегда свежие, а в проде
на те же словари смотрит кеш, лог или очередь. Контролировать алиасинг можно тремя способами: дисциплиной (копировать на
входе, документировать мутацию) — так живут C и Python; системой типов — const в C++, readonly/in в C#, владение и
заимствование в Rust, где компилятор гарантирует «либо одна изменяемая ссылка, либо сколько угодно неизменяемых»; отказом от
мутации — неизменяемые данные, см. Функциональное программирование. Тот же алиасинг в многопоточной среде превращается в гонки данных — про это Конкурентные парадигмы: акторы, CSP, STM, разделяемая память.
Процедуры, стек и стоимость вызова
Механика вызова — не деталь реализации, а то, что определяет пределы применимости процедурного стиля.
Каждый вызов кладёт на стек кадр (stack frame): адрес возврата, сохранённые регистры, параметры, локальные переменные;
возврат снимает кадр. Отсюда практические следствия: локальные переменные «бесплатны» (это просто смещения от указателя кадра);
рекурсия глубины d стоит O(d) памяти, а не O(1), как эквивалентный цикл (в CPython жёсткий лимит sys.setrecursionlimit,
по умолчанию 1000, в C — переполнение стека и SIGSEGV); возврат большой структуры по значению — это копирование, поэтому в C
принято передавать указатель на буфер результата (out-параметр).
Обратите внимание на последнюю ремарку. В процедурном коде результат работы часто не возвращается, а записывается — в out-параметр, в глобальную структуру, в файл. Это и есть императивность на уровне интерфейсов: контракт процедуры описывается не только типом возврата, но и тем, что она меняет.
Способы передачи параметров.
| Способ | Семантика | Где встречается |
|---|---|---|
| по значению | копия аргумента | C (скаляры), Go, Java (примитивы) |
| по ссылке | процедура пишет прямо в переменную вызывающего | C++ int&, C# ref, Pascal var |
| по указателю | копируется адрес, мутация видима | C, Go (*T), Rust (&mut T) |
| по разделяемому объекту | копируется ссылка, объект общий | Python, Java (объекты), JS |
| по имени | аргумент подставляется текстуально и вычисляется при каждом обращении | ALGOL 60, макросы C |
Питоновскую модель часто ошибочно называют «по ссылке». Корректный термин — call by sharing: переприсваивание параметра внутри функции не видно снаружи, а мутация объекта — видна.
Полноценный пример: процедурный модуль на C
Классическая процедурная архитектура — «непрозрачный тип + набор процедур над ним»: инкапсуляция без единого объекта, ровно по Парнасу.
/* ring.h — интерфейс (внутри защита от повторного включения и <stddef.h>) */
typedef struct Ring Ring; /* непрозрачный тип: определение спрятано в .c */
Ring *ring_create(size_t capacity);
void ring_destroy(Ring *r);
int ring_push(Ring *r, int value); /* 0 — успех, -1 — буфер полон */
int ring_pop(Ring *r, int *out); /* 0 — успех, -1 — буфер пуст */
size_t ring_size(const Ring *r); /* const: процедура не мутирует */
/* ring.c — реализация. Всё проектное решение (кольцевой буфер) спрятано здесь. */
#include <stdlib.h>
#include "ring.h"
struct Ring {
int *data;
size_t cap; /* ёмкость */
size_t head; /* индекс чтения */
size_t count; /* сколько элементов занято */
};
Ring *ring_create(size_t capacity) {
if (capacity == 0) return NULL;
Ring *r = malloc(sizeof *r);
if (!r) return NULL;
r->data = malloc(capacity * sizeof *r->data);
if (!r->data) { free(r); return NULL; } /* важно: не течём при частичном отказе */
r->cap = capacity; r->head = 0; r->count = 0;
return r;
}
void ring_destroy(Ring *r) { /* терпим NULL: free(NULL) допустим по стандарту */
if (r) { free(r->data); free(r); }
}
int ring_push(Ring *r, int value) {
if (r->count == r->cap) return -1; /* переполнение — это не паника */
r->data[(r->head + r->count) % r->cap] = value; /* кольцо: индекс по модулю */
r->count++;
return 0;
}
int ring_pop(Ring *r, int *out) {
if (r->count == 0) return -1;
*out = r->data[r->head]; /* результат через out-параметр */
r->head = (r->head + 1) % r->cap; r->count--;
return 0;
}
size_t ring_size(const Ring *r) { return r->count; }
Что здесь ценного с точки зрения парадигмы:
- Состояние явно и локализовано — оно всё в
struct Ring, и добраться до него можно только через процедуры модуля. - Никакой глобальной изменяемости — каждый вызов получает
Ring *явным первым аргументом. Это ровно то, чем являетсяself/thisв ООП: процедурный код просто не прячет его. - Ошибки — часть контракта возврата, а не исключения. Тот же подход в Go:
if err != nil. - Сложность:
push/pop/size— O(1) по времени и O(1) по дополнительной памяти; весь буфер — O(capacity), выделенный один раз, без реаллокаций и фрагментации.
Именно за эту предсказуемость процедурный C выбирают для системного софта: SQLite объясняет свой выбор языка ровно так — производительность, совместимость, стабильность (Why Is SQLite Coded In C).
Мутация как инструмент производительности
Главный содержательный аргумент за императивность — не привычка, а алгоритмическая эффективность: огромный класс алгоритмов формулируется «на месте» (in-place) и получает O(1) дополнительной памяти там, где чистый функциональный аналог требует O(n). Простейший пример — разворот массива двумя указателями:
Более серьёзный пример — разбиение Ломуто из быстрой сортировки. Оно принципиально императивно: смысл в том, что мы переставляем элементы одного массива, а не строим новые.
def partition(a: list[int], lo: int, hi: int) -> int:
"""Схема Ломуто. Опорный элемент — a[hi]. Время O(hi-lo), доп. память O(1).
Инвариант: a[lo..i-1] <= pivot, a[i..j-1] > pivot, a[hi] == pivot.
"""
pivot = a[hi]
i = lo # граница «малых» элементов
for j in range(lo, hi):
if a[j] <= pivot:
a[i], a[j] = a[j], a[i] # мутация — суть алгоритма
i += 1
a[i], a[hi] = a[hi], a[i] # ставим опорный на его окончательное место
return i
def quicksort(a: list[int], lo: int = 0, hi: int | None = None) -> None:
"""На месте: O(n log n) в среднем, O(n^2) в худшем, O(log n) памяти на стеке."""
hi = len(a) - 1 if hi is None else hi
while lo < hi:
p = partition(a, lo, hi)
# Рекурсия в МЕНЬШУЮ половину, итерация в большую: глубина стека гарантированно O(log n)
if p - lo < hi - p: quicksort(a, lo, p - 1); lo = p + 1
else: quicksort(a, p + 1, hi); hi = p - 1
Сравните с функциональным quicksort (less ++ [pivot] ++ greater): он короче и красивее, но аллоцирует O(n log n)
промежуточных данных и полностью теряет локальность кэша. На массиве из 10 миллионов чисел разница в разы — и не из-за
«языка», а из-за парадигмы. Разбор классических in-place алгоритмов — CLRS, «Алгоритмы: построение и анализ»
(mitpress.mit.edu), главы 2 и 7. Мышление «данные лежат
в памяти линейно, и мы их переставляем» доведено до предела в data-oriented design — см. доклад Майка Актона
Data-Oriented Design and C++ (CppCon 2014).
Жизненный цикл изменяемого состояния
Всякое изменяемое состояние в реальной системе проходит через фазы, и большинство багов — это операции, выполненные не в той фазе.
Полезно смотреть на эту диаграмму как на карту защитных мер: переход «Неинициализировано → Ошибка» закрывается конструкторами, T x = {0}, нулевыми значениями Go и обязательной инициализацией в Rust; «Валидно → Повреждено» — тем, что весь мутирующий код собран в одном модуле (тот самый ring.c) и проверяется assert’ами; «Освобождено → Ошибка» — RAII, сборщиком мусора, borrow checker’ом или дисциплиной «кто выделил, тот и освобождает», записанной в комментарии к API.
Типичные ошибки императивного кода
1. Глобальное изменяемое состояние. Самый дорогой антипаттерн: любая процедура может изменить глобальную переменную, поэтому, чтобы понять одну функцию, надо прочитать всю программу. Плюс невозможность параллелить и тестировать изолированно. Лечится передачей зависимости явным параметром — handle(req, cfg) вместо чтения глобали config внутри тела.
2. Функция, которая и считает, и мутирует, и печатает. Смешение «команд» и «запросов». Правило Бертрана Мейера — command–query separation: метод либо возвращает значение и ничего не меняет, либо меняет состояние и ничего не возвращает (martinfowler.com/bliki/CommandQuerySeparation.html).
3. Длинные процедуры и глубокая вложенность. Когда в теле 300 строк и пять уровней if, инвариант держать в голове невозможно. Практичное лекарство — guard clauses: серия ранних return на проверках входа, после которых основная логика идёт на нулевом уровне вложенности.
4. Off-by-one и флаговые переменные. Путаница границ — прямое следствие ручного управления индексами; дисциплина спасает:
всегда полуоткрытые интервалы [lo, hi) (Дейкстра объяснил почему в
EWD831). А done = False; while not done: ... почти
всегда переписывается через while с честным условием или break — и становится читаемее.
5. Мутация коллекции во время итерации. Классика во всех языках:
for item in items:
if item.expired: items.remove(item) # часть элементов будет молча пропущена
items[:] = [i for i in items if not i.expired] # правильно: перестроить и присвоить срезу
6. Преждевременная оптимизация ради «императивной эффективности». Ручной цикл вместо понятной библиотечной операции оправдан только там, где вы измерили. Кнутовское «преждевременная оптимизация — корень всех зол» — из той же статьи 1974 года.
Как выбирать: цикл или свёртка
или буфер фиксированного размера?} Q1 -->|да| IMP[Императивный цикл на месте] Q1 -->|нет| Q2{Это отображение или фильтрация
без общего состояния?} Q2 -->|да| FUN[map / filter / comprehension] Q2 -->|нет| Q3{Инвариант накопителя
формулируется одной фразой?} Q3 -->|да| IMP2[Цикл с инвариантом в комментарии] Q3 -->|нет| SPLIT[Разбить на процедуры поменьше] IMP --> BENCH[Замерить, если это горячий путь] FUN --> BENCH
Практическое правило: цикл оправдан, когда у него есть инвариант, который вы можете сформулировать одной фразой; если сформулировать не получается — цикл делает слишком много и должен быть разбит.
Trade-offs: честный список
| Сильные стороны | Слабые стороны |
|---|---|
| Прямое соответствие железу → предсказуемые время и память | Нелокальность рассуждения: чтобы понять фрагмент, нужно знать всю историю состояния |
| Естественность для задач, которые по сути про изменение мира: драйверы, игровой цикл, эмуляторы, парсеры, аллокаторы | Плохая тестируемость при неявных зависимостях: результат зависит от порядка вызовов, а не только от аргументов |
| In-place алгоритмы с O(1) дополнительной памяти и хорошей локальностью кэша | Гонки в конкурентности: разделяемое изменяемое состояние + параллелизм = самый дорогой класс багов |
| Низкий порог входа и отличная отлаживаемость: состояние всегда видно в отладчике | Слабая композируемость: две корректные процедуры подряд могут дать неверный результат, если первая нарушила предусловие второй; мало алгебраических законов — нельзя свободно переставлять и распараллеливать вычисления |
Компромисс, ставший мейнстримом в 2020-е, — functional core, imperative shell: ядро логики пишется чистыми функциями, а вся мутация, ввод-вывод и работа с состоянием вынесены в тонкую императивную оболочку. Подробнее — в статье Как выбирать и смешивать парадигмы в реальном проекте.
Где императивный и процедурный стиль живут в проде сегодня
Императивность никуда не «устарела» — она просто заняла свои ниши.
- Ядра ОС и драйверы. Linux, FreeBSD, Windows kernel — процедурный C: нужен контроль над каждым байтом и над временем выполнения, а исключения и сборка мусора там недопустимы.
- Встраиваемые системы и авионика. MISRA C и The Power of 10 — правила JPL/NASA: запрет рекурсии, запрет динамической аллокации после инициализации, фиксированные границы циклов. Это императивность, ужатая до полностью анализируемого подмножества.
- Базы данных, рантаймы, игровые движки. SQLite, Redis, PostgreSQL, JVM и V8 — процедурный C/C++; игровой цикл
update(dt); render();императивен по определению, а ECS доводит это до идеала data-oriented design. - Численные вычисления и HPC. Fortran жив в LAPACK и климатических моделях не по инерции: плотные циклы по массивам с известными границами компилятор векторизует лучше всего.
- Go как сознательный выбор — процедурный язык с интерфейсами: функции, структуры, явные ошибки, никакого наследования (Effective Go, Go: обзор). И, наконец, скрипты, миграции и ETL-джобы: там, где задача буквально является последовательностью шагов над внешним миром, императивный код честнее любой абстракции.
Даже в языках, которые принято считать объектными или функциональными, тело метода почти всегда императивно: var, циклы,
присваивания. Парадигмы работают на разных масштабах — императивная на масштабе тела функции, объектная и функциональная на
масштабе архитектуры. Это видно на примере C#, где императивное тело метода соседствует с LINQ и записями: C#: обзор.
Мини-итог и рабочие правила
- Императивная парадигма = вычисление как последовательность команд, изменяющих состояние; модель — машина фон Неймана, базовая операция — присваивание. Структурное программирование дисциплинировало поток управления, а процедурная парадигма добавила декомпозицию: процедуры как единицы абстракции, модули как единицы сокрытия проектных решений.
- Строгий инструмент рассуждения — тройки Хоара и инварианты циклов; без них императивный код держится на удаче. Сила парадигмы — контроль над памятью и временем; слабость — нелокальность рассуждения и хрупкость в конкурентности.
Что делать на практике: сужать область видимости до минимума (объявлять переменную там, где она нужна); держать
неизменяемость по умолчанию (const, final, readonly, val — бесплатное сокращение пространства состояний);
записывать инвариант цикла в комментарий; давать процедуре одну ответственность и один уровень абстракции; передавать
зависимости явными параметрами вместо глобалей; разделять команды и запросы; мутировать только то, чем владеете (иначе
копировать или называть функцию sort_in_place); использовать полуоткрытые интервалы [lo, hi) везде. Одной фразой:
держите изменяемое состояние маленьким, локальным и явным — тогда императивный код останется союзником.
Источники
- E. W. Dijkstra. Go To Statement Considered Harmful, CACM, 1968; Why numbering should start at zero (EWD831), 1982.
- C. A. R. Hoare. An Axiomatic Basis for Computer Programming, CACM, 1969.
- D. Knuth. Structured Programming with go to Statements, Computing Surveys, 1974.
- D. Parnas. On the Criteria To Be Used in Decomposing Systems into Modules, CACM, 1972.
- J. Backus. Can Programming Be Liberated from the von Neumann Style?, Тьюринговская лекция, 1978.
- G. Holzmann. The Power of 10: Rules for Developing Safety-Critical Code, IEEE Computer, 2006.
- B. Kernighan, R. Pike. The Practice of Programming, 1999; B. Kernighan, D. Ritchie. The C Programming Language, 2-е изд.
- Linux kernel coding style · Why Is SQLite Coded In C · M. Fowler, CommandQuerySeparation.
Что дальше
Мы разобрали программу как последовательность команд над состоянием и увидели её главную проблему: состояние легко расползается по всей программе. Первым системным ответом на это стала идея связать данные с процедурами, которые ими управляют, и раздать получившимся «капсулам» единый протокол общения.
Следующая статья: Объектно-ориентированное программирование: инкапсуляция, полиморфизм, наследование.