Pull to refresh

Compile-time рефлексия D, практика

Reading time 11 min
Views 5.2K
Доброго времени суток, хабр!

В прошлой статье были рассмотренны базовые элементы compile-time рефлексии, те кирпичики, из которых строят «настоящие» метаконструкции. В этой статье я хочу показать некоторые такие приёмы. Попробуем реализовать сигналы и слоты, похожие на те, что в Qt, будет примерно так:
class Foo : XObject
{
    @​signal
    void message( string str );
}
class Bar : XObject
{
    @​slot
    void print( string str ) { writefln( "Bar.print: %s", str ); }
}
void main()
{
    auto a = new Foo, b = new Bar;
    connect( a.message, b.print );
    a.message( "hello habr" ); // Bar.print: hello habr
}

Осторожно: много кода (с комментами).

Примерно, но не так =) Но по большему счёту не хуже, на всё есть свои причины, мы о них поговорим. Конечный вариант:
class Foo : XObject
{
    mixin MixX; // нам нужно вставлять некоторый код, без mixin не обойтись
    @​signal
    void _message( string str ) {} // досадное правило, см ниже
}
class Bar : XObject
{
    mixin MixX;
     // не вижу смысла в атрибуте slot, так как это по сути просто любой метод
    void print( string str ) { writefln( "Bar.print: %s", str ); }
}
void main()
{
    auto a = new Foo, b = new Bar;
    connect( a.signal_message, &b.print ); // об этом позже
    a.message( "hello habr" ); // Bar.print: hello habr
}

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

Теперь начнём по порядку. Первым делом нужно осознать, что подход с массивом делегатов «не очень». Конечно всё сильно зависит от задачи. В нашем случае будем считать, что есть несколько небольших требований:
  1. любой объект может быть валидным и нет
  2. можно перевести объект в невалидное состояние (после создания он валиден)
  3. у объекта могут быть дочерние объекты
  4. если родитель перестаёт быть валидным дочерние тоже перестают таковыми быть
  5. вызов слотов не валидного объекта производиться не должен (не будет иметь смысла)

По логике дочерние объекты целиком и полностью принадлежат родителю.
В D объекты классов управляются сборщиком, вызов деструктора происходит при сборке мусора либо с помощью функции destroy(obj). Так же есть один момент: управлять памятью при сборке мусора нельзя. Из-за этого мы не можем убрать из какого-либо списка уничтожаемый объект, да и сборщик сам не будет ничего делать пока объект в таком списке. Рассматривая начальные требования и мысль о сборщике приходим к выводу, что нужна концепция ContextHandler. Это будет наш базовый интерфейс.
Не полный, но достаточный для понимания, код ContextHandler
interface ContextHandler
{
protected:
    void selfDestroyCtx(); // девалидация самого объекта
public:

    @​property
    {
        ContextHandler parentCH(); // указатель на родителя
        ContextHandler[] childCH(); // список дочерних
    }

    final
    {
        T registerCH(T)( T obj, bool force=true ) // можно зарегистрировать объект как дочерний
            if( is( T == class ) )
        {
            if( auto ch = cast(ContextHandler)obj )
                if( force || ( !force && ch.parentCH is null ) ) // force - даже при наличии родителя у obj сменять на себя
                    ...
            return obj;
        }

        T newCH(T,Args...)( Args args ) { return registerCH( new T(args) ); } // либо сразу создать

        void destroyCtx() // ради этого метода всё и затеяно
        {
            foreach( c; childCH ) // делаем не валидными дочерние объекты
                c.destroyCtx();
            selfDestroyCtx(); // потом себя
        }
    }
}

По сути это дерево. При девалидации объекта, он делает то же самое с дочерними. Вернёмся к нему позже.

Следующие концепции относятся к понятию «слот». Хоть мы и не создали для слотов отдельный UDA, создать как таковой слот имеет смысл.
interface SignalConnector // безшаблонный слот
{
    void disconnect( SlotContext );
    void disonnectAll();
}

class SlotContext : ContextHandler // каждый слот имеет тот самый контекст, который может стать невалидным
{
    mixin MixContextHandler; // ContextHandler имеет mixin template для простой его реализации
protected:
    size_t[SignalConnector] signals; // все сигналы, с которыми соединён слот
public:
    void connect( SignalConnector sc ) { signals[sc]++; }
    void disconnect( SignalConnector sc )
    {
        if( sc in signals )
        {
            if( signals[sc] > 0 ) signals[sc]--;
            else signals.remove(sc);
        }
    }
protected:
    void selfDestroyCtx() // при разрушении контекста разъединяем все соединённые сигналы
    {
        foreach( sig, count; signals )
            sig.disconnect(this);
    }
}

// просто для удобства
interface SlotHandler { SlotContext slotContext() @property; }

class Slot(Args...) // как таковой слот
{
protected:
    Func func; // функция
    SlotContext ctrl; // контекст
public:
    alias Func = void delegate(Args);
    this( SlotContext ctrl, Func func ) { this.ctrl = ctrl; this.func = func; }
    this( SlotHandler hndl, Func func ) { this( hndl.slotContext, func ); }
    void opCall( Args args ) { func( args ); }
    SlotContext context() @​property { return ctrl; }
}

Сразу рассмотрим сигнал
class Signal(Args...) : SignalConnector, ContextHandler
{
    mixin MixContextHandler;
protected:
    alias TSlot = Slot!Args;
    TSlot[] slots; // всё соединённые слоты
public:
    TSlot connect( TSlot s )
    {
        if( !connected(s) )
        {
            slots ~= s;
            s.context.connect(this);
        }
        return s;
    }
    void disconnect( TSlot s ) // можно разъединить
    {
        slots = slots.filter!(a=>a !is s).array;
        s.context.disconnect(this);
    }
    void disconnect( SlotContext sc ) // даже сразу весь контекст
    {
        foreach( s; slots.map!(a=>a.context).filter!(a=> a is sc) )
            s.disconnect(this);
        slots = slots
            .map!(a=>tuple(a,a.context))
            .filter!(a=> a[1] !is sc)
            .map!(a=>a[0])
            .array;
    }
    void disconnect( SlotHandler sh ) { disconnect( sh.slotContext ); }
    void disonnectAll() // или сразу все слоты
    {
        slots = [];
        foreach( s; slots ) s.context.disconnect( this );
    }
    // вызов сигнала ведёт за собой вызов всех слотов
    void opCall( Args args ) { foreach( s; slots ) s(args); }
protected:
    bool connected( TSlot s ) { return canFind(slots,s); }
    void selfDestroyCtx() { disonnectAll(); } // так же разъединяем все связи при разрушении
}

И, наконец, мы подобрались к самому интересному: интерфейсу XBase и промежуточному классу XObject (вставляется MixX и создаётся конструктор по умолчанию). Интерфейс XBase расширяет ContextHandler всего парой функций, самое важное это mixin template MixX. В нём как раз и происходит вся магия метапрограммирования. Сначала следует объяснить логику всех действий. UDA @​signal помечает функции, которые должны стать основой для создания настоящих сигнальных функций и самих объектов сигналов. От помеченных функций берётся почти всё: имя (без начального нижнего подчёркивания), уровень доступа (public, protected) и, конечно же, аргументы. Из атрибутов разрешён только @ system, так как мы хотим, чтобы сигналы могли работать с любыми слотами. Настоящая функция-сигнал вызывает opCall соответствующего сигнального объекта, передавая все агрументы. Чтобы не создавать все сигнальные объекты в каждом новом классе, мы реализуем в MixX функцию, которая это делает за нас. Зачем создавать отдельно функцию-сигнал и сигнальный объект? Для того, чтобы сигнал был функцие, как ни странно. Это позволит реализовывать интерфейсы в класссе, наследующем XObject или реализующим XBase, а так же соединять сигналы с вызовом других сигналов:
    interface Messager { void onMessage( string ); }
    class Drawable { abstract void onDraw(); } // сигнальными могут стать только абстрактные методы
    class A : Drawable, XBase
    {
        mixin MixX;
        this() { prepareXBase(); } // создаём всё необходимое
        @​signal void _onDraw() {}
    }
    class B : A, Messager
    {
        mixin MixX;
        @​signal void _onMessage( string msg ) {}
    }
    class Printer : XObject
    {
        mixin MixX;
        void print( string msg ) { }
    }

    auto a = new B;
    auto b = new B;
    auto p = new Printer;

    connect( a.signal_onMessage, &b.onMessage ); // соединяем сигнал с сигналом
    connect( &p.print, b.signal_onMessage ); // функцию connect разберём в самом конце
...

Вернёмся к XBase. Будем разбирать код по частям:
interface XBase : SlotHandler, ContextHandler
{
public:
    enum signal; // не существующие идентификаторы нельзя использовать в UDA, поэтому объявим просто enum
protected:
    void createSlotContext();
    void createSignals();

    final void prepareXBase() // эта функция должна вызываться в конструкторе класса, реализующего XBase
    {
        createSlotContext();
        createSignals();
    }

    // XBase расширяет и SlotHandler, по этому может быть основой для создания слотов
    final auto newSlot(Args...)( void delegate(Args) f ) { return newCH!(Slot!Args)( this, f ); }

    // можно сразу соединить делегат с сигналом, возлагая ответственность на объект, у которого был вызван этот метод
    final auto connect(Args...)( Signal!Args sig, void delegate(Args) f )
    {
        auto ret = newSlot!Args(f);
        sig.connect( ret );
        return ret;
    }

    mixin template MixX()
    {
        import std.traits;

        // воспользуемся приёмом из С++, так как mixin template не модуль, можно и конфликты словить
        static if( !is(typeof(X_BASE_IMPL)) )
        {
            enum X_BASE_IMPL = true;

            mixin MixContextHandler; // вставляем реализацию ContextHandler

            // реализуем SlotHandler
            private SlotContext __slot_context;

            final
            {
                public SlotContext slotContext() @​property { return __slot_context; }
                protected void createSlotContext() { __slot_context = newCH!SlotContext; }
            }
        }

        // а этот код уже будет вставляться каждый раз

        mixin defineSignals; // здесь собираются все сигнальные функции и объекты

        override protected
        {
            // если createSignal ещё абстрактная функция, значит этот код вставляется впервый раз
            static if( isAbstractFunction!createSignals )
                void createSignals() { mixin( mix.createSignalsMixinString!(typeof(this)) ); }
            else // иначе, мы должны в ней вызвать createSignals для базового класса
                void createSignals()
                {
                    super.createSignals();
                     // mix.createSignalsMixinString собирает все сигналы из типа и возвращает строку, в которой эти сигналы уже создаются
                    mixin( mix.createSignalsMixinString!(typeof(this)) );
                }
        }
    }
...
}

Стоит сразу оговориться, что mix это структура, в которой сконцентрированы все методы работы со строками. Возможно это не самое удачное решение, но оно позволяет сократить объём имён, попадаемых в конечный класс, при этом содержать всё в нужном месте (в интерфейсе XBase). И раз уж заговорили, рассмотрим эту структуру.
    static struct __MixHelper
    {
        import std.algorithm, std.array;
        enum NAME_RULE = "must starts with '_'";
    static pure @​safe:
        // имена шаблонов для сигналов могут начинаться только с нижнего подчёркивания
        bool testName( string s ) { return s[0] == '_'; }
        string getMixName( string s ) { return s[1..$]; }
        
        // в этой функции происходит формирование строк, создающих сигнальный объект и функцию-сигнал
        string signalMixinString(T,alias temp)() @​property
        {
            ...
        }

        // имена сигнальных объектов начинаются с такого префикса
        enum signal_prefix = "signal_";
        
        // формирование строки для миксина в createSignals
        string createSignalsMixinString(T)() @​property
        {
            auto signals = [ __traits(derivedMembers,T) ]
                .filter!(a=>a.startsWith(signal_prefix)); // отбираем только те имена, которые начинаются на нужный нам префикс

            /+ если вы используете префикс signal_ в своём классе для других объектов
             + Вам следует профильтровать список ещё раз с проверкой на тип
             +/

            return signals
                .map!(a=>format("%1$s = newCH!(typeof(%1$s));",a)) // signal_onSomething = newCH!(typeof(signal_onSomething);
                .join("\n");
            // при создании сигналов, они добавляются как дочерние к объекту
        }

        // служебная функция для вывода ошибок
        template functionFmt(alias fun) if( isSomeFunction!fun )
        {
            enum functionFmt = format( "%s %s%s",
                    (ReturnType!fun).stringof, // берём возвращаемый тип функции
                    __traits(identifier,fun), // её имя
                    (ParameterTypeTuple!fun).stringof ); // и список параметров
        }
    }

    protected enum mix = __MixHelper.init;

Вернёмся к MixX, в нём самым сложным будет непреметный mixin defineSignals.
    // в нём мы получаем все функции с атрибутом @​signal и передаём в defineSignalsImpl
    mixin template defineSignals() { mixin defineSignalsImpl!( typeof(this), getFunctionsWithAttrib!( typeof(this), signal ) ); }

    // немного функциональщины, но иначе такой список не обработать (список функций как таковых, а не имён)
    mixin template defineSignalsImpl(T,list...)
    {
        static if( list.length == 0 ) {} // когда пусто
        else static if( list.length > 1 )
        {
            // "разделяй и властвуй"
            mixin defineSignalsImpl!(T,list[0..$/2]);
            mixin defineSignalsImpl!(T,list[$/2..$]);
        }
        else mixin( mix.signalMixinString!(T,list[0]) ); // вставляем строки, объявляющие сигнальные функцию и объект
    }

Шаблон getFunctionsWithAttrib и mix.signalMixinString примерно равносильны по сложности, но сначала рассмотрим mix.signalMixinString, так как при рассказе про __MixHelper я её вырезал:
        string signalMixinString(T,alias temp)() @​property
        {
            enum temp_name = __traits(identifier,temp); // получаем имя функции-шаблона для сигнала
            enum func_name = mix.getMixName( temp_name ); // получаем имя уже сигнальной функции

            // для функций-шаблонов разрешён только атрибут @​system
            enum temp_attribs = sort([__traits(getFunctionAttributes,temp)]).array;
            static assert( temp_attribs == ["@​system"],
                    format( "fail Mix X for '%s': template signal function allows only @​system attrib", T.stringof ) );

            // нужно проверить, не объявлена ли функция с таким же именем
            static if( __traits(hasMember,T,func_name) )
            {
                alias base = AT!(__traits(getMember,T,func_name)); // рассмотрим её ближе

                // она должна быть абстрактной
                static assert( isAbstractFunction!base,
                        format( "fail Mix X for '%s': target signal function '%s' must be abstract in base class",
                            T.stringof, func_name ) );

                // и так же может иметь только атрибут @​system
                enum base_attribs = sort([__traits(getFunctionAttributes,base)]).array;
                static assert( temp_attribs == ["@system"],
                        format( "fail Mix X for '%s': target signal function allows only @system attrib", T.stringof ) );

                enum need_override = true;
            }
            else enum need_override = false;

            enum signal_name = signal_prefix ~ func_name;

            // помимо объявлений сигналов ещё создаётся alias на кортеж типов параметров сигнала, так проще потом вызывать сигнал
            enum args_define = format( "alias %sArgs = ParameterTypeTuple!%s;", func_name, temp_name );

            enum temp_protection = __traits(getProtection,temp); 

            // формируем объявление сигнального объекта с той же доступностью, что и функция-шаблон
            enum signal_define = format( "%s Signal!(%sArgs) %s;", temp_protection, func_name, signal_name );
            
            // формируем объявление сигнальной функции сразу с телом, в нём вызываем opCall сигнального объекта
            enum func_impl = format( "final %1$s %2$s void %3$s(%3$sArgs args) { %4$s(args); }",
                    (need_override ? "override" : ""), temp_protection, func_name, signal_name );

            // не знаю зачем (всё равно результат никто не увидит), но форматируем в несколько строк
            return [args_define, signal_define, func_impl].join("\n");
        }

Вернёмся к получению списка помеченных функций.
    template getFunctionsWithAttrib(T, Attr)
    {
        // <b>ВАЖНО</b>: мы берём только те поля и методы, что объявлены конкретно в классе T
        // как раз по этому нам нужно вызывать создание сигналов базового объекта
        alias getFunctionsWithAttrib = impl!( __traits(derivedMembers,T) );

        enum AttrName = __traits(identifier,Attr);

        // в std.typetuple есть функции, облегчающие работу с кортежами типов
        // такой шаблон можно использовать в staticMap и/или anySatisfy
        template isAttr(A) { template isAttr(T) { enum isAttr = __traits(isSame,T,A); } }

        // и опять функциональный стиль
        template impl( names... )
        {
            alias empty = TypeTuple!();

            static if( names.length == 1 )
            {
                enum name = names[0];

                // не для всего, что возвращает __traits(derivedMembers,T) можно создать alias,
                // например некое this не является полем, поэтому его нельзя получить
                static if( __traits(compiles, { alias member = AT!(__traits(getMember,T,name)); } ) )
                {
                    // единственный неудобный момент: нельзя напрямую написать alias some = __traits(...)
                    // поэтому используется такой хак template AT(alias T) { alias AT = T; }
                    alias member = AT!(__traits(getMember,T,name));
                    
                    // та же ситуация, но здесь уже не одно значение
                    alias attribs = TypeTuple!(__traits(getAttributes,member));

                    // если хоть один атрибут является нужным нам
                    static if( anySatisfy!( isAttr!Attr, attribs ) )
                    {
                        enum RULE = format( "%s must be a void function", AttrName );

                        // проверяем функция ли это вообще
                        static assert( isSomeFunction!member,
                                format( "fail mix X for '%s': %s, found '%s %s' with @%s attrib",
                                    T.stringof, RULE, typeof(member).stringof, name, AttrName ) );

                        // функции-сигналы могут быть только void
                        static assert( is( ReturnType!member == void ),
                                format( "fail mix X for '%s': %s, found '%s' with @%s attrib",
                                    T.stringof, RULE, mix.functionFmt!member, AttrName ) );

                        // имя функции-шаблона должно начинаться с _
                        static assert( mix.testName( name ),
                                format( "fail mix X for '%s': @%s name %s",
                                    T.stringof, mix.functionFmt!member, AttrName, mix.NAME_RULE ) );

                        alias impl = member; // наконец мы можем "вернуть" результат
                    }
                    else alias impl = empty;
                }
                else alias impl = empty;
            }
            else alias impl = TypeTuple!( impl!(names[0..$/2]), impl!(names[$/2..$]) );
        }
    }

Проверок можно вставить и больше, в зависимости от задачи.

Осталось рассмотреть функцию connect. Она достаточно странно выглядит на фоне метапрограммирования:
void connect(T,Args...)( Signal!Args sig, T delegate(Args) slot )
{
    auto slot_handler = cast(XBase)cast(Object)(slot.ptr); // по сути это грязный хак
    enforce( slot_handler, "slot context is not XBase" );

    // так как слотом может быть любая функия мы будем просто игнорировать результат, если функция не void
    static if( is(T==void) ) slot_handler.connect( sig, slot );
    else slot_handler.connect( sig, (Args args){ slot(args); } );
}
void connect(T,Args...)( T delegate(Args) slot, Signal!Args sig ) { connect( sig, slot ); }

Почему я не сделал такой хак и для сигнала? Например, чтобы можно было вызывать connect как в начале статьи:
    connect( a.message, b.print );

Во-первых, в таком случае нужно зафиксировать порядок следования сигнала и слота, что по хорошему стоило бы отразить в имени. Но самая важная причина: так сделать не получится. Такая форма
void connect!(alias sig, alias slot)() ...
не позволяет сохранить контекст, alias передаёт по сути Class.method где Class это имя класса, а не объект. И нужно вводить доп. проверку на соответствие агрументов сигнала и слота. А форма с делегатами
void connect(T,Args...)( void delegate(Args) sig, T delegate(Args) slot ) { ... }
// для такого вызова
connect( &a.message, &b.print );
теряет информацию о классе, который содержит сигнал. Я не нашёл способа по указателю функции (sig.funcptr) вывести её имя, да и происходило бы это уже в runtime, а имя сигнального объекта как-то нужно было бы сконструировать, а возвращать из словаря (SignalConnector[string]) не очень выглядело бы. По этому реализовано так как реализовано =)

Код примера доступен на github и как пакет dub.
Tags:
Hubs:
+13
Comments 0
Comments Leave a comment

Articles