Метапрограммирование на C++: проверка условия во время компиляции

Определение проблемы

Решение проблемы - собираем велосипед

Решение без велосипедов - использование BOOST C++

Второе решение - инструменты библиотеки Loki

Литература

 

Определение проблемы

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

Допустим, есть алгоритм, работающий с текстовой информацией. Мы предусмотрели возможность работы с 1- и 2-байтовыми символами (то есть char и wchar_t).

switch( sizeof(T_CHAR) )
{
 case 1: ... break;
 case 2: ... break;
 default: assert(0);
}

Любой опытный (то есть не раз наступивший на грабли языка C++) программист почти обязательно впишет обработку ветки default в операторе switch, чтобы не получить сюрприз. Кстати, компилятор gcc (и MSVS 2003 тоже) в этом смысле умен - он выдает предупреждение, что не все альтернативы условия в операторе switch обработаны. Но так бывает только если альтернативы известны (условие типа enum).

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

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

Был у нас как раз такой случай. Windows-версия программы работала на ура, а Linux-версия сбоила, причем делала это редко (что еще хуже). Причиной оказалось то, что тип wchar_t компилятором gcc реализован как 4-байтовый. А часть кода, работавшая лишь при некоторых сочетаниях условий, была привязана к соблюдению условия, что размер символов равен либо 1 байт, либо 2 байта.

Можно ли отловить случаи, когда алгоритм будет работать некорректно, не выполняя тестовые прогонки программы? Для этого нужно, чтобы во время компиляции программы транслятор умел выполнять проверку условий и выдавать осмысленное описание ошибки при нарушении этого условия. То есть, фактически, нам нужна метапрограмма.

 

Решение проблемы - собираем велосипед

Решение задачи возможно несколькими способами. Покажем первый - с написанием собственной версии основного инструментария, чтобы затем перейти к решениям, предложенному в библиотеках Boost и Loki (эти решения приведены далее).

Во-первых, можно использовать тот факт, что в C/C++ недопустимы массивы нулевой длины. Вдумайтесь в этот факт - проверка на ноль длины массива выполняется компилятором во время трансляции. Это то, что доктор прописал (первое письменное упоминание этого приема датируется хрониками в 1997 году)!

#define STATIC_CHECK(condition) { char dummy[ (condition)  ? 1 : 0]; }

Этот макрос проверяет во время компиляции, что его аргумент не равен нулю. К примеру, код

STATIC_CHECK( sizeof(wchar_t)<=2 );

даст ошибку компиляции, если тип wchar_t имеет непредусмотренный нами размер 4 байта.

Это решение работоспособно, но непрактично в том плане, что выдаваемое сообщение об ошибке совершенно не проясняет ситуацию. Вот, к примеру, что выдает MS VisualStudio 2005:

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

Скачать файл compile-time-assertions.cpp (300 байтов)

Теперь посмотрим, как можно улучшить юзабилити нашего макроса.

Решение основано на специализации шаблонов. Вот код:

template < bool > struct CompileTimeChecker;

template <> struct CompileTimeChecker<true>
{
CompileTimeChecker(...) {};
};


#define STATIC_CHECK( expr ) { sizeof( CompileTimeChecker< (expr) != 0 > ( expr ) ); }
 

Вся механика здесь построена на специализации шаблона CompileTimeChecker. Обратите внимание, что специализация параметра для false не объявлена - это-то и является второй веткой альтернативы проверки.

Скачать файл compile-time-assertion-2.cpp (353 байта)

 

Решение без велосипедов - использование BOOST C++

Наконец, после рассмотрения двух вариантов реализации проверки условия на стадии компиляции, можно спокойно отказаться от самостоятельно написанного кода и воспользоваться удивительной библиотекой BOOST C++. Среди множества вещей там есть библиотечка Static Assertions - как раз то, что нужно, особенно если учесть, что код BOOST C++ проверяется на множестве платформ (надо заметить, что реализация деталей использования шаблонов сильно различается для разных компиляторов - это может сильно испортить настроение при написании платформонезависимого кода).

#include <boost/static_assert.hpp>
 

...
BOOST_STATIC_ASSERT( sizeof(wchar_t) <=2 );
 

Второе решение - инструменты библиотеки Loki

Классика жанра: книга одного из основоположников современного подхода к метапрограммированию в языке C++

Современное проектирование на С++: Обобщенное программирование и прикладные шаблоны проектирования: подробнее

Андрей Александреску
Современное проектирование на С++: Обобщенное программирование и прикладные шаблоны проектирования

Теперь посмотрим на другую замечательную библиотеку - Loki. Ее автор предлагает рассмотреть другую задачу, где необходима проверка во время компиляции, и мы последуем за ним.

Предположим, что мы разрабатываем функцию, которая выполняет безопасное преобразование типа (см. для справки библиотеку Boost.Conversion). Задача заключается в том, чтобы гарантировать, что в ходе преобразования нет потери информации - как например в коде

double a=M_PI;
float b = static_cast<float>(a);

Самый простой и общий способ проверить это - сравнить размер соответствующих типов, хотя такой подход отсекает значительное число корректных случаев (если записать в переменную double значение 1, то можно спокойно преобразовывать ее в типы float, int и даже char - потери разрядов не будет!).

Пишем первый вариант реализации функции:

template <class To, class From>
To safe_reinterpret_cast( From from )
{
 assert(sizeof(From) <= sizeof(To) );
 return reinterpret_cast<To>(from);
}

Использовать ее можно так же, как и стандартные операторы преобразования C++:

int i = 123456;
char *bad_pointer = safe_reinterpret_cast<char*>(i);

Тип To, к которому выполняется приведение, указывается явно, а исходный тип From выводится компилятором из аргумента вызова шаблонной функции. Что же нас не устраивает в данной версии функции? Самое главное - потенциально неверные преобразования обнаруживаются только при работе скомпилированной программы и только в ее отладочной версии (в релиз-версии обычно макрос assert() отключается). То есть ошибка не будет обнаружена до момента, пока соответствующий участок программы не будет исполнен. Но обратите внимание, что вся информация о типах в данном случае известна компилятора при конкретизации шаблонной функции safe_reinterpret_cast! Так почему бы не заставить компилятор выполнять проверку размеров типов при трансляции?

Вот новая реализация функции:

#define STATIC_CHECK(expr) { char dummy[ (expr) ? 1 : 0 ]; }

template <class To, class From>
To safe_reinterpret_cast( From from )
{
 STATIC_CHECK( sizeof(From) <= sizeof(To)  );
 return reinterpret_cast<To>(from);
}

По сути, функционально это вполне работоспособный код, в котором во время компиляции с помощью вспомогательного макроса STATIC_CHECK проверяется условие - размер нового типа To должен быть не меньше размера старого типа From. Его недостаток - непонятное сообщение об ошибке, генерируемое в случае несоблюдения условия безопасного преобразования. Действительно, сообщение о том, что размер массива dummy задан как 0 (см. скриншот выше) может сбить с толку.

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

Попробуем модифицировать макрос STATIC_ASSERT, введя в него второй аргумент - выдаваемое сообщение при нарушении условия.

template<bool> struct CompileTimeChecker
{
 CompileTimeChecker(...);
};

template<> struct CompileTimeChecker<false> {};

#define STATIC_CHECK(expr,msg) \
{ \
 class ERROR_##msg {};\
 (void)sizeof( CompilerTimeChecker<(expr)!=0>((ERROR_##msg())));\
}

Теперь функцию безопасного преобразования можно реализовать так:

template < class To, class From >
To safe_reinterpret_cast( From from )
{
 STATIC_CHECK( sizeof(From) <= sizeof(To), Destination_Type_Too_Narrow );
 return reinterpret_cast<To>(from);
}

Что интересного можно обнаружить в этом коде? Во-первых, частичную специализацию шаблона (для CompileTimeChecker). Во-вторых, конструктор класса, который принимает любой аргумент - это описывается с помощью декларатора '...'. В третьих, объявление локального (внутри шаблонной функции safe_reinterpret_cast) класса с именем, состоящем из префикса ERROR_ и затем - указанного нами сообщения об ошибке.

Посмотрим, что получится при реальном применении новой версии функции преобразования. Рассмотрим такой код:

void* somePointer = NULL;

char c = safe_reinterpret_cast<char>(somePointer);

После подстановки макроса STATIC_CHECK, компилятор имеет дело с такой реализацией:

template <class To, class From>
To safe_reinterpret_cast( From from )
{
 {
  class ERROR_Destination_Type_Too_Narrow {};

  (void)sizeof( CompileTimeChecker<(sizeof(From) <= sizeof(To))>(ERROR_Destination_Type_Too_Narrow()));
 }

 return reinterpret_cast<To>(from);
}

Если условие безопасности преобразования выполняется (то есть sizeof(From) <= sizeof(To)), то созданный временный объект класса ERROR_Destination_Type_Too_Narrow успешно выступает в роли аргумента при создании объекта конкретизации шаблонного класса CompileTimeChecker<true> - его конструктор принимает любые типы благодаря '...'. Если же условие не соблюдается, то вызвать конструктор конкретизации шаблонного класса CompileTimeChecker<false> не удастся, так как для нее конструктор просто не объявлен, поэтому компилятор выдаст сообщение об ошибке, которое весьма вероятно будет содержать упоминание о невозможности преобразовать объект ERROR_Destination_Type_Too_Narrow в CompileTimeChecker<false>. Для компилятора MS VisualStudio результат такой:

 

В библиотеке Loki приведенный выше макрос STATIC_CHECK объявлен в хидере static_check.h. Вы можете скачать и скомпилировать программу, использующую описанную технологию проверки условий во время компиляции.

Проект для MS VisualStudio 2003 (2 Kb)

Литература

Часть материала данной статьи написана на основе и по мотивам главы 2 книги "Modern C++ Design: Generic Programming and Design Patterns applied" by Andei Alexandrescu (см. описание библиотеки Loki).

Последние изменения 12.06.2005   © Mental Computing 2009  main page  rss  email  icq  download