code2读书笔记-第七章 高质量子程序
首先,什么是“子程序(routine)”?
子程序是为实现一个特定的目的而编写的一个可被调用的方法(method)或过程(procedure)。
那什么又是高质量的子程序呢?回答这个问题最简单的方式就是看下边这个低质量子程序的例子:
void HandleStuff( CORP_DATA & inputRec, int crntQtr, EMP_DATA empRec,
double & estimRevenue, double ytdRevenue, int screenX, int screenY,
COLOR_TYPE & newColor, COLOR_TYPE & prevColor, StatusType & status,
int expenseType )
{
int i;
for ( i = 0; i < 100; i++ ) {
inputRec.revenue[i] = 0;
inputRec.expense[i] = corpExpense[ crntQtr ][ i ];
}
UpdateCorpDatabase( empRec );
estimRevenue = ytdRevenue * 4.0 / (double) crntQtr;
newColor = prevColor;
status = SUCCESS;
if ( expenseType == 1 ) {
for ( i = 0; i < 12; i++ )
profit[i] = revenue[i] - expense.type1[i];
}
else if ( expenseType == 2 ) {
profit[i] = revenue[i] - expense.type2[i];
}
else if ( expenseType == 3 )
profit[i] = revenue[i] - expense.type3[i];
}
这个程序有哪些不妥呢?至少有十个不同问题。先自己列出,然后看下边这个列表:
- 程序名, HandleStuff 一点没有告诉这个程序是做什么的。
- 缺少文档
- 布局不好,代码的物理组织形式没有给出任何关于逻辑组织的提示。
- 输入变量 inputRec 的值被改变了。如果它需要被修改,就不要这样命名。
- 读写了全局变量。从 corpExpense 读取数值并写入 profit。
- 没有单一目的。 初始化一些变量,数据库写入数据,做了一些计算。但是看不出这些事情的关联。
- 没有防范错误数据(bad data)。 如果 crntQtr 等于 0。
- 使用了若干神秘数值(magic number):100、4.0、12、等等
- 一个参数传递方式有误: prevColor 标记为引用参数,但未赋值。
- 参数太多: 合理参数个数,其上限大概是 7 个。
- 参数顺序混乱且未经注释。
使用子程序的好处就是因为它避免了重复的代码,从而使程序更易于开发、调试、编档和维护,等等。然而这样的解释不够好,也不够完整,下面就详细解释一下子程序。
1.创建子程序的正当理由
这里列出一些创建子程序的正当理由。有些理由互有重叠,因为本来也没有打算形成一个正交的集合。
- 降低复杂度 一个重要原因就是降低程序复杂度。通过创建子程序隐藏一些信息。
- 引入中间、易懂的抽象 把一段代码放入一个命名恰当的子程序内,是说明这段代码用意最好的方法之一。
- 避免代码重复 创建子程序最普遍的原因是为了避免代码重复。 如果在两段子程序内编写相似的代码,就意味着代码分解出现了差错。
- 支持子类化 覆盖简短而规整的子程序所需新代码的数量,要比覆盖冗长而邋遢的子程序更少。
- 隐藏顺序 把处理事情的顺序隐藏起来是一个好主意。 如果一个行代码执行依赖于另一行代码的执行,就应该把它们放到一个子程序中,将其执行顺序隐藏起来。
- 隐藏指针操作 指针操作的可读性通常都很差,而且容易出错。
- 提高可移植性 可以用子程序来隔离程序中不可移植的部分,从而明确识别和隔离未来的移植工作。
- 简化复杂的布尔判断 为了理解程序的流畅,通常没有必要去研究那些复杂的布尔判断的细节。应该把这些判断放到函数中,以提高代码的可读性,因为:(1)这样就把判断细节放到一边了;(2)一个具有描述性的函数名字可以概括出该判断的目的。
- 改善性能 通过子程序,你可以只在一个地方优化代码。把代码集中在一处可以更方便地查出哪些代码运行效率低下。
- 确保所有的子程序都很小 不是的。有时候写一个大的子程序完成还会更好。
除此之外,创建类的很多理由也是创建子程序的理由:
- 隔离复杂度
- 隐藏实现细节
- 限制变化所带来的影响
- 隐藏全局变量
- 形成中央控制点
- 促成可重用的代码
- 达到特定的重构目的
2.在子程序层上设计
抽象和封装,在类这一层的设计中更为适用,而内聚性,在单个子程序这一层次上,仍是设计时常用的启发方式。对子程序而言, 内聚性是指子程序中各种操作之间联系的紧密程度。
关于内聚性的讨论一般会涉及到内聚性的几个层次。理解一些概念要比记住一些特定的术语更重要。这些概念可以帮助你思考 如何让子程序尽可能的内聚。
- 功能的内聚性 这是最强也是最好的一种内聚性,也就是说让一个子程序仅执行一项操作。
- 顺序上的内聚性 是指在子程序内包含有需要按特定顺序执行的操作,这些步骤需要共享数据,而且只有在全部执行完毕后才完成了一项完整的功能。
举一个例子,如果某个子程序需要根据出生日期计算出员工年龄和退休时间。子程序先计算年龄,然后根据年龄计算 退休时间,那么就具有顺序内聚性。
如何设计成功能上的内聚性,就是根据生日分别计算员工的年龄和退休时间。其中退休时间子程序可以调用计算年龄的子程序。 - 通信上的内聚性 是指一个子程序中的不同操作使用了同样的数据,但不存在其他任何联系。
- 临时的内聚性 是指含有一些因为需要同时执行才放到一起操作的子程序。可以把临时性的子程序看做是一系列事件的组织者,应该使用它去掉用其它子程序,由这些子程序来完成特定的操作。
一般来说,其它类型的内聚性都是不可取的。它们会导致代码组织混乱、难于调试、不变修改。下面就给出一些不可取的内聚性。
- 过程上的内聚性 是指一个程序中的操作是按特定顺序执行的,而这些操作并不需要为了除此之外的任何原因而彼此关联。
- 逻辑上的内聚 若干操作被放入同一个子程序中,通过传入控制标志选择执行其中的一项操作。子程序按照一定的控制流或者所谓“逻辑”将一些操作放到一起,它们被包裹在一个很大的 if 或者 case 语句中,其实没有任何逻辑上的关联。似乎更应该成为“缺乏逻辑的内聚性”
- 巧合的内聚 是指子程序中的各个操作之间没有任何可以看到的关联。
3.好的子程序名字
好的子程序名字能清晰地描述子程序所做的一切。这里是有效地给子程序命名的一些指导原则。
- 描述子程序所做的所有事情 子程序的名字应当描述其所有的输出结果以及副作用。如果你写的函数名又长又笨,就该考虑是不是换一种方式编写这个程序了。
- 避免使用无意义、模糊或者表述不清的动词 有些动词的含义非常灵活,可以延伸到覆盖几乎任何含义。根本不能说明子程序具体是做什么的。有时候子程序仅有的问题就是其名字表述不清,还有另外一种情况导致其动词含糊不清,是由于子程序执行的操作就是含糊不清的。
- 不要仅通过数字来形成不同的子程序名字 比如:part1,part2
- 根据需要确定子程序名字的长度 研究表明,变量名最佳长度是 9 到 15 个。
- 给函数命名时要对返回值有所描述 函数有返回值,因为,函数的命名要应该针对其返回值进行。
- 给过程起名时使用语气强烈的动词加宾语的形式 一个具有功能内聚性的过程通常是针对一个对象执行一种操作。
- 准确使用队长次 如:add/move , lock/unlock
- 为常用操作确立命名规则 在某些系统里,区别不同类别的操作非常重要。
4.子程序可以写多长
在面向对象的程序中,一大部分子程序都是访问器子程序,它们都非常短小。在任何时候,复杂的算法总会导致更长的子程序。在这种情况下,可以允许子程序的长度有序地增长到100到200行(不算代码中的注释和空行)。
数十年证据表明这么长的子程序也和短小的子程序一样不易出错。与其对子程序的长度强加限制,不如关注子程序的内聚性、潜逃层次,变量数量,决策点的数量等等。
5,如何使用子程序参数
子程序之间的接口是程序中最易出错的部分之一。一项研究发现,程序中有39%的错误都是属于内部接口错误,也就是子程序间相互通信时发生的错误。以下是一些可以减少接口错误的原则。
- 按照输入-修改-输出的顺序排列参数 不要随机地或按字母顺序排列参数。而应该先列出仅作为输入用途的参数,然后是即作为输入又作为输出用途的参数,最后才是仅作为输出用途的参数。
- 考虑自己创建 in 和 out 关键字
- 如果几个子程序都用了类似的一些参数,应该让这些参数的排列顺序保持一致 子程序的参数顺序可以产生记忆效应——不一致的顺序会让参数难以记忆。
- 使用所有的参数 既然往子程序中传递了一个参数,就一定要用到这个参数。
- 把状态或出错变量放在最后 按照习惯,状态变量和那些用于指示发生错误的变量应该放在参数表的最后。它们只是附属于程序的主要功能,而且它们仅用于输出的参数。
- 不要把子程序的参数用做工作变量
- 在接口中对参数的假定加以说明 如果你假定了传递给子程序的参数具有某种特征,那就要对这种假定加以说明。
- 把子程序的参数个数限制在大约7个以内 心理学研究发现,通常人类很难记住超过 7 个单位的信息。如果你发现自己要传递的参数很多,那说明子程序之间的耦合太过紧密了。
- 考虑对参数才用某种表示输入、修改、输出的命名规则 可以给这些参数加上不同的前缀,如: i_、m_、o_。
- 为子程序传递用以维持其接口抽象的变量或对象 比如你有一个程序需要一个对象中的三项数据。第一种,传三个特定数据,因为可以最大限度地减少子程序之间的关联;第二种,应该传递整个对象,因为在不修改程序接口的情况下,就能让子程序使用其它成员,保持接口稳定性。
但这两种都没有切中问题要害:子程序的接口要表达何种抽象? 如果要表达的抽象是子程序期望的 3 项特定的数据,只是碰巧由一个对象提供,那就选择第一种。如果要表达的抽象是一直拥有某个特定对象,那就第二种。(ps:在平时项目中经常遇到这个问题,一直简单化的尽量使用第一种方式,没有深入考虑。需要注意)。 - 使用具名参数 在某些语言中,可以显示地把形参和实参对应起来。
- 确保实参和形参相匹配 请养成良好习惯,总要检查参数类型,同时留意编译器给出的关于参数类型不匹配的警告。
6.使用函数时要特别考虑的问题
在很多现代语言中,都同时支持函数和过程。函数是指有返回值的子程序,过程是指没有返回值的子程序。函数与过程的区别更多是语义区别,而不是语法区别。
什么时候使用函数,什么时候使用过程
语言纯化论者们认为,一个函数应该只有一个返回值,就像数学函数一样。一种常用的编程实践是让函数像过程一样执行并返回状态值。
设置函数的返回值
使用函数总是存在返回不正确返回值的风险,当函数内有多个可能的执行路径,而其中有一个没有返回值时,这个错误就出现了。请按照以下给出的建议来做。
- 检查所有可能的返回路径 请确保在所有可能的情况下该函数都会返回值。
- 不要返回指向局部数据的引用或者指针
7.宏子程序和内联子程序
- 把宏表达式整个包含在括号内
- 把含有多条语句的宏用大括号括起来 一个宏可以有多条语句,你把它当做一条语句使用就会出错。
- 用给子程序命名的方法给展开后代码形同子程序的宏命名,以便在需要时可以用子程序来替代宏
要点
- 创建子程序最主要的目的是提高程序的可管理性,当然也有其他一些好的理由。其中,节省代码空间只是一个次要原因;提高可读性,可靠性和可修改性等原因都要更重要一些。
- 有时候,把一些简单的操作写成独立的子程序也非常有价值。
- 子程序可以按照其内聚性分为很多类,而你应该让大多数子程序具有功能上的内聚性,这是最佳的一种内聚性。
- 子程序的名字是它质量的指示器。如果名字糟糕但恰如其分,那就说明这个子程序设计得很差劲。如果名字糟糕而且又不准确,那么它就反映不出程序是干什么的。不管怎么样,糟糕的名字都意味着程序需要修改。
- 只有在某个子程序的主要目的是返回由其名字所描述的特定结果时,才应该使用函数。
- 细心的程序员会非常谨慎地使用宏,而且只在万不得已时才用。