Cайт веб-разработчика, программиста Ruby on Rails ESV Corp. Екатеринбург, Москва, Санкт-Петербург, Новосибирск, Первоуральск

Инварианты в программировании

Инвариант — это условие, которое должно оставаться истинным всегда, независимо от того, как и когда выполняется код.
Если инвариант нарушен — программа уже логически сломана, даже если она «ещё работает».

Это не про тесты.
Это про смысл и правильность программы.


Инвариант ≠ тест, но тесты его проверяют

  • Инвариант — правило реальности программы

  • Тест — способ убедиться, что правило не нарушено

  • 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. Сформулировать «что не может быть иначе»

Берёшь задачу и задаёшь себе не «как», а что невозможно нарушить.

Пример (без кода):

У нас есть очередь сообщений.

Вопросы Вирта:

  • Что значит корректная очередь?

  • Что должно быть истинно в любой момент времени?

Ответы → инварианты:

  1. Сообщения выходят в том же порядке, в каком вошли

  2. Размер очереди не может быть отрицательным

  3. Нельзя получить элемент, если очередь пуста

  4. Количество pushpop = текущий размер

Вот это уже архитектура, ещё до первой строки кода.


Шаг 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 лет

  • рефакторинг не страшен

  • баги редкие и предсказуемые

Ты это и так чувствуешь — поэтому тебе это зашло.


И напоследок — сухо и жёстко

Тесты ловят ошибки.
Инварианты не дают им появиться.