“Overload set” is a term of art that seemingly arises from the reuse of overload rules across the years. Overload sets quietly power a lot of D’s generic programming, and once you understand them explicitly, a whole class of API design opens up.

import std;

void bar(int)   { "int".writeln;   }
void bar(float) { "float".writeln; }
void bar(bool)  { "bool".writeln;  }

void useIt(alias F)() {
    F(1);      // prints "int"
    F(13.37);  // prints "float"  
    F(true);   // prints "bool"
}

unittest { useIt!bar; }

What exactly is bar when passed to useIt? Wait, does it survive as useIt.F? (Yes, it compiles as is.)

What Are Overload Sets?

The specification:

An Overload Set is the set of functions with the same name declared in the same scope that participate in overload resolution.

And from the alias spec:

Aliases can also ‘import’ a set of overloaded functions that can be overloaded with functions in the current scope…

The behavior of overload sets is scattered across the spec. A few sentences here, a few more tucked away in the template section. Template specialization lets you pattern-match against the whole set; pick the right implementation based on compile-time arguments.

Overload sets can work with several very different things. I use the following definition:

An overload set is a collection of things that share a name and a closely related template header.

You may not know everything that can be templatized, and real-world template usage is extremely concerned about making only one declaration match at a time.

Specialization on Integer Values

alias typeAt(int I : 0) = int;
alias typeAt(int I : 1) = float;
alias typeAt(int I : 2) = string;
enum typeAtLength = Length!typeAt;

template Length(alias A, int acc=0){ // returns the pseudo-length of an overloadset
    static if( ! __traits(compiles,A!acc) ){
        enum Length=acc;
    } else {
        enum Length=Length!(A,acc+1);
    }
}

static foreach (i; 0 .. typeAtLength) {
    pragma(msg, typeAt!i.stringof);
}
// prints: int, float, string

By treating value specialization of ints as an array, you can make an overload set that is foreachable. You can build compile-time lookup tables, generate code for each type in the set, and dispatch based on integer constants, etc.

Real-World Pattern: Unified Vector Interface

In Kap’s joka library, GVec2, GVec3, GVec4 are all parameterized by type, but what if I want dimension-generic code?

It’s an incomplete templatization:

// Separate types for each dimension
alias BVec2 = GVec2!byte;
alias IVec2 = GVec2!int;
// ... more aliases

struct GVec2(T) { T x, y; }
struct GVec3(T) { T x, y, z; }
struct GVec4(T) { T x, y, z, w; }

You can’t write “works on any N-dimensional vector”; the dimension is stuck in the type name. To specialize on dimension, you can expand the shared template header to include an int parameter.

alias GVec2(T = float) = GVec!(2, T); // (optional, allows backwards compatibility)
alias GVec3(T = float) = GVec!(3, T);
alias GVec4(T = float) = GVec!(4, T);

// struct GVec(int N, T); // implicit, no primary definition exists

struct GVec(int N : 2, T) { T x = 0; T y = 0; }
struct GVec(int N : 3, T) { T x = 0; T y = 0; T z = 0; }
struct GVec(int N : 4, T) { T x = 0; T y = 0; T z = 0; T w = 0; }

Now you can write functions that take GVec!(N, T) and work across any dimension. Pattern-matching on N at compile time allows you to easily access N in trivial meta-code:

void print(int N, T)(GVec!(N, T) v) {
    import std;
    writeln("Vector of dimension ", N, " with type ", T.stringof);
    static foreach (c; "xyzw"[0 .. N]) {
        writeln(c, ": ", mixin("v." ~ c));
    }
}

unittest {
    IVec3(1, 4, 7).print;
    DVec4(2, 6, 0, 0).print;
}

The Ad-hoc Template API

The above is an Ad-hoc Template API. Note that no primary struct GVec(int N, T) exists in the source code. The symbol GVec exists only as a collection of specializations. The API is a coordinate map of successful matches. You are programming against the existence of a match in the resolution logic rather than a central definition.

The API that can be named is not the immortal API. -monkyyy-tzu

Swapping an Overload Set

std.conv.to is an overload set with the implied syntax of .to!T. You can let users pass their own instead for serialization.

template someSerializeFunc(alias TO_ = void, Args...)(Args args) {
    static if (is(TO_ == void)) {
        import std.conv;
        alias TO = std.conv.to; // Successful partial symbol resolution
    } else {
        alias TO = TO_;
    }
    // ... use args with .TO!T for conversions
}

This code uses the default std.conv.to unless the user provides their own. So long as the user matches the ad-hoc API of to, they can extend it (and fix its edge cases). This flexible API, with an overloadable-overload-set, lets users fix behavior.

Shared Header Mechanics

enum foo(int I : 0) = 0;
int foo(int I : 1)(int) => 1;
template foo(int I : 2) {
    int foo = 2;
}
struct foo(int I : 3) {
    int myint = 3;
}

The overload set resolution and specialization mechanisms work on all types of templates in D. The compiler only cares about something matching the template header; you can define it however you want and make ad-hoc APIs.

With type and value specialization shared across at least four declaration patterns, which of your problems could be approached by asking, “How can I define a good template header”?

Conclusion

Overload sets aren’t exotic. Combine them with template specialization and you get ad-hoc APIs that support multiple access patterns without duplicating code. Build your types to play nice with meta-programs, design your APIs to take overload sets, and let users swap in their own implementations. This works even for more complex APIs such as std.conv.to.


Based on 2 chapters from my book, ‘Black Magic in D’.

https://crazymonkyyy.github.io/