Using template constraints and static if

We saw template constraints and static if used in our range consuming functions in Chapter 3, Ranges. There, they were used to selectively use range functionality for the purpose of optimization and explicitly documenting our interface requirements. These same features can also be used for producing custom compile-time errors.

Getting ready

To understand the problem we're trying to solve, compile the following simple program:

void main() {
    import std.conv;
    struct Foo {}
    Foo f;
    int i = to!int(f);
}

Also, observe the long error message with most of the locations being reported as inside Phobos! (If you look at the very last line of the message, it will finally report the location in your code.) It also doesn't tell you why it didn't match.

Similarly, try to call std.algorithm.sort with a range that lacks one of the requirements. It may fail to provide swappable or assignable elements or might have a typo in a property name like emptty instead of empty. The error message tells you no function matches, but it doesn't say what, specifically, the problem is. (The typo problem is why we used static assert to verify we correctly implemented the interface on every range we wrote in Chapter 3, Ranges. However, even there, we would be informed of a problem early on to narrow the scope, but it was still up to us to find what, specifically, the problem was.)

Also, try to compile this program:

string toString(T)(T t) {
  return t;
}
string toString(T)(T t) {
  return cast(string) t;
}
void main() {
  string s = toString(0.0);
}

Despite these functions not being semantically valid for the given type (double), which produces compilation errors (here, we're interested in the matching, not the specifics of actually implementing a conversion to string algorithm), it also produces an error that the given arguments match two different functions.

For simple templates or a small number of overloads or constraint requirements, the error message helps get to the point quickly. However, for a complex function with multiple requirements, the error messages can be of very little help at all. How can we improve the error message situation for our users?

How to do it…

Perform the following steps:

  1. Write two functions for your constraints: one that returns a bool result and one that executes a series of static assert with appropriate error messages.
  2. Use if(!__ctfe) {} to keep the code from actually running in the test.
  3. Keep template constraints simple so that the error messages are readable. If required, refactor complex conditions into helper functions.
  4. Use the template constraints for coarse requirements.
  5. Perform other checks with argument specialization.
  6. If the requirements still lead to complex error messages, use individual static if or static assert statements inside the function to customize the errors.

An improvement to Phobos' default isInputRange is shown as follows:

bool checkInputRange(T)() {
  if(!__ctfe) {
    // notice that this portion is the same as std.range.isInputRange
    T t = void;
    if(t.empty) {}
    t.popFront();
    auto f = t.front;
  }
  return true;
}
template isInputRange(T) {
  enum isInputRange = is(typeof(checkInputRange!T));
}
struct Test {
  int[] s;
  bool eempty() { return s.length == 0; } // typo intentional!
  int front() { return s[0]; }
  void popFront() { s = s[1 .. $]; }
}
// this tells it failed, but not why…
//static assert(isInputRange!Test);

// the error message here points out the typo!
static assert(checkInputRange!Test);

Compilation results with the two styles are shown as follows:

cte.d(33): Error: static assert  (isInputRange!(Test)) is false
cte.d(4): Error: no property 'empty' for type 'Test', did you mean 'eempty'?

These messages aren't perfect, but they do point you in the right direction, toward the property empty on the type Test.

How it works…

Getting compilation errors is a great time saver compared to runtime problems; however, if they are difficult to read, they can be needlessly frustrating. D's metaprogramming capabilities enable a lot of patterns, but at the same time, can lead to complex or unhelpful error messages. By separating our concerns, we can help the compiler provide more help, or failing that, clean up the error messages ourselves.

To do this, we need to understand the way templates are chosen and how code can be test-compiled inside the program. There are two ways to try to compile code: with is(typeof( /* a function that tries to use the code */)) and __traits(compiles, the_code);. Both return true if the code can be successfully compiled and false if the compilation failed. These return values may be checked in template constraints or the static if statements.

A template, unless it has obviously invalid syntax (such as a missing semicolon or mismatched parenthesis, in other words, it must successfully lex and parse) is not considered to have failed compilation until a particular set of compile-time arguments is passed to it and they fail semantic analysis.

The is() expression is a versatile tool for compile-time introspection. We'll explore it in detail in Chapter 8, Reflection. Here, we're using it to check whether a given type is valid. A function, if it compiles, is always a valid type, and if it does not compile, is an error type. The is(typeof()) expression, thus, can be used to check if a function compiles. This is a very common pattern to check compile-time interfaces in Phobos.

Alternatively, we could use __traits(compiles, some_code_here);. This can be given in any block of code and simply sees if it compiles in-context or not, allowing us to achieve the same task as is(typeof()). __traits(compiles) is less often seen in Phobos simply because it was added to the D language after is(). Both patterns are acceptable to perform these interface checks. However, is() works better when comparing specific types, and __traits(compiles) is a better choice when checking whether a single expression compiles.

Now that we know how to check whether a piece of code is valid, we need to understand how templates (including types and functions with a compile-time parameter list) are chosen by the compiler.

Templates are matched based on their argument signature and constraint condition. If a given set of compile-time arguments matches the template's signature, it will be compiled, even if one overload doesn't actually work for the given arguments. This leads to the two errors we saw in the second exercise while getting ready. Although the function body was correct, the signature was and so the compilation was attempted.

This is where template constraints (or, in this case, argument specializations) come into play. When a template constraint fails, that template is taken out of the running entirely as a valid overload. Consider the following code snippet:

// this overload is only considered when the argument is NOT convertible to double
string toString(T)(T t) if(!is(T : double)) {
  return t;
}
// this one is only considered when it IS convertible to double
string toString(T)(T t) if(is(T : double)) {
  return cast(string) t;
}

Attempting to compile will now only complain that our function doesn't actually implement a correct algorithm (with a specific line number of the problem and usage point!). The error about matching both functions is gone. Now, there is only one valid choice for the given type of double.

Our code now can be made to work and gives useful error messages. In this case, we could have also alternatively used argument specializations:

string toString(T)(T t) { /* the generic implementation */
string toString(T:double)(T t) { /* a specialized implementation for double */}

With argument specializations, the compiler considers the best match for a given set of arguments, just like the traditional runtime overloaded functions, but with the option of a generic implementation to act as a catch-all.

The advantage of argument specialization is they are typically easier to write than template constraints, especially when the number of overloads becomes large. Since one and only one signature must match the given arguments, a list of specializations with constraints quickly becomes if(cond_1 && !cond_2 && !cond_3) on one, if(!cond_1 && cond_2 && !cond_3), and so on to keep each option valid for only one condition. With specializations, on the other hand, you just write your requirements for this implementation in this signature.

The advantage of template constraints is they can become arbitrarily complex and serve as a gateway to remove an option entirely in order to ensure it isn't attempted to be compiled even if all other requirements is set. While specializations are limited to looking at one argument at a time and may not call helper functions to perform the matching, the condition in a constraint may call any number of functions by using any compile-time reflection options available in the language. These also inspect as many compile-time arguments as it wants, including looking at a combination of arguments.

You may mix constraints and specializations together. A coarse constraint with a fine-grained specialization, when possible, tends to give the best balance of control, concise code, and readable error messages.

The final tool we'll look at gives even finer control over compilation and is necessary to produce custom error messages: static if paired with static assert. This is the technique we can use to help clean up the error messages with std.conv.to.

Note

The std.conv.to expression is even more complex than our example here. The reason the error messages haven't been improved in Phobos is because it was not written with these concerns in mind and would now require a massive refactoring with care to ensure none of its complex functionality is broken in the process. At the time this book was written nobody had the time to do the work. In your code, you may wish to consider using these techniques from the start to prevent the problem from becoming so massive down the line.

Both specializations and constraints depend on the compiler to produce the error message, which will come in the form of either cannot deduce function from argument types or function called with argument types (list) matches both <options>. If we want to customize it, we need to be permissive in the signature to get past the compiler gate. For example, consider the following code:

string toString(T)(T t) { /* no constraint here, no specialization */
static if(is(T : double)) {
  return null; // implementation not important
}
/* after doing our list of supported arguments, end with a custom error message: */
else static assert(0, "toString only works on floating point types, not " ~ T.stringof);

The following shows what happens if we try to compile while passing a string:

test.d(2): Error: static assert  "toString only works on floating point types, not string"
test.d(5):        instantiated from here: toString!string

The custom error message is tailored for the function's needs, giving a custom error and a succinct location, in user code, where it originated.

Finally, in the checkInputRange function we wrote earlier, we used if(!__ctfe){} to compile our code but not actually run it. The __ctfe variable is a magic variable that the compiler sets to true if the code is being run at compile time, and it is set to false once compilation is completed (allowing that code to be immediately removed from code generation). It can be used to do the following:

  • Provide a special implementation for compile-time evaluation versus runtime evaluation. For example, using a generic algorithm at compile time and a hand-optimized assembler algorithm at runtime. The asm functions cannot be run at compile time at all.
  • Remove the code generation functions from the compiled program (we'll see this in the following chapter).
  • Provide a semantic check via compile-time evaluation without requiring the code to be run successfully, which is why we used it here.

By enclosing the implementation in if(!__ctfe){}, we are saying that the function must compile, but it doesn't need to actually process any data.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.187.108