C++中的 PIMPL:强大的设计模式
一、PIMPL 简介
PIMPL,即 to ,是 C++ 中一种重要的设计模式。它通过使用指针的方式将实现的细节进行隐藏,主要作用是将两个文件间的编译依存关系降至最低。
PIMPL 的作用主要体现在多个方面。首先,它能解开类的使用接口和实现的耦合。在传统的 C++ 编程中,类的实现细节往往与接口紧密相连,这导致了一旦实现细节发生变化,可能会引起大量的编译依赖,进而影响整个项目的编译速度。而 PIMPL 通过将实现细节隐藏在一个单独的类中,使得类的接口更加稳定,减少了外部对实现细节的依赖。
其次,PIMPL 可以降低编译依赖,提高编译速度。当类的实现发生变化时,由于接口与实现分离,只有实现部分需要重新编译,而使用该类的其他部分无需重新编译。例如,在一个大型项目中,如果一个类的实现发生了多次修改,使用 PIMPL 可以大大减少编译时间。
此外,PIMPL 还能提高接口的稳定性。因为接口与实现分离,即使实现部分发生了重大变化,只要接口保持不变,使用该类的其他部分就不会受到影响。这对于维护大型项目的稳定性非常重要。
总的来说,PIMPL 在 C++ 编程中具有重要的地位,它能够提高代码的可维护性、可扩展性和编译速度,是一种非常实用的设计模式。
二、PIMPL 的应用实践
(一)原始指针实现 PIMPL
在 C++ 中,可以使用原始指针来实现 PIMPL。以一个类为例,首先在类的头文件中声明一个指向实现结构体的指针:
class Widget {
public:
Widget() = default;
private:
struct Impl;
Impl *pImpl;
};
然后在实现文件中定义Impl结构体,并实现类的构造函数和析构函数:
#include "widget.h"
#include "gadget.h"
#include
#include
struct Widget::Impl {
std::string name;
std::vector data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(new Impl) {}
Widget::~Widget() {
delete pImpl;
}
通过这种方式,将类的实现细节放到了Impl类中,减少了头文件的依赖。如果等类型定义发生变化,只有的实现文件需要重新编译,而头文件无需重新编译,从而减少了编译时间。
(二)std:: 实现 PIMPL
使用std::实现 PIMPL 是一种常见的方法。在类的头文件中,引入std::并指向Impl结构体:
#include
class Widget {
public:
Widget() = default;
private:
struct Impl;
std::unique_ptr pImpl;
};
在实现文件中,使用std::来创建Impl对象:
#include "widget.h"
#include "gadget.h"
#include
#include
struct Widget::Impl {
std::string name;
std::vector data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique()) {}
使用std::的好处在于它会自动管理资源,在对象被销毁时,自动释放Impl对象,无需手动编写析构函数。然而,需要注意的是,如果在类中使用了std::,却没有声明一个析构函数,并且Impl是一个不完全类型,那么在客户端尝试编译时可能会出现错误。例如,std::的析构函数调用时会调用操作符来确保内部的原生指针不是指向一个不完全类型的对象。如果出现这种情况,可以考虑在类中显式声明析构函数,或者按照特定的规则进行模板实例化等方法来解决问题。
三、PIMPL 的优势
(一)信息隐蔽
PIMPL 能够有效地将私有成员隐藏在共有接口之外。对于闭源 API 的设计来说,这一点尤为重要。通过 PIMPL,开发者可以将实现细节完全封装起来,只向用户提供简洁明了的接口。同时,很多与平台相关的宏控制代码也可以隐藏在实现类当中。这样一来,用户无需了解这些琐碎的平台依赖细节,就能轻松使用 API。例如,在开发跨平台软件时,不同平台可能需要不同的实现方式,通过 PIMPL 可以将这些特定于平台的代码隐藏在实现类中,使得接口更加通用和稳定。
(二)加速编译
PIMPL 在加速编译方面有着显著的优势。它阻断了类的实现和类的编译依赖性。通常情况下,一个类的实现发生变化时,所有包含该类头文件的代码都需要重新编译。然而,使用 PIMPL 后,由于类的实现细节被隐藏在单独的实现文件中,只有当类的接口发生变化时,才需要重新编译使用该类的代码。这大大减少了不必要的头文件包含,提高了编译速度。据统计,在一些大型项目中,使用 PIMPL 可以将编译时间缩短数倍甚至更多。
(三)更好的二进制兼容性
PIMPL 能够保证在实现变更时良好的二进制兼容性。在传统的 C++ 编程中,对一个类的修改可能会影响到类的大小、对象的表示和布局等信息,这就导致任何使用该类的用户都需要重新编译。但是,对于使用 PIMPL 的类,如果实现变更被限制在实现类中,那么公有类只持有一个实现类的指针,即使实现发生重大变更,也能够保证良好的二进制兼容性。这意味着用户无需重新编译他们的代码,就可以使用更新后的库或模块。
(四)惰性分配
PIMPL 还支持惰性分配,实现类可以做到按需分配或者在实际使用时再分配。这一特性可以节省资源并提高响应速度。例如,在一个大型软件系统中,某些功能可能只有在特定条件下才会被使用。如果不使用 PIMPL,这些功能的实现代码可能会在程序启动时就被加载,占用不必要的内存资源。而使用 PIMPL 后,可以在需要使用这些功能时才分配实现类的内存,从而提高系统的资源利用率和响应速度。
四、公有类和实现类的隔离
(一)推荐的隔离方式
公有类是实现类的抽象,实现类是公有类的封装隐藏。推荐的隔离方式是将所有非 的 成员都放置到 impl 中去,同时将 成员函数需要调用的公有函数也放置到 impl 中去。
(二)原因分析 信息隐藏:非 的 成员通常包含具体的实现细节,将它们放置到 impl 中可以更好地隐藏这些细节,防止外部直接访问和修改。这样可以提高代码的安全性和稳定性,符合信息隐蔽的原则。 减少耦合:通过将这些成员放置到 impl 中,可以减少公有类与实现类之间的耦合度。公有类只通过指针与实现类进行交互,而不需要了解实现类的具体内部结构。这使得实现类的修改不会直接影响到公有类的外部接口,从而提高了代码的可维护性。 避免继承问题:对于 的成员,由于其是相对于继承关系而生效的,放置到 impl 中没有任何意义。同样, 成员也不应该放到 impl 中去,因为 函数需要被继承链中的派生类去 。如果将 成员放置到 impl 中,可能会导致继承关系的混乱,影响代码的可读性和可维护性。 (三)注意事项 函数调用开销:将 函数需要调用到的 方法也放到 impl 中去,是为了避免出现所谓的 “back ” 带来的开销。但是,这样做也会增加一层间接性,使得公有类成员函数的每次调用都必须通过 class,从而增加了函数调用的开销。在实际应用中,需要权衡这种开销与信息隐藏和减少耦合的好处。 极端情况处理:还有一种极端的方式是将所有的 成员都丢到 impl 中去,此时公有类就相当于一个接口类,进而所有接口都需要一个 进行调用的转发。这种方式会使公有类实现得比较无趣和杂乱,而且无法被继承复用。因此,在实际应用中,需要谨慎考虑这种极端情况,根据具体的需求和设计目标来选择合适的隔离方式。 五、PIMPL 实现注意事项
(一)资源管理
在 PIMPL 的实现中,资源管理是一个重要的方面。应尽可能避免使用原始指针来创建和释放实现类对象。使用原始指针可能会导致内存泄漏、悬挂指针等问题,并且需要手动管理内存的分配和释放,增加了代码的复杂性和出错的可能性。
相比之下,推荐使用智能指针来管理实现类对象,如std::、std::和boost::等。这些智能指针能够自动管理资源,在适当的时候释放内存,大大降低了内存管理的难度。
std::是一种独占所有权的智能指针,它对所管理的资源拥有唯一的所有权,不允许资源的共享。在 PIMPL 中使用std::可以确保实现类对象的唯一所有权,避免资源的重复释放和悬挂指针的问题。例如,在类中,可以使用std::来管理实现类对象,当对象被销毁时,std::会自动释放Impl对象,无需手动编写析构函数。
std::是一种共享所有权的智能指针,它允许多个std::对象共享同一个资源。在某些情况下,如果需要实现类对象的共享,可以使用std::。但是,需要注意的是,std::的实现相对复杂,可能会带来一定的性能开销。
boost::也是一种独占所有权的智能指针,它的功能与std::类似,但在某些方面可能略有不同。总的来说,boost::和std::在实现上要比std::高效得多。
(二)拷贝语义
在 PIMPL 中,共有类的复制语义是一个需要特别关注的问题。由于实现类是以指针的方式作为共有类的一个成员,而默认 C++ 生成的拷贝操作只会执行对象的浅复制,这显然违背了 PIMPL 的原本意图。
默认的拷贝操作只会复制指针的值,而不会复制指针所指向的对象。这意味着,如果对一个使用 PIMPL 的对象进行拷贝,那么两个对象将指向同一个实现类对象。当其中一个对象被销毁时,另一个对象的指针将变为悬挂指针,可能会导致程序崩溃。
为了解决这个问题,可以采取以下两种方法:
禁止复制操作:将所有的复制操作定义为 的,或者在新标准中将这些复制操作定义为 的即可。这样可以确保对象不能被复制,避免了浅复制带来的问题。 显式定义复制语义:创建新的实现类对象,执行深度复制操作。这种方法需要手动编写复制构造函数和赋值运算符,确保在复制对象时,创建新的实现类对象,并复制其内容,而不是仅仅复制指针的值。
例如,可以在共有类中定义如下的复制构造函数和赋值运算符:
class Widget {
public:
Widget(const Widget& other) : pImpl(std::make_unique(*other.pImpl)) {}
Widget& operator=(const Widget& other) {
if (this!= &other) {
*pImpl = *other.pImpl;
}
return *this;
}
private:
struct Impl;
std::unique_ptr pImpl;
};
通过这种方式,可以确保在复制对象时,执行深度复制操作,避免了浅复制带来的问题。