让我尝试说明将指针传递给对象的不同可行模式,这些对象的内存由std::unique_ptr
类模板的实例管理;它也适用于较旧的std::auto_ptr
类模板(我相信它允许唯一指针所做的所有使用,但另外,在需要右值的地方将接受可修改的左值,而不必调用std::move
),并且在某种程度上也适用于std::shared_ptr
.
作为讨论的具体示例,我将考虑以下简单的列表类型
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
此类列表的实例(不允许与其他实例共享部分或循环)完全由持有初始list
指针的人所有。如果客户端代码知道它存储的列表永远不会为空,它也可以选择node
直接存储第一个而不是list
. 不需要node
定义析构函数:由于其字段的析构函数是自动调用的,一旦初始指针或节点的生命周期结束,整个列表将被智能指针析构函数递归删除。
这种递归类型让我们有机会讨论一些在指向普通数据的智能指针的情况下不太明显的情况。此外,函数本身偶尔也会(递归地)提供客户端代码的示例。typedef forlist
当然偏向于unique_ptr
,但是可以将定义更改为 use auto_ptr
,或者shared_ptr
无需太多更改为下面所说的内容(特别是在不需要编写析构函数的情况下确保异常安全性)。
传递智能指针的模式
模式 0:传递指针或引用参数而不是智能指针
如果您的函数不关心所有权,这是首选方法:根本不要让它采用智能指针。在这种情况下,您的函数不需要担心谁拥有指向的对象,或者通过什么方式管理所有权,因此传递原始指针既是完全安全的,也是最灵活的形式,因为无论所有权如何,客户端都可以产生一个原始指针(通过调用get
方法或从地址运算符&
)。
例如,计算此类列表长度的函数不应给出list
参数,而应给出原始指针:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
持有变量的客户端list head
可以调用此函数 as length(head.get())
,而选择存储node n
表示非空列表的客户端可以调用length(&n)
。
如果保证指针不为空(这里不是这种情况,因为列表可能为空),则可能更喜欢传递引用而不是指针。const
如果函数需要更新节点的内容,而不添加或删除它们中的任何一个(后者将涉及所有权),它可能是指向非的指针/引用。
属于模式 0 类别的一个有趣案例是制作列表的(深层)副本;虽然这样做的函数当然必须转移它创建的副本的所有权,但它并不关心它正在复制的列表的所有权。所以可以定义如下:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
这段代码值得仔细研究一下,无论是关于它为什么编译的问题(copy
初始化列表中递归调用的结果绑定到的移动构造函数中的右值引用参数unique_ptr<node>
,也就是list
初始化next
字段时生成node
),以及为什么它是异常安全的问题(如果在递归分配过程中内存用完并且调用了new
throws std::bad_alloc
,那么当时指向部分构造的列表的指针被匿名保存在临时类型中list
为初始化列表创建,其析构函数将清理该部分列表)。顺便说一句,一个人应该抵制诱惑(就像我最初所做的那样)替换第二nullptr
个p
,毕竟在那一点上知道它是空的:即使知道它是空的,也不能从(原始)指针构造一个智能指针到常量。
方式一:按值传递智能指针
将智能指针值作为参数的函数立即占有指向的对象:调用者持有的智能指针(无论是在命名变量中还是在匿名临时变量中)被复制到函数入口处的参数值和调用者的指针已变为空(在临时的情况下,副本可能已被省略,但无论如何调用者都无法访问指向的对象)。我想用现金调用这种模式:调用者预先为调用的服务付费,并且在调用后对所有权没有任何幻想。为了清楚起见,语言规则要求调用者将参数包装在std::move
如果智能指针保存在变量中(从技术上讲,如果参数是左值);在这种情况下(但不适用于下面的模式 3),此函数执行其名称所暗示的操作,即将值从变量移动到临时变量,使变量为空。
对于被调用函数无条件获取(窃取)指向对象的所有权的情况,这种模式与std::unique_ptr
orstd::auto_ptr
一起使用是传递指针及其所有权的好方法,这样可以避免任何内存泄漏的风险。尽管如此,我认为只有极少数情况下模式 3 不优于模式 1(非常轻微)。出于这个原因,我将不提供该模式的使用示例。(但请参见reversed
下面模式 3 的示例,其中指出模式 1 至少也可以。)如果函数接受的参数比这个指针更多,则可能会出现另外一个技术原因来避免模式1(带std::unique_ptr
或std::auto_ptr
):因为实际的移动操作发生在传递指针变量时p
通过表达式std::move(p)
,在评估其他参数时不能假定它p
具有有用的值(未指定评估顺序),这可能会导致细微的错误;相比之下,使用模式 3 确保p
在函数调用之前不会发生移动,因此其他参数可以安全地通过p
.
与 一起使用时std::shared_ptr
,这种模式很有趣,因为它允许调用者选择是否为自己保留指针的共享副本,同时创建一个新的共享副本供函数使用(当左值提供了参数;调用时使用的共享指针的复制构造函数增加了引用计数),或者只是给函数一个指针的副本而不保留一个或触及引用计数(这发生在提供右值参数时,可能包裹在 ) 调用中的左值std::move
。例如
void f(std::shared_ptr<X> x) // call by shared cash
{ container.insert(std::move(x)); } // store shared pointer in container
void client()
{ std::shared_ptr<X> p = std::make_shared<X>(args);
f(p); // lvalue argument; store pointer in container but keep a copy
f(std::make_shared<X>(args)); // prvalue argument; fresh pointer is just stored away
f(std::move(p)); // xvalue argument; p is transferred to container and left null
}
同样可以通过分别定义void f(const std::shared_ptr<X>& x)
(对于左值情况)和void f(std::shared_ptr<X>&& x)
(对于右值情况)来实现,函数体的不同之处仅在于第一个版本调用复制语义(使用时使用复制构造/赋值x
),而第二个版本移动语义(std::move(x)
改为编写,如示例代码中所示)。所以对于共享指针,模式 1 可以避免一些代码重复。
模式2:通过(可修改的)左值引用传递智能指针
在这里,该函数只需要对智能指针有一个可修改的引用,但没有说明它将如何处理它。我想通过卡调用此方法:调用者通过提供信用卡号来确保付款。引用可用于获取指向对象的所有权,但并非必须如此。此模式需要提供可修改的左值参数,对应于函数的预期效果可能包括在参数变量中留下有用值的事实。具有希望传递给此类函数的右值表达式的调用者将被迫将其存储在命名变量中以便能够进行调用,因为该语言仅提供到常量的隐式转换来自右值的左值引用(指临时)。(与由 处理的相反情况不同,使用智能指针类型std::move
从Y&&
to强制转换是不可能的;但是,如果确实需要,可以通过简单的模板函数获得这种转换;请参阅https://stackoverflow.com/a/24868376 /1436796)。对于被调用函数打算无条件地获取对象所有权的情况,从参数中窃取,提供左值参数的义务是给出错误的信号:调用后变量将没有有用的值。因此,模式 3 在我们的函数中提供了相同的可能性,但要求调用者提供一个右值,这种用法应该是首选。Y&
Y
但是,模式 2 有一个有效的用例,即可以修改指针的函数,或者以涉及所有权的方式指向的对象。例如,将节点添加到 a 的函数list
提供了这样一个使用示例:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
显然,在这里强制调用者使用是不可取的std::move
,因为他们的智能指针在调用之后仍然拥有一个定义明确且非空的列表,尽管与之前不同。
同样有趣的是,如果prepend
调用因缺少可用内存而失败,观察会发生什么。然后new
调用将抛出std::bad_alloc
;此时,由于 nonode
可以分配,因此可以肯定传递的右值引用(模式 3)std::move(l)
还不能被窃取,因为这样做是为了构造未能分配的next
字段。node
所以l
抛出错误时,原来的智能指针仍然持有原来的链表;该列表要么被智能指针析构函数正确销毁,要么l
由于足够早的catch
子句而得以幸存,它仍将保存原始列表。
这是一个建设性的例子;对这个问题眨眼,还可以给出更具破坏性的示例,即删除包含给定值的第一个节点(如果有):
void remove_first(int x, list& l)
{ list* p = &l;
while ((*p).get()!=nullptr and (*p)->entry!=x)
p = &(*p)->next;
if ((*p).get()!=nullptr)
(*p).reset((*p)->next.release()); // or equivalent: *p = std::move((*p)->next);
}
同样,这里的正确性非常微妙。值得注意的是,在最后一条语句中,在(*p)->next
要删除的节点中保存的指针在(隐式)销毁该节点(当它销毁由 保存的旧值时)之前release
是未链接的(by ,它返回指针但使原始空值),确保当时只有一个节点被销毁。(在评论中提到的替代形式中,这个时间将留给实例的移动赋值运算符的实现内部;标准说 20.7.1.2.3;2 该运算符应该“像调用“,这里的时间也应该是安全的。) reset
p
std::unique_ptr
list
reset(u.release())
请注意,prepend
为始终非空列表remove_first
存储局部变量的客户端不能调用node
and ,这是正确的,因为给出的实现不适用于这种情况。
模式 3:通过(可修改的)右值引用传递智能指针
这是简单地获得指针所有权时使用的首选模式。我想通过支票来调用这个方法:调用者必须接受放弃所有权,就像提供现金一样,通过签署支票,但实际的提款被推迟到被调用的函数实际上窃取了指针(就像使用模式 2 时一样)。“检查签名”具体意味着调用者必须将参数包装在std::move
(如模式 1 中)如果它是一个左值(如果它是一个右值,“放弃所有权”部分是显而易见的并且不需要单独的代码)。
请注意,从技术上讲,模式 3 的行为与模式 2 完全相同,因此被调用的函数不必承担所有权;但是我坚持认为,如果所有权转移存在任何不确定性(在正常使用中),模式 2 应该优先于模式 3,因此使用模式 3 隐含地向调用者发出他们放弃所有权的信号。有人可能会反驳说,只有模式 1 的参数传递才真正标志着调用者被迫失去所有权。但是如果客户对被调用函数的意图有任何疑问,她应该知道被调用函数的规格,这应该消除任何疑问。
很难找到一个涉及我们list
使用模式 3 参数传递的类型的典型示例。将一个列表移动b
到另一个列表的末尾a
是一个典型的例子;但是a
(保留并保存操作结果)最好使用模式 2 传递:
void append (list& a, list&& b)
{ list* p=&a;
while ((*p).get()!=nullptr) // find end of list a
p=&(*p)->next;
*p = std::move(b); // attach b; the variable b relinquishes ownership here
}
下面是模式 3 参数传递的一个纯粹示例,它接受一个列表(及其所有权),并返回一个包含相同节点的反向列表。
list reversed (list&& l) noexcept // pilfering reversal of list
{ list p(l.release()); // move list into temporary for traversal
list result(nullptr);
while (p.get()!=nullptr)
{ // permute: result --> p->next --> p --> (cycle to result)
result.swap(p->next);
result.swap(p);
}
return result;
}
可以调用此函数l = reversed(std::move(l));
以将列表反转为自身,但也可以以不同的方式使用反转列表。
在这里,为了提高效率,参数立即移动到局部变量(可以l
直接使用参数代替p
,但每次访问它都会涉及额外的间接级别);因此与模式 1 参数传递的差异很小。实际上,使用该模式,参数可以直接用作局部变量,从而避免初始移动;这只是一般原则的一个实例,如果通过引用传递的参数仅用于初始化局部变量,则最好通过值传递它并将参数用作局部变量。
标准似乎提倡使用模式 3,所有提供的库函数都使用模式 3 转移智能指针的所有权这一事实证明了这一点。一个特别令人信服的例子是构造函数std::shared_ptr<T>(auto_ptr<T>&& p)
。该构造函数使用 (in std::tr1
) 获取可修改的左值引用(就像auto_ptr<T>&
复制构造函数一样),因此可以使用in的auto_ptr<T>
左值调用,之后将其重置为 null。由于在参数传递中从模式 2 更改为 3,现在必须重写此旧代码,然后才能继续工作。我知道委员会不喜欢这里的模式 2,但他们可以选择更改为模式 1,通过定义p
std::shared_ptr<T> q(p)
p
std::shared_ptr<T> q(std::move(p))
std::shared_ptr<T>(auto_ptr<T> p)
相反,他们可以确保旧代码无需修改即可工作,因为(与唯一指针不同)自动指针可以被静默地取消引用到一个值(指针对象本身在过程中被重置为 null)。显然,与模式 1 相比,委员会更喜欢提倡模式 3,以至于他们选择主动破坏现有代码,而不是使用模式 1,即使是已经被弃用的用法。
何时更喜欢模式 3 而不是模式 1
模式 1 在许多情况下都非常有用,并且在假设所有权将采用将智能指针移动到局部变量的形式(reversed
如上例中)的情况下可能比模式 3 更受欢迎。但是,我可以看到在更一般的情况下更喜欢模式 3 的两个原因:
传递一个引用比创建一个临时指针和nix旧指针更有效(处理现金有点费力);在某些情况下,指针可能会在实际被盗之前多次原封不动地传递给另一个函数。这种传递通常需要写入std::move
(除非使用模式 2),但请注意,这只是一个实际上不做任何事情的强制转换(特别是没有取消引用),因此它的附加成本为零。
是否可以想象在函数调用的开始和它(或某些包含的调用)实际将指向的对象移动到另一个数据结构的点之间任何东西都会引发异常(并且该异常尚未在函数本身内部捕获),那么当使用模式 1 时,智能指针引用的对象将在catch
子句处理异常之前被销毁(因为函数参数在堆栈展开期间被破坏),但在使用模式 3 时不会如此。后者给出在这种情况下,调用者可以选择恢复对象的数据(通过捕获异常)。请注意,此处的模式 1不会导致内存泄漏,但可能会导致程序无法恢复的数据丢失,这也可能是不可取的。
返回智能指针:总是按值
总结一下返回一个智能指针,大概指向一个为调用者使用而创建的对象。这与将指针传递给函数并不能相提并论,但为了完整起见,我想坚持在这种情况下始终按值返回(并且不要 std::move
在return
语句中使用)。没有人想获得对可能刚刚被取消的指针的引用。