在 C++23 中使用智能指针进行现代内存管理 – 第 1 部分

Blog
Author:
Incredibuild TeamIncredibuild Team
Published On:
11月 27, 2024
Estimated reading time:
1 minutes

目录

内存管理和唯一指针

C++ 以具有难以处理的内存模型而闻名,尤其是对于来自托管内存语言的程序员。它因越界引用错误和内存泄漏被开发者吐槽。

尽管如此,现代 C++ 比以前安全得多,现在甚至比托管内存模型更安全(性能更高)。

在这个由两部分组成的系列的第 1 部分中,我们将解释托管内存语言和传统 C C++ 中的内存管理原则,解释每种方法的问题,然后建议智能指针如何提供帮助。最后,我们将深入探讨一个重要的内置智能指针,即唯一指针 (unique_ptr)。

本文的第二部分将介绍另一个有用的智能指针:共享指针,以及它的一些朋友,并将其与唯一指针进行比较。

传统内存管理的工作原理

以托管内存语言为例, 你可以在其中实例化变量 — 例如,在堆栈上或新对象内部 — 如下所示:

MyClass p = new MyClass(p1, p2, );

p.field = 0;

在幕后有两个步骤:

  1. 新对象在堆中分配,然后根据其构造函数进行初始化。
  2. 在上下文中创建一个 “pointer”,它指示可以找到新对象 “referent” 的位置。

图 1:堆栈上的指针引用堆上的对象(托管内存版本)

如果不初始化变量,则仍会创建指针,但不会指示任何内容这是 null。无论哪种方式, 你创建的变量看起来都是 MyClass 类,但实际上,它只是指向实际 MyClass 对象的指针。

C、C++ 中的内存管理

C C++ 中,MyClass 对象和指针之间有明显的区别,由 * 表示。上述 C C++ 代码的等效项如下所示:

MyClass* c = new MyClass(p1, p2, );

*c.field = 0; // or, equivalently, c->field = 0;

图 2:堆栈上的指针引用堆上的对象(C++ 版本)

这种区别的原因是,与托管内存语言不同,可以在堆以外的位置创建对象:堆栈上、另一个对象内部,甚至某个受保护的内存块内。

以下是在堆栈上创建对象的方法:

MyClass s(p1, p2, );

图 3:在堆栈而不是堆上创建对象

垃圾回收:托管内存与 C++

托管内存和 C++ 的内存之间还有另一个区别:清理。

在托管内存语言中,对象使用的内存使用垃圾回收器自动回收。有许多不同的策略,但最终它们都通过搜索程序分配的所有对象来工作,查找不再可访问的对象。然后,它会删除这些孤立对象并回收它们使用的内存,将它们返回到堆中。

这个过程并不完美。它通常会导致意外的减速,因为垃圾回收器在程序运行时无法工作,当然,你不知道何时回收对象,这意味着可能会不必要地留下其他昂贵的资源。但它是自动的。

相比之下,C++ 的对象在超出范围的那一刻就会被销毁。堆栈上的 MyClass:它删除其资源,并在包含方法结束时回收其内存。

但是指针 c 呢?指针被删除了,但引用仍然存在,除非程序员在它不再可访问时煞费苦心地删除它,否则其他人将永远无法删除它,因为没有其他人对它有引用。

这就是我们所说的内存泄漏。有时(例如,在异常期间),创建者甚至无法安全地删除引用。

图 4:已删除的指针。MyClass 对象永远无法释放,因此其内存已“泄漏”。

智能指针进行救援!

C C++ 中,指针隐式公开包含运算符 * 和运算符 -> 的接口。这两个运算符都取消引用指针:也就是说,它们返回指针的引用对象。

C++ 中,我们可以创建实现这些运算符的类。我们的想法是,它们的外观和工作方式与我们刚刚看到的那些简单指针类似,但它们可以在一定程度上进行自定义。它们是普通的类,因此它们可以在构造、销毁、取消引用甚至基于来自系统中其他位置的信号时应用特殊处理。这些指针称为 “智能指针”。

本系列探讨了两种内置智能指针:unique_ptr shared_ptr。但是,在 第 1 部分 中,我们将只关注 unique_ptr,这通常是更有用的。

unique_ptr 和所有权的概念

C++ 11 中引入了唯一指针。它是一个智能指针(因此它导出了运算符 * 和运算符 ->),但它添加了所有权的概念,就像 Rust 中的所有权概念一样。

就像传统指针一样,唯一指针表示已在堆上分配的内存块。但与传统指针不同的是,当删除 unique_ptr 时,它还会析构并释放引用对象。

当然,这之所以有效,只是因为每个引用都只有一个管理它的 unique_ptr:只有一个所有者。诀窍在于 unique_ptr 本身会强制执行这一点,正如你看到的。

与托管内存垃圾回收相比,它有巨大的优势:

  • 一旦引用不再可访问,它就会被删除,并且其所有资源都会被回收,因此不会像托管内存那样延迟回收内存
  • 当内存开始不足并且垃圾回收器启动时,程序的执行不会暂停。

C++ 的传统 new delete 相比,unique_ptr 具有显著的优势。内存回收是完全自动的。程序员无需手动删除内存,如果不使用高级、不安全的功能,这甚至是不可能的。

它本质上也是异常安全的。你可能知道,C++ 没有 finally 语句;在异常处理期间,异常处理程序需要删除 你分配的任何资源,这通常是不可能的,因为异常是从何处引发的并不被知晓。异常会导致内存泄漏。

C++ 会在对象的上下文被销毁时(例如,当函数结束时)自动删除对象。unique_ptr 还利用该机制自动删除引用对象,从而确保内存安全。

基本用法

使用 unique_ptr 的最简单方法是使用 make_unique 同时创建指针和引用。两者都在 memory 命名空间中定义,因此:

#include <memory>

unique_ptr<MyClass> c = make_unique<MyClass>(p1, p2, );

c->field=0;

如你所见,指针和引用的创建看起来与之前非常相似:make_unique 采用与 MyClass 的构造函数相同的参数;如果 MyClass 具有多个重载的构造函数,则它们将按预期工作。

我们可以通过定义一个 instrumentation 类来证明这是有效的:

#include <memory>

Class Test {

    Test () {

        cout << Test ctor << endl;

    }

    ~Test () {

        cout << Test dtor << endl;

    }

}

unique_ptr<Test> t = make_unique<Test>();

这将输出:

Test ctor

Test dtor

但是,如果我们尝试将唯一指针复制到另一个指针:

unique_ptr<Test> q = t;

你会收到一个 compile-time 错误,抱怨它无法完成。这是有道理的:复制指针会给所指对象两个所有者(t q),但 unique_ptr 的全部目的是确保所指对象只有一个。

因此, 唯一指针无法被复制,但可以移动它们。例如:

unique_ptr<Test> make_test () {

    return make_unique<Test>();

}

unique_ptr<Test> s = make_test();

你还可以将它们放入 STL 容器中:

vector<unique_ptr<Test>> v;

v.emplace_back (make_unique<Test>());

Or even swap them:

unique_ptr<Test> a; // initialized to null

unique_ptr<Test> b = make_unique<Test>();

swapab;//b 现在为 nulla 有一个引用,并且未删除任何内容

高级用法

正如你上面看到的,可以创建一个不与任何引用关联的 unique_ptrunique_ptr 公开了 operator bool,它测试是否有引用;

unique_ptr<Test> a;

if (a) { /* use *a */ }

事实上,有一整套方法允许 你访问并 “帮助” unique_ptr 完成其工作:

  • 你可以使用 get() 获取指向引用对象的指针。
  • 你可以使用 release() 获得引用的所有权。这将返回指向引用对象的指针,但它也会将 unique_ptr 归零。所指现在是你的问题!
  • 你可以 reset() unique_ptr。这将删除 unique_ptr 所具有的任何引用(就像删除 unique_ptr 一样),然后获得 你刚刚为其提供的指针的所有权。

通常,当 你使用这些函数时, 你将放弃唯一指针安全网的很大一部分。如果调用引用 unique_ptr 的函数,则不知道该函数在返回给 你之前可以对其进行哪些更改。指针可能拥有与 你想象的完全不同的引用,或者根本没有。

除非在某些非常特殊的情况下,否则 你不应允许函数移动、释放或重置 唯一指针。 你可以通过将 unique_ptr 声明为 const 来防止这种情况:

const unique_ptr<Test> c = make_unique<Test>();

unique_ptr 数组

唯一指针的行为很像传统指针,但它们不做一件事:索引。例如;

unique_ptr<C> c = make_unique<C>();

auto x = c[2]; // forbidden

auto y = *c+1; // forbidden

++c; // forbidden

这是有道理的:所指对象只是一个对象,而不是它们的数组,并且数组在堆中的处理方式与单个对象非常不同,这就是我们有 new new[] 的原因;delete delete[]

但是 unique_ptr 有一种方法可以处理数组。喜欢这个:

unique_ptr<C[]> cc = make_unique<C[]>(5);

cout << *(cc.Get()+1) << nl;

cout << cc[3] << nl;

++cc; // still forbidden!

这仅在模板类型是无界数组(即 C[] 而不是 C[5] )时有效,并且仅在 C 具有默认构造函数时有效。换句话说, 你不能从初始值设定项列表初始化数组。

结论

unique_ptr 是一种非常简单的方法来保护 程序免受内存错误的影响。它将堆对象的生存期绑定到其他更可预测的对象的生存期,并且你可以随时转移所有权。如果使用得当,它可以防止 null-pointer srid-pointer 错误,并且可以完全避免内存泄漏。最后,与 C 样式指针相比,unique_ptr 几乎不涉及运行时开销,也几乎不涉及任何内存开销。

但是,在使用它们之前, 你应该检查它们是否是 你问题的正确解决方案:

  • 回收内存真的一点也不必要吗?如果程序只是一个短暂运行的程序(如命令行实用程序或编译器),则可能不需要回收内存,因为当程序终止时,它无论如何都会被回收。
  • allocations 和 disallocation 是否与堆栈同步?将堆栈用于瞬态内存比使用堆要快得多;它还更安全,因为 对象将作为正常堆栈代谢的一部分自动清理。(当心! 堆的容量比堆栈大得多,并且堆栈可能不够大,无法满足 所有内存需求。

更多提示和推荐阅读

假设 你决定需要分配和解除分配堆内存,请遵循以下规则:

  • 优先使用 make_unique 而不是 new 和 delete。如果 程序仅运行较短的时间,则可以使用 new(不带 delete),但切勿将 new/delete 与 unique_ptr 混合使用。
  • 将每个 unique_ptr 声明为 const 以避免意外,除非你真的需要修改引用。
  • 将 unique_ptr<C> 传递给另一个函数时,请将其作为 C& 或 const unique_ptr<C>& 传递。切勿将 unique_ptr 传递给非引用参数 – 这会将引用的所有权授予参数,当参数在函数结束时超出范围时,将删除引用。这很可能不是你想要的。

只要遵循这些规则,则程序的编写和读取将比使用简单指针要容易得多。 你也不会遭受内存泄漏或内存冲突。

例如, 你可能遇到的错误将涉及通过将引用的所有权移动到短期内容(如函数参数)来过早释放指针。由此引起的错误立即可见 ;unique_ptr 引用变为 null,因此,如果尝试取消引用它,则可靠地会收到错误,并且调试器将在传递所有权时显示。

全面使用智能指针, 你很少需要使用 Valgrind 等内存检查工具。

想要了解更多信息?当然,unique_ptr 的权威来源是 cpp 参考。 请继续关注本系列的第 2 部分,我们将在其中讨论共享指针并将其与唯一指针进行比较!