poita.org

A Better Assert for D

Posted: 2012-09-02 - Link

Like C and C++, the D programming language provides an assert mechanism that allows you to check conditions at runtime, and halt execution if the condition fails. You can optionally print out a error if you’re in a particularly good mood.

float log(float x)
{
    assert(x > 0.0f, "Argument to log must be positive.");
    ...
}

Trying to call log(-1) will give you an error at runtime.

core.exception.AssertError@test.d(3): Argument to log must be positive.

In this example, I know that the argument was -1, but what if the argument was the result of some long calculation? It would be nice to know what was actually passed into log. Typically, D programmers will use std.string.format for this purpose:

float log(float x)
{
    import std.string : format;
    assert(x > 0.0f, format("Argument must be positive (x = %f).", x));
    ...
}

This solves the problem, but has a couple of issues:

  1. You need to import std.string.format.
  2. The call to format is a bit ugly.

Granted, these are relatively minor issues, but it would be nice if we could simply write:

float log(float x)
{
    assert(x > 0.0f, "Argument must be positive (x = %f).", x);
    ...
}

C and C++ programmers typically work around this by using some clever macros, but D doesn’t have a pre-processor, so we’ll need to use standard functions.

Here’s a first attempt:

void assertf(Args...)(bool test, Args args)
{
    import std.string : format;
    assert(test, format(args));
}

float log(float x)
{
    assertf(x > 0.0f, "Argument must be positive (x = %f).", x);
    ...
}

This works, giving the error:

core.exception.AssertError@test.d(4): Argument must be positive (x = -1).

Notice however, that the file and line number are of the assert inside assertf rather than log. This is less than ideal.

Fortunately, D has a tricky little feature designed just for this. D defines a couple of keywords, __FILE__ and __LINE__ that statically evaluate to the file name, and line number of the locations of those keywords respectively. Furthermore, if you use those keywords as default arguments to a function then you get the file name and line number of the calling function. For example:

void getInfo(string file = __FILE__, int line = __LINE__)
{
    import std.stdio;
    writeln(file, " : ", line);
}

// test.d
void main()
{
    getInfo(); // test.d : 10
    getInfo(); // test.d : 11
}

This is exactly what we need. There’s just one problem: you can’t put default arguments after variadic parameters; the compiler can’t figure out whether the last arguments refer to the variadic parameters or the optional parameters.

The way to work around this is to make the file and line template parameters, that way they can be automatically deduced, and the user won’t need to specify them manually.

void assertf(string file = __FILE__, int line = __LINE__, Args...)
            (bool test, Args args)
{
    import std.string : format;
    import core.exception : AssertError;
    if (!test)
    {
    	throw new AssertError(format(args), file, line);
    }
}

Our function is working as expected now, but suffers a major, obvious flaw: it works in -release builds.

While D provides a standard way to detect -debug builds (using the debug keyword), there is currently no standard way to detect builds where an assert should fire.

I say “currently” because I have requested the feature, and as of DMD 2.061, you’ll be able to write:

version(assert) if (!test)
{
    throw new AssertError(format(args), file, line);
}

This is conditionally compile the test only in builds where asserts are meant to fire, similar to how we can currently use version(unittest) to detect unit testing builds.

The final remaining difference between our function and the built-in assert is the evaluation strategy. When asserts are compiled out of your build, not only does the assert do nothing, but the arguments aren’t even evaluated. For example:

assert(printf("Hello, world") > 0);

In -release builds, this will print out nothing, however the analgous call to our formatting assert will still print out.

The way to work around this is to use lazy evaluation.

void assertf(string file = __FILE__, int line = __LINE__, Args...)
            (lazy bool test, lazy Args args)
{
    import std.string : format;
    import core.exception : AssertError;
    version(assert) if (!test)
    {
        throw new AssertError(format(args), file, line);
    }
}

Note the addition of the lazy storage class in the parameter list. This tells the compiler to not evaluate the arguments until they are used, and since the arguments will not be used in -release builds, they will not be evluated, solving the final issue with our formatted assert.

That’s it! Remember, version(assert) won’t work until DMD 2.061 (or unless you get HEAD from git). Until then, you could either just leave it in all builds, or use debug to enable it only in -debug builds.