C语言入门(三)——简单函数(一)

简介: C语言入门(三)——简单函数

数学函数


在数学中我们用过sin和ln这样的函数,例如Vin(ʌ ,ln1=0等等,在C语言中也可以使用这些函数(ln函数在C标准库中叫做log):

在C语言中使用数字函数

#include <math.h>
#include <stdio.h>
int main(void)
{
 double pi = 3.1416;
 printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), 
log(1.0));
 return 0;
}

编译运行这个程序,结果如下:

1. $ gcc main.c -lm
2. $ ./a.out
3. sin(pi/2)=1.000000
4. ln1=0.000000

在数学中写一个函数有时候可以省略括号,而C语言要求一定要加上括号,例如log(1.0)。在C语言的术语中,1.0是参数(Argument),log是函数(Function),log(1.0)是函数调用(FunctionCall)。sin(pi/2)和log(1.0)这两个函数调用在我们的printf语句中处于什么位置呢?在上一章讲过,这应该是写表达式的位置。因此函数调用也是一种表达式,这个表达式由函数调用运算符(()括号)和两个操作数组成,操作数log是一个函数名(Function Designator),它的类型是一种函数类型(Function Type),操作数1.0是double型的。log(1.0)这个表达式的值就是对数运算的结果,也是double型的,在C语言中函数调用表达式的值称为函数的返回值(Return Value)。总结一下我们新学的语法规则:


表达式 → 函数名


表达式 → 表达式(参数列表)


参数列表 → 表达式, 表达式, ...


现在我们可以完全理解printf语句了:原来printf也是一个函数,上例中的printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), log(1.0))是带三个参数的函数调用,而函数调用也是一种表达式,因此printf语句也是表达式语句的一种。但是printf感觉不像一个数学函数,为什么呢?因为像log这种函数,我们传进去一个参数会得到一个返回值,我们调用log函数就是为了得到它的返回值,至于printf,我们并不关心它的返回值(事实上它也有返回值,表示实际打印的字符数),我们调用printf不是为了得到它的返回值,而是为了利用它所产生的副作用(SideEffect)--打印。C语言的函数可以有Side Effect,这一点是它和数学函数在概念上的根本区别。


Side Effect这个概念也适用于运算符组成的表达式。比如a + b这个表达式也可以看成一个函数调用,把运算符+看作函数,它的两个参数是a和b,返回值是两个参数的和,传入两个参数,得到一个返回值,并没有产生任何Side Effect。而赋值运算符是有Side Effect的,如果把a = b这个表达式看成函数调用,返回值就是所赋的值,既是b的值也是a的值,但除此之外还产生了Side Effect,就是变量a被改变了,改变计算机存储单元里的数据或者做输入输出操作都算Side Effect。


回想一下我们的学习过程,一开始我们说赋值是一种语句,后来学了表达式,我们说赋值语句是表达式语句的一种;一开始我们说printf是一种语句,现在学了函数,我们又说printf也是表达式语句的一种。随着我们一步步的学习,把原来看似不同类型的语句统一成一种语句了。学习的过程总是这样,初学者一开始接触的很多概念从严格意义上说是错的,但是很容易理解,随着一步步学习,在理解原有概念的基础上不断纠正,不断泛化(Generalize)。比如一年级老师说小数不能减大数,其实这个概念是错的,后来引入了负数就可以减了,后来引入了分数,原来的正数和负数的概念就泛化为整数,上初中学了无理数,原来的整数和分数的概念就泛化为有理数,再上高中学了复数,有理数和无理数的概念就泛化为实数。坦白说,到目前为止本书的很多说法都是不完全正确的,但这是学习理解的必经阶段,到后面的章节都会逐步纠正的。


程序第一行的#号(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.h中声明的库函数还有一点特殊之处,gcc命令行必须加-lm选项,因为数学函数位于libm.so库文件中(这些库文件通常位于/lib目录下),-lm选项告诉编译器,我们程序中用到的数学函数要到这个库文件里找。本书用到的大部分库函数(例如printf)位于libc.so库文件中,使用libc.so中的库函数在编译时不需要加-lc选项,当然加了也不算错,因为这个选项是gcc的默认选项。关于头文件和库函数目前理解这么多就可以了,到后面再做详解。

注意: C标准库和glibcC标准主要由两部分组成,一部分描述C的语法,另一部分描述C标准库。C标准库定义了一组标准头文件,每个头文件中包含一些相关的函数、变量、类型声明和宏定义。要在一个平台上支持C语言,不仅要实现C编译器,还要实现C标准库,这样的实现才算符合C标准。不符合C标准的实现也是存在的,例如很多单片机的C语言开发工具中只有C编译器而没有完整的C标准库。

在Linux平台上最广泛使用的C函数库是 glibc,其中包括C标准库的实现,也包括第三部分介绍的所有系统函数。几乎所有C程序都要调用 glibc的库函数,所以 glibc是Linux平台C程序运行的基础。 glibc提供一组头文件和一组库文件,最基本、最常用的C标准库函数和系统函数在 libc.so库文件中,几乎所有C程序的运行都依赖于 libc.so,有些做数学计算的C程序依赖于 libm.so,以后我们还会看到多线程的C程序依赖于 libpthread.so。以后我说 libc时专指 libc.so这个库文件,而说 glibc时指的是 glibc提供的所有库文件。 glibc并不是Linux平台唯一的基础C函数库,也有人在开发别的C函数库,比如适用于嵌入式系统的 uClibc。

自定义函数


我们不仅可以调用C标准库提供的函数,也可以定义自己的函数,事实上我们已经这么做了:我们定义了main函数。例如:

int main(void)
{
 int hour = 11;
 int minute = 59;
 printf("%d and %d hours\n", hour, minute / 60);
 return 0;
}

main函数的特殊之处在于执行程序时它自动被操作系统调用,操作系统就认准了main这个名字,除了名字特殊之外,main函数和别的函数没有区别。我们对照着main函数的定义来看语法规则:


函数定义 → 返回值类型 函数名(参数列表) 函数体


函数体 → {语句列表 }


语句列表 → 语句列表项 语句列表项 ...


语句列表项 → 语句


语句列表项 → 变量声明、类型声明或非定义的函数声明


非定义的函数声明 → 返回值类型 函数名(参数列表);


我们稍后再详细解释“函数定义”和“非定义的函数声明”的区别。从结构体开始我们才会看类型声明。


给函数命名也要遵循上一章讲过的标识符命名规则。由于我们定义的main函数不带任何参数,参数列表应写成void。函数体可以由若干条语句和声明组成,C89要求所有声明写在所有语句之前(本书的示例代码都遵循这一规定),而C99的新特性允许语句和声明按任意顺序排列,只要每个标识符都遵循先声明后使用的原则就行。main函数的返回值是int型的,return 0;这个语句表示返回值是0,main函数的返回值是返回给操作系统看的,因为main函数是被操作系统调用的,通常程序执行成功就返回0,在执行过程中出错就返回一个非零值。比如我们将main函数中的return语句改为return 4;再执行它,执行结束后可以在Shell中看到它的退出状态(Exit Status):

1. $ ./a.out 
2. 11 and 0 hours
3. $ echo $?
4. 4

$?是Shell中的一个特殊变量,表示上一条命令的退出状态。关于main函数需要注意两点

main函数定义写成main(){...}的形式,不写返回值类型也不写参数列表,这是Old Style C的风格。Old Style C规定不写返回值类型就表示返回int型,不写参数列表就表示参数类型和个数没有明确指出。这种宽松的规定使编译器无法检查程序中可能存在的Bug,增加了调试难度,不幸的是现在的C标准为了兼容旧的代码仍然保留了这种语法,但读者绝不应该继续使用这种语法。


其实操作系统在调用main函数时是传参数的,main函数最标准的形式应该是int main(int argc, char *argv[]),在指针那里会详细介绍。C标准也允许intmain(void)这种写法,如果不使用系统传进来的两个参数也可以写成这种形式。但除了这两种形式之外,定义main函数的其它写法都是错误的或不可移植的。

关于返回值和return语句我们将在第 1 节 “return语句”详细讨论,我们先从不带参数也没有返回值

的函数开始学习定义和使用函数:

最简单的自定义函数

#include <stdio.h>
void newline(void)
{
 printf("\n");
}
int main(void)
{
 printf("First Line.\n");
 newline();
 printf("Second Line.\n");
 return 0;
}

执行结果是:

1. First Line.
2. 
3. Second Line.

我们定义了一个newline函数给main函数调用,它的作用是打印一个换行,所以执行结果中间多了一个空行。newline函数不仅不带参数,也没有返回值,返回值类型为void表示没有返回值,说明我们调用这个函数完全是为了利用它的Side Effect。如果我们想要多次插入空行就可以多次调用newline函数:

int main(void)
{
 printf("First Line.\n");
 newline();
 newline();
 newline();
 printf("Second Line.\n");
 return 0;
}

如果我们总需要三个三个地插入空行,我们可以再定义一个threeline函数每次插入三个空行:

较简单的自定义函数

#include <stdio.h>
void newline(void)
{
 printf("\n");
}
void threeline(void)
{
 newline();
 newline();
 newline();
}
int main(void)
{
 printf("Three lines:\n");
 threeline();
 printf("Another three lines.\n");
 threeline();
 return 0;
}

通过这个简单的例子可以体会到:

  1. 同一个函数可以被多次调用。
  2. 可以用一个函数调用另一个函数,后者再去调第三个函数。
  3. 通过自定义函数可以给一组复杂的操作起一个简单的名字,例如threeline。对于main函数来说,只需要通过threeline这个简单的名字来调用就行了,不必知道打印三个空行具体怎么做,所有的复杂操作都被隐藏在threeline这个名字后面。
  4. 使用自定义函数可以使代码更简洁,main函数在任何地方想打印三个空行只需调用一个简单的threeline(),而不必每次都写三个printf("\n")

读代码和读文章不一样,按从上到下从左到右的顺序读代码未必是最好的。比如上面的例子,按源文件的顺序应该是先看newline再看threeline再看main。如果你换一个角度,按代码的执行顺序来读也许会更好:首先执行的是main函数中的语句,在一条printf之后调用了threeline,这时再去看threeline的定义,其中又调用了newline,这时再去看newline的定义,newline里面有一条printf,执行完成后返回threeline,这里还剩下两次newline调用,效果也都一样,执行完之后返回main,接下来又是一条printf和一条threeline。如下图所示:

函数调用的执行顺序

image.png

读代码的过程就是模仿计算机执行程序的过程,我们不仅要记住当前读到了哪一行代码,还要记住


现在读的代码是被哪个函数调用的,这段代码返回后应该从上一个函数的什么地方接着往下读。


现在澄清一下函数声明、函数定义、函数原型(Prototype)这几个概念。比如voidthreeline(void)这一行,声明了一个函数的名字、参数类型

和个数、返回值类型,这称为函数原型。在代码中可以单独写一个函数原型,后面加;号结束,而不写函数体,例如:

void threeline(void);

这种写法只能叫函数声明而不能叫函数定义,只有带函数体的声明才叫定义。上一章讲过,只有分配存储空间的变量声明才叫变量定义,其实函数也是一样,编译器只有见到函数定义才会生成指令,而指令在程序运行时当然也要占存储空间。那么没有函数体的函数声明有什么用呢?它为编译器提供了有用的信息,编译器在翻译代码的过程中,只有见到函数原型(不管带不带函数体)之后才知道这个函数的名字、参数类型和返回值,这样碰到函数调用时才知道怎么生成相应的指令,所以函数原型必须出现在函数调用之前,这也是遵循“先声明后使用”的原则。


在上面的例子中,main调用threeline,threeline再调用newline,要保证每个函数的原型出现在调用之前,就只能按先newline再threeline再main的顺序定义了。如果使用不带函数体的声明,则可以改变函数的定义顺序:

#include <stdio.h>
void newline(void);
void threeline(void);
int main(void)
{
 ...
}
void newline(void)
{
 ...
}
void threeline(void)
{
 ...
}

这样仍然遵循了先声明后使用的原则。


由于有Old Style C语法的存在,并非所有函数声明都包含完整的函数原型,例如void threeline();这个声明并没有明确指出参数类型和个数,所以不算函数原型,这个声明提供给编译器的信息只有函数名和返回值类型。如果在这样的声明之后调用函数,编译器不知道参数的类型和个数,就不会做语法检查,所以很容易引入Bug。读者需要了解这个知识点以便维护别人用Old Style C风格写的代码,但绝不应该按这种风格写新的代码。


如果在调用函数之前没有声明会怎么样呢?有的读者也许碰到过这种情况,我可以解释一下,但绝不推荐这种写法。比如按上面的顺序定义这三个函数,但是把开头的两行声明去掉:

#include <stdio.h>
int main(void)
{
 printf("Three lines:\n");
 threeline();
 printf("Another three lines.\n");
 threeline();
 return 0;
}
void newline(void)
{
 printf("\n");
}
void threeline(void)
{
 newline();
 newline();
 newline();
}

编译时会报警告:

$ gcc main.c
main.c:17: warning: conflicting types for ‘threeline’
main.c:6: warning: previous implicit declaration of ‘threeline’ was 
here

但仍然能编译通过,运行结果也对。这里涉及到的规则称为函数的隐式声明(ImplicitDeclaration),在main函数中调用threeline时并没有声明它,编译器认为此处隐式声明了int threeline(void);,隐式声明的函数返回值类型都是int,由于我们调用这个函数时没有传任何参数,所以编译器认为这个隐式声明的参数类型是void,这样函数的参数和返回值类型都确定下来了,编译器根据这些信息为函数调用生成相应的指令。然后编译器接着往下看,看到threeline函数的原型是void threeline(void),和先前的隐式声明的返回值类型不符,所以报警告。好在我们也没用到这个函数的返回值,所以执行结果仍然正确。

相关文章
|
2天前
|
C语言
【C语言基础篇】字符串处理函数(四)strcmp的介绍及模拟实现
【C语言基础篇】字符串处理函数(四)strcmp的介绍及模拟实现
|
1天前
|
C语言
C语言prinf函数
C语言prinf函数
10 4
|
1天前
|
编译器 程序员 Serverless
|
1天前
|
机器学习/深度学习 C语言
详细解读C语言math.h中常用函数
详细解读C语言math.h中常用函数
|
1天前
|
C语言
C语言刷题(函数)
C语言刷题(函数)
|
1天前
|
存储 C语言
c语言scanf函数用法
c语言scanf函数用法
|
2天前
|
C语言
【海贼王编程冒险 - C语言海上篇】库函数怎样模拟实现?
【海贼王编程冒险 - C语言海上篇】库函数怎样模拟实现?
5 1
|
13小时前
|
存储 C语言
C语言中的printf函数详解
C语言中的printf函数详解
5 0
|
2天前
|
存储 C语言
【C语言基础篇】字符串处理函数(三)strcat的介绍及模拟实现
【C语言基础篇】字符串处理函数(三)strcat的介绍及模拟实现
|
2天前
|
C语言
【C语言基础篇】字符串处理函数(二)strcpy的介绍及模拟实现
【C语言基础篇】字符串处理函数(二)strcpy的介绍及模拟实现