从示例入手了解惯用法之PIMPL

2024-04-17 16:14:52 浏览数 (1)

你好,我是雨乐!

今天我们聊聊项目中一个常用的用法`PIMPL。

概念

PIMPLpointer 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;

好了,打完收工~

0 人点赞