C语言中的函数-从认识到深入
函数
1.函数的概念
- 函数 这个词我们第一反应大概率会想到数学中的各种函数,一次函数,幂函数,指数函数…
- 但其实,在C语言中也有 函数(function) 的概念,或者叫子程序,对于C语言来说,函数就是一个完成特定程序的一小段代码
1.创建函数,我们可以将一段代码封装起来,并在需要的地方多次调用。这避免了代码的重复编写,提高了代码的重用性。
2.函数可以将程序划分为更小、更易于管理的部分。每个函数都负责执行一个特定的任务,这使得代码更易于理解和维护。
3.将代码划分为各种函数,我们可以为每个函数提供有意义的名称,从而增强代码的可读性。
4.在函数中,我们可以集中处理可能出现的错误,这有助于确保程序在出现错误时能够优雅地退出或恢复。
5.函数提供了一种抽象机制,允许我们隐藏实现细节,只暴露必要的接口。这有助于降低代码的复杂性,并使得代码更易于维护和修改。
6.通过使用函数,我们可以将注意力集中在解决特定问题上,而不是花费大量时间编写重复的代码。这有助于提高开发效率,减少开发时间。
2.库函数
- C语言标准中规定了C语言的各种语法,C语言并不是提供库函数;C语言的国际标准ANSI规定了一些常用的函数标准,被称为库函数,不同的编译器厂商根据ANSI提供的C语言标准就给出了一系列的函数实现内置到编译器中。这些函数就被称为库函数
- 我们常见的
printf
,scanf
就是库函数。以至于一些常见的功能就不再需要程序员自己实现,一定程度上提高了效率; - 各种编译器的标准库中提供了一系列的库函数,这些库函数根据功能的划分,都在不同的头文件中进型了声明:
C 标准库头文件
C 标准库的接口由下列头文件的汇集定义。
头文件 | 解释 |
---|---|
<assert.h> | 条件编译宏,将参数与零比较 |
<complex.h> (C99 起) | 复数算术 |
<ctype.h> | 用来确定包含于字符数据中的类型的函数 |
<errno.h> | 报告错误条件的宏 |
<fenv.h> (C99 起) | 浮点环境 |
<float.h> | 浮点类型的极限 |
<inttypes.h> (C99 起) | 整数类型的格式转换 |
<iso646.h> (C95 起) | 运算符的替代写法 |
<limits.h> | 整数类型的范围 |
<locale.h> | 本地化工具 |
<math.h> | 常用数学函数 |
<setjmp.h> | 非局部跳转 |
<signal.h> | 信号处理 |
<stdalign.h> (C11 起) | alignas 与 alignof 便利宏 |
<stdarg.h> | 可变参数 |
<stdatomic.h> (C11 起) | 原子操作 |
<stdbit.h> (C23 起) | 处理各类型的字节和位表示的宏 |
<stdbool.h> (C99 起) | 布尔类型的宏 |
<stdckdint.h> (C23 起) | 实施带检查整数算术的宏 |
<stddef.h> | 常用宏定义 |
<stdint.h> (C99 起) | 定宽整数类型 |
<stdio.h> | 输入/输出 |
<stdlib.h> | 通用工具:内存管理、程序工具、字符串转换、随机数、算法 |
<stdnoreturn.h> (C11 起) | noreturn 便利宏 |
<string.h> | 字符串处理 |
<tgmath.h> (C99 起) | 泛型数学(包装 math.h 和 complex.h 的宏) |
<threads.h> (C11 起) | 线程库 |
<time.h> | 时间/日期工具 |
<uchar.h> (C11 起) | UTF-16 和 UTF-32 字符工具 |
<wchar.h> (C95 起) | 扩展多字节和宽字符工具 |
<wctype.h> (C95 起) | 用来确定包含于宽字符数据中的类型的函数 |
3.自定义函数
3.1自定义函数的语法形式
ret type fun_name(形式参数)
{
}
ret type
是函数的返回类型fun_name
是函数名- 括号中放的是形式参数
{}
括起来的是函数体
3.2 函数举例
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x+y;
return z;
}
int main()
{
int a = 0;
int b = 0;
//输⼊
scanf("%d %d", &a, &b);
//调⽤加法函数,完成a和b的相加
//求和的结果放在r中
int ret = Add(a, b);
//输出
printf("%d\n", ret);
return 0;
}
- 我们给函数取名为:
Add
,函数Add
需要接收2个整型类型的参数 - 函数的参数部分我们需要交代清楚参数个数,每个参数的类型是什么,形参的名字叫什么
4.形参和实参
- 在C语言中,形参(parameter) 是函数定义中声明的变量,用于在函数体内部接收调用函数时传递的值。实参(argument) 是在函数调用时传递给形参的实际值。
#include <stdio.h>
// 函数定义,其中x和y是形参
void swap(int x, int y) {
int temp = x;
x = y;
y = temp;
// 注意:这里的交换只在函数内部有效,因为x和y是形参的局部副本
printf("内部互换: x = %d, y = %d\n", x, y);
}
int main() {
int a = 5;
int b = 10;
// 函数调用,其中a和b是实参
printf("交换前: a = %d, b = %d\n", a, b);
swap(a, b); // 传递实参a和b给形参x和y
printf("交换后: a = %d, b = %d\n", a, b);
// 注意:由于形参是值的传递,所以main中的a和b的值并没有改变
return 0;
}
void swap(int x, int y)
这是函数定义,其中x和y是形参;swap(a, b);
传递实参a和b给形参x和y,这里的a和b就是实参;
注意:
实际上,如果我们只是定义了swap
函数,但是不去调用的话,那么swap
函数的参数a
和b
只是形式上的存在,不会向内存申请空间,并不会真是存在,所以叫形式参数。形式参数只有在函数被调用的过程中为了存放实参传递过来的值,才会向内存申请空间,这个过程也叫做形参的实例化。
4.1 形参与实参的关系
- 形参和实参各自是独立的内存空间。
- 这个现象我们通常可以通过编译器的调试功能实现:
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 0;
int b = 0;
//输⼊
scanf("%d %d", &a, &b);
//调⽤加法函数,完成a和b的相加
//求和的结果放在r中
int ret = Add(a, b);
//输出
printf("%d\n", ret);
return 0;
}
我们可以通过调试观察到,x
和y
确实得到了a
和b
的值,但是x
和y
的地址和a
和b
不一样,所以我们可以理解为形参是实参的一份临时拷贝。
- 所以我们得出结论,形参和实参是完全不同的内存空间,形参的修改不影响实参
5. return语句
在函数设计过程中,函数中经常会出现return
语句,return
语句用于从函数中返回一个值,并结束函数的执行。return
语句通常出现在函数的末尾,用于返回函数的结果给调用者。如果函数没有返回值(即其返回类型为void
),则return
语句可以单独使用,而不带任何返回值,仅仅用于提前退出函数。
return
语句后面可以是一个具体的数值,也可以是一个表达式,如果是表达式,则先执行表达式,在返回表达式的得出的数值;return
语句后面也不跟任何值或式子,通常用于void
类型的函数或提前退出函数;return
返回的值如果和函数的返回类型不一致时,系统通常自动将返回值的值隐式转换为函数的返回类型;return
语句执行之后,函数就彻底返回了,不会再执行后面的代码- 如果函数中存在
if
等分支语句,则要保证每种情况下都有返回值,否则汇出现编译错误;
6. 数组做函数参数
- 当数组作为函数参数时,实际上传递的是数组的指针,而不是整个数组的内容。因为C语言采用值传递的方式,而数组本身是一个连续的内存块,不能直接复制整个数组到函数的参数中。
- 当一个数组作为参数传递给函数时,实际上是传递了数组首元素的地址。函数内部通过这个地址来访问数组中的元素。因此,在函数内部,我们可以通过指针运算来访问和修改数组的元素。
例如:我们写一个函数,将一个整型数组的内容全部置为1,在写一个函数打印数组的内容
// 引入标准输入输出库
#include <stdio.h>
// 定义一个函数,用于将数组中的元素全部设置为-1
void set_arr(int arr[], int sz) {
int i = 0;
// 使用for循环遍历数组,将每个元素设置为-1
for (i = 0; i < sz; i++) {
arr[i] = -1;
}
}
// 定义一个函数,用于打印数组的内容
void Print_arr(int arr[], int sz) {
int i = 0;
// 使用for循环遍历数组,并打印出每个元素的值
for (i = 0; i < sz; i++) {
printf("%d ", arr[i]);
}
// 打印换行符,使输出结果更整洁
printf("\n");
}
int main() {
// 定义一个整型数组,并初始化
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
// 计算数组的长度
int sz = sizeof(arr) / sizeof(arr[0]);
// 调用set_arr函数,将数组中的元素全部设置为-1
set_arr(arr, sz);
// 调用Print_arr函数,打印出数组的内容
Print_arr(arr, sz);
// 程序正常结束,返回0
return 0;
}
数组传参的重点:
1.函数的形式参数要和函数的实参个数匹配;
2.函数的实参是数组,那么形参也可以写成数组形式,通常我们还会创建一个额外的参数来表示数组的大小,以避免越界访问等错误;
3.如果形参是一维数组,那么数组大小可以省略不写;
4.如果形参数组是二维数组,那么行可以省略不写,但列必须要写;
5.数组传参时,形参是不会创建新的数组的;
6.形参操作的数组和实参的数组是同一个数组,因为它们访问的是同一个地址;
7.嵌套调用和链式访问
7.1嵌套调用
- 例如:计算某年某月有多少天?
那么我们就可以设计两个函数:
is_leap_year
:判断年份是否为闰年;
get_days_of_month
:调用了函数is_leap_year
判断是否为闰年之后,计算月份的天数;
#include <stdio.h>
// 判断是否为闰年的函数
int is_leap_year(int year) {
// 如果年份能被4整除但不能被100整除,或者能被400整除,则为闰年
if (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)) {
return 1; // 返回1表示是闰年
}
else {
return 0; // 返回0表示不是闰年
}
}
// 获取某年某月天数的函数
int get_days_of_month(int year, int month)
{
// 定义一个数组,存储每个月的天数(非闰年)
int days[] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
// 初始化day为对应月份的天数
int day = days[month];
// 如果是闰年且月份为2(二月),则天数加1
if (is_leap_year(year) && month == 2) {
day += 1;
}
return day; // 返回该月的天数
}
int main()
{
int year = 0; // 初始化年份变量
int month = 0; // 初始化月份变量
// 从标准输入读取年份和月份,%*c用于跳过中间的空格
scanf("%d%*c%d", &year, &month);
// 调用函数获取指定年月的天数
int day = get_days_of_month(year, month);
// 打印该月天数
printf("%d\n", day);
return 0; // 程序正常结束
}
- main函数调用了函数
scanf
,printf
,get_days_of_month
; get_days_of_month
函数调用了is_leap_yea
r函数;
注意:函数之间可以嵌套调用,但是函数是不能嵌套定义的;
7.2 链式访问
- 链式访问就是:把一个函数的返回值作为另一个函数的参数,像链条一样将函数串联起来。链式访问能够简化代码,使代码更易于阅读和维护。
#include <stdio.h>
int main() {
int length = strlen("abcdefg");//求字符串长度
printf("%d",length);打印长度大小
return 0;
}
上面的代码运用了两条语句将字符串长度进行了输出,那么如果我们直接把strlen
的值,直接作为printf
函数的参数就是链式访问。
#include <stdio.h>
int main() {
strlen("abcdefg");
printf("%d", strlen("abcdefg"));//链式访问
return 0;
}
再看:
#include <stdio.h>
int main()
{
printf("%d", printf("%d", printf("%d", 43)));
return 0;
}
这个代码的关键在于明白printf
函数返回的是什么?
下面是对printf
函数返回值的介绍:
printf
函数返回的是打印在屏幕上的字符个数;- 上述代码,从左往右第一个
printf
函数打印的是第二个printf
的返回值,第二个printf
函数打印的是第三个printf
函数的返回值;- 第三个
printf
打印的是43
,在屏幕上打印了2
个字符,所以返回值为2
; - 第二个
printf
函数打印的是2
,在屏幕上打印了1
个字符,所以返回值为1
; - 第一个
printf
函数打印的是1
;
- 第三个
8.函数的声明和定义
函数声明(也称为函数原型)用于告诉编译器函数的名字、返回类型以及它接受的参数类型和数量。函数声明通常出现在源文件的顶部或其他任何调用该函数之前的地方。这样做的好处是,编译器可以在看到实际的函数定义之前就知道函数的存在,从而可以在其他函数中调用它
- 函数声明的基本语法:
return_type function_name( parameter list );
return_type
是函数返回的数据类型。如果函数不返回任何值,则使用 void 关键字。function_name
是函数的名称。parameter list
包含函数参数的类型、顺序和数量。如果函数没有参数,则使用空括号 ()。
8.1单个文件
- 通常我i们使用函数时,直接将函数写出来就可以使用了。我们定义的函数常写在主函数
main
函数之前,但是如果我们见函数当定义在主函数之后,而我们又恰好在主函数中调用了它,那么编译器就很可能报错,即使程序可以运行。
#include <stdio.h>
is_leap_year(int year) {
if (((year % 4 == 0) && (year % 100 != 0)) || year % 400 == 0) {
return 1;
}
else {
return 0;
}
}
//函数的定义
int get_days_of_month(int year, int month)
{
int days[] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
int day = days[month];
if (is_leap_year(year) && month == 2) {
day += 1;
}
return day;
}
int main()
{
int year = 0;
int month = 0;
scanf("%d%*c%d",&year,&month);
int day = get_days_of_month(year, month);//函数的调用
printf("%d\n",day);
return 0;
}
函数的定义在主函数之前,此时没有有任何问题;
#include <stdio.h>
int main()
{
int year = 0;
int month = 0;
scanf("%d%*c%d",&year,&month);
int day = get_days_of_month(year, month);//函数的调用
printf("%d\n",day);
return 0;
}
int is_leap_year(int year) {
if (((year % 4 == 0) && (year % 100 != 0)) || year % 400 == 0) {
return 1;
}
else {
return 0;
}
}
//函数的定义
int get_days_of_month(int year, int month)
{
int days[] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
int day = days[month];
if (is_leap_year(year) && month == 2) {
day += 1;
}
return day;
}
当我们把函数的调用放到主函数之后,此时编译器就会出现警告信息(如上)
这是因为编译器对源代码进行编译时,从第一行向下读取时,并没有发现函数的定义就调用此函数,就会出现此警告。
解决方法就是,在函数调用之前先声明一下get_days_of_month
函数,声明时我们需要交代:函数名,函数的返回类型,函数参数;
即:int get_days_of_month
int get_days_of_month(int year, int month);//函数声明
#include <stdio.h>
int main()
{
int year = 0;
int month = 0;
scanf("%d%*c%d",&year,&month);
int day = get_days_of_month(year, month);//函数的调用
printf("%d\n",day);
return 0;
}
int is_leap_year(int year) {
if (((year % 4 == 0) && (year % 100 != 0)) || year % 400 == 0) {
return 1;
}
else {
return 0;
}
}
//函数的定义
int get_days_of_month(int year, int month)
{
int days[] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
int day = days[month];
if (is_leap_year(year) && month == 2) {
day += 1;
}
return day;
}
此时函数就编译正常了;
- 函数的定义的原则:先声明后使用;
8.2多个文件
- 在现实工作过程中,我们的代码会有许多,我们通常会根据代码的功能,将代码拆后放在不同文件当中;
- 一般情况下,函数的声明,类型的声明放在
.h
的头文件中,函数的实现会放在.c
的源文件中
例如:
定义.c
文件
int Add(int x,int y)
{
return x + y;
}
声明.h
文件
int Add(int x ,int y);
测试.c
文件
#include <stdio.h>
#include "add.h"
int main()
{
int a = 518;
int b = 2;
//函数调用
int c = Add(a,b);
printf("%d\n",c);
return 0;
}
9.static
和extern
static
和extern
都是C语言中的关键字;static
是静态的的意思,作用:- 修饰局部变量
- 修饰全局变量
- 修饰函数
extern
是用来声明外部符号的;
9.1 作用域和生命周期
- 作用域(scope):变量、函数或类型名在程序中可以被引用的区域。通俗来说就是,一段代码中所用到的名字并不是总是有效可用的,而限定这个名字的可用范围就是这个名字的作用域;
1.局部变量的作用域是变量所在的局部范围;
2.全局变量的作用域是整个项目工程; - 生命周期:变量在内存中存在的时间范围,即变量的创建(申请内存)到变量的销毁(收回内存)之间的一个时间段;
1.局部变量的生命周期:进入作用域变量创建,生命周期开始,出作用域生命周期结束;
2.全局变量的生命周期:整个程序的生命周期;
9.2 static
修饰局部变量
//代码1
#include <stdio.h>
void test() {
int i = 0;
i++;
printf("%d ",i);
}
int main() {
int i = 0;
for(i = 0; i < 5; i++) {
test();
}
return 0;
}
//代码2
#include <stdio.h>
void test() {
static int i = 0;
i++;
printf("%d ", i);
}
int main() {
int i = 0;
for (i = 0; i < 5; i++) {
test();
}
return 0;
}
- 对比代码1和代码2:
1.代码1,test
函数中的局部变量i
是每次进入test
函数后,先创建变量(生命周期的开始)并赋值为0
,然后++
,再打印,出函数的时候变量周期结束(释放空间);
2.代码2,,输出结果有累积的效果,其实test
函数中的i
创建好后,出函数的时候是不会销毁释放内存的,重新进入的时候也不会再创建变量,会直接累积上次的值继续计算输出; - 结论:
static
修饰局部变量会改变变量的生命周期,生命周期的本质改变的是变量的储存类型,原本一个局部变量是储存在内存的栈区,但是被static
修饰后就会储存在静态区,然而储存在静态区的变量和全局变量生命周期是一样的,生命周期就和程序的生命周期一样了,只有程序结束,变量才会销毁,内存才会释放销毁回收。但是作用域不会改变;
9.3 static
修饰全局变量
static
修饰全局变量时,主要的作用是限制该变量的可见性,使其只在定义它的源文件内部可见,即该变量具有文件作用域。以至于其他源文件无法直接访问这个全局变量。
代码一:
fun1.c
的源文件
int globalVar = 42;
test_fun1.c
的源文件
#include <stdio.h>
extern int globalVar; // 声明外部全局变量
int main(){
printf("%d\n",globalVar);
return 0;
}
代码二:
fun2.c
源文件
static int globalVar = 42;
test_fun2.c
源文件
#include <stdio.h>
extern int globalVar; // 声明外部全局变量
int main(){
printf("%d\n",globalVar);
return 0;
}
- 代码一正常,代码二编译时出现链接性错误;
extern
用来声明外部符号,如果一个全局变量的符号在A文件中定义的,在B文件中想要使用,就可以使用extern
在B文件中声明,再进行使用;
总结:
- 一个全局变量被
static
修饰,会使得这个全局变量只能在自己的本源文件中只使用,不能在其他文件中使用(即使已经用extern
进行声明)。 - 本质原因:全局变量默认是具有外部链接属性的,在外部文件中想要使用,只要进行适当的声明即可使用;但是被
static
修饰之后的,外部链接属性会变成内部链接属性,使其只能在自己的所在的源文件内部使用,其它源文件即使声明了也不能正常使用;
9.4 static
修饰函数
代码一:
add.c
源文件
int Add1(int x,int y){
return x + y;
}
test_add.c
源文件
#include <stdio.h>
extern int Add1(int x,int y);
int main(){
printf("%d\n",Add1(5,4));
return 0;
}
代码二:
Add2.c
源文件
static int Add2(int x,int y){
return x + y;
}
test_Add2.c
源文件
#include <stdio.h>
extern int Add1(int x,int y);
int main(){
printf("%d\n",Add2(5,4));
return 0;
}
- 代码一正常运行,代码二出现链接性错误;
static
修饰函数和static
修饰全局变量的功能一模一样,一个函数在整个工程中都可以使用,被static
修饰后就只能在文件内部使用,即使其它文件声明了也无法使用;- 本质原因:函数默认的是具有外部链接属性,使得函数在整个工程中只要适当声明就可以被使用。但是被static修饰之后就变成内部链接属性了,使函数就只能在自己所在源文件内部使用。