C++ Pimpl

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的方式进行信息隐藏,面临多个问题:

  • 别人能看到private成员变量的信息;
  • 必须同时给出依赖的X.h,Y.h和Z.h;
  • 依赖的头文件和类本身的任何改动都将引发重新编译,即使这个改动本质上是不影响外部调用的。
  • 这种方式本质上是一种紧耦合,只是简单的面向对象的封装,隐藏实现细节。

使用依赖类的声明而非定义,这种方式的头文件形式如下:

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的间接调用,效率肯定有所降低。

这种实现方式有一些问题需要注意:

  1. Impl的声明最好设置为struct,原因我也不清楚,因为我用class声明的AImpl(不包含private成员),在Linux上能过,在windows过不去,一直报LINK
    ERROR的错误。我怀疑windows上看不到类的定义时,直接引用类成员函数会有问题。
  2. 一般使用unique_ptr来包装Impl类,但是使用unique_ptr的时候,接口类的析构函数不能直接定义在类的声明中。因为在类的声明中直接定义析构函数(或者使用=default)的时候,看不到Impl类的实现,也就是看不到Impl类的析构函数,而接口类的析构函数,必须要看unique_ptr成员函数Impl类的析构函数,否则会报can’t
    delete an incomplete type错误。
    • 这个错误其实是一类错误,主要是类的声明不知道类的大小,无论是构造还是析构,都不知道需要为类的对象分配或者回收的内存大小,因此是incomplete type。
    • 同时这中前向声明的方式,通常也用于解决循环引用的问题,但是forward declaration方式,被声明的类只能被用于指针,因为作为类的成员变量,必须知道其大小,而声明的Impl类没看到定义,不知道大小,但是指针的大小是固定的。

Impl

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的一部分,所以这也带来的编译器无法优化、减少代码大小

PIMPL的优点:

1)降低模块的耦合。因为隐藏了类的实现,被隐藏的类相当于原类不可见,对隐藏的类进行修改,不需要重新编译原类。

2)降低编译依赖,提高编译速度。指针的大小为(32位)或8(64位),X发生变化,指针大小却不会改变,文件c.h也不需要重编译。

3)接口与实现分离,提高接口的稳定性。

1、通过指针封装,当定义“new C”或"C c1"时 ,编译器生成的代码中不会掺杂X的任何信息。

2、当使用C时,使用的是C的接口(C接口里面操作的类其实是pImpl成员指向的X对象),与X无关,X被通过指针封装彻底的与实现分离。

参考
编译防火墙

你可能感兴趣的:(c++,c++,开发语言)