目录
我们已经用过C标准库函数一段时间了,比如说:printf()、scanf()等等,现在呢,我们来具体学习一下函数。深入了解一下它。
1. 函数的概念
首先,什么是函数?函数(function)是完成特定任务的独立代码单元。语法规则定义了函数的结构和使用方式。
为什么要使用函数?首先,函数可以省去编写重复代码的苦差。如果程序要多次完成同一项任务,那么只需要编写一个合适的函数,就可以在需要时使用这个函数,或者在不同程序中使用该函数。其次,函数可以让程序更加模块化,提高了代码的可读性,方便了后期的更改。完善。
在C语言中,我们一般会见到两类函数:
- 库函数
- 自定义函数
2. 库函数
库函数的体量是特别大的,我们是不可能在短时间内将它学习透彻的,但我们不需要死记硬背,随着时间的推移,我们会一点一点的学习到,这是急不来的,就像学习英语一样,是个细水长流的活儿,在这里我就不过多介绍了,(毕竟目前我也就用那么几个)
感兴趣的小伙伴可以自己在官网查看学习。我们在未来遇到不会的可以在以下两个工具中查找:
C/C++官方链接:https://en.cppreference.com/w/
cplusplus.com:https://legacy.cplusplus.com/
那么这些库文档该如何读?其一般格式为:
- 函数原型
- 函数功能介绍
- 参数和返回类型说明
- 代码举例
- 代码输出
- 相关知识链接
3. 自定义函数
函数的大家族内不只有库函数,库函数也不是万能的,我们也可以根据自己的需求来自定义函数
编写自定义函数,就可以创造无限的可能!
函数的形式大体上是一样的,其形式如下:
// 自定义函数
// 这里我们想实现俩个整数求和
// 自己编写一个函数
#include <stdio.h>
int Add(int x, int y)
{
return x + y;
}
// Add 函数名 -- 就是我们给自己编写的函数起的名字,尽量和功能相联系。
// int (Add前的) 返回类型 -- 就是我们我们需要返回的值的类型,类型特别多,这里以int为例。
// 也可以啥都不返回void.
// int x, int y; 形式参数 -- 其中 int 是形式参数的类型 ,x,y就是形式参数,可有可无。有时候给个类型就行。
// { } -- 大括号里面的叫做函数体,在这里面实现自己想实现的功能,
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
int r = Add(a, b); // 调用函数
printf("%d", r);
return 0;
}
以上的Add函数就是自定义函数,其形式就是自定义函数的形式,我们举一反三,就可以自己定义函数了,比如说:减法函数、乘法函数、除法函数等等。
由此,我们在定义函数的时候,要注意参数问题:参数的个数,参数的类型,形参的名字。
未来我们在设计自己的函数的时候,函数名、参数、返回类型的是灵活多变的。
4. 形式参数和实际参数
4.1 实参(实际参数)
在第3节的示例代码中,有一行注释了调用函数,在调用Add函数时,传递给Add的参数a和b就是实际参数,简称实参
顾名思义,实际参数就是真实传递给函数的参数。
4.2 形参(形式参数)
在第3节示例代码中,定义Add函数的时候,在函数名后面的括号中的x、y就是形式参数,简称形参
为什么叫形式参数呢?因为,如果只定义了Add函数,但不调用,函数中的x、y只是形式上存在,不会向内存申请空间,不是真实存在的,所以叫做形式参数。
形式参数只有在函数调用的过程中为了存放实参传递过来的值,才会向内存申请空间,这个过程叫做:形参的实例化。
4.3 形参和实参的关系
虽然实参是传递给形参的,它们之间是有联系的,但是它俩所处的空间是独立的。
就拿第3节的示例代码为例,我们监视一下它们四个参数的地址:
所以我们可以看出以下几点:
- 形参与实参有各自独立的空间
- 形参的修改不会影响实参的值
- 形参是实参的一份临时拷贝
- 形参的名字和实参的名字可以相同,依然处于不同空间
5. return 语句
在函数的设计中,我们经常会用到return语句,我们来详细介绍一下:
1.return后边可以是一个数值,也可以是一个表达式,如果是表达式则先执行表达式,再返回表达式后面的结果。表达式的话,第3节的代码示例就是表达式。
// 编写一个函数,判断用户输入的是否是奇数。
#include <stdio.h>
int is_odd(int n)
{
if (n % 2 == 1)
return 1;
else
return 0;
}
int main()
{
int n = 0;
scanf("%d", &n);
if (is_odd(n))
printf("是奇数\n");
else
printf("是偶数\n");
return 0;
}
2. return后面也可以什么都没有,直接写return;这种写法适合函数的返回类型为void的情况。
// 随便举个例子
#include <stdio.h>
void test(int n)
{
// 如果n 是负数就直接返回
// 如果n>20 就打印haha
// 否则就打印hehe
if (n < 0)
return;
else if (n > 20)
printf("haha\n");
else
printf("hehe\n");
printf("heihei");
}
int main()
{
int n = 0;
scanf("%d", &n);
test(n);
return 0;
}
3. return语句执行后,函数就彻底返回,后面的代码就不再执行。
执行2内的代码,输入-10后直接返回。后面的heihei就直接不执行。
4.return返回的值和函数返回类型不一致,系统会自动将返回的值隐式转换为函数的返回类型。
但是编译器会报警告。
这个时候直接把double类型的值转换为int类型的值,其中会丢失部分数据。
5.如果函数中存在if等分支语句,则需要保证每种情况下都有return返回,否则会有编译错误。(逻辑漏洞)
6.函数的返回类型如果不写,编译器会默认函数的返回类型是int。
建议:如果返回类型明确,就写好;如果没有返回类型,返回类型就写void。
7.函数写了返回类型,但是函数中没有使用return返回值,那么函数的返回值是未知的。
6. 数组做函数参数
在使用函数的时候,难免会将数组作为参数传递给函数,在函数内部对数组进行操作。
就随便举个例子:
// 写一个函数将一个整型数组的内容全部置为-1.并再写一个函数将其打印
#include <stdio.h>
// 将数组置为-1
void set_arr(int arr[], int sz)
{
for (int i = 0; i < sz; i++)
arr[i] = -1;
}
// 打印数组的内容
void print_arr(int arr[], int sz)
{
for (int i = 0; i < sz; i++)
printf("%d", arr[i]);
}
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[0]);
set_arr(arr, sz); // 调用函数
print_arr(arr, sz); // 调用函数
return 0;
}
// -1-1-1-1-1-1-1-1-1-1
在这个例子中,set_arr函数对数组内的元素进行了转换(全部变为-1),在这里面,我们需要遍历数组才能对数组内的每个元素进行赋值,因此我们需要知道数组内元素的个数。所以还需要第2个参数,数组元素的个数。同理,print_arr函数也是一样的。
一维数组的函数传参是这样,那么二维数组呢?二维数组传参的时候,行可以省略,但是列不能省略。其他的都是一个模式。
7. 嵌套调用和链式访问(非常重要!)
7.1 嵌套调用
函数嵌套调用如同搭建积木,每个函数都是独立模块。当开发者将这些模块按需组合、层层嵌套,零散的代码就能串联成流畅的程序逻辑,最终构建出功能完备的应用系统,实现从代码片段到完整程序的蜕变。
举一个例子:确定某年某月有多少天?
题目分析:
- 每一年的每月有多少天是确定的,但是,二月是例外,闰年有29天,平年有28天
- 首先我们需要知道要输出多少天,而在知道具体输出多少天之前,需要知道是什么年
- 需要一个判断是闰年还是平年的程序
- 之后在2月上加1天
// 确定某年某月有多少天
#include <stdio.h>
#include <stdbool.h>
bool is_leap_year(int y)
{
if (((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0))
return true;
else
return false;
}
int get_days_of_month(int y, int m)
{
int day[] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
// 0 1 2 3 4 5 6 7 8 9 10 11 12
int d = day[m];
if (is_leap_year(y) && m == 2)
{
d += 1;
}
return d;
}
int main()
{
int year = 0;
int month = 0;
printf("请输入年份和月份\n");
scanf("%d %d", &year, &month);
int day = get_days_of_month(year, month);
printf("%d", day);
return 0;
}
在这串代码中,每一个函数的功能都是单一的,这样做的原因是,方便函数的重复调用,只要足够单一,它就只完成一个任务,方便组合。
函数是可以嵌套调用的,但是函数是不能嵌套定义的。
7.2 链式访问
把一个函数的返回值作为另一个函数的参数,这就是链式访问。
示例:
// 链式访问
#include <string.h>
#include <stdio.h>
int main()
{
printf("%d", strlen("abcdef")); // 链式访问
return 0;
}
这个就是链式访问,把strlen的返回值直接作为printf的参数。
8. 函数的声明和定义
8.1 单个文件
一般情况下,我们使用函数,就是写出来直接使用了。
就比如:编写一个函数判断是不是闰年。
// 直接写一个函数,判断是不是闰年
#include <stdio.h>
int is_leap_year(int y) // 函数定义
{
if (((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0))
return 1;
else
return 0;
}
int main()
{
int year = 0;
scanf("%d", &year);
if (is_leap_year(year)) // 函数调用
printf("是闰年\n");
else
printf("是平年\n");
return 0;
}
这种情况是函数定义在函数调用之前,程序会正常运行。但是如果把is_leap_year,放在main函数后呢?
编译器会报警告。这是为什么?因为编译器执行程序是从上到下执行的,当遇到函数调用那一行时,编译器就会报警告。那么如何消除警告呢?
就是在函数调用之前先声明一下这个函数,声明函数只需要交代清楚:函数名,函数的返回类型,函数的参数,就行。就是如下:
// 函数声明
// 就是告诉编译器,有一个函数,它的返回类型是什么,参数是什么,名字是什么。
int is_leap_year(int y);
int main()
{
int year = 0;
scanf("%d", &year);
if (is_leap_year(year))
printf("是闰年\n");
else
printf("是平年\n");
return 0;
}
int is_leap_year(int y)
{
if (((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0))
return 1;
else
return 0;
}
至于为什么第一种写法,编译器不报警告,这是因为,函数定义是一种特殊的函数声明,而在函数声明中参数的名字是可以省略的只需要告诉参数类型就行。
所以:函数调用时,需要先声明后使用。
8.2 多个文件
当程序规模变大,单文件代码会变得杂乱难维护。多文件编程能把不同功能拆分到独立文件里,让代码结构清晰、方便修改和重复使用。下面,我们就来学习如何编写多文件程序。
一般情况下,函数的声明、类型的声明放在头文件(.h)中,函数的实现是放在源文件(.c)中的 。
至于如何使用,我们在下一节扫雷游戏中可以见到,在这里就不多加叙述了。只需要看个示例,相信聪明的你一定会在心中有谱的。
8.3 static 和 extern
static和extern都是C语言的关键字。
static是 静态的 可以用来:修饰局部变量,修饰全局变量,修饰函数
extern是用来声明外部符号的。外部符号可以是函数、全局变量等。
在学习static和extern之前,我们先来了解一下:作用域和生命周期。
作用域:(scope)是程序设计概念,通常来说,一段程序代码中所用到的名字并不总不有效(可用)的,而限定这个名字的可用性代码范围就是这个名字的作用域。
- 局部变量的作用域是变量所在的局部范围。
#include <stdio.h>
int main()
{
{
int n = 100;
printf("1: %d", n);
}
// 这个n只能在大括号内使用,也就是作用域
printf("2: %d", n); // err 未定义
return 0;
}
- 全局变量的作用域是整个项目。
#include <stdio.h>
int n = 100;
int main()
{
{
printf("1: %d", n);
}
printf("2: %d", n);
// 这样子,就可以在该项目中任意使用。
return 0;
}
生命周期是变量的创建(申请内存)到变量的销毁(收回内存)之间的一个时间段。
- 局部变量的生命周期是:进入作用域变量的创建,生命周期开始,出作用域生命周期结束。
- 全局变量的生命周期是:整个程序的生命周期。
8.3.1 static修饰局部变量
请看如下,两段代码,试着考虑它们的输出是否一样。
#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;
}
#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 1 1 1 1;1 2 3 4 5。这是为什么?当然是static的作用。
第一段代码:test函数中的局部变量 i 是每次进入test函数先开始生命周期,并赋值为0,然后 ++ 再打印,出了test函数,其的生命周期终止。
第二段代码:i 的值具有累加效果,顾可以分析出,static修饰的变量出函数时未被销毁,直接累计上次的值。
8.3.2 static修饰全局变量
这两张图片,一张是有static修饰全局变量的,一张没有,从运行结果来看,
static修饰的全局变量,只能在本源文件中使用,不能在其它源文件使用。其原因是,把外部属性转为了内部属性。在其他源文件中即使声明了也是无法使用的。
8.3.3 static修饰函数
同样的,函数直接使用是可以正常运行的,但是加了static就出现了链接错误。
和全局变量是一样的,函数被static修饰后,只能在自己所在的源文件中使用。