你好,我是雨乐!
今天我们聊聊项目中一个常用的用法`PIMPL。
概念
PIMPL
是pointer to implementation
的缩写,意指指向实现的指针,是一种广泛使用的减少编译依赖性的技术。
PIMPL主要目的是隐藏类的实现细节,对于减少编译时依赖性和打破头文件之间的循环依赖性特别有用,同时降低耦合度,提高ABI(Application Binary Interface)稳定性,以及简化跨编译单元的共享库升级。
相信很多人在开发的时候,为了解决编译不过的问题,在自己的头文件中增加了很多用不到的其它的头文件,而这样不仅违背了信息隐藏原则,编译时间也会显著增加。正是基于这个原因,才引入了PIMPL这一惯用法。
从一个例子入手
为了从直观上了解PIMPL带来的好处,我们且看一个例子。
在这个例子中,包含三个类,分别在car.h、engine.h以及car_imp.h中。
engine.h
代码语言:javascript复制class Engine {
public:
Engine() = default;
};
car_imp.h
代码语言:javascript复制#include "engine.h"
class CarImp {
public:
CarImp() = default;
private:
Engine engine_;
};
car.h
代码语言:javascript复制#include "car_imp.h"
class Car {
public:
Car() = default;
void Start() {}
private:
CarImp carimp_;
};
从car.h中,可以看出,这里面存在一个依赖,即:如果要使用car这个类,不仅仅要包含其头文件,也需要知道car_imp.h
。从设计的角度来看,car_imp.h应该被隐藏或者说不被使用car.h的用户看到,显然,上面这个设计不满足。
另一方面,正如我们所知道的,类的变量和函数都是在头文件中声明或定义的,如果头文件发生了更改,那么须重新编译包含相关头文件的所有其他模块。这将意味着大型项目会出现严重耗时的情况。
如果我们依赖了很多头文件,emm,耗时可想而知。。。
横空出世
正如前面代码中类Car所示,其所依赖的CarImp成员变量为其私有,对于对象类型的变量,必须包含其相应的头文件car_imp.h
,否则将会编译失败,如果将其声明为指针方式呢?
且看看PIMPL的实现方式,代码如下:
car.h
代码语言:javascript复制#include <memory>
class CarImp;
class Car {
public:
Car();
void Start() {}
private:
std::unique_ptr<CarImp> carimp_;
};
car.cc
代码语言:javascript复制#include "car.h"
#include "car_imp.h"
Car::Car() : carimp_(std:: make_unique <CarImp>()) {}
与上节的例子相比,carimp_仍然作为Car类的私有成员变量,与之前不同的是,这本例中其类型为std::unique_ptr,且增加了CarImp类的前置声明,表明该文件中未提供CarImp类的完整定义。
其次,本例中,头文件car.h和car_imp.h被移到了car.cc中。
好了,不妨使用如上代码:
代码语言:javascript复制#include "car.h"
int main() {
Car car;
return 0;
}
编译之后,报错如下:
代码语言:javascript复制car.cc:4:1: error: definition of explicitly-defaulted ‘Car::Car()’
4 | Car::Car() : carimp_(std:: make_unique <CarImp>()) {}
| ^~~
In file included from car.cc:1:
car.h:7:3: note: ‘constexpr Car::Car()’ explicitly defaulted here
7 | Car() = default;
| ^~~
In file included from /opt/rh/devtoolset-11/root/usr/include/c /11/memory:76,
from car.h:1,
from main.cc:1:
unique_ptr.h: In instantiation of ‘void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = CarImp]’:
unique_ptr.h:361:17: required from ‘std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = CarImp; _Dp = std::default_delete<CarImp>]’
car.h:7:3: required from here
unique_ptr.h:83:23: error: invalid application of ‘sizeof’ to incomplete type ‘CarImp’
83 | static_assert(sizeof(_Tp)>0,
| ^~~~~~~~~~~
好了,现在开始着手解决上述报错~
析构函数可见性
在c 中,有一条这样的规则:如果指针的类型为void*或者指向的类型不完整(前向声明),则删除指针可能会导致未定义的行为。
在上面的例子中,在头文件car.h中,CarImp仅被前向声明,因此删除它的指针将导致未定义行为。
对于std::unique_ptr来说,在调用删除之前检查会类型的定义是否可见。如果仅向前声明该类型,则std::unique_ptr拒绝编译以及调用删除,从而防止潜在的未定义行为。
标准规定,如果定义的类中,为声明析构函数,则编译器会帮忙生成它,但是,编译器生成的方法被声明inline,因此直接在头文件中实现,又因为头文件中仅仅是前向声明,类型并不完整,这就导致类编译失败。
继续回到我们的例子,如果不为类Car编写析构函数,编译器会默认生成,为了不让编译器生成,则需要我们自己声明一个析构函数,又因为CarImp在头文件car.h中仅仅作为前向声明,所以这就要求我们将析构函数定义在.cc中,好了,直接看代码吧:
car.h
代码语言:javascript复制#include <memory>
class CarImp;
class Car {
public:
Car();
void Start() {}
~Car();
private:
std::unique_ptr<CarImp> carimp_;
};
car.cc
代码语言:javascript复制#include "car.h"
#include "car_imp.h"
Car::Car() : carimp_(std:: make_unique <CarImp>()) {}
Car::~Car() = default;
好了,打完收工~