Skip to content

Гарантированное вычисление константного выражения на этапе трансляции #645

Description

@ssoft-hub

Описание идеи

Предлагается изменить одно правило: вызов constexpr функции, который может быть вычислен на этапе трансляции, обязан быть на нём вычислен, где бы он ни был записан. Сейчас это зависит от места записи: там, где язык требует константу, вычисление обязательно, а в остальных местах оставлено на усмотрение компилятора. Предлагается убрать эту зависимость от места.

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

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

Это предложение подобно P0135, в котором гарантированное устранение копий получено не обязательством устранять саму копию, а переопределением категорий значений, после которого копии не возникает.

В этом предложении меняется правило о том, какое выражение подвергается константному вычислению.

Из этого правила следует:

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

Ничего дополнительно не нужно писать. Нет ни нового ключевого слова, ни атрибута, ни спецификатора. Автор кода уже написал constexpr.

Обходные пути становятся ненужными. Отпадает потребность объявить одну и ту же функцию дважды, consteval для гарантии и constexpr для вызова во время исполнения. Удовлетворить эту потребность всё равно нельзя: второе объявление после первого есть ошибка.

Примеры, где идея будет полезна

Хеш строкового литерала

Строковые идентификаторы событий, команд и ресурсов принято переводить в целое число на этапе трансляции, чтобы сравнивать их за одну операцию и использовать в switch. Функция ниже это и делает, а её единственный вызов получает литерал, то есть всё нужное известно заранее.

#include <cstdint>

constexpr std::uint64_t fnv1a(char const * text)
{
    std::uint64_t hash = 14695981039346656037ull;
    while (*text != '\0')
    {
        hash ^= static_cast<unsigned char>(*text);
        hash *= 1099511628211ull;
        ++text;
    }
    return hash;
}

void use(std::uint64_t value);

void example()
{
    use(fnv1a("event.started"));
}
Компилятор Результат при включённой оптимизации
GCC 13.1.0, -O2 загрузка непосредственного значения 0x563C754BD12624A8 и переход в use
Clang 22.1.8, -O2 загрузка того же значения и переход в use
MSVC 19.44, /O2 цикл по байтам строки, выполняемый при каждом вызове

Разбор литерала в значение

Адреса, идентификаторы, версии и коды состояний записывают литералом, а хранят числом. Разбор естественно написать один раз и применять и к литералу, и к строке, прочитанной из файла.

#include <cstdint>

constexpr std::uint32_t ipv4(char const * text)
{
    std::uint32_t result = 0;
    std::uint32_t octet = 0;
    for (; *text != '\0'; ++text)
    {
        if (*text == '.')
        {
            result = (result << 8) | octet;
            octet = 0;
        }
        else
        {
            octet = octet * 10 + static_cast<std::uint32_t>(*text - '0');
        }
    }
    return (result << 8) | octet;
}

void use(std::uint32_t value);

void example()
{
    use(ipv4("192.168.100.7"));
}
Компилятор Результат при включённой оптимизации
GCC 13.1.0, -O2 загрузка непосредственного значения 0xC0A86407 и переход в use
Clang 22.1.8, -O2 цикл разбора, выполняемый при каждом вызове
MSVC 19.44, /O2 цикл разбора, выполняемый при каждом вызове

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

Предложение исправляет зависимость результата от длины литерала и от уровня оптимизации

Тот же первый пример, тот же ключ -O2, та же функция, меняется только длина литерала:

Длина литерала Clang 22.1.8
100 знаков свёрнут в константу
101 знак цикл, выполняемый при каждом вызове

Порог проходит ровно между этими двумя длинами и в исходном тексте никак не виден. GCC 13.1.0 на тех же файлах сворачивает вызов при любой длине вплоть до 4096 знаков, то есть границы у компиляторов разные и нигде не объявлены.

Уровень оптимизации решает не меньше. Вызов с литералом из 13 знаков Clang сворачивает на -O1, -O2, -O3 и -Os, но не сворачивает на -O0. Отладочная сборка считает хеш во время исполнения там, где релизная берёт готовое число, и ни одна из них об этом не сообщает.

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

Константное подвыражение внутри неконстантного выражения

Здесь задать constexpr переменную не просто неудобно, а недоступно в принципе. Оператор-объявление недопустим в списке инициализации конструктора и в аргументе по умолчанию, а оба эти выражения вычисляются при каждом построении объекта и при каждом вызове функции соответственно.

struct handler
{
    std::uint64_t identifier;

    explicit handler(std::uint64_t salt) : identifier{fnv1a("event.started") ^ salt} {}
};

Значение salt известно только во время исполнения, поэтому весь инициализатор в константу не вынести. Константен лишь вложенный вызов, но объявить для него локальную переменную в списке инициализации негде. Clang 22.1.8 и GCC 13.1.0 сворачивают этот вызов, MSVC 19.44 оставляет цикл и загружает адрес строки при каждом построении объекта.

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

Почему consteval эту задачу не решает

Объявленная consteval, та же функция даёт гарантию и одновременно запрет.

#include <cstddef>
#include <cstdint>

template <std::size_t Size>
consteval std::uint64_t fnv1a(char const (&text)[Size])
{
    std::uint64_t hash = 14695981039346656037ull;
    for (std::size_t index = 0; index + 1 < Size; ++index)
    {
        hash ^= static_cast<unsigned char>(text[index]);
        hash *= 1099511628211ull;
    }
    return hash;
}

void use(std::uint64_t value);

void from_literal()
{
    use(fnv1a("event.started"));   // константа на любом компиляторе
}

void from_buffer(char c)
{
    char const text[4] = {c, c, c, '\0'};
    use(fnv1a(text));              // не собирается
}
Компилятор Сообщение на второй функции
GCC 13.1.0 error: the value of 'text' is not usable in a constant expression
Clang 22.1.8 error: call to consteval function 'fnv1a<4ULL>' is not a constant expression
MSVC 19.44 error C7595: 'fnv1a': call to immediate function is not a constant expression

Разрешение перегрузок не учитывает спецификатор consteval, и правила "если вычислить константно нельзя, выбрать другого кандидата" не существует. Различить литерал и массив, заполненный во время исполнения, нечем: тип у них один и тот же. Ни одно из трёх сообщений не подсказывает, что делать.

Предложение исправляет распространение ограничения consteval на обёртку

Библиотека редко вызывает такую функцию напрямую: между пользователем и ей стоит вызываемый объект, конструктор или шаблон-адаптер.

template <std::size_t Size>
constexpr std::uint64_t wrapper(char const (&text)[Size])
{
    return fnv1a(text);
}

void through_wrapper()
{
    use(wrapper("event.started"));
}
Компилятор Результат
Clang 22.1.8 собирается
GCC 13.1.0 error: 'text' is not a constant expression
MSVC 19.44 error C7595: 'fnv1a': call to immediate function is not a constant expression

Clang собирает потому, что реализует P2564: по этому правилу constexpr функция, вызвавшая consteval функцию, сама становится consteval.

void wrapper_at_run_time(char c)
{
    char const text[4] = {c, c, c, '\0'};
    use(wrapper(text));
}

Clang отвечает error: call to immediate function 'wrapper<4ULL>' is not a constant expression. Обёртка, объявленная обычной constexpr функцией, сама стала consteval и перестала принимать аргумент времени исполнения. Механизм P2564 распространяет требование вверх по цепочке вызовов, а не откатывается на вычисление во время исполнения, и относится он только к неявно constexpr сущностям: шаблонам, лямбда-выражениям, функциям, определённым по умолчанию. Обычная constexpr-функция под него не попадает.

В этом предложении обёртке нечего распространять: внутренний вызов уже является константой, и consteval здесь не нужен вовсе.

Отсутствие предлагаемого механизма расходится с принципом нулевых накладных расходов

Принцип, на котором построен язык, состоит из двух частей: "What you don't use, you don't pay for (in time or space) and further: What you do use, you couldn't hand code any better". Нарушается вторая половина: автор кода, когда использует функцию, платит за неё больше, чем стоил бы написанный руками эквивалент с constexpr переменной.

Эквивалент отличается одной строкой и не стоит ничего:

void example()
{
    constexpr std::uint64_t identifier = fnv1a("event.started");
    use(identifier);
}

Функция та же и литерал тот же, но GCC 13.1.0, Clang 22.1.8 и MSVC 19.44 сводят эту запись к загрузке одного непосредственного значения и переходу в use. Разница между двумя записями не в том, что вычисляет программа, а только в том, потребовал ли автор вычисления на этапе трансляции. Абстракция, которая должна быть бесплатной, стоит цикл ровно тогда, когда автор не написал одного лишнего слова.

Что применяют сегодня и почему этого недостаточно

Привязка результата к константе. Показанная выше запись работает на всех компиляторах и даёт полную гарантию. Но она держится на дисциплине автора и никак не проявляется в точке вызова: забыть слово constexpr легко, и ни один компилятор об этом не сообщит. А там, где объявить constexpr переменную нельзя, эта запись недоступна вовсе.

Передача текста параметром-значением шаблона. Запись вида fnv1a<"event.started">() гарантию даёт, однако массив не может быть параметром-значением напрямую, поэтому требуется структурная обёртка над строкой.

Объявление consteval. Даёт гарантию константного вычисления, но отнимает вызов во время исполнения. Объявить рядом второе, constexpr, нельзя: consteval объявление после constexpr объявления той же функции есть ошибка, error: consteval declaration of 'f' follows constexpr declaration.

Код с реализацией идеи

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

Определение константного выражения от контекста не зависит. [expr.const.core] задаёт основное константное выражение структурными правилами, а [expr.const.const] добавляет к нему требование к результату. Ни то, ни другое не спрашивает, куда выражение попало.

От контекста зависит перечень того, что подвергается константному вычислению. Его задаёт [expr.const.defns] через понятие manifestly constant-evaluated, куда входят константные выражения, условия if constexpr, вызовы consteval функций, инициализаторы переменных с константной инициализацией и прочее.

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

Все примеры выше собираются как есть и проверяемы на godbolt.org: достаточно поставить -O2 и сравнить вывод трёх компиляторов.

Возражения, которые напрашиваются, и ответы на них

Время сборки вырастет. Достаточно constexpr-цикла на миллион витков, вызываемого из сотни мест. Но ограничитель для этого уже существует и уже работает. Стандарт рекомендует реализациям ограничивать объём константного вычисления, и все три компилятора дают такую настройку: -fconstexpr-steps у Clang, -fconstexpr-ops-limit у GCC, /constexpr:steps у MSVC. Вызов, выходящий за предел, сегодня константным выражением не является, и при предлагаемом правиле не станет: он останется обычным вызовом. Новой границы вводить не требуется, а величина предела по умолчанию относится к качеству реализации.

Поведение программ с std::is_constant_evaluated() изменится. Изменится то, какая из двух ветвей исполняется, но не результат. Замысел этой функции состоит в выборе между двумя реализациями одного вычисления: ветвь этапа трансляции написана на переносимом стандартном C++, а ветвь времени исполнения вправе пользоваться платформенными средствами или отдельно собранной библиотекой. При верно написанной функции обе дают одно значение.

Результаты вычислений с плавающей точкой изменятся. Вычисление на этапе трансляции определено точно. C++20 допускает float, double и long double в качестве параметров-значений шаблона (P1714), причём сравниваются они побитово, так что +0.0 и -0.0 различны; значение, вычисленное на этапе трансляции, обязано иметь определённое битовое представление, участвующее в тождестве шаблона и в декорировании имён.

Ссылки

  • P0135 - гарантированное устранение копий через переопределение
    категорий значений. Образец того, как формулируется предложение такого рода.
  • P1073 - consteval, вошло в C++20.
  • P0595 - std::is_constant_evaluated, вошло в C++20.
  • P1714 - числа с плавающей точкой как параметры-значения шаблона, вошло в C++20.
  • P1938 - if consteval, вошло в C++23.
  • P2564 - распространение требования consteval вверх по цепочке вызовов, вошло в C++23.
  • Big Picture Issues, ISO C++ FAQ - формулировка принципа нулевых накладных расходов.

Смежные обсуждения в этом трекере:
282 о снятии ограничений с constexpr,
321 о записи constexpr(выражение),
330 о квалификаторе constexpr на аргументах,
334 об обязательной оптимизации хвостового вызова.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions