Инварианты в программировании
Инвариант — это условие, которое должно оставаться истинным всегда, независимо от того, как и когда выполняется код.
Если инвариант нарушен — программа уже логически сломана, даже если она «ещё работает».
Это не про тесты.
Это про смысл и правильность программы.
Инвариант ≠ тест, но тесты его проверяют
-
Инвариант — правило реальности программы
-
Тест — способ убедиться, что правило не нарушено
-
assert — технический инструмент проверки инварианта
Инвариант существует даже если ты его нигде не проверяешь.
Простейшие примеры
1. Инвариант структуры данных
class Stack
def initialize
@items = []
end
def pop
raise "empty stack" if @items.empty?
@items.pop
end
end
Инвариант:
стек никогда не должен позволять
pop, если он пуст
Ты можешь:
-
проверять это
raise -
проверять тестами
-
проверять
assert
Но само правило — инвариант.
2. Инвариант состояния объекта
class BankAccount
def initialize(balance)
raise ArgumentError if balance < 0
@balance = balance
end
def withdraw(amount)
raise if amount > @balance
@balance -= amount
end
end
Инвариант:
@balance >= 0всегда
Даже между вызовами методов.
Даже ночью, когда никто не смотрит 😏
3. Инварианты циклов (классика из Вирта и Дейкстры)
sum = 0
i = 0
while i < n
sum += a[i]
i += 1
end
Инвариант цикла:
sum == сумма элементов a[0...i]
Это:
-
основа доказательства корректности
-
не имеет отношения к фреймворкам
-
чистая математика + инженерия
Старо? Да.
Работает? Всегда.
Почему это важнее тестов
Тесты:
-
проверяют частные случаи
Инварианты:
-
определяют что вообще допустимо
Можно:
-
написать 1000 тестов
-
и всё равно не понять, что программа делает
Но если ты сформулировал инварианты:
-
код становится очевидным
-
ошибки видно до выполнения
-
рефакторинг становится безопасным
Инварианты и Ruby / Rust
В Ruby
-
инварианты чаще в голове и в код-ревью
-
иногда — через
raise,assert, контракты -
Ruby — язык доверия к разработчику
В Rust
-
инварианты вшиты в типовую систему
-
компилятор — твой злой, но честный напарник
-
многие инварианты невозможно нарушить в принципе
Вот почему Ruby + Rust — идеальная пара:
-
Ruby — выразить смысл
-
Rust — зафиксировать инварианты железом
Коротко, по-честному
Инвариант — это не проверка, а закон.
Проверки — всего лишь полиция.
Если ты думаешь инвариантами — ты уже не «кодер», а инженер.
Так думали Вирт, Дейкстра и все, кто писал код, который живёт десятилетиями.
Мыслить инвариантами, до написания кода
Отлично. Значит говорим по-взрослому, по старой школе — без Agile-мантр и «потом покроем тестами».
Это действительно Вирт + Дейкстра + Кнут, и именно так пишут код, который переживает поколения и фреймворки.
Главная мысль (запомни её)
Код — это следствие.
Инварианты — причина.
Ты не начинаешь с классов.
Ты не начинаешь с методов.
Ты начинаешь с того, что обязано быть истинным всегда.
Шаг 1. Сформулировать «что не может быть иначе»
Берёшь задачу и задаёшь себе не «как», а что невозможно нарушить.
Пример (без кода):
У нас есть очередь сообщений.
Вопросы Вирта:
-
Что значит корректная очередь?
-
Что должно быть истинно в любой момент времени?
Ответы → инварианты:
-
Сообщения выходят в том же порядке, в каком вошли
-
Размер очереди не может быть отрицательным
-
Нельзя получить элемент, если очередь пуста
-
Количество
push−pop= текущий размер
Вот это уже архитектура, ещё до первой строки кода.
Шаг 2. Разделить инварианты по уровням
1️⃣ Инварианты данных (структура)
Пример:
-
массив всегда отсортирован
-
ключи уникальны
-
дерево остаётся деревом, а не кустом
∀ i < j : a[i] ≤ a[j]
Это не код.
Это контракт реальности.
2️⃣ Инварианты состояния (жизнь объекта)
Пример:
-
заказ не может быть «оплачен» и «отменён» одновременно
-
сессия либо активна, либо закрыта — третьего не дано
state ∈ {new, paid, shipped, closed}
Никаких «ну а вдруг».
Если вдруг — это баг.
3️⃣ Инварианты переходов (конечные автоматы)
Это уже твоя тема 😉
Пример:
new → paid → shipped → closed
Инвариант:
нельзя перепрыгнуть через состояние
нельзя вернуться назад
Дейкстра бы сказал:
Запрещённые переходы важнее разрешённых
Шаг 3. Инвариант → API → код
Вот тут начинается магия.
Был инвариант:
баланс ≥ 0 всегда
Значит API не имеет права выглядеть так:
account.balance -= amount
Потому что:
-
ты отдал инвариант на милость человеку
Правильный путь:
-
инвариант диктует форму API
-
API диктует реализацию
account.withdraw(amount)
А внутри уже:
-
проверка
-
отказ
-
гарантия
Шаг 4. Циклы — корона инженерии
Дейкстра говорил:
Если ты не можешь сформулировать инвариант цикла — ты не понимаешь алгоритм
Пример до кода:
Задача:
найти максимум массива
Инвариант:
max— максимум элементовa[0...i]
Только после этого пишется цикл.
Не наоборот.
Шаг 5. Проверка на «взрослость»
Хороший инвариант отвечает на вопрос:
-
❌ «как это реализовать»
-
✅ «что обязано быть истинным при любой реализации»
Если можно сменить язык, БД, алгоритм —
а инвариант остаётся тем же → ты всё сделал правильно.
Почему это почти исчезло
Честно?
-
это медленно
-
это требует мышления
-
это не продаётся на конференциях
Зато:
-
код живёт 20 лет
-
рефакторинг не страшен
-
баги редкие и предсказуемые
Ты это и так чувствуешь — поэтому тебе это зашло.
И напоследок — сухо и жёстко
Тесты ловят ошибки.
Инварианты не дают им появиться.