C++中的编译防火墙--PImpl


PImpl简介

PIMPL(Pointer to Implementation)是通过一个私有的成员指针,将指针所指向的类的内部实现数据进行隐藏。

PImpl的优点:

//x.h
class X
{
public:
    void Fun();
private:
    int i; //add int i;
};

//c.h
#include <x.h>
class C
{
public:
    void Fun();
private:
    X x; //与X的强耦合
};

PIMPL做法:

//c.h
class X; //代替#include <x.h>
class C
{
public:
    void Fun();
private:
    X *pImpl; //pimpl
};

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

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

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

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

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

第43条 明智地使用Pimpl

<<C++编程规范101条规则、准则与最佳实践>>

摘要
抑制语言的分离欲望:C++将私有成员指定为不可访问的,但并没有指定为不可见的。虽然这样自有其好处,但是可以考虑通过Pimpl惯用法使私有成员真正不可见,从而实现编译器防火墙,并提高信息隐藏度(见第11条和第41条)。

讨论
如果创建“编译器防火墙”将调用代码类的私有部分完全隔离是明智的,就应该使用Pimpl惯用法:将私有部分隐藏在一个不透明的指针(即指向已经声明但是尚未定义的类的指针,最好是选择合适的智能指针)后面

例如:

class Map {
// ……
private:
    struct Impl;
    shared_ptr<Impl> pimpl_;
};

应该用Pimpl来存储所有的私有成员,包括成员数据和私有成员函数。这使我们能够随意改变类的私有实现细节,而不用重新编译调用代码——独立和自由正是这个惯用法的标记性特点。(见第41条。)

请注意:一定要如上所示使用两个声明来声明Pimpl。将两行合并成一条语句,在一句中前置声明类型和指针,即采用struct Impl* pimpl;这种形式的,也是合法的,但是意义就不同了,此时Impl处于外围名字空间中,而不是类中的嵌套类型

使用Pimpl的理由至少有三个,而且它们都源自C++语言可访问性(是否能够调用或者使用某种东西)和可见性(是否能看到它从而依赖它的定义)之间的差异。另外,类的所有私有成员在成员函数和友元之外是不可访问的,但是对整个世界,所有看得到类定义的代码而言,都是可见的。

这种差异的第一个后果,就是潜在更长的构建时间,因为需要处理不必要的类型定义。对于通过值保存的私有数据成员以及通过值接受的或者用于可见函数实现的私有成员函数中的参数,必须定义它们的类型,即使在此编译单元中根本不需要。这会导致更长的构建时间。例如:

class C {
// ……
private:
    AComplicatedType act_;
};

含有类C定义的头文件必须包含含有AComplicatedType定义的头文件,后者继而又要传递性地包含AComplicatedType可能需要的所有头文件,依此类推。如果头文件数量很大,编译时间将受到显著影响。

第二个后果是会给试图调用函数的代码带来二义性名字隐藏。即使私有成员函数不能从类外及其友元调用,它们也会参加名字查找和重载解析,因此会使调用无效或者存在二义性。C++在可访问性检查之前执行名字查找,然后重载解析。这是可见性具有优先级的原因:

int Twice(int);             //1
class Calc {
public:
    string Twice(string);     //2
private:
    char*Twice(char*);        //3
    int Test() {
        return Twice(21);     // A: 错误,2 和 3 是无法独立存在的(1 可以独立存在,
    }                   // 但是不能考虑,因为它是隐藏的)
};

Calc c;
c.Twice("Hello");           // B: 错误,3 不可访问(2 没有问题,但是
                                // 不能考虑,因为 3 是更好的匹配)

在A行,解决的办法是显式限定调用,即::Twice( 21 ),以强制名字查找来选择全局函数。

在B行,解决的办法是添加一个显式强制转换,即c.Twice( string("Hello") ),以重载解析来选择需要的函数。

这些调用问题中,有些是可以用Pimpl惯用法以外的办法解决的,例如,永远不写成员函数的私有重载,但是并非所有Pimpl能解决的问题都能有这样的替代方案。

第三个后果是对错误处理和错误安全的影响。考虑Tom Cargill的Widget示例:

class Widget {// ……
public:
    Widget& operator=( const Widget& );
private:
    T1 t1_;
    T2 t2_;
};

简而言之,如果 T1 或者 T2 操作的是不可逆的方式失败(见第 71条),我们就无法编写operator=来提供强大的保证,以至所需的最少(基本)保证。好在以下的简单转换总是能够为防错赋值提供最基本的保证,而且只要所需的T1和T2操作(值得注意的有构造和析构)没有副作用,通常还能够提供较强的保证:通过指针而不是值来保存成员对象,将它们都放在一个Pimpl指针之后更好。

class Widget {// ……
public:
    Widget& operator=( const Widget& );
private:
    struct Impl;
    shared_ptr<Impl> pimpl_;
};

Widget& Widget::operator=( const Widget& ) {
    shared_ptr<Impl> temp( new Impl( /*...*/ ) );
    // 改变 temp->t1_ 和 temp->t2_; 如果失败就抛出异常,否则这样提交:
    pimpl_ = temp;
    return *this;
}

例外情况

只有在弄清楚了增加间接层次确实有好处之后,才能添加复杂性,Pimpl也是一样。(见第6条和第8条。)

参考文献

[Coplien92]§5.5·[Dewhurst03]§8·[Lakos96]§6.4.2·[Meyers97]§34·[Murray93]§3.3·[Stroustrup94]§2.10, §24.4.2·[Sutter00]§23, §26-30·[Sutter02]§18, §22·[Sutter04]§16-17


参考

PImpl

编译防火墙--C++的Pimpl惯用法解析

【C++自我精讲】基础系列六 PIMPL模式

posted @ 2024-07-11 15:47  guanyubo  阅读(3)  评论(0编辑  收藏  举报