DesseraLab

Technology exploration and development

0%

现代 C++ - 初探所有权管理

说到现代C++,我们就不得不提到智能指针,这些模板类自C++11起被引入进标准库,不涉及操作系统、不涉及繁琐的内存申请流程,甚至你自己就能实现一个简单的智能指针。但即便如此,直到今天,仍然有人没有了解过,也不去使用智能指针。

实际上我认为,智能指针为 C++ 这门语言带来了一个新的概念,即所有权。智能指针表面上管理了一块由用户申请的内存,但实际上它是管理了这一块资源的所有权。这一概念在 Rust 语言中被更加明确地表达出来,但在 C++ 中,我们也可以通过智能指针来实现。

所有权

为什么会产生所有权机制?回想一下你在古老的 C/C++ 工作时,你不得不手动申请内存,手动管理他们的销毁时机,使用对象,还要时刻提防可能存在的拷贝操作(后来还要提防一些移动操作)。这些东西一开始就该由程序员管理,但是别忘了,我们在谈论一个现代语言!

在我看来,现代语言是高级语言的一个子集,它们应当在某一领域,解决过去程序员们的痛点。专注于底层的现代语言,如 Zig,它保留了内存管理的能力、并在语言层面支持了 Allocator, 扩充了内存管理的能力。而另一些现代语言,如 Rust,它在语言层面支持了所有权机制,使得程序员不再需要关心内存的申请和释放。

我们在谈论C++,从C++11开始,这门古老的语言开始了自己的现代化之路,而其中一个最显著的改变就是智能指针和其带来的所有权机制。

简单来说,所有权是一种属于关系,在这种关系下定义的对象其本身不会有隐式的复制和移动操作,将一个对象赋值、传递给另一个对象,只不过是将所有权转移给了另一个对象,在这个过程中,没有任何内存的拷贝和移动操作。

所有权的实现

对于 C++ 来说,所有权有两种,一种是独占所有权,另一种是共享所有权。独占所有权是指一个对象只能被一个所有者拥有,而共享所有权是指一个对象可以被多个所有者拥有。独占所有权在 C++ 中由 std::unique_ptr 实现,而共享所有权则由 std::shared_ptr 实现。

在一些场景(尤其是嵌入式领域)中,我们通常会针对每一个通信协议创建一个类,他们负责与其他设备的通信实现,而一些上层应用类,它们会需要调用这些协议类的方法。

创建这个协议类有两种方式:

  • 由应用类自己创建
  • 从外部创建,传递给应用类

但一般情况下,这些协议类的 IO 通道都是有限的,我们不希望应用类创建多个相同的协议类,这时候我们就可以使用智能指针来管理这些协议类的所有权。

由此可以引申出两种情况:

  • 一个协议类只服务于一个应用类,这时候我们可以使用 std::unique_ptr 来管理这个协议类的所有权
  • 一个协议类可能会被多个应用类使用,这时候我们可以使用 std::shared_ptr 来管理这个协议类的所有权

std::unique_ptr

std::unique_ptr 是一个独占所有权的智能指针,它只能有一个所有者,当这个所有者被销毁时,它所管理的资源也会被销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <memory>

class Protocol {
// ...
};

class Application {
public:
// 构造时,将协议类的所有权转移给 Application
Application(std::unique_ptr<Protocol> protocol) : protocol_(std::move(protocol)) {}

private:
std::unique_ptr<Protocol> protocol_;
};

int main() {
// 创建一个协议类,此时所有权掌握在 main 函数中
std::unique_ptr<Protocol> protocol = std::make_unique<Protocol>();

// 创建一个应用类,将协议类的所有权转移给 Application
Application app(std::move(protocol));
}

std::shared_ptr

std::shared_ptr 是一个共享所有权的智能指针,它可以有多个所有者,当最后一个所有者被销毁时,它所管理的资源也会被销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <memory>

class Protocol {
// ...
};

class Application {
public:
// 构造时,将协议类的所有权转移给 Application
Application(std::shared_ptr<Protocol> protocol) : protocol_(protocol) {}

private:
std::shared_ptr<Protocol> protocol_;
};

int main() {
// 创建一个协议类,此时所有权掌握在 main 函数中
std::shared_ptr<Protocol> protocol = std::make_shared<Protocol>();

// 创建一个应用类,将协议类的所有权转移给 Application
Application app(protocol);

// 创建另一个应用类,共享协议类的所有权
Application app2(protocol);
}

论所有权的转让与移动

很多人都有一个误解,认为移动语义体现了所有权的转让,但是,二者是不同的。

不论什么情况下,移动构造一定会重新创建资源对象,而至于资源内部的数据是如何移动的,这是对象的实现决定的,但针对资源对象本身,在这个过程中一定会重新创建一个资源对象。

而所有权的转让是另一回事,它直接将自己拥有的资源本身交予了另一个对象,在这个过程中,没有任何资源的拷贝和移动操作(实际上,在计算机眼中,这个过程什么也没发生)。

有趣的一点是,所有权的转让这一操作的实现依靠了移动语义,std::unique_ptr 不允许拷贝,但允许移动,刚才我们提到移动一定会创建新对象,而std::unqiue_ptr的移动虽然创建了新的std::unique_ptr,但其内部的指针仍然指向同一个资源对象,这就是所有权的转让的实现。

std::shared_ptr维护了一个引用计数,这让它可以进行拷贝,也可以进行移动,引用计数会时刻保证资源对象的生命周期,当最后一个所有者被销毁时,资源对象也会被销毁。

论资源的所有者和引用者

上文我们忽略的一种情况,有一些类或函数并不拥有资源,但他们需要暂时的资源使用权。对于我们已经建立的资源所有权模型,每一种所有权模式都有其对应的引用类型。

std::unique_ptr 对应的引用类型是原生指针,它指向资源对象本身,但在语义上不拥有它,只是暂时的使用。

std::shared_ptr 对应的引用类型是 std::weak_ptr,它指向资源对象,但不会增加引用计数,当资源对象被销毁时,std::weak_ptr 会被置空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <memory>

class Protocol {
// ...
};

class Application {
public:
// 构造时,将协议类的所有权转移给 Application
Application(std::shared_ptr<Protocol> protocol) : protocol_(protocol) {}

private:
std::shared_ptr<Protocol> protocol_;
};

void foo(std::weak_ptr<Protocol> weak_protocol) {
// 使用 weak_ptr
}

int main() {
// 创建一个协议类,此时所有权掌握在 main 函数中
std::shared_ptr<Protocol> protocol = std::make_shared<Protocol>();

// 创建一个应用类,将协议类的所有权转移给 Application
Application app(protocol);

// 创建一个 weak_ptr,暂时使用协议类
std::weak_ptr<Protocol> weak_protocol = protocol;

// 调用函数,传递 weak_ptr
foo(weak_protocol);
}

值得注意的一点是,std::weak_ptr 不能直接使用,它需要通过 lock 方法转换为 std::shared_ptr 才能使用。

总结

新的资源管理模型标志着现代 C++ 的到来,我一直秉承着一个观点——任何拥有内存申请和释放的程序都应当使用智能指针,这不仅仅是为了避免内存泄漏,更是为了通过所有权这一概念更好地构建应用逻辑模型。