Lesson3:函数
一、前言
函数这一章节在C语言的地位里非常之高。
对于程序员来说,写的项目基本都是由函数封装实现,离开函数基本就与写好代码无缘了。
实际上第一次写C语言时,各位编写的main主函数就属于函数的范畴。
二、本章重点
本章主要掌握函数的基本使用和递归。
<1>函数是什么 |
<2>库函数 |
<3>自定义函数 |
<4>函数的参数 |
<5>函数的调用 |
<6>函数的嵌套调用和链式访问 |
<7>函数的声明和定义 |
<8>函数递归 |
三、函数是什么
数学中我们常见到函数的概念。但是你了解C语言中的函数吗?
维基百科中对函数的定义:子程序
- 在计算机科学中,子程序是一个大型程序中的某部分代码,由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代码,具备相对的独立性。
- 一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软件库。
Note:C语言中函数的分类
【1】库函数(C语言库提供给我们的函数,我们可以直接使用)
【2】自定义函数(为完成某种特定需求,需要我们自己创造的函数)
四、库函数
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 world!";strcpy(arr1, arr2);printf("%s\n", 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[] = "hello world!";memset(arr, 'x', 5);printf("%s\n", arr);return 0;
}
三、自定义函数
如果库函数能干所有的事情,那还要程序员干什么?
日常写代码的需求各种各样,所以更加重要的是自定义函数。
自定义函数和库函数一样,有函数名、返回值类型和函数参数。 但是不一样的是这些都是我们自己来设计,这给程序员一个很大的发挥空间。
函数的组成
ret_type fun_name(para1, *)
{statement; // 语句项
}
ret_type 返回类型
fun_name 函数名
para1 函数参数
我们举几个栗子:
1.写一个函数可以找出两个整数中的最大值
#include<stdio.h> //get_max函数的设计 int get_max(int x, int y) {return (x > y) ? (x) : (y); } int main() {int num1 = 10;int num2 = 20;int max = get_max(num1, num2);printf("max = %d\n", max);return 0; }
在这里我们要找两个整数中最大值,就需要传递给函数的两个参数num1、num2。这样函数的定义时就需要定义两个整型来接收传递的参数,并且通过函数的返回值来获取最大值。
2.写一个函数可以交换两个整形变量的内容
#include <stdio.h> void Swap1(int x, int y) {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);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。因此可以认为,当实参传递给形参时,形参其实是实参的一份临时拷贝,对形参的修改是不会影响实参的。
那么我们怎样让实参发生改变呢?这里就需要传递地址了,当我们把num1和num2的地址传给函数,再通过解引用就可以实现传递了
我们通过地址就可以在函数内部改变外部变量的值。
四、函数的参数
实际参数(实参)
真实传给函数的参数,叫实参。实参可以是:常量、变量、表达式、函数等。无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参。
形式参数(形参)
形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
上面Swap1和Swap2函数中的参数 x,y,px,py 都是形式参数。在main函数中传给Swap1的num1,num2和传给Swap2函数的&num1,&num2是实际参数。
这里我们对函数的实参和形参进行分析:
代码对应的内存分配如下:
这里可以看到Swap1函数在调用的时候,x,y拥有自己的空间,同时拥有了和实参一模一样的内容。所以我们可以简单的认为:形参实例化之后其实相当于实参的一份临时拷贝。
五、函数的调用
传值调用
函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参。
当我们不操控实参就可以传值调用,比如printf函数。
传址调用
传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。
这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
要在函数内部改变外面的变量就需要传址调用,比如scanf函数。
六、函数的嵌套调用和链式访问
嵌套调用
函数和函数之间可以有机的组合的。
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() {char arr[20] = "hello";int ret = strlen(strcat(arr, "bit"));printf("%d\n", ret);return 0; }
strcat的返回值是拼接后字符串的指针,strlen通过返回值给的地址计算字符串长度(遇\0结束)
int main() {printf("%d", printf("%d", printf("%d", 43)));//结果是啥?return 0; }
printf的返回值是打印在屏幕上的字符的个数,如果发生错误,将返回负数。
因此屏幕上先打印43,返回值为2;再打印2,返回值为1;最后打印1。所以上面函数的输出结果为4321。
七、函数的声明和定义
函数声明
首先我们看下面实现加法函数的代码
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 extern int Add(int x, int y);//Add.c //将函数定义放在Add.c里 int Add(int x, int y) {int z = x + y;return z; }//test.c //包含头文件就相当于把头文件里的内容拷贝过来了,头文件中函数声明则在test.c中就声明了 #include<stdio.h> #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.对源码封装和隐藏
假设我需要加法的功能,但是加法的代码很复杂,我不想自己写,我就可以外包给别人,买别人的代码。
有些人已经开发出来这样加法的功能,他们已经把实现该功能的代码写完了(add.h,add.c)。
当我要买这个人的代码时,对方说:“我不能把源码卖给你,如果把源码一次性卖给你的话,你以后就不会买了。但是我又愿意把模块功能卖给你,你能用但你看不到源码。所以我要按年收费,一年十万块,以后代码有bug的话我也免费帮你修。”
那么所谓的把模块但不把源码卖给你(对源码封装和隐藏)这该怎么做呢?
对方不能只卖add.h,这样只有声明没有函数的主体,根本无法实现加法功能,但add.c对方又不卖。所以对方编译产生一个东西(这个东西叫作库,Library)卖给我们,库就是一段编译好的二进制代码,加上头文件就可以供我们使用。
因此对方首先创建一个项目add,然后将实现好的add.h,add.c文件拷贝放在项目的模块里面去,再打开该项目,把这两个文件在VS2019加进去(这个项目里面没有主函数,因此这个代码压根就是不能运行的,但是可以编译),接下来把该项目属性中的配置类型【原来为应用程序(.exe)】改为静态库(.lib),编译后会生成一个add.lib的文件。最终对方将add.lib和add.h打包卖给我们。此时我们可以正常使用模块里的功能,但是又看不到模块的源码。
我们将买来的add.lib和add.h拷贝放在test工程文件夹下,然后打开这个工程,我们把add.h加到工程里,add.lib就没必要加在工程里(加进去你也看不懂),然后在test.c里加入这样一句话#pragma comment(lib, "add.lib")来导入静态库,否则没有add函数定义还是无法编译运行。此时我们发现加法在不需要自己定义的情况下来实现该功能了。
总结
1. 函数的声明是告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。
2. 函数的声明一般出现在函数的使用之前。要满足先声明后使用。
3. 函数的声明一般要放在头文件中的(其他文件用#include包含头文件就可以了)。
函数定义
总结
函数的定义是指函数的具体实现,交待函数的功能实现。
add.h的内容 放置函数的声明
//头文件的包含相当于将头文件的内容拷贝到源文件里,包含了多少次就在源文件里拷贝了多少次 //以下这三句话说明你源文件不管包含多少次该头文件,最终只包含一次 #ifndef __ADD_H__ //如果已经定义了add.h的话,判断为假,不接着往下进行 #define __ADD_H__ //如果没有定义add.h的话,就进来define,和if-else语句有些相似 //这三句话的功能等价于#pragma once//函数的声明 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,这样代码效率将大大降低
test.c的内容 放置函数的实现
#include "test.h"//函数Add的实现 int Add(int x, int y) {return x + y; }
这样从而实现了加法模块
八、函数递归
什么是递归?
程序调用自身的编程技巧称为递归(recursion)。
递归做为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。 递归的主要思考方式在于:把大事化小。
递归的两个必要条件
1.存在限制条件,当满足这个限制条件的时候,递归便不再继续。
2.每次递归调用之后越来越接近这个限制条件。
递归练习
练习一:接受一个整型值(无符号),按照顺序打印它的每一位。 例如输入1234,输出1 2 3 4
#include<stdio.h> void print(unsigned int n) {if (n > 9) //限制条件,如果没有该限制条件将会无终止的递归,导致栈溢出{print(n / 10); //未满足限制条件时再不断地逼近这个限制条件}printf("%d ", n % 10); }int main() {unsigned int num = 1234;scanf("%u", &num);print(num);return 0; }
分析思路:
栈溢出:
如果没有函数递归的限制条件,死递归将会导致Stack overflow,即栈溢出。
在我们内存区域中,大体划分为栈区、堆区和静态区。栈区存放局部变量、函数的形式参数(进函数创建,出函数销毁);堆区动态内存分配的,涉及到malloc、calloc,realloc、free(这块空间想怎么分配空间就怎么分配);静态区存放全局变量、局部变量。
所谓栈溢出,就是栈区的空间满了。每一次函数调用都要在内存中开辟空间(即栈帧的创建,每个函数开辟的运行空间我们叫作栈帧),运行时堆栈。无线递归栈的空间将会耗干了,最终会导致栈溢出。
因此每次递归应该逐渐接近限制条件。
函数调用结束后就会归还栈区的空间,即栈帧的销毁。
练习二:编写函数不允许创建临时变量,求字符串的长度。
非递归实现:
//非递归实现 int my_strlen(char* s) {int count = 0;while (*s != '\0') {count++;s++;}return count; } int main() {char* arr = "abcd";//数组名arr是数组首元素的地址 - 传的参数类型是char*int len = my_strlen(arr);printf("%d\n", len);return 0; }
但题目要求不允许创建临时变量,但是非递归实现创建了临时变量count,所以我们应该用递归实现。
递归实现:
//递归实现 int my_strlen(char* s) {if (*s != '\0') {//字符指针+1 - 向后跳一个字节return 1 + my_strlen(s + 1);//++s可以,表示加了之后传进去;但是s++不行,但在递归不建议用++}return 0; } int main() {char* arr = "abcd";//数组名arr是数组首元素的地址 - 传的参数类型是char*int len = my_strlen(arr);printf("%d\n", len);return 0; }
练习三:求n的阶乘。(不考虑溢出)
int factorial(int n) {if (n <= 1)return 1;elsereturn 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;elsereturn 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. 当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。(比如数据结构中的二叉树,如果递归实现几行就结束了,但是非递归会写很多行)
Lesson3:函数相关推荐
- lesson3 肥胖计算器
lesson3 打包 1.课程内容 1.保留小数 a = float(input("请输入结果:")) print("输出结果为%.2f"%a) 2.组合法有空 ...
- 数据库中自定义排序规则,Mysql中自定义字段排序规则,Oracle中自定义字段排序规则,decode函数的用法,field函数的用法
数据库中自定义排序 场景:有一张banner表,表中有一个status字段,有0, 1, 2三个状态位,我想要 1,0,2的自定义排序(这里是重点),然后再进行之上对sequence字段进行二次排序( ...
- Mysql函数group_concat、find_in_set 多值分隔字符字段进行数据库字段值翻译
Mysql函数group_concat.find_in_set进行数据库字段值翻译 场景 配方表:记录包含的原料 sources表示原料,字段值之间用逗号分隔 原料表:对应原料id和原料名称 现需要查 ...
- C++ 笔记(34)— C++ exit 函数
当遇到 main 函数中的 return 语句时,C++ 程序将停止执行.但其他函数结束时,程序并不会停止.程序的控制将返回到函数调用之后的位置.然而,有时候会出现一些非常少见的情况,使得程序有必要在 ...
- C++ 笔记(30)— 友元函数与友元类
我们知道类的私有成员只能在类的成员函数内部访问,如果想在别处访问对象的私有成员,只能通过类提供的接口(成员函数)间接地进行.这固然能够带来数据隐藏的好处,利于将来程序的扩充,但也会增加程序书写的麻烦. ...
- 浅显易懂 Makefile 入门 (07)— 其它函数(foreach 、if、call、origin )
1. foreach 函数 foreach 函数定义如下: $(foreach <var>,<list>,<text>) 函数的功能是:把参数 <list&g ...
- 浅显易懂 Makefile 入门 (06)— 文件名操作函数(dir、notdir、suffix、basename、addsuffix、addperfix、join、wildcard)
编写 Makefile 的时候,很多情况下需要对文件名进行操作.例如获取文件的路径,去除文件的路径,取出文件前缀或后缀等等. 注意:下面的每个函数的参数字符串都会被当作或是一个系列的文件名来看待. 1 ...
- Go 学习笔记(65)— Go 中函数参数是传值还是传引用
Go 语言中,函数参数传递采用是值传递的方式.所谓"值传递",就是将实际参数在内存中的表示逐位拷贝到形式参数中.对于像整型.数组.结构体这类类型,它们的内存表示就是它们自身的数据内 ...
- Go 学习笔记(61)— Go 高阶函数、函数作为一等公民(函数作为输入参数、返回值、变量)的写法
函数在 Go 语言中属于"一等公民(First-Class Citizen)"拥有"一等公民"待遇的语法元素可以如下使用 可以存储在变量中: 可以作为参数传递给 ...
最新文章
- SUSTechTripleH队墓志铭
- 价值为王,市场需要降温
- Mysql生产指定时间段随机日期函数
- 五十、微信小程序云开发中的云数据库
- 学习计算机游戏编程,在线游戏学编程,游戏编程汇总
- 丰收互联蓝牙key怎么开机_ublox收购Rigado的蓝牙模块业务,扩大蓝牙低功耗产品组合...
- leetcode 1232. 缀点成线
- django与python之间关系_Django 模型中表与表之间关系
- 一元多项式计算器_人教版初中数学七年级上册——去括号、去分母解一元一次方程公开课优质课课件教案视频...
- 2017 年,阿里巴巴开源的那些事儿
- 奥比中光深度摄像头_奥比中光:确认iPhone X前置3D深度摄像头采用结构光方案...
- 3D GAME PROGRAMMING WITH DIRECTX11(3)
- 微服务下蓝绿发布、滚动发布、灰度发布等方案,必须懂!
- Sentinel控制台搭建使用
- Windows下使用命令修改文件权限和所有者
- yum设置 ccproxy 细节
- python pyplot bar 参数_数据可视化之条形图(1):Axes.bar
- 网站被劫持的方式,和检测方法、网站被劫持、检测方法有哪些
- 让玩家提升游戏耐玩度的8个小技巧
- 苏、陕、宁、浙四省主动安全防控/智能视频监控预警设备平台一览