使用const std::unique_ptr实现pimpl习惯用法

19

Herb Sutter在CppCon16上的演讲中,他建议使用 const std::unique_ptr 编写pimpl习惯用语(大约在10分钟处)。

在移动构造函数/赋值时,这应该如何工作?在C++17中是否有任何东西?我找不到任何消息。


没有阅读这篇文章,很明显带有pimpl的类是不可复制/移动的。它们将在动态范围内实例化,并且仅通过智能指针进行访问,pimpl隐藏了内部实现细节。 - Sam Varshavchik
7
为什么你不希望大多数pimpl类是可移动的?这似乎是一个完全合理的做法。 - Denis Yaroshevskiy
1
@DenisYaroshevskiy 只能假设他有特定的使用情况。一般来说,我同意使用unique_ptr作为pimpl的容器。如果你想让它可复制,当然必须通过克隆操作来实现。 - Richard Hodges
1
刚刚看了视频中相关的部分,我不同意他的观点。我认为对于大多数 pimpl 句柄,const unique_ptr 很快就会成为一种限制。 - Richard Hodges
1
@RichardHodges - Sutter 是正确的。如果你复制一个带有 pimpl 的类,如果 pimpl 是 shared_ptr,则会得到两个具有相同 pimpl 的对象,如果 pimpl 是 unique_ptr,则会得到一个没有 pimpl 的对象。这违反了一个对象、一个 pimpl 的约定。另一方面,一个带有“const”pimpl的对象仍然可以被移动。 - Sam Varshavchik
4
我不明白为什么一个“const unique_ptr”是可移动的。没有“const unique_ptr&&”构造函数。如果您能够提供示例代码,我会很感兴趣。我认为您必须编写一个移动构造函数来移动实现。这仍然会导致一个对象处于未定义状态。值得注意的是,他没有提供“const unique_ptr”作为pimpl(非)惯用语的任何用例。Herb是一个聪明的人,但我不一定会盲目追随他。 - Richard Hodges
2个回答

8
如果您的类不应为空,则非const唯一指针(具有默认移动/分配)不适合。移动构造函数和移动赋值运算符都将清空rhs。
const unique_ptr将禁用这些自动方法,如果要移动,则必须在impl内编写它(并且在外部编写一些粘合剂)。
我个人会编写一个具有所需语义的值指针(然后让编译器编写粘合剂),但是以const unique_ptr作为第一次尝试听起来很合理。
如果您放松了不为空的要求,并使其几乎不为空,则现在必须考虑许多方法的前提条件和可能的连锁错误。
这种技术最大的成本——难以返回值——随着C++17的到来而消失了。

2
如果您从一个对象中移出,它应该处于部分形成状态,因此只能分配和销毁,以便移动构造函数/赋值不会破坏您的not_null保证。如果您想要,可以使用类似于gsl的not_null进行包装。 - Denis Yaroshevskiy
@DenisYaroshevskiy 不,它处于任何你愿意保证的状态?除非这是你选择保证的,“它不应该处于部分形成的状态”。其中一个选项是几乎从不为空,只有在移动后才为空。这是一种选择,它有成本和收益。从几乎从不为空到几乎从不为空的成本不应被忽视。同样,从几乎从不为空到从不为空的成本也不应被忽视。从不为空更容易保证正确性,偏向于更经常正确的代码而不是更快的代码通常是一个好主意。 - Yakk - Adam Nevraumont
@Yakk 即使一个被移动的对象“永远不为空”,在移动后使用它也是一种强烈的代码异味。我认为,进行 pimpl 指针检查,然后使用 assert/exception 更可取,而不是让人们依赖于一个被移动的对象处于“默认”状态。当由于不可避免的任务蔓延而无法再满足保证时,这会导致灾难。我甚至可以说,这是一种反模式,与 std::move 的精神相悖。 - Richard Hodges
@RichardHodges 有时空类型是灾难性的,因为你一定会在某个可能为空的上下文中意外使用它们。预防这种情况是很困难的。你可以修复它,但只有进行全面而详尽的质量保证(如果您假设完美的质量保证,则不存在任何“危险”的代码)。通过使用 const unique ptr,您的对象无法移动,此问题不会出现。通过使用特定的 never_empty_ptr,您的代码永远不可能为空,此问题也不会发生。另一方面,手动将永不为空的状态插入类中可能是一个坏主意。 - Yakk - Adam Nevraumont
@Yakk 如果防止使用已移动对象很困难(对于给定的团队?),那么这就说明需要强制实施“禁止使用std::move”约束,以限制(可能是次标准的)开发人员。当然,所有优秀的开发人员一旦找到更好的工作就会离开,但您仍然可以保证您的“永不为空”的保证,同时允许其他团队编写高效的代码。我很难想象出一个合理的使用场景来解释这个习语。它一下子就抵消了从c++03升级到c++11所带来的90%的巨大效用和性能增量。 - Richard Hodges
或者只需使用 std::move,因为每个C++标准容器在移动后都表现得非常合理(除了移动分配),而且您编写的每种类型也可以在移动后表现得合理。有些对象可能是“有时为空”(例如unique ptr),但对于它们,所有使用它们的地方都需要进行保护。很少使用“几乎从不为空”的类型,因为它们会鼓励您假设它们永远不为空,而当它们为空时会发生灾难性的错误。这种观点中的问题在于“几乎从不为空”的类型,而不是std::move。或者编写您的移动空类型以在所有操作上自动填充。 - Yakk - Adam Nevraumont

3
这个如何与移动构造函数/赋值一起使用? 移动构造函数
如果以下任何条件为真,则类T的隐式声明或默认移动构造函数将被定义为已删除:
- T具有无法移动的非静态数据成员(具有已删除、不可访问或模糊的移动构造函数)。
由于const,const std::unique_ptr是这样的数据成员。
如果去掉const,编译器将生成移动构造函数和赋值操作符,但不会生成复制构造函数。
Herb解释了他为什么使用const unique_ptr
非const也可以工作,但更加脆弱,因为默认的移动语义可能是不正确的。
使用const成员更加健壮,因为const成员必须在构造函数中初始化。而且const说明对象的实现不会改变,它不是状态或策略设计模式。

那么在这种情况下,一个人就需要实现一个复制构造函数? - vordhosbn
如果有必要的话,@vordhosbn。 - Maxim Egorushkin
没错,但我的问题是关于移动构造/赋值的。使用普通的unique_ptr而不是const将保持移动构造/赋值的正常运行,而声明为const则禁止它。我曾经认为const数据成员是一种不好的做法(因为这个问题),但显然Herb Sutter并不这么认为。我想知道为什么。 - Denis Yaroshevskiy
1
我会对Sutter先生持相反的观点。std::unique_ptrstd::move的默认行为是绝对正确的,也是我们想要的——高效的状态转移,使经过移动的句柄处于一个非常明确定义的状态——“无效,请勿操作”。显式禁用移动是一回事(人们可能会对动机产生疑问),通过使用const进行神秘的禁用只是看起来像是一种嬉戏。 - Richard Hodges
6
对我而言,最重要的是通过构造来表达生命周期,并且将默认设置正确(在这种情况下,默认情况下不应进行脆弱的移动)。您可以自己编写复制和移动操作,例如 MyClass& operator=(const MyClass& that) { *pimpl = *that.pimpl; return *this; }Impl 类仍然可以复制甚至可移动,您可以委托给它。当然,这只能让您完成95%的工作,并且也可以编写自己的 value_ptr 类型,以自动执行深层复制/移动。 - Herb Sutter
显示剩余4条评论

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