A friendly type predicate

This is a sequel to the previous post on writing a custom type predicate. One of the readers on Reddit made a very insightful observation. The user has implemented a type that she intends to use with our library, call it Calc. She passes it to the library function, and she gets the compiler error:

static assertion failed: X does not have a desired interface

But what is our type missing? In the previous post we were describing 3 constraints. A concept could have far more of them. The user has already made an effort to have Calc comply with the constraints, so there must be something tiny missing. Maybe there is a bug in the implementation of the predicate? But it is difficult to track what it is that the predicate does not like about our type. We could use some more specific information.

Can Concepts Lite solve it?

First question to consider is if Concepts Lite can address this issue. After all one of the primary motivations for concepts is better diagnostic messages. On the other hand, the exact problem that Concepts Lite are solving is that the current error messages from template instantiations are too detailed. It is not like compiler gives you no hint about the problem: it gives you all the details about the problem — and this is simply too many details that is the problem. Concepts Lite try to reduce the amount of information in diagnostic messages to the minimum: a still usable minimum. Also, there are contexts where we simple expect a yes-no answer from a concept check, and there is no room for diagnostics. This is the case for concept-based overloading.

Here are two observations. First, inside a concept definition all requirements are already divided into “atomic” constraints (for the purpose of determining if one concept is a refinement of another concept). Let’s get back to the concept from the previous post:

template <typename T>
concept bool is_acceptable = requires(T x, int i)
{
  typename T::result_type;
  T::set_limit(i);
  { x.get_result() } noexcept -> const typename T::result_type&;
};

Let’s only consider the last declaration. It already contains four constraints:

  • x.get_result() is a valid expression,
  • it is declared not to throw exceptions,
  • const typename T::result_type& is a valid type,
  • the return type of the expression is convertible to the indicated type.

This means that the first declaration (line 4) is redundant; but it is still desirable to keep it for clarity and and the order in which diagnostic messages are listed.

Second observation is that there are two basic contexts in which a concept will be used:

  1. Selecting the best overload; or the best class template specialization.
  2. Manual checking if a type models the concept: using static_assert.

In the first context, it makes no sense to display to the programmer why the type did not match the concept, because the programmer may simply know why: she wanted a different overload — constrained with a different concept — to be selected, and so it was. The program compiles fine, so why displaying diagnostic messages. But when no matching overload has been found and there were some candidates, this might be an opportunity to tell which constraint failed in which concept. This sounds reasonable, but if we have a number of overloads, some constrained with different concepts (each with many constraints), the output might start looking like this:

main.cpp: In function 'int main()':
main.cpp:35:10: error: no matching function for call to 'f(X)'
     f(X{});
          ^
main.cpp:21:6: note: candidate: void f(T&) [with T = X]
 void f(T&) {}
      ^
main.cpp:21:6: note:   constraints not satisfied
main.cpp:13:14: note: within 'template concept const bool is_container [with T = X]'
 concept bool is_container = requires(T c)
              ^~~~~~~~~~~~
main.cpp:13:14: note:     with 'X c'
main.cpp:13:14: note: the required expression 'c.size()' would be ill-formed
main.cpp:13:14: note: the required expression 'c.begin()' would be ill-formed
main.cpp:13:14: note: the required expression 'c.end()' would be ill-formed
main.cpp:13:14: note: the required expression 'c.empty()' would be ill-formed
main.cpp:13:14: note: the required type 'typename T::value_type' would be ill-formed
main.cpp:13:14: note: the required type 'typename T::iterator' would be ill-formed
main.cpp:24:6: note: candidate: void f(T&) [with T = X]
 void f(T&) {}
      ^
main.cpp:24:6: note:   constraints not satisfied
main.cpp:10:14: note: within 'template concept const bool is_calc [with T = X]'
 concept bool is_calc = requires { T::calc(); };
              ^~~~~~~
main.cpp:10:14: note: the required expression 'T:: calc()' would be ill-formed
main.cpp:27:6: note: candidate: void f(T&) [with T = X]
 void f(T&) {}
      ^
main.cpp:27:6: note:   constraints not satisfied
main.cpp:2:14: note: within 'template concept const bool is_acceptable [with T = X]'
 concept bool is_acceptable = requires(T x, int i)
              ^~~~~~~~~~~~~
main.cpp:2:14: note:     with 'X x'
main.cpp:2:14: note:     with 'int i'
main.cpp:2:14: note: the required type 'typename T::result_type' would be ill-formed
main.cpp:2:14: note: the required expression 'T:: set_limit(i)' would be ill-formed
main.cpp:2:14: note: the required expression 'x.get_result()' would be ill-formed
main.cpp:29:6: note: candidate: void f(int)
 void f(int) {}
      ^
main.cpp:29:6: note:   no known conversion for argument 1 from 'X' to 'int'

If we display too much information we are going back to the original problem. Of course, the above error message is better than the current errors from template instantiations: it only refers to what is publicly available to the programmer: function signatures and concept definitions. Still, it appears to be better to just say, “overload constrained with C1: constraints not satisfied for X”. And most likely this is just enough for the programmer to realize what happened and why. She most likely has the definition of the concept available in the documentation:

x — lvalue of type T
i — rvalue of type int

T::result_type is a type
T::set_limit(i) return type unimportant
x.get_result() returns const result_type&, throws nothing

On the other hand, the context with static_assert, where we are only considering one concept and failing the build anyway, appears like an ideal place where the compiler could provide more diagnostic information, like which concept constraint failed. Compiler is required to display the exact string literal that we put inside static_assert, but is still allowed to display additional things. With this in place, a programmer who wants to know exactly which constraint failed, could use static_assert and expect a more detailed output.

Concepts Lite seem to provide enough functionality for a high quality compiler to implement the above philosophy of reporting errors from concept checking. Whether they will do it, we have yet to see. We could check what GCC 6.0 does, but its implementation was only intended to be a proof of concept. It has some missing parts, like constraining non-templates (see here). But we could still test it. In fact it does the opposite of what I just described: in overload resolution context it gives me detail about every violated constraint in every concept. In fact, the above example of compiler error message has been generated with GCC. (For a full example see here.) In contrast, in static_assert context we get no details about individual constraints.

A C++11 solution

We will implement this error reporting scheme in C++11. By default, the programmer will only get a yes-no answer, but upon an explicit request she will get a detailed breakdown into individual constraints. The plan is the following. We will build a number of mini-constraints, one for each thing we want to test. Of these constraints we will build two things: first, a predicate (as before), second a function that evaluates all the mini-constraints one-by-one in static_asserts. Once the predicate returns false and you need to know why, you have to instantiate the function template body, and then all the tests will be performed individually, and for each test you can put a descriptive diagnostic message.

Let’s do it. But first, one helper meta-function to make the definitions shorter, a variant of enable_if:

template <typename T, typename U>
using enable_if_same = enable_if_t<std::is_same<T, U>::value>;

Now, the mini-constraints. We will use practically the same definitions as in the original solution (from the previous post). But now each constraint will be a separate alias template:

namespace traits
{        
  template <typename T>
  using nested_result_type = typename T::result_type;
    
  template <typename T>
  using static_set_limit = decltype(T::set_limit(1));
    
  template <typename T>
  using get_result = decltype(declval<T>().get_result());
    
  template <typename T>
  using noexcept_get_result = 
    enable_if_t<noexcept(declval<T>().get_result())>;
    
  template <typename T>
  using get_result_return_type = 
    enable_if_same<typename T::result_type const&,
                   decltype(declval<T>().get_result())>;
}

We can see that the first and the third constraint are redundant as the last constraint is already checking them. But it still makes sense to keep them in order for the error messages to be more descriptive, as we shall see later. Now, we provide another alias template that represents a combined constraint:

namespace traits
{  
  template <typename T>
    using all = void_t<nested_result_type<T>,
                       static_set_limit<T>,
                       get_result<T>,
                       noexcept_get_result<T>,
                       get_result_return_type<T>>;
}

But all these constraints — individual or combined — are just alias templates. We need to turn them into meta-functions (meta-predicates). We will implement a generic tool for converting a well-formed/ill-formed type alias onto true_type/false_type. It will be similar to template models from the previous post. We will call it is_detected. Again, it will be a helper class template, a specialization thereof and an alias template that the users will use. First, the master template:

template <typename /**/,
          template <typename...> typename C,
          typename... T>
struct is_detected_ 
  : false_type {};

The first template parameter is used for matching in the specialization. It will be always set to void. The second one is the alias template (not a type, not a value, but a template) that represents a constraint. In our case the constraints are parametrized by one type. But this is supposed to be a generic tool: others may define constraints for two or more types. Next is the list of parameters that need to be passed to C in order to determine if these types satisfy the constraint. Now, the specialization:

template <template <typename...> typename C,
          typename... T>
struct is_detected_<void_t<C<T...>>,
                    C,
                    T...>
  : true_type {};

We can see that in the specialization, the second and the third argument are just “forwarded”, that is, anything there is a good match. In the first parameter we try to instantiate the input constraint with input types to determine correctness. If this succeeds, the specialization is a better match and we “return” answer true_type. Now, the alias template, which will hide the first parameter of the master template from the users:

template <template <typename...> typename C,
          typename... T>
  using is_detected = is_detected_<void, C, T...>;

As promised, it sets the first parameter to void. (is_detected is a likely candidate for the future standard.)

Now, with this tool at hand, we can build the type trait:

template <typename T>
struct is_acceptable : is_detected<traits::all, T>
{};

Thus, we have implemented the true/false part. Now, for the detailed description we will add a static member function diagnose, with static assertions that check each constraint individually:

template <typename T>
struct is_acceptable : is_detected<traits::all, T>
{
  static void diagnose()
  {
    using namespace traits;

    static_assert(is_detected<nested_result_type, T>::value,
                  "no nested type result_type");
     
    static_assert(is_detected<static_set_limit, T>::value,
                  "no static member function set_limit()");
    
    static_assert(is_detected<get_result, T>::value,
                  "no function get_result()");
    
    static_assert(is_detected<noexcept_get_result, T>::value,
                  "get_result() is not noexcept");
    
    static_assert(is_detected<get_result_return_type, T>::value,
                  "return type from get_result() "
                  "not const T::result_type&");
  }
};

This function is not a template itself. But it is only instantiated when it is really referenced in the program. Now we can appreciate why we have kept the redundant constraints. If there is no member function get_result in T, the first thing I want to report is "no static member function set_limit()", rather than that its result type is wrong.

Let’s test it. Let’s define a type that almost meets all requirements:

struct X
{
  using result_type = int*;
  static void set_limit(int);
  int get_result() noexcept; // <- bad return type
};

int main()
{
  static_assert(!is_acceptable<X>::value,
                "X does not have a desired interface");
}

We only get message:

"X does not have a desired interface"

So, now we want to learn why. We change the assertion to:

int main()
{
  is_acceptable<X>::diagnose();
}

Now (and only now) is the body of function diagnose instantiated and we get an error message containing:

return type from get_result() not const T::result_type&

For a full working example see here.

We didn’t mention it before, but instantiating all these templates does take time, and makes the compilation slower. This is where concepts could turn superior. We are able to emulate constraint checking, but we are using a tool (class template instantiations) that was not designed with this use case in mind, and is not optimized for it. Since language-level concepts are going to be a feature dedicated to constraint checking, it should be possible for compiler writers to optimize for this use case (without any class template instantiations). Meanwhile, we can consider it a reasonable trade-off: compilation is a bit slower, but understanding compiler errors is faster.

This entry was posted in programming and tagged , , , , , . Bookmark the permalink.

1 Response to A friendly type predicate

  1. Paul says:

    Although replacing `enable_if` with `static_assert` is not a good idea since the template is no longer constrained, but it can be used diagnose why a type does not fulfill the type. The same thing can be done with `TICK_TRAIT_CHECK` which will show which trait failed in a big hierarchy. This allows you to do something similar, for example:

    TICK_TRAIT(nested_result_type)
    {
        template<class T>
        auto require(const T& x) -> valid<
            typename T::result_type
        >;
    };
    
    TICK_TRAIT(static_set_limit)
    {
        template<class T>
        auto require(const T& x) -> valid<
            decltype(T::set_limit(1))
        >;
    };
    
    TICK_TRAIT(get_result)
    {
        template<class T>
        auto require(T&& x) -> valid<
            decltype(x.get_result())
        >;
    };
    
    TICK_TRAIT(noexcept_get_result)
    {
        template<class T>
        auto require(T&& x) -> valid<
            is_true_c<noexcept(x.get_result())>
        >;
    };
    
    TICK_TRAIT(get_result_return_type)
    {
        template<class T>
        auto require(T&& x) -> valid<
            has_type<decltype(x.get_result()), typename T::result_type const&>
        >;
    };
    
    TICK_TRAIT(is_acceptable, 
        nested_result_type<_>,
        static_set_limit<_>,
        get_result<_>,
        noexcept_get_result<_>,
        get_result_return_type<_>
    )
    {};
    

    And then doing TICK_TRAIT_CHECK(is_acceptable) will output this:

    error: implicit instantiation of undefined template 'tick::TRAIT_CHECK_FAILURE<get_result_return_type, is_acceptable >'

    Which tells which traits failed in the hierarchy, explicitly get_result_return_type. I guess this could be extended to allow custom messages.

    A better approach would just be to improve compilers to report the substitution failure directly, so this workaround is not needed.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.