五、软件构建中的设计
5.1 设计中的挑战
设计就是把需求分析和编码调试连在一起的活动。
5.1.1 设计是一个险恶的问题
学校的编程任务是从头到尾直线前进而设计的。而社会中的编程开发需求可能在反复变动。 瀑布模式和敏捷模式本质区别在于承认未来的需求是否会发生变化及发生变化的频率?
5.1.2 设计是一个无章法的过程
你很难判断设计何时算是“足够好”了,设计到什么细节才算够?又有多少设计可以留到编码阶段时再做?
思考设计到自己没时间为止:)
在用UML写设计图时,user case图等实际是一个设计结果,实际设计过程中可以用DDD模型中的用户旅程图、事件风暴、命令风暴来提取现实世界的逻辑关系在进行收敛、固化至相关UML设计图中。
5.2 关键的设计概念
5.2.1 管理复杂度的重要性
软件项目失败的原因很少是技术原因导致的,如果项目确实由技术原因导致时,其原因通常就是失控的复杂度。
5.2.2 理想的设计特征
- 最小的复杂度
- 易于维护
- 松散耦合
- 可扩展性
- 可重用性
- 高扇入:基础类应该尽可能多被使用,避免在高级类中重复造轮子;
- 低扇出:基础类应该尽量少的引用其他类,功能聚焦;
- 可移植性
- 精简性
- 层次性:如果你编写一个新系统用了设计不佳的旧代码,就需要写一个负责新旧系统交互层方便后续的重构;
- 标准技术:使用标准技术,降低团队其他成员的介入成本;
5.2.3 设计层次
- 软件系统
- 分解为子系统或包
- 分解为类
- 分解成子系统
5.3 设计构造块:启发式方法
软件设计是非确定性的,三个人可能有三种设计思路。
5.3.1 找出现实世界中的对象
首选且最流行的一种做法便是面向对象设计方法,此方法的要点是辨识现实世界中的对象以及人造对象。 使用对象进行设计的步骤是:
- 辨识对象及其属性(方法(method)和数据(data));
- 确定可以对各个对象进行的操作;
- 确定各个对象能对其他对象进行的操作;
- 确定对象的哪些部分对其他对象可见哪些不可以(public和private);
- 定义每个对象的公共接口;
5.3.2 形成一致的抽象
抽象是一种能让你在关注某一概念的同时可以放心地忽略其中一些细节的能力--在不同的层次处理不同的细节。你把房子称为房子而不是由玻璃、木材、钉子构成的组合体时,你就是在用抽象了。以复杂度的观点看,抽象的主要好处就在于它使你能忽略无关的细节。
5.3.3 封装实现细节
抽象是你能从高层的细节来看待一个对象,而封装则是除此之外,你不能看到对象的任何其他细节层次。封装帮助你管理复杂度的方法是不让你看到那些复杂度。
5.3.4 当继承能简化设计时就继承
继承的好处是他能很好地辅佐抽象的概念。抽象是从不同层次来看对象的。门从属性上看可以是不同种类构成,如:木头、铁,从功能层面讲可以防盗。木头又有自己的属性,如可以被锯开或者用乳胶粘合。继承能简化编程的工作。
5.3.5 信息隐藏
信息隐藏主要有两大类:隐藏复杂度
和隐藏变化源
。一个例子,所有代码调用如下代码来获得ID:id = ++max_id
,但后续很难对id做加强,最好方式是隐藏
到NewID()
函数中,如果后续对返回类型也可能做修改,返回类型最好也用宏定义做隐藏处理:typdef IDType int
,隐藏设计决策可以减少改动所影响的代码量
。请养成问"我该隐藏些什么"的习惯。
5.3.6 找出容易改变的区域
- 找出看起来容易变化的项目:如果需求做得好,那么其中就应该包含一份潜在变化清单,以及每一项变化的可能性;可以通过找程序中对用户最有用的最小子集来构成系统核心,然后用微小的步伐迭代扩充这个系统。
- 把容易变化的项目分离出来
- 把看起来容易变化的项目隔离开来
- 业务规则:业务规则很容易成为软件频繁变化的根源,所以最好使用"信息隐藏"原则隐藏相关信息;
- 输入和输出
- 非标准的语言特性
- 困难的设计区域和构建区域
- 状态变量
- 数据量的限制
5.3.7 保持松散耦合
尽量使你创建的模块不依赖或者少依赖其他模块。
5.3.7.1 耦合标准
- 规模:函数中参数个数及对外的可见公用方法;
- 可见性:通过参数表显性传递模块间的调用关系;
- 灵活性:模块更容易被其他模块调用,那么他们之间的耦合关系就会越松散;
5.3.7.2 耦合的种类
- 简单数据参数耦合
- 简单对象耦合
- 对象参数耦合:封装和信息隐藏不够好;
- 语义上的耦合:模块间知道彼此间的执行逻辑;
5.3.8 查阅常用的设计模式
设计模式通过把常见解决方案的细节予以制度化来减少出错,常用设计模式。
5.4 设计实践
自上而下的策略和自下而上策略的最关键区别在于前者时一种分解策略而后者是一种合成策略。前者从一般性的问题出发,把该问题分解成可控的部分。后者从可控的部分出发,去构造一个通用的方案。
最大的设计失误来自于我误认为自己已经做得很充分,可事后却发现还是做得不够,没能发现其他一些设计挑战。
有些项目因太过于专注对设计进行文档化而导致失败。(劣币驱逐良币原则)
传统的记录设计成果的方式是把它写成正式的设计文档。然而,你还可以用很多种方法来记录这一成果,而这些方法对于那些小型的、非正式的项目或者只需要轻量级的记录设计成果的方式的项目而言效果都不错:
- 把设计文档插入到代码里;
- 用Wiki来记录设计讨论和决策;
- 写总结邮件;
八、防御式编程
8.3 错误处理技术
8.3.1 健壮性与正确性
人身安全攸关的软件往往更倾向于正确性而非健壮性,消费类应用软件往往更注重健壮性而非正确性。
二十四、重构
24.1 软件演化的类型
软件演化类型就像生物进化一样,有些突变对物种是有益的,另外一些则是有害的。区分软件演化类型的关键就是程序的质量在这一过程中是提高了还是降低了。另外软件演化在开发阶段还是维护阶段的演化表现也有差别,在开发阶段的演化更自由。
24.2 重构简介
在不改变软件外部行为的前提下对其内部结构进行改变,使之更容易理解并便于修改。
24.2.1 重构的理由
- **代码重复:DRY原则;**W
- 冗长的子程序
- 循环过长或嵌套过深
- 内聚性太差的类:如果某类包揽了许多彼此无关的任务,那么这个类可以拆分成多个类;
- 类的接口未能提供层次一致的抽象;
- 拥有太多参数的参数列表;
- 类的内部修改往往被局限于某个部分:如果你仅修改类中的一部分那么该类应该讲相互独立的功能被拆分成多个类;
- 变化导致对多个类的相同修改:如果发现自己常常对一组类进行修改,这表明这些类中的代码应当被重新组织,使修改仅影响到其中的一个类;
- 对继承体系的同样修改;
- case语句需要做相同的修改;
- 同时使用的相关数据并未以类的方式进行组织;
- 成员函数使用其他类的特征比使用自身类的特征还要多;
- 过多使用基础数据类型;
- 某个类无所事事:如果代码重构导致某个类无所事事,需要确认此类是否可以彻底去掉;
- 一系列传递流浪数据的子程序;
- 中间人对象无事可做:类中的绝大部分代码只是去调用其他类中的成员函数,需要确认是否可以去掉此类直接改为直接调用其他的类;
- 某个类同其他类关系过于亲密:如果一个类对另外类的了解超过了应该的程度那么就需要进行更强的封装;
- 子程序命名不当;
- 数据成员被设置为公用;
- 某个类仅使用了基类的很少一部分成员函数:这时可以考虑将派生类相对于基类的关系从“is-a”转变为“has-a”;
- 注释被用于解释难懂的代码;
- 使用了全局变量;
- 子程序调用前使用了设置代码(setup code);
- 程序中的一些代码似乎是在将来的某个时候才会用到的;
24.3 特定的重构
24.3.1 数据集的重构
- 用具名常量替代神秘数值;
- 使变量的名字更为清晰且传递更多信息;
- 将表达式内联化;
- 用函数来代替表达式;
- 引入中间变量;
- 用多个单一用途变量代替某个多用途变量;
- 在局部用途中使用局部变量而不是参数;
- 将基础数据类型转换为类;
- 将一组类型码转化为类或枚举类型;
- 将一组类型码转换为一个基类及其相应派生类;
- 将数组转换为对象;
- 把群集封装起来;
- 用数据类代替传统记录;
24.3.2 语句级的重构
- 分解布尔表达式;
- 将复杂布尔表达式转换成命名准确的布尔函数;
- 合并条件语句不同部分中的重复代码片段;
- 使用break或return而不是循环控制变量;
- 在嵌套的if-then-else语句中一旦知道答案就立即返回,而不是去赋一个返回值;
- 用多态来替代条件语句(尤其是重复的case语句);
- 创建和使用null对象而不是去检测空值;
24.3.3 子程序级重构
- 提取子程序或方法;
- 将子程序的代码内联化;
- 将冗长的子程序转换为类;
- 用简单算法替代复杂算法;
- 增加参数;
- 删除参数;
- 将查询操作从修改操作中独立出来;
- 合并相似的子程序,通过参数区分它们的功能;
- 将行为取决于参数的子程序拆分开来;
- 传递整个对象而非特定成员;
- 传递特定成员而非整个对象;
- 包装向下转型的操作;
24.3.4 类实现的重构
- 将值对象转换为引用对象;
- 将引用对象转换为值对象;
- 用数据初始化替代虚函数;
- 改变成员函数或成员数据的位置;
24.3.5 类接口的重构
- 将成员函数放到另一个类中;
- 将一个类变成两个;
- 删除类;
- 去除委托关系;
- 去掉中间人;
- 用委托代替继承;
- 用继承代替委托;
- 引入外部的成员函数;
- 引入扩展类;
- 对暴露在外的成员变量进行封装;
- 对于不能修改的类成员,删除相关的Set()成员函数;
- 隐藏那些不会在类之外被用到的成员函数;
- 封装不适用的成员函数;
- 合并那些实现非常类似的基类和派生类;
24.3.6 系统级重构
- 为无法控制的数据创建明确的索引源;
- 将单向的类联系改为双向的类联系;
- 用Factory Method模式而不是简单地构造函数;
- 用异常取代错误处理代码,或者做相反的变换;
24.4 安全的重构
- 保存初始代码;
- 重构的步伐请小些;
- 同一时间只做一项重构;
- 把要做的事情一条条列出来;
- 设置一个停车场;
- 多使用检查点;
- 利用编译器警告信息;
- 重新测试;
- 增加测试用例;
- 检查对代码的修改;
- 根据重构风险级别来调整重构方法;
24.5 重构策略
- 在增加子程序时进行重构;
- 在添加类的时候进行重构;
- 在修补缺陷的时候进行重构;
- 关注易于出错的模块;
- 关注高度复杂的模块;
- 在维护环境下,改善你手中正在处理的代码;
- 定义清楚干净代码和拙劣代码之间的边界,然后尝试把代码移过这条边界;
三十四、软件工艺的话题
34.7 当心落石
设计度量也可作为一种警告。多数设计度量都对设计质量颇有启发性:成员多于七个的类并不一定就意味着设计不好,但能说明类有些复杂。任何警告信息都应让你质疑程序的质量。
如果发现自己的代码有重复,或者在若干做的修改很相似,你也应该觉得“不自在和不惬意”,而去质疑子程序或类中的控制是否得当。
编译警告是文字警告,易被忽视。如果程序出现警告或错误,决不能对其睁一只眼闭一只眼。连明明白白的“warning”都视而不见,你就不大可能注意到其他细微的错误。
34.8 迭代,反反复复,一次又一次
软件设计是一个逐步精化的过程,和其他类似过程一样,需要经过反复修正和改进。软件往往要通过实证而不是证明,这意味着它就得反复测试和开发,直至能解决问题为止。
迭代法对代码调整同样有益。一旦软件能够工作,对少部分代码精雕细琢就能显著改善整个系统的性能。
34.9 汝当分离软件与信仰
34.9.1 软件先知
一些专业优秀人员往往更容易偏执。革新方法需要公开,才能让别人尝试。尝试这些方法后才能证实或反驳之。
34.9.2 折中主义
要对编程问题找出最有效的解决方案时,盲目迷信某种方法只会缩小你的选择余地。要是软件开发是确定的精确过程,就能按固定的套路解决问题;但软件开发并非确定过程,是需要逐步细化的,因而生硬的过程是不合适的,很难指望会成功。如果没有充分了解问题就定下解决方法,说明你还不够成熟可以参考切斯特顿栅栏原则 Chesterton's_fence。受限于所坚持的思路,你很可能与最有效的方法失之交臂。
刚开始结束任何新方法时,人们会感到不自在。而建议你不要有编程“信仰”,并不是说用新方法解决问题遇到麻烦时就马上停用新方法。对新方法有个合适的定位,但同样也要对老方法有合适定位。
多数时候工具的选择关系不大,你可以选择老虎钳或者尖嘴钳。但有些场合,工具选择至关重要,故而要仔细做出取舍。工程学的的规则之一就是权衡各种技术。如果早早将自己的选择限制在某一工具上,就无法做出权衡。
34.9.3 试验
试验应贯穿于整个开发过程,但顽固会妨碍你这么做。要想有效地试验,应能基于试验结果改变思路;否则试验只会白白浪费时间。
软件开发中许多顽固的方法源于对错误的畏惧心理。“试图没有错误”是最大的错误。设计正是仔细地规划小错误避免大错误的过程。
参考书籍
代码大全(第2版)
High fan-in low fan-out