当前位置: 首页 >> 程序设计 >> 精通递归程序设计
 

精通递归程序设计

作者:      来源:zz     发表时间:2006-08-02     浏览次数:      字号:    

内容摘要 理解递归,以编写可维护的、一致的、保证正确的代码

递归的报告打印程序的证明

由于从来没有改变变量的状态,所以证明您的程序非常简单。让我们来研究关于清单 11 中报告打印程序的一些特性证明。

提示那些大学毕业后没有进行过程序证明的人(或者可能从来没有进行过),进行程序证明,基本上就是寻找程序的某个特性(通常指定为 P),并证明那个特性适用。要完成此任务需要使用:

  • 公理(axioms),假定的真理。
  • 定理(theorems) 由公理推论得到的关于程序的陈述。

目标是将公理与定理联合起来证明特性 P 为真。如果程序有不只一个特性,则通常分别去证明每一个。由于这个程序有若干个特性,我们将为其中一些给出简短的证明。

由于我们在进行不正式的证明,所以我不会为所使用的公理命名,也不会尝试去证明那些用来令证明有效的中间定理。但愿它们足够明显,以致不必对它们进行证明。

在证明过程中,我将把程序的三个递归点分别称为 R1、R2 和 R3。所有程序都有一个隐式的假设:report_lines 为合法的指针,且 num_lines 准确地反映 report_lines 所显示的行数。

在示例中,我将尝试去证明:

  • 任意给定的一组行,程序都能终止。
  • 在 LINES_PER_PAGE 行之后会发生分页。
  • 每一个报告项都严格只打印一次。

证明程序会终止

此证明将确认对于任意给定的一组行,程序都会终止。这个证明将使用一种在递归程序中常见的证明技术,名为 归纳证明(inductive proof)

归纳证明由两个步骤构成。首先,需要证明特性 P 对于给定的一组参数是适用的。然后去证明一个归纳,表明如果 P 对于 X 的值适用,那么它对于 X + 1(或者 X - 1,或者任意类型的步进处理)的值也必须适用。通过这种方法您就可以证明特性 P 适用于从已经证明的那个数开始依次连续的所有数。

在这个程序中,我们将证明 print_report_i 会在 current_line == num_lines 的条件下终止,然后证明如果 print_report_i 会在给定的 current_line 条件下终止,那么它也可以在 current_line - 1 条件下终止,假定 current_line > 0

证明 1. 证明对于任意给定的一组行,程序都能终止

假定
我们假定 num_lines >= current_lineLINES_PER_PAGE > 1
基线条件证明
通过观察,我们可以知道,当 current_line == num_lines 时,程序会立即终止。
归纳步骤证明
在程序的每一次迭代中,current_line 或者增 1(R3),或者保持不变(R2 和 R3)。

只有当 current_line 的当前值与 current_line 的先前值不同时,R2 才会发生,因为 current_groupprevious_group 直接由它派生而来。

只有 num_lines_this_page 中的变化会令 R1 发生,而这种变化只能由 R2 和 R3 产生。

由于只有以 R3 为基础才能发生 R2,且只有以 R2 和 R3 为基础才能发生 R1,我们可以断定,current_line 必须是增加的,而且只能是单调递增。

因此,如果 current_line 的某一值可以终止,那么 current_line 之前的所有值都可以终止。

我们现在已经证明,在我们的假设前提下,print_report_i 将可以终止。

证明在 LINES_PER_PAGE 行之后会发生分页

这个程序会保持在何处分页的追踪,因此,有必要证明分页机制有效。如前所述,证明以公理和定理做为其论据。在此,我将提出两个定理来完成证明。如果定理的条件被证明为真,则我们可以使用此定理来确定我们的程序的定理结果的正确性。

证明 2. 在 LINES_PER_PAGE 行之后发生分页

假定
当前页的第一行已经打印了一个页眉。
定理 1
如果 num_lines_this_page 设置为正确起始值(条件 1),每打印一行 num_lines_per_page 增 1(条件 2),并且在分页之后重新设置 num_lines_per_page(条件 3),那么 num_lines_per_page 则精确地表示此页上已经打印的行数。
定理 2
如果 num_lines_this_page 精确地表示已经打印的行数(条件 1),并且每当 num_lines_this_page == LINES_PER_PAGE 时执行一次分页,那么我们就确信我们的程序在打印 LINES_PER_PAGE 行之后会进行一次分页。
证明
设想定理 1 的条件 1。如果我们假定通过 print_report 调用 print_report_i,那么观察可知,这无论如何都是显然的。

通过确认每一个打印一行的过程都相应使 num_lines_this_page 增 1,条件 2 能得到确定。在以下三种情况下完成行打印:

  1. 打印组的总数。
  2. 打印单个的报告行。
  3. 打印页眉。

观察可见,行打印条件 1 和 2 将 num_lines_this_page 增 1,行打印条件 3 在一个 分页/页眉 组合打印之后将 num_lines_this_page 重新设置为适当的值(常规的条件 3)。定理 1 的要求得到了满足,所以我们就已经证明了程序会在打印 LINES_PER_PAGE 行之后进行一次分页。

证明每一个报告项都严格只打印一次

我们需要确保程序总是打印报告的每一行,从不遗漏。我们可以使用一个归纳证明来证明,如果 print_report_i 根据 current_line == X 准确地打印一行,那么它也将或者准确地打印一行,或者因 current_line == X + 1 而终止。另外,由于我们既有起始条件也有终止条件,所以我们必须证明两者都是正确的,因此我们必须证明那个基线条件,即当 current_line == 0print_report_i 有效,而当 current_line == num_lines 时它 只是 会终止。

不过,在本例中,我们可以进行相当程度的简化,只是通过补充我们的第一个证明来给出一个直接证明。我们的第一个证明表明,在起始时使用一个给定的数字,将在适当的时候终止程序。通过观察可知,算法继续进行,证明已经完成了一半。

证明 3. 每一个报告项的行都严格只打印一次

假定
由于我们要使用证明 1,所以这个证明依赖于证明 1 的假定。另外我们假定 print_report_i 的第一次调用来自于 print_report,这表示 current_line 从 0 开始。
定理 1
如果 current_line 只有在一次 print_line(条件 1)调用之后才会增加,而且只有在 current_line 增加之前才会调用 print_line,那么 current_line 每经历一个数字将会打印出单独的一行。
定理 2
如果定理 1 为真(条件 1),同时 current_line 经历从 0 到 num_lines - 1 的每一个数字(条件 2),而且当 current_line == num_lines 时终止(条件 3),那么每一个报告项的行都严格只打印一次。
证明
观察可知,定理 1 的条件 1 和条件 2 为真。R3 是惟一一处 current_line 会增加的地方,而且这个增加在 print_line 的调用之后随即就会发生。因此,定理 1 可证,同样定理 2 的条件 1 可证。

通过归纳可以证明条件 2 和 3,并且,实际上这只是第一个终止证明的重复。我们可以使用终止的证明来最终证明条件 3。基于那个证明,以及 current_line 从 0 开始的假定,条件 2 为真。因此,我们已经证明报告的每一行都严格只打印一次。

证明和递归程序设计

这些只是我们能为程序所做的一些证明。它们还可以更为严格,不过我们大部分人选择的是程序设计而非数学,因为我们不能忍受数学的单调,也不能忍受它的符号。

使用递归可以极度简化程序的核查。并不是不能为命令式程序进行那种程序证明,而是状态改变发生的次数之多使得那些证明不具意义。使用递归程序,用递归取代修改状态,状态改变发生的次数很少,并且,通过一次设置所有递归变量,程序变更能保持前后一致。这不能完全避免逻辑错误,但它确实可以消除其中很多种类的错误。这种只使用递归来完成状态改变和重复的程序设计方法通常被称作 函数式程序设计(functional programming)







尾部递归(Tail-recursive)函数

这样,我已经向您展示了循环与递归函数有何种关联,以及如何将循环转化为递归的、非状态改变的函数,以获得比先前的程序设计可维护性更高而且能够保证正确的成果。

不过,对于递归函数的使用,人们所关心的一个问题是栈空间的增长。确实,随着被调用次数的增加,某些种类的递归函数会线性地增加栈空间的使用 —— 不过,有一类函数,即 尾部递归 函数,不管递归有多深,栈的大小都保持不变。

尾部递归

当我们将循环转化为递归函数时,递归调用是函数所做的最后一件事情。如果仔细观察 print_report_i,您会发现在函数中递归调用之后没有再进一步发生任何事情。

这表现为类似循环的行为。当循环到达循环的末尾,或者它执行 continue 时,那是它在代码块中要做的最后一件事情。同样地,当 print_report_i 再次被调用时,在递归点之后不再做任何事情。

函数所做的最后一件事情是一个函数调用(递归的或者非递归的),这被称为 尾部调用(tail-call)。使用尾部调用的递归称为 尾部递归。让我们来看一些函数调用示例,以了解尾部调用的含义到底是什么:


清单 12. 尾部调用和非尾部调用


int test1()
{
int a = 3;
test1(); /* recursive, but not a tail call. We continue */
/* processing in the function after it returns. */
a = a + 4;
return a;
}

int test2()
{
int q = 4;
q = q + 5;
return q + test1(); /* test1() is not in tail position.
* There is still more work to be
* done after test1() returns (like
* adding q to the result
*/
}

int test3()
{
int b = 5;
b = b + 2;
return test1(); /* This is a tail-call. The return value
* of test1() is used as the return value
* for this function.
*/
}

int test4()
{
test3(); /* not in tail position */
test3(); /* not in tail position */
return test3(); /* in tail position */
}

可见,要使调用成为真正的尾部调用,在尾部调用函数返回之前,对其结果 不能执行任何其他操作

注意,由于在函数中不再做任何事情,那个函数的实际的栈结构也就不需要了。惟一的问题是,很多程序设计语言和编译器不知道如何除去没有用的栈结构。如果我们能找到一个除去这些不需要的栈结构的方法,那么我们的尾部递归函数就可以在固定大小的栈中运行。

尾部调用优化

在尾部调用之后除去栈结构的方法称为 尾部调用优化

那么这种优化是什么?我们可以通过询问其他问题来回答那个问题:

  • 函数在尾部被调用之后,还需要使用哪个本地变量?哪个也不需要。
  • 会对返回的值进行什么处理?什么处理也没有。
  • 传递到函数的哪个参数将会被使用?哪个都没有。

好像一旦控制权传递给了尾部调用的函数,栈中就再也没有有用的内容了。虽然还占据着空间,但函数的栈结构此时实际上已经没有用了,因此,尾部调用优化就是要在尾部进行函数调用时使用下一个栈结构 覆盖 当前的栈结构,同时保持原来的返回地址。

我们所做的本质上是对栈进行处理。再也不需要活动记录(activation record),所以我们将删掉它,并将尾部调用的函数重定向返回到调用我们的函数。这意味着我们必须手工重新编写栈来仿造一个返回地址,以使得尾部调用的函数能直接返回到调用它的函数。

这里是为那些真正热衷底层编程的人准备的一个优化尾部调用的汇编语言模板:


清单 13. 尾部调用的汇编语言模板


;;Unoptimized tail-call
my_function:
...
...
;PUSH ARGUMENTS FOR the_function HERE
call the_function

;results are already in %eax so we can just return
movl %ebp, %esp
popl %ebp
ret

;;Optimized tail-call optimized_function:
...
...
;save the old return address
movl 4(%ebp), %eax

;save old %ebp
movl (%ebp), %ecx

;Clear stack activation record (assuming no unknowns like
;variable-size argument lists)
addl $(SIZE_OF_PARAMETERS + 8), %ebp ;(8 is old %ebp + return address))

;restore the stack to where it was before the function call
movl %ebp, %esp

;Push arguments onto the stack here
;push return address
pushl %eax

;set ebp to old ebp
movl %ecx, %ebp

;Execute the function
jmp the_function

可见,尾部调用使用了更多一些指令,但是它们可以节省相当多内存。使用它们有一些限制:

  • 当函数返回到进行调用的函数时,后者一定不能依赖于仍在栈中的参数列表。
  • 调用的函数一定不能顾虑栈指针当前所指的位置。(当然,可以假定它超出了其本地变量的范围。)这意味着您不能使用 -fomit-frame-pointer 进行编译,所有寄存器向栈中保存都要参照 %ebp 而不是 %esp
  • 不能有任何变长的参数列表。

当函数在尾部调用中调用自己时,方法更为简单。我们只需要将参数的新值移到旧值之上,然后在本地变量保存到栈上之后立即进行一个到函数中位置的跳转。由于我们只是跳转到同一个函数,所以返回地址和旧的 %ebp 是相同的,栈的大小也不会改变。因此,在跳转之前我们要做的惟一一件事情就是使用新的参数取代旧的参数。

从而,在只是付出了一些指令的代价后,您的程序会拥有函数式程序的可证明性和命令式程序的速度和内存特性。惟一的问题在于,现在只有非常少的编译器实现了尾部调用优化。Scheme 实现必需要实现这种优化,很多其他函数式语言实现也必须要实现。不过,要注意,由于有时函数式语言使用栈的方式与命令式语言区别很大(或者根本不使用栈),所以实现尾部调用优化的方法可能会有相当大的区别。

最新版本的 GCC 也包含了一些在受限环境下的尾部递归优化。例如,前面描述的 print_report_i 函数在 GCC 3.4 中使用 -O2 进行尾部调用优化编译,因此运行时使用的栈的大小是固定的,而不是线性增长的。







结束语

递归是一门伟大的艺术,使得程序的正确性更容易确认,而不需要牺牲性能,但这需要程序员以一种新的眼光来研究程序设计。对新程序员来说,命令式程序设计通常是一个更为自然和直观的起点,这就是为什么大部分程序设计说明都集中关注命令式语言和方法的原因。不过,随着程序越来越复杂,递归程序设计能够让程序员以可维护且逻辑一致的方式更好地组织代码。







参考资料







关于作者


Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书介绍的是使用 Linux 汇编语言进行程序设计。他是 New Media Worx 的首席开发人员,为客户开发 Web、视频、自助服务(kiosk)以及桌面应用程序。通过 johnnyb@eskimo.com 与 Jonathan 联系。

[1] [2]

责任编辑 webmaster

 
 
 
 
 
评论更多>>
 
 
 
发表
 
姓名: QQ:
性别: MSN:
E-mail: 主页:
评分: 1 2 3 4 5
评论内容:
验证码:
  
  • 请遵守《互联网电子公告服务管理规定》及中华人民共和国其他各项有关法律法规。
  • 严禁发表危害国家安全、损害国家利益、破坏民族团结、破坏国家宗教政策、破坏社会稳定、侮辱、诽谤、教唆、淫秽等内容的评论 。
  • 用户需对自己在使用本站服务过程中的行为承担法律责任(直接或间接导致的)。
  • 本站管理员有权保留或删除评论内容。
  • 评论内容只代表网友个人观点,与本网站立场无关。
  •