Разбираем идеи Тони Хоара: что такое «корректность» в ПО, как работают предусловия/постусловия и инварианты, и чему учит пример Quicksort.

Имя Тони Хоара часто появляется рядом со словом «корректность» не потому, что он «просто придумал ещё одну теорию», а потому что предложил удобный язык, на котором можно обсуждать правильность программ без гадания и расплывчатых формулировок. Его подход помогает превратить вопрос «похоже, работает» в вопрос «какое именно обещание даёт этот код — и выполняет ли он его при заданных условиях».
В этой статье корректность — это не мифическое состояние «без багов вообще». Это гораздо практичнее: программа корректна, если она соответствует спецификации (явной или подразумеваемой). То есть:
Такое определение сразу снимает часть споров: мы сравниваем не «ощущение качества», а поведение кода с договорённостью.
Корректность имеет значение не только в «критичных системах». Ошибка в бизнес‑логике может стоить денег и доверия (неверные начисления, скидки, статусы заказов). Ошибка в инфраструктуре или безопасности — привести к потере данных, простоям и инцидентам. Чем дороже последствия, тем полезнее уметь формулировать и проверять обещания кода.
Дальше мы пройдём путь от идеи логики Хоара (как «договоров» для программ) к классическому примеру с Quicksort, а затем приземлим это на практику: как корректность связана с безопасным мышлением и снижением риска в обычной разработке.
Корректность программы — это не про то, «запускается ли она» и даже не про то, «проходит ли тесты». В самом простом виде корректность означает: программа делает ровно то, что было обещано, для всех ситуаций, которые входят в её условия использования.
Фраза «у меня работает» обычно означает: вы попробовали несколько примеров — и увидели ожидаемый результат. Это полезно, но не гарантирует ничего за пределами этих примеров.
«Работает по определению» — это когда поведение программы следует из чёткой формулировки того, что она должна делать, и из аргумента (или доказательства), что код этому следует. Тогда вас меньше удивляют редкие случаи: пустой ввод, повторяющиеся элементы, очень большие числа, неожиданные форматы.
Нельзя доказать корректность «вообще». Корректность всегда звучит как утверждение вида:
Эти условия и свойства и есть спецификация (пусть даже короткая, на уровне комментария или постановки задачи). Без спецификации нечего доказывать: у кода нет «обещаний», а значит любое поведение можно объявить приемлемым задним числом.
Пример спецификации для функции сортировки: «на выходе элементы идут по неубыванию и это ровно те же элементы, что были на входе». Это уже два конкретных обещания.
Частичная корректность отвечает на вопрос: если программа завершилась, верен ли результат?
Полная корректность добавляет ещё один слой: она не только выдаёт правильный результат, но и обязательно завершается (для всех входов, которые допускает спецификация).
На практике многие ошибки прячутся именно в разнице между этими понятиями: алгоритм может быть «правильным», но зависать из‑за случая, о котором забыли.
Корректность помогает всем трём, но не заменяет их: можно корректно реализовать неверную спецификацию или корректно обработать вход «как задумано», но при этом оставить уязвимость в другом месте.
Логика Хоара часто звучит как «формальные методы для математиков», но в основе у неё очень бытовая идея: у любого фрагмента кода есть условия, при которых он обязан работать, и обещания, которые он должен выполнить. Это и есть «договор» между кодом и его окружением — другими функциями, пользователем, базой данных, системой.
Запись Хоара выглядит так: {P} C {Q}.
Её можно читать по‑человечески так: «Если перед запуском кода выполняется условие P, то после выполнения кода будет гарантирован результат Q».
Предусловие — это не «каприз автора», а способ честно сказать: «Вот при каких входных данных и состоянии системы мой код берёт на себя ответственность».
Простой пример: функция, которая берёт элемент массива по индексу.
Важно: предусловие относится не только к аргументам функции. Оно может включать состояние — например, «соединение с базой открыто» или «пользователь аутентифицирован».
Постусловие — это обещание кода. Оно описывает результат и изменения состояния.
Например, для функции сортировки:
Хорошее постусловие отвечает на вопрос: «Как мне проверить, что код сделал ровно то, что нужно — не больше и не меньше?»
Договор полезен тем, что разделяет ответственность:
Так требования становятся конкретными: вместо расплывчатого «функция иногда падает» появляется понятное «вызвали без выполнения предусловия» или «код нарушил постусловие». Это упрощает обсуждение корректности, ревью и поиск причин ошибок — особенно в больших командах и сложных системах.
Предусловие — это «что должно быть верно до вызова функции», постусловие — «что гарантированно станет верно после». Вместе они работают как договор: вызывающая сторона обещает входные условия, а функция — результат.
Функция sort(items).
Предусловие (простыми словами): «Мне передают список элементов, которые можно сравнивать между собой».
Постусловие: «Я верну список той же длины, состоящий из тех же элементов, но в неубывающем порядке».
Заметьте: «в том же порядке, что и раньше» — это уже про стабильность сортировки. Это не всегда нужно, но если важно, это должно быть явной частью постусловия.
Функция find(userId, users).
Предусловие: «users — коллекция пользователей, у каждого есть уникальный идентификатор».
Постусловие: «Если пользователь с таким userId существует, верну его; иначе верну признак отсутствия (например, null/None)».
Если уникальность идентификатора не гарантируется, функция должна либо уточнить поведение (кого возвращаем при дубликатах), либо требовать это как предусловие.
Функция normalizePhone(input).
Проверяемые условия: «вход — строка», «на выходе только цифры и ведущий +», «пустой ввод даёт пустой результат/ошибку (выбрать одно)».
Уточняемые условия: «какие страны поддерживаем», «как трактуем добавочные номера», «нормализуем ли “8” в “+7”». Их тоже можно записать, но иногда сначала как бизнес‑правила, а уже потом формализовать.
Начинайте человеческим языком: 2–3 предложения про вход, выход и ошибки. Затем постепенно делайте условия точнее: перечисляйте варианты, добавляйте примеры, фиксируйте формат результата.
Контракты особенно полезны в спорах о коде: вместо «мне кажется так красивее» обсуждают конкретное утверждение — выполняется ли предусловие и обеспечивается ли постусловие. Это снижает вкусовщину и помогает быстро найти, кто и где нарушил договор.
Инвариант — это утверждение, которое остаётся верным на протяжении выполнения некоторого фрагмента программы. Обычно речь про цикл или рекурсивную функцию: код «крутится» много раз, а инвариант служит якорем, который помогает не потерять смысл происходящего.
Если предусловие отвечает на вопрос «с чего мы начинаем?», а постусловие — «что должно быть в конце?», то инвариант фиксирует «что должно оставаться истинным по дороге». Он соединяет три состояния: до шага, во время повторений и после завершения.
В линейном коде легче держать в голове причинно‑следственные связи. Циклы и рекурсия создают повторяющиеся переходы состояния, где ошибка часто не видна на первых итерациях.
Инвариант позволяет рассуждать о корректности по схеме:
Хороший инвариант не должен быть «умным ради умности»:
assert).На практике инварианты дисциплинируют мышление: вы начинаете описывать, какие свойства данных обязаны сохраняться после каждого шага. Это одновременно и мини‑доказательство, и отличный способ понять чужой алгоритм без магии.
Quicksort часто вспоминают как «быструю сортировку», но его настоящая ценность в обучении — он отлично показывает разницу между идеей алгоритма и корректной реализацией. Описание на уровне «выбрать опорный элемент, разбить массив, рекурсивно отсортировать части» выглядит коротким и ясным. А вот в реализации легко допустить мелкую ошибку, которая проявится только на отдельных входах — поэтому Quicksort и стал классическим учебным примером.
У алгоритма есть сильная интуиция и при этом много острых углов:
Самый рискованный участок — разбиение (partition). Здесь обычно путают границы диапазона, неверно двигают два указателя навстречу друг другу, неправильно обрабатывают элементы, равные опорному, или делают swap не в тот момент.
Корректность partition удобно держать на «договоре» в виде инварианта: в процессе разбиения всегда сохраняется утверждение, что все элементы слева удовлетворяют условию относительно опорного, а все элементы справа — противоположному (например, слева ≤ pivot, справа ≥ pivot). Если инвариант где‑то нарушился — дальнейшая рекурсия уже не спасёт.
Даже идеальный partition бесполезен, если рекурсия не гарантирует прогресс. Для корректности важны два пункта:
уменьшение задачи: каждый рекурсивный вызов должен работать с диапазоном строго меньшего размера;
базовый случай: диапазон длины 0 или 1 уже отсортирован.
Quicksort хорош тем, что эти условия можно проверять как логическими утверждениями (про границы и инварианты), так и практикой (краевые случаи: повторы, уже отсортированные массивы, все элементы одинаковые).
Quicksort часто воспринимают как «просто сортировку», но на практике он ломается не из‑за идеи, а из‑за деталей реализации. Эти детали коварны: алгоритм может «почти всегда» работать, а затем выдавать редкие и трудноуловимые сбои.
Самые частые ошибки — в границах и условиях остановки.
[l, r] в одном месте и [l, r) в другом). Итог — пропущенный элемент, выход за пределы массива или «странные» перестановки.partition возвращает те же границы, что и вход.partition): элементы, равные опорному, обрабатываются неверно. Например, все «равные» уходят в одну сторону, и на массивах с большим количеством одинаковых значений рекурсия перестаёт сокращаться.Quicksort может давать верный результат на «обычных» данных и проваливаться на редких:
Опасность в том, что тесты часто покрывают типовые наборы данных, но не все «углы». Ошибка проявляется у пользователя, а не у разработчика.
Чтобы говорить о корректности, полезно формулировать постусловие явно. Для сортировки оно состоит из двух частей:
Порядок: результат упорядочен (например, для всех i < j выполняется a[i] ≤ a[j]).
Сохранение элементов: результат — это перестановка исходного массива (ничего не потеряли и не «создали»). На практике именно эта часть чаще всего ломается из‑за неверных обменов или выхода за границы.
Контракты (пред- и постусловия) и инварианты помогают не «гадать», а локализовать проблему.
Например, для шага разбиения удобно держать инвариант вида: «все элементы слева от указателя ≤ pivot, все справа ≥ pivot». Если при очередном обмене инвариант перестал быть истинным — вы нашли точку, где разбиение реализовано неверно. А если после разбиения не выполняется предикат «подмассивы строго меньше исходного», это сразу объясняет риск бесконечной рекурсии.
Тестирование и доказательства корректности отвечают на разные вопросы. Тесты помогают найти ошибки, а доказательства объясняют, почему ошибок быть не может (при заданных предположениях). На практике эти подходы не конкурируют, а усиливают друг друга.
Тестирование по сути спрашивает: «Могу ли я подобрать входные данные, на которых программа ведёт себя неправильно?» Если контрпример найден — отлично, баг воспроизводим.
Доказательство корректности спрашивает другое: «Почему для любого допустимого входа результат будет правильным?» Оно строится на предусловиях, постусловиях и, для циклов, на инвариантах.
Важно помнить: доказательство всегда опирается на модель. Если предусловия выбраны слишком слабо или забыты ограничения (например, про диапазон индексов), «доказанная» программа всё равно может падать.
Тестов часто хватает, если:
Более строгий подход нужен, когда стоимость ошибки высока или крайние случаи коварны: деньги, безопасность, права доступа, конкурентные состояния, обработка границ массивов.
Практичная связка выглядит так:
Спросите себя: «Сколько стоит ошибка?» и «Насколько вероятно, что мы пропустим редкий крайний случай?» Чем выше оба ответа, тем больше смысла инвестировать в доказательность: контракты, анализ инвариантов и более формальную верификацию.
Корректность — это не только про «доказать, что сортировка сортирует». Это про привычку проектировать так, чтобы целые классы ошибок не появлялись вообще. Такой подход особенно ценен там, где цена сбоя высока: деньги, персональные данные, здоровье, производство.
Идея Хоара полезна как дисциплина мышления: мы заранее фиксируем, какие состояния программы допустимы, а какие — нет. Когда это сделано, многие баги перестают быть «неожиданными» и превращаются в нарушения договора, которые легче обнаружить на ревью, статическим анализом или проверками в коде.
Вместо расплывчатого «функция должна работать для любых входов» появляется конкретика: какие входы разрешены, какие значения недопустимы, что функция гарантирует на выходе.
Безопасность начинается с вопроса: что должно быть истинным до вызова (предусловие) и после (постусловие)? Спецификация также должна описывать запреты:
Так вы сокращаете пространство неопределённости — а именно в нём чаще всего и прячутся уязвимости и аварии.
Безопасное проектирование не требует «никогда не падать». Оно требует падать предсказуемо: с понятной ошибкой, без повреждения данных и без частично выполненных действий. Это ведёт к решениям вроде явных кодов ошибок/исключений, атомарных операций, проверок границ и валидации входов на ранней стадии.
Когда вы формулируете договоры, интерфейсы естественно становятся проще: меньше «магических» значений, скрытых зависимостей и неявных ожиданий. В итоге снижается риск: уменьшается число состояний, в которых система может оказаться, и растёт доля ситуаций, которые команда умеет объяснить и контролировать.
Логика Хоара часто ассоциируется с формальными доказательствами, но её главный подарок практикам — привычка формулировать «что должно быть верно» до и после работы кода. Это легко встроить в повседневную разработку без математики на доске.
Начните с кратких утверждений рядом с кодом: что функция ожидает (предусловия) и что гарантирует (постусловия). Важно писать не «как сделано», а «что должно быть верно».
Для более крупных решений используйте ADR (Architecture Decision Record): одна страница о контексте, решении и последствиях. Такой текст легче поддерживать, чем объёмную документацию, и он реже «отстаёт» от кода, если обновлять его в тех же PR.
assert и проверки предусловий: где уместныПроверки предусловий — это способ сделать договор явным. Хорошие места: границы модулей, публичные API, обработка внешнего ввода.
assert) от «ошибка пользователя» (понятная валидация и сообщение).Типы, линтеры и статический анализ частично выполняют роль «машинной проверки» договоров: не дают передать null там, где его быть не должно, ловят недостижимые ветки, подозрительные сравнения, забытые обработки ошибок. Это не заменяет корректность целиком, но срезает целый класс багов ещё до тестов.
Описывайте API в терминах договоров:
Так идеи Хоара превращаются в понятные правила игры для команды и пользователей вашего кода.
Даже если вы собираете продукт через чат‑подход (vibe‑coding), «договоры» остаются ключом к предсказуемому результату. В TakProsto.AI удобно начинать именно со спецификации: вы формулируете предусловия/постусловия и сценарии, а затем просите платформу построить реализацию веб/серверной/мобильной части так, чтобы эти условия выполнялись.
Практически это выглядит так:
Плюс для команд на российском рынке — данные и окружение остаются в РФ, а стек (React на фронтенде, Go + PostgreSQL на бэкенде, Flutter для мобильных приложений) позволяет формулировать и проверять контракты на всех слоях.
Пытаться «внедрить корректность» можно по‑разному: через предусловия/постусловия, инварианты, контракты в коде, чек‑листы к ревью. Но первые попытки часто дают обратный эффект — люди устают от формальностей, а реальных дефектов меньше не становится. Ниже — типовые промахи и как их распознать.
Иногда разработчики делают предусловие настолько жёстким, что функция становится удобной только «в идеальном мире». Например: «входной массив всегда отсортирован», «значение всегда непустое», «индекс всегда в диапазоне». Так проще рассуждать о корректности, но API теряет практическую ценность: реальный код вынужден раздуваться проверками и костылями.
Правило простое: если предусловие постоянно нарушается в местах вызова, значит оно не отражает реальность — либо перенесите проверку внутрь, либо измените контракт и поведение (например, возвращайте ошибку).
Постусловие вроде «возвращает результат» или «что‑то делает с коллекцией» выглядит как формальность: его невозможно проверить, и оно не помогает в ревью.
Постусловие должно отвечать на вопрос «что именно гарантируется»: отсортировано по неубыванию, элементы не потеряны, длина не изменилась, возвращаемое значение соответствует предикату и т. п.
Инварианты иногда пишут слишком «умными»: длинными, зависимыми от половины системы, непроверяемыми в рантайме и непонятными команде. В итоге они не помогают находить ошибки в циклах и состояниях.
Полезный инвариант обычно:
assert хотя бы в debug;Самая частая ловушка — «делаем корректность, потому что так надо». Держите фокус на целях: ясность, меньше дефектов, предсказуемое поведение. Если контракт не помогает принимать решения (в дизайне API, ревью, тестах), значит его стоит переписать проще или убрать.
Пытаться «доказать всё» сразу — почти гарантированный путь к разочарованию. Лучше относиться к идеям Хоара как к навыку командной гигиены: формулируем ожидания, проверяем границы, постепенно укрепляем самые рискованные места.
Сформулируйте контракт: что должно быть верно до вызова (предусловия) и что гарантируется после (постусловия). Запишите это рядом с кодом: в комментарии, типах, assert или в спецификации.
Найдите инварианты: что обязано оставаться истинным на каждом шаге цикла/итерации/обработки. Это особенно помогает в «скользких» местах вроде индексов, указателей и границ массивов.
Проверьте границы: пустой ввод, один элемент, дубликаты, максимальные размеры, неожиданные значения, переполнения. Если есть сравнения ≤/< /≥/>, проверьте, что выбран именно тот знак.
Выберите 1–2 критичных модуля, где ошибка дороже всего:
Для них заведите короткие спецификации (на одну страницу) и договоритесь, что новые изменения принимаются только вместе с обновлённым контрактом и набором проверок.
Ищите материалы по темам: логика Хоара, инварианты циклов, спецификации и контракты (Design by Contract), базовые идеи формальной верификации.
Если нужны примеры, шаблоны и инструменты/поддержка, загляните в /blog. Для обсуждения внедрения в команде и подбора тарифа (free/pro/business/enterprise) — /pricing.
Корректность — это соответствие спецификации: при оговорённых входах и состоянии система делает ровно то, что обещано, включая обработку ошибок и ограничения.
Это не «вообще без багов», а проверяемое совпадение поведения с договорённостью.
Пока не зафиксировано, что именно должно быть верно на входе и на выходе, вы спорите про ожидания, а не про факты.
Даже короткая спецификация (2–3 пункта) превращает «похоже, работает» в проверяемые утверждения.
Частичная корректность отвечает: «если завершилась — результат верный».
Полная корректность добавляет: «и она гарантированно завершится для допустимых входов». Частая проблема — алгоритм даёт правильный ответ, но зависает на краевых случаях.
Формула читается так: если перед выполнением кода истинно P (предусловие), то после выполнения будет истинно Q (постусловие).
Это удобный язык «договоров»: вызывающая сторона отвечает за P, а функция/фрагмент кода — за Q.
Предусловия описывают допустимые входы и состояние (например, «индекс в диапазоне», «соединение открыто»).
Постусловия фиксируют гарантию результата и эффектов (например, «коллекция отсортирована» и «это перестановка исходных элементов»).
Инвариант — утверждение, которое остаётся истинным на каждом шаге цикла/итерации/рекурсии.
Он помогает доказать корректность по схеме: инвариант верен до старта → сохраняется после каждого шага → вместе с условием выхода даёт нужное постусловие.
Чаще всего — в partition: границы диапазона, движение указателей, обработка элементов, равных опорному.
Полезно держать инвариант разбиения (например, «слева ≤ pivot, справа ≥ pivot») и отдельно проверять прогресс рекурсии: подзадачи должны строго уменьшаться.
Тесты ищут контрпримеры: «есть ли вход, где всё ломается?»
Доказательность (контракты, инварианты) объясняет, почему для любого допустимого входа результат будет верным — при условии, что предположения (предусловия/модель) сформулированы правильно. На практике лучше сочетать оба подхода.
Начните с «мягкой» спецификации: комментарий/докстрока с предусловиями и постусловиями, затем добавляйте проверки на границах модулей.
Дальше подключайте типы, линтеры, статический анализ и тесты по краям (пустые входы, дубликаты, максимальные размеры).
Частые ошибки:
Правило: контракт должен помогать принимать решения в дизайне API, ревью и тестах; иначе упростите или пересмотрите его.