Метапрограммирование на основе текстового препроцессора

МЕТАПРОГРАММИРОВАНИЕ НА ОСНОВЕ ТЕКСТОВОГО

ПРЕПРОЦЕССОРА

В.Ю. Лоторейчик Научный руководитель - кандидат технических наук Д.Г. Шопырин

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

Введение

Препроцессор - это программа, которая обрабатывает входные данные и формирует выходные данные, используемые как входные другой программой. Наиболее часто в программировании встречается пример, когда препроцессор создает из исходного кода программы входной поток информации для компилятора. Такой препроцессор принято называть текстовым. Основные применения текстового препроцессора в программировании - это написание макросов, условная компиляция и включение файлов. В данной статье будет рассмотрен только механизм макросов. Вызовы макросов в коде программы текстовый препроцессор заменяет на заранее описанный или формируемый по определенным правилам текст (код), при этом для формирования текста подстановки могут выполняться различные нетривиальные действия.

Хотя текстовый препроцессор применяется широко в различных средах разработки, но в этой статье будем касаться только препроцессора языка С++. Создатель языка С++ Б. Страуструп не рекомендует пользоваться препроцессором, потому что он создает проблемы для многих инструментов разработки, но иногда его использование вполне оправдано. Описанный на данный момент стандартом С++ препроцессор достаточно беден и не позволяет писать макросы, выполняющие сложные действия для формирования текста макроподстановки (замена макроса на некоторый программный код). В работе предлагается ряд мер по усовершенствованию препроцессора С++. Расширенный препроцессор языка C++ будем для краткости называть CPP#.

Аналогичные разработки:

• Camlp4 [1];

• Generic Preprocessor [2].

В Camlp4 присутствует механизм рекурсии. Оба препроцессора позволяют выполнять вычисления на стадии обработки кода препроцессором. Основные преимущества CPP# по сравнению с Camlp4 и Generic Preprocessor - это высокая безопасность и возможность структурировать макросы. Также с помощью CPP# метапрограммировать гораздо удобней, так как он изначально проектировался с этой целью.

Проблематика

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

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

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

1. Построение таблицы значений некоторой функции. Использование макросов позволяет осуществлять данную операцию на стадии препроцессинга.

2. Автоматическое написание схожих по структуре фрагментов кода. Использование макросов позволяет избежать ошибок, связанных с дублированием, и повышает читаемость программы.

В состав пакета Boost входит библиотека Boost.Preprocessor [3], позволяющая ме-тапрограммировать и выполнять вычисления на стадии препроцессинга в рамках старого препроцессора С++, но она обладает большими ограничениями. Например, складывать можно лишь те числа, сумма которых не превосходит 256. Реализацию этих макросов можно посмотреть в исходных кодах библиотеки BOOST [3].

Повышение уровня безопасности

Первоочередной задачей является повышение уровня безопасности использования препроцессора. Для сохранения обратной совместимости с уже существующим кодом безопасный синтаксис действует внутри директив #safe и #safe-end. Перед именами макросов при их вызове в безопасном синтаксисе ставится служебный символ '#', что позволяет обращаться к макросам явно. Директивы #safe и #safe-end должны обязательно идти в паре.

#define A 5

#safe

int a = #A; //нормальный вызов, #A раскроется в число 5 int b = A; //препроцессор пропустит это

#safe-end

Безопасный синтаксис решает проблему совпадения имен макросов с именами переменных или функций. Текст программы, не заключенный между вышеприведенными директивами, обрабатывается согласно синтаксису стандартного препроцессора С++. Можно принудительно заставить препроцессор перейти в стандартный синтаксис с помощью директив #unsafe и #unsafe-end. Вызовы макросов внутри других макросов осуществляются без префикса.

Введение в механизм макросов пространств имен позволяет решить проблему совпадения имен макросов между собой, а также больше их структурирует. Для объявления пространства имен используется директива CPP # name space. После этой директивы идет название пространства имен. Описание пространства имен заканчивается директивой #namespace-end, присутствие которой необходимо. Доступ к макросам, объявлен-

ным внутри пространства имен, осуществляется через префикс в виде названия этого пространства имен, после которого ставится диез, а затем само имя макроса.

#namespace math

#define sqr

#namespace-end

#safe

int a = #math#sqr(5); //присвоится 25 #safe-end

Внутри пространства имен доступ к макросам, в нем объявленным, идет напрямую, без префикса. Также CPP+ допускает вложенные пространства имен. Доступ к макросам вложенных пространств имен идет через присоединение соответствующего количества префиксов, разделенных символом диез (например: #math#geom#ANGLE) . Для подключения пространства имен используется директива #using, после которой через запятую перечисляются названия тех пространств имен, которые будут подключены. В области текста программы, где подключено пространство имен, доступ к его макросам осуществляется без префикса. Пространства и подпространства имен образуют древовидную структуру, подобную XML или обычным пространствам имен С++.

В CPP# добавлен механизм изоляции. Механизм изоляции основан на идеях, предложенных Б. Страуструпом в работе [4]. В области кода программы, заключенного между директивами препроцессора #scope и #scope-end, не видны макросы, которые объявлены вне этого блока. После того, как программист написал директиву #scope, он может быть уверен в том, что коллизий с макросами у него не будет. Макросы, объявленные внутри некоторого scope-блока, видны только внутри этого блока. Пример:

#safe

#define A 10 #define B 9

#scope //отключает все объявленные снаружи макросы int A = 7; //объявление целого числа A #define B 7 #define C 99

int x = #B; //x становится равным 7 #scope-end

int x = #A; //x становится равным 10 int y = #B; //y становится равным 9 int z = #C; //ошибка: C не определено

#safe-end

Предлагаемый в настоящей работе механизм изоляции предоставляет также различные способы взаимодействия пространств имен и блоков изоляции. Scope-блoки могут быть вложены друг в друга - это может применяться в крупных проектах. Иногда может быть необходимо получить доступ к некоторым объявленным снаружи макросам внутри scope-блoкa. Для этого в СРР# используется директива #import. По-

еле неё идет перечисление через запятую тех макросов или пространств имен, доступ к которым необходимо получить внутри scope-блока.

Расширение функциональных возможностей препроцессора

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

• механизм рекурсии;

• механизм вычислений на стадии обработки кода препроцессором;

• механизм специализации макросов;

• механизм перегрузки макросов;

• механизм передачи в макрос переменного числа параметров.

Механизм рекурсии. Рассмотрим пример вычисления факториала на стадии пре-процессинга:

#include <iostream>

#safe

#define FACTORIAL(num) $(num  FACTORIAL($(num - 1))) #define FACTORIAL(#(0)) 1 /* специализация */

int main() {

std::cout<<#FACTORIAL(5);

}

#safe-end

Первое расширение касается внесения в макросы возможности рекурсии. В теле макроса может использоваться тот же самый макрос. Выполняется рекурсивная макроподстановка. Рекурсия была бы бесконечной, но за счет специализации макросов имеется возможность сделать ее конечной (механизм специализации будет описан ниже). Рассмотрим, как работает рекурсивный механизм:

#define A 2 ## A

При вызове макроса без аргументов A препроцессор сначала раскрывает макрос A, находящийся в теле A. Процесс происходит до тех пор, пока не исчерпается стек препроцессора. A -> 2 ## A -> 2 ## 2 ## A -> 2 ## 2 ## 2 ## A -> ... количество итераций зависит от реализации препроцессора.

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

#safe

#define A

//специализация макроса A

#define A

printf("%d", #A(5)); //выдаст число из 5 двоек

#safe-end

Макровызов #A(10) работаетследующимобразом: 2 ## A($(10 - 1)) -> 2 ## A(9) -> 2 ## 2 ## A($(9 - 1)) ->...-> 2 ## ... ## 2 ## A(0) -> (вместо A(0) подставляется пустая строка, так как для значения аргумента равного нулянаписанаспециализация) витогеполучается 2 ## 2 ## ... ## 2. После этого выполняется конкатенация. В итоге получается 22.. .2.

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

#safe

#define A({x}) x + 1 #define B 2

int a = #A(#B) //B раскрывается раньше, чем A #safe-end

Рекурсия — очень мощный инструмент. Он позволяет реализовывать циклы. Как говорит Л. Питер Дойч, «Итерация - от человека, рекурсия - от Бога» [5].

Механизм вычислений на стадии обработки кода препроцессором. При реализации рекурсии возникает необходимость в целочисленных вычислениях. Необходимость в других вычислениях меньше, но также есть. Аргументом оператора $() может быть:

1. целочисленное выражение,

2. вещественное выражение,

3. булевское выражение,

4. выражение, содержащее строки.

Оператор $() выполняет вычисление выражения, которое является его аргументом, и подставляет результат. Если оператор $() сам является аргументом некоторого макроса ( #A($ (10-1) ) ) , то он раскрывается раньше, чем вызывается макрос. Аналогично, если у оператора $() в выражении встречается макрос, то он раскрывается прежде, чем начнется вычисление выражения.

В соответствующих выражениях допустимы лишь применимые к ним операции и операнды. Например, в целочисленных выражениях допустимы операции сложения, умножения, деления нацело, взятие остатка, унарный минус и логические операции. CPP# сам определяет, с каким типом выражения он имеет дело.

#define SUM

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

Механизм специализации макросов. Механизм специализации необходим для реализации рекурсии. Однако этот механизм может быть полезен и сам по себе. Синтаксически механизм специализации макросов похож на механизм специализации шаблонов. Основная идея состоит в том, чтобы вызовы макросов с определенными значе-

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

#define MACRO(x, y, z) $(x + y + z) #define MACRO(#(<=0), #(<=0), #(<=0)) 0

Если вызвать макрос MACRO со всеми численными аргументами, не превосходящими нуля, то будет вызвана специализация, и вместо макроса будет подставлено 0.

Опишем формальные правила, по которым выбирается специализация. Для численных и строковых аргументов допустимы такие специализации аргумента, как равенство и строгое или нестрогое неравенство. В случае строковых аргументов сравнение происходит согласно лексикографическому порядку. Для булевских аргументов допустима только специализация равенство. Если некоторый набор аргументов подошел под две различные специализации макроса, возникает вопрос о том, какую из специализаций выбрать.

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

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

3. Возможны также ситуации, когда нет признаков, по которым можно отдать предпочтение одной из специализаций. В этом случае препроцессор выдает сообщение об ошибке.

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

#define COMMA_IF(n) , #define COMMA_IF(#(0)) #define COMMA_IF(#(<0)) .

Сравнивать аргументы можно с выражениями, содержащими другие макросы. Иногда бывает полезно в специализации получить значение аргумента. Тогда обращение к нему можно выполнить через его номер. Нумерация аргументов начинается с нуля. Используется специальный оператор препроцессора CPP# диез. Например, #(0) является синонимом значения первого аргумента. Доступ к аргументам через их номера крайне полезен для макросов с переменным числом аргументов. Пример:

#define MACRO

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

Механизм перегрузки макросов. Еще один инструмент - это перегрузка макросов. Он позволяет писать разный код для макросов с одинаковым названием, но разным количеством аргументов. Пример:

#define PRINT

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

Механизм передачи в макросы переменного числа параметров. Синтаксис и семантика CPP# допускает написание макросов с переменным числом аргументов. При объявлении переменного числа аргументов вместо последнего аргумента такого макроса и исключительно только вместо последнего ставится $$. Перед $$ могут идти другие аргументы макроса. При вызове на месте $$ ставится любое число аргументов, в том числе их может и не быть. Если макрос с переменным количеством аргументов вызывает другой макрос или самого же себя рекурсивно, то вызванному макросу можно передать $$ как аргумент, и он будет совпадать с тем, что было передано исходному макросу при вызове. Если в вышеизложенном случае одним из аргументов, соответствующих $$, был другой макрос, то он раскроется перед новым вызовом. Макросы с переменным количеством аргументов могут быть полезны при работе с массивами на стадии обработки кода препроцессором. Служебное слово $$len возвращает количество аргументов, стоящих на месте $$. Доступ к аргументам осуществляется по номеру аргумента через оператор препроцессора $$(). $$firstn $$last обозначают соответственно первый и последний аргументы в перечислении. A $$tail отрезает у перечисления первый элемент и возвращает оставшуюся часть. Рассмотрим пример макроса, вычисляющего сумму некоторого количества чисел.

//Вспомогательные макросы

#define SUM_

#define SUM_(#(=0), $$) $$(0) #define SUM_(#(<0), $$) //Макрос, вычисляющий сумму

//Обратите внимание, что $$len возвращает количество //аргументов у макроса SUM_ на месте $$, анеу макроса //SUM #define SUM({$$}) SUM_($($$len - 1), $$) //Макрос, генерирующий арифметическую прогрессию #define SEQ(n) n, SEQ($(n - 1)) #define SEQ(#(=0)) 0

//Применение - вычисление суммы арифметической прогрессии #safe

int sum10 = #SUM(#SEQ(10)); #safe-end

Отметим, что раскрытие SEQ (10) происходит перед вызовом SUM_ и получившаяся последовательность подставляется на место $ $, поэтому $ $ l e n возвращает 10, а не 1.

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

Примеры использования

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

ровании на С++ требуется большое количество повторений похожего по форме кода. Рассмотрим структуру данных из работы [7]:

template <class T0, class T1, class T2> struct tiny_size : mpl::int_<3> {};

Помимо повторяющегося фрагмента в списке параметров вышеуказанного шаблона, для него в работе [7] написаны три специализации, которые имеют много общего:

template <class T0, class T1> struct tiny_size<T0, T1, none> : mpl::int_<2> {};

template <class T0> struct tiny_size<T0, none, none> : mpl::int_<1> {};

template <>

struct tiny_size<none, none, none> : mpl::int_<0> {};

Предпочтительно такой «механический» код не писать руками, а генерировать автоматически. Это позволит избежать ошибок и сделает код более гибким. Реализуем шаблон из предыдущего раздела для 4 параметров-типов. Сначала запишем само объявление:

#define TINY_MAX_SIZE 4

#define ENUM_PARAMS(num, T) ENUM_PARAMS(#(num - 1), T) \

,T ## $

#define ENUM_PARAMS

template <ENUM_PARAMS(TINY_MAX_SIZE, class T)> struct tiny_size : mpl::_<TINY_MAX_SIZE> {};

Макрос ENUM_PARAMS используется для генерации перечисления параметров. Например, ENUM_PARAMS(5, class T) раскроется в class T0, class T1, ..., class T4.

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

Первый макрос ENUM_PARAMS уже написан. Нам понадобится макрос пунктуации:

#define COMMA_IF

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

#define REPEAT

#define REPEAT

Этот макрос повторяет другой макрос ACTION num раз, причем в качестве аргумента для ACTION он подставляет номер текущей итерации. Теперь перейдем непосредственно к написанию макропрограммы.

#define TINY_print

#define TINY_size(n) \

template <ENUM_PARAMS(n, class T)> \

struct tiny_size< \

ENUM_PARAMS(n, T) \

COMMA IF(n) \

REPEAT($(TINY_MAX_SIZE - n), TINY_print); \ > \

: mpl::int_<n> {};

#safe

#REPEAT(#TINY_MAX_SIZE, #TINY_size) #safe-end

Заключение

В работе предложен ряд мер по усовершенствованию препроцессора языка C/C++. Для повышения безопасности использования препроцессора предложен явный синтаксис макроподстановки, пространства имен для макросов и блоки изоляции. Для повышения выразительной силы препроцессора предложены следующие усовершенствования:

• механизм рекурсии;

• механизм вычислений на стадии обработки кода препроцессором;

• механизм специализации макросов;

• механизм перегрузки макросов;

• механизм передачи в макрос переменного числа параметров.

Целями дальнейшей работы являются реализация препроцессора CPP#, а также разработка библиотеки макросов, аналогичной библиотеке Boost.Preprocessor.

Литература

1. http://caml.inria.fr/pub/docs/manual-camlp4/index.html

2. http://www.nothingisreal.com/gpp/gpp.html/

3. http://boost-consulting.com/tmpbook/preprocessor.html

4. http://www.open-std.org/

5. Страуструп Б. Язык программирования С++ . М.: БИНОМ, 2005. С. 203-205.

6. Ахо А., Сети Р., Ульман Дж. Компиляторы: принципы, технологии, инструменты. М., СПб, Киев: Вильяме, 2003. С. 22-37.

7. Abrahams D., Gurtovoy A. C++ Template Metaprogramming: Concepts, Tools, and Techniques from Boost and Beyond. Addison-Wesley Professional, 2004. Appendix A.

8. http://www.solarix.ru/