我们知道,当对头文件进行更改时,包括它的所有源都需要重新编译。在大型项目和库中,由于即使对实现进行了很小的更改,每个人都必须等待一段时间才能编译代码,这可能会导致构建时间问题。
解决此问题的一种方法是使用PImpl Idiom
,它将实现隐藏在hearer
中,并包括一个可立即编译的接口文件。
The PImpl Idiom (Pointer to IMPLementation)
是一种用于将实现与接口分离的技术。这项技术通过把类中的成员变量替换成指向一个实现类(或结构体)的opaque pointer
,成员变量被放进单独的实现类中,然后通过该指针间接获取原来的成员变量。
其最大程度地减少了hearer
暴露,并帮助程序员减少了构建依赖性。
class Widget { // 在头文件“widget.h”中
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget是某个用户定义的类型
};
因为Widget的成员变量有std::string
,std::vector
和Gadget
,那么这些类型的头文件在Widget
编译时必须出现,这意味Widget
的用户必须要#include <string>,<vector>
以及gadget.h
。
这些增加的头文件会增加Widget
用户的编译时间,而且这使得用户依赖于这些头文件,即如果某个头文件的内容被改变了,Widget
的用户就要重新编译。 标准库头文件和不会经常改变,但是“gadget.h”
可能会经常修改。
在C++98中使用 Pimpl Idiom
,让Widget
的成员变量替换成一个指向结构体的原生指针,这个结构体只被声明,没有被实现
class Widget { // 依然在头文件“widget.h”中
public:
Widget();
~Widget();
...
private:
struct Impl; // 声明实现类
Impl *pImpl; // 声明指针指向实现类
};
因为Widget不再提起std::string
,std::vector
和Gadge
t类型,所以Widget
的用户不再需要“#include”
那些头文件了。那样加快了编译速度,也意味着当头文件内容改变时,Widget的用户不会受到影响。
一个被声明,却没定义的类型称为不完整类型(incomplete type)。 Widget::Impl
就是这样的类型,不完整类型能做的事情很少,不过可以声明一个指针指向它们,Pimpl Idiom
就是利用了这个特性。
Pimpl Idiom
的第一部分是声明一个指向不完整类型的指针作为成员变量,第二部分是动态分配和回收一个装有原来成员变量的对象,分配和回收的代码要写在实现文件,例如,对于Widget
,写在“Widget.cpp”中:
#include "widget.h" // 在实现文件“widget.cpp”
#include "gadget.h"
#include <string>
#include <vector>
`
struct Widget::Impl { // 用原来对象的成员变量来定义实现类
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
`
Widget::Widget() : pImpl(new Impl) {} // 为Widget对象动态分配成员变量
Widget::~Widget() { delete pImpl; } // 销毁这个对象的成员变量
依赖已经从“widget.h”
(它被所有Widget
类的使用者包含,并且对他们可见)转移到“widget.cpp”
(该文件只被Widget
类的实现者包含,并只对它可见)。现在,即使gadget.h
发生了任何改变,影响的也仅仅是“widget.cpp”
而与“widget.h”
无关,而“widget.h”
并不需要include “widget.cpp”
。 不过这个代码是动态分配的,需要在Widget的析构函数中回收分配的对象。
实例
实现方法:
- 创建一个单独的class(或struct)以实现
- 将所有header的私有成员放到这个类中
- 在头文件中定义一个实现类(Impl)
- 在头文件中,创建一个指向实现类的前向声明(指针)
- 定义一个析构函数和一个复制/赋值运算符
明确声明析构函数的原因是,在编译时,智能指针(std :: unique_ptr)检查类型定义中是否存在可见的析构函数,如果仅前向声明,则会引发编译错误。
Example:
- 头文件中包含的类定义是该类的公共接口
- 我们定义
unique pointer
(std::unique_ptr)而不是原始的指针,因为接口类型的对象负责对象的生存期 - 由于std :: unique_ptr是完整类型,因此需要用户声明的析构函数和复制/赋值运算符才能使实现类完整
- 从用户的角度来看,
The pimpl approach
是透明的。在内部,对IMPLementation
结构所做的更改仅影响包含它的文件(User.cpp)。这意味着用户无需重新编译即可应用这些更改。
/* |INTERFACE| User.h file */
#pragma once
#include <memory> // PImpl
#include <string>
using namespace std;
class User {
public:
// Constructor and Destructors
~User();
User(string name);
// Asssignment Operator and Copy Constructor
User(const User& other);
User& operator=(User rhs);
// Getter
int getSalary();
// Setter
void setSalary(int);
private:
// Internal implementation class
class Impl;
// Pointer to the internal implementation
unique_ptr<Impl> pimpl;
};
/* |IMPLEMENTATION| User.cpp file */
#include "User.h"
#include <iostream>
using namespace std;
struct User::Impl {
Impl(string name)
: name(name){};
~Impl();
void welcomeMessage()
{
cout << "Welcome, "
<< name << endl;
}
string name;
int salary = -1;
};
// Constructor connected with our Impl structure
User::User(string name)
: pimpl(new Impl(name))
{
pimpl->welcomeMessage();
}
// Default Constructor
User::~User() = default;
// Assignment operator and Copy constructor
User::User(const User& other)
: pimpl(new Impl(*other.pimpl))
{
}
User& User::operator=(User rhs)
{
swap(pimpl, rhs.pimpl);
return *this;
}
// Getter and setter
int User::getSalary()
{
return pimpl->salary;
}
void User::setSalary(int salary)
{
pimpl->salary = salary;
cout << "Salary set to "
<< salary << endl;
}
PImpl的优点:
- 二进制兼容性:二进制接口独立于私有字段。对实现进行更改不会使相关代码停滞不前。
- 编译时间:由于只需要重建实现文件而不是每个客户端都重新编译其文件,因此编译时间减少了。
- 数据隐藏:可以轻松隐藏某些内部细节,例如实现技术和用于实现公共接口的其他库。
PImpl的缺点:
- 内存管理:由于分配的内存多于默认结构,因此内存使用量可能会增加,这对于嵌入式软件开发至关重要。
- 维护工作:由于为了使用pimpl和附加的指针间接调用而增加了类,因此维护变得更加复杂(接口只能通过指针/引用使用)。
- 继承:隐藏的实现(Hidden implementation)无法继承,尽管PImpl类可以
More example:
#include <iostream>
#include <memory>
#include <experimental/propagate_const>
// interface (widget.h)
class widget {
class impl;
std::experimental::propagate_const<std::unique_ptr<impl>> pImpl;
public:
void draw() const; // public API that will be forwarded to the implementation
void draw();
bool shown() const { return true; } // public API that implementation has to call
widget(int);
~widget(); // defined in the implementation file, where impl is a complete type
widget(widget&&); // defined in the implementation file
// Note: calling draw() on moved-from object is UB
widget(const widget&) = delete;
widget& operator=(widget&&); // defined in the implementation file
widget& operator=(const widget&) = delete;
};
// implementation (widget.cpp)
class widget::impl {
int n; // private data
public:
void draw(const widget& w) const {
if(w.shown()) // this call to public member function requires the back-reference
std::cout << "drawing a const widget " << n << '\n';
}
void draw(const widget& w) {
if(w.shown())
std::cout << "drawing a non-const widget " << n << '\n';
}
impl(int n) : n(n) {}
};
void widget::draw() const { pImpl->draw(*this); }
void widget::draw() { pImpl->draw(*this); }
widget::widget(int n) : pImpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) = default;
// user (main.cpp)
int main()
{
widget w(7);
const widget w2(8);
w.draw();
w2.draw();
}
Reference:
- https://en.cppreference.com/w/cpp/language/pimpl
- https://www.geeksforgeeks.org/pimpl-idiom-in-c-with-examples/
- Effective Modern C++ 条款22