【问题标题】:RAII and smart pointers in C++C++ 中的 RAII 和智能指针
【发布时间】:2010-09-28 13:12:15
【问题描述】:

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

【问题讨论】:

    标签: c++ smart-pointers raii


    【解决方案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 的剩余引用,它将被删除。因此,您不再需要担心自己删除它。

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

    那么,让我们使用我们的 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 被破坏),文件将被自动删除。

    【讨论】:

    • 需要注意的是,很多字符串的实现都是根据引用计数指针来实现的。这些写时复制语义使得按值返回字符串非常便宜。
    • 即使对于那些不是的,许多编译器也实现了 NRV 优化来处理开销。总的来说,我发现 shared_ptr 很少有用——只要坚持使用 RAII 并避免共享所有权。
    • 返回字符串并不是真正使用智能指针的好理由。返回值优化可以轻松优化返回,而 c++1x 移动语义将完全消除副本(如果使用正确)。显示一些现实世界的例子(例如,当我们共享相同的资源时):)
    • 我认为你早期关于为什么 Java 不能做到这一点的结论不够清晰。在 Java 或 C# 中描述此限制的最简单方法是因为无法在堆栈上进行分配。 C# 允许通过特殊关键字进行堆栈分配,但是,您会丢失类型安全性。
    • @Nemanja Trifunovic:在这种情况下,RAII 是指在堆栈上返回副本/创建对象?如果您有可以子类化的类型的返回/接受对象,那将不起作用。然后你必须使用指针来避免对对象进行切片,我认为在这些情况下,智能指针通常比原始指针更好。
    【解决方案2】:

    RAII 对于一个简单但很棒的概念来说,这是一个奇怪的名字。 Scope Bound Resource Management (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);
    }
    

    如您所见,我们可以通过多种方式获得 pwn。这个想法是我们将资源管理封装到一个类中。其对象的初始化获取资源(“资源获取即初始化”)。在我们退出块(块范围)时,资源再次被释放。

    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

    是一个独占对象的智能指针。它不在 boost 中,但它可能会出现在下一个 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

    是一个既不能复制也不能移动的 boost 智能指针。当您想确保在超出范围时删除指针时,这是一个完美的选择。

    代码:

    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. 
    

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

    【讨论】:

    • 据我所知,不可复制的对象根本不适合在 stl 容器中使用,因为它们依赖于值语义——如果你想对该容器进行排序会发生什么? sort 确实复制元素...
    • C++0x 容器将被更改,以便它尊重像 unique_ptr 这样的只移动类型,sort 也将被更改。
    • 你还记得你第一次听到SBRM这个词的地方吗? James is trying to track it down.
    • 我应该包含哪些头文件或库来使用它们?对此有任何进一步的阅读吗?
    • 这里有一个建议:如果@litb 对 C++ 问题有一个答案,那么它就是正确的答案(无论是投票还是标记为“正确”的答案)...
    【解决方案3】:

    前提和理由很简单,在概念上。

    RAII 是确保变量在其构造函数中处理所有需要的初始化并在其析构函数中处理所有需要的清理的设计范例。这将所有初始化和清理简化为一个步骤。

    C++ 不需要 RAII,但越来越多的人认为使用 RAII 方法会产生更健壮的代码。

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

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

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

    【讨论】:

    • 此外 - 指针是 RAII 最常见的应用 - 您分配的指针可能比任何其他资源多数千倍。
    【解决方案4】:

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

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

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

    1. 我们总是在使用之前打开 网络套接字,即使我们不喜欢 -- 使用 RAII 很难以另一种方式做到这一点。如果您尝试在没有 RAII 的情况下执行此操作,您可能会打开空套接字,例如 MSN 连接。然后像“今晚就做吧”这样的消息可能不会被转移,用户不会被解雇,你可能会面临被解雇的风险。
    2. 即使出现错误,我们也会关闭网络套接字。没有挂起的套接字,因为这可能会阻止响应消息“肯定会在底部”回击发件人。

    现在,如您所见,RAII 在大多数情况下都是非常有用的工具,因为它可以帮助人们上床。

    智能指针的 C++ 来源在网络上以百万计,包括我上面的响应。

    【讨论】:

      【解决方案5】:

      Boost 有很多这些,包括Boost.Interprocess 中的共享内存。它极大地简化了内存管理,特别是在令人头疼的情况下,例如当您有 5 个进程共享相同的数据结构时:当每个人都使用完一块内存时,您希望它自动被释放而不必坐在那里试图弄清楚谁应该负责在一块内存上调用delete,以免最终导致内存泄漏,或者指针被错误地释放两次并可能破坏整个堆。

      【讨论】:

        【解决方案6】:
        无效的富() { 标准::字符串栏; // // 这里有更多代码 // }

        无论发生什么,一旦 foo() 函数的作用域被抛在后面,bar 就会被正确删除。

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

        此外,内部引用计数使得当不再需要内部字符串的副本时,内存将被正确删除。

        【讨论】:

        • void f() { Obj x; Obj x 通过堆栈帧的创建/销毁(展开)被删除......它与引用计数无关。
        • 引用计数是字符串内部实现的一个特性。 RAII 是对象超出范围时删除对象背后的概念。问题是关于 RAII 和智能指针。
        • “不管发生什么”——如果在函数返回之前抛出异常会发生什么?
        • 返回哪个函数?如果 foo 中抛出异常,则删除 bar。 bar 抛出异常的默认构造函数将是一个非常事件。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2020-11-18
        • 1970-01-01
        • 1970-01-01
        • 2011-08-12
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多