让我尝试说明传递指向由
std::unique_ptr
类模板管理内存的对象的不同可行模式;它也适用于旧的
std::auto_ptr
类模板(我相信它允许unique pointer所做的所有使用,但此外还将接受可修改的左值,而无需调用
std::move
,并且在某种程度上也适用于
std::shared_ptr
)。作为讨论的具体示例,我将考虑以下简单的列表类型。
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
这种列表的实例(不能与其他实例共享部分或成为循环)完全由持有初始list指针的人拥有。如果客户端代码知道它存储的列表永远不会为空,它也可以选择直接存储第一个节点而不是列表。不需要定义node的析构函数:由于其字段的析构函数会自动调用,一旦初始指针或节点的生命周期结束,智能指针析构函数将递归地删除整个列表。
这种递归类型提供了讨论在智能指针到纯数据的情况下不太明显的一些情况的机会。函数本身偶尔(递归地)也提供客户端代码的示例。当然,list的typedef偏向于unique_ptr,但定义可以更改为使用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
的客户端可以调用这个函数,如 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(也就是list)的移动构造函数中的右值引用参数,用于初始化生成的node的next字段),也要考虑为什么它是异常安全的(如果在递归分配过程中内存不足并且某个new的调用抛出std::bad_alloc,则此时一个对部分构造列表的指针匿名地保存在为初始化列表创建的类型为list的临时变量中,并且它的析构函数将清理该部分列表)。顺便说一句,应该抵制将第二个nullptr替换为p(就像我最初所做的那样)的诱惑,因为毕竟在那一点上已知它为空:即使已知它为空,也不能从指向常量的(原始)指针构造智能指针。
模式1:通过值传递智能指针
一个以智能指针值为参数的函数会立即接管所指向的对象:在函数进入时,调用者持有的智能指针(无论是在命名变量中还是匿名临时变量中)会被复制到参数值中,而调用者的指针已经变成了空指针(在临时变量的情况下,复制可能已经被省略,但无论如何,调用者已经失去了对所指向对象的访问权限)。我想称这种模式为
预付费调用:调用者事先支付服务费用,并且在调用后不能对所有权产生幻想。为了明确这一点,语言规则要求调用者在智能指针存储在变量中(技术上,如果参数是左值)时将其包装在
std::move
中;在这种情况下(但不适用于下面的第3种模式),该函数会按照其名称所示执行操作,即将值从变量移动到临时变量中,使变量为空。
对于那些无条件地获取指向对象的所有权(窃取)的调用函数的情况,使用
std::unique_ptr
或
std::auto_ptr
与此模式一起使用是传递指针及其所有权的好方法,可以避免任何内存泄漏的风险。尽管如此,我认为只有极少数情况下,模式3不如模式1(稍微)优越。因此,我将不提供此模式的使用示例。(但请参见下文模式3的反转示例,其中指出模式1至少可以做得一样好。)如果函数除了这个指针之外还需要更多参数,则可能会发生
technical reason to avoid mode 1(使用
std::unique_ptr
或
std::auto_ptr
)的情况:由于在通过表达式
std::move(p)
传递指针变量
p
时实际进行移动操作,不能假定在评估其他参数时
p
持有有用值(求值顺序未指定),这可能导致微妙的错误;相比之下,使用模式3确保在函数调用之前不会从
p
移动,因此其他参数可以安全地通过
p
访问值。
使用
std::shared_ptr
时,此模式非常有趣,因为它允许调用方通过单个函数定义来
选择是否保留指针的共享副本供自己使用,同时创建一个新的共享副本供函数使用(当提供lvalue参数时会发生这种情况;在调用时使用的共享指针的复制构造函数会增加引用计数),或者只是给函数一个指针的副本而不保留任何一个或触摸引用计数(当提供rvalue参数时会发生这种情况,可能是一个包装在
std::move
调用中的lvalue)。例如。
void f(std::shared_ptr<X> x)
{ container.insert(std::move(x)); }
void client()
{ std::shared_ptr<X> p = std::make_shared<X>(args);
f(p);
f(std::make_shared<X>(args));
f(std::move(p));
}
可以通过单独定义void f(const std::shared_ptr<X>& x)
(用于左值情况)和void f(std::shared_ptr<X>&& x)
(用于右值情况)来实现相同的效果,两个函数体仅在调用方式上有所不同。第一个版本使用复制语义(在使用x
时使用复制构造/赋值),而第二个版本使用移动语义(使用std::move(x)
,例如示例代码)。因此,对于共享指针,模式1可用于避免一些代码重复。
模式2:通过(可修改的)左值引用传递智能指针
这个函数只需要一个可修改的智能指针引用,但没有说明它将如何使用它。我想称呼这种方法为“刷卡调用”:调用者通过提供信用卡号来确保付款。这个引用可以用来拥有指向的对象,但不一定要这样做。此模式需要提供一个可修改的左值参数,对应于函数所需的期望效果可能包括在参数变量中留下有用的值。希望将rvalue表达式传递给这样一个函数的调用者将被迫将其存储在命名变量中才能进行调用,因为语言仅从rvalue提供隐式转换为常量左值引用(引用临时对象)。 (与
std :: move
处理的相反情况不同,从
Y &&
到
Y&
的转换对于智能指针类型的
Y 是不可能的; 尽管如此,如果真的需要,可以通过一个简单的模板函数获得这种转换;请参见https://dev59.com/I4Hba4cB1Zd3GeqPU7mk#24868376)。对于那些打算无条件地获取对象并从参数中窃取的被调用函数,提供左值参数的义务会发出错误的信号:调用后该变量将没有有用的值。因此,应该优先考虑模式3,它在我们的函数内提供了相同的可能性,但要求调用者提供一个rvalue。
然而,模式2有一个有效的用例,即可能以涉及所有权的方式修改指针或所指对象的函数。例如,将节点前缀到列表中的函数提供了这种用法的示例:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
显然,强制调用者使用std::move
是不可取的,因为在调用之后,他们的智能指针仍然拥有一个定义明确且非空的列表,尽管与之前不同。
如果prepend
调用由于缺乏可用内存而失败,则会发生有趣的情况。然后new
调用将抛出std::bad_alloc
;此时,由于无法分配任何node
,可以确定从std::move(l)
传递的右值引用(模式3)尚未被盗窃,因为这将用于构造未能分配的node
的next
字段。因此,在抛出错误时,原始智能指针l
仍然持有原始列表;该列表将通过智能指针析构函数正确销毁,或者在catch
子句足够早的情况下l
应该幸存下来,它仍将持有原始列表。
这是一个有建设性的例子;通过参考这个问题,我们也可以给出更具破坏性的例子,即删除包含给定值的第一个节点(如果存在):
void remove_first(int x, list& l)
{ list* p = &l;
while (
这里正确性相当微妙。值得注意的是,在最后一条语句中,要删除的节点内部保存的指针(*p)->next
被取消链接(通过release
),该方法返回指针但将原始指针设为空值,此时在reset
(隐式)销毁该节点(当它销毁p
所保存的旧值时),确保该时间只销毁一个节点。(在评论中提到的替代形式中,此时的时间将留给std :: unique_ptr
实例list
的移动赋值运算符的内部实现;标准规定20.7.1.2.3;2,该运算符应“如同调用reset(u.release())
”一样操作,因此此时的时间也应是安全的。)
请注意,对于始终非空的列表,存储本地node
变量的客户端不能调用prepend
和remove_first
。这是正确的,因为所给出的实现对于这种情况无法工作。
第三种模式:通过(可修改的)右值引用传递智能指针
这是简单地接管指针的首选模式。我想称之为“检查调用”方法:调用者必须接受放弃所有权,就像提供现金一样,签署支票,但实际提款将延迟到被调用的函数实际窃取指针时(完全与使用模式2相同)。具体来说,“签署支票”意味着如果参数是左值,则调用者必须使用std::move将其包装起来(如模式1中所示),如果它是右值,则“放弃所有权”部分是显而易见的,不需要单独的代码。
请注意,从技术上讲,模式3的行为与模式2完全相同,因此被调用的函数不必承担所有权;然而,如果存在任何关于所有权转移(在正常使用中)的不确定性,我会坚持认为应该优先选择使用模式2而不是模式3,这样使用模式3隐含地向调用者发出信号,告诉他们他们正在放弃所有权。有人可能会反驳说,只有模式1的参数传递真正向调用者发出了强制失去所有权的信号。但是,如果客户对被调用的函数的意图有任何疑问,她应该知道被调用的函数的规格,这应该消除任何疑虑。
寻找一个使用模式3参数传递的典型示例,涉及我们的list
类型,实际上是非常困难的。将列表b
移动到另一个列表a
的末尾是一个典型的例子; 但是,最好使用模式2来传递a
(它将幸存并保存操作的结果):
void append (list& a, list&& b)
{ list* p=&a;
while ((*p).get()!=nullptr)
p=&(*p)->next;
*p = std::move(b);
}
一种纯粹的第三模式参数传递的例子是,接受一个列表(及其所有权),并返回一个包含相同节点但顺序相反的列表。
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));
将列表反转到它自身,但反转后的列表也可以以不同的方式使用。
在这里,参数立即移动到一个本地变量中以提高效率(虽然可以直接在 p
的位置上使用参数 l
,但每次访问都需要多一层间接引用,因此与模式1的参数传递方式相比差异很小)。实际上,如果使用该模式,参数可以直接用作局部变量,从而避免了初始移动;这只是一般原则的一个例子,即如果仅通过引用传递参数来初始化局部变量,则可以将其作为值传递并将参数用作局部变量。
使用模式3似乎是标准所倡导的,这可以从所有提供的库函数中转移智能指针所有权时都使用模式3这一事实得到证明。一个特别有说服力的例子是构造函数std::shared_ptr<T>(auto_ptr<T>&& p)
。该构造函数(在std::tr1
中使用)以可修改的左值引用(就像auto_ptr<T>&
复制构造函数一样)作为参数,并且因此可以使用auto_ptr<T>
左值p
进行调用,例如std::shared_ptr<T> q(p)
,之后p
将被重置为null。由于参数传递方式从模式2更改为模式3,因此必须重新编写旧代码以使用std::shared_ptr<T> q(std::move(p))
,然后它将继续工作。我了解委员会不喜欢这里的模式2,但他们可以通过定义std::shared_ptr<T>(auto_ptr<T> p)
来改为使用模式1,他们本可以确保旧代码无需修改即可工作,因为(与唯一指针不同)自动指针可以静默地取消引用到一个值(指针对象本身在此过程中被重置为null)。显然,委员会更喜欢倡导模式3而不是模式1,他们选择积极破坏现有的代码,而不是即使对于已经过时的用法也使用模式1。
何时优先使用模式3而不是模式1
在许多情况下,模式1是完全可用的,并且在假设所有权采用将智能指针移动到本地变量的形式(如上面的reversed
示例)的情况下,可能比模式3更受青睐。然而,在更一般的情况下,我可以看到优先选择模式3的两个原因:
传递引用比创建临时变量并取消旧指针的效率稍高(处理缓存有些费力);在某些情况下,指针可能会在被实际窃取之前多次不变地传递给另一个函数。这样的传递通常需要编写 std::move
(除非使用模式2),但请注意,这只是一种不执行任何操作的转换(特别是没有解引用),因此它没有额外的成本。
如果在函数调用的开头和实际将所指对象移入其他数据结构(或某个包含调用)的点之间抛出异常,并且(该异常)未在函数内部捕获,则使用模式1时,在 catch
子句能够处理异常之前(因为在堆栈展开期间函数参数已被析构),智能指针引用的对象将被销毁,但使用模式3则不会发生这种情况,后者使调用方有可能在这种情况下恢复对象的数据(通过捕获异常)。请注意,此处的模式1不会导致内存泄漏,但可能会导致程序的数据不可恢复的损失,这也可能是不希望发生的。
返回智能指针:始终按值返回
关于返回一个智能指针,它可能指向为调用者创建的对象,这并不是将指针传递到函数中的可比情况。但是为了完整起见,我想坚持在这种情况下始终按值返回(并且不要在return
语句中使用std::move
)。没有人想得到一个可能已经被取消的指针的引用。