软件构造知识点总结 - 4
第七章
第一节 健壮性和正确性的区别
健壮性(Robustness)和正确性(correctness)
健壮性
- 定义:系统在不 正常输入或不正常外部环境下仍能够表现正常的程度
- 面向健壮性编程:
- 处理未期望的行为和错误终止
- 即使终止执行,也要准确/无歧义的向用户展示全面的错误信息
- 错误信息有助于进行debug
- 健壮性原则:
- Paranoia (偏执狂):总是假定用户恶意、假定自己的代码可能失败
- 把用户想象成白痴,可能输入任何东西(返回给用户的错误提示信息要详细、准确、无歧义)
- 对别人宽容点,对自己狠一点(对自己的代码要保守,对用户的行为要开放)
- 面向健壮性编程的原则:
- 封闭实现细节,限定用户的恶意行为
- 考虑极端情况,没有“不可能”
正确性
- 含义:程序按照spec加以执行的能力,是最重要的质量指标。
- 对比健壮性和正确性:
- 正确性:永不给用户错误的结果; 让开发者变得更容易:用户输入错误,直接结束(不满足precondition调用)。
- 健壮性:尽可能保持软件运行而不是总是退出; 让用户变得更容易:出错也可以容忍,程序内部已有容错机制。
- 正确性倾向于直接报错(error),健壮性则倾向于容错(fault-tolerance);
- 对外的接口,倾向于健壮性;对内的实现,倾向于正确性。
- Reliability(可靠性) = Robustness + correctness
Problem | 健壮性 | 正确性 |
---|---|---|
浏览器发出包含空格的URL | 剥离空白,正常处理请求。 | 将HTTP 400错误请求错误状态返回给客户端。 |
视频文件有坏帧 | 跳过腐败区域到下一个可播放部分。 | 停止播放,引发“损坏的视频文件”错误 |
配置文件使用了非法字符 | 在内部识别最常见的评论前缀,忽略它们。 | 终止启动时出现“配置错误”错误 |
奇怪格式的日期输入 | 尝试针对多种不同的日期格式解析字符串。将正确的格式呈现给用户。 | 日期错误无效 |
如何测量健壮性和正确性
- 外部观察角度:
- Mean time between failures (MTBF,平均失效间隔时间):描述了可修复系统的两次故障之间的预期时间,而平均故障时间(MTTF)表示不可修复系统的预期故障时间。
- 内部观察角度:
- 残余缺陷率:每千行代码中遗留的bug的数量
第二节 错误与异常处理
Java中的错误和异常
Throwable
- Java.lang.throwable
- Throwable 类是 Java 语言中所有错误或异常的超类。
- 继承的类:extends Object。
- 实现的接口:implements Serializable。
- 直接已知子类:Error, Exception(直接已知子类:IOException、RuntimeException)。
Error
- Error类描述很少发生的Java运行时系统内部的系统错误和资源耗尽情况(例如,VirtualMachineError,LinkageError)。
- 对于内部错误:程序员通常无能为力,一旦发生,想办法让程序优雅的结束
- Error的类型:
- 用户输入错误
- 例如:用户要求连接到语法错误的URL,网络层会投诉。
- 设备错误
- 硬件并不总是做你想做的。
- 输出器被关闭
- 物理限制
- 磁盘可以填满
- 可能耗尽了可用内存
- 用户输入错误
异常(Exception)
- 异常:程序执行中的非正常事件,程序无法再按预想的流程执行。
- 异常处理:
- 将错误信息传递给上层调用者,并报告“案发现场”的信息。
return
之外的第二种退出途径:若找不到异常处理程序,整个系统完全退出
【异常按结构层次的分类】
- 运行时异常:由程序员处理不当造成,如空指针、数组越界、类型转换
- 其他异常:程序员无法完全控制的外在问题所导致的,通常为IOE异常,即找不到文件路径等
【异常按处理机制角度的分类】
为什么区分checked 和 unchecked:原因其实很简单,编译器将检查你是否为所有的已检查异常提供了异常处理机制,比如说我们使用
Class.forName()
来查找给定的字符串的class
对象的时候,如果没有为这个方法提供异常处理,编译是无法通过的。Checked exception:
- 编译器可帮助检查你的程序是否已抛出或处理了可能的异常
- 异常的向上抛出机制进行处理,如果子类可能产生A异常,那么在父类中也必须
throws A
异常。可能导致的问题:代码效率低,耦合度过高。 - checked exception是需要强制catch的异常,你在调用这个方法的时候,你如果不catch这个异常,那么编译器就会报错,比如说我们读写文件的时候会
catch IOException
,执行数据库操作会有SQLException
等。 - 对checked Exception处理机制
- 抛出:声明是
throws
,抛出时throw
- 捕获(
try
/catch
):try
出现异常,忽略后面代码直接进入catch
;无异常不进入catch
;若catch
中没有匹配的异常处理,程序退出;若子类重写了父类方法,父类方法没有抛出异常,子类应自己处理全部异常而不再传播;子类从父类继承的方法不能增加或更改异常 - 处理:不能代替简单的测试,尽量苛刻、不过分细化、将正常处理与异常处理分开、利用好层次结构、早抛出晚捕获、避免不必要的检查
- 清理现场、释放资源(
finally
):finally
中语句不论有无异常都执行
- 抛出:声明是
unchecked exception:
- 程序猿对此不做任何事情,不得不重写你的代码(不需要在编译时使用
try-catch
等机制处理) - 这类异常都是
RuntimeException
的子类,它们不能通过client code来试图解决 - 这种异常不是必须需要
catch
的,你是无法预料的,比如说在调用一个list.size()
时,如果list
为null
,那么就会报NullPointerException
,而这个异常就是RuntimeException
,也就是UncheckedException
- 常见的unchecked exception:JVM抛出,如空指针、数组越界、数据格式、不合法的参数、不合法的状态、找不到类等
- 程序猿对此不做任何事情,不得不重写你的代码(不需要在编译时使用
checked和unchecked总结
- 当要决定是采用checked exception还是Unchecked exception的时候,问一个问题: “如果这种异常一旦抛出,client会做怎样的补救?”
- 如果客户端可以通过其他的方法恢复异常,那么采用checked exception;
- 如果客户端对出现的这种异常无能为力,那么采用unchecked exception;
- 异常出现的时候,要做一些试图恢复它的动作而不要仅仅的打印它的信息。
- 尽量使用unchecked exception来处理编程错误:因为uncheckedexception不用使客户端代码显式的处理它们,它们自己会在出现的地方挂起程序并打印出异常信息。
- 如果client端对某种异常无能为力,可以把它转变为一个unchecked exception,程序被挂起并返回客户端异常信息
Checked exception应该让客户端从中得到丰富的信息。
要想让代码更加易读,倾向于用unchecked exception来处理程序中的错误
checked异常的处理机制
异常中的LSP原则
- 如果子类型中
override
了父类型中的函数,那么子类型中方法抛出的异常不能比父类型抛出的异常类型更广泛 - 子类型方法可以抛出更具体的异常,也可以不抛出任何异常
- 如果父类型的方法未抛出异常,那么子类型的方法也不能抛出异常。
- 其他的参考第五章第二节的LSP
利用throws
进行声明
- 使用
throws
声明异常:此时需要告知你的client需要处理这些异常,如果client没有handler来处理被抛出的checked exception,程序就终止执行。 - 程序员必须在方法的spec中明确写清本方法会抛出的所有checked exception,以便于调用该方法的client加以处理
- 在使用throws时,方法要在定义和spec中明确声明所抛出的全部checked exception,没有抛出checked异常,编译出错,Unchecked异常和Error可以不用处理。
利用throw
抛出一个异常
- 步骤:
- 找到一个能表达错误的
Exception
类/或者构造一个新的Exception
类 - 构造
Exception
类的实例,将错误信息写入 - 抛出它
- 找到一个能表达错误的
- 一旦抛出异常,方法不会再将控制权返回给调用它的client,因此也无需考虑返回错误代码
try-catch
语句
- 使用
try
和catch
关键字可以捕获异常。try
/catch
代码块放在异常可能发生的地方。 try
/catch
代码块中的代码称为保护代码,catch
语句包含要捕获异常类型的声明。当保护代码块中发生一个异常时,try
后面的catch
块就会被检查。- 如果发生的异常包含在
catch
块中,异常会被传递到该catch
块,这和传递一个参数到方法是一样。
finally
语句
- 场景:当异常抛出时,方法中正常执行的代码被终止;但如果异常发生前曾申请过某些资源,那么异常发生后这些资源要被恰当的清理,所以需要用
finally
语句。 finally
关键字用来创建在 try 代码块后面执行的代码块。- 无论是否发生异常,
finally
代码块中的代码总会被执行。 - 在
finally
代码块中,可以运行清理类型等收尾善后性质的语句。 finally
代码块出现在catch
代码块最后:- 注意下面事项:
catch
不能独立于try
存在。- 在
try
/catch
后面添加finally
块并非强制性要求的。 try
代码后不能既没catch
块也没finally
块。try, catch, finally
块之间不能添加任何代码。
自定义异常
- 如果JDK提供的exception类无法充分描述你的程序发生的错误,可以创建自己的异常类。
- 如果希望写一个检查性异常类,则需要继承
Exception
类。 - 如果你想写一个运行时异常类,那么需要继承
RuntimeException
类。
- 如果希望写一个检查性异常类,则需要继承
第三节 断言和防御性编程
断言
什么是断言
- 作用:允许程序在运行时检查自己,测试有关程序逻辑的假设,如前置条件、后置条件、内部不变量、表示不变量、控制流不变量等
- 目的: 为了在开发阶段调试程序、尽快避免错误
- 使用阶段:
- 断言主要用于开发阶段,避免引入和帮助发现bug
- 实际运行阶段, 不再使用断言
- 软件发布阶段,禁用断言避免影响性能。
应用场景
- 输入参数或输出参数的取值处于预期范围
- 子程序开始执行(结束)时,文件或流处于打开(关闭)状态
- 子程序开始执行(结束)时,文件或流的读写位置处于开头(结尾)
- 文件或流已打开
- 输入变量的值没有被子程序修改
- 指针非空
- 传入子程序的数组至少能容纳X个元素
- 表已初始化,存储着真实的数据
- 子程序开始(结束)时,容器空(满)
- 一个高度优化过的子程序与一个缓慢的子程序,结果一致
- 断言只在开发阶段被编译到目标代码中,而在生成代码时不编译进去。使用断言的指导建议:
- 用错误处理代码来处理预期会发生的状况,断言不行
- 避免把需要执行的代码放入断言中(如果未编译断言呢?)
- 用断言来注解并验证前条件和后条件
- 对于高健壮性的代码,应该先用断言,再处理错误
注意
- 编译时加入-ea(enable assertion)选项运行断言,-da(disable assertion)关闭断言
- 条件语句或开关没有涵盖所有可能的情况,最好使用断言来阻止非法事件
- 可以在预计正常情况下程序不会到达的地方放置断言:
assert false
- 断言有代价,需慎用,一般用于验证正确性,处理绝不应该发生的情况
- 不能作为公共方法的检查,也不能有边界效应
断言和异常的对比
- 用异常处理技术来处理你“希望发生”的不正常情况
- 用断言来处理“不希望发生”的情况;断言的方式处理一定是发生了错误
- 不要把业务逻辑(执行代码)放到断言里面去处理
- 参数检查通常是方法发布的规范(或契约)的一部分,无论断言是启用还是禁用,都必须遵守这些规范。
- 如果参数来自于外部(不受自己控制),使用异常处理
- 如果来自于自己所写的其他代码,可以使用断言来帮助发现错误(例如postcondition就需要)
第四节 调试
什么是bug
- bug即程序中的错误,导致程序以非预期或未预料到的方式执行。
- 一个包含大量bug和/或严重干扰其功能的bug的程序被称为buggy。
- 报告程序中的bug通常被称为bug报告、故障报告、问题报告、故障报告、缺陷报告等
bug产生的原因
- 代码错误
- 未完成的要求或不够详细
- 误解用户需求
- 设计文档中的逻辑错误
- 缺乏文件
- 没有足够的测试
bug的常见类型
- 数学bug:例如 零除法,算术溢出
- 逻辑bug:例如 无线循环和无限递归
- 源头bug:例如 使用了为被定义的变量、资源泄漏,其中有限的系统资源如内存或文件句柄通过重复分配耗尽而不释放。缓冲区溢出,其中程序试图将数据存储在分配存储的末尾。
- 团队工程bug:例如 评论过时或者评论错误、文件与实际产品的区别
调试的基本过程
- Debug是测试的后续步骤:测试发现问题,debug消除问题;当防御式编程和测试都无法挡住bug时,我们就必须进行debug了;
- Debug的目的:寻求错误的根源并消除它;(Debug占用了大量的时间)
调试的过程
- 常用方法:假设-检验
- 重现(Reproduce)$\to$诊断(Diagnose/Locating)$\to$修复(Fix)$\to$反思(Reflect)
- 重现(Reproduce):寻找一个可靠、方便得在线需求问题的方法。
- 从最小的测试用例开始复现错误(保持复现bug的前提下降低输入规模)
- 消除因版本、环境、配置等不同引起的差异(通过构建软件实现),确定bug出现的环境(通过程序模拟硬件平台的细节,实现不同的操作系统环境)
- 利用逆向设计推断导致错误的输入
- 若无法重现,则无法观察以证明分析和修补的正确性
- 诊断(Diagnose/Locating):构建假设,并通过执行实验来测试它们,直到您确信已识别错误的根本原因。
- 从假设开始,构造实验,证明它是对的或者错的
- 从不符合理论的观察结果开始,修正理论
- 查看导致错误的测试输入,以及错误的结果,失败的断言以及由此导致的堆栈跟踪
- 提出一个与所有数据一致的假设,说明错误发生的位置或错误发生的位置,设计实验测试假设
- 收集实验数据,减少错误可能出现的范围,做出新的假设
- 设计不同的实验:检查内部状态、修改运行方式、改变本身逻辑
- 每次只做一个修改、做好记录、不忽略细节、运行不同的测试用例、设置断点、用可实现相同功能并且被证实无问题的组件替代当前组件
- 修复(Fix):设计和实施解决问题的变化,避免引入回归,并保持或提高软件的整体质量。
- 确保从干净的源代码树开始
- 运行现有的测试,并证明它们通过
- 添加一个或多个新测试,或修复现有测试,以演示错误
- 修复错误、发现可改进之处
- 证明你的修复工作正常且没有引入回归(以前通过的测试现在失败)
- 如果引入回归,通过回顾以前的版本来找出确切的变化
- 反思(Reflect):思考需求、设计、测试、结构(库、编译器等)
调试的技术和工具
调试技术
- 暴力调试(Brute Force Attack)
- 蛮力方法可以分为至少三类:
- 看内存导出文件
- 根据“在整个程序中分散打印语句”的常见建议进行调试。
- 自动化调试工具
- 蛮力方法可以分为至少三类:
- 递归调试(Induction)
- 演绎调试(Decution)
- 回溯调试(Backtracking)
- 测试调试(Testing)
调试工具
- 语法和逻辑检查(本课程未涵盖)
- 源代码比较器(Source-code comparator)
- 内存堆转储(Memory heap dump)
- 打印调试/日志记录(Print debugging / logging)
- 堆栈跟踪(Stack trace)
- 编译器警告消息(Compiler Warning Messages)
- 调试器(Debugger)
- 执行分析器(Execution Profiler)
- 测试框架(Test Framework)
第五节 测试与测试优先编程
测试和测试优先编程
测试的定义
- 测试:发现程序中的错误 提高程序正确性的信心
- 程序正确确认的基本方法:
- 形式化推理
- 代码评审
- 测试
- 测试是提高软件质量的重要手段
- 确认是否可达到可用的级别
- 关注系统某一侧面的质量特性
- 是否满足需求
- 是否正确响应所有需求
- 性能是否可接受
- 是否可用
- 可否正确部署安装
- 是否达到期望
测试的分类
- 单元测试
- 集成测试
- 系统测试
- 回归测试
- 验收测试
黑盒测试
白盒测试:对程序内部代码结构的测试 只关注代码内部的问题
黑盒测试:对程序外部表现出来的行为的测试 采用两个方法
等价划分
将程序可能的输入进行分类 划分为不同集合 包括不合法数据
- 等价类划分可有两种不同的情况:有效等价类和无效等价类。
- 若一组对象自反、对称、传递,则为等价类
- 可产生相似结果的输入集合中的一个可代替整个集合
- 同理,对输出也可以划分等价类
- 极端:每个分区只有一个测试用例,覆盖所有分区
边界值分析方法
边界值分析法是对输入输出的边界值进行测试一种黑盒测试方法,是对等价类分析法的补充。
- 错误通常隐藏在边界中,如一位偏移、边界值需单独处理等
- 找到有效数据和无效数据的分界点(最大值、最小值),对该分界点以及两边的值分别单独进行测试。
- 等价类划分法可以挑选等价类范围内任意一个数据作为代表,而边界值分析法要求每个边界值都要作为测试条件。
测试困难
- 软件行为在离散输入空间中差异巨大
- 大多数正确,少数错误
- bug出现不遵循特定概率分布
- 无统计规律可循
- 软件行为在离散输入空间中差异巨大
代码覆盖度
- 定义:已有的测试用例有多大程度覆盖了被测程序;
- 代码覆盖度越低,测试越不充分;但要做到很高的代码覆盖度,需要更多的测试用例,测试代价高;
- 代码覆盖率高的程序在测试期间执行了更多的源代码,与低代码覆盖率的程序相比,包含未检测到的软件错误的可能性较低
- 基本覆盖标准:函数覆盖;语句覆盖、分支覆盖、条件覆盖、路径覆盖
- 测试效果:路径 > 分支 > 语句
- 测试难度:路径 > 分支 > 语句
以注释的形式撰写测试策略
- “测试策略”通俗来讲就是6个字:“测什么”和“怎么测”。测试策略非常重要,需要在程序中显性记录下来。
- 目的:在代码评审过程中,其他人能够理解你的测试,并评判测试是否充分
- 在测试类的顶端写策略
- 在每个测试方法前说明测试用例是如何选择的
JUnit 测试用例写法
- JUnit单元测试是依据 注释中
@Test
之前的方法编写的 - JUnit测试经常调用多次方法,使用
assertEqual || assertTrue || assertFalse
来检查结果 @Before
:准备测试、完成初始化,每个测试方法前执行一次@After
:清理现场,每个测试方法后执行一次@Test
:表明测试方法,内含assert语句- 第一个参数是预期结果、第二个参数实施及结果;
- 如果断言失败,该测试方法直接返回,JUnit记录该测试的失败;
- 一个测试方法失败,其他测试方法仍运行
@Test(expected = *.class)
:对错误的测试,expected的属性值是一个异常@Test(timeout = xxx)
:测试方法在制定的时间之内没有运行完则失败
@ignore
:忽略测试方法- examples
1 | public class Calculator { |