c++ 防火墙设计模式PImpl

  • Post author:
  • Post category:其他


PImpl(Pointer to implementation)是一种C++编程技术,其通过将类的实现的详细信息放在另一个单独的类中,并通过不透明的指针来访问。这项技术能够将实现的细节从其对象中去除,还能减少编译依赖。有人将其称为“编译防火墙(Compilation Firewalls)”。

1防火墙设计模式的目的

在C ++中,如果头文件类定义中的任何内容发生更改,则必须重新编译该类的所有用户-即使唯一的更改是该类用户甚至无法访问的私有类成员。这是因为C ++的构建模型基于文本包含(textual inclusion),并且因为C ++假定调用者知道一个类的两个主要方面,而这两个可能会受到私有成员的影响:


  • 大小和布局:

    调用代码必须知道类的大小和布局,包括私有成员变量。这种实现的约束会导致更紧密地耦合调用方和被调用方,这是C ++对象模型和哲学的核心,因为保证编译器默认情况下直接访问对象是(也许是)必不可少的C ++实现其着名的高度优化效率的重要因素。

  • 函数:

    调用代码必须能够解析对类成员函数的调用,包括无法访问的、由非私有函数重载的私有函数-如果私有函数更好地匹配,则调用代码将无法编译。(出于安全原因,C ++做出了精心的设计决策,在进行可访问性检查之前执行了重载解析。例如,人们认为将功能的可访问性从私有更改为公共不应改变合法调用代码的含义。)

为了减少这些编译依赖性,一种常见的技术是使用不透明的指针来隐藏一些实现细节。这是基本概念:

// Pimpl idiom - basic idea
class widget {
    // :::
private:
    struct impl;        // things to be hidden go here
    impl* pimpl_;       // opaque pointer to forward-declared class
};

类widget使用了handle/body惯用法的变体。handle/body主要用于对一个共享实现的引用计数,但是它也具有更一般的实现隐藏用法。为了方便起见,从现在开始,我将

widget

称为“可见类”,将

impl

称为“ Pimpl类”。

这种习惯用法的一大优势是,它打破了编译时的依赖性。首先,系统构建运行得更快,因为使用Pimpl可以消除额外的#include。我从事过一些项目,在这些项目中,仅将几个广为可见的类转换为使用Pimpls即可使系统的构建时间减少一半。其次,它可以本地化代码更改的构建影响,因为可以自由更改驻留在Pimpl中的类的各个部分,也就是可以自由添加或删除成员,而无需重新编译客户端代码。由于它非常擅长消除仅由于现在隐藏的成员的更改而导致的编译级联,因此通常被称为“编译防火墙”。

但这留下了一些问题选项:pimpl应当是原生指针吗?Pimpl类应该做什么?因此,让我们看一下这些以及其他重要的细节。


2.防火墙模式实现

在C++11中,体现Pimpl的最好路径是什么?

避免使用原生指针和显式的delete。要仅使用C ++标准设施表达Pimpl,最合适的选择是通过unique_ptr来保存Pimpl对象,因为Pimpl对象唯一被可见类拥有。使用unique_ptr的代码很简单:

// in header file
class widget {
public:
    widget();
    ~widget();
private:
    class impl;
    unique_ptr<impl> pimpl;
};
 
// in implementation file
class widget::impl {
    // :::
};
 
widget::widget() : pimpl{ new impl{ /*...*/ } } { }
widget::~widget() { }                   // or =default

注意以下几点:

  • 最好使用unique_ptr来保存Pimpl。它比使用shared_ptr更有效,并且可以正确表达不应该共享Pimpl对象的意图。
  • 在您自己的实现文件中

    定义



    使用

    Pimpl对象。这就是隐藏其细节的原因。
  • 在可见类的离线构造函数中(out-of-line constructor),分配Pimpl对象。
  • 您仍然需要自己编写可见类的析构函数,并在实现文件中进行定义,即使通常与编译器默认生成的析构函数相同。这是因为尽管unique_ptr和shared_ptr都可以使用不完整的类型实例化,但是unique_ptr的析构函数需要完整的类型才能调用delete(与shared_ptr构造时会捕获更多信息的方法不同)。通过自己将其写入实现文件中,您可以将其强制定义在已经定义了impl的地方,这成功地阻止了编译器尝试根据需要在未定义impl的调用者代码中自动生成析构函数。
  • 上面的模式默认情况下不会使可见类可拷贝或移动,因为C ++ 11不太希望编译器为您生成默认的拷贝和移动操作。由于我们必须编写用户定义的析构函数,因此将关闭编译器默认生成的move构造函数和move赋值运算符。如果您决定提供拷贝和/或移动,请注意,出于与析构函数相同的原因,需要在实现类中定义拷贝和移动赋值操作符。

Pimpl在C++11中的一个新优势是Pimpl类型是非常易于移动的类型,因为它们只需要复制一个指针值即可。


2.防火墙模式拆分

类的哪些部分应该指向实现对象(go into the impl object)


  1. 所有的private data(but not functions)

这是一个好的开始,因为现在我们可以前向声明任何仅显示为数据成员的类,而不是#include该类的实际定义(这么做依赖性太大)。

但是也有缺点:有点烦人的是,在可见类的实现中,我们仍然始终需要编写pimpl->。一个更重要的烦恼是,当我们添加或删除私有成员函数时,我们仍然会重新编译。在极少数情况下,如果它们使用非私有函数进行了重载,会干扰重载解析。


2.所有的private members

这几乎是我的惯常做法。毕竟,在C++中,短语“外部代码不应该也不在乎这些部分”是私有的。

但有三个警告,第一个是我上面“差不多”的原因:

  • 即使虚函数是私有的,您也无法在Pimpl中隐藏虚成员函数。如果虚函数覆盖(overrid)了从基类继承的虚函数,则它必须出现在实际的派生类中;如果虚函数是新的,则它必须仍然出现在可见类中,以便可以被其他派生类覆盖。
  • 如果Pimpl中的函数需要依次使用可见函数,则它们可能需要指向可见对象的“后向指针”,这又增加了一个间接层次。
  • 通常,较好的折衷方法是使用选项2(本选项),并仅将那些需要由私有函数调用的非私有函数放入Pimpl中。


3.所有的private and protected members

采取这一额外步骤来包括受保护的成员实际上是错误的。像虚拟成员一样,受保护的成员切勿进入Pimpl,因为将它们放置在那里会使他们一文不值。毕竟,受保护的成员专门存在于派生类中,以供派生类查看和使用,因此,如果派生类无法看到或使用它们,它们就几乎没有用。派生类型将被迫也必须知道并从Pimpl类型派生,并保持并行的两个对象层次结构。


4.所有的private nonvirtual members

这是理想的。为了减少存储或传递后向指针的需要,您还可以将私有函数调用的任何公共函数放入Pimpl中,并在可见类中传递给它们。但是,如上所述,您将无法将受保护的功能或虚拟功能移动到Pimpl中。


5.一切,并且只写public class作为public interface,所有的实现都作为一个简单的forwarding function (a handle/body variant)

这在少数情况下很有用,并且由于所有服务都在Pimpl类中可用,因此避免了后向指针的好处。主要缺点是,它需要额外的包装函数调用,并且通常会使可见类对继承无效。


6. 如何选择?

关键的是,使用任何OO语言,一个类有三部分:

  • 调用者接口 = public成员。这是外部调用者可以看到和使用的所有信息。
  • 派生者的接口 = protected或virtual members。这是只有派生类才能看到和使用的东西。
  • 其他一切 = 私有成员和非虚拟成员。根据定义,所有这些只是实现细节。

只有上述3中提到的应该在Pimpl中隐藏。


问题4

impl是否需要指向公共对象的后向指针(back pointer)?如果是,提供它的最佳方法是什么?如果没有,为什么不呢?

答案是:有时候不幸的是:需要。毕竟,我们要做的是(某种程度上是人为地)将每个对象分成两半,以隐藏一部分。

考虑:每当调用可见类中的函数时,通常都需要隐藏部分中的某些函数或数据来完成请求。很好,很合理。但是,正如已经讨论的那样,有时Pimpl中的函数必须在可见类中调用非私有或虚函数。在这种情况下,它需要一个指向可见类的指针。

有两点注意:

  • 将后指针存储在Pimpl中。这会产生少量开销,并在始终不需要指针时始终存储指针。另外,当您重复自己时,您可能会撒谎–如果您不小心正确地将其指向正确的可见对象(例如在移动操作期间可能无法正确设置为默认值),则后向指针可能会不同步。
  • (推荐)将

    this

    作为参数传递给Pimpl函数(例如pimpl-> func(this,params))。在函数调用(简短)过程中,这只会在堆栈上产生短暂的空间开销,并且不可能不同步。但是,这确实意味着要向每个隐藏函数添加一个额外的参数。