📜 ⬆️ ⬇️

Compile-time reflection D, practice

Good day, Habr!

In the last article , the basic elements of the compile-time reflection, those building blocks of which “real” metaconstructions are built, were considered. In this article I want to show some of these techniques. Let's try to implement signals and slots, similar to those in Qt, would be something like this:
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 } 

Caution: a lot of code (with comments).

Approximately, but not so =) But by and large not worse, everything has its own reasons, we will talk about them. Final version:
 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 } 

The annoying rule says that if the function was declared via mixin and there is the same, but simple (usually declared), then the function declared through mixin is replaced with a simple completely, even if the simple has no body. Because of this, you need to declare essentially a different function with the body.

Now let's start in order. First of all, you need to realize that the approach with an array of delegates is "not very." Of course, it all depends on the task. In our case, we assume that there are several small requirements:
  1. any object can be valid and no
  2. it is possible to translate an object into an invalid state (after creation it is valid)
  3. an object can have child objects
  4. if the parent ceases to be valid, the children also cease to be such
  5. no slots call of a valid object should be made (will not make sense)

By logic, the child objects are wholly owned by the parent.
In D, class objects are managed by the collector, the destructor is called when garbage is collected or using the function destroy (obj). There is also one thing: you can't manage memory when garbage collection. Because of this, we cannot remove the object to be deleted from any list, and the collector himself will not do anything while the object is in such a list. Considering the initial requirements and the idea of ​​the collector, we conclude that we need the concept of ContextHandler. This will be our basic interface.
Not complete, but sufficient for understanding, ContextHandler code
 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(); //   } } } 

This is essentially a tree. When an object is dealated, it does the same with its children. Let's return to it later.
')
The following concepts refer to the concept of "slot." Although we have not created a separate UDA for slots, it makes sense to create a slot as such.
 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; } } 

Immediately consider the signal
 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(); } //        } 

And finally, we got to the most interesting: the XBase interface and the intermediate class XObject (MixX is inserted and the default constructor is created). The XBase interface extends ContextHandler with just a couple of functions, the most important is mixin template MixX. All the magic of metaprogramming happens in it. First, the logic of all actions should be explained. UDA @ signal marks the functions that should be the basis for creating real signaling functions and the signal objects themselves. Almost everything is taken from the marked functions: the name (without the initial underscore), the access level (public, protected) and, of course, the arguments. Of the attributes, only @ system is allowed, since we want the signals to work with any slots. The present signal function calls the opCall of the corresponding signal object, passing all the arguments. In order not to create all signal objects in each new class, we will implement in MixX the function that does this for us. Why create a separate function-signal and signal object? In order for the signal to be a function, oddly enough. This will allow to implement interfaces in classes that inherit XObject or implement XBase, as well as connect signals with the call of other signals:
  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     ... 

Let's go back to XBase. We will sort the code in parts:
 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)) ); } } } ... } 

It should immediately make a reservation that mix is ​​a structure in which all methods of working with strings are concentrated. Perhaps this is not the best solution, but it allows you to reduce the amount of names that fall into the final class, while keeping everything in the right place (in the XBase interface). And once started talking, consider this structure.
  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; 

Returning to MixX, the most difficult part of it will be the 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]) ); //  ,      } 

The getFunctionsWithAttrib template and mix.signalMixinString are roughly equivalent in complexity, but first consider mix.signalMixinString, as I cut out the __MixHelper story:
  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"); } 

Let's get back to the list of marked functions.
  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..$]) ); } } 

Checks can be inserted and more, depending on the task.

It remains to consider the function connect. It looks rather strange against the background of metaprogramming:
 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 ); } 

Why didn't I do such a hack for a signal? For example, so that you can call connect like at the beginning of the article:
  connect( a.message, b.print ); 

Firstly, in this case, you need to fix the order of the signal and the slot, which would be well worth reflecting in the name. But the most important reason: do not succeed. This form
 void connect!(alias sig, alias slot)() ... 
does not allow to save the context, alias passes essentially Class.method where Class is the name of the class, not an object. And you need to enter additional. check for matching signal and slot arguments. A form with delegates
 void connect(T,Args...)( void delegate(Args) sig, T delegate(Args) slot ) { ... } //    connect( &a.message, &b.print ); 
loses information about the class that contains the signal. I couldn’t find a function pointer (sig.funcptr) to display its name, and it would have happened at runtime, and the name of the signal object would need to be constructed somehow, and returned from the dictionary (SignalConnector [string]) did not look like would. On this implemented as implemented =)

The sample code is available on github and as a dub package.

Source: https://habr.com/ru/post/261641/


All Articles