9

考虑以下:

PImpl.hpp

class Impl;

class PImpl
{
    Impl* pimpl;
    PImpl() : pimpl(new Impl) { }
    ~PImpl() { delete pimpl; }
    void DoSomething();
};

PImpl.cpp

#include "PImpl.hpp"
#include "Impl.hpp"

void PImpl::DoSomething() { pimpl->DoSomething(); }

实现.hpp

class Impl
{
    int data;
public:
    void DoSomething() {}
}

客户端.cpp

#include "Pimpl.hpp"

int main()
{
    PImpl unitUnderTest;
    unitUnderTest.DoSomething();
}

这种模式背后的想法是,它Impl的接口可以改变,但客户端不必重新编译。然而,我看不出这是怎么回事。假设我想为这个类添加一个方法——客户端仍然需要重新编译。

基本上,我能看到的唯一需要更改类的头文件的更改是类的接口发生更改的事情。当这种情况发生时,无论是 pimpl 还是没有 pimpl,客户端都必须重新编译。

在不重新编译客户端代码方面,这里的哪些类型的编辑给我们带来了好处?

4

7 回答 7

10

主要优点是接口的客户端不必包含所有类的内部依赖项的标头。因此,对这些标头的任何更改都不会级联到您大部分项目的重新编译中。加上关于实现隐藏的一般理想主义。

此外,您不一定将您的 impl 类放在它自己的标题中。只需将其作为单个 cpp 中的结构,并让您的外部类直接引用其数据成员即可。

编辑: 示例

一些类.h

struct SomeClassImpl;

class SomeClass {
    SomeClassImpl * pImpl;
public:
    SomeClass();
    ~SomeClass();
    int DoSomething();
};

SomeClass.cpp

#include "SomeClass.h"
#include "OtherClass.h"
#include <vector>

struct SomeClassImpl {
    int foo;
    std::vector<OtherClass> otherClassVec;   //users of SomeClass don't need to know anything about OtherClass, or include its header.
};

SomeClass::SomeClass() { pImpl = new SomeClassImpl; }
SomeClass::~SomeClass() { delete pImpl; }

int SomeClass::DoSomething() {
    pImpl->otherClassVec.push_back(0);
    return pImpl->otherClassVec.size();
}
于 2010-08-30T03:38:47.570 回答
7

有很多答案......但到目前为止还没有正确的实施。我有点难过示例不正确,因为人们可能会使用它们......

“Pimpl”成语是“Pointer to Implementation”的缩写,也称为“编译防火墙”。现在,让我们开始吧。

1.什么时候需要包含?

使用类时,仅在以下情况下才需要其完整定义:

  • 你需要它的大小(你的类的属性)
  • 您需要访问其中一种方法

如果您只引用它或有一个指向它的指针,那么由于引用或指针的大小不依赖于引用/指向的类型,您只需声明标识符(前向声明)。

例子:

#include "a.h"
#include "b.h"
#include "c.h"
#include "d.h"
#include "e.h"
#include "f.h"

struct Foo
{
  Foo();

  A a;
  B* b;
  C& c;
  static D d;
  friend class E;
  void bar(F f);
};

在上面的示例中,哪些包含是“方便”包含并且可以在不影响正确性的情况下删除?最令人惊讶的是:除了“啊”。

2. 实现 Pimpl

因此,Pimpl 的想法是使用一个指向实现类的指针,这样就不需要包含任何头文件:

  • 从而将客户端与依赖项隔离开来
  • 从而防止编译涟漪效应

另一个好处:保留了库的 ABI。

为了便于使用,Pimpl 成语可以与“智能指针”管理风格一起使用:

// From Ben Voigt's remark
// information at:
// http://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Checked_delete
template<class T> 
inline void checked_delete(T * x)
{
    typedef char type_must_be_complete[ sizeof(T)? 1: -1 ];
    (void) sizeof(type_must_be_complete);
    delete x;
}


template <typename T>
class pimpl
{
public:
  pimpl(): m(new T()) {}
  pimpl(T* t): m(t) { assert(t && "Null Pointer Unauthorized"); }

  pimpl(pimpl const& rhs): m(new T(*rhs.m)) {}

  pimpl& operator=(pimpl const& rhs)
  {
    std::auto_ptr<T> tmp(new T(*rhs.m)); // copy may throw: Strong Guarantee
    checked_delete(m);
    m = tmp.release();
    return *this;
  }

  ~pimpl() { checked_delete(m); }

  void swap(pimpl& rhs) { std::swap(m, rhs.m); }

  T* operator->() { return m; }
  T const* operator->() const { return m; }

  T& operator*() { return *m; }
  T const& operator*() const { return *m; }

  T* get() { return m; }
  T const* get() const { return m; }

private:
  T* m;
};

template <typename T> class pimpl<T*> {};
template <typename T> class pimpl<T&> {};

template <typename T>
void swap(pimpl<T>& lhs, pimpl<T>& rhs) { lhs.swap(rhs); }

它有什么其他人没有的?

  • 它只是遵循三法则:定义复制构造函数、复制赋值运算符和析构函数。
  • 它这样做是为了实现强保证:如果副本在分配期间抛出,则对象保持不变。请注意,析构函数不T应该抛出......但是,这是一个非常普遍的要求;)

在此基础上,我们现在可以稍微轻松地定义 Pimpl'ed 类:

class Foo
{
public:

private:
  struct Impl;
  pimpl<Impl> mImpl;
}; // class Foo

注意:编译器无法在此处生成正确的构造函数、复制赋值运算符或析构函数,因为这样做需要访问Impl定义。因此,尽管有pimpl帮助程序,您仍需要手动定义这 4 个。但是,由于 pimpl 帮助程序,编译将失败,而不是将您拖入未定义行为的领域。

3. 更进一步

应该注意的是,virtual函数的存在通常被视为实现细节,Pimpl 的优点之一是我们拥有正确的框架来利用战略模式的力量。

这样做需要更改 pimpl 的“副本”:

// pimpl.h
template <typename T>
pimpl<T>::pimpl(pimpl<T> const& rhs): m(rhs.m->clone()) {}

template <typename T>
pimpl<T>& pimpl<T>::operator=(pimpl<T> const& rhs)
{
  std::auto_ptr<T> tmp(rhs.m->clone()); // copy may throw: Strong Guarantee
  checked_delete(m);
  m = tmp.release();
  return *this;
}

然后我们可以定义我们的Foolike

// foo.h
#include "pimpl.h"

namespace detail { class FooBase; }

class Foo
{
public:
  enum Mode {
    Easy,
    Normal,
    Hard,
    God
  };

  Foo(Mode mode);

  // Others

private:
  pimpl<detail::FooBase> mImpl;
};

// Foo.cpp
#include "foo.h"

#include "detail/fooEasy.h"
#include "detail/fooNormal.h"
#include "detail/fooHard.h"
#include "detail/fooGod.h"

Foo::Foo(Mode m): mImpl(FooFactory::Get(m)) {}

请注意,ABIFoo完全不关心可能发生的各种变化:

  • 没有虚方法Foo
  • 的大小mImpl是一个简单指针的大小,无论它指向什么

因此,您的客户不必担心会添加方法或属性的特定补丁,并且您不必担心内存布局等......它自然而然地工作。

于 2010-08-30T08:40:22.857 回答
5

使用 PIMPL 习语,如果 IMPL 类的内部实现细节发生变化,则不必重新构建客户端。IMPL(以及因此的头文件)类的接口中的任何更改显然都需要更改 PIMPL 类。

顺便说一句,在显示的代码中,IMPL 和 PIMPL 之间存在强耦合。因此 IMPL 的类实现的任何变化也将导致需要重建。

于 2010-08-30T03:40:15.467 回答
4

考虑一些更现实的事情,好处变得更加显着。大多数情况下,我将它用于编译器防火墙和实现隐藏,我在可见类所在的同一编译单元中定义实现类。在您的示例中,我不会Impl.h或看起来像:Impl.cppPimpl.cpp

#include <iostream>
#include <boost/thread.hpp>

class Impl {
public:
  Impl(): data(0) {}
  void setData(int d) {
    boost::lock_guard l(lock);
    data = d;
  }
  int getData() {
    boost::lock_guard l(lock);
    return data;
  }
  void doSomething() {
    int d = getData();
    std::cout << getData() << std::endl;
  }
private:
  int data;
  boost::mutex lock;
};

Pimpl::Pimpl(): pimpl(new Impl) {
}

void Pimpl::doSomething() {
  pimpl->doSomething();
}

现在没有人需要知道我们对boost. 当与策略混合使用时,它会变得更加强大。Impl通过使用幕后的变体实现,可以隐藏诸如线程策略(例如,单对多)之类的细节。另请注意,还有许多Impl未公开的附加方法可用。这也使得这种技术有利于分层实现。

于 2010-08-30T03:58:28.420 回答
3

在您的示例中,您可以更改的实现而data无需重新编译客户端。如果没有 PImpl 中介,情况就不会如此。同样,您可以更改(到某一点)的签名或名称Imlp::DoSomething,而客户不必知道。

一般来说,可以声明private(默认)或protectedin的任何内容都可以在Impl不重新编译客户端的情况下进行更改。

于 2010-08-30T03:46:32.480 回答
1

非 Pimpl类头文件中,.hpp 文件在一个大桶中定义了类的公共和私有组件。

Privates 与您的实现紧密耦合,因此这意味着您的 .hpp 文件确实可以泄露很多关于您的内部实现的信息。

考虑一下您选择在类中私下使用的线程库之类的东西。如果不使用 Pimpl,线程类和类型可能会作为私有成员或私有方法的参数遇到。好的,线程库可能是一个不好的例子,但你明白了:你的类定义的私有部分应该对那些包含你的标题的人隐藏起来。

这就是 Pimpl 的用武之地。由于公共类标头不再定义“私有部分”,而是有一个指向实现的指针,因此您的私有世界仍然隐藏在“#include”是您的公共类标头的逻辑中。

当您更改私有方法(实现)时,您正在更改隐藏在 Pimpl 下的内容,因此您的类的客户端不需要重新编译,因为从他们的角度来看,没有任何改变:他们不再看到私有实现成员。

http://www.gotw.ca/gotw/028.htm

于 2010-08-30T04:36:59.277 回答
1

并非所有类都受益于 p-impl。您的示例在其内部状态中只有原始类型,这解释了为什么没有明显的好处。

如果任何成员在另一个头文件中声明了复杂类型,您可以看到 p-impl 将该头文件的包含从类的公共头文件移动到实现文件,因为您形成了指向不完整类型的原始指针(但不是嵌入字段也不是智能指针)。您可以单独使用指向所有成员变量的原始指针,但是使用指向所有状态的单个指针使内存管理更容易并改善数据局部性(好吧,如果所有这些类型依次使用 p-impl,则没有太多局部性)。

于 2010-08-30T04:48:31.880 回答