软件构件中的设计
设计中的挑战
软件设计就是把需求分析和编码调试连在一起的活动。
设计是一个险恶的问题
险恶的问题就是只有通过解决或部分解决才能被明确的问题。只有把问题解决一遍才能明确的定义它,然后再次解决该问题,形成可行的方案。就如同设计大桥时值考虑了是否结实,没考虑到风的影响,最终风让大桥坍塌。
设计是一个了章法的过程
形成设计的过程会采取很多错误的步奏,多次误入歧途。犯错正是设计的关键所在。设计过程犯错并改正比编码后才发现代价要小得多。设计的优劣差异微妙。很难判断设计何时才算足够好了,永无止尽。
设计就是确定取舍和调整顺序的过程
设计的关键内容就是去衡量彼此冲突的各项设计特性,并寻找平衡。
设计受到诸多限制
一部分在创造可能发生的事情,另一部分在限制可能发生的事情。
设计是不确定的
设计同一套程序可能有很多种做法,每种都很不错。
设计是启发式过程
设计具有探索性,不能保证能产生可重复性结果。总有试验和犯错误。没有通用的设计或技术
设计是自然而然形成的
设计不是在谁的头脑中直接跳出来的,是在不断的设计评估、非正式讨论、写实验代码以及修改试验代码中演化和完善的。
关键的设计概念
软件的首要技术使命:管理复杂度
本质的问题和偶然的问题让软件开发变得困难。本质属性是指事物必须具备,不具备就不是该事物的属性,如汽车的轮子、车门和引擎。偶然属性是事物碰巧具有的属性。如汽车的v8发动机或是涡轮增压四缸发动机。
本质困难的根源在于复杂性。项目由于技术因素导致失败时,原因通常是失控的复杂度。我们不应该试着在同一时间把整个程序塞入大脑,应该去组织程序,使得可以在一个时刻专注于一个特定的部分,从而介绍任一时间内考虑的程序量。把整个系统分解为多个子系统来降低问题的复杂度。子系统的互相依赖越少,越容易专注问题。
高代价低效率的设计源于下面三种根源:用复杂的方法解决简单的问题、用简单但错误的方法解决复杂的问题、用不恰当的复杂方法解决复杂的问题。
用两种方法管理复杂度。把任何人在同一时间需要处理的本质复杂度减到最少、不要让偶然性复杂度无谓的快速增长。
理想的设计特征
高质量的设计的设计范畴特征
- 最小复杂度,做出简单而易于理解的设计
- 易于维护,为做维护工作的程序员着想
- 松散耦合, 让程序的各个组成部分之间关联最小。通过类接口的合理抽象,封装性和信息隐藏等原则,设计出相互关联最少的类。
- 可扩展性,改动系统的某一部分而不影响其他。
- 可重用性
- 高扇入,让大量的类使用某个给定的类。设计的系统很好的利用了较低层次的工具类。
- 低扇出,让一个类少量或适中的使用其他的类。
- 可移植性
- 精简性
- 层次性,设计出来的系统应该能在任意层次观察而不需要进去其他层次
- 标准技术
设计的层次
- 第一层、软件系统
往往从第二层开始思考比较有益。 - 第二层、子系统或者包。
识别出所有的主要子系统。例如数据库、用户界面、业务规则、命令解释器、报表引擎。任务有确定如何把程序分为主要子系统,定义清楚允许子系统如何使用其他子系统。
应该限制子系统之间的通信,使得替换或者去掉子系统时,带来的影响最小。尽量简化子系统之间的交互关系。最简单的是调用其他子系统的子程序,接着是子系统包含另一个子系统的类,最复杂的是继承另一个子系统的类。 - 第三层、分解为类
识别出系统中所有的类。同时定义类与系统其余部分打交道的细节,尤其是接口。 - 第四层、分解成子程序
把类细分为子程序。 - 第五层、子程序内部的设计
编写伪代码、选择算法、组织代码块,用编程语言编写代码。
设计构造块:启发式方法
找出现实世界中的对象
使用对象进行设计的步奏
- 辨识对象及其属性(方法、数据)
- 确定可以对各个对象进行的操作
- 确定各个对象能对其他对象进行的操作
- 确定对象的哪些部分对其他对象可见
- 定义对象的公开接口
形成一致的抽象
抽象是能让你在关注某一概念的同时忽略细节的能力-在不同的层次处理不同的细节。好的接口是一种抽象,能让你关注接口而不是类的内部工作方式。抽象是处理复杂度的主要手段。
封装实现细节
封装就是不让你看到对象的其他细节层次。
当继承能简化设计时就继承
继承能辅佐抽象的概念、简化编程的工作。
隐藏秘密
设计类时,一个关键的决策就是确定类的哪些特性应该对外可见,哪些应该隐藏起来。类的接口应该尽可能的少暴露内部工作机制。
秘密主要有两种,隐藏复杂度和隐藏变化源。
信息隐藏的障碍
信息过度分散
用命名常量代替到处使用的数字。
循环依赖
把类的内部数据误认为全局数据
全局数据的问题:其他子程序可能也在用这些数据进行操作、不知道其他子程序做了什么操作。而类内数据只有类内部的子程序才能直接访问。性能损耗
找出容易改变的区域
- 找出容易改变的项目
- 把容易改变的项目分离出来
- 把看起来容易改变的项目隔离开来
一些容易改变的区域:
业务规则、对硬件的依赖、输入输出、非标准的语言特性、困难的设计区域和构建区域、状态变量、
设计好你的系统,让变化的影响或范围与发生变化的可能性成反比。找出程序中对用户有用的最小子集。
保持松散耦合
尽量让你的模块不依赖或者少依赖其他模块。
耦合标准
- 规模、尽可能少的参数和公用方法
- 可见性、通过参数表传递数据比修改全局数据来连接要好。把与全局数据的连接写入文档,稍微好点。
- 灵活性、连接是否容易改动。一个模块越容易被其他模块调用,耦合关系越松散。
耦合的种类
- 简单数据参数耦合:参数传递数据
- 简单对象耦合:模块实例化对象
- 对象参数耦合:传单的参数为一个对象。
语义上的耦合:使用了模块内部工作细节的语义知识。如:
- 模块一传递了控制标志来告诉模块二应该做什么。
- 模块一修改了全局数据后,模块二再使用该数据。
- 模块二的a程序需要在b程序后调用。模块一只调用了a程序,没有先调用b程序
模块一传递了baseObj给模块二,模块二知道模块一实际上传的是derivedObj,使用了其中特有的方法。
这非常危险,可能会破坏调用的模块。
查阅常用的设计模式
好处
- 提供现成的抽象来减少复杂度
- 把常见解决方案的细节予以制度化来减少出错
- 能提供多种设计方案,带来启发性的价值
- 提升对话层次,简化交流。
其他启发性方法
- 高内聚性
- 分层结构
- 严格描述类契约
- 分配职责
- 为测试而设计
- 避免失误
- 有意识的选择绑定时间
- 创建中央控制点
- 蛮力突破
- 画图
- 模块化
设计实践
迭代
同时从高层和底层看问题。找到足够好的设计方案也不要停下来。继续尝试。
分而治之
把程序分解为不同的关注区域,分别处理。
自上而下与自下而上
自上而下:不断分解问题,直到直接编码比继续分解更容易。可以推迟构建的细节。
自下而上:自上而下有时过于抽象,难以入手。自下而上可以较早找出所需的功能。
考虑因素:
- 对系统需要做的事项,你知道些什么
- 找到具体的对象和职责
- 找出通用对象,组织起来
- 在更上一层工作,或者回到自上而下
建立实验性原形
写出能回答特定设计问题的,量最少的能随时扔掉的代码
合作设计
多少设计才够
影响设计和文档的因素
- 团队的代码经验
- 团队在该领域的经验
- 团队的人员变动
- 程序是安全或者使命相关
- 程序的规模
- 程序的生命周期
记录你的设计成果
- 把设计文档插入代码
- 记录到wiki
- 写总结邮件
- 使用相机
- 保留设计挂图
- 使用crc卡片
- 使用uml图
可以工作的类
类是数据和子程序的集合,他们共同有用一组内聚的、明确定义的职责。
类的基础:抽象数据类型(ADT)
抽象数据类型指的是一些数据及对这些数据进行操作的集合。这些操作既向程序其他部分描述了数据是怎样的,也允许程序其他部分修改这些数据。
需要用到ADT的例子
假设有一个控制字体、字号、文本属性的程序。用ADT就能有捆绑在相关数据上的一组操作字体的子程序,包括字体名称、字号和文字属性。子程序与数据集合为一体就是ADT。
不用ADT,加入设置字体为12磅(points),刚好为16像素,就像 currentFont.size = 16,使用了ADT,就像 currentFont.size = PointsToPixels(12)
使用ADT的益处
- 隐藏实现细节
- 改动不会影响到整个程序
- 让接口提供更多信息,让语句更明确。
- 更容易提高性能,只需要修改子程序,不用来回修改整个程序
- 让程序的正确性更显而易见
- 让程序更具有自我说明性
- 无需在程序内到处传递数据
- 可以像现实世界中那样操作实体,不用在底层实现上操作他。
ADT和类
可以把类当成ADT加上继承与多态
良好的类接口
好的抽象
- 类的接口应该展现一致的抽象层次,一个类应该实现一个ADT,且仅实现一个ADT。
- 一定要理解类所实现的抽象是什么。
- 提供成对的服务,如添加和删除,开灯与关灯。
- 把不相关的信息转移到其他类。如果类中一半子程序用着一半的数据,另一半子程序用着另一半数据,把它们拆开。
- 尽可能让接口可编程,而不是表达语义。可编程的部分由数据组成,可以让编辑器要求和强制。语义部分不行。一个接口中任何无法通过编译器强制实施的部分就是可能被误用的部分。想办法把语义接口元素转换为编程接口元素。
- 谨防在修改时破坏接口的抽象
- 不要添加与接口抽象不一致的公用成员。
- 同时考虑抽象性与内聚性。他们联系很紧密。
良好的封装
抽象让你忽略细节,封装强制阻止你看到细节。
- 尽可能限制类和成员的可访问性。
- 不要公开暴露成员数据
- 避免把私用的实现细节放入类的接口
- 不要对类的使用者做出任何假设
- 不要因为子程序仅仅使用公用子程序,就把它归入公开接口
- 让阅读代码比编写代码更方便
- 格外警惕从语义上破坏封装性
- 留意过于紧密的耦合关系
有关设计和实现的问题
包含(‘有一个’)
- 通过包含来实现‘有一个’的关系。如一个雇员有一个名字,有一个电话号码。
- 在万不得已时通过private继承来实现‘有一个的关系
- 警惕超过七个数据成员的类
继承(‘是一个’)
需要做决策:成员函数应该对派生类可见吗,该函数可以被覆盖吗,数据成员应该对派生类可见吗
- 用public来继承是一个的关系
- 使用继承并详细说明,要么不使用
- 遵循liskov替换原则。派生类必须能通过基类的接口而被使用,且使用者无须了解他们的差异。
- 确保只继承需要继承的部分。子程序根据能否被覆盖与是否提供默认实现分为四种。
- 不要覆盖不可覆盖的成员函数
- 把公用的接口、数据和操作放到继承树尽可能高的位置
- 只有一个实例的类是值得怀疑的
- 只有一个派生类的基类也值得怀疑
- 派生后覆盖了某个子程序却未做任何操作,也值得怀疑。
- 避免继承体系过深。
- 尽量使用多态,避免大量的类型检查
- 让所有数据都是private
何时使用继承
- 多个类共享行为而不是数据
- 基类控制接口时,使用继承,自己控制接口是,使用包含
成员函数和数据成员
- 让子程序数量尽可能少
- 禁止隐式的产生不需要的成员函数和运算符
- 减少类所调用的不同子程序的数量
- 对其他类的子程序的间接调用尽可能少
创建类的原因
- 为现实世界中的对象建模
- 为抽象的对象建模
- 降低复杂度
- 隔离复杂度
- 隐藏实现细节
- 限制变动的影响范围
- 隐藏全局数据
- 让参数传递更顺畅
- 建立中心控制点
- 让代码更易于重用
- 为程序族做计划
- 把相关操作包装在一起
- 实现某种特定的重构
应该避免的类
- 万能类
- 无关紧要的类,如只有数据没有行为的类
- 动词命名的类,只有行为没有数据的类不是真正的类
高质量的子程序
差劲的子程序
- 差劲的名字
- 没有文档
- 没有代码规范和布局
- 输入变量被改变了
- 读写全局变量
- 没有单一的目的
- 没有防范错误数据
- 用了若干魔法数
- 有未使用的参数
- 参数过多,超过七个
- 参数顺序混乱,没有注释
创建子程序的正当理由
- 降低复杂度
- 引入中间、易懂的抽象
- 避免代码重复
- 支持子类化
- 隐藏顺序
- 隐藏指针操作
- 提高可移植性
- 简化复杂的布尔判断
- 方便改善性能
- 确保子程序很小
- 很多跟创建类类似的理由
在子程序层上设计
内聚性
- 功能的内聚性,让子程序仅仅执行一项操作。
- 顺序的内聚性 有按顺序执行的操作,需要共享数据,全部完成后才完成功能
- 通信的内聚性 使用了同样的数据
- 临时的内聚性 因为需要同时执行才放到一起,让start函数。最好让他调用其他的子程序,而不是直接执行操作
不可续的内聚性
- 过程上的内聚性。
- 逻辑上的内聚性,如通过控制标志来执行不同的操作
好的程序名字
- 描述子程序所做的所有事情
- 避免使用无意义的、模糊的或者表述不清的动词
- 根据需要确定子程序名字的长度
- 对返回值有所描述
- 动词加宾语,如printDocument
- 对仗词
- add/remove
- increment/decrement
- open/close
- begin/end
- insert/delete
- show/hide
- create/destroy
- lock/unlock
- source/target
- start/stop
- next/previous
子程序可以写多长
100行到200行,错误较小
如何使用子程序参数
- 按照输入-修改-输出的顺序排列参数
- 几个子程序使用了类似的参数,排序保持一致
- 使用所有的参数
- 把状态和出错变量放最后
- 不把子程序的参数当作工作变量
在接口中对参数的假定加以说明
- 参数时输入、修改还是输出的
- 表示数量的参数的单位
- 状态代码和错误值的含义
- 所能接受的数值的范围
- 数量限制在七个以内
- 确保实际参数与形式参数相匹配
防御式编程
子程序应该不因传入错误数据而被破坏。
保护程序免遭非法输入数据的破坏
三种方法处理进来垃圾的情况
- 检查来源于外部的数据的值。
- 检查子程序所有输入参数的值
- 决定如何处理错误的输入数据
断言
用于让程序在运行时进行自检的代码。
可以用断言检查如下假定
- 输入或输出参数在预期范围
- 子程序开始执行时文件或流的状态和读写位置,打开方式
- 只用于输入的变量的值没有被子程序修改
- 指针非空
- 传入子程序的数值或其他容器至少能容纳x个数数据元素
- 表已初始化,存储着真实数值
- 容器的状态
- 高度优化的复杂子程序与相对缓慢的简单子程序的结果一致
指导建议
- 用错误处理代码来处理预期会发生的状况,用断言来处理绝不应该发生的状况。
- 避免把需要执行的代码放入断言中
- 用断言来注解和验证前条件和后条件
- 先使用断言再处理错误
错误处理技术
- 返回中立值,数值计算返回0,字符串操作返回字符串。
- 换用下一个正确的数据
- 返回与前次相同的数据
- 换用最接近的合法值
- 把警告信息记录到日志文件
- 返回错误码
- 调用错误处理子程序或对象
- 现实出错信息
- 局部处理错误
- 关闭程序
健壮性与正确性
正确性表示不返回不准确的结果。健壮性意味着不断尝试某种措施让软件持续运转下去。
异常
- 用异常通知程序的其他部分,发生了不可忽略的错误
- 只在真正例外的情况下才抛出异常
- 不能用异常来推卸责任
- 避免在构造函数抛出异常
- 在恰当的抽象层次抛出异常
- 在异常消息中加入关于导致异常发生的全部信息
- 避免使用空的catch语句
- 了解函数库可能抛出的异常
- 考虑创建一个集中的异常报告机制
- 把对异常的使用标准化
- 考虑异常的替换方案
隔离程序,使之包容由错误造成的损害
让软件的某些部分处理不干净的数据,其他部分处理干净的数据。把某些接口选定为安全区域的边界,对穿越安全区域边界的数据进行合法性校验。
在输入数据时将其转换为恰当的类型
辅助调试的代码
- 不要把产品版的限制加入到开发版上。
- 尽早引入辅助调试的代码
- 进攻式编程。在开发阶段就让异常显现出来
- 计划移除辅助调试的代码
确定产品代码中该保留多少防御式代码
- 保留那些检查重要错误的代码
- 去掉检查细微错误的代码
- 去掉可以导致程序硬性崩溃的代码
- 保留让程序稳妥奔溃的代码
- 让技术支持人员记录错误信息
- 确认留在代码中的错误消息是有好的
伪代码编程过程
创建类和子程序的步奏概述
创建类的步奏
- 创建类的总体设计,定义类的特定职责,定义类要隐藏的秘密,精确定义类的接口所表示的抽象概念,决定这个类是否从其他类派生,是否允许其他类从它派生。指出类的公用方法,标志并设计重要的数据成员。
- 创建类中的子程序
- 复审并测试整个类
创建子程序的步奏
- 设计子程序
- 检查设计
- 编写代码
- 检查代码
伪代码
指导原则:
- 用类似英语的语句来精确描述特定的操作
- 避免使用目标编程语言中的语法元素
- 在意图的层面上编写伪代码
- 在足够低的层次上编写伪代码
好处:
- 让评审变得容易
- 支持反复迭代优化
- 让变更变得容易
- 减少注释工作量
- 比其他的设计文档容易维护
通过伪代码编写过程创建子程序
设计子程序
- 检查先决条件
- 定义要解决的问题
- 子程序要隐藏的信息
- 子程序的输入
- 子程序的输出
- 调用子程序的前条件
- 子程序将控制权交回时,后条件的成立
- 为子程序命名
- 决定如何测试子程序
- 在标准库中搜寻可用的功能
- 考虑错误处理
- 考虑效率问题
- 研究算法和数据类型
- 编写伪代码
- 检查伪代码
- 在伪代码中试验想法
编写子程序的代码
- 写子程序的声明
- 把伪代码转换成注释
- 在注释下填充代码
- 检查代码是否需要进一步分解
检查代码
- 在脑海里检查
- 编译子程序
- 逐行执行代码
- 测试代码
- 消除程序中的错误
收尾工作
- 检查接口
- 检查设计质量
- 检查变量
- 检查语句与逻辑
- 检查布局
- 检查文档
- 除去冗余的注释
替代方案
- 测试先行开发
- 重构
- 契约式开发
- 东拼西凑