C ++中的RAII和智能指针


Answers:


317

一个简单的(也许是过度使用的)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被破坏),文件将被自动删除。


7
应当注意,许多字符串实现都是根据引用计数的指针实现的。这些写时复制语义使得按值返回字符串确实非常便宜。

7
即使不是这样,许多编译器仍会执行NRV优化,以解决开销问题。通常,我发现shared_ptr很少有用-坚持使用RAII并避免共享所有权。
Nemanja Trifunovic

27
返回字符串并不是真正使用智能指针的充分理由。返回值优化可以轻松地优化返回值,并且c ++ 1x move语义将完全消除副本(正确使用时)。请显示一些真实的示例(例如,当我们共享相同的资源时):)
Johannes Schaub-litb

1
我认为您早期关于Java无法做到这一点的结论尚不清楚。用Java或C#描述此限制的最简单方法是,因为没有方法可以在堆栈上进行分配。C#允许通过特殊关键字分配堆栈,但是您会丢失类型安全。
ApplePieIsGood

4
@Nemanja Trifunovic:在这种情况下,RAII是指在堆栈上返回副本/创建对象?如果您具有可以子类化的类型的返回/接受对象,则该方法不起作用。然后,您必须使用指针来避免对对象进行切片,而我认为在这种情况下,智能指针通常比原始指针要好。
Frank Osterfeld

141

RAII这是一个简单但很棒的概念的奇怪名称。范围绑定资源管理(SBRM)是更好的名称。这样的想法是,您经常碰巧在块的开头分配资源,而需要在块的出口释放资源。退出块可能会通过正常的流控制,跳出它甚至发生异常而发生。为了涵盖所有这些情况,代码变得更加复杂和多余。

只是没有SBRM的示例:

void o_really() {
     resource * r = allocate_resource();
     try {
         // something, which could throw. ...
     } catch(...) {
         deallocate_resource(r);
         throw;
     }
     if(...) { return; } // oops, forgot to deallocate
     deallocate_resource(r);
}

如您所见,我们有很多方法可以进行伪造。这个想法是我们将资源管理封装到一个类中。其对象的初始化获取资源(“ Resource Acquisition Is Initialization”)。在我们退出该块(块作用域)时,资源再次被释放。

struct resource_holder {
    resource_holder() {
        r = allocate_resource();
    }
    ~resource_holder() {
        deallocate_resource(r);
    }
    resource * r;
};

void o_really() {
     resource_holder r;
     // something, which could throw. ...
     if(...) { return; }
}

如果您拥有自己的类,而这些类不仅仅用于分配/分配资源,那很好。分配只是完成他们工作的一个附加问题。但是,只要您只想分配/重新分配资源,上述内容就变得不方便了。您必须为获取的每种资源编写包装类。为了缓解这种情况,智能指针使您可以自动化该过程:

shared_ptr<Entry> create_entry(Parameters p) {
    shared_ptr<Entry> e(Entry::createEntry(p), &Entry::freeEntry);
    return e;
}

通常,智能指针是围绕new / delete的精简包装,delete它们所拥有的资源超出范围时恰好会调用它们。一些智能指针(例如shared_ptr)使您可以告诉它们一个所谓的删除器,该删除器用于代替delete。例如,这使您可以管理窗口句柄,正则表达式资源和其他任意内容,只要您告诉shared_ptr有关正确的删除程序的信息即可。

有不同用途的不同智能指针:

unique_ptr

是专门拥有一个对象的智能指针。它不是增强功能,但可能会出现在下一个C ++标准中。它是不可复制的,但支持所有权转让。一些示例代码(下一个C ++):

码:

unique_ptr<plot_src> p(new plot_src); // now, p owns
unique_ptr<plot_src> u(move(p)); // now, u owns, p owns nothing.
unique_ptr<plot_src> v(u); // error, trying to copy u

vector<unique_ptr<plot_src>> pv; 
pv.emplace_back(new plot_src); 
pv.emplace_back(new plot_src);

与auto_ptr不同,unique_ptr可以放入容器中,因为容器将能够容纳不可复制(但可移动)的类型,例如流和unique_ptr。

scoped_ptr

是不可复制或不可移动的增强型智能指针。当您要确保超出范围时要删除指针时,这是完美的选择。

码:

void do_something() {
    scoped_ptr<pipe> sp(new pipe);
    // do something here...
} // when going out of scope, sp will delete the pointer automatically. 

shared_ptr

用于共享所有权。因此,它既可复制又可移动。多个智能指针实例可以拥有相同的资源。拥有资源的最后一个智能指针一旦超出范围,资源将被释放。我的项目之一的一些真实示例:

码:

shared_ptr<plot_src> p(new plot_src(&fx));
plot1->add(p)->setColor("#00FF00");
plot2->add(p)->setColor("#FF0000");
// if p now goes out of scope, the src won't be freed, as both plot1 and 
// plot2 both still have references. 

如您所见,plot-source(函数fx)是共享的,但是每个都有一个单独的条目,我们在该条目上设置颜色。有一个weak_ptr类,当代码需要引用智能指针拥有的资源但不需要拥有该资源时,将使用该类。您应该创建一个weak_ptr,而不是传递原始指针。当发现您尝试通过weak_ptr访问路径尝试访问资源时,即使没有shared_ptr拥有该资源,它也会引发异常。


据我所知,不可复制对象根本不适合在stl容器中使用,因为它们依赖于值语义-如果要对该容器排序,会发生什么?排序确实会复制元素...
fmuecke

C ++ 0x容器将进行更改,使其遵循仅限移动类型,例如unique_ptr,并且sort也将同样进行更改。
Johannes Schaub-litb

您还记得第一次听到SBRM一词的地方吗?詹姆斯正试图追踪它。
GManNickG

我应该包括哪些标题或库来使用它们?对此有任何进一步的阅读吗?
atoMerz,2011年

这里的一个建议是:如果@litb有C ++问题的答案,那么它是正确的答案(无论投票或答案标记为“正确”)……
fnl 2015年

32

从概念上讲,前提和原因很简单。

RAII是一种设计范例,可确保变量在其构造函数中处理所有需要的初始化,并在其析构函数中处理所有需要的清理。 这样可以将所有初始化和清理工作减少到一个步骤。

C ++不需要RAII,但是越来越多地接受使用RAII方法将产生更强大的代码。

RAII在C ++中有用的原因是,无论变量是通过正常的代码流还是通过异常触发的堆栈展开,C ++都会在变量进入和离开作用域时内在地管理变量的创建和销毁。这是C ++中的免费赠品。

通过将所有初始化和清理与这些机制联系在一起,可以确保C ++也会为您完成这项工作。

在C ++中谈论RAII通常会引发对智能指针的讨论,因为在清理时指针特别脆弱。管理从malloc或new获取的堆分配的内存时,程序员通常有责任在销毁指针之前释放或删除该内存。智能指针将使用RAII哲学来确保每次销毁指针变量时销毁分配给堆的对象。


另外-指针是RAII的最常见应用-您分配的指针可能比任何其他资源多数千倍。

8

智能指针是RAII的变体。RAII表示资源获取是初始化。智能指针在使用之前获取资源(内存),然后将其自动丢弃在析构函数中。发生两件事:

  1. 我们总是在使用内存之前就分配内存,即使我们不喜欢它也很困难-使用智能指针很难做另一种方式。如果这没有发生,则您将尝试访问NULL内存,从而导致崩溃(非常痛苦)。
  2. 即使出现错误,我们也会释放内存。没有留下任何记忆。

例如,另一个示例是网络套接字RAII。在这种情况下:

  1. 我们总是在使用网络插座之前就打开它,即使我们不愿意使用RAII也很难做到这一点。如果您尝试在没有RAII的情况下执行此操作,则可以打开空套接字,例如进行MSN连接。这样一来,“今晚就去做”之类的消息可能不会被传送,用户也不会被放下,您可能会被解雇。
  2. 即使出现错误,我们也会关闭网络套接字。没有插座悬空,因为这可能阻止响应消息“确保生病了”回击发件人。

如您所见,现在,RAII在大多数情况下是一个非常有用的工具,因为它可以帮助人们奠定基础。

C ++智能指针的来源遍布网络,包括我上方的响应。


2

Boost有许多这样的功能,包括Boost.Interprocess中用于共享内存的功能。它极大地简化了内存管理,尤其是在引起头痛的情况下,例如当您有5个进程共享相同的数据结构时:当每个人都完成了一块内存后,您希望它自动释放,而不必坐在那里试图弄清楚应该由谁来负责调用delete大块内存,以免导致内存泄漏或指针被错误地释放两次并可能破坏整个堆,从而导致内存泄漏。


0
无效foo()
{
   std :: string bar;
   //
   //更多代码在这里
   //
}

无论发生什么情况,一旦foo()函数的作用域被遗忘了,bar将被正确删除。

内部的std :: string实现经常使用引用计数的指针。因此,仅在更改字符串的副本之一时才需要复制内部字符串。因此,引用计数的智能指针可以仅在必要时复制某些内容。

此外,内部引用计数可以在不再需要内部字符串的副本时正确删除内存。


1
无效f(){Obj x; } Obj x通过堆栈帧创建/销毁(展开)的方式删除...与引用计数无关。
埃尔南

引用计数是字符串内部实现的功能。当对象超出范围时,RAII是对象删除背后的概念。问题是关于RAII以及智能指针。

1
“不管发生了什么”-如果在函数返回之前引发异常,将会发生什么?
titaniumdecoy

返回哪个函数?如果在foo中引发异常,则删除bar。bar抛出异常的默认构造函数将是一个特殊事件。
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.