函数
函数
苏丙榅1. 函数概述
1.1 函数的分类
C语言是一种通用的高级编程语言,拥有丰富的函数库和强大的系统编程能力。在C语言中,函数是模块化和可重用的代码段,用于实现特定的功能
。函数将一系列的语句组织在一起,可以接受输入参数并返回值
。
函数的定义包括函数名
、参数列表
、返回类型
和函数体
:
- 函数名是唯一的标识符,用于调用函数。
- 参数列表指定了函数接受的参数,可以是任意数量和类型的参数。
- 返回类型指定了函数返回值的类型,可以是基本数据类型、指针类型或结构体类型。
- 函数体是实现具体功能的语句块。
C语言中的函数可以分为以下几类:
标准库函数(Standard Library Functions)
:C语言提供了很多常用的函数,如输入输出函数(如scanf
和printf
)、字符串处理函数(如strcpy
和strlen
)、数学库函数(如sqrt
和sin
)等。这些函数被包含在标准库中,可以直接调用。用户自定义函数(User-defined Functions)
:在程序中,我们可以自定义函数,通过函数名调用并执行特定的功能。用户自定义函数可以接受参数并返回值,可以将程序分解成多个函数,提高代码的模块化和可读性。递归函数(Recursive Functions)
:递归函数是一种特殊的函数,它在函数体内部调用自身。通过递归调用,函数可以解决涉及重复计算的问题,实现更简洁和优雅的算法。但需要注意递归函数应当包含条件语句以避免无限递归。内联函数(Inline Functions)
:内联函数是一种将函数的代码嵌入到调用点的编译器特性。内联函数的主要目的是减少函数调用的开销,提高程序的执行速度。通过使用关键字inline
来声明内联函数。回调函数(Callback Functions)
:回调函数是一种通过函数指针作为参数传递的函数。它可以在程序运行时动态地指定回调函数,以实现特定的行为。常见的例子是在图形界面编程中,通过回调函数响应用户的操作。
无论是标准库函数、用户自定义函数还是其他类型的函数,都可以根据需要进行组合和调用,实现复杂的程序逻辑和功能。
1.2 函数的作用
函数在编程中有多种作用和用途,以下是函数的一些常见作用:
代码模块化
:函数可以将一段代码逻辑封装成一个独立的模块,使代码结构更清晰和可维护。通过将程序分解成多个函数,可以使代码更易于理解、修改和调试。代码重用
:函数的设计就是为了提高代码的重用性。通过将通用的功能封装成函数,可以在程序中多次调用该函数来实现相同或类似的功能,避免重复编写重复的代码。提高可读性
:函数使代码的逻辑更加清晰和易于理解。给函数起一个好的函数名,并使用适当的注释,可以使代码的意图更加明确,提高代码的可读性。提高代码的维护性
:使用函数可以将代码结构化,并使其更易于理解和修改。当需要修改某个功能时,只需修改对应的函数,而不需要修改整个程序。这种模块化的设计使得代码更易于维护和改进。
总的来说,函数是编程中的基本构建块,它们提供了一种有效的方式来组织、重用和管理代码。通过合理地设计和使用函数,可以提高代码的可读性、可维护性、可测试性和复用性,进而改善开发效率和代码质量。
关于函数的使用,下面通过两个例子来进行说明:
1 | // 示例1 |
- 第 4 行的
max
是一个自定义函数,有两个int
型参数a
和b
,返回值为int
类型 - 在
main
函数中可以多次调用自定义函数max
,并且可以动态给它指定不同的实参,从而得到不同的最大值,非常灵活
通过例子对函数有了大致的了解之后,接下来我们来详细讲解如何去定义和声明一个函数。
2. 函数的定义和声明
2.1 函数定义
在编程中,函数定义是指定义一个函数的结构、功能和操作的代码块。函数定义包含函数的名称、参数列表、返回类型、函数体等组成部分。
以下是一个函数定义的一般格式:
1 | 返回类型 函数名(参数列表) |
具体来说,各个部分的含义如下:
返回类型:函数执行完成后返回的数据类型。
- 可以是基本数据类型,如整数、浮点数、字符等
- 可以是指针、结构体、自定义类型等。
- 如果函数没有返回值,可以使用
void
关键字表示。
1
2
3
4
5
6// 函数返回值为整形 int
int max(int a, int b){}
// 函数返回值为布尔类型 bool
bool isRunning(int c){}
// 没有返回值
void show(){}函数名:函数的名称(最好见名知义),用于调用和引用函数。
- 合法的函数名由字母、数字和下划线组成
- 一般约定以字母开头
1
2
3
4
5
6// 有意义的函数名
int max(int a, int b){} // 求最大值
int sum(int a, int b, int c){} // 求和
// 没有意义的函数名
void zaaaaaaaa(){}
void xyc123(char c){}参数(形参)列表:参数列表中的参数可以有N(N>=0)个
- 定义函数的时候指定的参数叫做形参,形参不占用存储空间
- 调用函数的时候指定的参数叫做实参,实参是占用存储空间的
- 参数可以有多个,用逗号分隔,每个参数包括参数类型和参数名
- 如果函数没有参数,参数列表可以为空,或者指定为 void
- C 语言中,形参不允许指定默认值(赋值)
1
2
3
4
5
6
7
8// 没有参数
void show(){}
void print(void){}
// 有参数
int max(int a, int b){} // 2个参数, a, b 都是形参
int sum(int a, int b, int c){} // 3个参数, a, b, c 都是形参
// 给形参初始化 -- error
int show(int a = 9){} // error, 语法错误函数体:函数的具体实现,包括一系列语句和操作。
- 函数体内可以有变量声明、条件语句、循环语句、函数调用等
- 如果函数有返回值,需要再函数体最后添加
return
语句return
语句是函数体内部执行的最后一个语句return
语句一般出现在函数体末尾,但是也可能出现在函数体中间位置,表示函数从该位置退出了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31// 函数有返回值,需要返回一个整形
int max(int a, int b)
{
if(a > b)
{
return a; // 返回一个变量, 变量是整形
}
else
{
return b+10; // 返回一个表达式, 表达式结果是整形
}
}
double sum(int a, int b)
{
// 返回值是一个表达式, 值是整形
// 会先将表达式结果隐式转换为 double 类型, 然后再返回
return a+b;
}
// 函数没有返回值
void show()
{
printf("hello, world\n");
}
void print()
{
printf("hello, world\n");
return; // 相当于没有返回任何数值, 可以省略不写
}
2.2 函数声明
所谓函数声明,就是在函数尚在未定义的情况下,事先将函数的名称、参数列表和返回类型通知编译器,这样可以保证编译能正常进行,作为用户也可以通过声明去理解和使用这个函数。函数声明通常放置在头文件中,可以在多个源文件中共享。
函数声明的一般格式如下:
1 | 返回类型 函数名(参数列表); |
关于函数声明我们应该掌握如下几点:
函数声明可以写到头文件也可以写到源文件
一个函数只能被定义一次,但可以声明多次
如果函数定义的位置在主调函数(调用它的函数)之后,则必须在调用此函数之前对被调用的函数作声明
以下是一个函数声明的示例:
1 | int add(int a, int b); |
在上述示例中,函数声明了一个名为 add
的函数,它接受两个整数类型的参数 a
和 b
,返回类型为 int
。函数声明只说明了函数的名称、参数列表和返回类型,而没有提供函数的具体实现。可以在函数调用的地方使用这个声明,而编译器会在链接时找到该函数的定义。
函数定义在主调函数之前 - 不需要声明
1
2
3
4
5
6
7
8
9
10
11
12
int add(int a, int b)
{
return a + b;
}
int main()
{
int result = add(8, 9);
printf("result = %d\n", result);
return 0;
}在上面的例子中,主调函数对应的就是
main
函数,在main
函数之前add
函数已经被定义出来了,所以此时不需要进行函数声明。函数定义在主调函数之后 – 需要先声明
1
2
3
4
5
6
7
8
9
10
11
12
13
int add(int a, int b);
int main()
{
int result = add(8, 9);
printf("result = %d\n", result);
return 0;
}
int add(int a, int b)
{
return a + b;
}main
函数是程序的入口函数,在这个函数中调用了add
函数,此时编译器是懵逼的,全然不知道有这么一个函数的存在,所以在编译的时候我们就能看到相关的警告信息:1
warning C4013: “add”未定义;假设外部返回 int
解决方法也很简单,只需要在主调函数(
main函数
)之前添加被调用函数的函数声明即可,此时编译器就知道add
函数是何方神圣(叫什么名字),有什么神通了(参数有几个分别是什么类型,返回值是什么类型)。
最后,给大家总结一下函数定义和声明的区别:
定义是指对函数功能的确立,包括指定函数名、函数类型、形参及其类型、函数体等,它是一个完整的、独立的函数单位。
声明的作用则是把函数的名字、函数类型以及形参的个数、类型和顺序(注意,不包括函数体)告知给编译器,以便在对包含函数调用的语句进行编译时,据此对其进行对照检查(例如函数名是否正确,实参与形参的类型和个数是否一致)。
3. 函数的调用
定义函数后,必须要该函数进行调用,这样它的函数体才能够被执行。自定义函数和main()
函数不一样,main()为编译器设定好自动调用的主函数,无需人为调用
,我们都是在main() 函数里调用别的函数,一个 C 程序里有且只有一个main()函数
。
3.1 函数的执行流程
下面通过一个简单的例子给大家讲一下C语言中程序的执行流程:
1 |
|
- 找到程序中的入口函数
main()
从第一行开始执行 - 调用了自动以函数
add(int a, int b)
- 编译器会在
main()
函数的上面寻找这个叫做add
的自定义函数 - 如果找到了,检查函数的参数类型是否匹配,没参数则忽略
- 编译器会在
- 执行
add()
函数,此时main()
函数里面的执行会被add()
这一行(第9行)代码阻塞,直到add()
函数执行完毕,main()
中的代码才会继续向下(第10行)执行。 main()
函数执行到return 0
, 程序退出,执行完毕。
3.2 函数的形参和实参
在函数调用中,有两个相关概念:形式参数(形参)
和实际参数(实参)
:
形式参数(形参):
在函数定义中声明的参数
。形参用于描述函数接受的参数类型和顺序。
形参在函数定义中充当占位符,表示函数在执行时将接受的值。
形参不占用内存空间
形参通常在函数定义的括号内声明,并且可以有多个,用逗号分隔。
形参只在函数内部可见,其作用域局限在函数内部。
下面是一个简单的函数定义,其中
a
和b
就是形式参数:1
2
3
4
5int add(int a, char b)
{
int sum = a + b; // b 会进行隐式类型转换 char->int
return sum;
}在上述示例中,
add
函数定义了两个整型形式参数a
和b
。实际参数(实参):
在函数调用时传递给函数的具体值或表达式。
实参作为函数调用的一部分,提供了函数在执行时所需的数据。
实参可以是常量、变量、表达式或函数返回值等。
实参传递给函数,按照函数定义中形式参数的顺序进行匹配。
下面是一个函数调用的示例,其中 整数
2
和 字符a
就是实际参数:1
int result = add(2, 'a');
在上述示例中,函数
add
被调用时,实际参数2
和3
传递给了函数的形式参数a
和b
。
在进行函数调用的时候,形参和实参有如下区别:
- 实参变量对形参变量的
数据传递是“值传递”
,即单向传递,只能由实参传给形参,反之则不被允许。 - 在
调用函数时、调用时、调用时
,编译器临时给形参分配存储单元。调用结束后,形参单元被释放。 实参单元与形参单元是不同的单元
,因此,在执行一个被调用函数时,形参的值如果发生改变,并不会改变主调函数中实参的值。
1 |
|
以上代码输出的结果如下:
1 | 形参 a = 109, b = 206 |
- 在自定义函数
modify
内部操作了两个整形变量,就是这个函数的两个形参a
和b
,这两个形参只能在改函数体内部使用。 - 在主函数
main
中对自定义函数modify
进行了调用,并且给函数指定了两个实参num1
和num2
,也就意味着这两个实参的值被赋值给了函数的两个形参,相当于做了这样的操作:a = num1;
b = num2;
- 实参
num1
和num2
只是将值传递给了modify
函数的形参a
和b
- 形参
a
和b
值的改变不会对实参num1
和num2
有任何影响 - 实参
num1
和num2
访问范围是主函数main
的函数体 - 实参
num1
和num2
的生命周期随着主函数main
的函数体结束而结束
- 形参
3.3 函数的返回值
函数返回值是指在函数执行完毕后,通过 return
语句将结果返回给函数调用的地方。返回值允许函数将计算结果、状态信息或其他需要传递的数据返回给调用者。
函数的返回值在函数定义时通过返回类型声明,在函数调用时使用接收变量来接收返回值。以下是一般的函数返回值的语法格式:
1 | 返回类型 函数名(参数列表) |
具体来说,函数返回值的相关要素如下:
返回类型:函数执行完成后返回的数据类型。
- 可以是基本数据类型,如整数、浮点数、字符等
- 可以是指针、结构体、自定义类型等。
- 如果函数没有返回值,可以使用
void
关键字表示。
return
语句:用于返回函数执行的结果。- 它可以后接一个表达式,表示返回的值。
- 如果函数的返回类型是
void
,则不需要return
语句。
以下是一个函数返回值的示例:
1 | int add(int a, int b) |
在上述示例中,函数 add
接受两个整数类型的参数 a
和 b
,在函数体中计算它们的和,并通过 return
语句返回结果。
在函数调用时,可以使用变量来接收函数的返回值:
1 | int result = add(2, 3); |
在上述示例中,函数 add
将返回结果 5
,并将其赋值给变量 result
。如果函数调用者对该函数的返回值不感兴趣,可以不去接收这个返回值,无视即可。
函数的返回值可以用于进行进一步的计算、输出、赋值或其他操作。通过返回值,函数可以将结果传递给调用者,使得函数的执行结果能够被利用和处理。
4. 内联函数
在C语言中,内联函数(Inline Function)是一种用于优化代码执行效率的机制。内联函数在编译时将函数的代码直接插入到调用它的地方,而不是通过函数调用的方式执行,从而减少了函数调用的开销,提高了代码的执行速度
。
C语言的内联函数使用 inline
关键字来声明。将函数声明为内联函数只是给编译器一个建议
,告诉它将函数的代码插入到调用的地方。编译器可以选择忽略内联函数的建议
,继续将函数编译为常规函数。
以下是内联函数的一般格式:
1 | inline 返回类型 函数名(参数列表) |
具体来说,内联函数的使用规则和特点包括:
- 关键字
inline
必须与函数定义体放在一起才能使函数成为内联,仅将inline
放在函数声明前面不起任何作用。 - 内联函数的定义通常放在头文件中,以便在多个源文件中共享。
- 内联函数的函数体通常较简单,避免包含复杂的控制流程、循环或递归调用和大量的代码。
- 内联是以代码膨胀为代价,从而省去了函数调用的开销,从而提高了函数的执行效率(空间换时间)
下面是一个简单的内联函数的示例:
1 |
|
在上述示例中,multiply
函数被声明为内联函数,它接受两个整数类型的参数 a
和 b
,并返回它们的乘积。在使用这个内联函数时,编译器将尝试在调用该函数的地方插入相应的代码,而不会执行函数调用操作。
5. 递归函数
在C语言中,递归函数是一种函数调用自身的技术
。递归函数可以用于解决需要重复执行相同操作的问题
,将问题不断分解为更小的子问题,并通过函数调用自身来处理这些子问题,直到达到最简单的情况。
递归函数的基本原理是把大问题分解成一个或多个相同的小问题,然后通过调用自身来解决这些小问题。递归函数包括两部分:基本情况和递归调用:
基本情况指的是递归函数终止的条件,当函数遇到基本情况时,不再调用自身,直接返回结果。
递归调用指的是在函数内部调用自身来解决规模较小的子问题。通过递归调用,问题的规模不断缩小,直到达到基本情况,然后逐层回溯,获得最终的结果。
需要注意的是,递归函数必须具备终止条件,否则会陷入无限递归的循环中,导致程序异常
。
5.1 阶乘
阶乘是一个正整数与比它小的所有正整数的乘积。一般来说,我们用符号 "!" 表示阶乘
。
例如,5 的阶乘表示为 5!,计算方法为:
5! = 5 × 4 × 3 × 2 × 1 = 120
阶乘的计算可以通过循环或递归来实现。下面分别给出阶乘的循环和递归算法示例。
1. 循环计算阶乘:
1 | unsigned int factorial(unsigned int n) |
2. 递归计算阶乘:
1 | unsigned int factorial(unsigned int n) |
0和1的阶乘为1
,当参数为 0 或者 1时,就是递归结束的条件如果参数为n(n>1),需要用当前的值
n
乘以n-1
的阶乘,也就是n * factorial(n - 1)
通过上图可知递归分为两个阶段
- 执行函数体内不满足递归结束条件的部分代码,继续递归(压栈)
- 递归结束条件满足之后,执行剩余代码,并返回数据,依次类推(出栈)
- 最后被调用的函数第一个返回数据(也就意味着这个函数第一个执行完函数体内的所有代码),第一个被调用的函数最后一个返回数据(此时得到的就是最终的值)
5.2 斐波那契数列
斐波那契数列(Fibonacci Sequence)是一个数列,其中每个数字都是前两个数字之和
。数列的前两个数字通常定义为0和1,然后通过递推关系计算后续的数字。斐波那契数列的定义如下:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) (n ≥ 2)
根据这个定义,斐波那契数列的前几个数字为:0, 1, 1, 2, 3, 5, 8, 13, 21, …
以下是两种常见的计算斐波那契数列的方法:迭代和递归。
1. 迭代方法:
1 | int fibonacci(int n) |
2. 递归方法:
1 | int fibonacci(int n) |
在上述示例中,递归函数 fibonacci
接受一个整数参数 n
,用于计算斐波那契数列的第 n 项。
- 当
n
小于等于 1 时,达到基本情况,直接返回n
。 - 当
n
大于 1 时,通过递归调用fibo_recursive(n-1)
和fibo_recursive(n-2)
来计算第n
项的值。
6. 多文件编程
6.1 代码模块化
当需求比较复杂的时候或者做一个比较大的项目的时候,不可能将所有的源码都写到一个文件中,此时就需要进行模块化处理,思路如下:
- 将需求拆分成若干个小模块,每个模块对应一个源文件
- 给每个源文件提供一个头文件,通过这种方式实现函数的复用
- 头文件进行函数声明
- 源文件进行函数定义
假设我现在要做一个计算器,根据需求拆分成了加、减、乘、除
四个模块,那么此时对应的源文件就是五个:
- 处理加法的源文件:
add.c
- 处理减法的源文件:
subtract.c
- 处理乘法的源文件:
multiply.c
- 处理除法的源文件:
divide.c
- 主函数
main
(入口函数)对应的源文件:test.c
add.h
1 | // add.h |
add.c
1 |
|
subtract.h
1 | // subtract.h |
subtract.c
1 |
|
main.c
1 |
|
关于乘法和除法的例子在这里就不写了,如法炮制就可以,完全是一样的。对于上面的代码有一下细节需要说明一下:
- 一个头文件可能被多个源文件包含,只要包含了这个头文件,在源文件中就可以使用头文件中声明的函数了
- 一个头文件中也可以包含其它头文件
- 在包含多个头文件的时候,对顺序没有要求
要避免自定义头文件被重复包含
在C语言中,一个源文件对应一个头文件并不是必须的,有时候多个源文件可以对应同一个头文件,也就是说N个源文件中定义的函数的声明都被放到了同一个头文件中。
calc.h
1 | // add.h |
add.c
1 |
|
subtract.c
1 |
|
main.c
1 |
|
6.2 避免头文件重复包含
在包含头文件的时候可能由于失误将一个头文件包含了两次,或者是因为头文件的嵌套包含导致某个头文件被包含了多次(>1)
1 | // a.h |
如果出现了以上情况,程序在编译的时候就会报错。为了避免同一个文件被include
多次,C/C++中有两种方式:
使用文件保护宏
文件保护宏(header guard macro)是一种
传统的预处理指令
,用于防止头文件重复包含。文件保护宏可以确保在编译时只包含一次特定的头文件,从而避免由于多次包含导致的重复定义错误。文件保护宏需要在头文件的开头和结尾进行定义和结束
,如下所示:1
2
3
4
5
6#ifndef HEADER_FILE_NAME_H
#define HEADER_FILE_NAME_H
// 头文件的内容
#endif // HEADER_FILE_NAME_HHEADER_FILE_NAME_H
是一个唯一的标识符,用于确保宏定义的唯一性,因此每个头文件都应该使用不同的宏名称。- 可以根据需要自定义宏的名称,常见的做法是
以头文件名称的大写形式作为宏的名称
。 - 在编译过程中,首次包含头文件时,
ifndef
条件为真,进入#define
块,并定义了该宏。随后的重复包含,由于宏已经有定义,条件为假,不再执行#define
块内的代码。
使用
#pragma once
#pragma once
是一种预处理指令,用于确保头文件只被编译一次。它是一种用于防止头文件重复包含的非标准的预处理指令,被大多数主流编译器所支持。使用
#pragma once
可以替代传统的头文件保护宏,如#ifndef
、#define
和#endif
。通过使用#pragma once
,可以更简洁地确保头文件只被编译一次,而无需手动编写宏定义。示例使用
#pragma once
的头文件:1
2
3#pragma once
// 头文件内容需要注意的是,虽然
#pragma once
在大多数情况下提供了简洁的头文件包含机制,而且几乎所有主流的编译器都支持它,但它不是C或C++的标准预处理指令
。因此,如果您希望代码更具可移植性,可以继续使用传统的头文件保护宏。