关于pimpl idiom有一些关于 SO 的问题,但我更好奇它在实践中的使用频率。
我知道在性能和封装之间存在一些权衡,以及由于额外的重定向而导致的一些调试烦恼。
有了这个,这是应该在每个班级还是全有或全无的基础上采用的东西?这是最佳实践还是个人偏好?
我意识到这有点主观,所以让我列出我的首要任务:
- 代码清晰
- 代码可维护性
- 表现
我总是假设我需要在某个时候将我的代码公开为一个库,所以这也是一个考虑因素。
编辑:完成同样事情的任何其他选项都是受欢迎的建议。
关于pimpl idiom有一些关于 SO 的问题,但我更好奇它在实践中的使用频率。
我知道在性能和封装之间存在一些权衡,以及由于额外的重定向而导致的一些调试烦恼。
有了这个,这是应该在每个班级还是全有或全无的基础上采用的东西?这是最佳实践还是个人偏好?
我意识到这有点主观,所以让我列出我的首要任务:
我总是假设我需要在某个时候将我的代码公开为一个库,所以这也是一个考虑因素。
编辑:完成同样事情的任何其他选项都是受欢迎的建议。
我想说的是,你是按班级做还是在全有或全无的基础上做这件事,取决于你首先选择 pimpl 成语的原因。在建立图书馆时,我的原因是以下之一:
这些原因都没有提示采用全有或全无的方法。在第一种情况下,您只需要隐藏您想要隐藏的内容,而在第二种情况下,对于您希望更改的类,这样做可能就足够了。同样由于第三和第四个原因,只有隐藏重要的成员才能受益,这些成员又需要额外的标头(例如,第三方库,甚至 STL)。
无论如何,我的观点是我通常不会发现这样的东西太有用:
class Point {
public:
Point(double x, double y);
Point(const Point& src);
~Point();
Point& operator= (const Point& rhs);
void setX(double x);
void setY(double y);
double getX() const;
double getY() const;
private:
class PointImpl;
PointImpl* pimpl;
}
在这种情况下,权衡开始打击你,因为需要取消引用指针,并且不能内联方法。但是,如果您只为非平凡的类执行此操作,那么通常可以容忍轻微的开销而不会出现任何问题。
pimpl ideom 的最大用途之一是创建稳定的 C++ ABI。几乎每个Qt 类都使用类似于 pimpl 的“D”指针。这允许在不破坏 ABI 的情况下执行更简单的更改。
代码清晰度是非常主观的,但在我看来,具有单个数据成员的标头比具有许多数据成员的标头更具可读性。然而,实现文件比较嘈杂,因此那里的清晰度降低了。如果该类是基类,主要由派生类使用而不是维护,这可能不是问题。
为了 pimpl'd 类的可维护性,我个人发现在数据成员的每次访问中额外的取消引用是乏味的。如果数据纯粹是私有的,访问器将无济于事,因为无论如何您都不应该为它公开访问器或修改器,并且您会被不断取消引用 pimpl 所困扰。
对于派生类的可维护性,我发现这个习惯用法在所有情况下都是一个纯粹的胜利,因为头文件列出了更少的不相关细节。所有客户端编译单元的编译时间也得到了改善。
性能损失在许多情况下很小,在少数情况下很重要。从长远来看,它是虚函数性能损失的数量级。我们正在讨论每个数据成员每次访问的额外取消引用,加上 pimpl 的动态内存分配,以及销毁时内存的释放。如果 pimpl'd 类不经常访问其数据成员,则 pimpl'd 类的对象经常被创建并且是短暂的,那么动态分配可能会超过额外的取消引用。
我认为性能至关重要的类,例如一个额外的取消引用或内存分配会产生重大影响,无论如何都不应该使用 pimpl。如果编译时间显着改善,那么性能下降并不显着且头文件广泛#include'd 的基类可能应该使用 pimpl。如果编译时间没有减少,则取决于您的代码清晰度。
对于所有其他情况,这纯粹是口味问题。在做出决定之前尝试一下并测量运行时性能和编译时性能。
pImpl 在您使用强异常保证来实现 std::swap 和 operator= 时非常有用。我倾向于说,如果你的班级支持其中任何一个,并且有多个非平凡的领域,那么它通常不再取决于偏好。
否则,它是关于您希望客户端通过头文件绑定到实现的紧密程度。如果二进制不兼容的更改不是问题,那么您可能不会在可维护性方面受益匪浅,尽管如果编译速度成为问题,通常会节省很多。
性能成本可能与内联损失有关,而不是与间接有关,但这是一个疯狂的猜测。
您可以随时添加 pImpl,并声明从今天起客户端不必因为您添加了私有字段而重新编译。
因此,这一切都不是一种全有或全无的方法。你可以有选择地为它给你带来好处的课程做它,而不是为那些它没有好处的课程做它,然后改变你的想法。例如,将迭代器实现为 pImpl 听起来像是设计太多......
当我想避免头文件污染我的代码库时,我通常会使用它。Windows.h 就是一个完美的例子。它表现得如此糟糕,我宁愿自杀也不愿让它随处可见。因此,假设您想要一个基于类的 API,将其隐藏在 pimpl 类之后可以巧妙地解决问题。(如果您满足于只公开单个函数,则可以直接声明这些函数,当然,无需将它们放入 pimpl 类)
我不会在任何地方都使用 pimpl ,部分原因是性能受到影响,部分原因是它需要大量额外的工作才能获得通常很小的好处。它为您提供的主要内容是实现和接口之间的隔离。通常,这不是一个非常高的优先级。
我在我自己的库中的几个地方使用了这个习惯用法,在这两种情况下,都是为了将接口与实现清晰地分开。例如,我有一个在 .h 文件中完全声明的 XML 阅读器类,它具有一个 RealXMLReader 类的 PIMPL,该类在非公共 .h 和 .cpp 文件中声明和定义。RealXMlReader 反过来又是我使用的 XML 解析器(目前是 Expat)的便利包装器。
这种安排让我将来可以从 Expat 更改为另一个 XML 解析器,而无需重新编译所有客户端代码(当然我仍然需要重新链接)。
请注意,我这样做不是出于编译时性能的原因,只是为了方便。有一些 PIMPL fabnatics 坚持认为,任何包含三个以上文件的项目都将无法编译,除非您始终使用 PIMPL。值得注意的是,这些人从来没有拿出任何实际证据,只是模糊地提到了“Latkos”和“exponential time”。
当我们有 r-value 语义时,pImpl 将工作得最好。
pImpl 的“替代方案”,也将实现隐藏实现细节,是使用抽象基类并将实现放在派生类中。用户调用某种“工厂”方法来创建实例,并且通常会使用指向抽象类的指针(可能是共享的)。
pImpl 背后的基本原理可以是:
pImpl 的容器类的语义可能是: - 不可复制,不可分配......所以你在构造时“新建”你的 pImpl,在销毁时“删除” - 共享。所以你有 shared_ptr 而不是 Impl*
使用 shared_ptr,只要类在析构函数处是完整的,就可以使用前向声明。即使是默认的(可能是),也应该定义您的析构函数。
可交换。您可以实现“可能为空”并实现“交换”。用户可以创建一个实例,并通过一个“交换”向它传递一个非常量引用来填充它。
2阶段建设。你构造一个空的然后在它上面调用“load()”来填充它。
shared 是唯一一个没有 r-value 语义的我什至偏爱的人。有了它们,我们还可以正确实现不可复制不可分配。我喜欢能够调用一个给我的函数。
然而,我发现我现在更倾向于使用抽象基类而不是 pImpl,即使只有一个实现也是如此。