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

Pimpl(pointer to implementation, 指向实现的指针)是一种常用的,用来对“类的接口与实现”进行解耦的方法。这个技巧可以避免在头文件中暴露私有细节(见下图 1),因此是促进 API 接口与实现保持完全分离的重要机制。但是 Pimpl 并不是严格意义上的设计模式(它是受制于 C++ 语言特定限制的变通方案),这种惯用法可以看作桥接设计模式的一种特例。

图 1: Pimpl 惯用法:这里的公有类拥有一个私有指针,该指针指向隐藏的实现类

在类中使用 Pimpl 惯用法,具有如下优点:

  • 降低耦合
  • 信息隐藏
  • 降低编译依赖,提高编译速度
  • 接口与实现分离

为了实现 Pimpl,我们先来看一种普通的类的设计方法。

假如我们要设计一书籍类 BookBook 包含目录属性,并提供打印书籍信息的对外接口,Book 设计如下:

1
2
3
4
5
6
7
8
class Book
{
public:
void print();

private:
std::string m_Contents;
};

Book 的使用者来说,他只需要知道print() 接口,便可以使用Book类,看起来一切都很美好。

然而,当某一天,发现Book需要增加一标题属性,对Book类的修改如下:

1
2
3
4
5
6
7
8
9
class Book
{
public:
void print();

private:
std::string m_Contents;
std::string m_Title;
};

虽然使用print()接口仍然可以直接输出书籍的信息,但是Book 类的使用者却不得不重新编译所有包含Book类头文件的代码。

为了隐藏Book类的实现细节,实现接口与实现的真正分离,可以使用 Pimpl 方法。

我们依然对Book类提供相同的接口,但Book类中不再包含原有的数据成员,其所有操作都由BookImpl类实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* public.h */
#ifndef PUBLIC_H_INCLUDED
#define PUBLIC_H_INCLUDED

class Book
{
public:
Book();
~Book();
void print();

private:
class BookImpl; // Book 实现类的前置声明
BookImpl* pimpl;
};

#endif

在对外的头文件public.h 中,只包含Book 类的外部接口,将真正的实现细节被封装到BookImpl类。为了不对外暴露BookImpl类,将其声明为Book类的内嵌类,并声明为private

BookImpl类的头文件如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* private.h */
#ifndef PRIVATE_H_INCLUDED
#define PRIVATE_H_INCLUDED

#include "public.h"
#include <iostream>

class Book::BookImpl
{
public:
void print();

private:
std::string m_Contents;
std::string m_Title;
};

#endif

private.h并不需要提供给Book类的使用者,因此,如果往后需要重新设计书籍类的属性,外界对此一无所知,从而保持接口的不变性,并减少了文件之间的编译依赖关系。

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
/* book.cpp */
#include "private.h" // 我们需要调用 BookImpl 类的成员函数,
// 所以要包含 BookImpl 的定义头文件
#include "public.h" // 我们正在实现 Book 类,所以要包含 Book 类
// 的头文件

Book::Book()
{
pimpl = new BookImpl();
}

Book::~Book()
{
delete pimpl;
}

void Book::print()
{
pimpl->print();
}

/* BookImpl 类的实现函数 */

void Book::BookImpl::print()
{
std::cout << "print from BookImpl" << std::endl;
}

使用Book 类的接口的方法如下:

1
2
3
4
5
6
7
8
9
10
/* main.cpp */
#include "public.h"

int main()
{
Book book;
book.print();

return 0;
}

Book类这样使用 Pimpl 的类,往往被称为 handle class,BookImpl类作为实现类,被称为 implementation class

为简单实现起见,Book类省略了复制构造函数和复制赋值函数。在实际应用中,一般有两种可选方案解决Book的复制和赋值的语义问题。

(1)禁止复制类
如果不打算让用户创建对象的副本,那么可以将对象声明为不可复制的。可以将复制构造函数和复制赋值函数声明为私有的,这样在复制或者赋值时就会产生编译错误。

以下代码通过声明私有的复制构造函数和复制赋值函数来使得对象不可以复制,不需要修改相关的.cpp文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* public.h */
#ifndef PUBLIC_H_INCLUDED
#define PUBLIC_H_INCLUDED

class Book
{
public:
Book();
~Book();
void print();

private:
// 禁止复制类
Book(const Book&);
const Book &operator = (const Book &);

class BookImpl; // Book 实现类的前置声明
BookImpl* pimpl;
};

#endif

(2)显示定义复制语义
如果希望用户能够复制采用 Pimpl 的对象,就应该声明并定义自己的复制构造函数和复制赋值函数。它们可以执行对象的深复制,即创建对象的副本,而非复制指针。

Pimpl 惯用法最主要的缺点是,必须为你创建的每个对象分配并释放实现对象,这使对象增加了一个指针,handle class 成员函数的每次调用都必须通过implementation class,这会增加一层间接性。在实际中你需要对这些开销进行权衡。

另外,采用了 Pimpl 的对象,编译器将不再能够捕获 const 方法中对成员变量的修改。这是由于成员变量现在存在于独立的对象中,编译器仅检查const方法中的pimpl 指针是否发生变化,而不会检查 pimpl 指向的任何成员。

可以使用下图 2 来说明 Pimpl 方法在以上Book类设计的作用:

图2: Pimpl 作为编译防火墙

由于 Pimpl 解除了接口与实现之间的耦合关系,从而降低文件间的编译依赖关系,Pimpl 也因此常被称为“编译期防火墙“ 。

本文的示例代码可以通过以下链接下载: https://github.com/haozlee/pimpl (如果觉得本文对您有帮助,麻烦动手点个 star 哈)。

下载源代码后,编译步骤:

1
2
g++ -c book.cpp
g++ -o pimpl main.cpp book.o

执行:

1
./pimpl

输出:

print from BookImpl

参考资料

  1. C++ Programming/Idioms: Pointer To Implementation (pImpl)
  2. The C++ Pimpl
  3. Compilation Firewalls
  4. 《Effective C++(第三版)》, Scott Meyers著, 侯捷译
  5. PIMPL模式
  6. 《C++ API设计》, Martin Reddy著,