文章目录
- 前言
- 一、函数是什么?
- 二、C语言中函数的分类
- 1、库函数
- 2、自定义函数
- 三、函数的参数
- 1、实际参数(实参)
- 2、形式参数(形参)
- 四、函数的调用
- 1、传值调用
- 2、传址调用
- 3、专项练习
- 3.1 素数判断
- 3.2 闰年判断
- 3.3 二分查找
- 3.4 修改数值
- 五、函数的嵌套调用和链式访问
- 1、嵌套调用
- 2、链式访问
- 六、函数的声明和定义
- 1、函数声明
- 2、函数定义
- 七、函数递归
- 1、什么是函数递归?
- 2、函数递归的两个必要条件
- 3、题目的展开与剖析
- 3.1 例题1:打印数字
- 3.2 例题2:求字符串长度
- 3.3 例题3:阶乘求解
- 3.3 例题4:斐波那契数列
- 八、函数栈帧的创建的销毁
前言
在C语言中,这个函数时必不可少的,没有函数没有灵魂,要不然代码就会乱成一团,所以我们要学函数,接下来就开始函数之旅~~
一、函数是什么?
-
数学中我们其实就见过函数的概念,比如:一次函数y=kx+b ,k和b都是常数,给一个任意的x,就得到一个y值。其实在C语言也引入函数(function) 的概念,有些翻译为:子程序,子程序这种翻译更加准确一些。C语言中的函数就是一个完成某项特定的任务的一小段代码。
-
这段代码是有特殊的写法和调用方法的。C语言的程序其实是由无数个小的函数组合而成的,也可以说:一个大的计算任务可以分解成若干个较小的函数(对应较小的任务)完成。同时一个函数如果能完成某项特定任务的话,这个函数也是可以复用的,提升了开发软件的效率。
-
在C语言中我们一般会见到两类函数:
- 库函数
- 自定义函数
二、C语言中函数的分类
1、库函数
- C语言标准中规定了C语言的各种语法规则,C语言并不提供库函数;C语言的国际标准ANSI C规定了一些常用的函数的标准,被称为标准库,那不同的编译器厂商根据ANSI提供的C语言标准就给出了一系列函数的实现。这些函数就被称为库函数。
我们前面内容中学到的printf 、scanf 都是库函数,库函数的也是函数,不过这些函数已经是现成的,我们只要学会就能直接使用了。有了库函数,一些常见的功能就不需要程序员自己实现了,一定程度提升了效率;同时库函数的质量和执行效率上都更有保证。
- 各种编译器的标准库中提供了一系列的库函数,这些库函数根据功能的划分,都在不同的头文件中进行了声明。
- 库函数相关头文件:https://zh.cppreference.com/w/c/header
有数学相关的,有字符串相关的,有日期相关的等,每一个头文件中都包含了,相关的函数和类型等信息,库函数的学习不用着急一次性全部学会,慢慢学习,各个击破就行。
- C语言常用的库函数都有:
- IO函数
- 字符串操作函数
- 字符操作函数
- 内存操作函数
- 时间/日期函数
- 数学函数
- 其他库函数
接下来,我会参照文档,给大家将两个常用的库函数,来教会大家如何入阅读英文文档
- 首先我们看函数原形:
strcpy
char * strcpy ( char * destination, const char * source );
- 再到代码中来看看它的实际应用场景,在使用这个函数的时候需要引入头文件 【string.h】
- 那么怎么看一个库函数改引入什么头文件呢?可以打开上面那个网站,搜索库函数,然后在左面就会有
#include<stdio.h>
#include<string.h>
int main()
{char arr1[] = "###########";char arr2[] = "hello word";//strcpy在拷贝的时候'\0'也会被拷贝过来strcpy(arr1, arr2);printf("%s", arr1);return 0;
}
- 再来看看运行结果
接下去再来看一库函数【memset】
memset
void * memset ( void * ptr, int value, size_t num );
- 一样来看一下代码该如何书写
char arr[] = "hello bit";
memset(arr, 'x', 5);
printf("%s\n", arr);
- 来看一下上面这段代码的执行结果。
注:
但是库函数必须知道的一个秘密就是:使用库函数,必须包含 #include 对应的头文件。
这里对照文档来学习上面几个库函数,目的是掌握库函数的使用方法。
如何学会使用库函数?
需要全部记住吗?
No 需要学会查询工具的使用:
MSDN(Microsoft Developer Network)
en.cppreference.com【英文版】
zh.cppreference.com【中文版】
2、自定义函数
- 如果库函数能干所有的事情,那还要程序员干什么?
- 所以更加重要的是【自定义函数】
- 自定义函数和库函数一样,有函数名,返回值类型和函数参数。但是不一样的是这些都是我们自己来设计。这给程序员一个很大的发挥空间
函数的组成:
ret_type fun_name(para1, * )
{statement;//语句项
}
- ret_type 返回类型
- fun_name 函数名
- para1 函数参数
我们首先来举一个例子:
写一个函数可以找出两个整数中的最大值。
int Get_Max(int x, int y)
{return (x > y ? x : y);
}
int main()
{int a = 0;int b = 0;scanf("%d %d", &a, &b);int max = Get_Max(a, b);printf("二者中的较大值为:%d\n", max);return 0;
}
-
然后给大家讲解一下这个函数
-
接下去我们再来举一个例子,也是函数这一块最经典的案例:【数值交换】
-
将交换两个数这个逻辑单独封装成了一个函数,因为它是作为一个功能出现的
void swap(int x, int y)
{int t = x;x = y;y = t;
}
int main()
{int a = 10;int b = 20;printf("交换前:a = %d, b = %d\n",a, b);swap(a, b);printf("交换后:a = %d, b = %d\n", a, b);return 0;
}
- 来看一下运行结果。可以看到两个数并没有发生交换
- 那有小伙伴就很诧异,这是为什么呢?我们一起来调试分析一下
结论: 形参实例化之后其实相当于实参的一份临时拷贝
-
上面这一个,叫做【传值调用】,函数内部形参的修改是不会影响实参的,接下来我们来讲讲【传址调用】~~
-
先看一下代码,然后我们再通过DeBug来展开分析一下
void swap(int* px, int* py)
{int t = *px;*px = *py;*py = t;
}
- 最后来看一下运行结果,已经交换成功了~~
三、函数的参数
1、实际参数(实参)
-
真实传给函数的参数,叫实参
-
对于实参而言,它可以是【常量】、【变量】、【表达式】、【函数】等。我们到VS里来测试一下
-
无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参
2、形式参数(形参)
- 形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
所以我们可以总结出来一句话:
函数调用时,实参传递给形参,形参是实参的一份临时拷贝,形参的改变不影响实参
四、函数的调用
- 这里我们再来说一下函数的【传值调用】和【传址调用】
1、传值调用
函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参
2、传址调用
- 传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。
- 这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
3、专项练习
3.1 素数判断
- 【素数】又叫质数。素数,指的是大于1的整数中,只能被1和这个数本身整除的数
- 知道了规则,那代码就好写了。我们来输出一下100~200的所有素数。在外部写一个循环,就可以获取100 - 200之间的数字了,然后对于素数的求解,可以单独封装为一个函数
- 首先来思考需要传入哪些参数,很明显:只需要传入被判断的数字即可,然后就要去思考这个函数的返回值,可以这样设定:在函数内部若是判断出其为素数,那么就返回1,否则就返回0,然后在主函数外部进行一个判断即可
int main()
{for (int i = 100; i <= 200; ++i){if (IsPrime(i) == 1){printf("%d是素数\n", i);}}return 0;
}
-
接下去我们来看看函数的部分
-
如果i能够被**2, sqrt(i)**之间的任意数据整除,则i不是素数
-
原因:如果 m 能被 2 ~ m-1 之间任一整数整除,其二个因子必定有一个小于或等于 sqrt(m),另一个大于或等于 sqrt(m)。
-
注意: 在使用sqrt的时候需要引入头文件【math.h】
/*素数判断*/
int IsPrime(int n)
{int j = 0;for (int j = 2; j < sqrt(n); j++){if (n % j == 0)return 0;}return 1;
}
- 然后我们来看一下运行结果
3.2 闰年判断
- 【闰年】公历年份是4的倍数,且不是100的倍数,为普通闰年。公历年份是整百数,且必须是400的倍数才是世纪闰年
- 一样,我们先将主函数写好。需求是输出一下1000~2000之间的所有闰年
for (int i = 1000; i <= 2000; ++i)
{if (Isleap(i) == 1){printf("%d ", i);}
}
- 然后的各方面条件和上面是一样的,便不做详解,我们来看看函数体~~
- 这一块我给出两种方法,第一种就是直接了当一些,若是能被4整数但是不能被100整数,或者可以被400整数的数,那就是一个闰年,此时返回1即可,反之返回0,利用到了【逻辑操作符】
int Isleap(int year)
{if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)){return 1;}return 0;
}
- 第二种方法就是一个if的嵌套判断,这里要注意的一点是对400取余的条件外面要写成【if】,不要写成【else if】,否则两个判断条件就只会进去一个了,那就回漏掉几个年份
int Isleap(int year)
{if (year % 4 == 0){if (year % 100 != 0){return 1;}}if (year % 400 == 0)return 1;
}
- 第三种方法是可以直接return
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
,如果为真,就返回1
,如果为假,就返回0
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
- 来看一下运行结果~~
3.3 二分查找
- 首先我们需要设定一个有序数组,因为二分查找只能在序列有序的情况下才可以进行
int arr[] = { 1,2,3,4,5,6,7,8,9 };
int sz = sizeof(arr) / sizeof(arr[0]);
int k = 7;
- 可以看到,上面除了设定数组以外,我还求出这个数组的元素个数,因为在进行函数传参的时候需要使用到
int pos = BinarySearch(arr, sz, k);
- 来看如何传值以及接收返回值,首先我们需要将这个数组的【首元素地址】传入
- 除了要传入数组的首元素地址之外,还需要传入我在上面求出来的【sz】,也就是数组的个数
//int BinarySearch(int* a, int n, int k) //指针接收地址
int BinarySearch(int a[], int n, int k) //数组接收数组
{int left = 0;int right = n - 1;while (left <= right){int mid = left + (right - left) / 2;if (k > a[mid])left = mid + 1;else if (k < a[mid])right = mid - 1;elsereturn mid;}return -1;
}
3.4 修改数值
- 通过函数内部的修改带动函数外部数组的修改,这里就要使用到我们上面所说的【传址调用】
void change(int* px)
{(*px)++;
}
int num = 0;
change(&num);
printf("num = %d\n", num);
- 在函数外部,我传入了num值的地址,然后在函数体的形参中使用指针来进行接收,此时对这个指针进行解引用就可以获取到外部的实参,就可以直接对其进行修改了
- 这里主要写成
(*px)++
,不能写成*px++
,因为【++】的优先级比【*】来得高,所以会先对这个指针变量进行一个++,然后再对其进行一个解引用的操作,但是当这个指针后移的时候,就已经变成了野指针,此时再去访问这个野指针就会出现问题
- 但是我们上
*px
外的括号去掉再看看
五、函数的嵌套调用和链式访问
1、嵌套调用
函数和函数之间可以根据实际的需求进行组合的,也就是互相调用的
- 首先来看第一个例子~~
void print()
{printf("haha\n");
}void three()
{for (int i = 0; i < 3; ++i){print();}
}
int main()
{three();return 0;
}
函数可以嵌套调用,但是不能嵌套定义
2、链式访问
把一个函数的返回值作为另外一个函数的参数
- 这里我先介绍一下【strlen】这个函数
- 我们到代码里面来用一下
char a[] = "hello world";
int len = strlen(a);
printf("len = %d\n", len);
-
这里要注意一点的是strlen()去求字符串长度的时候是不算’\0’的
-
接着再来介绍一个函数【strcat】
- 接下去我们来看看如何做到链式访问
char a[10] = "hello";
int len = strlen(strcat(a, "bit"));
printf("len = %d\n", len);
- 接下去再来看一下链式访问的例子。你知道这句代码输出的结果是多少吗
printf("%d", printf("%d", printf("43")));
- 为什么这句代码的输出是【4321】呢,我们来看一下【printf】这个函数
- 可以看到对于printf的返回值是输出字符的个数,那这就可以解释通了:对于内层的
printf("43")
输出的字符个数有2个,然后再看外层的printf("%d", printf("43"))
便会在输出一个2,它的返回值就是输出了一个字符,那么整体的这个printf("%d", printf("%d", printf("43")))
就会在输出一个1,作为返回值
六、函数的声明和定义
1、函数声明
- 告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数
声明决定不了。 - 函数的声明一般出现在函数的使用之前。要满足先声明后使用。
- 函数的声明一般要放在头文件中的
- 来看一下代码
int Add(int x, int y);int main()
{int a = 0;int b = 0;scanf("%d %d", &a, &b);int sum = Add(a, b);printf("sum = %d\n", sum);return 0;
}int Add(int x, int y)
{return x + y;
}
2、函数定义
函数的定义是指函数的具体实现,交待函数的功能实现
- 对于上面的代码,我们可以对其做一个分解,将【add】这个功能单独放到一个.c的文件中,然后再写一个.h的文件去声明一下这个函数。因为在日常的项目工程中,是有很多程序员一起写代码的,但是他们不可能在一个.c文件中一起书写,也不能等一个人写好之后另一个人再接着写,这样就会降低开发效率
- 所以我们就有一个分模块编写的说法,也就是在一个头文件中先写好函数的声明和各种库函数的引用和定义,然后每个程序员都可以创立自己的.c文件,然后去书写自己的那段逻辑,最后将大家的代码进行一个整合,就完成了整个项目
七、函数递归
接下去我们来说说函数递归,这也是函数这一块最难理解的内容
1、什么是函数递归?
程序调用自身的编程技巧称为递归( recursion)
只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量
递归的主要思考方式在于:把大事化小
2、函数递归的两个必要条件
- 存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件
3、题目的展开与剖析
3.1 例题1:打印数字
-
【要求】:输入1234,打印1 2 3 4
-
在之前我们学习分支和循环的时候有做过类似的题目,那个时候我们是利用取余【%】和整除【/】倒着打印一个数字的每个数位,也就是下面这段代码
void print1(int num)
{while (num > 0){printf("%d ", num % 10);num /= 10;}
}int main()
{print1(1234);return 0;
}
运行结果如下
- 现在我们要顺着将这个数的每一个数位进行一个打印,这该怎么去做呢?我们来分析一下
- 我们在屏幕上通过scanf输入一个数,然后通过封装好的一个函数传入
print(num)
,即print(1234)
,那既然上面讲到了这个分割数字的思路,这里我们也可以使用这个思路来完成, - 对于
print(1234)
我们可以拆成print(123) 4
- 对于
print(123)
又可以拆成print(12) 3
- 对于
print(12)
又可以拆成print(1) 2
- 那
print(1234)
就被拆成了print(1) 2 3 4
,也就是当这个num < 10为一个个位数时,就做一个打印,否则的话就不断将其以十分之一倍得进行缩小。我们将其转化为代码的形式就【一目了然】了 - 可以看到,这里是使用到了一个递归的逻辑,上面说到递归都是具有结束条件的,若是不符合的话就层层递归下去,知道当前传入的num值为个位数为止才进行一个回调
void print2(int num)
{if (num > 9){print2(num / 10);}printf("%d ", num % 10);
}
运行结果如下
- 我们通过递归展开图再来看看~~
3.2 例题2:求字符串长度
-
【要求】:输入abc,输出其长度为3
-
上面有讲到过
strlen()
这个函数,其可以求出一个字符串的长度,我们首先通过这个函数来试试
int main()
{char str[] = "abc";int len = strlen(str); //利用库函数进行求解printf("len = %d\n", len);return 0;
}
- 接下去的话我们将这个strlen()函数改成my_strlen(),我们自己来实现一下这个底层的逻辑。
- 对于
int len = my_strlen(str);
,我们传入了字符数组str的首元素地址,那上面有讲到过对于地址要使用指针来进行接收,最后还要返回求出的长度,所以对于函数我们可以定义成这样
int my_strlen(char* str)
-
对于str这个字符指针现在是指向传入字符数组的首元素地址,也就是【a】,【\0】是一个字符串的结束标志,可以让这个字符指针不断后移,每一次对其进行解引用看看是否为【\0】即可,有了思路我们就可以写出代码了
-
在这里的话还需要设置计数器,我们我们要去求解这个字符串的长度,因此判断到它不为【\0】的时候就count++,然后让这个字符指针进行后移即可。
int my_strlen(char* str)
{int count = 0;while ((*str) != '\0'){count++; //计数器累加str++; //字符指针后移}return count;
}
- 那我们现在的要求是不可以使用计数器进行求解字符串的长度
- 一样,我们可以使用到上面的分割思想
- 对于
my_strlen(abc)
我们可以拆成1 + my_strlen(bc)
- 对于
my_strlen(bc)
我们可以拆成1 + my_strlen(c)
- 对于
my_strlen(c)
我们可以拆成1 + my_strlen('\0')
- 那
my_strlen(abc)
就相当于是1 + 1 + 1 + 0
。也是使用字符指针去进行一个后移,若其不为【\0】时,就不断对这个字符串进行拆分,然后直到遇到【\0】时便return 0
。接下去就可以写出代码了
int my_strlen(char* str)
{if (*str != '\0'){return 1 + my_strlen(str + 1);}return 0;
}
- 然后来看一下结果吧,上面三段代码的结果都是一样的,所以一起展示了
- 一样,我们来画一下递归展开图
3.3 例题3:阶乘求解
- 【要求】:输入一个数,输出其求阶乘后的结果
- 阶乘就是从从一个数开始乘,慢慢减少,一直乘到1为止,举个例子3! = 3 * (3 - 1) * (3 - 2) = 3 * 2 * 1 = 6
- 也就是当【n <= 1】时,即0的阶乘和1的阶乘最后都是1,当【n >= 2】时,最后的结果就是n去乘以它减1的阶乘,于是我们就可以得出递归的代码
int Func1(int n)
{if (n <= 1)return 1;elsereturn n * Func1(n - 1);
}
- 对于阶乘来说,不仅可以使用递归实现,还可以使用循环的方式来是实现
int Func2(int n)
{int ret = 1;for (int i = 1; i <= n; ++i){ret *= i;}return ret;
}
这里再对上面的递归实现做一个递归展开图的分析
3.3 例题4:斐波那契数列
-
【要求】:输入一个数,输出从1到这个数的斐波那契数
-
斐波那契数就是前两个数加起来等于第三个数
-
这里给出从1~10的斐波那契数列 【1 1 2 3 5 8 13 21 34 55】,可以看出对于1,2两个数来说都是
1
,后面就是数就是前两个数之和,因此我们可以列出下面的公式 -
然后根据这个公式写出代码
int Fib1(int n)
{if (n <= 2)return 1;elsereturn Fib1(n - 2) + Fib1(n - 1);
}
八、函数栈帧的创建的销毁
本内容请看这篇文章——> 反汇编深挖【函数栈帧】的创建和销毁【制作中】
最后,函数的所有内容就到这里就结束了~~
如果有什么问题可以私信我或者评论里交流~~
感谢大家的收看,希望我的文章可以帮助到正在阅读的你🌹🌹🌹