为什么我应该使用std::unique_ptr而不是在析构函数中销毁对象?

4

假设我有这个类:

class Foo
{
public:
    Foo()
    {
        bar = new Bar;
    }

    ~Foo()
    {
        if(bar)
            delete bar;
    }
private:
    Bar* bar;

};

在使用unique_ptr时,相比于裸指针,为什么我要选择std::unique_ptr?它有哪些优势呢?是否存在析构函数不被调用的情况?


13
std::unique_ptr 为什么被认为是臃肿的?它只是对原始指针的一个简单包装而已。 - chris
我的朋友说:“你调用了一个额外的构造函数和一个额外的析构函数”。 - ar1a
3
因为你会忘记,也许不是现在,但以后会。你会改变某些东西,然后忘记去改变其他的东西。现在你出现了内存泄漏。或者你会双倍释放它,此外,这并不是“臃肿”。 - xaxxon
1
为了证明一点,看看这个构造函数和getter函数给我带来了多少成本在这里。请注意,编译器只是加载值并打印它,完全优化掉了对我的类的任何引用。而且这还忽略了复制8个字节的构造函数调用在几乎所有情况下都是可以忽略不计的事实。 - chris
3
你的朋友过于早期优化,这会导致他们的代码变得一团糟。写出整洁的代码,利用编程语言帮助你正确完成它。 - GManNickG
3
在我的测试中,unique pointerraw pointer 的速度一样快。当查看生成的汇编代码时,编译器在解引用 unique pointerraw pointer 时生成了相同的代码。优化器在解引用时完全删除了 std::unique_ptr - Galik
3个回答

34

您上面的代码实际上有一个错误,因为您没有定义复制构造函数或赋值运算符。想象一下这段代码:

Foo one;
Foo two = one;

因为twoone的一个副本,所以它使用默认的拷贝构造函数进行初始化 - 这将使得两个bar指针都指向同一个对象。这意味着当two的析构函数被调用时,它将回收与one共享的同一对象,因此one的析构函数将引发未定义行为。糟糕了。

现在,如果您不希望使您的对象可以被拷贝,您可以明确地这样表示:

class Foo
{
public:
    Foo()
    {
        bar = new Bar;
    }
    Foo(const Foo&) = delete;
    Foo& operator= (const Foo&) = delete;

    ~Foo()
    {
        if(bar)
            delete bar;
    }
private:
    Bar* bar;

};

这样就解决了那个问题 - 但看看所涉及的代码量!你必须明确删除两个函数并手动编写析构函数。

除了还有另一个问题。假设我这样做:

Foo one;
Foo two = std::move(one);

这将通过将one的内容移动到two来初始化two。或者说是这样吗?不幸的是,答案是否定的,因为默认移动构造函数会默认移动指针,这只会执行直接指针复制。所以现在你得到了跟之前一样的结果。糟糕。

不用担心!我们可以通过定义自定义移动构造函数和移动赋值运算符来解决这个问题:

class Foo
{
public:
    Foo()
    {
        bar = new Bar;
    }
    Foo(const Foo&) = delete;
    Foo& operator= (const Foo&) = delete;

    Foo(Foo&& rhs)
    {
        bar = rhs.bar;
        rhs.bar = nullptr;
    }

    Foo& operator= (Foo&& rhs)
    {
        if (bar != rhs.bar)
        {
            delete bar;
            bar = rhs.bar;
            rhs.bar = nullptr;
        }
    }

    ~Foo()
    {
        if(bar)
            delete bar;
    }
private:
    Bar* bar;

};

哎呀!这是一堆代码,但至少它是正确的。(或者不是吗?)

另一方面,想象一下你写了这个:

class Foo
{
public:
    Foo() : bar(new Bar) {
    }
private:
    std::unique_ptr<Bar> bar;
};

哇,那样子就简短多了!它自动确保该类无法被复制,而且能使默认的移动构造函数和移动赋值运算符正常工作。

因此,std::unique_ptr 的一个巨大优势在于它自动处理资源管理,但另一个优势是它与复制和移动语义相协调,不会出现意外行为。这是使用它的主要原因之一。您可以明确表达自己的意思——“只有我应该知道这个东西,你不能共享它”——编译器会为您执行强制性规定。让编译器帮助您避免错误几乎总是一个好主意。

至于代码膨胀问题——我需要看到证据。 std::unique_ptr 是对指针类型的轻薄封装,良好的优化编译器应该可以为其生成良好的代码。确实,与 std::unique_ptr 相关联的构造函数、析构函数等,但合理的编译器将内联这些调用,从根本上做的事情与最初描述的一样。


7
可以笑一下吗?“但我的例子非常简单”……然而,发帖人仍然搞砸了。 - xaxxon
5
@Snorflake 我不确定你的朋友是什么意思。我认为上面的回答与懒惰无关。(另外,要非常严谨,但希望以一种指导性的方式:术语“STL”通常指的是像std::vectorstd::map这样最初是作为STL库的一部分,但现在已经(在一定程度上)合并到语言中的东西。我不会真的将std::unique_ptr称为STL的一部分)。 - templatetypedef
6
我假设你的朋友也使用原始的char *并且每次需要时都会编写自己的动态数组实现。而你使用标准库,因为它们是已经解决的问题。为什么要每次写新程序时都要重复解决这些问题呢? - Miles Budnek
6
你的朋友对日常问题的简单解决方法有一种奇怪的厌恶感。 - jonspaceharper
6
“因为我太懒了,不想避免使用STL。” 有真正的理由避免使用标准库中的任何特定元素。但声称使用经过良好测试、高度实用的代码是“懒惰”的说法完全荒谬。这个人似乎对理性不敏感,所以建议您不要听从他们的建议。 - Nicol Bolas
显示剩余5条评论

4
你基本上依赖一个类来管理指针的生命周期,但忽略了指针会在函数之间传递、从函数中返回,并且通常随处存在的事实。如果你的示例中的指针需要比类更长的生存期怎么办?如果它需要在类被销毁之前被删除怎么办?
考虑下面这个函数:
Bar * doStuff(int param) {
    return new Bar(param);
}

您现在拥有一个动态分配的对象,如果您忘记删除它,可能会导致内存泄漏。也许您没有仔细阅读文档,或者文档不够清晰明了。无论如何,这个函数会使您额外承担销毁 Bar 实例的负担。

现在考虑以下情况:

std::unique_ptr<Bar> doStuffBetter(int param) {
    return new Bar(param);
}

返回的unique_ptr管理包装的指针的生命周期。函数返回unique_ptr的事实消除了关于所有权和生命周期的任何混淆。一旦返回的unique_ptr超出范围并调用其析构函数,Bar实例将自动删除。 unique_ptr只是标准库提供的几种方法之一,使使用指针的过程变得更加简洁,并表达所有权。它既轻量级又基本上像普通指针一样工作,除了拷贝。

1
使用std::unique_ptr(RAII)而不是原始指针更容易实现异常安全。
考虑一个类在其构造函数中获取两个成员变量的内存。
class Foo
{
public:
    Foo()
    {
        bar = new Bar;
        car = new Car;    // <- What happens when this throws exception?
    }

    ~Foo()
    {
        if(bar)
            delete bar;
        if(car)
            delete car;
    }
private:
    Bar* bar;
    Car* car;

};

如果Foo的构造函数抛出异常,Foo没有被成功构造,因此其析构函数不会被调用。当new Car抛出异常时,bar没有被删除,因此会发生内存泄漏。
现在考虑使用std::unique_ptr的代码。
class Foo
{
public:
    Foo() : bar(std::make_unique<Bar>()), car(std::make_unique<Car>()) {}

private:
    std::unique_ptr<Bar> bar;
    std::unique_ptr<Car> car;

};

如果Foo的构造函数抛出异常,则Foo没有成功构造,因此其析构函数不会被调用。但是,成功创建的成员实例的析构函数将被调用。即使std::make_unique<Car>()抛出异常,bar的析构函数也会被调用,因此不会发生内存泄漏。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接