• C++使用PIMPL机制优化代码结构,降低耦合,提高编译速度


    好多年写的一篇博客,分享下。

    1 概述

    开发C++项目的时候,我们可能会关心以下这两个需求:

    1) 尽量减少编译的时间。这也是C++的顽疾了,往往会有这样的体检:仅仅修改了某个文件,编译却需要很长时间。我们知道,这是因为头文件之间依赖引起的,某个文件的改动会导致所有包含它的文件的重新编译。

    2) 尽量减少接口和实现耦合。对于这个需求我们一般的做法是写一个类似JAVA接口的抽象类,然后继承它。那么,除了这个方法还有没有其它方法呢?

    对于上面这两个需求,现代C++有个比较好的方法PIMPL(Private Implementation)机制,也叫编译器防火墙。Pimpl机制,顾名思义,将实现私有化力图使得头文件对改变不透明。主要思想是把客户与所有关于类的私有部分的实现隔离开,从而达到减少编译的时间和接口和实现的分离的目的。

    下面通过一段代码的对比,详细了解一下PIMPL机制。

    2 说明

    2.1 一个简单的例子

    首先举个简单的例子:

            // Base.h  
    #include "Other.h"
    class Base  
    {
    public:  
            int  fun(); 
            private:  
            int  m_data;
    COther m_other; //Other为第三方接口
    };
     
    // Derived.h
    #include "Base.h"
    class Derived : public Base  
    {
    public:
            //其他的方法
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    这是个很常见的类继承例子,没有任何错误。但是,我们仍然可以找到不足:

    1) 引入的头文件降低编译速度。

    我们一般将声明写在一个头文件中,而头文件是不能预编译或增量编译的,如果你因此而引入一个诸如Osp.h之类的头文件,那么产生的代价可能是一杯咖啡的编译时间,而且每次编译都这样。

    对于上面的例子,假设你现在希望在Base.h中加入一个新的private和protected成员函数,那么Derived和所有包含Base.h的源文件都需要重新编译。在一个大工程中,这样的修改可能导致重新编译时间的激增。一般来说,不在头文件中包含头文件是一个比较好的习惯,但是这也不能完全消除修改Base.h带来的重新编译代价,只是在表现上更完美了一些。

    2) 提高了模块的耦合度。

    在上面代码中,Derived从此与 Base紧紧绑定。在一个库里的模块互相耦合当然可以忍受,不过这里有两种耦合度:一个是编译期的,一个是运行期的。这种方式下,无论编译还是运行,它们都耦合在一起,只要 Base发生任何变更,Derived的模块也必须重新编译;

    3) 降低了接口的稳定程度。

    接口的稳定,至少有两个方面:一个是对于库的运用,即方法调用不能变;一个是对于库的编译,即动态库的变更最好能让客户程序不用重编译。

    对于上面的例子,如果COther 变了,Derived显然必须重新编译,因为Cother的 private 部分,虽然对于客户程序Derived不可用,但并不是不可见,尤其是对编译器来说。对于一个动态链接库,这个问题可能会让人无法忍受。

    2.2 通过PIMPL机制改进

    为了解决上面提出的三个问题,我们可以对代码进行如下改写:

    // XImpl.h  
    class XImpl {  
    public:  
        int  fun();  
    private:  
        int  m_data;  
        COther m_other; //Other为第三方接口
    };  
     
    // X.h  
    class XImpl;  
    class X {  
    public:  
        X();  
            ~X();
        int  fun();  
    private:  
            XImpl*  m_pXImpl; 
    };  
     
    // X.cpp  
    #include "XImpl.h"  
    #include "X.h"  
    X::X():m_pXImpl( new XImpl() ){ }  
    X::~X(){
            try { delete m_pXImpl; }
            catch(...) {}
    }
     
    int  X::fun() { 
            return m_pXImpl->fun(); 
    }  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    现在,除非我们自己修改XImpl的公有接口,否则这个头文件是不会被修改了。从语义上说,成员数据是属于XImpl的实现部分,并没有暴露给X类。然后,我们可以用这个XImpl类的实现来完成X类的细节实现;接着,在X类的构造函数中实例化XImpl类指针的。

    对比2.1的代码,我们不难发现:

    1) 降低了编译依赖,进而可以提高编译速度。

    我们知道,引用头文件是不能直接编译的,它包含于源文件中。而上面的代码,将Ximpl.cpp和X隔离开,也就是说COther类和X类是无关的。

    2) 隐藏数据细节。

    因为X类只关心Ximpl的公共方法,并不关心Ximpl内部是如何实现的。对于Ximpl类我们完全可以封装成DLL组件。这样我们就可以将X类的一些细粒度的操作放到Ximpl中实现,而没必要采用耦合度比较大的继承方法。

    但是,我们注意到这样修改带来了两个问题:

    1) 增加了内存占用。

    我们知道指针会占用4或8字节(目前大部分系统),很多64bit程序跑不过32bit就和这个指针有莫大关系。如果涉及到字节对齐问题,可能会增加更多的无用字节。

    2) 增加了运行时间。

    指针是一个变量,所以取指针指向的内容,就要先加载指针的值,然后把这个值看成地址,再去地址取真正的数据,也就是所谓的“解引用”(dereference)。那么对m_pXImpl这个指针的任何调用都相当于多了一层解引用的开销。多一个new的开销,我们知道栈比堆的数据开辟速度快,栈只需要移动一下栈顶指针,而堆需要考虑内存碎片,多线程竞争等等。

    3 PIMPL机制原理简介

    上面的两个例子对比,我们知道了PIMPL机制的优缺点。下面简单将一下原理。

    3.1 类似于桥接模式

    桥接模式(Bridge)的定义:将抽象部分与它的实现部分分离,使它们都可以独立地变化。

    这里说的意思不是让抽象基类与具体类分离,而是现实系统可能有多角度分类,每一种分类都有可能变化,那么把这种多角度分离出来让它们独立变化,减少它们之间的耦合性,即如果继承不能实现“开放-封闭原则”的话,就应该考虑用桥接模式。

    在这里插入图片描述

    上图翻译为代码为:

    class Implementor{
    public:
            virtual void Run()=0;
    };
     
    class ConcreteImplementorA : public Implementor{
    public:
            virtual void Run(){ 
                   //ConcreteImplementorA 具体实现
            }
    };
     
    class ConcreteImplementorB : public Implementor{
    public:
            virtual void Run(){ 
                   //ConcreteImplementorB 具体实现
            }
    };
     
    class Abstraction{
    protected:
            Implementor* m_pImpl;
    public:
            void SetImplementor(Implementor* pImpl){
                   m_pImpl = pImpl;
            }
            virtual void Run()=0;
    };
     
    class RefinedAbstraction : public Abstraction{
    public:
            virtual void Run(){
                    m_pImpl->Run();
            }
    };
     
    int main(){
            Abstraction *ab;
            ab = new RefinedAbstraction();
            ab->SetImplementor(new ConcreteImplementorA());
            ab->Run();
            ab->SetImplementor(new ConcreteImplementorB());
            ab->Run();
     
            return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    PIMPL机制大体上和桥接模一致。不同之处是:一般通过SetImplementor函数将pImpl指针传进来,而PIMPL机制一般是在构造函数中初始化(参考2.2的X::X():m_pXImpl( new XImpl() ){ })。

    3.2 PIMPL机制原理

    PIMPL机制又叫编译器防火墙。既然叫防火墙那有两个问题就必须提出:

    1) 谁造防火墙的?2)如何穿透防火墙?

    下面简述这两个问题

    3.2.1 谁造编译期防火墙的?

    C++的编译模式为“分离式编译”,即不同的源文件是分开编译的。就是说,不同的源文件之间有一道天然的防火墙,一个源文件“失火”并不会影响到另一个源文件。

    头文件是不能直接编译的,它包含于源文件中,并作为源文件的一部分被一起编译。这也就是说,只有源文件使用的开放接口变动了,才会导致源文件重新编译。

    pImpl指针在这里充当了一座桥。将依赖信息“推”到了另一个编译单元,与用户隔绝开来。任何的访问都必须经过这座“指针桥”。而防火墙是C++编译器的固有属性。

    3.2.2 如何穿透编译期防火墙?

    是什么穿越了C++编译期防火墙?是指针!使用指针的源文件“知道”指针所指的是什么对象,但是不必直接“看到”那个对象。它可能在另一个编译单元,是指针穿越了编译期防火墙,连接到了那个对象。

    从某种意义上说,只要是代表地址的符号都能够穿越C++编译期防火墙,而代表结构的符号则不能。例如函数名,它指的是函数代码的始地址。所以,函数能够声明在一个编译单元,但定义在另一个编译单元,编译器会负责将它们连接起来。用户只要得到函数的声明就可以使用它。而类则不同,类名代表的是一个语言结构,使用类,必须知道类的定义,否则无法生成二进制代码。变量的符号实质上也是地址,但是使用变量一般需要变量的定义,而使用extern修饰符则可以将变量的定义置于另一个编译单元中。

    4 总结

    PIMPL机制主要优点是:可以降低编译依赖,进而可以提高编译速度;可以减小接口和实现之间的耦合。主要缺点是会增加代码复杂度。

    因此,对于小项目或代码片段,PIMPL机制并没什么优势。但是,对于像McsLib这样的大项目还是值得使用的。一方面,程序越大,越是需要结构清晰;另一方面,我们程序员有很多时间都在调试,经常需要编译。如果每次重新编译都要几分钟,那肯定会让人恼火。因此,赶紧将PIMPL机制加入到你的项目中吧。

    5 参考资料

    The Joy of Pimpls (or, More About the Compiler-Firewall Idiom)

    Use of PIMPL Design Pattern

    旧话重提:pImpl惯用手法的背后

    Exceptional C++中文版(第7章) 侯捷译

  • 相关阅读:
    (1)(1.16) Maxbotix I2C声纳
    算法 二叉树的遍历(迭代法/递归法)
    努力走向更优秀的测试/开发程序员,“我“打破自己了......
    Vue3清除Echarts图表
    考 PMP 证书真有用吗?
    webpack 基础配置
    vue 代理
    使用 ES 实现疫情地图或者外卖点餐功能(含代码及数据)
    vulnhub靶机DC1
    wdb_2018_2nd_easyfmt
  • 原文地址:https://blog.csdn.net/stallion5632/article/details/125603112