函数基础
在标准 C 语言中,函数定义遵循严格的语法规则,确保函数的结构清晰和一致性。一个典型的函数定义包括以下部分:
- 返回值类型:指定函数返回值的数据类型,如
int
、void
等。 - 函数名:一个描述性的标识符,遵循命名规范,推荐使用小驼峰命名法,以动词开头。
- 形参列表:括号内列出函数接收的参数,参数之间用逗号分隔。如果函数不需要参数,可以使用
void
表示。
函数定义的规范:
- 函数定义的结构是固定的,不应省略任何部分,以确保代码的可读性和标准化。
注意事项:
- 返回值类型限制:C 语言不允许函数直接返回数组类型,如
int[]
,因为数组在返回时不能被复制。 - 返回值类型省略:虽然在 C90 及早期版本中可以省略返回值类型,编译器会默认为
int
,但这种做法在现代 C 编程中不推荐。 - 函数名的唯一性:函数名必须是唯一的,不能在同一文件中重复定义同名函数。
- 形参列表的表示:如果函数不接受任何参数,推荐使用
void
来明确表示。
#include <stdio.h>
// x 形式参数
double F(double x) {
return x * x + x + 1;
}
double G(double x, double y, double z) {
return x * x + y * y + z * z;
}
int main(void) {
/*
* <return type> <name> (<parameters>) {
* ... statement
* return <return value>;
* }
*/
puts("HelloWorld");
// 2.0: 实际参数
double result_f = F(2.0);
double result_g = G(3.0, 4.0, 5.0);
// Add
// Sum
// FindNumber
printf("result of f: %F\n", result_f);
printf("result of G: %F\n", result_g);
return 0;
}
函数设计的重要性:函数是编程中实现代码复用的基本单位。当发现代码中存在重复逻辑时,提取成函数是一种优化的做法。此外,即使代码没有明显的复用需求,将复杂的逻辑分支提取成函数也有助于突出程序的主干逻辑,提高代码的可读性和可维护性。
设计函数时应遵循的原则:
- 单一职责原则:一个函数应该只有一个改变的理由,即它的功能应该尽可能单一。这样做可以减少函数间的依赖,降低代码的复杂性。
- 性能优化:在设计函数时,应考虑其执行效率。在不影响代码可读性和可维护性的前提下,尽可能使函数执行高效。
一个好的函数设计应该是专注的,即每个函数只负责完成一个具体的任务,并且尽可能地做好这件事。这种设计哲学有助于构建清晰、易于理解和维护的代码。
函数调用
形参和实参
在 C 语言中,函数的参数可以通过形参和实参两个概念来理解:
- 形参(形式参数):形参是函数定义中的一个占位符,用于指示函数调用时需要传递的参数的数量、类型和顺序。形参的名字在函数调用时并不重要,因为它们仅在函数体内部使用。例如,函数定义
void test(int a)
中的a
就是一个形参,它告诉调用者函数需要一个int
类型的参数。 - 实参(实际参数):实参是在函数调用时传递给函数的实际数据。实参用于替换函数定义中的形参,将具体的值传递给函数。例如,在调用
test(100);
时,100
就是实参,它替换了形参a
,并将这个值传递给test
函数。
函数调用的过程:
- 函数定义时,通过形参指定所需的参数类型和顺序。
- 函数调用时,提供相应的实参,这些实参按照形参指定的类型和顺序传递给函数。
注意事项:
- 形参的命名不影响函数的调用,重要的是形参的类型、顺序和个数。
- 实参的值在函数调用时被复制到函数的局部变量中,因此实参和形参在内存中是独立的。
函数原型
- 在 C 语言的
main
函数定义中,如果未指定任何参数,如int main(){ return 0; }
,这表明main
函数是可变参数的,意味着在调用时可以选择传递任意数量的参数,包括零个,例如main()
,main(1)
, 或者main(1, "hello")
。 - 使用
int main(void)
在 C 语言中明确指出main
函数不接受任何参数,这与 C++ 的int main()
不同,在 C++中后者表示不接受参数,尝试传递参数会导致编译错误。 puts()
函数用于输出字符串至标准输出,它不接受格式化字符串或变量作为参数。例如,不能使用puts("结果是%d", i);
这样的语法。正确的用法是分别使用puts("结果是")
和printf("%d", i);
来输出字符串和变量。- 在 C 语言中声明函数时,如果只需要指定函数的返回类型和参数类型,可以省略参数名,如
int sub(int, int);
,这为函数的声明提供了足够的信息,而无需具体的参数名。 - 如果在 C 语言中定义函数时省略了返回值类型,如
add(int a, int b){ ... }
,编译器会根据函数体内的return
语句推断出返回类型。如果没有return
语句,则默认返回类型为int
。
#include <stdio.h>
void EmptyParamList(void);
/*
* 1. 函数名
* 2. 函数返回值类型,如果没写,默认为 int
* 3. 重要的是函数参数列表,参数类型,和参数的顺序,参数形参名不重要
*/
int Add(int, int); // 函数声明时可以不写形参名称
int main(void) {
puts("");
EmptyParamList();
int result = Add(1, 2);
printf("result of add: %d\n", result);
return 0;
}
void EmptyParamList(void) {
puts("Hello");
}
函数的声明和调用
在 C 语言编程中,函数的声明和定义的顺序对程序的编译有重要影响。为了确保函数可以被正确调用,应遵循以下推荐做法:
- 定义优先:尽量将函数的定义放在调用该函数的代码之前。这样做可以保证在调用函数时,编译器已经看到了函数的定义。
- 声明在前:如果函数定义在调用之后,应在调用之前提供函数的声明。这可以通过将声明放在文件的顶部或使用头文件来实现。声明提供了函数的原型,包括返回类型、函数名和参数列表。
原因分析: C 语言的编译器按照源代码的顺序进行编译。如果在调用一个函数之前没有遇到该函数的声明或定义,编译器将无法识别该函数,从而导致编译错误。
头文件的使用: 头文件通常用于包含函数声明、宏定义、类型定义等,它们可以被多个源文件包含。在实际工作中,使用头文件来声明函数是一种常见的做法,这有助于维护代码的组织结构和可读性。
在编译器平台上,函数的调用和声明顺序对编译过程有特定的影响。根据 MSVC 和 GCC 的编译规则:
- 默认返回值类型:如果在调用函数之前没有声明或定义该函数,编译器会默认该函数的返回值类型为
int
。 - 后续声明或定义:编译器会继续编译后续代码,并在遇到函数的声明或定义时进行检查。
- 匹配返回值类型:如果后续找到了该函数的声明或定义,并且其返回值类型为
int
,则编译过程会继续,不会报错。 - 类型不匹配:如果找到了同名函数,但其返回值类型不是
int
,GCC 编译器不会报错,返回int
值;MSVC 编译器会报错,错误信息通常指出函数被重定义,且具有不同的基本类型。
这种编译行为意味着在 MSVC 和 GCC 平台上,程序员需要确保在调用函数之前,要么已经提供了该函数的声明,要么已经定义了该函数,以避免编译错误。
对于以下代码:
#include <stdio.h>
int main() {
test();
return 0;
}
int test() {
return 1;
}
MSVC 构建时无报错信息,GCC 构建时输出以下信息:
~.c: In function 'main':
~.c:4:9: warning: implicit declaration of function 'test' [-Wimplicit-function-declaration]
4 | test();
|
运行结果为:
进程已结束,退出代码为 0
对于以下代码:
#include <stdio.h>
int main() {
func();
return 0;
}
void func() {
return -1;
}
GCC 构建后输出结果如下,可以正常运行,返回结果 0:
~.c: In function 'main':
~.c:4:9: warning: implicit declaration of function 'func' [-Wimplicit-function-declaration]
4 | func();
| ^~~~
~.c: At top level:
~.c:8:6: warning: conflicting types for 'func'; have 'void()'
8 | void func() {
| ^~~~
~.c:4:9: note: previous implicit declaration of 'func' with type 'void()'
4 | func();
| ^~~~
~.c: In function 'func':
~.c:9:16: warning: 'return' with a value, in function returning void
9 | return -1;
| ^
~.c:8:6: note: declared here
8 | void func() {
| ^~~~
MSVC 构建后输出结果如下,无法正常运行:
~.c(8): error C2371: 'func': 重複定義; 基本類型不相同
~.c(9): warning C4098: 'func': 'void' 函式正在傳回值
值传递
参数传递机制
当调用函数时,需要将数据传递给函数内部的形参。在 C 语言中,参数传递机制遵循“值传递”原则,这意味着传递给函数的是实参的一个副本。
- 值传递(By Value):在 C 语言中,当一个函数被调用时,传递给函数的参数是通过值传递的。也就是说,实参的值被复制到函数内部的形参中。函数内部对形参的任何操作或修改都只影响副本,而不会影响原始的实参。
- 参数传递的重要性:参数传递机制是任何编程语言中的基础概念,它使得函数能够接收外部数据并根据这些数据执行相应的操作。
- 对实参无影响:由于 C 语言的参数传递是值传递,函数内部对形参的修改不会影响实参。这意味着函数调用不会导致调用者环境中的数据发生变化。
值传递的本质
- 值传递的定义:在 C 语言中,当通过函数调用传递参数时,采用的是值传递机制。这意味着传递给函数的是实参的一个副本,而不是实参本身。
- 函数内部的修改限制:由于传递的是副本,函数内部对形参的任何修改都不会影响到原始的实参。这一点对于保护实参数据的完整性至关重要。
- 实参的类型无关性:值传递机制适用于所有类型的实参,无论是局部变量还是全局变量,也无论是基本数据类型还是指针类型。在所有情况下,传递给函数的都是实参值的一个副本。
- 保护实参:这种机制确保了函数调用不会改变调用者环境中的原始数据,从而提高了程序的稳定性和可预测性。
值传递的优缺点
在 C 语言中,仅采用值传递的方式具有以下优点:
- 安全性:由于函数接收的是实参的副本,原始数据保持不变。这确保了函数操作不会对外部变量造成影响,有效防止了意外的副作用,从而保护了数据的完整性。
- 简洁性与直观性:与 C++中多样的参数传递方式相比,C 语言的单一传值方式体现了其简洁和统一的设计原则,使得语言更加易于理解和使用。
然而,这种设计也存在一些局限性:
- 灵活性不足:由于只支持值传递,C 语言在某些情况下可能不如其他支持引用传递或指针传递的语言灵活。
- 功能限制:值传递的方式可能限制了某些需要直接操作内存地址或需要函数返回多个值的场景。
变量的类型和作用域
auto 类型
在 C 语言中,使用 auto int i = 1;
声明了一个整型变量 i
并赋予初始值 1。这里的 auto
关键字并非 C 语言标准的一部分,而是某些编译器(如 GCC)中的历史遗留特性,用于声明自动存储期的变量,意味着变量的生命周期与声明它的函数或代码块一致,当函数或代码块执行完毕时,变量会被自动销毁。
值得注意的是,auto
关键字在现代 C 语言编程实践中很少使用,因为默认情况下,未指定存储类别的局部变量就是自动存储期的。 变量的作用域限制在它被声明的函数或代码块内,一旦退出该作用域,变量就会被销毁,无法再被访问。
至于初始值的处理,不同的编译器可能会有细微的差别。MSVC 编译器下运行,首先会报以下错误,原因是未进行初始化:
点击忽略后销毁该变量,将该地址上的值初始化为 0xcccccccc(十进制为 -858993460)。
GCC 则不会进行初始化。
静态变量类型 static
static
关键字在 C 语言中用于声明具有静态存储期的变量。与 auto
变量不同,static
变量的生命周期贯穿整个程序的运行过程,即使它们所在的函数或代码块已经执行完毕。这意味着,static
变量的内存分配在程序启动时进行,并在程序结束时释放,而不是在函数调用结束后立即销毁。
如果 static
变量未显式初始化,编译器会将其默认初始化为 0。这一点与 auto
变量的行为不同,后者的未初始化行为是未定义的,可能导致不可预测的结果。
此外,static
变量的作用域通常是全局的,但与全局变量(定义在所有函数外部的变量)不同,static
变量只在定义它们的文件内部可见,这使得它们成为实现文件内封装和信息隐藏的有效工具。
void LocalStaticVar(void) {
// 静态变量
// 1. 作用域全局,内存不会因函数退出而销毁
// 2. int 初值默认为 0
static int static_var;
// 自动变量
// 1. 函数、块作用域,随着函数和块退出而销毁
// 2. 没有默认初值
int non_static_var;
printf("static var: %d\n", static_var++);
printf("non static var: %d\n", non_static_var++);
}
int main(){
int a[5] = {1,2,3,4,5};
// TestScope(5, a);
// TestStatic();
LocalStaticVar();
LocalStaticVar();
LocalStaticVar();
return 0;
}
GCC 输出结果如下:
0
static var: 0
non static var: 0
static var: 1
non static var: 1
static var: 2
non static var: 2
而 MSVC 输出结果如下:
0
static var: 0
non static var: -858993460
static var: 1
non static var: -858993460
static var: 2
non static var: -858993460
而对于以下代码:
void LocalStaticVar(void) {
// 静态变量
// 1. 作用域全局,内存不会因函数退出而销毁
// 2. int 初值默认为 0
static int static_var;
// 自动变量
// 1. 函数、块作用域,随着函数和块退出而销毁
// 2. 没有默认初值
int non_static_var;
printf("static var: %d\n", static_var++);
printf("non static var: %d\n", non_static_var++);
}
void CleanMemory() {
int eraser = -1;
}
int main(){
int a[5] = {1,2,3,4,5};
// TestScope(5, a);
// TestStatic();
LocalStaticVar();
CleanMemory();
LocalStaticVar();
CleanMemory();
LocalStaticVar();
return 0;
}
GCC 输出结果如下:
0
static var: 0
non static var: 0
static var: 1
non static var: -1
static var: 2
non static var: -1
块作用域
块作用域范围内的变量只在块内生效
#include <stdio.h>
int main(){
{
int a = 0;
}
printf(a);
return 0;
}
~.c: 7:10: error: 'a' undeclared (first use in this function)
7 | printf(a);
| ^
寄存器变量类型 register
在 C 语言中,使用 register
关键字声明的变量(例如 register int a
)旨在建议编译器尽可能将该变量存储在 CPU 寄存器中,以提高访问速度。与普通变量(如 int a
)相比,寄存器变量在汇编层面的操作更为直接和高效。例如,在传递参数时,普通内存变量需要经过额外的步骤:首先将值从寄存器复制到内存的特定位置(如 PTR [rbp-4]
),然后再从该内存位置复制到目标寄存器(如 eax
)。这一过程涉及两次数据复制,增加了延迟。
void PassByMemory(int parameter) {
printf("%d\n", parameter);
}
PassByMemory(int):
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
mov esi, eax
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
nop
leave
ret
而寄存器变量则避免了这一额外步骤,可以直接在寄存器之间传递值,如直接将 edi
寄存器中的值复制到 eax
寄存器,从而减少了指令周期和提高了执行效率。
void PassByRegister(register int parameter) {
printf("%d\n", parameter);
}
PassByRegister(int):
push rbp
mov rbp, rsp
mov esi, edi
mov edi, OFFSET FLAT:.LC0
mov eax, 0
call printf
nop
pop rbp
ret
函数原型作用域
在 C 语言中,函数原型如 int sort (int size, int array [size]);
展示了一种特定的变量作用域限制。这里的参数 size
仅在该函数声明的上下文中有效,意味着它不能在函数外部被访问或使用。
此外,这种声明方式在不同编译器中的行为也有所差异。在 MSVC 编译器中,这样的声明是被允许的,但在 GCC 中则不被接受,并会提示错误,指出需要一个常量表达式,并且不允许数组的大小为零。
进一步地,我们可以区分两种作用域的概念:函数作用域和文件作用域。函数作用域内的参数和变量仅在该函数内部有效,而文件作用域中的变量则在整个文件的范围内都是可访问的,不受特定函数的限制。
函数的变长参数
从本质上讲,可变参数技术涉及将一系列参数作为一个连续的内存块传递给函数。函数接收到这个内存块的起始地址和参数的总数。随后,它根据每个参数的数据类型所占用的字节数 —— 例如,一个整型(int)占用 4 个字节,一个双精度浮点数(double)占用 8 个字节 —— 来解析和提取内存块中的每个参数。通过这种方式,函数能够逐一识别并处理传递给它的所有参数。
#include <stdio.h>
#include <stdarg.h>
/*
* 实现变长参数
*/
void HandleArgs(int arg_count, ...) {
// 1. 定义一块空间(va_list),存入所有的变长参数
va_list args;
// 2. 将这块空间分割为参数数目个数
va_start(args, arg_count);
// 3. 读取每块空间的参数
for (int i = 0; i < arg_count; ++i) {
int arg = va_arg(args, int);
printf("%d: %d\n", i, arg);
}
// 4. 这块空间使用结束
va_end(args);
}
int main() {
HandleArgs(4, 1, 2, 3, 'a');
return 0;
}
伪随机数的生成
在 C 语言中,生成伪随机数通常依赖于几个标准库函数,这些函数包括 rand()
、srand()
以及与时间相关的 time()
函数。以下是使用这些函数生成伪随机数的步骤:
- 包含头文件:首先,需要包含两个标准库头文件
<stdlib.h>
和<time.h>
,它们分别提供了随机数生成和时间获取的功能。 -
设置随机数种子:使用
srand()
函数设置随机数生成器的种子。种子值通常是一个无符号整数,可以通过以下声明调用:void srand(unsigned seed);
这个函数没有返回值,调用时传入一个种子值,用于初始化随机数生成器。
-
生成随机数:调用
rand()
函数生成一个伪随机数。该函数的声明如下:int rand(void);
它是一个无参函数,每次调用都会返回一个
int
类型的伪随机数。 -
返回值范围:
rand()
函数返回的伪随机数有一个固定的范围,通常是从 0 到RAND_MAX
(在大多数平台上是 32767)。 -
伪随机性:由于
rand()
函数生成的随机数序列是由种子值决定的,因此它们不是真正的随机数,而是伪随机数。每次使用相同的种子值初始化时,都会生成相同的数列。 -
提高随机性:为了增加随机性,可以使用
time(NULL)
作为种子值,这样可以保证每次程序运行时种子值都不同,从而使得生成的随机数序列也不同。 -
注意事项:
srand()
通常在程序的一次运行期间只需要调用一次,多次调用可能会影响随机数的分布。- 避免在循环中设置种子值,因为这可能导致种子值重复,从而影响随机数的生成。
函数的递归
- 递归的定义: 递归是一种编程范式,其中函数通过直接或间接的方式调用自身,实现对问题的分解和逐步解决。
- 递归的优势: 递归提供了一种解决复杂问题的方法,特别是那些可以自然分解为相似子问题的问题。它能够使代码更加简洁,易于理解和维护。
- 递归的风险: 然而,递归也存在潜在的风险。如果递归深度过大,可能会导致栈溢出,从而引发程序崩溃。因此,在使用递归时需要谨慎,确保递归有明确的终止条件,并且递归深度在可控范围内。
- 递归的使用建议: 尽管递归在某些情况下非常有用,但开发者应该权衡其利弊。在可能的情况下,考虑使用迭代方法或其他替代方案,以避免递归可能带来的风险。
递归的实现
- 递归的三个关键要素:
- 递归体:这是函数中调用自身的部分,是递归实现的核心。
- 递归出口:这是递归结束的条件,确保递归不会无限进行下去,防止栈溢出。一个有效的递归必须有一个明确的出口条件。
- 递归深度:即使递归有正确的出口,如果递归调用的深度过大,也可能导致栈空间不足,从而引发栈溢出。因此,递归深度的控制对于防止程序崩溃至关重要。
- 递归的实现步骤:
- 确定问题是否适合使用递归解决。
- 设计递归函数,明确递归体和递归出口。
- 考虑递归深度,确保递归调用在合理的范围内。
- 递归的注意事项:
- 递归函数应该有一个明确的基本情况(base case),以避免无限递归。
- 递归函数应该确保每次递归调用都向基本情况更进一步。
- 在设计递归函数时,应该考虑递归的性能和栈空间限制。
递归的思想和思路
- 识别递归体:确定问题的核心部分,这部分是可以通过递归调用自身来解决的。递归体是递归函数中自我调用的部分,它将问题分解成更小的子问题。
- 定义递归出口:递归出口是递归结束的条件,也就是当问题规模小到可以直接解决时的情况。递归出口是防止递归无限进行下去的关键,它确保了递归能够最终停止。
- 递归深度考虑:即使递归有明确的出口,也必须考虑递归的深度,以避免栈溢出。递归深度是指递归调用的层数,它需要在设计递归函数时进行合理控制。
- 组合子问题的解:一旦解决了所有子问题,就需要将这些子问题的解组合起来,以形成原问题的解。这个过程通常在递归的每次返回中逐步进行。
- 递归的效率:在设计递归算法时,应该考虑递归的效率,包括递归的深度和每次递归调用的计算量。有时,可以通过优化算法或使用迭代方法来提高效率。
递归的优缺点
优点:
- 自然性:递归解决问题的思想与人类思考问题的方式非常契合,使得递归算法容易理解,尤其是在处理那些自然递归定义的问题(如斐波那契数列、树形数据结构和文件系统)时。
- 代码简洁性:递归通常可以用更少的代码实现复杂的功能,因为它避免了迭代时需要的循环控制结构。
- 适应性:在自然界和开发中,很多问题的定义是递归的,因此递归是解决这类问题的一种自然而直接的方法。
缺点:
- 栈溢出风险:由于递归涉及函数调用,如果调用层数过深,可能导致栈溢出,从而引起程序崩溃。
- 性能问题:递归可能会导致大量的重复计算,尤其是在没有优化的情况下。即使没有重复计算,递归的频繁函数调用也可能影响性能。
- 空间效率:递归需要额外的栈空间来保存函数调用的状态,这在递归深度较大时尤其明显。
- 调试困难:递归函数的调试通常较为复杂,因为它们涉及多层函数调用和状态。
- 中间过程:递归使得程序员可能无法清晰地跟踪或分析递归调用的中间过程。
- 思维模式限制:使用递归时,程序员可能只需要关注问题的分解和出口,但这种思维模式可能不适用于需要详细分析中间过程的场景。
例子
阶乘 n! 的递归实现:
unsigned int Factorial(unsigned int n) {
if (n == 0) {
return 1;
}
return n * Factorial(n - 1);
}
for
循环实现:
unsigned int FactorialByIteration(unsigned int n) {
unsigned int result = 1;
for (unsigned int i = n; i > 0; --i) {
result *= i;
}
return result
}
斐波那契数列的递归实现:
unsigned int Fibonacci(unsigned int n) {
if (n == 0 || n == 1) {
return n;
}
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
for
循环实现:
unsigned int FibonacciByIteration2(int n) {
if (n == 0 || n == 1) {
return n;
}
int last = 0;
int current = 1;
for (int i = 0; i <= n - 2; ++i) {
int temp = current;
current = current + last;
last = temp;
}
return current;
}