书中第六章 隔离。 主要在撰述什么需要定义在头文件?什么应当移到编译单元中?
核心仍然是先区分接口定义与实现细节。实现细节的改变会导致客户代码的重新编译,从逻辑上也表示与客户代码间可能存在着强耦合。
实现细节与隔离
主要考察以下实现细节,它们会在接口中引入实现细节,也是需要考虑进行隔离的内容:
- 继承
- 分层
简单的说就是类的成员中有另一个类的实例时,如Foo mFoo. 这个类就会依赖于Foo的定义。而转为持有地址时,即将关系从HasA改为HoldA时,就不存在这个问题。也就是定义为Foo* mFoo;或Foo& mFoo; 这也是Google C++ Coding Style曾经就减少头文件依赖建议过的方式,后来则去掉了这项建议,改为:”不要为了使用前置声明,将成员变量改为指针类型”, 因为它反而增加了逻辑上的复杂度,比如额外的判空处理。 - 内联函数
- 私有成员
- 保护成员
- 编译器生成的默认实现函数,如拷贝。
- 包含指令,即头文件的包含。
- 默认参数
- 枚举
在一些大型项目中,一些存有基本枚举类型的头文件,最后变成没人敢改,而更愿意新增头文件。其实还不如放到具体的域或类中定义。
后面作者对各个细节推荐一些手法,相对比较简单。后面则介绍了几个常用手法:
- 协议类(接口类)
- Opaque Pointer和PIMPL
- Wrapper (封装器), 即引入中间层。
过程接口
考虑到上层代码对底层的操作需求,作者提出了过程接口(The Procedural Interface),可以结合常见的API来理解,它是一组函数的集合,出现在组件的顶部,并将功能的一个子集暴露给用户。作者概括了编程接口的要求:
- 接口必须提供必要的功能来操纵底层系统。
- 接口一定不能暴露专属的实现细节。
- 底层组织的变化必须与客户端程序相隔离。
- 与该接口相关的开销一定不能过大。
在实现方式上,以面向对象的Wrapper来实现这样的需求最佳的,而过程接口将针对无法简单使用独立的封装类来实现的系统。其实一个大型系统也是可以拆分出不同的领域,分别以Wrapper的形式来实现的。可以对比WebView的接口,以及Blink中的web层次。
书中主要是探讨了针对所持有对象的操作。上面也提到的Opaque Pointer,还特别说明了Handle(句柄)模式来管理动态分配的对象。
一个过程接口既不是面向对象的也不是特别美观,但它确有一个很大的优点:过程接口总是能够用于将大系统的组织与客户端程序相隔离–即使在设计的早期阶段并没有考虑这样的接口。
隔离或不隔离
隔离会引入一些开销,选择是否进行隔离的常见原因包括:
- 暴露 (被使用的范围,或者扇入)
- 访问数据的性能
- 创建对象的性能
- 开发成本 (在没有明确理由的情况强行隔离,会引入额外的开发工作)
- 组件的数量 (可能会新增组件,增加维护成本)
- 组件的复杂性 (引入新的复杂度,导致难以理解和维护)
作者提供两套经验值供决策时参考(中文编译的图表不太严谨,第5章有图标错,这里明明是两个表,却合成了一个表。)。
访问的相对开销
- 内联函数传递值 : 1
- 内联函数传递指针 : 2
- 非内联函数,非虚函数 : 10
- 虚函数机制 : 20
创建相对于单独分配的成本
- 自动 (栈上) : 1.5
- 动态 (堆上) : 100+
作者最后讨论隔离决策时,建议是否进行隔离取于被使用的范围,性能要求的高低,以及成员函数的大小(是否轻量级)。性能要求高不要隔离,轻量级的实现也不需要隔离。其实就是隔离本身会引入开销,如果为了隔离引入的开销过式,或者引入更不稳定的复杂度,就不要急于隔离。而对于大型、广泛使用的对象则要尽早隔离。