智能指针的陷阱






4.83/5 (22投票s)
关于智能指针的一些值得了解的问题。
引言
在现代 C++ 中使用智能指针存在一些问题。
- 为什么
auto_ptr
被弃用了? - 为什么
unique_ptr
最终能用得很好? - 如何在数组中使用
unique_ptr
? - 为什么使用
make_shared
创建shared_ptr
? - 如何在数组中使用
shared_ptr
? - 如何将智能指针传递给函数?
- 如何转换智能指针?
在学习如何使用新的 C++ 标准时,我遇到了一些关于智能指针的问题。总的来说,使用这些辅助对象可以让你少犯很多错误。建议尽可能使用它们代替裸指针。不幸的是,有些话题你需要理解才能充分利用它们。正如大多数情况一样,你得到了一个解决问题的工具,但另一方面,这个工具也引入了其他问题。
一些预定义
让我们以一个简单的 Test
类和一个成员来展示后续的概念
class Test
{
public:
Test():m_value(0) { std::cout << "Test::Test" << std::endl; }
~Test() { std::cout << "Test::~Test destructor" << std::endl; }
int m_value;
};
typedef std::auto_ptr<Test> TestAutoPtr;
typedef std::unique_ptr<Test> TestUniquePtr;
typedef std::shared_ptr<Test> TestSharedPtr;
为什么 auto_ptr 被弃用了?
auto_ptr
是 C++ 中最早引入的智能指针类型之一(更准确地说是在 C++98 中)。它被设计为一个简单的独占指针(只有一个所有者,没有引用计数器),但人们也试图以共享指针的形式使用它。auto_ptr
的实现两者都未能令人满意!下面是一个快速示例
void doSomethig(TestAutoPtr myPtr) {
myPtr->m_value = 11;
}
void AutoPtrTest() {
TestAutoPtr myTest(new Test());
doSomethig(myTest);
myTest->m_value = 10;
}
尝试编译并运行此代码...会发生什么?在我们离开 doSomething
函数后,程序就会崩溃!我们会假设在 doSomething
中,某个指针的引用计数器会被增加,但 auto_ptr
并没有这个功能。
由于我们在离开 doSomething
函数时,指针超出了作用域并被删除,因此对象被销毁。为了使其正常工作,我们可以传递一个 auto_ptr
的引用。
另一件事是我们删除更复杂对象的有限方式,完全没有控制,只能使用 delete
。
为什么 unique_ptr 最终能用得很好?
幸运的是,有了新标准,我们得到了一套全新的智能指针!当我们用 std::unique_ptr<Test>
替换我们之前示例中的 auto_ptr
时,我们会得到一个编译错误(而不是运行时错误),说明不能将指针传递给其他函数。这是正确的行为。
unique_ptr
是通过移动语义正确实现的。我们可以从一个指针移动(但不能复制)所有权到另一个指针。我们还需要注意何时何地传递所有权。
在我们的示例中,我们可以使用
doSomethig(std::move(myTest));
来移动指针的所有权。这样,在函数返回后,我们的指针也将失效,但我们毕竟是故意的。这种指针的另一个优点是可以自定义删除器。当处理复杂资源(文件、纹理等)时非常有用。
如何在数组中使用 unique_ptr?
首先需要知道的是
std::unique_ptr<int> p(new int[10]); // will not work!
上面的代码可以编译,但在资源即将被删除时,只会调用一次 delete
。那么我们如何确保调用 delete[]
呢?幸运的是,unique 指针具有数组的正确偏特化,我们可以使用
std::unique_ptr<int[]> p(new int[10]);
p[0] = 10;
对于我们特定的示例,我们可以写
std::unique_ptr<Test[]> tests(new Test[3]);
并且我们会得到预期的输出
Test::Test
Test::Test
Test::Test
Test::~Test destructor
Test::~Test destructor
Test::~Test destructor
正如预期的那样。
请注意,如果你想传递第一个元素的地址,你必须使用 &(pointerToArray[0])
。直接写 pointerToArray
是无效的。
为什么使用 make_shared 创建 shared_ptr?
Unique 指针仅通过明智地使用 C++ 语法(使用私有拷贝构造函数、赋值运算符等)提供其功能,它们不需要额外的内存。对于 shared_ptr
,我们需要将一个引用计数器与我们的对象关联起来。当我们这样做时
std::shared_ptr<Test> sp(new Test());
std::shared_ptr<Test> sp2 = std::make_shared<Test>();
我们将得到预期的输出
Test::Test
Test::Test
Test::~Test destructor
Test::~Test destructor
那么有什么区别?为什么不使用类似于创建 unique_ptr
的语法?答案在于分配过程。对于第一种构造方式,我们需要为对象分配空间,然后为引用计数器分配空间。对于第二种构造方式,只有一次分配(使用 placement new),并且 ref
计数器与所指向的对象共享同一内存块。
上面,您可以看到 VS 2012 中本地视图的图片。比较对象数据和引用计数器块的地址。对于sp2,我们可以看到它们非常接近。
为了确保我得到了正确的结果,我甚至在 stackoverflow 上提出了一个问题:http://stackoverflow.com/questions/14665935/make-shared-evidence-vs-default-construct。
顺便说一下: C++14 有一个很好的改进:make_unique
函数!这样创建智能指针就更加“统一”了。我们有了 make_shared
和 make_unique
。
如何在数组中使用 shared_ptr?
与使用 unique_ptr
相比,在数组中使用 shared_ptr
要棘手一些,但我们可以使用自己的删除器来获得完全的控制权。
std::shared_ptr<Test> sp(new Test[2], [](Test *p) { delete [] p; });
我们需要使用自定义删除器(这里是 lambda 表达式)。此外,我们不能使用 make_shared
构造。不幸的是,为数组使用共享指针并不那么方便。我建议改用 boost。例如:https://boost.ac.cn/doc/libs/1520/libs/smartptr/sharedarray.htm。
如何将智能指针传递给函数?
我们应该将智能指针作为 C++ 中的一等对象使用,所以通常应该将它们按值传递给函数。这样引用计数器会正确增加/减少。但我们可以使用一些其他看起来有点误导的构造。这里有一些代码
void testSharedFunc(std::shared_ptr<Test> sp) {
sp->m_value = 10;
}
void testSharedFuncRef(const std::shared_ptr<Test> &sp) {
sp->m_value = 10;
}
void SharedPtrParamTest() {
std::shared_ptr<Test> sp = std::make_shared<Test>();
testSharedFunc(sp);
testSharedFuncRef(sp);
}
上面的代码将按预期工作,但在 testSharedFuncRef
中,我们根本没有从使用共享指针中获得任何好处!只有 testSharedFunc
会增加引用计数器。对于一些性能关键的代码,我们还需要注意到按值传递需要复制整个指针块,所以也许在那里使用裸指针更好。
但是,也许第二种选择(带引用)更好?这取决于。主要问题是你想拥有对象的完全所有权。如果不想(例如,你有一个调用对象方法的通用函数),那么我们不需要所有权……简单地按引用传递是一种良好而快速的方法。
不只是我感到困惑。即使 Herb Sutter 也关注了这个问题,并且这是他在这个问题上的帖子:http://herbsutter.com/2012/06/05/gotw-105-smart-pointers-part-3-difficulty-710/。
如何转换智能指针?
让我们以一个简单的继承为例
class BaseA
{
protected:
int a{ 0 };
public:
virtual ~BaseA() { }
void A(int p) { a = p; }
};
class ChildB : public BaseA
{
private:
int b{ 0 };
public:
void B(int p) { b = p; }
};
你可以在不产生问题的情况下,创建一个 BaseA 的智能指针并用 ChildB 初始化它
std::shared_ptr<BaseA> ptrBase = std::make_shared<ChildB>();
ptrBase->A(10);
但是如何从 ptrBase
获取指向 ChildB 类的指针呢?虽然这不是一个好习惯,但有时我们知道这是需要的。
你可以试试这个
ChildB *ptrMan = dynamic_cast<ChildB *>(ptrBase.get());
ptrMan->B(10);
它应该会起作用。但是,这样你只得到一个“普通”指针!原始 ptrBase
的 use_count
没有增加。你现在可以观察对象,但你不是所有者。
最好使用为智能指针设计的转换
std::shared_ptr<ChildB> ptrChild = std::dynamic_pointer_cast<ChildB>(ptrBase);
if (ptrChild)
{
ptrChild->B(20);
std::cout << "use count A: " << ptrBase.use_count() << std::endl;
std::cout << "use count B: " << ptrChild.use_count() << std::endl;
}
通过使用 std::dynamic_pointer_cast,你可以得到一个共享指针。现在你也成为了所有者。在这种情况下,ptrBase
和 ptrChild
的使用计数器为'2'。
查看 std::static_pointer_cast 和 std::const_pointer_cast 以获取更多信息。
unique_ptr 怎么样?
在之前的例子中,你得到了原始指针的一个副本。但 unique_ptr 不能有副本……所以提供转换函数没有意义。如果你需要一个用于观察的转换指针,那么你需要用老方法来做。
一些补充说明
智能指针非常有用,但我们作为用户也需要聪明。我还没有像我想的那样精通智能指针。例如,有时我倾向于使用裸指针:我知道会发生什么,并且在某个时候我可以保证它不会搞乱内存。不幸的是,这可能是未来的一个潜在问题。当代码更改时,我的假设可能不再有效,新的 bug 可能会出现。另一件事是当新的开发者开始更改我的代码时。使用智能指针,不容易搞砸。
所有这些话题都有些复杂,但 C++ 中的一贯情况是,我们得到一些东西是需要付出代价的。我们需要了解我们在做什么,才能充分利用某个特定功能。
本文的代码可以在这里找到。