简单函数
第三章 简单函数
1.数学函数
在C语言中使用数学函数(ln函数在C标准库中叫做log)
编译运行这个程序,结果如下:
在数学中写一个函数有时候可以省略括号,而C语言要求一定要加上括号,例如log(1.0)。
在C语言的术语中,1.0是参数(Argument),log是函数(Function),log(1.0)是函数调用(Function Call)。sin(pi/2)和log(1.0)这两个函数调用在我们的printf语句中处于放表达式的位置。因此函数调用也是一种表达式,这个表达式由函数调用运算符(()括号)和两个操作数组成,操作数log是一个函数名(Function Designator),它的类型是一种函数类型(Function Type),操作数1.0是double型的。log(1.0)这个表达式的值就是对数运算的结果,也是double型的,在C语言中函数调用表达式的值称为函数的返回值(Return Value)。
程序第一行的#号(Pound Sign,Number Sign或Hash Sign)和include表示包含一个头文件(Header File),后面尖括号(Angel Bracket)中就是文件名(这些头文件通常位于/usr/include目录下)。头文件中声明了我们程序中使用的库函数,根据先声明后使用的原则,要使用printf函数必须包含stdio.h,要使用数学函数必须包含math.h,如果什么库函数都不使用就不必包含任何头文件,例如写一个程序int main(void){int a;a=2;return 0;},不需要包含头文件就可以通过编译通过,当然这个程序什么也做不了。
使用math.c中声明的库函数还有一点特殊之处,gcc命令必须加-lm选项,因为数学函数位于libm.so库文件中(这些库文件通常位于/lib目录下),-lm选项告诉编译器,我们程序中用到的数学函数要到这个库文件里找。函数printf位于libc.so库文件中,使用libc.so中的库函数在编译时不需要加-lc选项,当然加了也不算错,因为这个选项是gcc的默认选项。
C标准主要由两部分组成,一部分描述C的语法,另一部分描述C标准库。C标准库定义了一组标准头文件,每个头文件中包含一些相关的函数、变量、类型声明和宏定义。要在一个平台上支持C语言,不仅要实现C编译器,还要实现C标准库。在Linux平台上最广泛使用的C函数库是glibc,几乎所有C程序都要调用glibc的库函数,所以glibc是Linux平台C程序运行的基础。Glibc提供一组头文件和一组库文件,最基本、最常用的C标准库函数和系统函数在libc.so库文件中,几乎所有C程序的运行都依赖于libc.so,有些做数学计算的C程序依赖于libm.so,以后我们还会看到多线程的C程序依赖于libpthread.so。
2.自定义函数
我们定义了main函数,main函数的特殊之处在于执行程序时它自动被系统调用,系统就认准了“main”这个名字,除了名字特殊之外,main函数和别的函数没有区别。
函数定义的语法:函数定义—>返回值类型 函数名(参数列表){语句列表}
我们定义的main函数不带任何参数,参数列表写成void,main函数的返回值是int型的,return 0;这个语句就表示返回值是0,main函数的返回值是返回给操作系统看的,因为main函数是被操作系统调用的,通常程序执行成功就返回0,在执行过程中出错就返回一个非零值。比如我们将main函数中的return语句改为return 4;再执行它,执行结束后可以再Shell中看到它的退出状态(Exit Status):
$?是Shell中的一个特殊变量,表示上一条命令的退出状态。关于main函数需要注意两点:
- C标准兼容了旧的代码保留了main(){. . .}形式。该型式不写返回值类型就表示返回int型,也不写参数列表。这种宽松的规定使编译器无法检查程序中可能存在的Bug,增加了调试难度。
- 而系统在调用main函数时是传参数的,main函数最标准的形式应该是int main(int argc, char *argv[]),C标准也允许int main(void)这种写法,如果不使用系统传进来的两个参数也可以写成这种形式,但除了这两种形式之外,定义main函数的其他写法都是错误的或不可移植的。
最简单的自定义函数:
执行结果是:
我们定义了一个newline函数给main函数调用,它的作用是打印一个换行,所以执行结果中间多了一个空行。Newline函数不仅不带函数,也没有返回值,返回值类型为void表示没有返回值,这说明我们调用这个函数完全是为了利用它的Side Effect。如果想要多次插入空行,可以多次调用newline函数。
总结函数:1)同一个函数可以被多次调用。2)可以用一个函数调用另一个函数,后者再去调用第三个函数。3)通过自定义函数可以给一组复杂的操作起一个简单的名字,如doubleline。对于main函数来说,只需要通过doubleline这个简单的名字调用就行了,不必知道打印两个空行具体怎么做,所有的复杂操作都被隐藏在doubleline这个名字后面。4)使用自定义函数可以使代码更简洁,main函数在任何地方想打印三个空行只需调用一个简单的doubleline(),而不必每次都写三个printf(“\n”)。
读代码的过程就是模仿计算机执行程序的过程,我们不仅要记住当前读到了那一行代码,还要记住现在读的代码是被哪个函数调用的,这段代码返回后应该从上一个函数的什么地方接着往下读。
函数原型:比如void threeline(void)这一行,声明了一个函数的名字、参数类型和个数、返回值类型,这称为函数原型(Prototype)。
函数声明:void threeline(void);这种写法只能叫函数声明而不能叫函数定义,只有带函数体的声明才叫定义。只有分配存储空间的变量声明才叫变量定义,其实函数也是一样,编译器只有见到函数定义才会生成指令,而指令在程序运行时当然也要占存储空间。
那么没有函数体的函数声明有什么用呢?它为编译器提供了有用的信息,编译器在翻译代码的过程中,只有见到函数原型(不管带不带函数体)之后才知道这个函数的名字、参数类型和返回值,然后在碰到函数调用时才知道怎么生成相应的指令,所以函数原型必须出现在函数调用之前,这也是遵循“先声明后使用”的原则。
上面的例子中,main调用threeline,threeline再调用newline,要保证每个函数原型出现在调用之前,就只能按先newline再threeline再main的顺序定义了。如果使用不带函数体的声明,则可以改变函数的定义顺序:
这样仍然遵循了先声明后使用的原则。
由于有Old Style C语法的存在,并不是所有函数声明都包含函数原型,例如void threeline();这个声明并没有指出参数类型和个数,所以不算函数原型,这个声明提供给编译器的信息只有函数名和返回值类型。如果在这样的声明之后调用函数,编译器不知道参数的类型和个数,就不会做语法检查,所以很容易引入Bug。
如果在调用函数之前没有声明会怎么样呢?
编译时会报警告:
但仍然能编译通过,运行结果也对。这里涉及到的规则称为函数的隐式声明(Implicit Declaration),在main函数中调用threeline时并没有声明它,编译器认为此处隐式声明了int threeline (void);,隐式声明的函数返回值类型都是int,由于我们调用这个函数时没有传任何参数,所以编译器认为这个隐式声明的参数类型是void,这样函数的参数和返回值类型都确定下来了,所以编译器认为这个隐式声明的参数类型是void,这样函数的参数和返回值类型都确定下来了,编译器根据这些信息为函数调用生成相应的指令。然后编译器接着往下看,看到threeline函数的原型是void threeline(void),和先前的隐式声明的返回值类型不符,所以报警告。好在我们也没用到这函数的返回值,所以执行结果仍然正确。
如果函数newline没有返回值,那么表达式newline()不就没有值了吗? 由于任何表达式都有值和类型两个基本属性。这正是设计void这一个关键字的原因:首先从语法上规定没有返回值的函数调用表达式有一个void类型的值,这样任何表达式都有值,不必考虑特殊情况,编译器的语法解析比较容易实现;然后从语义上规定void类型的值不能参与运算,因此newline() + 1这样的表达式不能通过语义检查,从而兼顾了语法上的一致和语义上的不矛盾。
3.形参和实参
定义一个带参数的函数,需要在函数定义中指明参数的个数和每个参数的类型,定义参数就像定义变量一样,需要为每个参数指明类型,参数的命名也要遵循标识符命名规则。
带参数的自定义函数:
需要注意的是,定义变量时可以把同样类型的变量列在一起,而定义参数却不可以,例如下面这样的定义是错的:
总的来说,C语言的设计是非常优美的,只要理解了少数基本概念和基本原则就可以根据组合规则写出任意复杂的程序,很少有例外的规定说这样组合是不允许的,或者那样类推是错误的。相反,C++的设计就非常复杂,充满了例外。
回忆一个变量打印错误:
犯这种错误,一是不理解Literal的含义,二是自己想当然地把变量名组合到字符串里去,而事实上根本没有这条语法规则。
回到正题。我们调用print_time(23, 59)时,函数中的参数hour就代表23,参数minute就代表59。确切地说,当我们讨论函数中的hour这个参数时,我们所说的“参数”是指形参(Parameter),当我们讨论传一个参数23给函数时,我们所说的”参数“是指实参(Argument)。形参相当于函数中定义的变量,调用函数传递参数的过程相当于定义形参变量并且用实参的值来初始化。例如这样调用:
相当于在函数print_time中执行了这样一些语句:
Main函数的变量h和print_time函数的参数hour是两个不同的变量,只不过它们的存储空间中都保存了相同的值23,因为变量h的值赋给了参数hour。同理,变量m的值赋给了参数minute。C语言中的这种传递参数的方式称为Call by Value。在调用函数时,每个参数都需要得到一个值,函数定义中有几个形参,在调用时就要传几个实参,不能多也不能少,每个参数的类型也必须对应上。
为什么我们每次调用printf传的实参个数都不一样呢?用命令man 3 printf可以查看到printf函数的原型:
第一个参数是const char *类型的,后面的 . . . 可以代表0个或任意多个参数,这些参数的类型也是不确定的,这称为可变参数(Variable Argument)。总之,每个函数的原型都明确规定了返回值类型和个数,即使像printf这样规定为“不确定“也是一种明确的规定,调用函数时要严格遵守这些规定,有时候我们把函数叫做接口(Interface),调用函数就是使用这个接口,使用接口的前提是必须和接口保持一致。
Man Page是Linux开发最常用的参考手册,由很多页面组成,每个页面描述一个主题,这些页面被组织成若干个Section。FHS(Filesystem Hierarchy Standard)标准规定了Man Page各Section的含义如下:
注意区分用户命令和系统管理命令,用户命令通常位于/bin和/usr/bin目录,系统管理命令通常位于/sbin和/usr/sbin目录,任何用户都可以执行用户命令,而执行系统管理命令经常需要root权限。
Man Page中有些页面有重名,比如敲man printf命令看到的并不是C函数printf,而是位于第一个Section的系统命令printf,要查看位于第3个Section的printf函数应该敲man 3 printf,也可以敲man -k printf命令搜索哪些页面的主题包含printf关键字。
4.局部变量和全局变量
我们把函数中定义的变量称为局部变量(Local Variable),由于形参相当于函数中定义的变量,所以形参也是一种局部变量。在这里“局部“有两层含义:
1) 一个函数中定义的变量不能被另一个函数使用。例如print_time中的hour和minute在main函数中没有定义,不能使用,同样main函数中的局部变量也不能被print_time函数使用。如果这样定义:
Main函数中定义了局部变量hour,print_time函数中也有参数hour,虽然它们名称相同,但仍然是两个不同的变量,代表不同的存储单元。Main函数的局部变量minute和print_time函数的参数minute也是如此。
2) 每次调用函数时局部变量都表示不同的存储空间。局部变量在每次函数调用时分配存储空间,在每次函数返回时释放存储空间,例如调用print_time(23, 59)时分配hour和minute两个变量的存储空间,在里面分别存上23和59,函数返回时释放它们的存储空间,下次再调用print_time(12, 20)时又分配hour和minute的存储空间,在里面分别存上12和20。
在函数体中通常把所有的变量定义语句放在最前面,然后才能写其他语句。C99的新特性允许变量定义语句穿插在其他语句之中,只要对于每个变量都遵循先声明后使用的原则就行。
与局部变量的概念相对的是全局变量(Global Variable),全局变量定义在所有的函数体之外,它们在程序开始时分配存储空间,在程序结束时释放存储空间,在任何函数中都可以访问全局变量。例如:
正因为全局变量在任何函数中都可以访问,所以在程序运行过程中全局变量被读写的程序从源代码中是看不出来的,源代码的书写顺序并不能反映函数的调用顺序。程序出现了Bug往往就是因为在某个不起眼的地方对全局变量的读写顺序不正确,如果代码规模很大,这种错误是很难找到的。而对局部变量的访问不仅局限在一个函数内部,而且局限在一次函数调用之中,从函数的源代码很容易看出访问的先后顺序是怎样的,所以比较容易找到Bug。因此,虽然全局变量用起来很方便,但一定要慎用,能用函数代替的就不要用全局变量。
如果全局变量和局部变量重名了会怎样呢?
则第一次调用print_time打印的是全局变量的值,第二次直接调用printf打印的则是main函数局部变量的值。在C语言中每个标识符都有特定的作用域,全局变量是定义在所有函数体之外的标识符,它的作用域从定义的位置开始知道源文件结束,而main函数局部变量的作用域仅限于main函数之中。到目前为止我们在初始化一个变量时都是用常量做Initializer,其实也可以用表达式做Initializer,但要注意一点:局部变量可以用类型相符的任意表达式来初始化,而全局变量只能用常量表达式初始化。例如,全局变量pi这样初始化是合法的:double pi = 3.14 + 0.00156;但这样初始化是不合法的:double pi = acos(-1.0);
然而局部变量这样初始化却是可以的。
程序开始时要用适当的值来初始化全局变量,所以初始值必须保存在编译生成的可执行文件中,因此初始值在编译时就要计算出来,然而double pi = acos(-1.0);的Initializer的值必须在程序运行时调用acos函数才能得到,所以不能用来初始化全局变量。
请注意区分编译时和运行时这两个概念。为了简化编译器的实现,C语言从语法上规定全局变量只能用常量表达式来初始化,因此下面之中全局变量初始化时不合法的:
Int minute = 360; int hour = minute /60;
虽然在编译时计算出hour的初始值是可能的,但是minute /60不是常量表达式,不符合语法规定,所以编译器不必想办法去算这个初始值。
如果全局变量在定义时不初始化则初始值是0,如果局部变量在定义时不初始化则初始化值是不确定的。所以,局部变量在使用之前一定要先赋值,如果基于一个不确定的值做后续计算肯定会引入Bug。
局部变量的存储空间在每次函数调用时分配,在函数返回时释放?
第一次调用foo函数,分配变量i的存储空间,然后打印i的值,由于i未初始化,打印出来的应该时一个不确定的值,然后把i赋值为77,函数返回,释放i的存储空间。第二次调用foo函数,分配变量i的存储空间,然后打印i的值,由于i未初始化,如果打印的又是一个不确定的值,就证明了“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”。先编译,其实编译时已经有错,默认不报;然后运行程序
结果与假设矛盾,我们在两次foo函数调用之间插入一个别的函数调用,如:printf(“hello\n”);结果就不一样了:
这一回,第二次调用foo打印的i值又不是77了,而是32767,“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”才算正确。
函数声明也可以写在局部作用域中:
这样声明的标识符print_time具有局部作用域,只在main函数中是有效的函数名,出了main函数就不存在print_time这个标识符了。
在写函数声明时,参数可以只写类型而不起名,例如上面代码中的void print_time(int, int);,只要告诉编译器参数类型是什么,编译器就能为print_time(23, 59)函数调用生成正确的指令。另外注意,虽然在一个函数体中可以声明另一个函数,但不能定义另一个函数,C语言不允许嵌套定义函数。