C++20: Concepts 和泛型编程 C++20: Concepts 和泛型编程 C++20: Concepts 和泛型编程
这是学习 A Tour of C++ by Bjarne Stroustrup Chapter 8 “Concepts and Generic Programming” 的读书笔记。
concept 是用来在编译期检查【当前类型是否满足某些条件或者能否进行某些操作】的工具,可以看作是比 typename 要求更严格的关键字。
可以在模板参数位置用 concept 约束原本由 typename 声明的类型参数 即,原本是
template<typename T>
void f(T value);
表示 T 只要是一个类型即可 在使用了 concept 之后,可以用更具体的约束替换 “typename”,比如
template<std::integral T>
void f(T value);
等价于
template<typename T>
requires std::integral<T>
void f(T value);
这样,如果对一个不满足 std::integral 的类型调用了 f(),错误位置更接近调用点,而不是调用之后才出错
一般来说使用标准库当中的 concept 就足够了,这些 concept 命名良好、语义明确,可以让接口更易读。不要在代码当中到处写 requires
requires requires
所谓的 requires requires 格式,例如:
template<typename T>
requires requires(T a) { a + a; }
auto twice(T x);
其中第一个 requires 引入约束自居;第二个 requires(){} 是“检查这些表达式是否有效的 requires-expression”
通常应该少用 requires requires。它的问题在于暴露实现细节,但是缺乏语义表达。比如上述例子只有语法符号 ”+“,但是并没有从语义上描述这个操作的含义,比如 ”+” 既可以代表数值相加也可代表字符串相接,但是这里表达不出这种语义。因此如果一定要自定义约束,建议把 requires requires 封装成一个 concept,比如:
template<typename T>
concept Addable = requires(T a) { a + a; };
concept-based overloading
有些不同类型都支持同一个操作,但是能力等级不同,以迭代器为例:
- forward_iterator 支持 ++it,可以按步长为 1 向前移动
- random_access_iterator 步长任意,因此相当于支持更高的向前移动能力
由此可以对不同的 concept 实现函数重载。这种重载是在编译期确定的。 编译器在发现某个类型满足多种 concept 的时候,会选择最严格的 concept
template<std::forward_iterator Iter>
void advance(Iter p, int n) {
while(n--) {
p++;
}
}
template<std::random_access_iterator Iter>
void advance(Iter p, int n) {
p += n; // 这个版本显著比循环 p++ 更高效,但是只有 random_access_iterator 才支持
}
lifting
指的是把一个具体实现抽象为模板 而且很多时候可以 lift 的类型不止一个,比如
double sum(const std::vector<int>& values) {
double result = 0;
for (auto value: values) {
result += value;
}
return result;
}
这里可以以:
- 把 int 替换为满足数值运算要求的类型,例如 std::integral、std::floating_point 或自定义 Number concept
- 把 double 替换为 0LL, 0.0 或自定义数值类型
- 把 vector 替换为更一般的容器 变成:
template<std::ranges::forward_range R, typename C>
requires requires(C result, std::ranges::range_value_t<R> number) {
result += number;
}
C accumulate(const R& range, C result) {
for (auto number : range) {
result += number;
}
return result;
}
关键要求不是类型相同,而是表达式 result += number 合法;这可能依赖隐式转换或自定义 operator+=。
可变参数模板 variadic templates
普通的模板可以接收 fixed number of arbitrary types
variadic template 可以接收 arbitrary number of arbitrary types
因为有些时候我们希望参数类型、数量都灵活,比如 print(1);、 print(1, 2.2);、 print(1, 2.2, "hello");
于是可以写:
template<typename... Ts>
void print(const Ts&... values)
其中:
Ts...是类型参数包values...是对应的函数参数包
折叠表达式 fold expressions
有了可变参数模板,传入了多个不同类型的参数(参数包) 参数包不能像普通变量那样使用,编译器要对这些参数进行解析,需要把它们展开。 有 right fold 和 left fold 两种写法
right fold:
template<Number... T>
int sum(T... v) {
return (v + ... + 0);
}
这里会把 v 当中的每一个元素相加,相加的顺序是从最右边开始(最右边的元素先与0相加),然后逐步往左推进,比如:
sum(1, 2, 3, 4, 5);
展开结果可示意为 1 + (2 + (3 + (4 + (5 + 0))))
相应地有 left fold:
template<Number... T>
int sum(T... v) {
return (0 + ... + v);
}
相同参数在此处的相加顺序就是:
(((((0 + 1) + 2) + 3) + 4) + 5)
forwarding arguments
解决中间函数把收到的参数原样交给下一个函数的问题 当 wrapper 函数以及 target 函数涉及
- 原样包括类型、左值/右值属性
举例:
template<typename T>
void wrapper(T value) {
target(value);
}
这是按值传参,会构造 wrapper 内部的对象,不能保留原对象的身份、const 属性或左值/右值类别。target(value) 中的 value 是左值,但仍可传给 T&、const T& 或按值参数。
template<typename T>
void wrapper(T& value) {
target(value);
}
T& 是非 const 左值引用,只能绑定非 const 左值,不能绑定右值、临时对象或 const 左值。
template<typename T>
void wrapper(const T& value) {
target(value);
}
const T& 仍然是引用,可以绑定左值或右值,但不能通过它修改对象。右值传入后,在 target(value) 处也不再是右值,因为有名字的表达式是左值。
由此可见:在 std::forward 之前,没有一种写法能同时保留实参的引用、const 属性和左值/右值类别。
template<typename... Args>
void wrapper(Args&&... args) {
target(std::forward<Args>(args)...);
}
Args&& 在模板推导中是 forwarding reference:左值推导为 T&,右值推导为 T。std::forward<Args>(args)... 会恢复每个实参的值类别并展开参数包。工厂函数、emplace_back 和智能指针构造函数都使用这种模式。
附录
这里涉及一些 const 以及引用的知识,整理如下:
- 左值/右值是一个标准;是否为引用是另一个标准
- 普通左值引用不允许绑定右值
std::string& ref = std::string("Alice"); // 错误
- 错误:非 const 左值引用不能绑定右值临时对象
const std::string& ref = std::string("Alice"); // 合法
- 合法:直接初始化引用变量时,临时对象的生命周期会延长到
ref的生命周期 - 函数参数绑定临时对象时,临时对象只保证存活到当前完整表达式结束
- 因此结论是:
const引用可以绑定左值或右值;非 const 左值引用只能绑定非 const 左值