Не так давно Monnoroch опубликовал несколько прекрасных вступительных статей по языку D2, и это было хорошо. Но, прочитав последнюю статью, посвящённую метапрограммированию, захотелось сделать ещё лучше и раскрыть тему немножко подробнее. Дьявол, как известно, в деталях — и именно внимание к мелочам делает реализацию meta-парадигмы в D2 столь удобной. Если вы не читали статью Monnoroch, рекомендую вначале ознакомиться с ней, т.к. в рамках этой не хотелось бы тратить время на базовые вещи.
Итак, если вам уже знакомы некоторые возможности шаблонов в D2, я хотел бы подробнее рассказать о том, что сопутствует им — инструментах статической интроспекции, нюансах CTFE и даже такой запретной, но притягательной вещице, как mixin.
Цель — больше наглядных примеров кода с комментариями и меньше слов.
is — основной инструмент для получения булевого значения на этапе компиляции. Его название было выбрано, пожалуй, не слишком удачно и может ввести в заблуждение — на самом деле is отвечает за всевозможные сравнения и проверку корректности типов. Взгляните на эту небольшую программу:
Последний пример особенно интересен — is используется одновременно для того, чтобы
Впрочем, куда чаще вам доведётся увидеть is в контексте is(typeof(… )). Кстати о typeof:
typeof очень прост — принимает выражение как аргумент, возвращает его тип, если выражение семантически корректно. Если нет — выдаёт ошибку времени компиляции. Но в сочетании с is получается немного неочевидный эффект — выражение внутри typeof не компилируется как таковое, т.к. is лишь проверяет валидность типа, и все сообщения об ошибках подавляются. Получается устойчивая идиома D2 для проверки семантической корректности произвольного выражения на этапе компиляции. Чем-то подобным предполагались constraints, так и не попавшие в стандарт С++11.
Всё, что было сказано выше — весьма и весьма приятно, но если уж мы собираемся писать хорошие, красивые библиотеки с использованием template constraints, то хорошо бы иметь более обширный инструментарий для понимания того, чем является некий тип данных. И оно есть в целых двух экземплярах:
Traits — набор директив компилятору для инстроспекции типов. Вот несколько примеров:
std.traits — модуль стандартной библиотеки для аналогичных целей, написан с помощью ранее упомянутых инструментов. Некоторые утилитарные функции очень интересны, например, mangledName:
D2 умеет создавать кортежи как значений, так и типов, но нас, конечно же, прежде всего интересуют последние. Причина, по которой я упоминаю кортежи в контексте инструментов инстроспекции — специалная поддержка со стороны компилятора, позволяющая использовать кортежи, полученные с помощью того же std.traits, для дальнейшей компиляции.
Это может показаться не очень впечатляющим, но не стоит забывать, что над кортежем типов можно производить любые действия на compile-time, создавая разные сигнатуры для функций в разных условиях, удобно манипулируя функциями\шаблонами с переменным количеством аргументов и предаваясь прочим излишествам.
CTFE ( Compile-Time Field Evaluation ) — тема, которую очень просто описать вкратце и необъятная, если пытаться рассмотреть все нюансы. То, что некоторые функция \ выражения можно вычислять во время компиляции, не является новой концепцией и вполне знакомо тем же программистам на С++. Что необычно, так это то, насколько мало в D2 ограничений на выполнение CTFE функций. Вот описание из официальной документации:
Строго говоря, всё. Циклы, динамическое выделение памяти, работа со строками, ассоциативные массивы — всё это во время компиляции разрешено. Вот так, например, выглядит решение для типичной задачи по предрасчёту таблицы квадратных корней для заранее известного диапазона целых чисел:
Увы, некоторые ограничения действительно непрятны, особенно первое. Так, однажды, я был разочарован невозможностью преобразовать double в string средствами стандартной библиотеки — оказалось, что где-то в недрах оно залинковано на стандартную библиотеку C. Но, по мере работы над «оздоровлением» phobos и улучшением поддержки CTFE в компиляторе, ситуация улучшается — ещё не так давно ассоциативные массивы входили в запрещённый список. И это одна из областей развития D2, над которой работают прямо сейчас, если полистать коммиты на github.
Но как насчёт CTFE в контексте Самого Настоящего Метапрограммирования? Я ещё вернусь к ним, после того, как познакомлю вас с mixin.
Начну издалека, с несколько более приближенного к реальным условиях ( но всё равно надуманного ) примера. Предположим, у нас есть ряд классов, глубоко в своих приватных недрах хранящих ассоциативный массив и, дополнительно, запоминающих порядок добавления элементов. Есть задача — добавить всем этим классам интерфейс для итерации по элементам массива в оном порядке. Что можно сделать? Множественное наследование? Copy-paste? Наследование интерфейса и инкапусляция класса реализации? Эх, никакого представления об эстетике.
На помощь в D2 приходят template mixin — средство для «умного» copy-paste. Шаблоны для подстановки объявляются через ключевые слова mixin template (внезапно!) и выглядят похожими на любой обычный шаблон D2. Интересное начинается, когда подобный шаблон инстанциируется директивой mixin TemplateName!(Parameters) — получившийся в результате код подставляется в контекст, где был применён mixin.
Перед вами несколько более длинный кусок кода, уже куда более похожий на то, что может быть написано в реальном проекте:
Такой подход может показаться несколько монструозным, но задумайтесь — этот код объявляет шаблон, готовый к использованию через mixin в любом из ваших классов, где есть контейнеры с подходящими свойствами. При этом идёт проверка того, что требуемые от класса условия действительно выполняются — и всё это исключительно на compile-time! Любые изменения, касающиеся реализации forward range доступа к вашим классам, затронут лишь это место, а иерархия основной архитектуры останется без ненужных изменений.
И, наконец, мы добрались до одной из самых мощных и неоднозначных фич метапрограммирования D2. Заходя на эту территорию вы лишаетесь большинства удобных сообщений об ошибках, предоставляемых системой типов, и становитесь на шаг ближе к аду макросов С — но и возможности ваши становятся практически безграничны.
string mixin работает в чём-то похоже на eval из скриптовых языков, синтаксис его очень прост:
«some string here» подставится вместо mixin и будет скомпилировано. В данном случае, конечно, не будет, так как едва ли «some string here» окажется валидным программным кодом, но ничто не мешает использовать более полезную строку.
… например ту, что возвращает некая CTFE-функция. Фактически, вы имеете возможность определить DSL и, написав соответствующий CTFE транслятор в код на D2, использовать этот DSL в виде строк прямо в модулях D2. Хороший пример — модуль стандартной библиотеки std.range. В одном из режимов он умеет генерировать код парсеров регулярных выражений на этапе компиляции, основываясь на строке регулярки, заданном в привычном виде. Именно для авторов библиотек, на мой взгляд, эта возможность D2 и представляет наибольший практический интерес.
Чтобы не загромождать и без того разросшуюся статью, в качестве наглядного примера приведу ссылку на забавный трюк некоего Daniel Keep: www.prowiki.org/wiki4d/wiki.cgi?DanielKeep/shfmt
Daniel тосковал по возможности в стиле PHP сделать вот так:
… и реализовал это с помощью string mixin в D :) Предупреждаю — листинг по ссылке опасен для глаз, и это, к сожалению, пока что неизбежная цена за большие возможности.
Если вам доводилось использовать когда-либо использовать модуль algorithm из стандартной библиотеки Phobos, то удобный короткий формат записи лямбд для map, reduce & co в конечном итоге тоже работает благодаря волшебным string mixin.
Изначально мне хотелось куда подробнее остановится на каждой возможности и разобрать разные удачные и нежелательные способы применения. Но по мере написания оказалось, что размер статьи растёт просто неприличными темпами для чего-либо, столь обильно сдобренного примерами кода. В итоге я попытался просто дать как можно более широкое представление о всём наборе инструментов, которые D2 предоставляет для метапрограммирования. Если вы хотя бы раз подумали «Вау, а ведь с помощью этого можно сделать <фича моей мечты>», значит цель была выполнена.
Зачастую новички в D2 просто теряются от обилия возможностей сделать что-либо на этапе компиляции и возможности эти требуют изрядной дисциплины, чтобы быть полезными и не превратить ваш код в кашу. Так что постарайтесь соблюдать умеренность в архитектурных соблазнах. Приятного аппетита :)
Об опечатках и стилистических ляпах просьба сообщать в приват.
Все примеры были проверены на dmd версии 2.057
Итак, если вам уже знакомы некоторые возможности шаблонов в D2, я хотел бы подробнее рассказать о том, что сопутствует им — инструментах статической интроспекции, нюансах CTFE и даже такой запретной, но притягательной вещице, как mixin.
Цель — больше наглядных примеров кода с комментариями и меньше слов.
Инструменты интроспекции
is expression
is — основной инструмент для получения булевого значения на этапе компиляции. Его название было выбрано, пожалуй, не слишком удачно и может ввести в заблуждение — на самом деле is отвечает за всевозможные сравнения и проверку корректности типов. Взгляните на эту небольшую программу:
void main()
{
// семантическая корректность типа данных
assert( !is(garbage[id]) );
assert( !is(x) );
alias int x;
assert( is(x) );
// проверка неявного преобразования типов
assert( is(x : long) );
assert( !is( x : string ) );
// проверка точного совпадения типов
assert( is( x == int) );
assert( !is( x == long) );
// проверка неявного преобразования + pattern matching + alias declaration
alias long[char[]] AA;
static if( is( AA T : T[U], U : const char[]) )
alias T key;
else
alias void key;
assert( is(key == long) );
}
Последний пример особенно интересен — is используется одновременно для того, чтобы
- проверить приводимость типа к ассоциативному контейнеру, хранящему const char[]
- по pattern вычленить из типа контейнера тип ключа
- создать alias для вложенной области видимости
Впрочем, куда чаще вам доведётся увидеть is в контексте is(typeof(… )). Кстати о typeof:
typeof expression
typeof очень прост — принимает выражение как аргумент, возвращает его тип, если выражение семантически корректно. Если нет — выдаёт ошибку времени компиляции. Но в сочетании с is получается немного неочевидный эффект — выражение внутри typeof не компилируется как таковое, т.к. is лишь проверяет валидность типа, и все сообщения об ошибках подавляются. Получается устойчивая идиома D2 для проверки семантической корректности произвольного выражения на этапе компиляции. Чем-то подобным предполагались constraints, так и не попавшие в стандарт С++11.
int func();
void main()
{
// типичный пример, получаем возвращаемый тип функции
alias typeof(func()) ret_type;
assert( is( ret_type == int ) );
double func_prim()
{
// специальный случай, корректен только в области видимости функции
assert( is( typeof(return) == double ) );
return 0;
}
// пример комбинации с is для создания constraint "для типа должен быть определён оператор <"
void template_func(T)( T t )
if ( is(typeof( T.init < T.init )) )
{
}
template_func(20);
// struct S {}
// template_func(S.init); // error
}
traits / std.traits
Всё, что было сказано выше — весьма и весьма приятно, но если уж мы собираемся писать хорошие, красивые библиотеки с использованием template constraints, то хорошо бы иметь более обширный инструментарий для понимания того, чем является некий тип данных. И оно есть в целых двух экземплярах:
Traits — набор директив компилятору для инстроспекции типов. Вот несколько примеров:
abstract class C { }
class B { int a; }
void main()
{
assert( __traits(isAbstractClass, C ) );
assert(! __traits(isAbstractClass, B ) );
assert(! __traits(isAbstractClass, int ) );
assert( __traits(hasMember, B, "a") );
assert( !__traits(hasMember, C, "a") );
// offtopic: обратите внимание на трюк с [ ] - allMembers возвращает кортеж строк, который таким элегантным образом используется для создания массива
auto a = [ __traits(allMembers, B) ];
assert( a == ["a", "toString", "toHash", "opCmp", "opEquals", "Monitor", "factory"] );
}
std.traits — модуль стандартной библиотеки для аналогичных целей, написан с помощью ранее упомянутых инструментов. Некоторые утилитарные функции очень интересны, например, mangledName:
import std.traits;
import std.stdio;
void func1();
extern ( C ) void func2();
void main()
{
// _D9stdtraits5func1FZv
writeln( mangledName!(func1) );
// func2
writeln( mangledName!(func2) );
}
type tuples
D2 умеет создавать кортежи как значений, так и типов, но нас, конечно же, прежде всего интересуют последние. Причина, по которой я упоминаю кортежи в контексте инструментов инстроспекции — специалная поддержка со стороны компилятора, позволяющая использовать кортежи, полученные с помощью того же std.traits, для дальнейшей компиляции.
import std.typetuple;
import std.traits;
import std.stdio;
// Объявляем функцию привычным образом
void func1( int, double, string )
{
}
// Объявляем кортеж из трёх типов "от руки"
alias TypeTuple!( int, double, string ) tp_same;
// Фокус, функция с такой же сигнатурой, что и func1!
void func2( tp_same tp )
{
assert( is( typeof(tp[0]) == int ) );
assert( is( typeof(tp[1]) == double ) );
assert( is( typeof(tp[2]) == string ) );
}
// Зачем писать кортежи руками, когда можно скопировать с образца?
alias ParameterTypeTuple!(func1) tp_func1_copy;
// ...и третья функция с такой же сигнатурой.
void func3( tp_func1_copy tp )
{
foreach( i; tp)
{
writeln(typeid( typeof(i) ), " ", i);
}
// вывод:
// int 2
// double 2
// immutable(char)[] 2
}
void main()
{
// компилятор умеет разворачивать кортежи типов и наоборот
func1( 2, 2.0, "2");
func2( 2, 2.0, "2");
func3( 2, 2.0, "2");
// одинаковые!
assert( is( typeof(func1) == typeof(func2) ) );
assert( is( typeof(func2) == typeof(func3) ) );
}
Это может показаться не очень впечатляющим, но не стоит забывать, что над кортежем типов можно производить любые действия на compile-time, создавая разные сигнатуры для функций в разных условиях, удобно манипулируя функциями\шаблонами с переменным количеством аргументов и предаваясь прочим излишествам.
CTFE Unleashed
CTFE ( Compile-Time Field Evaluation ) — тема, которую очень просто описать вкратце и необъятная, если пытаться рассмотреть все нюансы. То, что некоторые функция \ выражения можно вычислять во время компиляции, не является новой концепцией и вполне знакомо тем же программистам на С++. Что необычно, так это то, насколько мало в D2 ограничений на выполнение CTFE функций. Вот описание из официальной документации:
- Должны иметь полностью доступный компилятору исходный код ( никаких extern )
- Не должны ссылаться на глобальный или статические переменные
- Не должны использовать inline asm
- Не-портируемые преобразования типов ( например, int[] в float[] ), включая преобразования, зависящие от порядка байтов, запрещены. Преобразования между знаковыми и беззнаковыми числами разрешены. Преобразования указателей в данные и наоборот — запрещены
- Действия с указателями разрешены только для указателей на элеументы массивов
- assert и ему подобные не создают exception, а просто прекращают интерпретацию
Строго говоря, всё. Циклы, динамическое выделение памяти, работа со строками, ассоциативные массивы — всё это во время компиляции разрешено. Вот так, например, выглядит решение для типичной задачи по предрасчёту таблицы квадратных корней для заранее известного диапазона целых чисел:
import std.math;
enum PrecomputedSqrtIndex
{
Min = 1,
Max = 10
}
// pure, nothrow и in тут, конечно, не обязательны, но нужно же привыкать к правилам хорошего тона :)
pure nothrow double[int] precompute_sqrt_lookup( in int from, in int to )
{
double[int] result;
foreach( i; from..to+1 )
result[i] = sqrt(i);
return result;
}
enum sqrt_lookup = precompute_sqrt_lookup( PrecomputedSqrtIndex.Min, PrecomputedSqrtIndex.Max );
void main()
{
}
Увы, некоторые ограничения действительно непрятны, особенно первое. Так, однажды, я был разочарован невозможностью преобразовать double в string средствами стандартной библиотеки — оказалось, что где-то в недрах оно залинковано на стандартную библиотеку C. Но, по мере работы над «оздоровлением» phobos и улучшением поддержки CTFE в компиляторе, ситуация улучшается — ещё не так давно ассоциативные массивы входили в запрещённый список. И это одна из областей развития D2, над которой работают прямо сейчас, если полистать коммиты на github.
Но как насчёт CTFE в контексте Самого Настоящего Метапрограммирования? Я ещё вернусь к ним, после того, как познакомлю вас с mixin.
Mixin
Template Mixin
Начну издалека, с несколько более приближенного к реальным условиях ( но всё равно надуманного ) примера. Предположим, у нас есть ряд классов, глубоко в своих приватных недрах хранящих ассоциативный массив и, дополнительно, запоминающих порядок добавления элементов. Есть задача — добавить всем этим классам интерфейс для итерации по элементам массива в оном порядке. Что можно сделать? Множественное наследование? Copy-paste? Наследование интерфейса и инкапусляция класса реализации? Эх, никакого представления об эстетике.
На помощь в D2 приходят template mixin — средство для «умного» copy-paste. Шаблоны для подстановки объявляются через ключевые слова mixin template (внезапно!) и выглядят похожими на любой обычный шаблон D2. Интересное начинается, когда подобный шаблон инстанциируется директивой mixin TemplateName!(Parameters) — получившийся в результате код подставляется в контекст, где был применён mixin.
Перед вами несколько более длинный кусок кода, уже куда более похожий на то, что может быть написано в реальном проекте:
import std.range : isForwardRange;
import std.stdio : writeln;
import std.typecons : Tuple;
// "магический" mixin, который будет использоваться всеми классами
mixin template AddForwardRangeMethods( alias data_container, alias order_container )
// удостоверимся, что мы можем проверить длинну data_container
if ( is(typeof( data_container.length ) : int ) &&
// ...и что order_container действительно применим для разиндексирования data_container
is(typeof( data_container[ order_container[0] ] ))
// скорее всего я что-то забыл, верим в отсутствующие здесь юнит-тесты :)
)
{
// т.к. мы можем пожелать итерировать по одному и тому же объекту несколько раз параллельно, то
// данные по состоянию лучше вынести в отдельную утилитарную структуру.
private struct Result
{
// подготавливаем вспомогательные переменные и типы данных
private int last_index;
// все нативные массивы - ссылочные типы данных, так что никаких
// дополнительных указателей не нужно
typeof(data_container) ref_data;
typeof(order_container) ref_order;
alias typeof(order_container[0]) Key_t;
static if ( is( typeof(data_container) T : T[U], U : Key_t ) )
{
alias T Value_t;
}
else
static assert(0, "Wrong data_container / order_container data_container types" );
this(typeof(data_container) data, typeof(order_container) order)
{
last_index = 0;
ref_data = data;
ref_order = order;
}
// задаем методы, определяюшие forward range
bool empty()
{
return last_index >= ref_data.length;
}
Tuple!(Key_t, Value_t) popFront()
{
// знакомство с директивой scope вне темы метапрограммирования, но, надеюсь
// тут и так понятен принцип её действия
scope (exit)
last_index++;
return front();
}
Tuple!(Key_t, Value_t) front()
{
return typeof(return)( ref_order[last_index], ref_data[ref_order[last_index]] );
}
Result save()
{
return Result( ref_data, ref_order );
}
}
public Result fwdRange()
{
return Result( data_container, order_container );
}
}
// пример класса, использующего mixin
class A
{
private int[int] a;
private int[] order;
this()
{
a = [ 2 : 4, 4 : 16, 3 : 9 ];
order = [ 2, 4, 3 ];
}
// вот так просто!
mixin AddForwardRangeMethods!(a, order);
}
void main()
{
// стандартная библиотека подтверждает - это самый настоящий forward range, спасибо duck typing
assert(isForwardRange!(A.Result));
auto a = new A();
auto r = a.fwdRange;
foreach( i; r )
writeln( i );
// Tuple!(int,int)(2, 4)
// Tuple!(int,int)(4, 16)
// Tuple!(int,int)(3, 9)
}
Такой подход может показаться несколько монструозным, но задумайтесь — этот код объявляет шаблон, готовый к использованию через mixin в любом из ваших классов, где есть контейнеры с подходящими свойствами. При этом идёт проверка того, что требуемые от класса условия действительно выполняются — и всё это исключительно на compile-time! Любые изменения, касающиеся реализации forward range доступа к вашим классам, затронут лишь это место, а иерархия основной архитектуры останется без ненужных изменений.
String Mixin
И, наконец, мы добрались до одной из самых мощных и неоднозначных фич метапрограммирования D2. Заходя на эту территорию вы лишаетесь большинства удобных сообщений об ошибках, предоставляемых системой типов, и становитесь на шаг ближе к аду макросов С — но и возможности ваши становятся практически безграничны.
string mixin работает в чём-то похоже на eval из скриптовых языков, синтаксис его очень прост:
mixin("some string here")
«some string here» подставится вместо mixin и будет скомпилировано. В данном случае, конечно, не будет, так как едва ли «some string here» окажется валидным программным кодом, но ничто не мешает использовать более полезную строку.
… например ту, что возвращает некая CTFE-функция. Фактически, вы имеете возможность определить DSL и, написав соответствующий CTFE транслятор в код на D2, использовать этот DSL в виде строк прямо в модулях D2. Хороший пример — модуль стандартной библиотеки std.range. В одном из режимов он умеет генерировать код парсеров регулярных выражений на этапе компиляции, основываясь на строке регулярки, заданном в привычном виде. Именно для авторов библиотек, на мой взгляд, эта возможность D2 и представляет наибольший практический интерес.
Чтобы не загромождать и без того разросшуюся статью, в качестве наглядного примера приведу ссылку на забавный трюк некоего Daniel Keep: www.prowiki.org/wiki4d/wiki.cgi?DanielKeep/shfmt
Daniel тосковал по возможности в стиле PHP сделать вот так:
int a = 3;
writeln("a = $a");
… и реализовал это с помощью string mixin в D :) Предупреждаю — листинг по ссылке опасен для глаз, и это, к сожалению, пока что неизбежная цена за большие возможности.
Если вам доводилось использовать когда-либо использовать модуль algorithm из стандартной библиотеки Phobos, то удобный короткий формат записи лямбд для map, reduce & co в конечном итоге тоже работает благодаря волшебным string mixin.
Вместо эпилога
Изначально мне хотелось куда подробнее остановится на каждой возможности и разобрать разные удачные и нежелательные способы применения. Но по мере написания оказалось, что размер статьи растёт просто неприличными темпами для чего-либо, столь обильно сдобренного примерами кода. В итоге я попытался просто дать как можно более широкое представление о всём наборе инструментов, которые D2 предоставляет для метапрограммирования. Если вы хотя бы раз подумали «Вау, а ведь с помощью этого можно сделать <фича моей мечты>», значит цель была выполнена.
Зачастую новички в D2 просто теряются от обилия возможностей сделать что-либо на этапе компиляции и возможности эти требуют изрядной дисциплины, чтобы быть полезными и не превратить ваш код в кашу. Так что постарайтесь соблюдать умеренность в архитектурных соблазнах. Приятного аппетита :)
Об опечатках и стилистических ляпах просьба сообщать в приват.
Все примеры были проверены на dmd версии 2.057