如果你一直在关注 C++ 编译器的最新进展(特别是 GCC、Clang 和 MSVC),你可能已经注意到编译速度在提高。特别是,编译器变得越来越增量,以至于编译器实际上只能重建已经改变的定义,并重用其余的,而不是重建整个翻译单元。
我提出 C++ 编译的原因是因为“一个奇怪的技巧”(又是那个短语!)一直被开发人员用来尝试和优化编译速度。
当然,我说的是…
让我首先解释一下在 Pimpl 习语中发生的技术方面的事情。假设您决定创建一个Person类,它存储一个人的名字并允许他们打印问候。不是像通常那样定义Person的成员,而是像这样定义类:
1 struct Person
2 {
3 std::string name;
4 void greet();
5
6 Person();
7 ~Person();
8
9 class PersonImpl;
10 PersonImpl *impl; // good place for gsl::owner<T>
11 };
这太奇怪了。对于一个简单的类来说,似乎有很多工作要做。让我们看看…我们有了名字和greet()函数,但是为什么还要麻烦构造器和析构函数呢?还有这个class PersonImpl是什么?
您看到的是一个选择在另一个类中隐藏其实现的类,这个类被称为PersonImpl。需要特别注意的是,这个类没有在头文件中定义,而是驻留在.cpp文件中(Person.cpp,所以Person和PersonImpl在同一位置)。它的定义非常简单
1 struct Person::PersonImpl
2 {
3 void greet(Person* p);
4 }
最初的Person类向前声明PersonImpl,并继续保存指向它的指针。正是这个指针在Person's构造器中被初始化,在析构函数中被销毁;如果智能指针能让你感觉更好,请随意使用。
1 Person::Person()
2 : impl(new PersonImpl) {}
3
4 Person::~Person() { delete impl; }
现在,我们开始实现Person:: greet(),你可能已经猜到了,它只是将控制权传递给了PersonImpl::greet():
1 void Person::greet()
2 {
3 impl->greet(this);
4 }
5
6 void Person::PersonImpl::greet(Person* p)
7 {
8 printf("hello %s", p->name.c_str());
9 }
所以…简而言之,这就是 Pimpl 的习惯用法,所以唯一的问题是为什么?!?为什么要麻烦地跳过所有的关卡,委派greet()并传递一个this指针呢?这种方法有三个优点:
- 更大比例的类实现实际上是隐藏的。如果您的
Person类需要一个充满了private/protected成员的丰富 API,那么您将向您的客户公开所有这些细节,即使由于private/protected访问修饰符,他们永远无法访问这些成员。使用 Pimpl,它们只能被提供公共接口。 - 修改隐藏 Impl 类的数据成员不会影响二进制兼容性。
- 头文件只需要包含声明所需的头文件,而不需要包含实现。例如,如果
Person需要一个类型为vector<string>的私有成员,您将被迫在Person.h头中对<vector>和<string>都进行#include(这是可传递的,所以任何使用Person.h的人也会包括它们)。使用 Pimpl 习惯用法,这可以在.cpp文件中完成。
您会注意到,上述几点使我们能够保持一个干净的、不变的头文件。这样做的副作用是降低了编译速度。而且,对我们来说重要的是,Pimpl 实际上是桥模式的一个很好的例子:在我们的例子中,pimpl opaque 指针(opaque 是 transparent 的反义词,也就是说,你不知道它后面是什么)作为一个桥,将公共接口的成员和隐藏在.cpp文件中的底层实现粘合在一起。
Pimpl 习惯用法是桥设计模式的一个非常具体的说明,所以让我们来看一些更一般的东西。假设我们有两类对象(在数学意义上):几何形状和可以在屏幕上绘制它们的渲染器。
就像我们对适配器模式的说明一样,我们将假设呈现可以以矢量和光栅的形式发生(尽管我们不会在这里编写任何实际的绘图代码),就形状而言,让我们只限于圆形。
首先,这里是Renderer基类:
1 struct Renderer
2 {
3 virtual void render_circle(float x, float y, float radius) = 0;
4 };
我们可以很容易地构造矢量和光栅实现;我将使用一些代码模拟下面的实际渲染,以将内容写入控制台:
1 struct VectorRenderer : Renderer
2 {
3 void render_circle(float x, float y, float radius) override
4 {
5 cout << "Rasterizing circle of radius " << radius << endl;
6 }
7 };
8
9 struct RasterRenderer : Renderer
10 {
11 void render_circle(float x, float y, float radius) override
12 {
13 cout << "Drawing a vector circle of radius " << radius << endl;
14 }
15 };
基类Shape将保持对渲染器的引用;该形状将支持使用draw()成员函数的自呈现,还将支持resize()操作:
1 struct Shape
2 {
3 protected:
4 Renderer& renderer;
5 Shape(Renderer& renderer) : renderer{ renderer } {}
6 public:
7 virtual void draw() = 0;
8 virtual void resize(float factor) = 0;
9 };
你会注意到Shape类引用了一个Renderer。这恰好是我们建造的桥梁。我们现在可以创建一个Shape类的实现,提供额外的信息,比如圆心的位置和半径。
1 struct Circle : Shape
2 {
3 float x, y, radius;
4
5 void draw() override
6 {
7 renderer.render_circle(x, y, radius);
8 }
9
10 void resize(float factor) override
11 {
12 radius *= factor;
13 }
14
15 Circle(Renderer& renderer, float x, float y, float radius)
16 : Shape{renderer}, x{x}, y{y}, radius{radius} {}
17 };
好了,这个模式很快就暴露出来了,有趣的部分当然是在draw()中:这是我们使用桥将Circle(它有关于它的位置和大小的信息)连接到渲染过程的地方。而确切地说,这里的桥梁是一座Renderer,例如:
1 RasterRenderer rr;
2 Circle raster_circle{ rr, 5,5,5 };
3 raster_circle.draw();
4 raster_circle.resize(2);
5 raster_circle.draw();
在前面的例子中,桥是RasterRenderer:你创建它,将一个引用传入Circle,从那时起,对draw()的调用将使用这个RasterRenderer作为桥,画圆。如果你需要微调圆圈,你可以resize()它,渲染仍然会工作得很好,因为渲染器不知道也不关心Circle,甚至不把它作为参考!
桥是一个相当简单的概念,作为一个连接器或胶水,将两个部分连接在一起。抽象(接口)的使用允许组件在没有真正意识到具体实现的情况下相互交互。
也就是说,桥模式的参与者确实需要意识到彼此的存在。具体来说,Circle需要一个对Renderer的引用,相反,Renderer知道如何专门画圆(因此,draw_circle()成员函数的名字)。这与中介模式形成对比,中介模式允许对象在不直接知道对方的情况下进行通信。