Monthly Archives: February 2023

Spelunking Attribute Inference in D

Inference of attributes is a huge part of D programming. D has admittedly quite a lot of atĀ­tribĀ­utes, and four categories of attributes are related to functions:

  • memory safety – Includes @safe, @system, and @trusted.
  • pure – functional purity means that a function cannot access shared or global data.
  • nothrow – Whether a function can throw an Exception (note this does not include Error or other Throwable derivatives, see my other post on this).
  • @nogc – Functions marked with this cannot allocate memory from the GC. This includes hidden allocations the compiler might insert.

This post isn’t really about those attributes, and if you want to learn more about them, I recommend reading the D language specification, and searching for information about them on the D official blog (I wrote a post myself on writing @trusted code).

What I want to talk about here is attribute inference. Because of the proliferation of these different attributes, and because D is a very generative-heavy programming language (templates, CTFE, etc), it can be quite awkward to properly attribute some functions. D’s solution to this is to infer attributes based on the code being compiled. This is limited to functions that the compiler knows it must always have the source code available in order to use. These include:

  • auto returning functions
  • template functions
  • functions inside a template
  • functions inside another function
  • lambda functions

Notably missing here are regular functions. Why? Because a function can be declared separately from the definition via a function prototype. Also of note: class member functions, even if inside a class template, will not be inferred. Since non-template class member functions are virtual, those functions must be explicitly attributed.

So what happens when attributes are wrong? The answer is that the compiler tells you something like the error from this code:

void foo() {
}
void main() @nogc {
    foo();
}
Error: `@nogc` function `D main` cannot call non-@nogc function `foo`

This is the error message from trying to call an incorrectly marked function foo. This is easy to figure out and correct — just put @nogc on foo and call it a day.

But what happens when the function that’s incorrectly marked is hidden behind an inferred function?

void foo() {
}
void bar(alias f)() {
    f();
}
void main() @nogc {
    bar!foo();
}
Error: `@nogc` function `D main` cannot call non-@nogc function `onlineapp.bar!(foo).bar`

No mention here of the real problem: foo is not marked @nogc. All we have is a reference to bar!foo. Now, this also isn’t too difficult to figure out, but this is also not the worst case. When inference failure happens, sometimes there are several layers to the problem. The function that needs attribution might be buried under 10 levels of templates, and maybe in those inside a static foreach, making it hard to figure out what, exactly, is causing the inference to do what it did.

So how do you find the problem? You do it by digging down through each layer until it becomes clear which part the compiler has seen that causes the inference to fail.

I’m going to pick one attribute — @nogc — and show how it works for that. But realistically, all of them can be done the same.

Technique 1: Explicitly mark the template

It’s not usually a good idea to mark a template with an attribute that can be inferred. Especially the attribute @trusted. But in this case, it is a temporary situation, where we want the compiler to dig a bit lower. You mark the template, and then when you solve the complete problem, you unmark it. To remind myself, I usually comment out the original line of code, and put a TODO: marker in there to remind me to remove it later.

If we mark our template above, we get a better error message:

void foo() {
}
void bar(alias f)() @nogc {
    f();
}
void main() @nogc {
    bar!foo();
}
Error: `@nogc` function `onlineapp.bar!(foo).bar` cannot call non-@nogc function `onlineapp.foo`

Nice! now we have the error that shows us the real problem — foo is not marked. Just mark foo, verify that it compiles, and remove the extra attribute from bar, done!

Technique 2: Copy and Rewrite

The problem with the first technique is that it sometimes adds failures for the rest of your code. What if we have something like this?

void foo() {
}
void bar(alias f)() @nogc {
    f();
}
void main() @nogc {
    bar!foo();
}

int x;
void allocateit() {
    x = new int(42); // actually uses GC
}
void otherFunc() { // not @nogc
    bar!allocateit();
}

Now we get two errors — If we are lucky! And in the case of allocateit, it really isn’t @nogc, so the marking of bar isn’t valid. In this case, we only want to mark bar as @nogc if the f parameter is @nogc. This is the main point of inference!

To fix this, we need to copy bar, add the expected attribute, and use the copy only when we are making the problematic call.

void bar(alias f)() { // leave this one alone
    f();
}
void bar2(alias f)() @nogc { // copy and add attribute 
    f();
}
void main() @nogc {
    bar2!foo(); // we get the correct error here
}
void otherFunc() { // not @nogc
    bar!allocateit(); // now this succeeds
}

In this way, we have isolated the path of the compiler for this one case, because this is the case we are interested in. We leave all other cases alone. In a large application where a template might be used in many places, this technique is essential.

Technique 3: Use static if

static if can help us make different decisions based compile-time data. Let’s say, for instance, the offending call is done inside a static loop. Maybe the template succeeds in being @nogc for some parameters, but not for others. Whether you use the normal path or the special attributed path has to depend on compile-time data detectible on the parameter.

This can be tricky, and there’s no “right” way to do this. It highly depends on what the “thing” is that triggers the error. I sometimes use type names, sometimes I use is expressions, sometimes I use __traits(compiles), etc. Whatever you use, single out the path you want to test, and make a specialized case for that one call.

void complicated(Args...)() {
    static foreach(T; Args) {
        static if(is(T == int)) bar2!T(); // specialized attributed path
        else bar!T(); // regular path
    }
}

Doing a full dig

Now that we’ve seen these techniques, how do we apply them to a real nasty 10-layer problem? In that case, we peel the rotten onion all the way to the core (likely caused by your missing attribute). Use whichever technique is appropriate at the next layer, and then repeat the sequence. Always look at the most inner inferred attribute function. Eventually, you will get to the answer.

This can be troublesome, since you may not control much of the code that is involved. Some of it may even be in D’s standard library! But don’t be afraid to (temporarily) modify your copy — none of the issues that might arise from doing this matter until the compilation succeeds. And at that point, you undo all the instrumentation.

Sometimes, I make a complete copy of the code, or just re-install the package once I’m done finding the problem. Don’t be afraid to take things apart, just remember which screws went to which parts!

Recursive instantiation inference failure

Sometimes, if a template is determined to depend on itself in certain way, the compiler gives up inference, and just assumes the worst case. An example:

auto forward(alias fn, Args...)(Args args) {
    return fn(args);
}
T factorial(T)(T val) {
    if(val == 1)
        return val;
    return forward!factorial(val - 1) * val;
}
void main() @nogc {
    auto x = factorial(5);
}
Error: `@nogc` function `D main` cannot call non-@nogc function `onlineapp.factorial!int.factorial`

Using technique 1, you can add @nogc to factorial, and it actually just compiles!

Unfortunately, there is no simple fix here. You can mark factorial explicitly @nogc, but this means that if some T value uses the GC, it can’t be used with factorial. These can sometimes be the hardest to diagnose, since normal techniques do not work.

I’ve seen different approaches to this, including using introspection to apply explicit attributes (which is not an easy thing to do). It may involve simply dictating to users the required attributes, and if you don’t use them, you don’t get to use the library.

I would like to see the compiler just become smarter about this. I believe that it could try compiling with the most restrictive attributes, and it should work most of the time. There might be some pathological cases that prevent inference, but just giving up is worse.

Great changes on the horizon!

In a recent version of the compiler (version 2.101.0), @safe inference has been instrumented so that when an inference results in failed compilation, the compiler does a lot of this work for you! Let’s take our original example, and replace @nogc with @safe (and compile with 2.101.0 or later)

void foo() {
}
void bar(alias f)() {
    f();
}
void main() @safe {
    bar!foo();
}
Error: `@safe` function `D main` cannot call `@system` function `testsafe.bar!(foo).bar`
       which calls `testsafe.foo`

That second error message is saying that the call to foo itself is actually what makes that instantiation of bar unsafe. We no longer have to instrument bar! Imagine that this is a call chain that is 7 layers deep. Having the compiler explain each layer without having to instrument it is going to save a lot of time.

Unfortunately, this is only for @safe code, and not for any of the other 3 attributes. Hopefully these improvements will be mimicked for all attributes, and instrumenting code will be a thing of the past!

But until then, hopefully this post helps you find some of these nasty inference bugs without too much hair-pulling!