понедельник, 15 июня 2026 г.

[prog.c++.imho] Не согласен с постулатами пропозала P3097 (контракты для виртуальных методов)

Комитет по стандартизации C++ продолжает творить дичь. Сперва в C++26 были включены кастрированные контракты (нет ключевого слова old в постусловиях, нет контрактов для виртуальных методов, нет инвариантов для экземпляров классов и циклов). Для людей, знакомых с Eiffel, контракты из C++26 выглядят как "мы не осилили тему полностью, поэтому впихнули в стандарт какой-то эрзац с надеждой, что со временем допилим". Не хочу обсуждать зачем нужен эрзац вместо нормального продукта. Просто перейду к следующей дичи.

Далее в C++29 включили предложение P3097, которое описывает контракты для виртуальных методов классов. И авторы этого предложения, как по мне, покусились на святое: на сформулированное много-много лет назад для Design By Contract в Eiffel-е требование о том, что производный класс может только ослабить предусловния и ужесточить постусловия, но не наоборот.

И вот авторы пропозала почему-то пришли к выводу, что C++ настолько особенный, что в нем можно это требование послать в пешее эротическое.

На протяжении нескольких страниц пропозала эти люди пытаются приводить "аргументацию" своей точки зрения. Меня эта аргументация не убеждает от слова совсем. Скорее наводит на мысль о том, что люди толком не понимают тему, о которой пытаются рассуждать и, скорее всего, не имеют опыта разработки на языках с поддержкой Design By Contract (в первую очередь на Eiffel-е, на который в данной теме и следует равняться).

Первые эмоции после беглого прочтения P3097 я уже постил в LinkedIn. Сейчас попробую пройтись по нескольким фразам и примерам оттуда, чтобы как-то обосновать свое негативное отношение.


В разделе "3.2 Adoptability in legacy code" есть интересный заход:

In other words, contract assertions must not break an existing program. While false negatives are inevitable, false positives must never be forced on users by the language design.

These requirements are not unique to contract checking; they apply equally to any tool intended to detect bugs in existing code, such as compiler warnings or static analysis tools.

Мне он кажется в принципе не правильным.

Во-первых, если мы внедряем механизм, который позволяет выявлять проблемы в коде (а DbC выявляет проблемы на уровне проектирования классов, их взаимосвязей и взаимодействия), то это хорошо. Это цель внедряемого механизма. Поэтому если мы с помощью контрактов формально зафиксировали ожидания, которые ранее были неформальными, и у нас что-то поломалось, значит контракты нашли проблему о которой мы и не подозревали. Следовательно, проблема требует решения (либо в виде переделки кода, либо в переформулировании контрактов).

Во-вторых, а почему бы не применить эту формулировку к такой штуке, как standard library hardening, которую также внедрили в C++26. У нас же теперь обращения к operator[] для std::span будут проверять индекс. И допустим, у нас был код:

unsigned long check_sum(const std::span<const char> & s)
{
   unsigned long sum = 0;
   for(std::size_t i = 0; i <= s.size(); ++i)
       sum += static_cast<unsigned char>(s[i]);
   return sum;
}
const std::string s{ "Hello, world" };
const char * l = "Hello, world";
auto ch1 = check_sum(s);
auto ch2 = check_sum(l);

Этот код работал годами поскольку в позиции s[s.size()] хранится неявно добавляемый к содержимому строки 0-символ. Мы совершали ошибку индексации, но нам это прощалось из-за особенностей представления содержимого нуль-терминированной строки и из-за того, что лишнее нулевое значение не влияет на данный алгоритм, а в std::span не было проверки границ для operator[]

Но в режиме hardening мы здесь получим исключение (или вообще принудительную терминацию программы). А ведь это же тот самый "breaking an existing program" которого следует избегать. Но для standard library hardening мы на breaking смело идем, а вот в случае с контрактами почему-то идти не должны. В воздухе отчетливо ощущается запах двойных стандартов.

Как по мне, так при внедрении некоторых фич в принципе нельзя оглядываться на легаси код. Вспомнить хотя бы всратые модули, добавленные в C++20. Так почему при внедрении контрактов нужно апеллировать к коду, в котором контрактов нет?


В разделе "4.8 Narrowing preconditions, widening postconditions" приводится пример на котором авторы P3097 пытаются оправдать своей тезис о том, что в мире С++ нужно отойти от "ослабить предусловия, ужесточить постусловия" в пользу "ужесточить предусловия, ослабить постусловия".

Типа есть класс:

class Image {
public:
    virtual void render() const;
};

и есть его наследник:

class GPUImage : public Image {
   bool readyToRender = false;
public:
   bool prepare() {
      // upload data to GPU, handle errors…
      readyToRender = true;
      return readyToRender;
   }

   void render() const override
      pre (readyToRender);
};

В классе наследнике предусловие для метода render ужесточается. Авторы пропозала считают этот пример нормальным и используют его в качества аргумента в пользу постулата, что в С++ нам нужно иметь возможность "ужесточать предусловия, ослаблять постусловия".

Я же здесь вижу непонимание смысла контрактов в случае наследования.

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

В данном конкретном примере класс GPUImage вообще не должен быть наследником Image (по крайней мере публичным). И возможное решение описанной "проблемы" не в изменении контракта, а в другой реализации GPUImage. Например, вот в такой:

class GPUImage {
   class ActualImage : public Image {...};
   std::optional<ActualImage> actualImage; // Will be initialized in prepare.
public:
   bool prepare() {
      actualImage.emplace(...); // upload data to GPU, handle errors…
      return true;
   }

   Image & getActualImage()
      pre (actualImage.has_value())
   {
      return *actualImage;
   }
};

В этом случае мы не можем отдать экземпляр GPUImage туда, где ждут Image, поскольку GPUImage не выполняет требования контракта. Но мы можем вызвать GPUImage.getActualImage(), чтобы получить актуальную реализацию интерфейса Image.

И вот уже к getActualImage() мы можем применить любой контракт, в том числе и тот, что getActualImage() может вызываться только после того, как будет вызван prepare().


В разделе "4.9 Multiple inheritance" приводится пример с классами EvenComputer и OddComputer:

struct EvenComputer {
  virtual int compute(int x)
    pre(isEven(x))
    post(r : isEven(r));
};

struct OddComputer {
  virtual int compute(int x)
    pre(isOdd(x))
    post(r : isOdd(r));
};

Мол если мы отнаследуемся от обоих классов сразу и сделаем общий compute, то у нас будет нарушение контрактов из базовых классов. Поэтому нужно уметь отменять унаследованные контракты, по типу:

struct Identity : EvenComputer, OddComputer {
  int compute(int x) override { return x; }
};

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

В Eiffel, как в языке, где к проблемам наследования отнеслись более тщательно, чем в C++ (т.е. подумали, а не сделали "как получилось"), есть возможность переименования в производном классе метода из базового класса. А в C++, увы, такой возможности нет. Поэтому в C++ мы имеем то безобразие, которое видно в этой самой структуре Identity.

Будь в C++ гипотетическая конструкция rename, то данный пример можно было бы переписать так:

struct Identity : EvenComputer, OddComputer {
  rename EvenComputer::compute as even_compute;
  rename OddComputer::compute as odd_compute;

  // Это уже метод Identity, а не переопределение унаследованных
  // методов compute.
  int compute(int x) { return x; }

private:
  // А вот это уже переопределение.
  int even_compute(int x) override { return this->compute(x); }
  int odd_compute(int x) override { return this->compute(x); }
};

В этом случае для каждого унаследованного метода compute контракты из базового типа сохранились бы. Только контракт EvenComputer::compute теперь применяется не к Identity::compute, а к Identity::even_compute. И вот в таком случае:

void use_computer(EvenCompute & c) { c.compute(0); }

Identity my_computer;
use_computer(my_computer);

Внутри use_computer вызывался бы метод Identity::even_compute.

Лично мне в C++ такого rename не хватает и без контрактов. И если бы rename в язык ввели, то это бы помогло и контрактам, и не только.

Поэтому в данном конкретном случае авторы P3097 увидели лишь часть проблемы, неверно ее идентифицировали и, соответственно, стали решать неправильно. Дело не в контрактах, а в ущербности множественного наследования в C++. А бороться с этой ущербностью посредством каких-то специальных контрактов, придуманных под якобы "особенности C++", это очень и очень странный подход, как по мне.


На самом деле при чтении пропозала (недостаточно внимательном, каюсь) у меня отторжение вызывала вся аргументация авторов. Не исключено, что я мог бы поспорить с каждым из "аргументов". Но уж извините, не располагаю достаточным временем, чтобы делать это.

Общее впечатление: у авторов пропозала нет достаточного опыта работы с языками с поддержкой Design By Contract. И, в первую очередь, с Eiffel. А без такого опыта рассуждения о якобы проблемах DbC и уникальности C++ -- это как из анекдота про "Рабинович напел".

Поэтому я категорически не согласен с тем, что от принципа "ослаблять предусловия, ужесточать постусловия" нужно отказываться. Тем более в сторону "ужесточать предусловия, ослаблять постусловия". Это выглядит как переворачивание DbC с ног на голову.

Не надо так. Не нужно окончательно превращать C++ в помойку из переусложненных концепций (хватит нам короутин, модулей и экзекуторов). Есть опыт Eiffel, уже почти сорокалетний(!!!), но... Но даже так вы сделать не можете (если думаете, что можете, посмотрите еще раз на то, что есть в Eiffel и то, что "есть" в С++26). Однако умничаете и изобретаете велосипед с квадратными колесами вместо того, чтобы научиться ездить на обычном.

Фокус с контрактами в том, что это такая же фича в распоряжении разработчика, как наследование, исключения и noexcept, перегрузка функций и операторов. Это значит, что контрактами нужно научиться пользоваться точно так же, как и другими возможностями языка. Что с контрактами точно так же будет и перебор (когда контракты делают слишком жесткими и пихают куда ни попадя), и недостаточное использование (когда от контрактов отказываются по надуманным причинам). А это значит, что разработчики будут совершать ошибки, эти ошибки затем придется устранять и это нормальный процесс. Такой же нормальный, как, например, впихивание noexcept куда ни попадя с последующим пересмотром и отказом от части noexcept.

Так что нужно будет учиться пользоваться контрактами. И тот факт, что в Eiffel этим занимаются уже много десятков лет но при этом от принципа "ослаблять предусловия, ужесточать постусловия" там не отказались, говорит о многом.

Посему не нужно изобретать велосипед с квадратными колесами только потому, что вам C++ кажется каким-то особенным языком, в котором почему-то не работает то, что работает в Eiffel. Вам, скорее всего, кажется. Но из-за того, что вы свои заблуждения протолкнули в стандарт, с этим теперь придется жить нам всем, даже тем, кто не разделяет вашей убежденности. Как с модулями, которые тоже кому-то казались хорошей идеей. Или как со спецификатором throw в C++98.

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