文章目录
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 是 requires-expression,用来检查表达式是否有效,类似 lambda
通常应该少用 requires requires。它的问题在于暴露实现细节,但是缺乏语义表达。比如上述例子只有语法符号 ”+“,但是并没有从语义上描述这个操作的含义,比如 ”+” 既可以代表数值相加也可代表字符串相接,但是这里表达不出这种语义。因此如果一定要自定义约束,建议把 requires requires 封装成一个 concept,比如:
template<typename T>
concept Addable = requires(T a) { a + a; };
template<Addable T>
void f (T a, T b);
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;
}
这里可以把几个参数的类型替换为更 generic 的类型:,例如
- 把 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;
}
关键是要求 C 能够与 range 当中的元素相加。这里使用了 std::ranges::range_value_t<R> 把 range 当中的元素类型抽取出来,进而使用 result += number 表达式来表达约束。
可变参数模板 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)
std::forward
要区分一个变量/参数的 类型 与它的 值类别。值类别指的是左值/右值。
在以下几种场景下我们会关心表达式的值类别:
- 重载选择:
f(const T&)和f(T&&)会根据传入表达式是左值还是右值选择不同重载 - 引用绑定:非 const 左值引用不能绑定右值;右值引用不能绑定普通左值
- 移动与拷贝:右值允许移动构造/赋值;左值通常只能拷贝,除非显式
std::move - 完美转发:包装函数中的具体参数(因为有名字,所以)作为临时对象是左值,需要
std::forward恢复调用者传入时的类别 - 赋值:等号左侧必须是可修改的左值;右侧是左值或者右值会影响拷贝还是移动
下面结合一个具体的例子来区分左值、右值与 std::forward
template<typename T>
void relay(T&& x) {
target(x);
}
在调用 relay() 的时候会根据传入参数来推导 T,进而推导 T&&。
调用 relay(obj) 的时候,传入的参数的值类别是 左值
T被推导为Widget&(//??:为什么是引用?这是规则吗?0)T&&被推导为Widget& &&。由于有引用折叠,T&&实际被推导为Widget&- 这里不用深究什么是引用折叠。只要知道有多层引用的时候,引用类型会被 “拍扁” 为一层
调用 relay(Widget{}) 的时候,传入的参数的值类别是 右值
T被推导为 Widget(可以理解为这里发生了移动操作)T&&被推导为Widget&&
由于这里的函数签名是 void relay(T&& x),因此 T&& 的类型就是 “函数参数类型”,也就是在 relay() 内部的临时对象 x 的类型。 由于在 relay() 内部, x 有名字,因此它的值类别是左值。因此 target(x) 当中,x 会作为左值传入,选择左值参数对应的函数重载。
因此,无论在调用 relay() 的时候传入的参数的值类别是 左值 还是 右值,在转发到 target(x) 的时候,都会作为 左值 传入,因此只会选择左值参数对应的函数重载。如果希望在转发到 target(x) 的时候保留传入 relay() 时的参数的值类别,就需要使用 std::forward。
我们发现,调用 relay(obj) 或者 relay(Widget{}) 时,T 被推导出的类型是不一样的,分别是 Widget& 和 Widget。std::forward 正是使用这个差异来在调用 target() 的时候恢复传入 relay 的参数原本的值类型。
这里也有一个很重要的发现:当 relay() 的形式参数用右值引用的时候(也就是 T&& x):
- 如果传入的参数是左值,
T&&会被推导为左值引用Widget& - 如果传入的参数是右值,
T&&会被推导为右值引用Widget&& T&&都保留了传入参数的值类别- 因此,软件工程当中的
wrapper、relay等中间函数的形式参数通常定义为右值引用 - 有关此的具体说明,见附录
附录
const 及引用的知识
本 Unit 还涉及一些 const 以及引用的知识,整理如下:
- 左值/右值是一个标准;是否为引用是另一个标准
- 普通左值引用不允许绑定右值
std::string& ref = std::string("Alice"); // 错误
- 错误:非 const 左值引用不能绑定右值临时对象
const std::string& ref = std::string("Alice"); // 合法
- 合法:直接初始化引用变量时,临时对象的生命周期会延长到
ref的生命周期 - 函数参数绑定临时对象时,临时对象只保证存活到当前完整表达式结束
- 因此结论是:
const引用可以绑定左值或右值;非 const 左值引用只能绑定非 const 左值
软件工程里写 wrapper 函数的常见实践
template<class F, class ... Args>
decltype(auto) wrapper(F&& f, Args&&... args) {
return std::forward<F>(f) (
std::forward<Args> (args)...
);
}
这里的 wrapper 函数之所以用右值引用,目的是:
- 保留调用者的左值/右值属性。避免 wrapper 改变原调用语义
- 避免不必要的复制。右值可以直接移动到下一层
- 支持 move-only 类型。比如
std::unique_ptr不能复制,但是可以通过右值传入 wrapper,再移动给真正的函数 - 支持完美转发。工厂函数、emplace、日志/计时/锁保护 wrapper、异步任务提交等,都希望中间层尽量透明