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

C++:指针和容器 C++:指针和容器 C++:指针和容器

裸指针只表示地址,不表示所有权,容易造成悬空或重复释放。

智能指针封装了指针并提供了一系列简化正确使用的方法:

  • unique_ptr 表示唯一拥有
  • shared_ptr 表示共享拥有

指针

引用其实是 “a pointer with implicit dereferencing”

owning pointer 表示对最终 delete 对象负责的指针。与之相对的是 non-owning pointer。unique_ptr 和 shared_ptr 都是 owning pointers,区别在于前者有唯一拥有者;后者有多个共同拥有者。

owning pointer 包括:

  1. unique_ptr
  2. shared_ptr

non-owning pointer 包括:

  1. weak_ptr
  2. span
  3. string_view

std::unique_ptr

唯一所有权。离开作用域时自动释放对象,适合表达明确的资源拥有关系。

对于各种类型的资源,标准库都有 resource handle (资源句柄)来自动处理 RAII,比如 std::vector 之于内存、std::thread 之于操作系统线程、std::lock_guard 之于 mutex。而之于裸指针,标准库提供 std::unique_ptr 和 std::shared_ptr 作为 handle 来管理。因此可以说这两个智能指针用于管理动态分配内存的对象的生命周期。

std::unique_ptr 不允许复制,只允许移动

获得 unique_ptr 的方式:

  1. 使用 make_unique() 创建 std::unique_ptr
  2. 用裸指针直接构造:std::unique_ptr p(new Foo());
  3. 从另一个 unique_ptr 移动
std::unique_ptr p2 = std::move(p1);

std::shared_ptr

共享所有权。最后一个拥有者销毁时释放对象,但有引用计数成本,且要防止循环引用。

std::shared_ptr 与 std::unique_ptr 的本质区别在于,前者允许拷贝,后者不允许拷贝。两者都允许移动。

缺点是使得对象生命周期难以预测

获得 shared_ptr 的方式

  1. std::make_shared:auto p = std::make_shared(args…);
  2. 用裸指针构造:std::shared_ptr p(new Foo());
  3. 从另一个 shared_ptr 复制:auto p2 = p1; 此时引用计数 +1。
  4. 从另一个 shared_ptr 移动:auto p2 = std::move(p1);
  5. 从 weak_ptr 获得:auto p = wp.lock();
  6. 从 unique_ptr 转成 shared_ptr
std::unique_ptr<Foo> up = std::make_unique<Foo>();
std::shared_ptr<Foo> sp = std::move(up);

注意,智能指针仍然是指针。如果可以通过容器等方式更好地管理资源,也不应使用智能指针。仅当确实需要指针语义的时候使用智能指针:

  1. 需要共享所有权的时候使用 shared_ptr
  2. 在传统 OOP 代码当中指向多态对象时使用 unique_ptr。(因为不知道具体类型是什么)

std::weak_ptr

指向由 std::shared_ptr 拥有的对象的指针,必须转成 std::shared_ptr 才能访问对象

std::weak_ptr 有一个 lock() 成员函数,可以用来获取一个对应的 std::shared_ptr

// wp 表示 weak_ptr, sp 表示 shared_ptr
if (auto sp = wp.lock()) {
    sp->foo();
}

weak_ptr 是对象的非拥有型观察者,但是影响【shared_ptr 控制块的生命周期的维护】。

【shared_ptr 的控制块】保存强引用计数(有多少个 shared_ptr 指向该对象)和弱引用计数(有多少个 weak_ptr 关联这个对象)。

  • 当强引用计数 > 0 的时候:对象存在;
  • 当强引用计数 = 0 但是弱引用计数 > 0 的时候:对象析构,但是控制块不析构。此时控制块存在的意义是让剩余的的 weak_ptr 还能够安全判断对象已经析构了,而不会直接悬空
  • 当强、弱引用计数都为 0 的时候:对象析构,控制块也析构

std::span

std::span 本质上保存 首元素指针 和 元素数量,解决传统接口中很多错误都来源于 “指针” 与 “长度信息” 分离容易导致越界的问题。std::span 其实是一种 std::ranges::view,它也不拥有底层数据。底层元素必须比 span 活得久,否则 span 会悬空。

std::span 提供三类能力:

  1. 访问元素:operator[]、front()、back()、data()
  2. 查看范围信息:size()、empty()、size_bytes()
  3. 切出子范围:first()、last()、subspan()

可以用来遍历

void fs(span<int> p) {
    for (auto x: p) {
        x = 0;
    }
}

它不关心底层到底容器是用什么实现的。

获得 span 的方法:

  1. 从已有连续容器创建 span,比如 array, 内置数组和 vector 等
  • std::span s(v); // v 是已有的 vector
  1. 从 “指针 + 长度”
  • std::span s(p, 10); // p 是 int*
  1. 从两个迭代器或指针表示的范围
  • std::span s(v.begin(), v.end());

owning pointer 表示对最终 delete 对象负责的指针。与之相对的是 non-owning pointer。unique_ptr 和 shared_ptr 都是 owning pointers,区别在于前者有唯一拥有者;后者有多个共同拥有者。

non-owning pointer 可能会悬空。(难道 owning pointer 就不会悬空吗?)

string_view 和 span 都是 non-owning pointers

悬空指的是指针所指向的地址的对象已经被 deleted 了,或者越界了。

std::shared_ptr 也可能出现问题,比如循环引用:

struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> previous;
};

即时外部不再持有它们,两个节点也能互相保持引用,造成资源无法释放。通常需要使用 weak_ptr 打破拥有关系。

使用指针的一些原则

  1. 不要保存【已失效的、指向局部对象的】指针
  • 不要在函数当中将局部变量的地址作为返回值返回
  1. 对 new 创建的对象使用拥有型指针
  • 即,使用 unique_ptr 或者 shared_ptr 管理 new 创建的对象
  • 避免内存泄漏、重复释放和所有权不清
  1. 不要在业务代码当中手写指针运算
  • 指针加减容易越界
  • 让资源句柄和迭代器封装这些计算细节

容器

此处讨论以下这些 containers:

  1. built-in array:T[N]
  2. std::array<T, N>
  3. bitset<N> 固定大小的 N 比特
  4. vector<bool>:底层是比特序列
  5. pair<T, U>:T 和 U 类型的两个元素
  6. tuple<T...>:任意个数的任意类型的元素构成的序列
  7. basic_string<C>:C 类型的字符序列,提供字符串运算(//??:什么鬼,C类型是什么鬼)
  8. valarray<T>:数值类型的序列,提供数值运算
  • pair 和 tuple 当中的多个元素之间类型可能不一样,其余容器当中的多个元素的类型一致
  • tuple 和 array 一致,元素内存空间连续分配。

built-in array

built-in array 会隐式转换成指针

int a[3] = {10, 20, 30};
int* p = a;

p 是指向第一个元素的指针,即 &a[0]

这里之所以是隐式转换,是因为 a 本身的类型是 int[3]、p 的类型是 int*

std::array

std::array 与 std::vector,它不是元素的句柄,而是直接持有元素。而 std::vector 自己只保存指向堆内存的指针和元素数量。std::array 只是 built-in array 更安全的版本,其它的与 built-in array 没啥不同。std::array 必须使用 Initializer List 来初始化。

std::array 在声明的时候,必须显式声明元素类型与元素个数(都不能少!)。且元素个数必须是一个正数常量,不能是变量。如果需要元素个数是变量,则应使用 std::vector

int n = 5;
std::array<int, 2> a0 = {1, 2};  // 没问题
std::array<int, n> a1;           // 错误:元素个数不能是变量
std::array<int, 0> a2;           // 错误:元素个数不为正

std::array 相比于 built-in array 的一大好处是不会隐式转换成指针。如果要把 std::array 转换成指针(以便与使用 built-in array 的接口兼容),则需要使用它的 .data() 成员。

std::array 与 std::vector 在特性与使用场景上的差异包括:

  1. std::array 在 stack 上分配空间;std::vector 在 heap 上分配空间
  2. std::array 直接访问元素;而 std::vector 是一个资源句柄(间接访问元素)
  3. std::array 不必动态分配内存;std::vector 需要动态分配内存。交易系统热路径可能要考虑这一点!

bitset

std::bitset<N> 表示长度固定为 N 的二进制序列,每位只能是 0 或 1

操作包括:

  • set(n) 把第 n 位设为 1
  • reset(n) 把第 n 位设为 0
  • flip(n) 翻转第 n 位
  • count() 统计为 1 的位数
  • any() 是否至少有一个为 1
  • none() 是否全部为 0

如果要把 bitset 整体左移/右移,会使用 << 和 >> 运算符

std::bitset<5> bs {"10011"};
auto bs1 = bs << 2; // 01100
auto bs2 = bs1 >> 3; // 00001

long long 有 64 个 bit。早期(在 C++ 还不支持 bitset 的时候),64 位以内的比特位是用 long long 来表示的。但是显然,这样语义没有那么清晰。直接用 bitset 来封装比特操作会语义更加清晰。

如果想要为 bitset 当中的比特位命名,可以使用 enum,比如:

enum Permission : uint8_t { // 普通的 enum(而不是 enum class)的定义会进入全局作用域
    read = 0,
    write = 1,
    execute = 2
};

std::bitset<3> permission;
bs.reset(read);  // 这里可以直接用 read, write, execute
bs.flip(execute);

to_ullong() 和 to_string 操作可以把 bitset 转换成 unsigned long long 和 std::string。比如:

std::bitset<5> bs {"10011"};
unsigned long long x = bs.to_ullong(); // x 是 19
std::string s = bs.to_string(); // s 是 "10011"

std::vector

是有特殊优化实现的 std::vector

普通的 std::vector<int> 每个元素直接占据一个 int 对象的空间,但 std::vector<bool> 把多个 bool 压缩到 bit 中,比如:

std::vector<bool> v {true, true, true, true, false, false, false, false};

实际存储的不是 8 个布尔值对象,而是 11110000,所以 8 个布尔值可以只占 1 个字节。

但是这样带来一些不符合普通 std::vector<T> 直觉的行为:

std::vector v {true, false};
auto x = v[0];

由于多个布尔值被压缩到一个字节中,这里的返回值不返回真正的 bool&(注意,在 vector 上使用非 const 的 operator[],我们期望返回引用,可以通过引用修改 vector 元素值!),因为单独的一个 bit 无法直接形成 C++ 引用,它会返回一个代理对象。

带来对象是代表另一个对象或一部分数据的临时对象,比如此处的 v[0] 可能返回这样的内部对象:

class bit_reference {
public:
    operator bool() const;
    bit_reference& operator=(bool);
};

这个代理对象知道:

  • 实际 bit 存在哪个机器字中
  • 它对应哪一位
  • 读取时如何提取这一位
  • 写入时如何修改这一位

pair

若函数想返回多个值,可以使用结构体或者 pair。结构体的好处是命名、语义清晰,而如果我们只是想要临时返回两个值、返回的两个值没有无特别领域含义,或者想要保持数据结构的泛化性(generic),则可以使用 pair。使用 pair 不需要思考命名。

pair 与 tuple 一样,不同成员的类型可以不同。pair 的第一、二个成员被命名为 first 和 second。

在读取 pair 的时候通常使用 structure binding 。这在用途上很像 Python 的 unpacking,把一个复合对象中的多个值分别绑定到多个变量

auto [a, b] = std::pair(1, 2);

这个实质上和使用 first 和 second 来获取对象是一致的:

std::pair p = {1, 2};
auto a = pair.first;
auto b = pair.second;

还可以通过把 pair 绑定到引用来修改原 pair 的值

std::pair p {1, 2};
auto& [a, b] = p;
a = 5;

有一些 STL 的算法就是返回一个 pair,比如 equal_range

tuple

典型的非 heterogeneous 容器,即容器内元素的类型各自不同,但是容器仍然可以被当作一个整体来看待。pair 只支持两个元素,而 tuple 可以看作是 pair 的泛化。tuple 只负责把多个值打包在一起,本身没有规定这些值之间的业务关系。

tuple 某种程度上有点像结构体,但是每个元素没有命名。访问 tuple 的元素需要通过 get<>() 函数。有两种办法:

  1. 通过下标来访问,即通过 get<n>(tuple) 方法来访问。这里的 n 必须是一个常量,不能是变量。这里的 get<>() 函数把下标当作模板参数来接收。
std::tuple<std::string, int, double> t {"Shark", 123, 3.14};
std::string animal = get<0>(t);
int count = get<1>(t);
double price = get<2>(t);
  1. 通过元素类型来访问,即通过 get<type>(tuple) 来访问。这要求 tuple 当作被访问的元素的类型在整个 tuple 中唯一。
auto fish = get<std::string>(t);
auto count = get<int>(t);

//TODO:有关 tuple 的知识还包括 “unpacking”(类似 Python 当中 list 的 unpacking)、遍历元素等等,但是我打算现在先不学了

std::basic_string

std::basic_string<T> 是字符串类型的通用模板。std::string 本质上是 std::basic_string<char>。

除此之外,std::u16string 本质上是 basic_string<char16_t>;std::u32string 本质上是 basic_string<char32_t>。

Alternatives

alternatives 指的是 “多个候选类型或候选值中的一个”。需要在某一时刻选择其中的一种类型。

union 是 built-in alternatives type

variant

variant<A, B, C> :当前对象一定是 A/B/C 三种类型当中的一种

用法:在函数返回值处,如果正常则返回业务数据、若有出错则返回错误码:

std::variant<string, Error_code> compose_message(istream& s) {
    std::string message;
    if (no_problem) { return message; }
    else { return Error_code{some_problem;} }
}

可以检查当前这个 std::variant 当中装的是什么类型

if (std::holds_alternative<std::string>(m)) {
    std::cout << std::get<std::string>(m);
}

这里的 m 这个 std::variant 当中如果装的不是 std::string,那么会报 std::bad_variant_access 错误。

get<T>(v);

可以取出 T,类型不对会抛 std::bad_variant_access 错误。

void check(Node∗ p)
    {
    visit(overloaded {
        [](Expression& e) { /* ... */ },
        [](Statement& s) { /* ... */ },
        // ... Declaration and Type ...
        }, 
        ∗p);
}

效果类似 virtual function call,但是更快 //TODO:这一块没有完全学完,今天 2026.10.4 有点学不动了

optional

在 C++ 当中一直有 “一个 T* 类型指针要么指向一个 T 类型对象,要么为 nullptr” 的做法,而 std::optional 就是这种做法的泛化。std::optional<T> 可以被看作 std::variant<T, nothing>。

对于一个可能返回(也可能不返回)某个对象的函数很有用

std::optional<string> compose_message(istream& s) {
    std::string message;

    if (no_problem) { return message; }
    else { return {}; } // 这是一个空的 optional
}

非空 optional 是指向对象的指针,而空 optional 是一个空对象,即 {}。可以把 {} 传入接受 optional 的参数处

int sum(std::optional<int> a, std::optional<int> b) {...}
sum({}, {}); // 这是合法的

访问空 optional 是未定义行为。

any

std::any 可以持有任何类型,并且保存这个类型信息。

  • 它与 std::variant 的区别在于,std::variant 只能存事先规定的那些类型;而 std::any 可以保存任意类型。
  • 它与 auto 的区别在于,auto 是静态推导,运行时不得改变;而 std::any 在运行时可以装不同的类型。
std::any compose_message(istream& s) {
    std::string message;

    if (no_problems) { return message;  // 返回一个 string}
    else { return error_number; // 返回一个 int}
}

我们可以假设希望从 std::any 当中获得什么类型,如果实际存储的不是这个类型则报 std::bad_any_access 错。本质上是把类型安全检查从编译器挪到运行期(牺牲了编译期的类型安全性来换取灵活性)

std::any x = std::string("hello");
int y = std::any_cast<int>(x); // 编译能过,运行时抛 std::bad_any_access 错