谈谈pimpl模式

相信大家在阅读C++代码的时候,一定都见过pImpl的实现方式。我第一次见到pImpl实现方式时,对于下面这种代码:

++
void MyClass::SomeFunc() {
    pImpl_->SomeFunc();
}

总有一种“脱了裤子放屁”的感觉。而对于经常提到的pImpl模式的优点,什么“屏蔽私有接口”,什么“增加封装性”,又觉得虚之又虚——我这系统要么给自己部门用的,要么是要开源的,有啥见不得人的,要屏蔽呢?多增加了一层pImpl也没见得封装性有什么提升啊?

其实,pImpl模式既然应用如此广泛,必然有其价值。查阅资料、深入思考之后,我觉得它的好处主要是如下两点。

编译速度友好

其实上面说的“屏蔽私有接口”,更重要的是“编译防火墙”的作用。

C++程序员一定都受过编译速度的折磨。一个build敲下去,就可以起身去瑞幸咖啡薅一把羊毛了。那有经验的程序员都知道,前置声明可以大大加快编译速度:对于指针/引用,编译期只需要知道它占几个字节,把内存对象布局搞定就可以了,并不需要知道实现细节。

那么如果把一个类的实现细节,用一个Impl类封装起来,并且使用前置声明,放到对外的接口类中。那么对外接口类应该是这样的:

++
// MyClass.h
Class MyClass {
 public:  
  // Interface functions, such as
  void SomeFunc();
 private:
  Class Impl;
  std::unique_ptr pImpl_;
}

如此这般,当我修改Impl类中的函数实现细节时,所有include “MyClass.h”的文件,均不会需要重新编译,因为整个MyClass对象的内存布局是和Impl的实现无关的。

所以我认为,pImpl最突出的一个优势,是可以大大加快编译速度。而正好,这种实现方式,隐藏了实现的细节。对于一些闭源软件库来说,正好,也可以避免暴露实现细节。

移动语义友好

C++11中引入了移动语义,可以参见我之前的一篇文章 《再谈右值引用与移动语义》 。那pImpl模式,对移动语义也是友好的。

对于不使用pImpl模式的类来说,要实现移动语义,需要对每一个数据成员变量进行处理;更糟糕的是,每次为接口类MyClass新增一个数据成员(这肯定是很常见的事情),都需要记得,要去修改移动语义相关的函数。相信我,你一定会忘记的。

用了pImpl就不一样了,对于移动语义,只需要“移动”一个指针即可,高效且一劳永逸~后面再增加数据成员,也是Impl类内部的事情,接口类的移动语义无需变动!完美~

损失

凡事都有两面性,pImpl模式肯定也有缺点。我这里想到两点:

  • 调用的间接性
    • 接口类函数的实现,一定要经过pImpl指针转一道,性能肯定有损耗,增加新接口写起来也麻烦;增加了一个指针的大小,虽然看上去不是什么大问题,但是对于每个对象都比较小,但是对象数量很多的那种类,这样的空间开销也不可忽视。
  • const问题
    • 另外,const是一个比较严重的问题。类的const函数可以确保不修改该类的数据成员变量,但是pImpl模式下,本来属于接口类的数据成员,全部被Impl类承包了。这时,接口类的const函数,只能确保pImpl 指针不被修改,而对于pImpl 指针所指向对象的成员变量,则无法强制保持const。
    • 这个问题可以通过 std::experimental::propagate_const 包装来修复
      ++
      // MyClass.h
      Class MyClass {
       public:  
        // Interface functions, such as
        void SomeFunc();
       private:
        Class Impl;
        std::experimental::propagate_const<std::unique_ptr> pImpl_;
      }
      

总结

pImpl是C++程序员经常遇到的一种编程模式,主要用于建立“编译防火墙”。同时,带来了屏蔽私有接口、移动语义友好等优点;

对于const函数被非const指针所屏蔽的问题,可以通过 std::experimental::propagate_const 来解决。

转载请注明出处: http://blog.guoyb.com/2019/06/07/pimpl/

欢迎使用微信扫描下方二维码,关注我的微信公众号TechTalking,技术·生活·思考: