Доброго времени суток, хабр!
В прошлой статье были рассмотренны базовые элементы 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 заменяется простой полностью, даже если у простой нет тела. Из-за этого нужно объявлять по сути другую функцию с телом.
Теперь начнём по порядку. Первым делом нужно осознать, что подход с массивом делегатов «не очень». Конечно всё сильно зависит от задачи. В нашем случае будем считать, что есть несколько небольших требований:
- любой объект может быть валидным и нет
- можно перевести объект в невалидное состояние (после создания он валиден)
- у объекта могут быть дочерние объекты
- если родитель перестаёт быть валидным дочерние тоже перестают таковыми быть
- вызов слотов не валидного объекта производиться не должен (не будет иметь смысла)
По логике дочерние объекты целиком и полностью принадлежат родителю.
В D объекты классов управляются сборщиком, вызов деструктора происходит при сборке мусора либо с помощью функции destroy(obj). Так же есть один момент: управлять памятью при сборке мусора нельзя. Из-за этого мы не можем убрать из какого-либо списка уничтожаемый объект, да и сборщик сам не будет ничего делать пока объект в таком списке. Рассматривая начальные требования и мысль о сборщике приходим к выводу, что нужна концепция 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.
ссылка на оригинал статьи http://habrahabr.ru/post/261641/
Добавить комментарий