问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

C++23中的智能指针:唯一指针的使用指南

创作时间:
作者:
@小白创作中心

C++23中的智能指针:唯一指针的使用指南

引用
CSDN
1.
https://m.blog.csdn.net/IncrediBuild/article/details/145679071

C++以其复杂的内存管理而闻名,但现代C++通过智能指针等特性,使得内存管理更加安全和高效。本文将详细介绍C++23中智能指针的使用,重点讲解唯一指针(unique_ptr)的原理和使用方法。

内存管理和唯一指针

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>
std::unique_ptr<MyClass> c = std::make_unique<MyClass>(p1, p2, …);
c->field=0;

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

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

#include <iostream>
class Test {
public:
    Test () {
        std::cout << “Test ctor” << std::endl;
    }
    ~Test () {
        std::cout << “Test dtor” << std::endl;
    }
};
std::unique_ptr<Test> t = std::make_unique<Test>();

这将输出:

Test ctor
Test dtor

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

std::unique_ptr<Test> q = t;

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

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

std::unique_ptr<Test> make_test () {
    return std::make_unique<Test>();
}
std::unique_ptr<Test> s = make_test();

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

std::vector<std::unique_ptr<Test>> v;
v.emplace_back (std::make_unique<Test>());

Or even swap them:

std::unique_ptr<Test> a; // initialized to “null”
std::unique_ptr<Test> b = std::make_unique<Test>();
std::swap(a, b); //b 现在为 null,a 有一个引用,并且未删除任何内容

高级用法

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

std::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 std::unique_ptr<Test> c = std::make_unique<Test>();

unique_ptr数组

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

std::unique_ptr<MyClass> c = std::make_unique<MyClass>();
auto x = c[2]; // forbidden
auto y = *c+1; // forbidden
++c; // forbidden

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

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

std::unique_ptr<C[]> cc = std::make_unique<C[]>(5);
std::cout << *(cc.Get()+1) << std::endl;
std::cout << cc[3] << std::endl;
++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&或const unique_ptr&传递。切勿将unique_ptr传递给非引用参数——这会将引用的所有权授予参数,当参数在函数结束时超出范围时,将删除引用。这很可能不是你想要的。

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

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

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

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

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号