打开导航 打开导航 Open menu
量化交易系统开发(C++)/ 期权策略与风控 / 行业研究分析
量化交易系统開發(C++)/ 期权策略与风控 / 行业研究分析
C++ trading systems / options strategy & risk controls / equity research
adrian@adrianxv.cn
文章目录

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 的类型:,例如

  1. 把 int 替换为满足数值运算要求的类型,例如 std::integral、std::floating_point 或自定义 Number concept
  2. 把 double 替换为 0LL, 0.0 或自定义数值类型
  3. 把 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

要区分一个变量/参数的 类型 与它的 值类别。值类别指的是左值/右值。

在以下几种场景下我们会关心表达式的值类别:

  1. 重载选择:f(const T&) 和 f(T&&) 会根据传入表达式是左值还是右值选择不同重载
  2. 引用绑定:非 const 左值引用不能绑定右值;右值引用不能绑定普通左值
  3. 移动与拷贝:右值允许移动构造/赋值;左值通常只能拷贝,除非显式 std::move
  4. 完美转发:包装函数中的具体参数(因为有名字,所以)作为临时对象是左值,需要 std::forward 恢复调用者传入时的类别
  5. 赋值:等号左侧必须是可修改的左值;右侧是左值或者右值会影响拷贝还是移动

下面结合一个具体的例子来区分左值、右值与 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 函数之所以用右值引用,目的是:

  1. 保留调用者的左值/右值属性。避免 wrapper 改变原调用语义
  2. 避免不必要的复制。右值可以直接移动到下一层
  3. 支持 move-only 类型。比如 std::unique_ptr 不能复制,但是可以通过右值传入 wrapper,再移动给真正的函数
  4. 支持完美转发。工厂函数、emplace、日志/计时/锁保护 wrapper、异步任务提交等,都希望中间层尽量透明