2

I'm struggling a lot to understand how concepts and contraints works. Until now I always managed to avoid them with type traits and static_assert or std::enable_if (or even SFINAE) but I want to reconcile with (well, for that part at least, since I have the same understanding struggles for almost anything that was added with ).


I have a function with a variadic template parameter for which I want to accept only integral values that are above a threshold, let's say 2.

For that purpose I defined an integral concept, and then I add a requires clause to add the threshold constraint, which gives me this:

template <typename T>
concept integral = std::is_integral<T>::value;

template <integral ... Ts>
void f(Ts ... ts) requires (... && (ts > 2))
{
   //blablabla
}

This compiles fine. But when I try to call f() with argumentsn for example f(8, 6);, I always get a compile-time error (GCC): error: 'ts#0' is not a constant expression

The full error trace (GCC):

<source>: In substitution of 'template<class ... Ts>  requires (... && integral<Ts>) void f(Ts >...) requires (... && ts > 2) [with Ts = {int, int}]':
<source>:15:6:   required from here
<source>:8:6:   required by the constraints of 'template<class ... Ts>  requires (... && integral<Ts>) void f(Ts ...) requires (... && ts > 2)'
<source>:8:43: error: 'ts#0' is not a constant expression
   8 | void f(Ts ... ts) requires (... && (ts > 2))
     |                            ~~~~~~~~~~~~~~~^~
<source>: In function 'int main()':
<source>:15:6: error: no matching function for call to 'f(int, int)'
  15 |     f(8, 6);
     |     ~^~~~~~
<source>:8:6: note: candidate: 'template<class ... Ts>  requires (... && integral<Ts>) void f(Ts >...) requires (... && f::ts > 2)'
   8 | void f(Ts ... ts) requires (... && (ts > 2))
     |      ^
<source>:8:6: note:   substitution of deduced template arguments resulted in errors seen above

What I don't understand is why are the arguments required to be constant expressions, and why is 8 not considered as such ?

Jan Schultke
  • 17,446
  • 6
  • 47
  • 96
Fareanor
  • 5,900
  • 2
  • 11
  • 37
  • 1
    Constraints can’t use the values of (function) parameters, which are never constant expressions: what would happen for a call with a runtime value that “unexpectedly” failed to satisfy the constraint? – Davis Herring Jun 14 '23 at 10:00
  • 1
    this wouldnt work with static_assert or `std::enable_if` as well. If you make `ts` a template argument, it will work in both worlds. Very roughly speaking, concepts doesnt make you do new things, but it lets you write them down in a much nicer way – 463035818_is_not_an_ai Jun 14 '23 at 10:10
  • 1
    in other words, try to do the same without concept, by appliying good ol' SFINAE. If you got that then the transition to using a concept will be simpler – 463035818_is_not_an_ai Jun 14 '23 at 10:11
  • Oh right, I mixed compile-time mechanisms with runtime things... I feel dumb :/ – Fareanor Jun 14 '23 at 10:58

1 Answers1

0

The values of function parameters cannot be used in function constraints. A much simpler way to reproduce your problem is this:

#include <concepts>

// note: there already is a concept for integral types
//       also, we can use an abbreviated function template
void f(std::integral auto x) requires (x > 2)
{
    // ...
}

void foo() {
    f(0);
}

This produces the error:

<source>:3:40: error: substitution into constraint expression
                      resulted in a non-constant expression
void f(std::integral auto x) requires (x > 2)
                                       ^~~~~
<source>:9:5: note: while checking constraint satisfaction
              for template 'f<int>' required here
    f(0);
    ^

The value of x is not known at compile time, and function constraints can only verify compile-time properties. Even though we are calling f with x = 0, f needs to work with all possible arguments, not just 0.

If you want a type which can only hold values greater than two, you can do that:

template <std::integral T>
class greater_two_integer {
private:
    T v;
public:
    greater_two_integer(T x) : v{x} {
        assert(x > 2);
    }

    operator T() const noexcept {

// OPTIONAL: aid compiler optimizations
#if __has_cpp_attribute(assume)
        [[assume(v > 2)]];
#elif defined(__clang__)
        __builtin_assume(v > 2);
#elif __cpp_lib_unreachable == 202202L
        // from <utility>
        if (v <= 2) std::unreachable();
#endif

        return v;
    }
};
template <typename T>
void f(greater_two_integer<T> x) { /* ... */ }

void g(greater_two_integer<int> x) { /* ... */ }

int main() {
    f(greater_two_integer{10}); // OK
    g(10); // OK

    f(greater_two_integer{0}); // runtime check fails
    g(0); // runtime check fails
}

See live example

Notes on [[assume(v > 2)]]

We use [[assume]] (since C++23) to enable compiler optimizations based on the fact that greater_two_integer always contains a value which is > 2. The attribute is applied to an empty statement in the conversion operator, so when we extract the value from the object, it is undefined behavior if v <= 2.

This is safe, because the constructor contains assert(v > 2), meaning:

  • we always write a value v > 2 first, and
  • we can assume that we will later read a value v > 2

Technically, you can break this class invariant by std::memcpying into the class, or by writing its value through reinterpret_cast<int*>. However, with both of these methods you're obviously shooting yourself in the foot, they don't happen by accident.

Jan Schultke
  • 17,446
  • 6
  • 47
  • 96