软件构建(六)——语句

本文开始从以数据为中心的编程观点转到以语句为中心的观点上。常用的编程语句有顺序、控制和循环三种基本结构,本文将依次介绍这三种基本结构的使用原则。最后,还总结了一些不太常见的控制结构,如子程序多处返回、递归、关于goto语句的讨论。

1. 顺序语句

下面分别举了一个有前后明确的运行顺序的没有明显顺序的例子:

// 有前后依赖关系的语句
data = ReadData();
results = CalculateResultsFromData(data);
PrintResults(results);

// 不太明显的前后依赖关系的语句
revenue.ComputeMonthly();
revenue.ComputeQuarterly();
revenue.ComputeAnnual();

如果语句之间存在依赖关系,并且这些关系要求你把语句按照一定的顺序加以排列,那么应设法使得这些依赖关系变得明显。下面是一些用于组织语句的简单原则。

  • 设法组织代码,使依赖关系变得非常明显
  • 使子程序名能突显依赖关系
  • 利用子程序参数明确显示依赖关系
  • 用注释对不清晰的依赖关系进行说明:首先要尽力写没有顺序依赖关系的代码
  • 用断言或者错误处理代码来检查依赖关系

如果语句没有明显依赖关系,那也应该按一定原则来排列语句,使得其可读性更好,其中的指导原则就是就近原则:把相关的操作放在一起。要让程序易于自上而下阅读,而不是让读者的目光跳来跳去。 此外,还要把相关的语句组织在一起,一种检验的方法是,打印出要检验的程序代码(可以输出图片),然后把相关的语句画上框,如果方框彼此不交叠则相关语句组织得好。

2. 条件语句

2.1 if-else语句

  • 首先写正常代码路径,再处理不常见情况
  • 确保对于等量的分支是正确的:仔细考虑“>”“<”和“>=”“<=”的逻辑
  • 把正常情况的处理放在if后面而不要放在else后面
  • 不要在if分支执行空语句
  • 考虑else分支:考虑是否真的仅仅只需要一个if分支就可满足,除非原因显而易见,否则也应考虑用注释来解释空else分支是没有必要的

2.2 switch-case语句

  • 为case选择最有效的排列方式:按字母数字顺序,或者把正常的情况撞在前面,或者按执行频率高低
  • 简化每种情况对应的操作:不要在case中堆一堆操作
  • 不要为了使用case语句而刻意制造一个变量
  • 把default子句用于检查真正的默认情况或检查错误
  • 在case末尾明确无误地标明需要穿越执行的程序流程

3. 循环语句

3.1 循环的种类

  • 计数循环:执行次数一定,如for循环。不要在for循环中去修改下标值或中途退出。
  • 连续求值的循环:预先不知道循环次数,如while循环
  • 无限循环:一旦启动就一直执行,除非在循环中间退出,如while循环带break中断
  • 迭代器循环:容器类的常见操作,如foreach循环

3.2 循环的原则

  • 进入循环
    • 只从一个位置进入循环
    • 把初始化代码紧放在循环前面
    • while(true)表示无限循环
    • 在适当的情况下多使用for循环;但如果while循环更适用的话,不要使用for循环:因为while循环需要在循环之前初始化条件,循环最后变更条件,容易出错和可读性较差
  • 处理循环体
    • 避免空循环
    • 循环内务操作(循环变量的变更)要么放在循环的开始,要么放在循环的末尾
    • 一个循环只做一件事:应该把循环体当做黑盒或子程序看待,读者只需要关心其循环条件是什么,而不需关心其内容
  • 退出循环
    • 设法确认循环能够终止:要考虑正常的情况、端点,及每一种异常情况
    • 使循环终止条件看起来很明显
    • 不要为了终止循环而胡乱改动for循环的下标
    • 避免出现依赖于循环下标最终取值的代码:更具自我描述性的做法是,在循环体内某个适当的地方把这一最终取值赋给某个变量
    • 考虑使用安全计数器:安全计数器是一个特殊变量,在每次循环之后都递增它,以便判断该循环的执行次数是不是过多
    • 考虑在while循环中使用break语句而不用布尔标记
    • 小心那些有很多break散布在循环各处
    • 在循环开始处用continue进行判断:可以避免用一个让整个循环体的缩进的if块
    • 除非你已经考虑过各种替换方案,否则不要使用break:使用break消除了把循环看做黑盒子的可能性
  • 检查端点:既要在脑海中模拟,也要手工检查一遍
  • 循环变量
    • 用整数或者枚举类型表示数组和循环的边界
    • 在嵌套循环中使用有意义的变量名来提高其可读性:避免i、j、k及下标串话误用
    • 把循环下标变量的作用域限制在本循环内
  • 循环的长度
    • 循环要尽可能地短,以便能够一目了然:建议限制在一屏之内
    • 把嵌套限制在3层以内
    • 把长循环的内容移到子程序里
    • 要让长循环格外清晰

4. 不太常见的控制结构

4.1 子程序多处返回

子程序的多处返回是指一个子程序中有多处地方出现return。通常来说,应该用防卫子句(早返回或早退出)来简化复杂的错误处理,不要使用过多的缩进嵌套,并且减少每个子程序中return的数量。下面的例子很好地体现了这些原则。

if (!file.validName()) {
    errorStatus = FileError.InvalidFileName;
    return;
}
if (!file.Open()) {
    errorStatus = FileError.CantOpenFile;
    return;
}
if (!encryptionKey.valid()) {
    errorStatus = FileError.InvalidEncryptionKey;
    return;
}
// 此处为处理正常情况的代码

4.2 递归

递归并不常用,但如果使用得谨慎,一些小范围内的问题还是可以得到非常优雅的解。对于大多数问题,它所带来的解将会是极其复杂的——在那些情况下,使用简单的迭代通常会比较容易理解。

  • 确认递归能够停止
  • 使用安全计数器防止出现无穷递归:安全计数器必须是一个不随每次子程序调用而重新创建的变量
  • 把递归限制在一个子程序内:循环边归(A调用B, B调用C, C调用A)非常危险
  • 留心栈空间:给安全计数器设置上限时考虑给递归子程序分配多少栈空间,并观察递归函数中局部变量的分配情况
  • 不要用递归去计算阶乘或者斐波纳契数列:最重要的,在用递归之前你应该考虑它的替换方案

4.3 关于goto语句

入们反对使用goto的普遍理由是:含有goto的代码很难安排好格式;使用goto也会破坏编译器的优化特性。关于goto的讨论非常多,而且其现代版本仍在以各种各样的形式出现。用不用goto是一个信仰问题。《代码大全》作者的信条是:

在现代语言里,你可以很容易地把九成的goto替换成与之等价的顺序结构。对于这些简单的情况,你应该把goto替换掉并把这当成习惯。对于复杂的情况,你仍有九成不用goto的可能:你可以把代码拆分成小的子程序,使用try-finally,使用嵌套if,检测并重新检测某个状态变量,或者重新设置条件结构。对于这些情况,想消除goto相对来说比较难,但这是一种很好的智力训练……
对于剩下的那1%的情况,即当使用goto是解决问题的合理办法的时候,请在使用的同时予以详细的说明。如果你穿着雨鞋,那么就没有必要绕开泥潭走路了。不过也要虚心参考别的程序员提出的不用goto的方法。也许他们发现了一些被你忽视的东西。

  • 在那些不直接支持结构化控制语句的语言里,用goto去模拟那些控制结构。在做这些的时候,应该准确地模拟,不要滥用goto所带来的灵活性
  • 如果语言内置了等价的控制结构,那么就不要用goto
  • 如果是为提高代码效率而使用goto,请衡量此举实际带来的性能提升
  • 除非你要模拟结构化语句,否则尽量在每个子程序内只使用一个goto标号
  • 除非你要模拟结构化语句,否则尽量让goto向前跳转而不要向后跳转
  • 确认所布的goto标号都被用到了。没用到的goto标号表明缺少了代码,即缺少了跳向该标号的代码。如果某些标号没有用,那么就删掉它们
  • 确认goto不会产生某些执行不到的代码
  • 如果你是一位经理,那么就应该持这样的观点:对某一个goto用法所展开的争论并不是事关全局的。如果程序员知道存在替换方案,并且也愿意为使用goto辩解,那么用goto也无妨

参考文献:电子工业出版社《代码大全(第2版)》第14-17章

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器