如前所述sergej
,它是设计模式Pimpl
的一个子集。Bridge
但我想为这个主题提供一个 C 视角。当我更深入地了解 C++ 时,我很惊讶它有这样一个名字,因为在 C 中应用了类似的做法,具有相似的优点和缺点(但由于 C 缺乏一些东西,所以多了一个优点)。
C 视角
在 C 中,有一个指向前向声明的不透明指针是相当普遍的做法struct
,如下所示:
// Foo.h:
#ifndef FOO_H
#define FOO_H
struct Foo* foo_create(void);
void foo_destroy(struct Foo* foo);
void foo_do_something(struct Foo* foo);
#endif
// Foo.c:
#include "Foo.h"
struct Foo
{
// ...
};
struct Foo* foo_create(void)
{
return malloc(sizeof(struct Foo));
}
void foo_destroy(struct Foo* foo)
{
free(foo);
}
void foo_do_something(struct Foo* foo)
{
// Do something with foo's state.
}
这带来了类似的优点/缺点,Pimpl
但还有一个额外的 C 优点。在 C 中,没有private
说明符 for ,这使其成为隐藏信息和防止外部世界structs
访问内部的唯一方法。struct
因此,它成为了一种隐藏和防止访问内部的手段。
在 C++ 中,有一个很好的private
说明符允许我们阻止对内部的访问,但是我们不能完全隐藏它们对外界的可见性,除非我们使用类似 a 的东西,Pimpl
它基本上包装了这种 C 概念,即指向前向声明的不透明指针class
具有一个或多个构造函数和一个析构函数的 a 形式的 UDT 。
效率
独立于唯一上下文的最明显的缺点之一可能是这种表示将可能是单个连续内存块的内容分成两个块,一个用于指针,另一个用于数据字段,如下所示:
[Opaque Pointer]-------------------------->[Internal Data Fields]
...这通常被描述为引入了额外的间接级别,但这里的性能问题并不是间接,而是引用位置的退化,以及堆分配时额外的强制缓存未命中和页面错误第一次访问这些内部结构。
有了这种表示,我们也不能再简单地在堆栈上分配我们需要的一切。只有指针可以分配给堆栈,而内部必须分配在堆上。
如果我们存储一组这些句柄的数组(在 C 中,不透明指针本身,在 C++ 中,包含一个对象),则与此相关的性能成本往往是最明显的。在这种情况下,我们最终会得到一个包含 100 万个指针的数组,这些指针可能指向所有地方,我们最终会以增加页面错误、缓存未命中和堆(免费存储)的形式为此付出代价分配/解除分配开销。
这最终会给我们留下类似于 Java 存储一百万个用户定义类型实例的通用列表并按顺序处理它们(运行和隐藏)的性能。
效率:固定分配器
一种显着减轻(但不是消除)这种成本的方法是使用 O(1) 固定分配器,它为内部提供更连续的内存布局。这在我们使用数组的情况下会有很大帮助Foos
,例如,通过使用分配器,它允许使用Foo
(更多)连续的内存布局(提高引用的局部性)来存储内部。
效率:批量接口
一种采用非常不同的设计思维方式的方法是开始在更粗略的级别上将公共接口建模为Foo
聚合(实例容器的接口),并隐藏甚至从外部世界单独Foo
实例化的能力。Foo
这仅适用于某些场景,但在这种情况下,我们可以将成本降低到整个容器的单个指针间接寻址,如果公共接口由对许多隐藏Foo
对象进行操作的高级算法组成,则它实际上开始变得免费立刻。
作为一个明显的例子(尽管希望没有人这样做过),我们不想使用一种Pimpl
策略来隐藏图像的单个像素的细节。相反,我们希望在整个图像级别对我们的界面进行建模,该级别由一堆像素和适用于一堆像素的公共操作组成。单个粒子与粒子系统的相同想法,甚至可能是视频游戏中的单个精灵。如果我们发现自己有性能热点,我们总是可以扩大我们的接口,因为模型太细化了,并且为此付出了内存或抽象损失(例如动态调度)。
“如果你想要达到最佳性能,你就得振作起来!增加这些接口的体积!快点上choppa!” -- 将螺丝刀穿过某人的颈静脉后的想象中的 Arnie 建议。
打火机头
可以看出,这些做法完全隐藏了一个class
或struct
外部世界的内部。从编译时和头文件的角度来看,这也可以作为一种解耦机制。
当Foo
外部世界不再通过头文件看到 a 的内部结构时,构建时间会立即缩短,只是因为头文件更小。也许更重要的是,内部Foo
可能需要包含其他头文件,例如Bar.h
. 通过隐藏内部,我们不再需要Foo.h
包含Bar.h
(只会Foo.cpp
包含它)。由于Bar.h
可能还包含其他具有级联效果的头文件,这可以显着减少预处理器所需的工作量,并使我们的头文件比使用Pimpl
.
因此,虽然Pimpls
有一些运行时成本,但它们减少了构建时间成本。即使在对性能最关键的领域,大多数复杂的代码库都将有利于生产力,而不是最大的运行时效率。从生产力的角度来看,冗长的构建时间可能是致命的,因此在运行时以轻微的性能下降换取构建性能可能是一个很好的权衡。
级联更改
此外,通过隐藏 内部结构的可见性,对其Foo
所做的更改不再影响其头文件。这使我们现在可以简单地更改Foo.cpp
,例如,更改 的内部结构Foo
,只需要在这种情况下重新编译这个源文件。这也与构建时间有关,但特别是在小的(可能非常小的)变化的情况下,必须重新编译各种东西可能是一个真正的 PITA。
作为奖励,这也可能会提高团队环境中所有队友的理智,如果他们不必重新编译所有内容以对某些班级的私人细节进行一些小改动。
有了这个,每个人都可以以更快的速度完成他们的工作,在他们的日程安排中留出更多的时间去参观他们最喜欢的酒吧并受到打击等等。
API 和 ABI
一个不太明显的优点(但在 API 上下文中非常重要)是当您向插件开发人员(包括在您控制之外编写源代码的第三方)公开 API 时,例如在这种情况下,如果您公开一个class
或者struct
通过插件访问的句柄直接包含这些内部结构的方式,我们最终会得到一个非常脆弱的 ABI。二进制依赖可能开始类似于这种性质:
[Plugin Developer]----------------->[Internal Data Fields]
这里最大的问题之一是,如果您对这些内部状态进行任何更改,内部的 ABI 会破坏插件直接依赖的工作。实际结果:现在我们最终得到了一堆可能由各种各样的人为我们的产品编写的插件二进制文件,这些二进制文件在为新 ABI 发布新版本之前不再有效。
这里一个不透明的指针(Pimpl
包括在内)引入了一个中介来保护我们免受此类 ABI 破坏。
[Plugin Developer]----->[Opaque Pointer]----->[Internal Data Fields]
...当您现在可以自由更改私有内部结构而不会冒此类插件损坏的风险时,这对于向后插件兼容性大有帮助。
优点和缺点
以下是优点和缺点的摘要以及一些额外的次要优点:
优点:
- 结果是轻量级标题。
- 减轻级联构建更改。可以更改内部结构,同时仅影响一个编译单元(也称为翻译单元,即源文件),而不是许多。
- 隐藏即使从美学/文档的角度来看也是有益的内部结构(不要向客户展示使用公共界面的次数超过他们使用公共界面所需的次数)。
- 防止客户依赖脆弱的 ABI,这会在修改单个内部细节时破坏,从而减少由于 ABI 更改而导致的对二进制文件的级联破坏。
缺点:
- 运行时效率(通过笨重的接口或高效的固定分配器减轻)。
- 次要:为实现者读/写更多的样板代码(尽管没有任何重要逻辑的重复)。
- 不能应用于要求其完整定义在生成代码的站点上可见的类模板。
TL;博士
所以无论如何,上面是对这个成语的简要介绍,以及一些历史和与 C 中早于它的实践的相似之处。