среда, 6 августа 2025 г.

[prog.c++.thoughts] Design By Contract и приватные методы классов в C++

В C++26 будут включены контракты. Т.е. можно будет сказать, что элементы Design By Contract наконец-то доберутся и до C++.

Недавно в LinkedIn запостил маленький скриншот с примером того, что мне иногда приходится делать посредством комментариев и что будет нормальным образом выражаться в C++26.

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

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


Технология Design By Contract (DbC) берет свое начало в языке Eiffel (вот еще одно описание от первоисточника). Если вы не знакомы с Eiffel хотя бы в части понимания DbC, то вы, к сожалению, многое упустили в своей профессиональной подготовке. Если же знакомы, то, надеюсь, понимать написанное ниже будет проще.

Ключевыми для нашего разговора будут три вещи (на самом деле в Eiffel есть еще и инварианты для циклов, и утверждения check, похожие на C-шный assert, но их мы спокойно можем игнорировать):

Во-первых, предусловия. Т.е. ответственность вызывающей стороны по отношению к вызываемой функции. Это то, на что может рассчитывать вызываемая функция/метод. Например, если у нас есть operator[] в std::vector, то вызывающая сторона обязана передать туда корректный индекс. Например, это может быть выражено так:

T & operator[](std::size_t index) pre(index < this->size()) {...}

Во-вторых, постусловия. Т.е. ответственность самого кода по отношению к результатам своей работы. Это то, на что может рассчитывать вызывающая сторона в случае успешного завершения метода/функции. Например, если в нашем std::vector есть push_back, то постусловием станет то, что новый размер окажется на 1 больше, чем исходный. Не знаю, как это может быть выражено в C++ (и может ли быть выражено вообще), но в Eiffel есть специальная синтаксическая конструкция old:

push_back (x: ELEMENT) is
  require
    count <= capacity
  do
    ... Some insertion algorithm ...
  ensure
    count = old count + 1
end

Запись `old count` позволяет взять и сохранить до завершения метода put значение count, а затем сравнить старое значение с новым.

В-третьих, инвариант класса. Т.е. это набор требований к методам класса -- если они вызываются при корректном инварианте класса, то после своего успешного завершения они также должны оставить инвариант класса в корректном состоянии. Это то, на что может рассчитывать пользователь класса. Например, для vector инвариантом может быть то, если если вектор не пуст, то у него есть корректно размещенный в динамической памяти вектор, и для всех size() элементов в этом векторе есть значения (тогда как для оставшихся (capacity() - size()) элементов эти значения не определены), при этом size() меньше или равно capacity().

Насколько я понимаю, в C++ нет возможности выразить инвариант класса формальным образом (если я ошибаюсь, то дайте, пожалуйста, ссылку на описание того, как это делать). Тогда как в Eiffel такая возможность есть.

Наличие сразу и предусловий, и постусловий, и инвариантов желательно, но необязательно.

Например, у метода empty() класса std::vector, нет ни предусловий, ни постусловий.

У метода push_back() класса std::vector нет предусловий, но есть постусловие -- размер вектора должен увеличиться на 1.

У константного operator[] класса std::vector есть предусловие -- индекс должен быть валидным, но нет постусловий.

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


Теперь, определившись с основным понятиями, давайте подумаем: а что мешает иметь пред- и постусловия (т.е. контракты) для приватных методов в С++?

Обращаю внимание на то, что предусловия -- это обязательства для вызывающей стороны. Т.е. это требования которые сам метод накладывает на вызывающую сторону. И методу безразлично кто будет вызывающей стороной: публичный метод, приватный метод или же он сам (в случае рекурсивных вызовов).

Тогда как постусловия -- это гарантии от метода для вызывающей стороны. И, опять же, не важно кто именно для метода вызывающая сторона. Будь то публичный метод или другой приватный, не суть. Суть в том, что если вызванный метод успешно завершился, то он обязуется обеспечить свои постусловия.

Почему это важно?

Потому что пред- и постусловия играют сразу несколько ролей:

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

Причем, по моим воспоминаниям о временах знакомства с Eiffel, именно в качестве дополнительной документации контракты крайне полезны. Автоматически сгенерированная документация в Eiffel-е для меня лично оказывалась гораздо полезнее и информативнее, чем аналогичная в C++ (через Doxygen), в Java (через JavaDoc) или в Ruby (через rdoc). Как раз из-за наличия в этой документации перечня пред- и постусловий.

И вот через призму всего вышесказанного можно вернуться к т.н. аргументу о том, что "для приватных методов контракты" не нужны. Как по мне, так это еще хуже, чем утверждение, что "для приватных методов комментарии не нужны".

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

Но если говорить о нормальной разработке с документированием, тестированием и вот этим вот всем?

В качестве примера в обсуждении на LinkedIn я приводил следующий:

Давайте представим, что мы выполняем некоторые действия, в процессе которых у нас накапливается вектор int-ов. И вот в какой-то момент нам нужно проверить, пуст ли этот вектор, и если не пуст, то выполнить несколько действий. Что-то вроде:

if(!values.empty()) {
  action_one();
  if(get_min_from(values) < threshold) {
    action_two();
  }
  else {
    action_three();
  }
}

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

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

[[nodiscard]] static
bool
get_min_from(std::span<const int> values) pre(!values.empty())
{...}

При этом вообще не важно, что метод get_min_from приватный. У него есть требование, без выполнения которого использовать метод нельзя. Вот это требование формально и декларируется. Что хорошо и правильно с учетом всех трех вышеперечисленных ролей (документирование, опциональные проверки в run-time, помощь статическим анализаторам). А приватность get_min_from вообще никакой роли не играет.


Отдельно можно рассмотреть ситуацию с инвариантами классов, которые есть в Eiffel, но которых, насколько я понимаю, нет в C++.

В Eiffel можно определить инварианты, которые определяют корректность состояния класса:

class
  TIME_OF_DAY

feature -- Access
    hour: INTEGER
            -- Hour expressed as 24-hour value

    minute: INTEGER
            -- Minutes past the hour

    second: INTEGER
            -- Seconds past the minute
...
invariant
    hour_valid: 0 <= hour and hour <= 23
    minute_valid: 0 <= minute and minute <= 59
    second_valid: 0 <= second and second <= 59
end

Вот то, что описано в секции invariant и является перечнем инвариантов класса. Инварианты проверяются в run-time при вызове каждого метода (естественно, то это отключаемо и в релизных сборках инварианты не выполняются).

При этом в Eiffel есть та особенность, что там (емнип) нет привычного нам по C++ разделения на public, protected и private методы. Видимостью методов для других классов в Eiffel-е управляют иначе. Поэтому можно сказать, что в классическом С++ом виде private-методов в Eiffel нет.

Насколько я помню, факт проверки инвариантов в Eiffel после завершения каждого метода класса создает некоторые сложности при декомпозиции объемной/сложной операции на несколько вспомогательных методов.

Вернемся еще раз к std::vector в качестве примера. Инвариантом для std::vector является то, что когда std::vector не пуст, то есть выделенный буфер, в котором size() первых элементов содержат значения, а size() меньше или равен capacity(). Допустим мы даже сумели выразить этот инвариант формально.

А теперь представим, что мы реализуем публичный метод insert(pos, begin, end), который может вставить сразу несколько элементов в наш вектор.

В реализации такого insert-а нам может потребоваться три вспомогательных приватных метода, если в текущем буфере недостаточно места для приема новых элементов:

  • expand_buffer, который либо расширяет существующий буфер за счет realloc-а, либо аллоцирует новый буфер;
  • create_new_items, который создает новые элементы в новом буфере;
  • copy_or_move_exisiting_items, который пытается переместить старые элементы в новый буфер или же копирует их, если перемещение невозможно.

В принципе, мы можем реализовать свой insert так, что пока все три вспомогательных приватных метода не завершатся, то инвариант класса будет нарушен. Т.е. если инварианты класса будут проверяется при завершении expand_buffer, create_new_items или copy_or_move_existing_items, то будет диагностировано нарушение инварианта для вектора.

Поэтому будь в C++ понятие "инварианта класса", то лично я бы не хотел, чтобы такой инвариант проверялся после приватных методов. А только после публичных методов.


Мое предположение о том, почему эти странные люди из комментариев на LinkedIn так упорно пытались доказать мне, что у приватных методов не должно быть контрактов.

Возможно, они понятия не имеют о Design By Contract, а рассуждают с точки зрения Defensive Programming.

Вот как раз в Defensive Programming, если не ошибаюсь, есть требование к обязательной проверке входных параметров на "внешнем периметре", т.е. в публичных методах. Но после того, как проверки сделаны, то "внутри периметра", т.е. в приватных методах, эти проверки избыточны и не выполняются.

Поэтому, если под "контрактами" подразумевать то, что применяется в Defensive Programming, то тогда аргумент "у приватных методов нет контрактов" имеет хоть какой-то смысл. Но ведь пред- и постусловия, добавляемые в C++26 -- это именно что Design By Contract, а не Defensive Programming (при том, что DbC может быть частью Defensive Programming).

Это совсем другое, понимать надо! (c)


Специально для тех, кто захочет рассказать мне, что показанный на скриншоте кусочек метода isSingleOwnerInReadOnlyMode -- это плохой дизайн: прежде чем доносить до меня свое важное (нет) мнение, потрудитесь сформулировать описание решаемой мной задачи и показать весь остальной код к которому относится фрагмент на скриншоте. Если вы не зная первого и не видя второго имеете столь веское мнение, то это мнение меня не интересует от слова совсем.

Комментариев нет: