Pull to refresh

Как проверить в C, является ли выражение константой

Reading time9 min
Views2.6K
Original author: NRK

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

  • Удостоверяет, что выражение является константой (т.e, его значение известно во время компиляции), а если это не так — отменяет компиляцию.

  • «Возвращает» то же самое значение.

  • Дополнительно: пусть возвращаемое значение относится к тому же типу, что и исходное выражение.

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

Составные литералы constexpr

Если вы работаете со стандартом C23 или выше, то можете указать, как долго должны храниться составные литералы. Сочетая их с typeof и constexpr, которые также стандартизированы в C23, можно поступить так:

#define C(x) ( (constexpr typeof(x)){x} )

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

Недостатки:

  • Требуется поддержка C23, которая пока не очень распространена.

  • Clang (v20) по-видимому не позволяет задавать длительность хранения составных литералов (а в GCC v14.2 такая функция вроде бы работает).

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

Правда, constexpr чуть богаче семантически, чем его более ранний аналог static. Дело в том, что constexpr требует, чтобы результирующее выражение (а не только инициализатор) тоже было константным.

__builtin_constant_p

Если вы располагаете расширениями GNU, то можете воспользоваться __builtin_constant_p, возвращающим  true, когда выражение является константным, а вместе с тем — и __builtin_choose_expr для выбора.

__attribute((error("not constant"))) int notconst(void);

#define C(x) __builtin_choose_expr(__builtin_constant_p(x), (x), notconst())

Когда __builtin_constant_p возвращает true__builtin_choose_expr выбирает первое выражение. В противном случае он вызывает формальную несуществующую функцию с атрибутом error, тем самым провоцируя ошибку компиляции.

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

Недостатки:

  • Для работы нужны расширения GNU.

static_assert

Этот фокус мне показал пользователь constxd в одном IRC-канале. Здесь мы используем static_assert из C11+, показывая с его помощью, что выражение — статическое. Но как же «вернуть» выражение? Ну… для этого нужно немного поколдовать с  sizeof + анонимными struct:

#define C(x) ((x) + 0*sizeof( \
    struct { _Static_assert((int)(x) || 1, ""); char tmp; } \
))

Выглядит диковато. Как можно было впустить static_assert прямо внутрь определения структуры? Дело в том, что стандарт классифицирует static_assert как объявление, которое ничего не объявляет (не спрашивайте меня, почему). Поэтому синтаксически ничто не мешает нам просто поместить это в структуру.

Сложение 0*sizeof(...) в сущности, операцией не является, поэтому значение остаётся неизменным. Но тип может измениться в результате продвижения.

Недостатки:

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

  • В стандарте сказано, что выражение static_assert должно быть целочисленным и константным, но, как кажется, gcc и clang в данном случае может принять и выражение с плавающей точкой; правда, при этом будут выдаваться предупреждения. (Замечание: при непосредственном приведении int получается работоспособная константа с плавающей точкой, напр., 1.1 но такие вещи как 1.1 + 2.2 без выдачи предупреждений не пройдут).

sizeof + составной литерал с типом массива

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

#define C(x) ( (x) + 0*sizeof( (char [(int)(x) || 1]){0} ) )

Что здесь интересно отметить:

1.     Со времён C99 массивы (и соответствующие им типы) могут иметь переменную длину. Но составные литералы не принимают типов переменной длины, что и послужит нам верификацией.

2.     В стандартном C (к сожалению) запрещены массивы нулевой длины, и поэтому || 1.

3.     Если массив превысит определённый размер, то объявить его будет невозможно (даже если не будет выделяться никакого пространства под хранение данных). На моём 64-разрядном ПК gcc отказывается работать с размерами сверх PTRDIFF_MAX (263-1) байт. Clang ещё консервативнее и отвергает всё, что больше 61 бита. Не считая проблем с поддержкой 0, || 1  также ужимает размер массива до 1.

Единственное преимущество этого способа по сравнению с использованием static_assert заключается в том, что здесь нам не требуется C11, и для работы достаточно C99. В остальном мы получаем здесь в наследство все проблемы, присущие static_assert:

  • Тип может меняться.

  • Не поддерживаются выражения с плавающей точкой (и, в отличие от случая с static_assert, gcc отказывается полностью скомпилировать выражение с плавающей точкой).

sizeof + константное перечисление

Поскольку константные перечисления — это обязательно целочисленные константные выражения, ими вполне можно воспользоваться вместо составного литерала:

#define C(x) ( (x) + 0*sizeof( enum { tmp = (int)(x) } ) )

Но здесь есть одна вопиющая проблема: в отличие от составных литералов, константные перечисления «протекают». То есть, таким макросом можно будет воспользоваться только один раз. Можно попробовать конкатенацию до обработки, чтобы прикреплять к строке её номер (__LINE__), но в таком случае вы не сможете повторно использовать этот макрос в той же строке.

Вот симпатичное (как кому…) решеньице, которое я придумал: будем определять перечисление внутри параметра функции, так, чтобы оно приобретало «область видимости»:

#define C(x) ( (x) + 0*sizeof(void (*)(enum { tmp = (int)(x) })) )

Работает. Но и gcc, и clang предупреждают нас о том, что это перечисление анонимное… пусть именно этого мы и добивались. Причём, с помощью #pragma этого не подавить, ведь мы имеем дело с макросом — и, следовательно, предупреждение выдаётся именно в той точке, где вызывается макрос.

Практическая польза от такого решения невелика, но оно совместимо с C89, если для вас это важно. Недостатки:

  • Тип может меняться.

  • Не поддерживаются выражения с плавающей точкой.

  • При каждом вызове макроса и gcc, и clang предупреждают вас, что перечисление является анонимным.

Оператор запятая

Все макросы, в которых используется фокус (x) + 0*sizeof(...), потенциально могут пострадать из-за изменения типа. Это красиво и элегантно решается: выносим sizeof в отдельное выражение и игнорируем его при помощи оператора-запятой:

#define C(x) (sizeof(...), (x))

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

АПДЕЙТ: оказывается, можно заглушить неиспользуемые предупреждения, если приводить результат sizeof к void (уф!).

Чудачества GCC

Поначалу я не пользовался атрибутом error в моём решении с __builtin_constant_p, отдавая предпочтение массиву с отрицательным размером (а в случаях, когда массивы переменной длины запрещены, оборачивал его в структуру), чтобы спровоцировать ошибку:

#define C(x) ((__typeof__(x)) ((x) + 0*sizeof(  \
    struct { char tmp[__builtin_constant_p(x) ? 1 : -1]; } \
)))

Ожидаемо, Clang сигнализирует об ошибке, когда размер массива составляет -1. Но GCC это проглатывает и не утруждает вас ничем кроме предупреждения (глубокий выдох). Поэтому используем здесь атрибут error (Арсен, спасибо за подсказку), который должен хорошо страховать нас от таких странностей.

Заключение

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

Хотелось бы знать, есть ли какие-то (надеюсь, более выигрышные) решения, которые я упустил.

Обновлениеu/P-p-H-d подсказал такое решение, в котором при помощи тернарного оператора _Generic и правил обращения с константными нулевыми указателями удаётся определить, является ли константой выражение с целыми числами. Решение требует C11 и работает только с целыми числами, а к числам с плавающей точкой, к сожалению, неприменимо.

Бывает, что на языке C удобно различать, является ли данное выражение «постоянным выражением целочисленного типа» или «константой нулевого указателя». Например, это принципиально, если объект выделяется статически. Обычно это удаётся определить напрямую, уже когда мы пишем инициализатор. Но, если требуется инициализировать сравнительно сложную структуру такой функцией как инициализирующий макрос, в более ранних версиях C мы оказываемся перед выбором:

  • Воспользоваться расширением компилятора, например, __builtin_constant_p в gcc

  • Написать две разные версии такого макроса — одну для статического выделения и одну для автоматического.

Далее объяснено, как этого добиться при помощи возможности _Generic, предусмотренной в C11. Не знаю, предусмотрена ли аналогичная возможность в C++. Кроме того, в данном случае используется тернарный оператор (а в C и C++ они заметно отличаются), поэтому читатель должен иметь в виду, с каким из этих языков собирается работать.

Имея подобный формальный тип:

typedef struct p00_nullptr_test p00_nullptr_test;

получим, что выражение наподобие следующего во время компиляции результирует в константное значение 0 или 1, в зависимости от того, является ли Y константой нулевого указателя:

_Generic((1 ? (p00_nullptr_test*)0 : (void*)(Y)),
         p00_nullptr_test*: true,
         default: false)

Или результирует в myFunc(Y) тогда и только тогда, когда Y является постоянным выражением целочисленного типа:

_Generic((1 ? (p00_nullptr_test*)0 : (void*)((Y)-(Y))),
         p00_nullptr_test*: 0,
         default: myFunc(Y))

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

(1 ? (p00_nullptr_test*)X : (void*)Y)

Естественно, значение выражения равно X, но каков его тип?

Пожалуй, вы догадались: зависит от контекста, точнее, зависит от Y, но не от X. Но ещё интереснее, что зависит оно не только от типа Y (после приведения он всё равно будет неактуален), но и от значения. Если Y — это константа нулевого указателя, и только в этом случае, возвращаемый тип тернарного выражения будет p00_nullptr_test*, в противном случае — void*.

(void*)Y

может как являться, так и не являться константой нулевого указателя в том смысле, который вкладывается в этот термин в стандарте C. Если Y — это целочисленная константа, равная 0, то да, является, а если Y не целочисленная, не относится к типу void*, или её значение не равно 0, то это не константа нулевого указателя.

Тогда в первом случае допускаются целочисленный литерал (напр., 0, 0x0, '\0'), перечислимая константа со значением 0 (enum { zero }) или константное выражение, состоящее из литералов, перечислимых констант и выражений sizeof с условием результирования в 0.

Если Y примет любое из следующих значений, то в результате мы не получим константу нулевого указателя:

  • 1 (значение не равно 0)

  • a (для любой переменной a, равной 0, даже квалифицируемой как константа)

  • (int*)0 (поскольку указатель после преобразования не является void*)

  • (void const*)0 (по той же причине, квалифицированные версии void не допускаются)

Теперь вернёмся к рассмотренному выше первому типу обобщённого выражения:

_Generic((1 ? (p00_nullptr_test*)0 : (void*)(Y)),
         p00_nullptr_test*: true,
         default: false)

и посмотрим, как оно работает. Как уже было сказано выше, если Y — это константа нулевого указателя, то тернарное выражение имеет тип  p00_nullptr_test*, и срабатывает первый вариант. Во всех остальных случаях тернарное выражение имеет тип void*, и выбирается вариант, заданный по умолчанию.

При работе с обобщённым выражением второго типа действует другая логика. Известно, что любое арифметическое выражение вида (Y)-(Y) всегда равно 0, но константой нулевого указателя оно будет только в том случае, когда Y — это константное выражение целочисленного типа.

Для P99 можно обернуть некоторые из этих вещей в макросы, готовые к использованию:

P99_GENERIC_INTEGRAL_CONSTANT(EXP, XTRUE, XFALSE)  // является ли EXP целочисленной конконстантой во время компиляции 
P99_GENERIC_NULLPTR_CONSTANT(PEXP, XTRUE, XFALSE)  // является ли PEXP 0 или (void*)0 во время компиляции
P99_GENERIC_PCONST(PEXP, NCONST, CONST)            // квалифицируется ли *PEXP как константа

В качестве 2-го и 3-го аргумента все они могут принимать произвольные (но валидные) выражения.

Например, вторым макросом можно воспользоваться для инициализации типа, являющегося указателем.

#define TRUC_INITIALIZER(PEXP) {                                                    \
   .someField = 32,                                                                 \
   .pointerField = P99_GENERIC_NULLPTR_CONSTANT(PEXP, (truc*)0, truc_account(PEXP)),\
}

static truc the_static_truc = TRUC_INITIALIZER(0);
auto truc the_auto_truc = TRUC_INITIALIZER(42);

Здесь после инициализации переменной как static она будет видеть только константные выражения. В свою очередь, при инициализации как  auto функция truc_account() будет вызываться всякий раз, когда выполнение дойдёт до этой точки инициализации.

Tags:
Hubs:
+24
Comments9

Articles