Pull to refresh

Язык D2 и метапрограммирование: всё страньше и страньше

Reading time9 min
Views3.8K
Не так давно Monnoroch опубликовал несколько прекрасных вступительных статей по языку D2, и это было хорошо. Но, прочитав последнюю статью, посвящённую метапрограммированию, захотелось сделать ещё лучше и раскрыть тему немножко подробнее. Дьявол, как известно, в деталях — и именно внимание к мелочам делает реализацию meta-парадигмы в D2 столь удобной. Если вы не читали статью Monnoroch, рекомендую вначале ознакомиться с ней, т.к. в рамках этой не хотелось бы тратить время на базовые вещи.

Итак, если вам уже знакомы некоторые возможности шаблонов в 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
Tags:
Hubs:
+35
Comments38

Articles

Change theme settings