软件构造知识点总结 - 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()时,如果listnull,那么就会报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语句

  • 使用trycatch关键字可以捕获异常。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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Calculator {
public int evaluate(String expression) {
int sum = 0;
for (String summand: expression.split("\\+"))
sum += Integer.valueOf(summand);
return sum;
}
}

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class CalculatorTest {
@Test
public void evaluatesExpression() {
Calculator calculator = new Calculator();
int sum = calculator.evaluate("1+2+3");
assertEquals(6, sum);
}
}

img