让我尝试说明将指针传递给对象的各种可行模式,这些对象的内存由std::unique_ptr
类模板的实例管理。它也适用于较老的std::auto_ptr
类模板(我认为允许所有使用唯一指针的模板,但是在需要rvalue的情况下,无需调用也可以接受可修改的lvalue std::move
),并且在某种程度上也适用于std::shared_ptr
。
作为讨论的具体示例,我将考虑以下简单列表类型
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
此类列表的实例(不允许与其他实例共享零件或为圆形)完全由拥有初始list
指针的人所有。如果客户端代码知道其存储的列表永远不会为空,则也可以选择存储第一个node
直接,而不是第一个list
。无需node
定义析构函数:由于将自动调用其字段的析构函数,因此一旦初始指针或节点的生命周期结束,整个列表将被智能指针析构函数递归删除。
这种递归类型使您有机会讨论在智能指针指向普通数据的情况下不可见的某些情况。同样,函数本身有时也(递归地)提供客户端代码的示例。的typedef list
当然偏向unique_ptr
,但是定义可以更改为使用auto_ptr
或shared_ptr
代替,而无需过多更改以下内容(特别是有关确保不需要编写析构函数的异常安全性)。
传递智能指针的方式
模式0:传递指针或引用参数而不是智能指针
如果您的函数与所有权无关,那么这是首选方法:不要使其完全采用智能指针。在这种情况下,您的函数不必担心谁拥有所指向的对象,或担心所有权的管理方式,因此传递原始指针既安全又是最灵活的形式,因为无论所有权如何,客户端都可以始终产生原始指针(通过调用get
方法或从address-of运算符&
)。
例如,用于计算此类列表长度的函数不应提供list
参数,而应使用原始指针:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
拥有变量的客户端list head
可以将该函数调用为length(head.get())
,而选择存储一个node n
代表非空列表的客户端可以调用length(&n)
。
如果保证指针为非null(此处不是这种情况,因为列表可能为空),则可能希望传递引用而不是指针。const
如果函数需要更新节点的内容而不增加或删除其中的任何内容(后者将涉及所有权),则它可能是指向非对象的指针/引用。
属于模式0类别的一个有趣情况是制作列表的(深层)副本。虽然执行此功能的功能当然必须转移其创建的副本的所有权,但它与正在复制的列表的所有权无关。因此可以定义如下:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
这段代码值得仔细一看,以解决为什么它根本不能编译的问题(初始化初始化字段时,copy
初始化列表中对的递归调用结果绑定到move构造函数unique_ptr<node>
aka 的右值引用参数上)。生成的),以及一个问题,为什么它是异常安全(如果在递归分配过程内存用完,有些通话的抛出,然后在该时间的指针部分构成列表匿名在临时类型的举行为初始化程序列表创建,其析构函数将清理该部分列表)。顺便说一个人应该抵制诱惑,以取代(正如最初我)第二个通过list
next
node
new
std::bad_alloc
list
nullptr
p
,毕竟在那一点上它已知为null:即使已知它为null,也无法从(原始)指针构造一个智能指针指向constant。
模式1:按值传递智能指针
以智能指针值作为参数的函数立即拥有指向的对象:调用方持有的智能指针(无论是在命名变量中还是匿名临时变量中)都将被复制到函数入口处的参数值中,而调用方的指针已变为空(在临时情况下,副本可能已被删除,但在任何情况下,调用者都无法访问指向的对象)。我想通过现金呼叫此模式:呼叫者为所调用的服务付费,并且对呼叫后的所有权没有任何幻想。为了清楚起见,语言规则要求调用者将参数包装在std::move
如果智能指针保存在变量中(从技术上讲,如果参数是左值);在这种情况下(但不适用于下面的模式3),此函数将执行其名称所建议的操作,即将值从变量移到临时变量,而使变量为null。
对于被调用函数无条件获得指向对象的所有权(盗用)的情况,此模式与一起使用std::unique_ptr
或是std::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:通过(可修改的)左值引用传递智能指针
在这里,该功能仅需要对智能指针进行可修改的引用,但没有提供对其功能的指示。我想通过卡调用此方法:调用者通过提供信用卡号来确保付款。该引用可用于获取指向对象的所有权,但不必如此。此模式需要提供一个可修改的左值参数,这与以下事实有关:函数的期望效果可能包括在参数变量中保留有用的值。希望传递给该函数的带有右值表达式的调用方将被迫将其存储在一个命名变量中,以便能够进行调用,因为该语言仅提供了对a的隐式转换。常量右值的左值引用(指临时值)。(不同于由处理相反的情况下std::move
,从铸造Y&&
到Y&
,与Y
智能指针类型,是不可能的;但是这种转换可以通过如果确实希望的一个简单的模板函数来获得;参见https://stackoverflow.com/a/24868376 / 1436796)。对于被调用函数打算无条件地获取对象所有权(从参数中窃取)的情况,提供左值参数的义务给出了错误的信号:变量在调用后将没有任何有用的值。因此,对于这种用法,应该首选模式3,该模式在我们的函数内部具有相同的可能性,但要求调用者提供一个右值。
但是,模式2有一个有效的用例,即可以修改指针或以涉及所有权的方式指向的对象的函数。例如,将节点前缀为a的函数list
提供了此类用法的示例:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
显然,在这里强制调用者使用是不希望的std::move
,因为他们的智能指针在调用之后仍然拥有一个定义良好且非空的列表,尽管与之前的列表不同。
再次有趣的是,观察prepend
由于缺少可用内存而导致调用失败的情况。然后new
电话会抛出std::bad_alloc
; 在这一时间点上,由于node
无法分配,因此可以确定从那里传递来的右值引用(模式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
的指针在 reset
(隐式)销毁该节点(当销毁由持有的旧值时)之前未链接(通过,它返回指针,但使原始null)p
。当时只有一个节点被破坏。(在评论中提到的另一种形式中,此时间将留给std::unique_ptr
实例的移动分配运算符的实现内部执行list
;标准规定20.7.1.2.3; 2该运算符应“像调用reset(u.release())
“,那么这里的时间也应该安全。)
请注意,prepend
并remove_first
不能由谁存储在本地客户端调用node
变量始终非空列表,这是正确的,因为在给定的实现不能为这样的情况下工作。
模式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)
。该构造函数使用(中的std::tr1
)获取可修改的左值引用(就像auto_ptr<T>&
复制构造函数一样),因此可以使用in中的auto_ptr<T>
左值来调用,之后将其重置为null。由于参数传递从模式2更改为3,因此现在必须将旧代码重写为旧代码,然后才能继续工作。我了解委员会在这里不喜欢模式2,但是他们可以通过定义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。
什么时候比模式1更喜欢模式3
模式1在许多情况下都可以完美使用,并且在假设所有权的情况下(reversed
如上例中那样)将智能指针移动到局部变量的形式可能比模式3更可取。但是,在更一般的情况下,我可以看到两个原因偏爱模式3的原因:
传递引用比创建临时指针和废除旧指针要有效得多(处理现金有些费力)。在某些情况下,在实际窃取指针之前,可能会将指针多次多次传递给另一个函数。这样的传递通常需要编写std::move
(除非使用模式2),但是请注意,这只是一个强制转换,实际上不执行任何操作(特别是不进行取消引用),因此其成本为零。
可以想象,在函数调用的开始与它(或某些包含的调用)的位置之间实际会指向对象的任何对象之间引发任何异常(并且该异常尚未在函数本身内部捕获) ),那么在使用模式1时,智能指针引用的对象将在catch
子句可以处理异常之前被销毁(因为函数参数在堆栈展开时被破坏了),但在使用模式3时则不是。在这种情况下,调用方可以选择恢复对象的数据(通过捕获异常)。请注意,此处的模式1 不会导致内存泄漏,但是可能导致程序的数据无法恢复,这也是不希望的。
返回智能指针:始终按值
总结一下有关返回智能指针的信息,大概是指向创建供调用者使用的对象。与将指针传递到函数中相比,这实际上不是一个可比的情况,但是为了完整性,我想坚持认为在这种情况下,始终按值返回(并且不要 std::move
在return
语句中使用)。没有人希望获得对可能刚刚被删除的指针的引用。