C++ - 传递 std::shared_ptr 或 boost::shared_ptr 的引用

116
如果我有一个需要使用shared_ptr的函数,那么把它传递一个引用而不是复制shared_ptr对象会更有效率,这样做有可能导致什么问题呢?我能想到两种可能的情况:

1)函数内部会对该参数进行复制,例如:

ClassA::take_copy_of_sp(boost::shared_ptr<foo> &sp)  
{  
     ...  
     m_sp_member=sp; //This will copy the object, incrementing refcount  
     ...  
}  

2) 在函数内部,参数仅被使用,就像在

Class::only_work_with_sp(boost::shared_ptr<foo> &sp) //Again, no copy here  
{    
    ...  
    sp->do_something();  
    ...  
}  

我认为没有充分的理由将 boost::shared_ptr<foo> 按值传递而不是按引用传递。按值传递只会通过复制“临时”增加引用计数,然后在退出函数范围时减少它。

我想确认一下,经过阅读多个答案后,我完全同意对过早优化的担忧,并且我总是尝试先进行性能分析,然后再解决热点问题。我的问题更多地是从技术代码角度出发的,如果你知道我的意思。


我不知道你是否可以修改你的问题标签,但请尝试添加一个boost标签。我试图寻找这个问题,但是因为我只搜索了boost和smart-pointer标签,所以没有找到任何结果。所以在撰写自己的问题后,我才发现了你的问题。 - Gustavo Muenz
17个回答

119

我发现自己不同意得票最高的答案,所以我去寻找专家意见,这里是他们的意见。 来自http://channel9.msdn.com/Shows/Going+Deep/C-and-Beyond-2011-Scott-Andrei-and-Herb-Ask-Us-Anything

Herb Sutter:“当你传递shared_ptrs时,复制是昂贵的”

Scott Meyers:“关于是否按值传递它或按引用传递它,shared_ptr没有任何特殊之处。使用与任何其他用户定义类型相同的分析方法。人们似乎认为shared_ptr在解决所有管理问题方面有些特别,并且因为它很小,所以按值传递必然是廉价的。它必须被复制,这是有成本的......按值传递它是昂贵的,因此如果我的程序可以通过适当的语义实现,我会按const引用或引用传递它。”

Herb Sutter: “始终将它们作为const引用传递,仅在您知道调用可能修改引用来源的内容时,偶尔才使用值传递...如果将它们作为参数复制,哦天啊,您几乎永远不需要增加引用计数,因为它已经被保持活动状态,并且应该通过引用传递,所以请这样做。”
更新:Herb在这里 http://herbsutter.com/2013/06/05/gotw-91-solution-smart-pointer-parameters/ 对此进行了扩展,尽管故事的寓意是您根本不应该传递shared_ptrs,“除非您想使用或操作智能指针本身,例如共享或转移所有权。”

9
发现得不错!很高兴看到两位该领域的顶尖专家公开反驳关于SO的传统看法。 - Stephan Tolksdorf
3
“在传递 shared_ptr 时,无论你是通过值传递还是通过引用传递,都没有什么特别的地方。” -- 我真的不同意这个说法。它确实很特殊。就个人而言,我更愿意安全起见,承受轻微的性能损失。如果有一个特定的代码区域需要优化,那么我会考虑 shared_ptr 常量引用传递的性能优势。 - JasonZ
3
值得注意的是,尽管有关于过度使用 shared_ptr 的共识,但在值传递 vs 引用传递问题上并没有达成一致。 - Nicol Bolas
7
Herb Sutter说:“很少情况下,可能是因为你所调用的函数会修改你获取引用的对象。” 再次强调,这只是一个小的例外情况,不会与我的答案相矛盾。问题仍然存在:如何“知道”使用const引用是安全的?在简单的程序中很容易证明,但在复杂的程序中却不那么容易。但嘿,这是C++,所以我们更愿意过早地进行微观优化而不是其他任何工程问题,对吧? :) - Daniel Earwicker
2
尽管这些“专家”声称如此,但正如Daniel Earwicker已经解释的那样,他们显然是错的。当最高票答案显然是可验证的事实陈述时,写下“我发现自己不同意最高票答案”的言论是荒谬的。 - J D
显示剩余11条评论

115

明确使用一个独立的shared_ptr实例的目的是要尽可能地保证,在此shared_ptr仍在范围内时,它所指向的对象仍将存在,因为其引用计数至少为1。

Class::only_work_with_sp(boost::shared_ptr<foo> sp)
{
    // sp points to an object that cannot be destroyed during this function
}

因此,使用 shared_ptr 的引用会禁用该保证。因此,在您的第二种情况中:

Class::only_work_with_sp(boost::shared_ptr<foo> &sp) //Again, no copy here  
{    
    ...  
    sp->do_something();  
    ...  
}

你怎么知道 sp->do_something() 不会因为空指针而导致崩溃?

这完全取决于代码中“...”部分的内容。如果你在第一个“...”期间调用了某些东西,它会产生副作用(在代码的另一部分),清除了对同一对象的shared_ptr,而且它恰好是该对象的最后剩余的不同shared_ptr,那么你想使用的对象就不见了。

因此,有两种方式回答这个问题:

  1. 仔细检查整个程序的源代码,直到你确定对象在函数体内不会死亡。

  2. 将参数改回一个独立的对象而不是引用。

这里有一个应用的通用建议:在使用性能分析器以及明确测量所要进行的更改是否对性能有显著影响之前,不要为了性能而进行冒险的代码修改。

评论者JQ的更新

以下是一个人为制造的例子,它故意简单化了错误。在实际例子中,由于隐藏在多层真实细节中,错误并不那么明显。

我们有一个将消息发送到某个地方的函数。这可能是一个大型消息,因此我们使用指向字符串的shared_ptr,而不是像传递到多个位置时会被复制的std::string

void send_message(std::shared_ptr<std::string> msg)
{
    std::cout << (*msg.get()) << std::endl;
}

对于这个例子,我们只是将其“发送”到控制台。

现在我们想要添加一种记住上一个消息的功能。我们希望实现以下行为:存在一个变量,其中包含最近发送的消息,但在当前正在发送消息时,不应有先前的消息(变量应在发送之前被重置)。因此,我们声明了新变量:

std::shared_ptr<std::string> previous_message;

然后,我们根据规定修改我们的函数:

void send_message(std::shared_ptr<std::string> msg)
{
    previous_message = 0;
    std::cout << *msg << std::endl;
    previous_message = msg;
}

所以在我们开始发送之前,我们会丢弃当前的上一条消息,然后在发送完成后,我们可以存储新的上一条消息。所有的都很好。这是一些测试代码:

send_message(std::shared_ptr<std::string>(new std::string("Hi")));
send_message(previous_message);

正如预期的那样,这将两次打印Hi!

现在维护人员登场了,他看着这段代码并想到:嘿,send_message函数的这个参数是一个shared_ptr

void send_message(std::shared_ptr<std::string> msg)

显然,这可以被更改为:

void send_message(const std::shared_ptr<std::string> &msg)

想象一下这将带来的性能提升!(别管我们即将通过某个通道发送一个通常很大的消息,所以性能提升将非常微小,无法测量)。

但真正的问题是现在测试代码将表现出未定义的行为(在Visual C++ 2010调试版本中会崩溃)。

维护者先生对此感到惊讶,但在send_message中添加了一个防御性检查,试图阻止这个问题的发生:

void send_message(const std::shared_ptr<std::string> &msg)
{
    if (msg == 0)
        return;

当然,它仍然会崩溃,因为在调用send_message时,msg从未为空。

就像我说的,在一个简单的示例中,所有代码都如此接近,很容易找到错误。但在真正的程序中,由于可变对象之间存在更复杂的关系,彼此持有指针,因此很容易犯错,并且很难构建必要的测试用例来检测错误。

如果您希望函数能够依赖于shared_ptr始终保持非空状态,则简单的解决方案是让函数分配自己的真正的shared_ptr,而不是依赖于对现有shared_ptr的引用。

缺点是,复制shared_ptr不是免费的:即使是“无锁”实现也必须使用交换操作来遵守线程保证。因此,在某些情况下,通过将shared_ptr改为shared_ptr &可以显著加快程序速度。但这不是一项可以安全应用于所有程序的更改。它改变了程序的逻辑含义。

请注意,如果我们始终使用std::string而不是std::shared_ptr<std::string>,那么将发生类似的错误,而不是:

previous_message = 0;

为了清除该消息,我们说:
previous_message.clear();

那么这个症状将是意外发送空消息,而不是未定义的行为。复制一个非常大的字符串的额外成本可能比复制 shared_ptr 的成本显著得多,因此权衡可能会有所不同。


16
传入的shared_ptr已经存在于调用方的作用域中。你可能会构造一个复杂的情境,导致这个问题中的代码出现悬空指针而崩溃,但我想,那时你面临的问题比引用参数更大了! - Magnus Hoff
10
它可能被存储在一个成员中。您可以调用一些操作来清除该成员。smart_ptr 的整个目的是避免在嵌套在调用堆栈周围的层次结构或作用域中协调生命周期,因此最好假设在这种程序中生命周期不会这样做。 - Daniel Earwicker
7
这并不是我的观点!如果你认为我所说的与我的代码有特定关系,那么你可能没有理解我的意思。我在谈论shared_ptr存在的必然含义:许多对象生命周期并不仅仅与函数调用相关。 - Daniel Earwicker
8
完全同意你的观点,对反对的程度感到惊讶。更让你的担忧变得更加相关的是线程,当它涉及其中时,关于对象有效性的保证变得更加重要。好答案。 - radman
3
不久前,我追踪到了一个非常严重的错误,原因是将一个共享指针的引用传递了过去。该代码处理对象的状态变化,当它注意到对象的状态已经改变时,它会将其从以前状态的对象集合中移除,并将其移动到新状态的对象集合中。删除操作销毁了对象的最后一个共享指针。该成员函数在集合中使用共享指针的引用调用。出问题了。丹尼尔·厄维克是正确的。 - David Schwartz
显示剩余31条评论

23

我建议除非你和与你一起工作的其他程序员真的,真的知道自己在做什么,否则不要采用这种做法。

首先,你不知道你的类接口可能会如何发展,而且你希望防止其他程序员做出错误的事情。通过引用传递shared_ptr并不是一个程序员应该预期看到的东西,因为它不是惯用语法,这使得很容易使用不正确。以防御性编程: 使接口难以被错误使用。通过引用传递只会在以后产生问题。

其次,在您确定此特定类将成为问题之前,请勿进行优化。先进行性能分析,如果您的程序确实需要通过引用传递来提高效率,那么也许可以考虑。否则,不要太在意小细节(即通过值传递需要额外的N条指令),而是关注设计、数据结构、算法和长期可维护性。


虽然litb的回答在技术上是正确的,但永远不要低估程序员的“懒惰”(我也很懒!)。littlenag的回答更好,因为对shared_ptr的引用将是意外的,并且可能(很可能)是一种不必要的优化,使未来的维护更具挑战性。 - netjeff

18

是的,在那里使用引用是可以的。你不想给该方法共享所有权,它只想与它一起工作。你也可以为第一种情况取一个引用,因为你无论如何都会复制它。但对于第一种情况,它获取所有权。有一个技巧可以实现只复制一次:

void ClassA::take_copy_of_sp(boost::shared_ptr<foo> sp) {
    m_sp_member.swap(sp);
}

在返回对象时也应进行复制操作(即不要返回引用),因为您的类不知道客户端到底做了什么(它可能会存储指向该对象的指针,然后出现大问题)。如果后来发现这是一个瓶颈(首先进行分析!),那么仍然可以返回一个引用。

编辑:当然,正如其他人所指出的那样,只有在您知道自己的代码并且知道自己没有以某种方式重置传递的共享指针时,上述才是正确的。如果存在疑问,最好直接按值传递。


11

shared_ptr 作为 const& 参数传递是明智的选择。这不太可能会引起问题(除非在函数调用期间被删除的 shared_ptr,详见 Earwicker 的说明),而且如果需要频繁传递很多 shared_ptr,那么这样传递可能会更快。请记住,boost::shared_ptr 的默认设置是线程安全的,因此复制它包括一个线程安全的增量。

尽量使用 const& 而不是仅使用 &,因为临时对象可能无法通过非 const 引用传递。(即使 MSVC 中的语言扩展允许您这样做)


3
是的,我总是使用常量引用,只是忘记在我的例子中加上了。 不管怎样,MSVC允许将非常量引用绑定到临时对象并不是因为bug,而是因为默认情况下它的属性“C/C++ -> 语言 -> 禁用语言扩展”设置为“否”。启用它,就不能编译它们了... - abigagli
abigagli:认真的吗?太棒了!我明天上班第一件事就会执行这个 ;) - Magnus Hoff

10
在第二种情况下,这样做更加简单:
Class::only_work_with_sp(foo &sp)
{    
    ...  
    sp.do_something();  
    ...  
}

你可以称之为

only_work_with_sp(*sp);

3
如果您遵循惯例,在不需要复制指针时使用对象引用,这将有助于记录您的意图并提供使用const引用的机会。请注意,这不会改变原始意思。 - Mark Ransom
是的,我同意使用对象引用作为一种表达所调用函数不“记住”该对象的方式。通常,如果函数正在“跟踪”对象,则我会使用指针形式参数。 - abigagli

3
我建议通过const引用传递共享指针 - 这意味着被传递的指针的函数不拥有该指针,这是开发人员的一种清晰习惯用法。
唯一的陷阱在于,在多线程程序中,由共享指针指向的对象在另一个线程中被销毁。因此,可以说在单线程程序中使用共享指针的const引用是安全的。
通过非const引用传递共享指针有时是危险的 - 原因是函数可能在内部调用swap和reset函数,以销毁在函数返回后仍然被认为是有效的对象。
我想这不是关于过早优化的问题 - 而是关于避免在你清楚自己想要做什么并且编码习惯已经被同事采纳的情况下浪费CPU周期。
只是我的个人看法 :-)

1
请看 David Schwartz 上面的注释:“我追查了一个非常严重的错误,由于传递了共享指针的引用。代码正在处理对象的状态更改,当它注意到对象的状态已经改变时,它将其从以前状态的对象集合中删除,并将其移动到新状态的对象集合中。删除操作会销毁对象的最后一个共享指针。成员函数已在集合中共享指针的引用上调用。砰!” - Jason Harrison

3

看起来这里的所有优缺点实际上都可以概括为任何通过引用传递的类型,而不仅仅是shared_ptr。在我看来,你应该了解按引用、按常量引用和按值传递的语义,并正确使用它们。但是,除非你认为所有引用都是坏的,否则通过引用传递shared_ptr并没有本质上的问题...

回到例子:

Class::only_work_with_sp( foo &sp ) //Again, no copy here  
{    
    ...  
    sp.do_something();  
    ...  
}

您如何知道sp.do_something()不会因为悬空指针而崩溃?

事实上,无论是shared_ptr还是不是,const还是不是,如果您存在设计缺陷,例如直接或间接在线程之间共享sp的所有权,误用执行delete this的对象,拥有循环所有权或其他所有权错误,都可能发生这种情况。


3
我建议避免使用“普通”的引用,除非函数明确可以修改指针。
在调用小型函数时,const &可能是一个明智的微观优化 - 例如,为了启用进一步的优化,如内联一些条件。另外,增量/减量 - 因为它是线程安全的 - 是同步点。虽然我不认为这在大多数情况下会有很大的差异。
通常情况下,你应该使用更简单的样式,除非你有理由不这样做。然后,要么始终使用const &,要么在仅在少数地方使用时添加注释说明原因。

2

还有一件事情没有被提到,那就是当你通过引用传递共享指针时,你会失去隐式转换。如果你想通过一个基类的共享指针引用来传递一个派生类的共享指针,这种情况下就需要注意。

例如,这段代码会产生错误,但是如果你修改test()函数,使得共享指针不是通过引用传递,它将能够正常工作。

#include <boost/shared_ptr.hpp>

class Base { };
class Derived: public Base { };

// ONLY instances of Base can be passed by reference.  If you have a shared_ptr
// to a derived type, you have to cast it manually.  If you remove the reference
// and pass the shared_ptr by value, then the cast is implicit so you don't have
// to worry about it.
void test(boost::shared_ptr<Base>& b)
{
    return;
}

int main(void)
{
    boost::shared_ptr<Derived> d(new Derived);
    test(d);

    // If you want the above call to work with references, you will have to manually cast
    // pointers like this, EVERY time you call the function.  Since you are creating a new
    // shared pointer, you lose the benefit of passing by reference.
    boost::shared_ptr<Base> b = boost::dynamic_pointer_cast<Base>(d);
    test(b);

    return 0;
}

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