深入理解函数
第五章 深入理解函数
1.return语句
有返回值的函数中,return语句的作用式提供整个函数的返回值,并结束当前函数返回到调用它的地方。在没有返回值的函数中也可以使用return语句,例如当前检查到一个错误时提前结束当前函数的执行并返回:
这个函数首先检查参数x是否大于0,如果x不大于0就打印错误提示,然后提前结束函数的执行返回到调用者,只有当x大于0时才能求对数,在打印了对数结果之后到达函数体的末尾,自然地结束执行并返回。注意,使用数学函数log需要包含头文件math.h,由于x是浮点数,应该与同类型的数做比较,所以写成0.0。
我们定义一个检查奇偶性的函数不是为了打印两个字符串就完事了,而是为了根据奇偶性的不同分别执行不同的后续动作。我们可以把它改成一个返回布尔值的函数:
有些人喜欢写成return (1);这种形式也可以,表达式外面套括号表示改变运算符优先级,在这里不起任何作用。我们可以这样调用这个函数:
返回布尔值的函数是一类非常有用的函数,在程序中通常充当控制表达式,函数名通常带有is或if等表示判断的词,这类函数也叫做谓词(Predicate)。is_even这个函数写的有点啰嗦,x % 2这个表达式本来就有0值或非0值,直接把这个值当作布尔值返回就可以了:
函数的返回值应该这样理解:函数返回一个值相当于定义一个和返回值类型相同的临时变量并用return后面的表达式来初始化。例如上面的函数调用相当于这样的过程:
当if语句对函数的返回值做判断时,函数已经退出,局部变量x已经释放,所以不可能在这时候才计算表达式 !(x % 2)的值,表达式的值必然是事先计算好了存在一个临时变量里的,然后函数退出,局部变量释放,if语句对这个临时变量的值做判断。注意,虽然函数的返回值可以看作是一个临时变量,但我们只是读一下它的值,读完值就释放它,而不能往它里面存新的值,换句话说,函数的返回值不是左值,或者说函数调用表达式不能做左值,因此下面的赋值语句是非法的: is_even(20) = 1;
C语言的传参规则是Call by Value,按值传递,现在我们知道返回值也是按值传递,即便返回语句写成return x;,返回的也是变量x的值,而非变量x本身,因为变量x马上就要被释放了。
在写带有return语句的函数时要小心检查所有的代码路径(Code Path)。有些代码路径在任何条件下都执行不到,这称为Dead Code,例如把&&和||运算符记混了,写出如下代码:
最后一行printf永远都没机会被执行到,是一行Dead Code。有Dead Code就一定有Bug,你写的每一行代码都是想让程序在某种情况下去执行的,你不可能故意写出一行永远不会被执行的代码,如果程序在任何情况下都不会去执行它,说明跟你预想的不一样,要么是你对所有可能的情况分析得不正确,也就是逻辑错误,要么就是像上例这样得笔误,语义错误。还有一些时候,对程序中所有可能得情况分析得不够全面将导致漏掉一些代码路径,例如:
这个函数被定义为返回int,就应该在任何情况下都返回int,但是上面这个程序在x==0时安静地退出函数,什么也不返回,C语言对于这种情况会返回什么结果是未定义得,通常返回不确定得值。另外这个例子中把-号当负号用而不是当减号用,事实上+号也可以这么用。正负号是单目运算符,而加减号是双目运算符,正负号得优先级和逻辑非运算符相同,比加减的优先级要高。
以上两段代码都不会生产编译错误,编译器只做语法检查和最简单的语义检查,而不检查程序的逻辑。
2.增量式开发(Incremental)
增量式开发非常适合初学者,每写一行代码都编译运行,确保没问题了再写下一行,一方面在写代码时更有信心,另一方面也方便了调试。总是有一个先前的正确版本做参照,改动之后如果出了问题,几乎可以肯定就是刚才改的那行代码出的问题,这样就避免了从很多行代码中查找分析到底是哪一行出的问题。在这个过程中printf功不可没,你怀疑哪一行代码有问题,就插入一个printf进去看看其中的计算结果,任何错误都可以通过这个办法找出来。
尽可能复用(Reuse)以前写的代码避免写重复的代码。封装就是为了复用,把解决各种小问题的代码封装成函数。
解决问题的过程是把大的问题分成小的问题,小的问题再分成更小的问题,这个过程在代码中的体现就是函数的分层设计(Stratify)。
3.递归
如果定义一个概念需要用到这个概念本身,我们称它的定义是递归的(Recursive)。
N的阶乘(Factorial):
Factorial这个函数自己调用自己。自己直接或间接调用自己的函数称为递归函数。这里的factorial是直接调用自己,有时候函数A调用函数B,函数B又调用函数A,也就是函数A间接调用自己,这也是递归函数。
分析存储空间的变化过程,随着函数调用的层层深入,存储空间的一端逐渐增长,然后随着函数的调用层层返回,存储空间的这一端又逐渐缩短,并且每次访问参数和局部变量时只能访问这一端的存储单元,而不能访问内部的存储单元比如当factorial(2)的存储空间位于末端时,只能访问它的参数和局部变量,而不能访问factorial(3)和main()的参数和局部变量。具有这种性质的数据结构称为堆栈或栈(Stack)。每个函数调用的参数和局部变量的存储空间称为一个栈帧(Stack Frame)。操作系统为程序的运行预留了一块栈空间,函数调用时就砸这个栈空间里分配栈帧,函数返回时就释放栈帧。
用数学归纳法(Mathematical Induction)来证明只需要证明两点:Base Case正确,递推关系正确。写递归函数时一定要记得写Base Case,否则即使递推关系正确,整个函数也不正确。如果factorial函数漏掉了Base Case:
那么这个函数就会永远调用下去,直到操作系统为程序预留的栈空间耗尽程序崩溃(段错误)为止,这称为无穷递归(Infinite recursion)。
有一个重要的结论就是递归和循环是等价的,用循环能做到的事用递归都能做,反之亦然,事实上有的编程语言(比如某些LISP实现)只有递归而没有循环。计算机指令能做的所有事情就是数据存取、运算、测试和分支、循环(或递归),在计算机上运行高级语言写的程序最终也要翻译成指令,指令做不到的事情高级语言写的程序肯定也做不到,虽然高级语言有丰富的语法特性,但也只是比指令写起来更方便而已,能做的事情是一样多的。
递归是计算机的精髓所在,也是编程语言的精髓所在,我们学习在C的语法时已经看到很多的递归定义了,例如:
函数调用的语法是用实参定义的,实参使用表达式定义的,而表达式又是用函数调用定义的,因为函数调用也是表达式的一种。
if/else是用两个子语句定义的,子语句又是用if/else定义的,因为if/else也是语句的一种。