1.函数的概念
一提到函数,我们立马会想到学习数学中的函数,如:y=kx+b。但是C语言的函数和数学中的不一样,维基百科是这样定义C语言函数的:函数,又名子程序。
- 一个大型程序中的某部分代码, 由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代码,具备相对的独立性。
- 一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软件库。
我们为什么要使用函数呢?
首先,函数是可以重复使用的,这就让我们可以不再重复编写代码,大大提高了工作效率;其次,也可以让代码更加模块化,便于后期对代码的修改、完善。
C语言中代码分为两类:
- 库函数:C语言现有的函数
- 自定义函数:根据需求自主设计的函数
2.库函数
2.1 库函数的定义
C语言标准中规定了C语言的各种语法规则,C语言并不提供库函数;C语言的国际标准ANSI C规定了一些常用的函数的标准,被称为标准库,那不同的编译器厂商根据ANSI提供的C语言标准就给出了⼀系列函数的实现。这些函数就被称为库函数。
各种编译器的标准库中提供了一系列的库函数,这些库函数根据功能的划分,都在不同的头文件中进行了声明。
库函数相关头文件:https://zh.cppreference.com/w/c/header
2.2 库函数存在的意义
我们在编写C语言代码的时候,经常会频繁使用一些功能,如
- 将信息按照一定的格式打印到屏幕上(printf)
- 将程序需要的信息输入进程序里(scanf)
- 计算一个字符串的长度(strlen)
像上面的这些基本的功能,在编写程序时经常会用到。所以C语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发,同时库函数的质量和执行效率上都更有保证。
2.3 库函数的学习与使用
C语言中常用的库函数有:IO函数,字符串操作函数,字符操作函数,内存操作函数,时间/日期函数,数学函数,其他库函数。
看到这可能有人会说:“这么多的库函数我不会要一下全背下来吧?”
哈哈!那倒是不用,我们在使用时可以去专门的网站去查阅,慢慢理解、记忆,各个击破就行了。
如:
C/C++官方的链接:https://zh.cppreference.com/w/c/header
Cplusplus.com:https://legacy.cplusplus.com/reference/clibrary/
第一个网站是带中文的,如果英语不好的小伙伴可以去使用这个网站;第二个就是全英文的网站,如果你对自己的英语很有信心,那么就可以去使用这个网站。
举个例子,如 sqrt :
2.3.1 函数的语法
- double 表示函数的返回类型
- sqrt 表示函数的名称
- double x 表示传入函数的参数类型和参数
2.3.2 函数的功能
2.3.3 函数的头文件包含
库函数是在标准库中对应的头文件中声明的,所以库函数的使用,务必包含对应的头文件,不包含是可能会出现⼀些问题的。如包含函数aqrt 的头文件:
2.3.4 函数的实践
代码如下:
#include<stdio.h>
#include<math.h>
int main()
{
double a = 16.0;
double b = sqrt(a);
printf("%lf ", b);
return 0;
}
运行结果如下:
2.3.5 查阅库函数文档的一般形式
-
函数原型
-
函数的功能介绍
-
参数和返回类型说明
-
代码举例
- 代码输出结果
- 相关知识链接
3.自定义函数
有时候我们在编写代码要实现一个功能复杂的程序,库函数中没有相应的函数能解决这个问题,那就需要我们自己来设计自定义函数来实现这个复杂的功能。而且设计自定义函数最能体现一位程序员的能力优劣的,所以自定义函数是非常重要的。
3.1 函数的语法形式
自定义函数的语法形式跟库函数的一样,如:
- ret_type 表示函数返回类型,有时候返回类型是 void,表示什么都不返回
- fun_name 表示函数名称,可自行设计,建议根据函数功能进行命名,避免混淆
- para1 表示形式参数(后面会提到)
- { }中的是函数体,函数体就是完成计算的过程
3.2 函数的举例
我们来设计一个加法函数来深刻认识一下自定义函数。
代码如下:
#include<stdio.h>
int Add(int x, int y)
{
int z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
//调用Add函数
//把Add函数返回值存入c中
int c = Add(a, b);
printf(“%d”, c);
return 0;
}
4.函数的参数
在函数的使用过程中,把函数的参数分为实参和形参。
4.1 实际参数(实参)
在上面的代码中,传给 Add 函数的参数 a、b 就是实际参数,简称实参。
**实参就是真实传给函数的参数;**实参可以是常量、变量、表达式和函数。
无论实参是何种类型的量,在进行该函数调用时,它们都必须有确定的值,以便把这些值传送给形参。
4.2 形式参数(形参)
在上面代码中,定义函数的时候,在函数名 Add 后的括号中写的 x 和 y ,称为形式参数,简称形参。
形式参数就是只定义一个函数,而不去调用的话,函数内的参数只是形式上的存在,不会向内存申请空间,不会真实存在。
形式参数只有在函数被调用的过程中为了存放实参传递过来的值,才向内存申请空间,这个过程就是形式的实例化。
通过代码我们可以深刻地理解实参与形参:
我们通过调试可以看到 a 和 b 的值确实传到了 x 和 y 中,但是它们的地址表示相同的,所以我们可以理解为形参是实参的一份临时拷贝。
5.return 语句
在函数的设计中,我们会经常遇见 return 语句,如果不对了解它的话一不留神就可能写出 Bug,所以我们来讲一下 return 语句的注意事项吧。
- return后边可以是⼀个数值,也可以是⼀个表达式,如果是表达式则先执行表达式,再返回表达式的结果。
- return后边也可以什么都没有,直接写 return; 这种写法适合函数返回类型是void的情况。
- return返回的值和函数返回类型不一致,系统会自动将返回的值隐式转换为函数的返回类型。
- return语句执行后,函数就彻底返回,后边的代码不再执行。
- 如果函数中存在if等分支的语句,则要保证每种情况下都有return返回,否则会出现编译错误。
6.函数的调用
6.1 传值调用
函数的实参和形参分别占有不同的内存块,对形参的修改不会影响实参。
所以我们在不改变函数实参的情况下,可以使用传值调用。
我们来写一个代码说明一下吧:#include<stdio.h>
int sub(int x, int y)
{
return x - y;
}
int main()
{
int a = 0;
int b = 0;
scanf(“%d%d”, &a, &b);
int c=sub(a, b);
printf(“%d”, c);
return 0;
}
上面这个程序中,我们只是调用了 a 和 b 的值,并没有改变它们的数值和属性,所以我们就使用传值调用。
6.2 传址调用
传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。
这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
传值调用需要使用两种操作符:
操作符 & :取地址操作符,功能是获取地址
操作符 * :解引用操作,放在指针前,能将指针内地址直接解引用
同样,我们通过代码来说明一下吧。
如:设计一个两数交换的函数
第一个代码:
#include<stdio.h>
void swap(int x, int y)
{
int num = 0;
num = x;
x = y;
y = num;
}
int main()
{
int a = 10;
int b = 20;
swap(a, b);
printf(“a=%d b=%d”, a, b);
return 0;
}
运行结果为:
通过运行结果,我们发现 a 和 b 的值并没有交换,这就是因为我们只交换了形参的值,并没有改变实参,而形参在函数结束后就被销毁了。
所以我们就得使用传址调用,让函数内部变量可以改变函数外部变量。
第二个代码:
#include<stdio.h>
void swap(int* pa, int* pb)
{
int num = 0;
num = *pa;
*pa = *pb;
*pb = num;
}
int main()
{
int a = 10;
int b = 20;
swap(&a, &b);
printf(“a=%d b=%d”, a, b);
return 0;
}
运行结果为:
7.函数的嵌套调用和链式访问
7.1 嵌套调用
嵌套调用就是函数之间的互相调用,每个函数就像一个乐高零件,正是因为多个乐高的零件互相无缝的配合才能搭建出精美的乐高玩具,也正是因为函数之间有效的互相调用,最后才写出了相对大型的程序。
而且嵌套调用并不神秘,其实在我们刚接触代码的时候就运用过了,如:
#include<stdio.h>
int main()
{
printf(“hello word”);
return 0;
}这个代码中 main 函数调用 printf 函数。哈哈!是不是很惊讶呢?
而我们要注意的:函数可以相互嵌套调用,但是不可以嵌套定义。
7.2 链式访问
链式访问就是将一个函数的返回值作为另外一个函数的参数,像链条一样将函数串起来就是函数的链式访问。
其实链式访问也不难理解,来!让我们举个例子加深理解:
直接把 strlen 的返回值作为函数 printf 的参数,这就形成了一个链式访问。
#include<stdio.h>
int main()
{
printf(“%d”, printf(“%d”, printf(“43”)));
return 0;
}运行结果如下:
这个问题的关键是知道 return 的返回值是多少。
printf函数返回的是打印在屏幕上的字符的个数。
上⾯的例子中,我们就第一个printf打印的是第二个printf的返回值,第二个printf打印的是第三个printf的返回值。
第三个printf打印43,在屏幕上打印2个字符,再返回2 ;
第二个printf打印2,在屏幕上打印1个字符,再放回1 ;
第一个printf打印1 ;
所以屏幕上最终打印:4321。
8.函数的声明与定义
8.1 函数的声明
- 告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。
- 函数的声明一般出现在函数的使用之前。要满足先声明后使用。
- 函数的声明一般要放在头文件中的。
- 函数的定义是种特殊的声明,所以函数定义放在调用之前也是可以的。
8.2 函数的定义
函数的定义是指函数的具体实现,交待函数的功能实现。
#include<stdio.h>
void swap(int* pa, int* pb)
{
int num = 0;
num = *pa;
*pa = *pb;
*pb = num;
}
int main()
{
int a = 10;
int b = 20;
swap(&a, &b);
printf(“a=%d b=%d”, a, b);
return 0;
}
上面代码中main函数上面是函数的定义。注意:函数不能嵌套定义。
8.3 程序的分块化编写
我们以后在公司写代码的时候,代码可能比较多,不会把所有的代码放在一个文件里,会根据代码的功能,将代码放在不同的文件中。
- 函数的声明、类型的声明放在头文件(.h)中
- 函数的实现是放在源文件(.c)中。
test.c
add.h
add.c
运行结果:
程序的分块化编写可以使代码简洁明了,也方便了后期对代码的完善与修复。
9.函数的递归与迭代
9.1 函数递归
什么是递归?
递归是一种解决问题的方法,在 C语言中,递归就是函数自己调用自己。
递归的核心思想是把大事化小的过程。
书写递归的限制条件:
- 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
我们来举例说明一下吧。
求 n 的阶乘:
我们来对题目进行分析:
所以我们就可以推出其递归公式:
这样我们就可以很轻松的写出代码了。
#include<stdio.h>
int Fact(int n)
{
if (n > 0)
{
return n*Fact(n - 1);
}
else
return 1;
}
int main()
{
int n = 0;
scanf(“%d”, &n);
int ret = Fact(n);
printf(“%d”, ret);
return 0;
}
我们输入 5(不能输入过大,过大容易导致栈溢出)
运行结果为:
9.1 函数的迭代
函数的迭代就相当于函数的循环。
也许会有人说:“函数递归用起来很方便呀,为什么还要学习迭代呢?”
因为这里面涉及到内存开销问题。
- 在C语言中每⼀次函数调用,都要需要为本次函数调用在栈区申请⼀块内存空间来保存函数调用期间的各种局部变量的值,这块空间被称为运行时堆栈,或者函数栈帧。
- 函数不返回,函数对应的栈帧空间就⼀直占用,所以如果函数调用中存在递归调用的话,每⼀次递归 函数调用都会开辟属于自己的栈帧空间,直到函数递归不再继续,开始回归,才逐层释放栈帧空间。
- 所以如果采用函数递归的方式完成代码,递归层次太深,就会浪费太多的栈帧空间,也可能引起栈溢出(stack overflow)的问题。
我们通过例子来体现一下吧。
用递归方法来 求第 n 个斐波那契数:
分析:前两个斐波那契数 1,1 已知,第三个斐波那契数等于第一个斐波那契数加上第二个斐波那契数;第四个斐波那契数等于第二个斐波那契数加上第三个斐波那契数;依次类推…
所以我们推出递归公式为:
代码如下:
#include<stdio.h>
int Fib(int n)
{
if (n >= 3)
{
return Fib(n - 1) + Fib(n - 2);
}
else
return 1;
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = Fib(n);
printf("%d", ret);
return 0;
}
输入 10,输出结果为:
输入 50,输出结果为:
我们会发现结果需要很长时间才能算出,这个计算所花费的时间,是我们很难接受的, 这也说明递归的写法是非常低效的。
这是为什么呢?
并不是 CPU 在偷懒,而是因为递归中的重复计算太多,CPU 根本算不过来
我们在上面的代码中计算一下 Fib(3)的计算次数,来直接见识一下 CPU 计算压力吧。
代码如下:
#include<stdio.h>
int count = 0;
int Fib(int n)
{
if (n == 3)
count++;
if (n >= 3)
{
return Fib(n - 1) + Fib(n - 2);
}
else
return 1;
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = Fib(n);
printf("%d\n", ret);
printf("count=%d\n", count);
return 0;
}
我们输入 40,其运行结果为:
当 n 特别大时,递归产生内存开销会非常大,极易导致栈溢出。
用函数迭代来求第 n 个斐波那契数,代码如下:
#include<stdio.h>
Fib(int n)
{
int a = 1;
int b = 1;
int c = 1;
while (n >= 3)
{
c = a + b;
a = b;
b = c;
n–;
}
return c;
}
int main()
{
int n = 0;
scanf(“%d”, &n);
int ret = Fib(n);
printf(“%d\n”,ret);
return 0;
}
我们输入 40,运行结果为:
我们会发现这次代码运行的很快,效率就高很多了。
递归和迭代的选择:
- 如果使用递归写代码,非常的容易,写出的代码没问题,那就使用递归。
- 如果使用递归写出的问题,是存在明显缺陷的,那就不能使用递归,得用迭代的方法。