一、前言
函数这一章节在C语言的地位里非常之高。
对于程序员来说,写的项目基本都是由函数封装实现,离开函数基本就与写好代码无缘了。
实际上第一次写C语言时,各位编写的main主函数以及printf打印函数就属于函数的范畴。
二、本章重点
以下就是我们函数这一章节的重点,主要掌握函数的基本使用和递归。
- 函数是什么
- 库函数
- 自定义函数
- 函数的参数
- 函数的调用
- 函数的嵌套调用和链式访问
- 函数的声明和定义
- 函数递归
三、函数是什么
数学中我们常见到函数的概念。但是你了解C语言中的函数吗?
维基百科中对函数的定义:子程序
- 在计算机科学中,子程序是一个大型程序中的某部分代码,由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代码,具备相对的独立性。
- 一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软件库。
Note:C语言中函数的分类
- 库函数(C语言库提供给我们的函数,包含头文件后我们可以直接使用)
- 自定义函数(为完成某种特定需求,需要我们自己创造的函数)
四、库函数
1.为什么会有库函数?
- 在我们学习C语言编程的时候,总是在一个代码编写完成之后迫不及待的想知道结果,想把这个结果打印到我们的屏幕上看看。这个时候我们会频繁的使用一个功能:将信息按照一定的格式打印到屏幕上(printf)。
- 在编程的过程中我们会频繁的做一些字符串的拷贝工作(strcpy)。
- 在编程时我们也计算,总是会计算n的k次方这样的运算(pow)。
像上面我们描述的基础功能,它们不是业务性的代码。我们在开发的过程中每个程序员都可能用的到,为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发。
试想一下,如果不提供库函数,每次实现打印功能都我们需要自己实现,这样会导致代码编写效率太低且五花八门不标准化。
2.怎么学习并使用库函数呢?
在这里,我向大家提供一个库函数的查询网站:
Reference - C++ Reference (cplusplus.com)https://legacy.cplusplus.com/reference/
进入该网站后,我们就会见到常用的C语言标准库。
将库函数进行简单的总结,可以分为几个大类:
总结C语言常用的库函数 | 例子 |
IO函数(输入输出函数) | printf、scanf、getchar、putchar等 |
字符串操作函数 | strlen、strcpy、strcmp、strcat等 |
字符操作函数 | tolower、toupper等 |
内存操作函数 | memcpy、memset、memmove、memcmp等 |
时间/日期函数 | time等 |
数学函数 | sqrt、abs、pow等 |
其他库函数 |
Note:虽然我们不需要记住所有的库函数,但是我们需要学会查询工具的使用,以方便我们日后想完成某种功能调用库函数就可以了。英文很重要,最起码得看懂文献。
下面我们参照文档,学习几个库函数,掌握如何使用文档学习库函数。
①strcpy
功能 | 复制字符串 将source指向的C字符串复制到destination指向的数组中,包括终止空字符(并在该点停止)。 为避免溢出,destination指向的数组的大小应足够长,以包含与source相同的C字符串(包括终止空字符),并且不应在内存中与source重叠。 |
参数 | destination:指向要在其中复制内容的目标数组的指针。 source:要复制的C字符串。 |
返回值 | 返回destination。 |
接下来,我们可以编写代码实践一下:
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[20] = { 0 }; // 目标空间
char arr2[] = "hello";
strcpy(arr1, arr2);
printf("%s\n", arr1);
return 0;
}
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[] = "xxxxxxxxxx"; // 调试 验证是否拷贝字符串结束标志
char arr2[] = "hello";
strcpy(arr1, arr2);
return 0;
}
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[20] = "xxxxxxxxxxxxx"; // 目标空间
char arr2[] = "hello";
char* ret = strcpy(arr1, arr2); // 接收strcpy函数的返回值
printf("%s\n", arr1);
printf("%s\n", ret); // 返回值为arr1数组的地址
return 0;
}
Note:使用库函数,必须包含#include对应的头文件。
②memset
功能 | 填充内存块 将ptr指向的内存块的前num个字节数的值设置为指定值value(解释为无符号字符)。 |
参数 | ptr:指向要填充的内存块的指针。 value:要设置的值。该值作为int传递,但该函数使用此值的unsigned char转换填充内存块。 num:要设置value的字节数。size_t是无符号整数类型。 |
返回值 | 返回ptr。 |
接下来,我们可以编写代码实践一下:
#include<stdio.h>
#include<string.h>
int main()
{
char arr[20] = "hello bit"; // 改为xxxxx bit
char* ret = (char*)memset(arr, 'x', 5); // 注意以字节为单位进行填充
printf("%s\n", arr);
printf("%s\n", ret);
return 0;
}
#include<stdio.h>
#include<string.h>
int main()
{
int arr[10] = { 0 };
memset(arr, 1, 5); // 调试发现不是1 1 1 1 1 0 0 0 0 0,注意以字节为单位
return 0;
}
五、自定义函数
如果库函数能干所有的事情,那还要程序员干什么?
日常写代码的需求各种各样,所以更加重要的是自定义函数。
自定义函数和库函数一样,有函数名、返回值类型和函数参数。 但是不一样的是这些都是我们自己来设计,这给程序员一个很大的发挥空间。
1.函数的组成
ret_type fun_name(para1, *)
{
statement; // 语句项
}
ret_type 返回类型
fun_name 函数名
para1 函数参数
我们举几个栗子:
<1>写一个函数可以找出两个整数中的最大值
#include<stdio.h>
int get_max(int x, int y) // x、y形式参数
{
return (x > y ? x : y); // 与函数返回类型保持统一
}
int main()
{
int num1 = 0;
int num2 = 0;
scanf("%d%d", &num1, &num2);
// 求2个数的较大值
int max = get_max(num1, num2); // a、b实际参数
printf("max = %d\n", max);
return 0;
}
在这里我们要找两个整数中最大值,就需要传递给函数两个实际参数num1、num2。
这样函数定义时就需要两个形式参数整型x、y来接收传递的参数,并且通过函数的返回值来获取最大值。
<2>写一个函数可以交换两个整形变量的内容
#include <stdio.h>
void Swap1(int x, int y) // err - 传值调用
{
int tmp = 0;
tmp = x;
x = y;
y = tmp;
}
void Swap2(int* px, int* py) // 传址调用
{
int tmp = 0;
tmp = *px;
*px = *py;
*py = tmp;
}
int main()
{
int num1 = 1;
int num2 = 2;
Swap1(num1, num2); // 实参num1和num2传给形参x和y的时候,形参将是实参的一份临时拷贝。改变形参变量x和y,是不会影响实参num1和num2的。
printf("Swap1::num1 = %d num2 = %d\n", num1, num2);
Swap2(&num1, &num2); // 通过地址与形参建立联系
printf("Swap2::num1 = %d num2 = %d\n", num1, num2);
return 0;
}
在这里我们发现,Swap1并没有实现两数的交换,Swap2实现了两数的交换,那么这两个函数有什么不同呢?
首先我们来看Swap1这个函数,实际上,形参x、y的内存空间和实参num1、num2的内存空间是独立的(地址不一样);x、y在传参之后与num1、num2再没有任何的联系,因此函数只实现了x、y的交换,并未交换num1、num2。
因此,当实参传递给形参时,形参其实是实参的一份临时拷贝,对形参的修改是不会影响实参的。
然后我们来看Swap2这个函数,通过传递实参num1、num2的地址,形参用指针变量px、py来接收,通过解引用就可以实现对实参num1、num2的改变。
因此,当需要对实参进行修改时,可以通过传递实参地址实现函数内部改变外部变量的值。
六、函数的参数
1.实际参数(实参)
真实传给函数的参数,叫实参。实参可以是:常量、变量、表达式、函数等。无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参。
2.形式参数(形参)
形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
上面Swap1和Swap2函数中的参数x,y,px,py都是形式参数。在main函数中传给Swap1的num1,num2和传给Swap2函数的&num1,&num2是实际参数。
这里我们对函数的实参和形参进行分析,代码对应的内存分配如下:
这里可以看到Swap1函数在调用的时候,x、y拥有自己的空间,同时拥有了和实参一模一样的内容。所以我们可以简单的认为:形参实例化之后其实相当于实参的一份临时拷贝。
七、函数的调用
1.传值调用
- 函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参。
当我们不操控实参就可以传值调用,比如printf函数。
2.传址调用
- 传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。
- 这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
要在函数内部改变外面的变量就需要传址调用,比如scanf函数。
3.练习
<1>写一个函数,可以判断一个数是不是素数。
#include <stdio.h>
#include <math.h>
int is_prime(int n) // 如果是素数,返回1;如果不是素数,返回0
{
// 判断n是否为素数
int j = 0;
for (j = 2; j <= sqrt(n); j++)
{
if (n % j == 0)
return 0;
}
return 1;
}
int main()
{
int i = 0;
scanf("%d", &i);
if (is_prime(i) == 1)
printf("%d是素数\n", i);
else
printf("%d不是素数\n", i);
return 0;
}
<2>写一个函数,判断一年是不是闰年。
#include <stdio.h>
// 法一
int is_leap_year(int y) // 如果是闰年,返回1;如果不是闰年,返回0
{
// 判断y是否为闰年
if (((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0))
{
return 1;
}
return 0;
}
// 法二
int is_leap_year(int y)
{
if (y % 4 == 0)
{
if (y % 100 != 0)
{
return 1;
}
}
if (y % 400 == 0)
{
return 1;
}
return 0;
}
// 错误写法 - 不能用else if
int is_leap_year(int y) // error 2000年是闰年,但是程序却判定不是闰年
{
if (y % 4 == 0)
{
if (y % 100 != 0)
{
return 1;
}
}
else if (y % 400 == 0)
{
return 1;
}
else
{
return 0;
}
}
// 法三 - 最简单
int is_leap_year(int y) // 最简单写法
{
return (((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0));
}
int main()
{
int y = 0;
scanf("%d", &y);
if (is_leap_year(y) == 1)
printf("%d是闰年\n", y);
else
printf("%d不是闰年\n", y);
return 0;
}
<3>写一个函数,实现一个整型有序数组的二分查找。
#include <stdio.h>
int binary_search(int arr[], int k, int sz) // 如果找到了,返回数组下标;如果找不到,返回-1
{
int left = 0;
int right = sz - 1;
while (left <= right)
{
int mid = (left + right) / 2;
if (arr[mid] > k)
{
right = mid - 1;
}
else if (arr[mid] < k)
{
left = mid + 1;
}
else
{
return mid;
}
}
if (left > right)
{
return -1;
}
}
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[0]);
int k = 7;
int ret = binary_search(arr, k, sz); // TDD - 测试驱动开发
if (ret == -1)
printf("找不到\n");
else
printf("该元素在数组的下标为%d\n", ret);
return 0;
}
<4>写一个函数,每调用一次这个函数,就会将num的值增加1。
#include <stdio.h>
void Add(int* p)
{
*p = *p + 1;
}
int main()
{
int num = 0;
Add(&num);
printf("%d\n", num); // 1
Add(&num);
printf("%d\n", num); // 2
Add(&num);
printf("%d\n", num); // 3
Add(&num);
printf("%d\n", num); // 4
return 0;
}
八、函数的嵌套调用和链式访问
函数和函数之间可以有机的组合的。
1.嵌套调用
void new_line()
{
printf("hehe\n");
}
void three_line() {
int i = 0;
for (i = 0; i < 3; i++)
{
new_line();
}
}
int main()
{
three_line();
return 0;
}
Note:函数可以嵌套调用,但不可以嵌套定义(比如不能在main函数内部定义函数)。
int main() // err - 函数不能嵌套定义 { void test() { printf("1"); } return 0; }
2.链式访问
把一个函数的返回值作为另外一个函数的参数。
int main()
{
int len = strlen("abc");
printf("%d\n", len);
printf("%d\n", strlen("abc")); // 函数的链式访问
return 0;
}
int main()
{
char arr1[20] = "xxxxxxxxxxxxx";
char arr2[20] = "abc";
strcpy(arr1, arr2);
printf("%s\n", arr1);
printf("%s\n", strcpy(arr1, arr2)); // 函数的链式访问
return 0;
}
Question:下面代码打印结果是什么?
int main() { printf("%d", printf("%d", printf("%d", 43))); return 0; }
printf的返回值是打印在屏幕上的字符的个数,如果发生错误,将返回负数。
因此屏幕上先打印43,返回值为2;再打印2,返回值为1;最后打印1。
代码的打印结果是4321。
九、函数的声明和定义
1.函数声明
- 函数的声明是告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。
- 函数的声明一般出现在函数的使用之前。要满足先声明后使用。
- 函数的声明一般要放在头文件中的(其他文件用#include包含头文件就可以了)。
首先我们看教科书中函数声明常举的例子:
int Add(int x, int y)
{
int z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int ret = Add(a, b);
printf("%d\n", ret);
}
该代码能实现两数相加,但如果把Add函数定义在main函数后面呢?
int main()
{
int a = 10;
int b = 20;
int ret = Add(a, b);
printf("%d\n", ret);
}
int Add(int x, int y)
{
int z = x + y;
return z;
}
我们会发现编译器报出警告说Add未定义,这是因为编译器只能从第一行开始逐行向下扫描,调用Add函数之前并未扫描到Add函数的定义,因此会报错,这时我们应该在main函数前面声明一下有这个Add函数。
// 函数声明可以省略extern,但变量声明不能省略。
// 函数的声明和定义通过实现函数的函数体的有无来区分,但是变量的声明和定义需要extern来区分
extern int Add(int x, int y);
int main()
{
int a = 10;
int b = 20;
int ret = Add(a, b);
printf("%d\n", ret);
}
int Add(int x, int y)
{
int z = x + y;
return z;
}
但是我们在实际生活中,实现工程中是将函数的定义放在另一个源文件独立出来,将函数的声明放在头文件中,当我们使用该函数时引用该头文件就可以实现了。
如以下代码,我们需要将加法模块独立出来:
// Add.h
// 函数的声明放在头文件Add.h里
#pragma once
#include <stdio.h>
extern int Add(int x, int y);
// Add.c
// 将函数定义放在Add.c里
#include"Add.h"
int Add(int x, int y)
{
int z = x + y;
return z;
}
// test.c
// 包含头文件就相当于把头文件里的内容拷贝过来了,头文件中函数声明则在test.c中就声明了
#include"Add.h" // 类似于使用库函数,使用Add函数需要包含Add.h头文件
int main()
{
int a = 10;
int b = 20;
int ret = Add(a, b);
printf("%d", ret);
return 0;
}
以上例子,我们就将加法函数模块独立出来了。
有些同学可能会有疑问,我们将函数都在test.c里实现不就好了,为什么还整出Add.h,Add.c……这么多文件来实现呢?这样不就更复杂了吗?
实际上我们分模块去写,有两个好处:
<1>可以实现多人协作
假设项目组5个人需要写代码实现计算器的功能(有加法、减法、乘法、除法等),如果都在test.c里写代码,张三写完交给李四,李四写完再交给王五,这样实现很容易乱套且效率不高。
因此项目组可以将计算器的功能分模块交给不同人去完成,加法模块(add.h,add.c)交给张三写,减法模块(sub.h,sub.c)交给李四写,乘法模块(mul.h,mul.c)交给王五写,除法模块(div.h,div.c)交给赵六去写,大家互相不冲突。
当独立的模块分别实现好之后,再让test.c去调用所有模块(#include "add.h"、#include "sub.h"、#include "mul.h"、#include "div.h"),最后经过编译(生成test.obj、add.obj、sub.obj、mul.obj、div.obj)、链接(将所有的.obj链接起来)生成可执行程序(test.exe)。
类似于生产汽车,一家公司生产电池,另一家公司生产玻璃……最后将所有零件组合起来就形成一台车了。
<2>对源码封装和隐藏
假设项目组需要求三角函数的功能,但是求三角函数的代码很复杂,项目组没有能力实现,此时项目组就可以将求三角函数的功能外包给其他能够实现该功能的人,购买别人的代码。
这个有能力的人已经开发出来求三角函数的功能,把实现该功能的代码写完了(tri.h,tri.c)。当项目组要买这个人的代码时,这个人说:“我不能把源码卖给你,但是我又愿意把求三角函数的模块卖给你,你能用但是看不到源码。”
那么所谓的卖功能模块但不卖源码(对源码封装和隐藏)该怎么做呢?
首先,这个人不能只卖tri.h,因为tri.h只有函数声明没有函数实现的主体,根本无法实现加法功能。其次,函数实现的主体在tri.c中,但是这个人不能卖tri.c。
为了解决这个问题,这个人编译产生静态库(Library)卖给项目组,其实是一段编译好的二进制代码(加密看不懂),加上头文件就可以供项目组使用。
产生静态库的操作如下:
- 首先创建一个项目tri,然后将实现好的tri.h,tri.c文件拷贝放在项目的模块里面去。
- 其次再打开该项目,把这两个文件在VS2019加入该项目(这个项目里面没有主函数,因此这个代码压根就是不能运行的,但是可以编译)。
- 接着把该项目属性中的配置类型从应用程序(.exe)改为静态库(.lib),编译后会生成一个tri.lib的文件。
- 最终将tri.lib和tri.h打包。
项目组将买来的tri.lib和tri.h拷贝放在test工程文件夹下,然后打开这个工程,我们把tri.h加到工程里,tri.lib就没必要加在工程里(加进去也看不懂)。
然后在test.c里加入下面一行代码来导入静态库,否则还是无法使用该功能。
#pragma comment(lib, "tri.lib")
2.函数定义
- 函数的定义是指函数的具体实现,交待函数的功能实现。
add.h的内容 放置函数的声明
// 头文件的包含相当于将头文件的内容拷贝到源文件里,包含了多少次就在源文件里拷贝了多少次
// 以下这三行代码说明你源文件不管包含多少次该头文件,最终只包含一次
#ifndef __ADD_H__ //如果已经定义了add.h的话,判断为假,不接着往下进行
#define __ADD_H__ //如果没有定义add.h的话,就进来define,和if-else语句有些相似
// 这三句话的功能等价于#pragma once
// 函数的声明
extern int Add(int x, int y);
#endif // 结束if
// #pragma once 或者 #ifndef #define #endif 经常用到,防止头文件多次包含
// 比如add.c、sub.c、mul.c、div.c都包含了stdio.h,最后test.c调用这四个功能时包含4次stdio.h,这样代码效率将大大降低
add.c的内容 放置函数的实现
#include "add.h"
// 函数Add的实现
int Add(int x, int y)
{
return x + y;
}
十、函数递归
1.什么是递归?
程序调用自身的编程技巧称为递归(recursion)。递归做为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的主要思考方式在于:把大事化小。
2.递归的两个必要条件
- 存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
3.递归练习
<1>接受一个整型值(无符号),按照顺序打印它的每一位。 例如输入1234,输出1 2 3 4
#include<stdio.h>
void print(unsigned int n)
{
if (n > 9) // 存在限制条件,当满足这个限制条件的时候,递归便不再继续。无此限制死循环栈溢出
{
print(n / 10); // 每次递归调用之后越来越接近这个限制条件。若print(n)死循环
}
printf("%d ", n % 10);
}
int main()
{
unsigned int num = 0;
scanf("%u", &num);
print(num);
return 0;
}
分析思路图解:
Note:如果没有函数递归的限制条件,死递归将会导致Stack overflow,即栈溢出。
在我们内存区域中,大体划分为栈区、堆区和静态区。栈区存放局部变量、函数的形式参数(进函数创建,出函数销毁)等;堆区动态内存分配的,涉及到malloc、calloc,realloc、free(这块空间想怎么分配空间就怎么分配)等;静态区存放全局变量、静态变量等。
所谓栈溢出,就是栈区的空间满了。每一次函数调用都要在栈区中开辟空间(即栈帧的创建,每个函数开辟的运行空间我们叫作栈帧),运行时堆栈。无限递归时,栈区的空间将会耗干,最终会导致栈溢出。因此每次递归应该逐渐接近限制条件。
函数调用结束后就会归还栈区的空间,即栈帧的销毁。
<2>编写函数不允许创建临时变量,求字符串的长度。
int my_strlen(char* s)
{
if (*s != '\0')
{
return 1 + my_strlen(s + 1);
}
else
{
return 0;
}
}
int main()
{
char arr[10] = "abcdef";
// 数组名arr是数组首元素的地址 - char*
printf("%d\n", my_strlen(arr));
return 0;
}
4.递归与迭代
<3>求n的阶乘。(不考虑溢出)
int factorial(int n)
{
if (n <= 1)
return 1;
else
return n * factorial(n - 1);
}
int main()
{
int num = 0;
scanf("%d", &num);
printf("%d", factorial(num));
return 0;
}
但是我们发现递归写阶乘有问题:使用factorial函数求10000的阶乘(不考虑结果的正确性),程序会崩溃。
在调试factorial函数的时候,如果你的参数比较大,那就会报错:stack overflow(栈溢出)这样的信息。系统分配给程序的栈空间是有限的,但是如果出现了死循环,或者(死递归),这样有可能导致一直开辟栈空间,最终产生栈空间耗尽的情况,这样的现象我们称为栈溢出。
那如何解决上述的问题:
1. 将递归改写成非递归。
2. 使用static对象替代nonstatic局部对象。在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。
int factorial(int n)
{
int result = 1;
while (n > 1)
{
result *= n;
n -= 1;
}
return result;
}
练习四:求第n个斐波那契数。(不考虑溢出)
斐波那契数列:1,1,2,3,5,8,13,21,34,55……(前两个数字之和等于第三个数字)
int fib(int n)
{
if (n <= 2)
return 1;
else
return fib(n - 1) + fib(n - 2);
}
int main()
{
int num = 0;
scanf("%d", &num);
printf("%d", fib(num));
return 0;
}
但是我们发现递归写有问题:
在使用 fib 这个函数的时候如果我们要计算第50个斐波那契数字的时候特别耗费时间。
所以我们不建议采用递归,可以采用迭代实现:
int fib(int n)
{
int result;
int pre_result;
int next_older_result;
result = pre_result = 1;
while (n > 2)
{
n -= 1;
next_older_result = pre_result;
pre_result = result;
result = pre_result + next_older_result;
}
return result;
}
总结
1. 许多问题是以递归的形式进行解释的,这只是因为它比非递归的形式更为清晰。
2. 但是这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性稍微差些。
3. 当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。(比如数据结构中的二叉树,如果递归实现几行就结束了,但是非递归会写很多行)