Доброго времени суток, хабр!
В прошлой статье были рассмотренны базовые элементы compile-time рефлексии, те кирпичики, из которых строят «настоящие» метаконструкции. В этой статье я хочу показать некоторые такие приёмы. Попробуем реализовать сигналы и слоты, похожие на те, что в Qt, будет примерно так:
Осторожно: много кода (с комментами).
Примерно, но не так =) Но по большему счёту не хуже, на всё есть свои причины, мы о них поговорим. Конечный вариант:
Досадное правило гласит, что если функция была объявлена через mixin и имеется такая же, но простая (обычно объявленная), то функция объявленная через mixin заменяется простой полностью, даже если у простой нет тела. Из-за этого нужно объявлять по сути другую функцию с телом.
Теперь начнём по порядку. Первым делом нужно осознать, что подход с массивом делегатов «не очень». Конечно всё сильно зависит от задачи. В нашем случае будем считать, что есть несколько небольших требований:
По логике дочерние объекты целиком и полностью принадлежат родителю.
В D объекты классов управляются сборщиком, вызов деструктора происходит при сборке мусора либо с помощью функции destroy(obj). Так же есть один момент: управлять памятью при сборке мусора нельзя. Из-за этого мы не можем убрать из какого-либо списка уничтожаемый объект, да и сборщик сам не будет ничего делать пока объект в таком списке. Рассматривая начальные требования и мысль о сборщике приходим к выводу, что нужна концепция ContextHandler. Это будет наш базовый интерфейс.
По сути это дерево. При девалидации объекта, он делает то же самое с дочерними. Вернёмся к нему позже.
Следующие концепции относятся к понятию «слот». Хоть мы и не создали для слотов отдельный UDA, создать как таковой слот имеет смысл.
Сразу рассмотрим сигнал
И, наконец, мы подобрались к самому интересному: интерфейсу XBase и промежуточному классу XObject (вставляется MixX и создаётся конструктор по умолчанию). Интерфейс XBase расширяет ContextHandler всего парой функций, самое важное это mixin template MixX. В нём как раз и происходит вся магия метапрограммирования. Сначала следует объяснить логику всех действий. UDA @?signal помечает функции, которые должны стать основой для создания настоящих сигнальных функций и самих объектов сигналов. От помеченных функций берётся почти всё: имя (без начального нижнего подчёркивания), уровень доступа (public, protected) и, конечно же, аргументы. Из атрибутов разрешён только @?system, так как мы хотим, чтобы сигналы могли работать с любыми слотами. Настоящая функция-сигнал вызывает opCall соответствующего сигнального объекта, передавая все агрументы. Чтобы не создавать все сигнальные объекты в каждом новом классе, мы реализуем в MixX функцию, которая это делает за нас. Зачем создавать отдельно функцию-сигнал и сигнальный объект? Для того, чтобы сигнал был функцие, как ни странно. Это позволит реализовывать интерфейсы в класссе, наследующем XObject или реализующим XBase, а так же соединять сигналы с вызовом других сигналов:
Вернёмся к XBase. Будем разбирать код по частям:
Стоит сразу оговориться, что mix это структура, в которой сконцентрированы все методы работы со строками. Возможно это не самое удачное решение, но оно позволяет сократить объём имён, попадаемых в конечный класс, при этом содержать всё в нужном месте (в интерфейсе XBase). И раз уж заговорили, рассмотрим эту структуру.
Вернёмся к MixX, в нём самым сложным будет непреметный mixin defineSignals.
Шаблон getFunctionsWithAttrib и mix.signalMixinString примерно равносильны по сложности, но сначала рассмотрим mix.signalMixinString, так как при рассказе про __MixHelper я её вырезал:
Вернёмся к получению списка помеченных функций.
Проверок можно вставить и больше, в зависимости от задачи.
Осталось рассмотреть функцию connect. Она достаточно странно выглядит на фоне метапрограммирования:
Почему я не сделал такой хак и для сигнала? Например, чтобы можно было вызывать connect как в начале статьи:
Во-первых, в таком случае нужно зафиксировать порядок следования сигнала и слота, что по хорошему стоило бы отразить в имени. Но самая важная причина: так сделать не получится. Такая форма
Код примера доступен на github и как пакет dub.
В прошлой статье были рассмотренны базовые элементы 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. Это будет наш базовый интерфейс.
Не полный, но достаточный для понимания, код 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.