История концептов в C++ – один из самых показательных примеров того, как язык развивается не линейно, а через десятилетия экспериментов, откатов и переосмыслений. Первые идеи, которые мы сегодня называем концептами, появились ещё в конце 1990-х, когда стало очевидно, что шаблоны C++ имеют колоссальную выразительность, но практически не дают средств для описания намерений программиста. Шаблон можно было инстанцировать почти с любым типом, но ошибка проявлялась либо в виде километров сообщений компилятора, либо в виде неожиданного поведения в рантайме. Уже тогда Страуструп сформулировал проблему как «отсутствие контрактов для шаблонов», когда программист знает, что от типа требуется оператор + или ==, но язык этого не выражает.

Ранние предложения концептов были чрезвычайно амбициозными и стремились описывать не только синтаксис, но и семантику. Например, концепт EqualityComparable должен был означать не просто наличие operator==, но и выполнение математических свойств эквивалентности: рефлексивности, симметричности и транзитивности. Аналогично, концепты для упорядоченных типов предполагали строгую слабую упорядоченность, а для итераторов корректное поведение при многократном проходе. Это отражало академический взгляд на обобщённое программирование, сильно вдохновленный функциональными языками и работами Степанова.


Если бы вам показали описание одного из ранних вариантов концептов, то вы бы подумали что это какой-нибудь адский питон (и да | здесь используется в виде логического И, которое добавляет условия):

concept Numeric {
    @abstract operator+(const T&, const T&)
    |
    @abstract operator-(const T&, const T&)
    |
    @abstract operator*(const T&, const T&)
    |
    @abstract operator/(const T&, const T&)
    |
    @delete operator<(const T&, const T&) //нельзя сравнивать
    |
    @allow T(0)  // можно создать из 0
};

Но довольно быстро стало ясно, что такие концепты невозможно реализовать в реальном компиляторе из-за сложности, потому что семантические свойства нельзя проверить автоматически. Компилятор не может доказать правильность operator==, для всех случаев применения,  если только не превратить C++ в язык формальных доказательств. Более того, даже формализация таких требований в спецификации стандарта оказалась крайне сложной: где именно проходит граница между «обещанием программиста» и «гарантией языка»? Еще один вариант отдельного языка для концептов, который опирался на математический аппарат выражений для задания ограничений:

concept Container<typename> {
    typename value_type
    typename iterator
    
    comparable ->
      T == T -> bool
      and T != T -> bool
      and T < T -> bool
      and T <= T -> bool
      and T > T -> bool
      and T >= T -> bool
};

Несмотря на все сложности, работа продолжалась, и к середине 2000-х годов концепты стали одним из ключевых направлений развития C++ и активно обсуждались в комитете, разрабатывались прототипы компиляторов, и в какой-то момент концепты даже почти попали в стандарт C++0x (будущий C++11). Однако именно здесь произошло первое крупное столкновение теории и практики, когда предложенная система все равно оказалась слишком сложной для реализации, и слишком непонятной для обычного программиста. В результате концепты были в последний момент исключены из C++11, что конечно, многими в сообществе было воспринято как серьёзное поражение.

Однако после этого наступил важный период переосмысления, и, вместо попыток сразу решить все проблемы концептов, решили, наоборот, упростить модель применения. Так родилась идея Concepts Lite как облегчённого варианта концептов, когда сознательно отказались от семантических требований и сосредоточились исключительно на том, что компилятор может проверить надёжно и эффективно предложенные программистом условия. Ключевую роль в этом сыграл Эндрю Саттон (Andrew Sutton), который предложил упрощенную модель ограничений, основанную на логических выражениях, атомарных ограничениях и строгих, формализованных правилах частичного порядка. Вот так примерно выглядели бы концепты, если бы были приняты в 2009:

// Концепт с аксиомами для описания семантики
concept TotalOrder<typename T, typename Op> {
    requires Predicate<Op, T, T>;
    
    // Аксиомы описывают семантические свойства
    axiom Reflexivity(Op op, T x) {
        op(x, x) <=> false;  // элемент не меньше сам себя
    }    
    axiom Antisymmetry(Op op, T x, T y) {
        if (op(x, y) && op(y, x))
            x <=> y;  // если x < y и y < x, то x == y
    }
    axiom Transitivity(Op op, T x, T y, T z) {
        if (op(x, y) && op(y, z))
            op(x, z) <=> true;  // транзитивность
    }
}

Concepts Lite не были «договором о поведении», а стали языковым механизмом для задания ограничения шаблонов и концепт в этой модели стал по сути именованным логическим выражением над типами и выражением, проверяемым на этапе компиляции. Концепт теперь лишь говорит, что «этот тип поддерживает ровно те операции, которые здесь перечислены».

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

Современные концепты стали результатом компромисса между выразительностью и реализуемостью. Хотя они и основаны не на проверке семантики и корректности алгоритмов, зато позволяют явно выразить требования к типам и существенно улучшить читаемость интерфейсов, давая компилятору достаточн�� информации, чтобы выбирать специализированные перегрузки.

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

Непустые комментарии

В самых первых предложениях концепт рассматривался как набор требований + семантический контракт, а синтаксис от Степанова напоминал отдельный мини-язык.

Пример концепта равенства:

concept EqualityComparable<typename T> {
    bool operator==(T, T);
    bool operator!=(T, T);

    /// semantic requirements:
    /// == is reflexive
    /// == is symmetric
    /// == is transitive
};

Здесь принципиально важно, что последние строки не были комментариями в духе “для документации”, а предполагалось, что это часть формального определения концепта. Идея шла из работ Гриса/Дейкстры и академической школы обобщённого программирования: алгоритм корректен только тогда, когда тип удовлетворяет математическим свойствам.

Неслучившиеся аксиомы

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

template <typename Iter>
concept RandomAccessIterator
    : BidirectionalIterator<Iter> -> axiom {
        i + n;
        i - n;
        i[n];
    };

// И далее:

template <RandomAccessIterator Iter>
void sort(Iter first, Iter last);

Тут важно отметить , что здесь всё ещё подразумевалось семантическое наследование: RandomAccessIterator не просто «имеет operator+», а гарантирует сложность операций O(1), корректность индексации, стабильность ссылок и т.д. Формально язык этого не проверял, но стандарт обещал, что если тип объявлен как удовлетворяющий концепту, он обязан вести себя правильно. В любом случае, ни один существующий компилятор не мог выполнить такие проверки, и это был контракт «на честное слово», поэтому дальше прототипов дело не пошло.

Тяжелые нулевые

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

template <typename T>
concept LessThanComparable -> requires(T a, T b) {
    { a < b } -> bool;
};

И использование:

template <LessThanComparable T>
T min(T a, T b) {
    return b < a ? b : a;
}

// Но дополнительно существовали механизмы:
template <typename T>
  requires LessThanComparable<T>
    void foo(T);

Но проблема была в том, что концепты:

  • влияли на вывод типов;

  • влияли на частичный порядок шаблонов;

  • вводили новые правила специализации;

  • делали ошибки компилятора ещё сложнее.

Даже Страуструп признавал, что эта версия была слишком сложной и все еще слишком непонятной обычному программисту. Переход к Concepts Lite, который мы видим сейчас, часто называют сознательным упрощением, но на самом деле это был не шаг назад, а скорее шаг в сторону реальности как уже существующих проектов, так и новых кодовых баз. После неудачной попытки включить «тяжёлые» концепты в C++11 стало понятно, что язык зашёл слишком далеко в стремлении формализовать правильность программ: формально программа может быть правильной, но она от этого не станет работать лучше и не будет понятнее. Именно в этот момент появилась идея упростить модель и оставить только то, что компилятор действительно способен переварить.

Легкие концепты

В основе Concepts Lite лежит простая, но очень важная мысль: концепт — это не философское утверждение из ранних работ Степанова о свойствах типа, а всего лишь техническое ограничение на допустимые подстановки шаблонных параметров. В этом случае концепт отвечает не на вопрос «правильный ли это тип», а на вопрос «можно ли с этим типом сгенерировать данный код». Поэтому современный концепт EqualityComparable в стиле C++20 требует лишь существования выражений a == b и a != b и того, чтобы их результат можно было привести к bool, а компилятору остается проверить исключительно форму выражения и его тип, не  пытаясь делать выводов о том, ведёт ли себя операция равенства корректно с точки зрения математики.

// Концепт проверяет только синтаксис, не поведение
template<typename T>
concept EqualityComparable = requires(T a, T b) {
    { a == b } -> std::convertible_to<bool>;
    { a != b } -> std::convertible_to<bool>;
};

Это означает, что такие свойства, как рефлексивность, симметричность или транзитивность, которые присутствовали в первых вариантах концептов, полностью выпадают из области ответственности языка и, соответственно, компилятора. Тип может формально удовлетворять EqualityComparable, но при этом иметь абсурдную реализацию operator==, которая возвращает случайные значения или зависит от глобального состояния, и с точки зрения компилятора это абсолютно нормально, потому что его задача не доказывать корректность программы, а проверить, что сгенерированный код вообще возможен.

Если сравнить старые и новые концепты, то в раннем, математически полном подходе утверждение «тип удовлетворяет EqualityComparable» означало гораздо больше, чем просто наличие операторов. Алгоритмы могли полагаться на то, что равенство ведёт себя как “отношение эквивалентности”, и вся корректность STL строилась вокруг этого предположения, что конечно было красиво в теории, но эта теория держалась на негласном соглашении между автором типа и стандартной библиотекой.

Отношение эквивалентности – это доказательство, что некоторые элементы множества «одинаковы» с определённой точки зрения. Чтобы отношение было отношением эквивалентности, оно должно вести себя так же естественно, как обычное равенство. Представьте, что у нас есть какой-то знак ∼, который говорит «эквивалентно», и мы хотим, чтобы он обладал теми же интуитивными свойствами, что и знак равенства. 

Для этого отношение должно удовлетворять трём аксиомам. Рефлексивность, когда любой элемент эквивалентен сам себе (a ∼ a), что звучит очевидно, но важно зафиксировать явно. Симметричность: если a эквивалентно b, то и b эквивалентно a (если a ∼ b, то b ∼ a) и порядок не имеет значения. И наконец, транзитивность – если a эквивалентно b, и b эквивалентно c, то a должно быть эквивалентно c (если a ∼ b и b ∼ c, то a ∼ c). Вот например очень простое описание эквивалентности:

Отношение ∼ на множестве S называется отношением 
эквивалентности, если для всех a, b, c ∈ S выполнены три аксиомы:

┌─────────────────────────────────────────────────────────────────┐
│ РЕФЛЕКСИВНОСТЬ                                                  │
│                                                                 │
│   ∀a ∈ S: a ∼ a                                                │
│                                                                 │
│   "Каждый элемент эквивалентен самому себе"                     │
│                                                                 │
│   Пример: 42 = 42, человек равен самому себе                    │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ СИММЕТРИЧНОСТЬ                                                  │
│                                                                 │
│   ∀a, b ∈ S: a ∼ b  ⟹  b ∼ a                                  │
│                                                                 │
│   "Если a эквивалентно b, то b эквивалентно a"                  │
│                                                                 │
│   Пример: если 6/2 = 3, то 3 = 6/2                              │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ТРАНЗИТИВНОСТЬ                                                  │
│                                                                 │
│   ∀a, b, c ∈ S: (a ∼ b) ∧ (b ∼ c)  ⟹  a ∼ c                  │
│                                                                 │
│   "Если a∼b и b∼c, то a∼c"                                     │
│                                                                 │
│   Пример: если a = b и b = c, то a = c                          │
└─────────────────────────────────────────────────────────────────┘

Эти три свойства вместе гарантируют, что наше отношение ведёт себя разумно и позволяет разбить всё множество на непересекающиеся группы эквивалентных элементов, т.е. они имеют “отношение эквивалентности”. Ну как вам идея такого мат. аппарата в концептах? И другой вопрос как это описать?

Современный подход куда более приземлённый: если тип удовлетворяет EqualityComparable, это означает лишь одно: код, который использует == и != в соответствующих местах, скомпилируется, но никаких гарантий корректности алгоритма язык не даёт. Ответственность за то, чтобы operator== действительно имел осмысленную семантику, полностью лежит на программисте, а компилятор переводит это в форму, доступную процессору.

Что осталось в стандарте

Старые концепты не выжили, потому что семантические требования невозможно проверить компилятором автоматически, а значит, они неизбежно превращаются либо в комментарии, либо в источник проблем и формализовать такие требования в стандарте оказалось крайне сложно и нельзя четко сказать где проходит граница между обязательством языка и кодом разработчика. Реализация тяжёлых концептов перегружала компиляторы и делала и без того сложные правила шаблонов ещё более сложными, вместо того чтобы упрощать процесс программирования. Может и хорошо, что комитет тогда не осилил математически полную реализацию концептов, а то мы сейчас погрязли бы в рисовании стрелочек и доказывании аксиом. Но концепты не стали серебряной пулей, добавив определенную сложность и в, без того, не самый простой язык. А вот почему «легкие» концепты все же привели к «тяжелым» иерархиям обсудим в следующей статье...

Комментарии (0)