C++20: Constriants & Concepts
2/22/2021
Author's Profile Avatar
Constraints 和 concepts 是 C++20 引入的两个非常受欢迎的特性。Constraints 和 concepts 的引入使得原本许多只能依靠 SFINAE 等模板黑魔法的写法变得十分简单和易于理解。考虑如下模板函数原型:
template <typename From, typename To>
To& cast(From& obj);
我们希望实现 cast 函数,使得:
  • 当 To 是 From 的基类时,使用 static_cast 将 obj 转换为 To &
  • 当 To 是 From 的子类时,使用 dynamic_cast 将 obj 转换为 To &
在 C++20 以前,简单的做法是使用 C++17 引入的 if constexpr
template <typename From, typename To>
To& cast(From& obj) {
  if constexpr (std::is_base_of<To, From>::value) {
    return static_cast<To &>(obj);
  } else {
    return dynamic_cast<To &>(obj);
  }
}
当然如果在 C++17 以前,便只能使用 SFINAE 黑魔法将两种情况分开到两个重载中讨论:
template <typename From, typename To,
          std::enable_if_t<std::is_base_of_v<To, From>, int> = 0>
To& cast(From& obj) {
  return static_cast<To &>(obj);
}

template <typename From, typename To,
          std::enable_if_t<std::is_base_of_v<From, To>, int> = 0>
To& cast(From& obj) {
  return dynamic_cast<To &>(obj);
}
SFINAE 的问题无需多说。其不仅机制复杂,容易出错,且出错时容易造成令人绝望的编译器错误消息。
从 C++20,Constraints 结合 concepts 使用,可以在类似于上面的场景下大大提升代码可读性,降低出错率,同时在出错时大幅度提升编译器错误消息的可读性:
template <typename From, typename To>
  requires std::is_base_of_v<To, From>
To& cast(From& obj) {
  return static_cast<To &>(obj);
}

template <typename From, typename To>
  requires std::is_base_of_v<From, To>
To& cast(From& obj) {
  return dynamic_cast<To &>(obj);
}

struct Base {
  virtual ~Base() noexcept;
};
struct Derived : public Base { };

struct Foo { };

Base& base;
Derived& derived;

cast<Derived, Base>(derived); // Use static_cast
cast<Base, Derived>(base);    // Use dynamic_cast

cast<Base, Foo>(base); // Error
/** Compiler generated error message:
<source>:31:10: error: no matching function for call to 'cast'
  return cast<Base, Foo>(obj);
         ^~~~~~~~~~~~~~~
<source>:5:5: note: candidate template ignored: requirement 'std::is_base_of_v<Foo, Base>' was not satisfied [with From = Base, To = Foo]
To& cast(From& obj) {
    ^
<source>:11:5: note: candidate template ignored: requirement 'std::is_base_of_v<Base, Foo>' was not satisfied [with From = Base, To = Foo]
To& cast(From& obj) {
    ^
1 error generated.

*/
即使在更加一般的场景中,constraints 和 concepts 的引入也能大幅度改善 C++ 长期被诟病的编译错误信息冗长而信息密度低的问题。例如,STL 中的 std::sort 有如下重载:
template <typename Iter>
void sort(Iter first, Iter last);
std::sort 对上述重载中包含的模板参数和函数参数有如下的约定:
  • Iter 必须是一个支持随机访问的迭代器(即必须满足 LegacyRandomAccessIterator 的各项要求);
  • Iter 这个迭代器经解引用操作后的类型,必须满足:
  • 必须是可以被移动构造和被移动赋值的;
  • ADL 可以找到对应的 operator< 的定义,用于比较两个值的大小;
  • ADL 可以找到对应的 swap 函数的定义,用于交换两个值。
在 C++20 以前,如果我们在调用 std::sort 时,传入的模板参数和函数参数不满足上述的条件,则编译器可能会给出非常冗长且看似无关的错误信息:
int a, b;
std::sort(a, b);

/** Compiler will generate some error messages like:
In file included from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/algorithm:62,
                 from <source>:1:
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h: In instantiation of 'void std::__insertion_sort(_RandomAccessIterator, _RandomAccessIterator, _Compare) [with _RandomAccessIterator = int; _Compare = __gnu_cxx::__ops::_Iter_less_iter]':
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:1886:25:   required from 'void std::__final_insertion_sort(_RandomAccessIterator, _RandomAccessIterator, _Compare) [with _RandomAccessIterator = int; _Compare = __gnu_cxx::__ops::_Iter_less_iter]'
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:1977:31:   required from 'void std::__sort(_RandomAccessIterator, _RandomAccessIterator, _Compare) [with _RandomAccessIterator = int; _Compare = __gnu_cxx::__ops::_Iter_less_iter]'
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:4859:18:   required from 'void std::sort(_RAIter, _RAIter) [with _RAIter = int]'
<source>:4:19:   required from here
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:1849:3: error: no type named 'value_type' in 'struct std::iterator_traits<int>'
 1849 |   __val = _GLIBCXX_MOVE(*__i);
      |   ^~~~~
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:1851:8: error: invalid type argument of unary '*' (have 'int')
 1851 |        *__first = _GLIBCXX_MOVE(__val);
      |        ^~~~~~~~
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:1849:3: error: no type named 'value_type' in 'struct std::iterator_traits<int>'
 1849 |   __val = _GLIBCXX_MOVE(*__i);
      |   ^~~~~
In file included from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algobase.h:71,
                 from /opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/algorithm:61,
                 from <source>:1:
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/predefined_ops.h: In instantiation of 'constexpr bool __gnu_cxx::__ops::_Iter_less_iter::operator()(_Iterator1, _Iterator2) const [with _Iterator1 = int; _Iterator2 = int]':
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:82:17:   required from 'void std::__move_median_to_first(_Iterator, _Iterator, _Iterator, _Iterator, _Compare) [with _Iterator = int; _Compare = __gnu_cxx::__ops::_Iter_less_iter]'
/opt/compiler-explorer/gcc-10.2.0/include/c++/10.2.0/bits/stl_algo.h:1924:34:   required from '_RandomAccessIterator std::__unguarded_partition_pivot(_RandomAccessIterator, _RandomAccessIterator, _Compare) [with _RandomAccessIterator = int; _Compare = __gnu_cxx::__ops::_Iter_less_iter]'

// ... more error messages not included
*/
即便是有经验的 C++ 程序员也需要花费不少时间从编译器产生的冗长的、间接的错误信息中分析出错误的真正原因。
我们可以发现,造成这种情况的原因在于 std::sort 函数在其声明中没有对前述的若干限制条件进行显式的声明。在 C++20 之前,这种对模板参数类型的语义性质的约束声明是无法做到的。从 C++20 开始,我们可以结合使用 constraints 和 concept 对模板参数类型进行语义级别的限制,如下所示:
template <typename Iter>
concept RandomAccessIterator = requires {
  // `std::iterator_traits<Iter>` should be able to be instantiated
  typename std::iterator_traits<Iter>;
  // Type `std::iterator_traits<Iter>::iterator_category` should be defined
  typename std::iterator_traits<Iter>::iterator_category;
  // Type `std::iterator_traits<Iter>::iteartor_category` should be `std::random_access_iterator_tag`
  requires std::same_as<
    typename std::iterator_traits<Iter>::iterator_category,
    std::random_access_iterator_tag>;
};

template <typename Iter>
  requires RandomAccessIterator<Iter> &&
    requires (const typename std::iterator_traits<Iter>::value_type & lhs,
              const typename std::iterator_traits<Iter>::value_type & rhs) {
      // `operator<` should be defined for the iterated objects
      lhs < rhs;
    }
void sort(Iter first, Iter last);
经过这一番改造,如果按照如下的方式调用 sort 函数:
int a, b;
sort(a, b);
编译器将给出如下的错误消息:
<source>:22:5: error: no matching function for call to 'sort'
    sort(a, b);
    ^~~~
<source>:19:6: note: candidate template ignored: constraints not satisfied [with Iter = int]
void sort(Iter first, Iter last);
     ^
<source>:14:12: note: because 'int' does not satisfy 'RandomAccessIterator'
  requires RandomAccessIterator<Iter> &&
           ^
<source>:7:40: note: because 'typename std::iterator_traits<Iter>::iterator_category' would be invalid: no type named 'iterator_category' in 'std::iterator_traits<int>'
  typename std::iterator_traits<Iter>::iterator_category;
                                       ^
1 error generated.
可以看到,错误消息变得简洁而精确了不少,很容易分析出来引发错误的原因是因为向 sort 函数传入的参数不是一个支持随机访问的迭代器。

Concept

C++20 引入了 concept 这一新的语言元素,用于为类型约束(即 constraints)提供一个符号名称。显然,由于 concept 需要对一类类型的公有属性进行定义,concept 只能出现在模板定义中。示例如下:
template <typename T>
concept CopyConstructible = std::is_copy_constructible<T>::value;
可以看到,concept 的语法非常简单,定义 concept 时只需要提供一个常量布尔表达式即可。上述 concept 即可用于描述一个类型是否可以被复制构造。当 concept 模板实例化后,若这个常量布尔表达式被编译器求值为真,则模板参数满足这个 concept;否则模板参数不满足这个 concept。
需要注意到的是,相较于普通的模板,concept 模板有不少限制。首先,concept 模板不能被偏特化、全特化和显式模板实例化。其次,下一节将介绍的 constraints 不能用于 concept 模板。
当提供模板参数后,concept 本身可以被当作一个常量布尔表达式,该表达式的值为模板参数是否满足这个 concept。例如:
constexpr bool a = CopyConstructible<std::vector<int>>;  // a is true
constexpr bool b = CopyConstructible<std::unique_ptr<int>>;  // b is false

Constraints

C++20 引入了 constraints 用于在语义层面对模板参数进行约束。Concept 即为一系列 constraint 的符号名称,方便重用 constraints 的逻辑。
在形式上,C++20 定义了三个种类的 constraints,其中的两种是递归定义的:
  • 合取约束(conjunction)。合取约束具有如下形式:A && B,其中 A 和 B 都是 constraint。模板参数满足合取约束,当且仅当这个参数同时满足合取约束的两个子约束。
  • 析取约束(disjunction)。析取约束具有如下形式:A || B,其中 A 和 B 都是 constraint。模板参数满足析取约束,当且仅当这个参数至少满足析取约束的某一个子约束。
  • 原子约束(atomic)。 原子约束是一个常量布尔表达式,且不包含 && 和 || 运算符。模板参数满足原子约束,当且仅当将模板参数代入这个常量布尔表达式后,编译器求值这个表达式的结果为真。 需要注意,用于原子约束的常量表达式的类型必须是 bool,不能是其他任何类型,即使这些类型可以被显式或隐式转换为 bool
在定义模板时,为模板参数添加 constraints 的方式是多样的。例如,如果需要用已有的 concept 对模板类型参数加以约束,可以直接在模板参数列表中指定相应的 concept:
template <CopyConstructible T>
std::vector<T> copy(const std::vector<T>& values);
注意在上述情况下,concept 的模板参数可以省略。此时 concept 的模板参数即为 T
对于任意 constraint,都可以在模板参数之后、函数返回类型或 class / union 等关键字之前通过 requires 从句指定 constraint:
template <typename T>
  requires CopyConstructible<T>
std::vector<T> copy(const std::vector<T>& values);
在 C++20 引入的简化函数模板语法中,如果需要用已有的 concept 对模板类型参数加以约束,可以在 autodecltype(auto) 前指定相应的 concept:
void foo(CopyConstructible auto value);

// Logically equivalent to:
template <CopyConstructible T>
void foo(T value);
对于任意 constraint,都可以在模板函数声明的末尾通过 requires 从句指定 constraint:
template <typename T>
std::vector<T> copy(const std::vector<T>& values)
  requires CopyConstructible<T>;
上述四种为模板参数指定 constraint 的方式可以混用。例如:
template <typename T>
concept CopyConstructible = std::is_copy_constructible<T>::value;

template <typename T>
concept MoveConstructible = std::is_move_constructible<T>::value;

template <typename T>
  requires CopyConstructible<T> && MoveConstructible<T>
void foo(T value);

template <CopyConstructible T>
  requires MoveConstructible<T>
void foo(T value);

template <typename T>
  requires CopyConstructible<T>
void foo(T value) requires MoveConstructible<T>;
当混用时,所有出现的 constraint 在逻辑上是合取关系。在上面的示例代码中,三个 foo 函数的声明在逻辑上都是等价的。

Normalization

前文提到,C++20 引入的 constraints 机制会参与到函数模板的重载决议中。直观上讲,函数模板重载决议会选择模板参数匹配的、满足 constraints 中给出的约束条件的、且约束条件最强的函数模板重载。例如:
template <CopyConstructible T>
void foo(const T& value); // #1

template <MoveConstructible T>
void foo(const T& value); // #2

template <typename T>
  requires CopyConstructible<T> && MoveConstructible<T>
void foo(const T& value); // #3

std::vector<int> v;
std::unique_ptr<int> p;

foo(v); // Calls #3, since #3 is "more strict" than #1 and #2
foo(p); // Calls #2, since #1 is not viable and #3 is "more strict" than #2
这一特性的引入实际上大大增加了函数模板重载决议的复杂性。引入的问题包括:
  • 如何鉴别 overload 和 redeclaration?换句话说,如何判定两个函数模板“重载”实际上是等价的(即后一个是前一个的 redeclaration 而不是 overload)?
  • 对于两个 constraint A 和 B,如何判定其中一个比另一个“约束更强”?换句话说,如何判定一个 constraint 蕴含(implies)另一个?
对于第一个问题,C++20 给出的解决方案是,如果两个 declaration 构成 redeclaration 关系,那么在语法层面它们必须完全相同(same syntactic form)。例如:
template <CopyConstructible T>
  requires MoveConstructible<T>
void foo(T value);

template <CopyConstructible T>
  requires MoveConstructible<T>
void foo(T value);  // OK, redeclaration

template <typename T>
  requires CopyConstructible<T> && MoveConstructible<T>
void foo(T value);  // Ill-formed, not considered redeclaration, although logically equivalent
对于第二个问题,解决方案则要复杂许多,因为需要考虑到 constraints 中蕴含的逻辑关系。
考虑两个模板声明。假设第一个模板有约束 P,第二个模板有约束 QPQ 中包含了一系列的原子约束和将这些原子约束连接起来的合取和析取操作,且所有的 concept 均展开为了定义 concept 时指定的常量布尔表达式。不妨假设我们希望判定 P 是否蕴含 Q。布尔逻辑告诉我们,我们一定能够将 PQ 转化为逻辑等价的合取范式和析取范式。在这里,我们将 P 化为具有如下形式的析取范式:P1 and P2 and P3 and ... and Pn,将 Q 化为具有如下形式的合取范式:Q1 or Q2 or Q3 or ... or Qn 。显然,P 蕴含 Q 当且仅当存在一个 PiQj ,使得 Pi 蕴含 Qj
我们需要重点考虑如何判定两个原子约束的蕴含关系。C++20 给出的解决方案为,如果两个原子约束在源码层面是由同一个表达式给出的,且携带的模板参数也相同,那么它们是等价的,互相蕴含对方。否则这两个原子约束没有任何等价关系,也互不蕴含对方。这一特性导致编写依靠 constraint 进行函数模板重载决议的代码非常容易出错:
template <typename T>
concept CopyConstructible = std::is_copy_constructible<T>::value;

template <typename T>
  requires CopyConstructible<T>
void foo(T value);  // #1

template <typename T>
  requires std::is_copy_constructible<T>::value && std::is_move_constructible<T>::value
void foo(T value);  // #2

std::vector<int> a;
foo(a);  // Error: ambiguous
在上面的示例代码中,foo(a) 这个调用是 ambiguous 的,因为编译器无法推导出函数 #2 的模板参数约束蕴含函数 #1 的模板参数约束。虽然 #2 的 std::is_copy_constructible 这一约束表达式与 #1 的 std::is_copy_constructible 这一约束表达式形式上相同,但它们不是源代码中的同一个表达式,因此编译器认为它们是不等价的。

Requires 表达式

C++20 引入了 requires 表达式用于评估类型是否满足一定的语义属性,常出现在 constraints 的约束定义中。需要注意,requires 表达式和 requires 从句是完全不同的两个语法结构:前者是一个表达式,用于评估类型是否满足一定的语义属性;后者是嵌套在模板声明和定义中的语法结构,用于表达对模板类型的约束:
template <typename T>
concept CopyConstructible = requires (const T& value) {
  T obj(value);
};  // requires expression that evaluates whether T is copy constructible

template <typename T>
  requires CopyConstructible<T>
void foo(T value);  // requires clause

template <typename T>
  requires requires (const T& value) { T obj(value); }
void foo(T value);
// Ad-hoc form, note that `requires` is used twice,
// once for requires clause and once for requires expression
Requires 表达式具有如下的基本结构:
requires (parameter-list) { seq }
其中 parameter-list 是一个可以省略的参数列表,用于声明 seq 中需要使用到的变量符号。seq 是一系列使用分号结尾的语句序列。直观上来说,requires 表达式的值为真,当且仅当 seq 中的所有语句不会触发任何编译错误。需要注意,seq 中的所有语句都是不会被执行的:编译器只会进行必要的语法和语义检查,不会实际执行这些语句。
seq 中的语句可以有如下的形式:
普通的语句,例如 a+b、函数调用等,用于约束这个语句是合法的。例如:
template <typename Lhs, typename Rhs>
concept Addable = requires (const Lhs& lhs, const Rhs& rhs) {
  lhs + rhs;
};

constexpr bool a = Addable<int, int>;  // true
constexpr bool b = Addable<int, std::vector<int>>;  // false
typename 关键字起始的 type requirements,用于约束一个类型或模板实例是存在的。例如:
template <typename T>
concept Container = requires {
  typename T::value_type;
  typename T::reference;
  typename T::pointer;
};

constexpr bool a = Container<std::vector<int>>;  // true
constexpr bool b = Container<int>;  // false

struct Foo {
  static constexpr int value_type = 0;
};

constexpr bool b = Container<Foo>;  // false, since Foo::value_type is not a type
复合约束(compound requirements),由一个表达式和一个 constraints 组成,用于约束这个表达式是合法的且表达式的类型满足约束。例如:
template <typename T>
concept Clonable = requires (const T& obj) {
  { obj.clone() } -> std::same_as<T>;
};

struct Foo {
  Foo clone();
};

struct Bar {
  Foo clone();
};

struct Baz {};

constexpr bool a = Clonable<Foo>;  // true
constexpr bool b = Clonable<Bar>;  // false, since `Bar::clone` returns `Foo` instead of `Bar`
constexpr bool c = Clonable<Baz>;  // false, since `Baz::clone` is ill-formed
requires 关键字起始的嵌套约束,用于直接通过一个 constraint 表达式进行约束。例如:
template <typename T1, typename T2>
concept IdiotSameAs = requires {
  require std::same_as<T1, T2>;
};

constexpr bool a = IdiotSameAs<int, int>;  // true
constexpr bool b = IdiotSameAs<int, bool>; // false