小编典典

C++ 中的 RAII 和智能指针

all

在 C++
的实践中,什么是RAII,什么是智能指针,这些是如何在程序中实现的,以及将
RAII 与智能指针一起使用有什么好处?


阅读 92

收藏
2022-07-01

共1个答案

小编典典

RAII 的一个简单(可能是过度使用)示例是 File 类。如果没有 RAII,代码可能如下所示:

File file("/path/to/file");
// Do stuff with file
file.close();

换句话说,我们必须确保在完成文件后关闭它。这有两个缺点——首先,无论我们在哪里使用 File,我们都必须调用
File::close()——如果我们忘记这样做,我们持有文件的时间就会超过我们需要的时间。第二个问题是如果在我们关闭文件之前抛出异常怎么办?

Java 使用 finally 子句解决了第二个问题:

try {
    File file = new File("/path/to/file");
    // Do stuff with file
} finally {
    file.close();
}

或者从 Java 7 开始,一个 try-with-resource 语句:

try (File file = new File("/path/to/file")) {
   // Do stuff with file
}

C++ 使用 RAII 解决了这两个问题——即在 File 的析构函数中关闭文件。只要 File
对象在正确的时间被销毁(无论如何都应该如此),我们就可以关闭文件了。所以,我们的代码现在看起来像:

File file("/path/to/file");
// Do stuff with file
// No need to close it - destructor will do that for us

这在 Java 中无法做到,因为无法保证对象何时会被销毁,因此我们无法保证文件等资源何时会被释放。

在智能指针上——很多时候,我们只是在堆栈上创建对象。例如(并从另一个答案中窃取示例):

void foo() {
    std::string str;
    // Do cool things to or using str
}

这很好用——但是如果我们想返回 str 怎么办?我们可以这样写:

std::string foo() {
    std::string str;
    // Do cool things to or using str
    return str;
}

那么,这有什么问题呢?好吧,返回类型是 std::string - 所以这意味着我们按值返回。这意味着我们复制 str
并实际返回副本。这可能很昂贵,我们可能希望避免复制它的成本。因此,我们可能会想出通过引用或指针返回的想法。

std::string* foo() {
    std::string str;
    // Do cool things to or using str
    return &str;
}

不幸的是,这段代码不起作用。我们正在返回一个指向 str 的指针——但是 str 是在堆栈上创建的,所以一旦我们退出
foo(),我们就会被删除。换句话说,当调用者得到指针时,它是无用的(并且可以说比无用更糟糕,因为使用它可能会导致各种时髦的错误)

那么,解决方案是什么?我们可以使用 new 在堆上创建 str - 这样,当 foo() 完成时, str 不会被销毁。

std::string* foo() {
    std::string* str = new std::string();
    // Do cool things to or using str
    return str;
}

当然,这个解决方案也不是完美的。原因是我们创建了
str,但我们从不删除它。这在非常小的程序中可能不是问题,但总的来说,我们希望确保删除它。我们可以说调用者在完成对象后必须删除它。缺点是调用者必须管理内存,这会增加额外的复杂性,并且可能会出错,从而导致内存泄漏,即即使不再需要对象也不会删除它。

这就是智能指针的用武之地。以下示例使用 shared_ptr - 我建议您查看不同类型的智能指针以了解您实际想要使用的内容。

shared_ptr<std::string> foo() {
    shared_ptr<std::string> str = new std::string();
    // Do cool things to or using str
    return str;
}

现在,shared_ptr 将计算对 str 的引用数。例如

shared_ptr<std::string> str = foo();
shared_ptr<std::string> str2 = str;

现在有两个对同一个字符串的引用。一旦没有对 str 的剩余引用,它将被删除。因此,您不再需要担心自己删除它。

快速编辑:正如一些评论所指出的,这个例子并不完美(至少!)有两个原因。首先,由于字符串的实现,复制字符串往往是廉价的。其次,由于所谓的命名返回值优化,按值返回可能并不昂贵,因为编译器可以做一些聪明的事情来加快速度。

因此,让我们使用我们的 File 类尝试一个不同的示例。

假设我们想使用一个文件作为日志。这意味着我们要以仅附加模式打开文件:

File file("/path/to/file", File::append);
// The exact semantics of this aren't really important,
// just that we've got a file to be used as a log

现在,让我们将文件设置为其他几个对象的日志:

void setLog(const Foo & foo, const Bar & bar) {
    File file("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

不幸的是,这个例子可怕地结束了——一旦这个方法结束,文件就会被关闭,这意味着 foo 和 bar
现在有一个无效的日志文件。我们可以在堆上构造文件,并将指向文件的指针传递给 foo 和 bar:

void setLog(const Foo & foo, const Bar & bar) {
    File* file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

但是谁负责删除文件呢?如果两个都没有删除文件,那么我们就有内存和资源泄漏。我们不知道 foo 或 bar
是否会先完成文件,所以我们不能指望自己删除文件。例如,如果 foo 在 bar 完成之前删除了文件, bar 现在有一个无效的指针。

因此,正如您可能已经猜到的那样,我们可以使用智能指针来帮助我们。

void setLog(const Foo & foo, const Bar & bar) {
    shared_ptr<File> file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

现在,没有人需要担心删除文件 - 一旦 foo 和 bar 都完成并且不再有任何对文件的引用(可能是由于 foo 和 bar 被破坏),文件将被自动删除。

2022-07-01