Search code examples
c++c++20c++-concepts

How can I check if my concept is sufficient for my function's implementation?


When I write a template function, I'd like to use concepts, and also to be confident that I haven't used something in my function over and above the "contract" that the concept specifies. See C++ core guidelines T.10.

Motivating -- but artificial -- example:

template <typename T>
concept maxable = std::totally_ordered<T>; // or require x < y -> bool

template <maxable T>
T max(T x0, T x1, T x2)
{
    T x = x0;
    if (x < x1)
        x = x1;
    if (x < x2)
        x = x2;
    return x;
}

max(0, 1, 2) /*->2*/ is ok but, oops, the following doesn't compile:

struct Val
{
    int x{};
    Val(int x) : x(x) {}
    Val(const Val&) = delete;
    auto operator<=>(const Val&) const = default;
};

auto v = max(Val{0}, Val{1}, Val{2});

with error 'Val::Val(const Val &)': attempting to reference a deleted function within the implementation of my function on line T x = x0;.

I can fix the bug in my concept by adding std::copy_constructible<T> upon which the compile error becomes (on MSVC)

error C2672: 'max': no matching overloaded function found
could be 'T max(T,T,T)'
note: the associated constraints are not satisfied
note: the concept 'maxable<Val>' evaluated to false
note: the concept 'std::copy_constructible<Val>' evaluated to false

This is what I want -- the user gets a warning about a constraint not being satisfied rather than having to look through my implementation. But is there some convenient way to check that I haven't forgotten something in my concept, or, conversely, used something I shouldn't have? I'm assuming that the experts who write std library algorithms must have some way of getting this right -- or am I thinking about this all wrong?


Solution

  • The short answer is that it's impossible. C++ does not have so-called constrained generics, where the compiler makes sure that nothing more than what your constraints guarantee is usable within a function template.

    One of the most common issues is forgetting about constraints for initialization and assignment. You forgot to account for the copy assignment operator, which is only covered by std::copyable.

    Here's how to do it properly:

    template <typename T>
    concept maxable = std::totally_ordered<T> && std::copyable<T>;
    
    template <maxable T>
    T max(T x0, T x1, T x2)
    {
        T x = x0;   // call to non-explicit copy constructor (std::copyable)
        if (x < x1) // comparison (std::totally_ordered)
            x = x1; // copy assignment operator (std::copyable)
        if (x < x2) // comparison (std::totally_ordered)
            x = x2; // copy assignment operator (std::copyable)
        return x;   // non-explicit move constructor or copy elision (std::copyable)
    }               // OK, function is sufficiently constrained :)
    

    However, max is arguably over-constrained because we don't need a type to be copyable, and we don't need assignment operators for max. With a different implementation, there can be lesser constraints.

    template <typename T>
    concept maxable = std::totally_ordered<T> && std::move_constructible<T>;
    
    template <maxable T>
    T max(T x0, T x1, T x2)
    {
        return x0 < x1 ? x1 < x2 ? std::move(x2) : std::move(x1)
                       : x0 < x2 ? std::move(x2) : std::move(x0);
    }
    

    If you selected a reference like std::max does, you could even avoid std::move_constructible.

    Conclusion

    There are two lessons to be learned:

    1. It's extremely difficult not to under-constrain. Gotchas like copy-constructible types with no assignment operators are easily forgotten. Note that std::copy_constructible<T> also requires std::convertible_to<T, T> to account for explicit constructors. When designing your own concepts, you also have to remember such details.

    2. It's extremely difficult not to over-constrain. There often exists a simpler implementation that doesn't require copyability, or that doesn't require assignment operators. This is problematic because changing constraints affects the ordering of functions in overload resolution, and therefore breaks API.


    Note: because copy-initialization requires non-explicit constructors, and because list-initialization may use std::initializer_list constructors (which win against copy constructors in overload resolution, by the way), you should prefer direct-initialization when in doubt. std::copyable covers copy-initialization, but it's still a good habit to use direct-initialization in function templates.