Design by introspection

What if we could arrange the atoms one after another as we please?
Richard Feynman

How many programming paradigms can you name? The list on this wikipedia page contains as many as 76 items. This list could be augmented by another approach called Design by Introspection. His main idea is the active use of simple metaprogramming and introspection types (compilation time) to create elastic components.


The author of this approach is Andrei Alexandrescu . The article used materials from his speech at DConf 2017.


Background


In 2001, a book called policy-based design was introduced in the book Modern Design in C ++ . In general, this is a “strategy” pattern, but using patterns and compiling at compile time. The host template class accepts as its parameters a set of policy types that implement each kind of independent functionality, and internally uses the interfaces provided by these components:


struct Widget(T, Prod, Error)
{
    private T frob;
    private Prod producer;
    private Error errPolicy;
    void doWork()
    {
        // используем неявные интерфейсы
        // обычный duck-typing
    }
}

Syntax Explanation

Here the template is described by short syntax. (T, Prod, Error)- its parameters.
The instantiation looks like this:


Widget!(int, SomeProducer, SomeErrorPolicy) w;

The advantages are obvious: template efficiency, good separation and code reuse. However, the components are solid, non-separable. If some part of the interface is missing, this will lead to a compilation error. Let's try to develop this scheme, to give the components "plasticity".


Requirements


So, for this we need:


  1. type introspection: “What methods do you have?”, “Do you support the xyz method?”
  2. code execution at compile time
  3. code generation

Let's take a look at what D language tools can be used for each of the points:


  1. .tupleof, __traits, std.traits
    __traits- built-in compiler tool of reflection. std.traits- library extension of embedded traits, among which we will be interested in the function hasMember.
  2. CTFE , static if, static foreach
    At compile time, you can perform a large class of functions (in fact, all are portable and do not have the side effects of global function).
    static if, static foreach- it ifand foreachcompile-time.
  3. templates and
    mixins Mixins in the D language come in two forms: template and string. The former are used to insert a set of definitions (functions, classes, etc.) into some place in the program. The latter turns the string generated at compile time directly into code. String mixins are usually used in small portions.

Optional interfaces


The most important feature of Design by Introspection is the optional interfaces. Here, the component contains R mandatory primitives (maybe 0) and O optional. With the help of introspection it is possible to find out whether a certain primitive is given, and the knowledge about the missing primitives is just as important as about those that the component contains. The number of possible interfaces thus becomes 2 O .


static if- A simple but powerful tool that makes a “magic fork”, doubling the number of options for using the code. It allows you to write linear code with an exponential increase in the number of possible behaviors. The exponential growth of the code generated by the compiler does not occur: you pay only for those instances of the templates that you really use in your application.


Example


As an example of using DbI, consider std.experimental.checkedint , a module of the standard Phobos library that implements safe work with integers. Which machine integer operations are unsafe?


  • + + = - - =, ++, -, , = may overflow
  • division by zero in / and / =
  • -x.min is equal to itself for signed types
  • -1 == uint.max, -1> 2u
  • ...

You can honestly insert checks after each operation, or you can develop a type that would do this for us. This raises many questions:


  • what type should check
  • what to do in case of violation of verification
  • what operations / conversions to prohibit
  • how to make it all effective
  • in the end, how to make it simple so that the library does not come with a twenty-page manual for using it

Let's create a “shell” that accepts the base type and “hook” as template parameters, which will perform our checks:


static Checked(T, Hook = Abort) if (isIntegral!T)   // Abort по умолчанию
{
    private T payload;
    Hook hook;
    ...
}

A hook does not always have a state. Let's take this into account by using static if:


struct Checked(T, Hook = Abort) if (isIntegral!T)
{
    private T payload;
    static if (stateSize!Hook > 0)
        Hook hook;
    else
        alias hook = Hook;
    ...
}

Here we are at hand in the fact that in the D syntax, the dot is used to access the fields of the object directly, and through the pointer, and its static members.
We will also configure the default value. This can be useful for hooks defining some NaN value. Here we use the template hasMember:


struct Checked(T, Hook = Abort) if (isIntegral!T)
{
    static if (hasMember!(Hook, "defaultValue"))
        private T payload = Hook.defaultValue!T;
    else
        private T payload;
    static if (stateSize!Hook > 0)
        Hook hook;
    else
        alias hook = Hook;
    ...
}

As an example of how many behaviors a small piece of code can contain, I will cite the overloaded increment and decrement operators.


Function Code Entire
ref Checked opUnary(string op)() return
if (op == "++" || op == "--")
{
    static if (hasMember!(Hook, "hookOpUnary"))
        hook.hookOpUnary!op(payload);
    else
    static if (hasMember!(Hook, "onOverflow"))
    {
        static if (op == "++")
        {
            if (payload == max.payload)
                payload = hook.onOverflow!"++"(payload);
            else
                ++payload;
        } else
        {
            if (payload == min.payload)
                payload = hook.onOverflow!"--"(payload);
            else
                --payload;
        }
    } else
        mixin(op ~ "payload;");
    return this;
}

If the hook intercepts these operations, delegate them to it:


static if (hasMember!(Hook, "hookOpUnary"))
    hook.hookOpUnary!op(payload);

Otherwise, we handle the overflow:


else static if (hasMember!(Hook, "onOverflow"))
{
    static if (op == "++")
    {
        if (payload == max.payload)
            payload = hook.onOverflow!"++"(payload);
        else
            ++payload;
    } else
    {
        // -- аналогично
    }
}

Finally, if nothing was intercepted, we use the operation as usual:


else
    mixin(op ~ "payload;");

This string mixin will unfold in ++payload;or --payload;depending on the operation.


Traditionally, the absence of any part of the interface leads to an error. Here, however, this leads to the absence of a part of the possibilities:


Checked!(int, void) x;  // x ведёт себя, как обычный int

The module std.experimental.checkedintdefines several standard hooks:


  • Abort: do assert(0)
  • Throw: throw an exception
  • Warn: display warning in stderr
  • ProperCompare: make correct comparisons
  • WithNaN: substitute some NaN value, indicating that something is wrong
  • Saturate: do not go beyond min and max

A hook may contain:


  • static fields: defaultValue, min, max
  • operators: hookOpCast, hookOpEquals, hookOpCmp, hookOpUnary, hookOpBinary, hookOpBinaryRight, hookOpOpAssign
  • event handlers: onBadCast, onOverflow, onLowerBound, onUpperBound

А написание собственного займёт меньше 50 строчек кода. Для примера, запретим все сравнения знаковых чисел с беззнаковыми:


struct NoPeskyCmpsEver
{
    static int hookOpCmp(Lhs, Rhs)(Lhs lhs, Rhs rhs)
    {
        static if (lhs.min < 0 && rhs.min >= 0 && lhs.max < rhs.max ||
                   rhs.min < 0 && lhs.min >= 0 && rhs.max < lhs.max)
        {
            // ассерт, сработающий во время компиляции
            static assert(0, "Mixed-sign comparison of " ~ Lhs.stringof ~ " and " ~ Rhs.stringof ~ " disallowed. Cast one of the operands.");
        }
    }
    return (lhs > rhs) - (lhs < rhs);
}
alias MyInt = Checked!(int, NoPeskyCmpsEver);

Композиция


До этого Checked принимал основным параметром только базовые типы. Обеспечим композицию, позволим ему принимать другой Checked:


struct Checked(T, Hook = Abort)
if (isIntegral!T || is(T == Checked!(U, H), U, H))
{...}

Это открывает интересные возможности:


  • Checked!(Checked!(int, ProperCompare)): чинить сравнения, падать в других ситуациях
  • Checked!(Checked!(int, ProperCompare), WithNaN): чинить сравнения, в других ситуациях возвращать «NaN»

а также вносит бессмысленные комбинации:


  • Abort, Throw, Warn несовместимы между собой
  • Abort/Throw перед ProperCompare/WithNaN/Saturate

и просто странные:


  • выдать ворнинг, затем исправить
  • вначале исправить, затем выдать ворнинг
  • и т.п.

Для решения этого вопроса предлагается использовать «полуавтоматическую» композицию:


struct MyHook
{
    alias
        onBadCast = Abort.onBadCast,
        onLowerBound = Saturate.onLowerBound,
        onUpperBound = Saturate.onUpperBound,
        onOverflow = Saturate.onOverflow,
        hookOpEquals = Abort.hookOpEquals,
        hookOpCmp = Abort.hookOpCmp;
}
alias MyInt = Checked!(int, MyHook);

With the help, aliaswe selected static methods from existing hooks and made our own, a new hook from them. That's how we can arrange the atoms as we like!


Conclusion


The considered approach exists largely thanks to static if. This operator extends the space of code use cases. When scaling, Design by Introspection will require some support from the developer tools.


Also popular now: