Contents

C++ quick tips: Concepts, type constraints and c++20 coding style

c++20 introduced concepts to the standard thanks to which now, we can specify constraints and restrictions on template parameters that a given type, variable or a function template accepts. Similarly as with e.g. virtual classes defining interfaces for a family of types through inheritance, concepts allow creating interfaces for generic code. With concepts, just by looking at the template declaration we know what to expect and what types are accepted. Having a simple template like:

1
2
3
4
5
6
7
8
9
template <std::integral T>
class IntCalc {
public:
    using result = T;

    result sum(T a, T b) const noexcept {
        return a + b;
    }
};

It’s obvious that IntCalc can only work with types adhering to std::integral concept.

I can define a concept for IntCalc specifically.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
template <template <class> class CalcT, typename T>
concept IntCalcLike = requires(CalcT<T> calc, T a, T b) {
    // simple requirement
    // https://en.cppreference.com/w/cpp/language/requires#Simple_requirements
    calc.sum(a, b);

    // type requirement
    // https://en.cppreference.com/w/cpp/language/requires#Type_requirements
    typename CalcT<T>::result;
};

With the above at hand, I can declare a CalcUser class template that only accepts types adhering to the IntCalcLike concept:

1
2
3
4
5
6
7
8
template <std::integral T, template <class> class CalcT>
requires IntCalcLike<CalcT, T>
class CalcUser {
public:
    CalcT<T>::result getStatus() const {
        return CalcT<T>{}.sum(123, 456);
    }
};

What about auto?

Type constraints can be specified on auto declarations as well. Consider the following:

1
std::integral auto x = foo();

This statement expresses a declaration where x’s deduced type must be conformant with the std::integral template.

The deduced type constraint must directly precede the auto keyword. There’s a quirky consequence to this:

1
2
3
4
5
6
7
8
// invalid syntax - "auto" must follow the constraint
std::integral const auto x = ...;

// OK
std::integral auto const x = ...;

// OK
const std::integral auto x = ...;

const in the beginning of the declaration seems too “detached” for lack of a better word, which makes me think that “east-coast” const style is the way to go :).

template <std::integral auto X> what?

With concepts, auto sneaks in to template arguments syntax as well:

1
2
3
4
5
6
template <std::integral T, std::integral auto SizeV>
struct Wrapper {
    // T is a type that must be compliant with `std::integral` concept
    // SizeV is a value who's deduced type must be conformant with `std::integral`
    std::array<T, SizeV> arr;
};

At first glance, the difference between template <concept T> and template <concept auto V> might not be obvious. I wasn’t sure myself what to think of it, the first time I’ve seen it. The meaning between two expressions is completely different though:

  • template <concept T> - type constrained by a concept
  • template <concept auto V> - value who’s deduced type is constrained by concept

This syntax can be used in function templates as well:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
auto sum(std::integral auto a, std::integral auto b) {
    return a + b;
}

// `sum` is equivalent to the below, but so much nicer
template <std::integral T1, std::integral T2>
auto sum_equiv(T1 a, T2 b) -> decltype(a + b) {
    return a + b;
}

std::integral auto const res =
    sum(false, true) +
    sum(123u, 456u) +
    sum(static_cast<char>(1), static_cast<char>(2));

Constraints can be applied to return values as well:

1
2
3
4

std::integral auto bar() {
    ...
}

Another context where type deduction happens quite often are range for loops:

1
2
3
4
std::vector v{1,2,3,4};
for (std::integral auto const& i : v) {
    std::cout << i << std::endl;
}

Concept based overloading

Given a set of type constraints defined by concepts, it’s possible to have a concept based function overloading.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
std::floating_point auto
sum(std::floating_point auto a,
    std::floating_point auto b) 
{
    std::cout << "sum of floats" << std::endl;
    return a + b;
}

std::integral auto
sum(std::integral auto a,
    std::integral auto b) 
{
    std::cout << "sum of integrals" << std::endl;
    return a + b;
}

int main() {
    sum(1, 2);
    sum(1.0f, 2.0f)
}

Types compliant with different concepts will be dispatched to different overloads.

decltype(auto)

Just as a reminder, decltype gives back the type of a variable or an expression given as a parameter. decltype(auto) is used to preserve exact type and category of an expression. It is used mostly for perfect forwarding of return values, as during template type deduction references, under variety of conditions, might be dropped.

Let’s start with some basic concepts:

1
2
3
4
5
6
7
8
template <typename T>
concept Indexable = requires(T t) {
    t[0];
};

auto getIndex(Indexable auto& container, std::integral auto i) {
    return container[i];
}

I can use getIndex the following way:

1
2
3
std::vector v{1,2,3,4};

std::integral auto a = getIndex(v, 0);

This does not involve perfect forwarding yet. I’d need perfect forwarding to preserve the actual type of the return value from the container - which in case of vector is gonna be the reference to the value type (most of the time):

1
2
3
4
5
6
7
8
decltype(auto)
getIndex(Indexable auto& container, std::integral auto i) {
    return container[i];
}

// ...
std::vector v{1,2,3,4};
getIndex(v, 0) = 123;

With c++20, it’s possible to constraint decltype(auto) as well:

1
2
3
4
5
6
7
template <typename T>
concept Reference = std::is_reference_v<T>;

Reference decltype(auto)
getIndex(Indexable auto& container, std::integral auto i) {
    return container[i];
}

Thanks to the above, if we’re working with a container who’s operator[] doesn’t return a reference (like std::vector<bool>), then the compiler will error out straight away:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<source>: In instantiation of ‘decltype(auto) [requires ::Reference<<placeholder>, >] getIndex(auto:38&, auto:39) [with auto:38 = std::vector<bool>; auto:39 = int]’:
<source>:20:13:   required from here
   20 |     getIndex(v, 0) = true;
      |     ~~~~~~~~^~~~~~
<source>:15:21: error: deduced return type does not satisfy placeholder constraints
   15 |     return container[i];
      |            ~~~~~~~~~^
<source>:15:21: note: constraints not satisfied
<source>:11:9:   required for the satisfaction of ‘Reference<decltype(auto) [requires ::Reference<<placeholder>, >]>’ [with decltype(auto) [requires ::Reference<<placeholder>, >] = std::_Bit_reference]
<source>:11:26: note: the expression ‘is_reference_v<T> [with T = std::_Bit_reference]’ evaluated to ‘false’
   11 | concept Reference = std::is_reference_v<T>;
      |                     ~~~~~^~~~~~~~~~~~~~~~~
Compiler returned: 1

Conclusion

In my opinion, concepts are the best addition, greatly improving working with generic code. Concepts allow for templates to be more expressive, safe and form a true set of interfaces between objects involved.

References

  1. What are C++20 concepts and constraints? How to use them?
  2. C++ Weekly - Ep 296 - Constraining auto in C++20