我在Tizen项目的开源代码中发现了可以缩短项目编译时间的模式。它在工程的许多地方都有使用。
作为一个例子,我选择了一个类的名字为ClientSubmoduleSupport
。它很短。以下是它们的来源:client_submode_support.h
,client_submode_support.cpp
。
可以看到,在client_submode_support.h
中定义了ClientSubmoduleSupport
和client_submode_support.cpp
,定义了ClientSubmoduleSupportImplementation
类,为ClientSubmoduleSupport
做工作。
你知道那个模式吗?我很好奇这种方法的利弊。
这个模式叫做">Bridge,也被称为">粉刺成语"。"。
目的:"将抽象与其实现解耦,以便两者可以独立变化">
来源:"四人帮"设计模式书
正如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中,structs
没有private
说明符,这是隐藏信息和防止外部世界访问struct
内部的唯一方法。因此,它成为了一种隐藏和阻止访问内部的方法。
在c++中,有一个很好的private
说明符允许我们阻止对内部的访问,但是我们不能对外部世界完全隐藏它们的可见性,除非我们使用像Pimpl
这样的东西,它基本上以class
的形式包装了这种不透明指针的C思想,指向一个前声明的UDT,带有一个或多个构造函数和析构函数。
[Opaque Pointer]-------------------------->[Internal Data Fields]
…这通常被描述为引入了额外的间接级别,但是这里的性能问题不是间接的,而是引用局部性的降低,以及在堆分配和第一次访问这些内部时额外的强制缓存丢失和页面错误。
使用这种表示,我们也不能再简单地分配所有我们需要的东西。只有指针可以被分配给
如果我们存储一堆这样的句柄(在C中,不透明指针本身,在c++中,包含一个对象)的数组,与此相关的性能成本往往是最明显的。在这种情况下,我们最终会得到一个包含一百万个指针的数组,这些指针可能指向所有的地方,我们最终会以增加页面错误、缓存丢失和堆(自由存储)分配/释放开销的形式付出代价。
这最终会给我们留下类似于Java存储百万用户定义类型实例的泛型列表并顺序处理它们(运行和隐藏)的性能。
效率:Fixed Allocator
显著降低(但不能消除)此成本的一种方法是使用O(1)固定分配器,它为内部提供更连续的内存布局。这在我们使用Foos
数组的情况下会有很大的帮助,例如,通过使用一个分配器,允许Foo
内部数据存储在一个(更)连续的内存布局中(改善引用的局部性)。
Efficiency: Bulk Interface
一种包含非常不同的设计思维的方法是开始在较粗糙的级别上对您的公共接口进行建模,使其成为Foo
聚合(Foo
实例容器的接口),并且隐藏甚至从外部世界单独实例化Foo
的能力。这只适用于某些场景,但在这种情况下,我们可以将成本降低到整个容器的间接单个指针,如果公共接口由同时操作许多隐藏Foo
对象的高级算法组成,则这实际上开始变得免费。
作为一个明显的例子(尽管希望没有人这样做),我们不想使用Pimpl
策略来隐藏图像中单个像素的细节。相反,我们想在整个图像级别上建模我们的接口,它由一堆像素和应用于一堆像素的公共操作组成。对于单个粒子vs.粒子系统,甚至是电子游戏中的单个精灵,这也是同样的想法。如果我们发现自己存在性能热点,因为建模过于细粒度,并为此付出内存或抽象代价(例如动态调度),那么我们总是可以扩充接口。
<"如果你想要达到最佳表现,你必须要兴奋起来!"增加这些接口!快去找choppa!"——假想的Arnie用螺丝刀刺穿别人的颈静脉后的建议
轻头/h2>
可以看到,这些实践完全隐藏了class
或struct
的内部。从编译时和头文件的角度来看,这也是一种解耦机制。
当Foo
的内部不再通过头文件对外部世界可见时,构建时间立即减少,只是因为头文件更小。也许更重要的是,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来工作。实际结果:现在我们最终得到了一堆插件二进制文件,这些插件可能是由各种各样的人为我们的产品编写的,这些插件在新ABI的新版本发布之前不再工作。
这里,不透明指针(包括Pimpl
)引入了一个中介,保护我们免受ABI破坏。
[Plugin Developer]----->[Opaque Pointer]----->[Internal Data Fields]
…当你现在可以自由地改变私有内部而不冒插件损坏的风险时,这对向后插件兼容性有很大的帮助。
利与弊
这里是一个优点和缺点的总结,以及一些额外的,次要的:
优点:
- 结果为轻量级标头。
- 减轻级联构建更改。内部可以改变,而只影响一个编译单元(又名翻译单元,即源文件),而不是许多。
- 隐藏内部,即使从美学/文档的角度来看,也可能是有益的(不要向使用公共接口的客户端显示超过他们需要看到的内容)。
- 防止客户端依赖脆弱的ABI,这会在修改单个内部细节时中断,从而减轻由于ABI更改而导致的对二进制文件的级联破坏。
缺点:
- 运行时效率(由较大的接口或高效的固定分配器减轻)。
- Minor:稍微多一些样板代码来读写实现者(尽管没有任何重要逻辑的重复)。
- 不能应用于类模板,这些类模板要求它们的完整定义在生成代码的站点可见。
TL;博士
无论如何,以上是对这个习语的简要介绍,以及它在c语言之前的一些历史和相似之处。
当您为第三方开发人员使用的库编写代码并且无法更改API时,您将主要使用此模式。它给了你改变函数底层实现的自由,而不需要你的客户在使用新版本的库时重新编译他们的代码。
(我已经看到API稳定性要求被写入法律合同)
使用这种模式来减少编译时间在j . Lakos。<大规模c++软件设计>(Addison-Wesley, 1996)。
Herb Sutter也对这种方法的优点进行了一些讨论这里。