Pimpl(Pointer to implementation,指向实现的指针) 是一种减少代码依赖和编译时间的C++编程技巧,其基本思想是将一个外部可见类(visible class)的实现细节(一般是所有私有的非虚成员)放在一个单独的实现类(implementation class)中,而在可见类中通过一个私有指针来间接访问该实现类。
C++虽然不太常提到设计模式,但是对外接口和实现细节的分离仍然是必须的。c++是静态编译语言,他看的就是文件和文件之间的依赖,如果是实例 type a,那么就一定需要include type相关头文件,这样导致一件事情:当多重依赖的时候,很可能基层类的小改动,导致所有包括这个类的大类都需要重新编译
.h文件中定义了一个类,虽然类中只有一些对外暴露的接口的成员函数,但是类中包含了一些private的成员变量。虽然不影响使用,但是从规范上讲是不合理的。因此需要将接口和实现的细节进行分离。也就是常说的信息隐藏。
对外Release的一个头文件a.h:
class A
{
public:
X getX();
Y getY();
Z getZ();
private:
X god;
Y damn;
Z it;
};
头文件形式如下,private成员变量:
#include "X.h"
#include "Y.h"
#include "Z.h"
class A
{
public:
X getX();
Y getY();
Z getZ();
private:
X god;
Y damn;
Z it;
};
如果直接使用private的方式进行信息隐藏,面临多个问题:
使用依赖类的声明而非定义,这种方式的头文件形式如下:
class X;
class Y;
class Z;
class A
{
public:
X getX();
Y getY();
Z getZ();
private:
X god;
Y damn;
Z it;
};
可以看到,不用再包含X.h,Y.h和Z.h,当他们发生变化时,A的调用者不必重新编译,阻止了级联依赖的发生,但是别人仍然能看到私有成员信息
使用Impl的代理模式,即A本身只是一个负责对外提供接口的类,真正的实现使用一个AImpl类来代理,接口的实现通过调用Impl类的对应函数来实现,从而实现真正意义上的接口和实现分离
// AImpl.h
struct AImpl
{
public:
X getX();
Y getY();
Z getZ();
private:
X x;
Y y;
Z z;
};
// A.h
class X;
class Y;
class Z;
struct AImpl;
class A
{
public:
// 可能的实现: X getX() { return pImpl->getX(); }
X getX()
Y getY()
Z getZ();
private:
std::tr1::unique_ptr<AImpl> pImpl;
};
任何实现的细节都封装在AImpl类中,所以对于调用端来说是完全不可见的,包括可能用到的成员。其次,只要A的接口没有变化,调用端都不需要重新编译。
但是这种实现也有一个问题,就是多了一个类需要维护,并且每次对A的调用都将是对AImpl的间接调用,效率肯定有所降低。
这种实现方式有一些问题需要注意:
weight.h
#ifndef WEIGHT_H
#define WEIGHT_H
#include
class Weight
{
public:
Weight();
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
weight.cpp
#include "Weight.h"
#include
#include
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
将所有需要实例化的成员变量创建一个结构体,结构体指针使用unique_ptr管理!!!
但是这种方式在实例化weight的时候会出问题,因为unique_ptr内部默认析构器会对指针类型进行判断如果是不完全的类型会进行报错,为啥会不完全呢,因为编译器默认的析构函数是在头文件隐式内联的,在头文件中当然看不到具体类型
解决办法是:
让析构的时候看到完整类型呗,也就是析构实现的时候看到结构体是完成的,所以将weight的析构函数移到.cpp中
#include "Weight.h"
#include
#include
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
Weight::~Weight() {
}
也可以使用 ~Weight() = default; 相当于实现使用默认的编译器生成代码
#include "Weight.h"
#include
#include
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
Weight::~Weight() = default;
那么析构有影响,拷贝构造和赋值操作符呢?
当声明了析构函数,编译器就不会默认生成移动操作符函数,需要显示声明
那么对于下面的
#ifndef WEIGHT_H
#define WEIGHT_H
#include
class Weight
{
public:
Weight();
~Weight();
Weight(Weight&& rhs) = default;
Weight& operator=(Weight&& rhs) = default;
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
因为unique_ptr的原因,我们只能使用默认的移动操作符
然而在
#include // std::streambuf, std::cout
#include "Weight.h"
int main () {
Weight w;
Weight c;
w = std::move(c);
return 0;
}
报错了,原因是在 移动操作符的默认实现中 会对原有的进行delete处理,这就和析构函数相同了,不完整类型
解决办法就是换个地方,在.h中统一声明
#ifndef WEIGHT_H
#define WEIGHT_H
#include
class Weight
{
public:
Weight();
~Weight();
Weight(Weight&& rhs);
Weight& operator=(Weight&& rhs);
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
#include "Weight.h"
#include
#include
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
Weight::~Weight() = default;
Weight::Weight(Weight&& rhs) = default;
Weight& Weight::operator=(Weight&& rhs) = default;
为了保证赋值操作符可以正常使用,必须手工自己进行实现
Weight& Weight::operator=(const Weight& rhs) {
if (this != &rhs) {
*m_impl = *rhs.m_impl;
}
return *this;
}
使用这种赋值方式,让结构体内部进行赋值,注意的是内存是两块内存,只不过现在内容是一样的了
换成shared_ptr后都不需要了
#ifndef WEIGHT_H
#define WEIGHT_H
#include
class Weight
{
public:
Weight();
private:
struct Impl;
std::shared_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
#include "Weight.h"
#include
#include
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
对于unique_ptr他的析构器是智能指针的一部分,因为一开始就可以确定下来,这让编译器可以快速执行代码,这就要求编译时候看到的指针类型是完全的;对于shared_ptr,他的内部析构器不是智能指针的一部分,属于control Block的一部分,所以这也带来的编译器无法优化、减少代码大小
1)降低模块的耦合。因为隐藏了类的实现,被隐藏的类相当于原类不可见,对隐藏的类进行修改,不需要重新编译原类。
2)降低编译依赖,提高编译速度。指针的大小为(32位)或8(64位),X发生变化,指针大小却不会改变,文件c.h也不需要重编译。
3)接口与实现分离,提高接口的稳定性。
1、通过指针封装,当定义“new C”或"C c1"时 ,编译器生成的代码中不会掺杂X的任何信息。
2、当使用C时,使用的是C的接口(C接口里面操作的类其实是pImpl成员指向的X对象),与X无关,X被通过指针封装彻底的与实现分离。
参考
编译防火墙