Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

《Large-Scale C++ Software Design》——John Lakos #20

Open
thzt opened this issue Apr 21, 2017 · 0 comments
Open

《Large-Scale C++ Software Design》——John Lakos #20

thzt opened this issue Apr 21, 2017 · 0 comments

Comments

@thzt
Copy link
Owner

thzt commented Apr 21, 2017

引言

大型项目
尽管对于最有经验的专业C程序员来说,C++的规模和复杂度在开始时都有点难以承受,
但一个能干的C程序员要写出一个小的但不是微不足道的C++程序,并不需要花太长时间。
然而,这种用C++创建小程序的未经训练的技术,来完成大型项目是完全不能胜任的。
也就是说,C++技术的不成熟应用不适合大型项目,不谙此道所造成的后果甚多。

物理设计
有若干关于逻辑设计的好书,但是也有不少这些书未涉及的问题,这些问题只有当程序变得很大时才出现。
这是因为与成功的大型系统设计相关的很多内容不属于逻辑设计的范畴,本书把它们作为物理设计。
物理设计涉及的问题包括与系统的物理实体有关的问题(如文件,目录和库等)以及组织问题(如物理实体之间的编译时依赖和连接时依赖等)。

对于小项目来说,一个文件目录就可满足要求,因而不必太重视物理设计。
但是对于大型项目来说,一个好的物理设计的重要性就大大提升了。
对于大型的项目来说,物理设计是项目成功的决定性因素。

重用
面向对象设计将易重用作为自己的优点。
但是正如其他风范一样,要获得好处,就要付出代价。
重用意味着耦合,而本身的耦合是我们不希望看到的。
如果若干程序员企图使用同一组件,并且不要求功能上的修改,则重用是合理和正当的。
但是,考虑这样的情形,有若干客户分别编写不同的程序,每个人都想重用一个公共组件以达到不同的目的。
如果另外一些独立客户也在积极寻求增强支持,他们会发现重用的结构彼此间不一致,
某一客户程序的加强可能会毁坏其他人的程序。
更糟糕的是,我们最终可能得到一个对谁都无用处的“超重”的类。

重用经常是好的方案,但是为了成功重用一个组件或一个子系统,该组件或子系统不应该与以一大块不需要的代码绑在一起。
也就是说,只有那些与系统其他部分没有必然联系的部分才有可能重用。
不是所有的代码都能重用。
试图实现过多的功能或者为实现对象进行完全彻底的错误检查,
都会增加不必要的研制和维护开销,也会增加可执行代码的大小。
实现者的以下两方面的知识对大型项目有益,何时重用代码和何时使代码能重用。

质量
通常,软件不能只是通过测试这一种手段来达到可靠性要求,
等到我们能测试软件的时候,软件的内在质量早已形成了,不是所有的软件都能够有效的测试。
要想让软件能够有效测试,必须从一开始就本着这个目标来对它进行设计。

为了易于测试而设计,虽然很少成为小项目最关注的事情,
却是成功构造大型和超大型系统的最重要的事情。
易测试性与质量本身一样,不可能是事后产生的想法,
它必须在编写第一行代码之前就预先考虑到。

必须在项目的一开始就考虑质量的各个方面,
设计一旦完成,就无法再提高质量了。

工具
没有工具能解决根本性问题,即固有的设计质量问题。
没有一个快捷和容易的方法可获得好的质量。
工具本身不能解决由低劣设计引起的基础性问题。
从根本上说,是经验,智力和规程产生高质量的产品。

小结
编译单元之间的循环“连接时依赖”会使程序难以理解,测试和重用。
不需要的或过多的“编译时依赖”会增加编译开销并且不利于维护。
当项目很大时,采用无组织的,无规程的或幼稚的C++开发方法,最终一定会出现这些问题。

重用不是无开销的,重用蕴含耦合,而耦合不是我们所希望的。
没有保障的重用应该避免。

质量的衡量标准有多个方面,可靠性,功能性,可用性,可维护性以及性能。
每一方面都会影响大型项目的成功或失败。

可靠性,是传统的质量定义(也就是说,“它有错误吗?”)。
功能性,是指一个产品是否能完成客户所期望的工作。
可用性,是指软件是否可以被有效的使用。
可维护性,和衡量支持一个系统工作相对开销的指标。
性能,是衡量产品速度和大小的指标。

获得高质量是一项工程的责任,从一开始就应积极追求质量,
质量不是在项目大体完成之后可以加入的东西。
良好的工具是开发过程的重要组成部分,
但是,在大型C++系统中,工具不能弥补固有的设计质量问题。


1. 预备知识

接口和实现
好的接口比好的实现要重要得多。
接口会对客户程序产生直接的影响,并且有全局影响力。
实现只影响作者和代码维护者。

有明确的理由要求在设计接口时实行严格的标准,尤其是在大型项目中。
修补接口通常要比修补实现更困难和昂贵。
假如有一个封装得很好的接口,那么扔掉一个糟糕的实现,
用一个更好的实现来取代它,通常并不会太困难。

特性爆炸
不断的修改和扩充对象功能是一种众所周知的把错误引进软件的方式。
同样,除非计划支持多个版本,否则不关心这些新功能的其他客户将被迫承担它们。

有些类的作者想让他们的类满足所有人的所有需求,
这样的类已经被亲切的称为温尼贝格(Winnebago)类。
这种很常见和似乎高贵的愿望令人忧虑,作为开发人员,我们必须记住,
只因为一个客户要求增加功能并不意味着对所有类都是适当的。

假如你是一个类的作者,10个客户中的每一个都请求你进行不同的增加。
如果你愿意,会发生两件事情:
(1)你将不得不实现,测试和存档10个新特征,你开始并没有认为这些新特征是你要实现的抽象的一部分。
(2)你的10个客户的每一个都会得到9个他们并没有请求和可能不必要或不想要的新特征。
每次你增加一个特性去取悦一个人,你就扰乱和潜在的骚扰了你的客户库的其他人。
原本是轻量级的和很有用的类,经过一段时间后变得过于臃肿,不但不能做好每件事情,
而且毫不夸张的说,它们已经变得每件事情都做不好了。

这种只要组件足够但不必完备的最低限要求方法,适用于正在开发的大型项目。
若一个功能实现对一个抽象来说是本质的,则省略该功能将没有意义。
在进行这种权衡时,记住要考虑到功能总是更容易增加而不容易删除。

小结
有外部连接的定义在连接时可以用来解析其他编译单元中的未定义符号,
把这样的定义放在头文件中,几乎肯定是一个编译错误。


2. 基本规则

文档
为接口建立文档以便其他人可以使用,至少请另外一个开发者检查每个接口。

想要理解为什么让别的开发者检查接口是有价值的,可以假设你自己就是试图理解你的类的客户或者测试工程师。
你自己非常了解如何使用接口——毕竟是你自己设计的。
你用来给成员函数命名的简洁名称是“明显的”和“自解释的”。
但除非你已花时间让别人检查了你的接口和文档,否则一定有很多需要改进的地方——特别是在它的可用性方面。

可用性主要是指拿到一个不熟悉的头文件就可以开始使用它。
如果客户必须被迫查看实现以便能够领会到如何使用组件,那么该文档就是不合适的。

文档的另一个重要方面,是明确的确定行为没有定义的条件。
即,明确的声明条件,在该条件下行为没有定义。
除非明确在注释中声明,否则客户和测试工程师一般来说没有办法区分什么是特意设计的或必需的行为,
什么是仅由特定实现选择引起的巧合行为。

没有明确的规定未定义行为的条件,会不经意的使用软件支持无关的行为,
这种行为会影响性能或限制实现的选择。
通过不恰当的(或无意识的)使用,客户可能会依赖这种巧合的行为。

assert
对系统的每一层进行错误检查,以便找出逻辑错误,这样做代价很高,对于大型系统来说尤为如此。
使用assert语句,有助于为程序员实现编码时的假设建立文档,明确声明某些行为是未定义的。

文档和assert语句的有效使用可以使我们得到更简练但仍十分有用的代码,
如果有人误用了某个函数,这是他们自己的错——而且他们很快就能发现这个错误。

如果每个开发者总是很清楚函数的指针参数不能为空,这是值得称赞的。
但是负责任的客户不应该假设指针参数可以为空,除非结果行为是明确声明的。

小结
把一个类的数据成员暴露给其他客户程序违反了封装原则,
提供对数据成员的非私有访问,意味着表示上的局部改变可能迫使客户重新编写代码。

全局变量会污染全局名称空间,而且会歪曲设计的物理结构,
使得实际上不可能进行独立的测试和有选择的重用。

良好的文档是软件开发必不可少的一部分,缺少文档将降低可用性。
文档的一个重要部分是声明什么是没有定义的。
否则,客户可能会依赖巧合的行为,这种行为只能来自特定的实现选择。

不是所有的代码都必须是鲁棒的,在系统每个层次上的冗余的运行时程序错误检查,
可能对性能产生无法接受的影响,文档和断言的结合可以达到通用的目的,
但在最终产品里可以获得更优越的运行时性能。


3. 组件

物理设计
逻辑设计只研究体系结构问题,物理设计研究组织问题。
物理设计集中研究系统中的物理实体以及它们如何相互关联的问题。

一个组件就是物理设计的最小单位。
一个组件严格的由一个头文件和一个实现文件构成。
一个组件一般会定义一个或多个紧密相关的类和被认定适合于所支持的抽象的任何自由运算符。

在一个组件内部声明的逻辑实体,不应该在该组件之外定义。
每个组件的.c文件都应该将包含它自己的.h文件的语句作为其代码的第一行有效的语句。
通过确保一个组件自己分析自己的.h文件——不要外部提供的声明和定义,可以避免潜在的使用错误。
在一个组件的.c文件中,避免使用有外部连接并且没有在相应的.h文件中明确声明的定义。
避免通过一个局部声明来访问另一个组件中带有外部连接的定义,而是要包含那个组件的.h文件。

依赖关系
如果编译y.c时需要x.h,那么组件y展示了对组件x的编译时依赖。
如果对象文件y.o包含未定义的符号,因此可能在连接时直接或间接的调用x.o来辅助解析这些符号,
那么就说组件y展示了对组件x的一种连接时依赖。

一个编译时依赖,几乎总是隐含一个连接时依赖。

友元关系
因跨越了组件边界,(远距离)友元关系变成了一个组件的接口的一部分,并且会导致该组件的封装性被破坏。
远距离友元关系还会通过允许对一个系统的物理上较远的部分进行密切的访问而进一步影响可维护性。


4. 物理层次结构

接口测试
面向对象技术的一种实际有效的应用是把极大的复杂性隐藏在一个小的,定义良好的,易于理解和易于使用的接口后面。
但是,正是这种接口(如果被不成熟的实现)会导致开发出来的子系统测试起来极其困难。

一盎司预防相当于一磅治疗。

质量设计的一个主要部分是易测试性设计(DFT)。
目前DFT是IC工业的一种标准,没有哪个称职的硬件工程师会在考虑设计一个复杂芯片时不直接考虑易测试性问题。
比较起来,大型软件系统的功能可能比在最复杂的集成电路中的功能复杂好几个数量级。
令人吃惊的是,很多软件系统在设计过程中,并没有适当的计划来确保软件是可测试的。
在过去的十年中,要求软件具备易测试性的努力经常遇到在IC工业界遇到的同样的挫折。
通常是人而不是技术对解决一个技术问题形成巨大的挑战。

对于测试来说,软件中的一个类类似于现实世界中的实例。
如果直接对它进行操作,而不是试图将它作为一个更大系统的一部分来测试,那么测试一个类的功能是最简单和最有效的。
并且和IC测试不同,我们自动的拥有了对该软件子系统的接口的直接访问权。
对整个设计的层次结构进行分布式测试,比只在最高层接口进行测试有效得多。

隔离测试是指这样的规程,独立于系统的其他部分,对单个组件或子系统进行的测试。

层次号
每个有向无环图都可被赋予唯一的层次号,一个有环的图则不能。

层次0,一个在我们软件包之外的组件,
层次1,一个没有局部物理依赖的组件,
层次N,一个组件,它在物理上依赖于层次N-1上(但不是更高层次上)的一个组件。

一个组件的层次是一个最长路径的长度,该路径是指从那个组件穿过(局部)组件依赖图到外部库组件的集合(可能为空)的路径。
在大多数真实情况下,如果大型设计要被有效的测试,它们必须是可层次化的。

分层测试是指在每个物理层次结构上测试单个组件的惯例。
增量式测试是指这样的测试惯例,只测试真正在被测试组件中实现的功能。
白盒测试是指通过查看组件的底层实现来验证一个组件的期望行为。
黑盒测试是指仅基于组件的规范(即不必了解其基础实现)来检验一个组件的期望行为的惯例。
回归测试指的是这样的规程,运行一个程序(该程序被给定了一个有固定期望结果集合的特定输入),比较其结果,
以便检验程序从一个版本升级到另一个版本时,是否能够继续如所期望的那样运行。


5. 层次化

小结
实现层次化的技术包括:
(1)升级
将相互依赖功能在屋里层次结构中提高。
(2)降级
将共有功能在物理层次结构中降低。
(3)不透明指针
让一个对象只在名称上使用另一个对象。
(4)哑数据
使用表示对同层对象的一个依赖的数据,但只在单独的,较高层对象的上下文中。
(5)冗余
通过重复少量的代码或数据避免耦合来故意避免重用。
(6)回调
使用客户提供的函数,这些函数可以使较低层次的子系统能够在更全局的上下文中执行特定任务。
(7)管理类
建立一个拥有和协调较低层次对象的类。
(8)分解
将独立可测试子行为,从涉及过度物理依赖的复杂组件的实现中移出来。
(9)升级封装
将实现细节对客户隐藏的地点移到物理层次结构的更高层。


6. 绝缘

绝缘
一个被包含的实现细节(类型,数据或函数),如果被修改,添加或删除时,不会迫使客户程序重新编译,
则称这样的实现细节被绝缘了。

假设你是一个C++应用程序库的销售商,如果你供应一个完全绝缘的库实现,
那么你在增强性能和修复故障时,完全不用打扰你的客户。
给客户发送一个更新版本不会迫使用户程序重新编译或者连接,
客户需要做只是重新配置环境以指向新的动态装载的库,然后离开,让它们工作。

协议类
满足下列条件的抽象类是一个协议类:
(1)它既不包含也不继承那些包含成员数据,非虚拟函数或任何种类的私有(或保护的)成员的类。
(2)它有一个非内联虚析构函数(定义一个空实现)。
(3)所有成员函数(除了包含被继承函数的析构函数)都声明为纯虚的,并任其处于未定义的状态。

一个协议类几乎是一个完美的绝缘器。
一个协议类可以用来消除编译时依赖和连接时依赖。

完全绝缘的具体类
只保留一个不透明指针(指向包含一个类的所有私有成员的结构),
会使一个具体的类能够将其客户程序与其实现绝缘。

一个具体类如果满足下列条件,就是完全绝缘的,
(1)只包含一个数据成员,它表面上是不透明的指针,指向一个定义具体类的实现的non-const struct(定义在.c文件中)
(2)不包含任何其他种类的私有的或保护的成员
(3)不继承任何其他类
(4)不声明任何虚拟的或内联的函数。

小结
下面几种结构被认为可能会潜在的导致不希望的编译时耦合:
(1)继承和分层迫使客户程序看到继承的或嵌入对象的定义
(2)内联函数和私有成员把对象的实现细节暴露给了客户程序
(3)保护成员把保护的细节暴露给了公共的客户程序
(4)编译器产生的函数迫使实现的变化影响声明的接口
(5)包含指令人为的制造了编译时耦合
(6)默认参数把默认值暴露给了客户程序
(7)枚举类型会引起不必要的编译时耦合,因为不合适的放置或不适当的重用。


7. 包


一个包就是被组织成一个物理内聚单位的组件集合。

最小化修改之后的源代码的重编译时间,可以显著的减少开发开销。
从一个定义了main的编译单元中分解出独立可测试和潜在可重用的功能,
本质上能够使程序的整个实现在一个更大型的程序中重用。

程序中的每一个非局部静态对象结构,都会潜在的增加启用时间。


8. 构建一个组件


9. 设计一个函数


10. 实现一个对象

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant