本文来自李明子csdn博客(http://blog.csdn.net/free1985),商业转载请联系博主获得授权,非商业转载请注明出处!
4 设计要点
为了提高可读性和健壮性,我们在设计递归函数时应该尽量使其结构明晰并拥有一定的容错能力。要做到这些,结束条件和中间值及结果的传递显得尤为重要。
4.1 结束条件
递归函数存在“调用函数本身”的性质,因此,结束条件对于递归函数显得尤为重要。如果代码中没有对递归函数的结束条件妥善处理,将造成灾难性的后果——死循环。此时,视所使用语言及编译、运行环境,递归调用将在达到最大堆栈限制或内存限制时返回或抛出异常。更糟糕的情况是,如果毫无限制,递归函数将一直运行下去,直至强制结束程序进程或硬件资源耗尽。
那么,我们应该如何设计递归函数的结束条件呢?从设计初衷来说,递归结束的条件无外乎两点,即“达到目的”和“防止死循环”。当递归已经达到目的时自然是应该停止调用。但同时,为了防止出现死循环这种严重的bug,我们还应该及时终止递归的无限运行趋势。
下面,针对第3节介绍的递归的几种常见使用场景,分别来介绍其结束条件的设计要点。
4.1.1 线性逼近
对于线性逼近场景,其所需达到的“目的”一般为递变量等于某个值。比如第2节中计算阶乘的例子中,当递变量为0时,递归结束。这是一个非常理想也不会出现“争议”的例子。遗憾的是,在实际工作中,由于算法设计漏洞或对参数取值范围限制不当,经常会出现死循环的情况。比如第2节的示例代码,如果主调函数使用参数-1调用该递归函数,将陷入死循环。
针对线性逼近类型的递归函数,可以考虑使用下面三种结束策略:
- 尽可能在结束条件中使用大于、小于等判断条件来代替等于条件;
- 将结束条件由等于指定值改为达到指定区间。对于很多与中间值精度相关的数学算法可以考虑使用这种方式;
- 在结束条件中,加入对中间值的变化趋势判断。线性逼近型的递归函数,中间值通常会呈现明显的递增或递减趋势。当中间值曲线出现拐点时,说明中间值出错,应该返回;
不难看出,上述策略中,第1、2项对应设计目的中的“达到目的”,第3项对应“防止死循环”。
4.1.2 重试
对于重试型的递归,其“目的”往往是显式的——方法调用的返回值或者是否抛出异常。显然目的达成是这类递归的主要结束条件。而考虑到不停失败从而陷入死循环这种情况,通常会为重试型递归增加一个辅助结束条件。
重试型递归函数的辅助结束条件,常见的有计数器和超时两种:
- 计数器。记录递归函数的执行次数,当达到计数器规定的最大重试次数时,即使目的没有达到仍然做返回处理;
- 超时。对于时效性要求较高或单次重试比较耗时的场景可以记录执行递归函数的总时间。当总时间超过阀值时,即使目的没有达到仍然做返回处理;
4.1.3 树状结构遍历
对于树状结构遍历型的递归函数,除了以找到符合条件的节点而达到“目的”从而结束递归的场景外,均以“穷尽”为结束条件。即对于自底向上递归,遍历到根则递归结束;对于自顶向下递归,遍历到叶子节点则递归结束。
在实际工作中,树状结构遍历型的递归最容易造成死循环。这是因为我们的递归算法能够正确执行的前提条件是树的结构是正确的。遗憾的是,这棵我们用来遍历的树,通常来自于其他模块或由用户通过界面交互式创建,其合规性不能完全保证。当它的通路出现问题时,可能就从树转变为图。此时我们的递归函数将陷入死循环。
那么,如何避免这种情况的发生呢?我们一般有以下两种容错策略:
- 最大层数限制。对树的遍历层数进行限制可以有效的遏制死循环的发生。注意,这里是对树的层数而非递归函数的调用次数进行限制,当树的遍历层数明显偏离业务含义时即终止递归。这种方法简单粗暴,无法保证树上节点被访问的次数,即访问次数可能是0次、1次和多次。
- 回路限制(节点仅可单次访问)。在树节点的遍历过程中,记录访问过的树节点对象或特征值。当访问到曾访问过的节点时说明该树已出现回路,结束递归。
4.1.4 监听
监听类型的递归函数因其业务特点拥有较长的生命周期,甚至贯穿主线程始终,因此它们基本没有“结束”的概念。但我们仍然可以通过一个开关变量来控制监听何时停止运行以满足诸如服务重启、被监听资源升级等场景。
4.2 中间值及结果的传递
就递归函数的一般形态来说,其中间值及结果主要有渐变和累积两种形式。比如重试类型的递归函数中,计数器就是一个渐变的中间值。而树状结构遍历中对指定节点子孙节点中包含特定关键字节点的查询则是一个结果累积的例子。递归函数运行的结果将得到一个包含指定关键字的节点集合。
与其他类型的函数一样,递归函数的中间值和结果既可以设计为通过函数参数和返回值来传递,也可以设计为用类的成员变量来传递。我建议在设计时最好通过函数参数和返回值来传递中间值和结果。这样做更符合面相对象语言程序设计的封装原则,使代码内聚性更高。
下面是一个通过函数返回值传递执行结果的示例。
/**
* 获取匹配的子孙节点集合
* @param matchString 要匹配的字符串
* @param node 父节点
* @return 匹配的子孙节点集合*
*/
public List<Node> getMatchedDescendants(String matchString,Node node){
// 匹配的节点集合
List<Node> matchedNodes = new ArrayList<Node>();
// 当前节点匹配
if(isMatched(matchString,node)){
matchedNodes.add(node);
}
// 遍历子节点
for (Node childNode : getChildren(node)) {
matchedNodes.addAll(getMatchedDescendants(matchString,childNode));
}
return matchedNodes;
}
5 最佳实践
5.1 文档与注释
文档与注释是软件开发人员记录和与他人交流软件各级构件设计思想的重要途径。对于递归函数这种可读性相对较差的函数类型,文档和注释显得尤为重要。对于递归函数,我们在设计文档和函数注释中应该注意以下方面:
- 正确的函数名。函数名应该有效的描述其业务含义,这是函数命名的基本原则。但在实际的工作中,违反这一原则的情况却屡见不鲜。对于递归函数,尤其是树状结构遍历类型的递归函数,常见的错误是将递归“单次处理”的业务含义作为函数的名称。比如,函数的功能为“获取指定节点的子孙节点集合”,但函数名却错误的写成“获取指定节点的子节点集合”。虽是一字之差,却谬之千里。有些开发规范中会要求开发者为递归函数命名时添加表征其“递归”特性的前缀或后缀。我觉得,是否有必要这样做要视具体情况而定。比如,现在很多IDE可以自动标注递归调用,这种情况下我们就无需通过函数名来标注递归函数了。
- 必要的注释与说明。递归函数在设计文档及函数注释中应该包含递归结束条件等设计要点信息以便其他开发人员调用和维护时了解其意图。另外,递归函数可能会将对象作为中间值,通过参数传递其引用,并在递归过程中修改该值。这种情况下应根据开发规范,表明其“in out”特性。
- 为了提高递归函数的可读性,应该严格控制递归函数体的规模,通过封装使递归函数更易于理解。
5.2 调试
对递归函数进行调试相对一般函数来说要更加复杂。在这里,我总结一些调试技巧供读者参考:
1. 使用计数器断点。大多数IDE都提供了计数器这种类型的断点,即只有当触发次数达到计数器设置值时断点才会被触发。对于重试类型等可以预期执行次数与对应业务逻辑关系的递归函数可以通过设置该类型的断点辅助调试。
2. 使用条件断点。顾名思义,条件断点是仅当设置的条件表达式达成时才会被触发的断点。利用该类型断点,我们可以灵活的设置断点触发条件。比如,我们可以将中间值满足某条件作为条件设置断点。
3. 调试关键点。对递归函数进行单元测试时,应当重点关注如下关键点:
a) 结束条件。尤其对于有多个结束条件的递归函数,在调试时应该关注所有结束条件及其组合。
b) 中间值。作为参数传递的中间值应该保持在合理范围,如果出现偏差就会使后边的递归调用出现意外结果。因此,调试时应该关注递归调用时传递的中间值的变化。
(完)