【C语言笔记】函数
1. 函数简介
C语言中的函数可以抽象的理解为可轻松重复使用的代码块,它将一个个表达式和变量封装在一起,用来独立地完成某个功能。函数存在入口和出口,在入口处可以接收用户传递的参数,也可以不接收,在出口处可以将一些函数内部信息返回,也可以不返回任何信息。
将代码块封装成函数的过程叫做函数的定义。
1.1. 声明
形参:函数原型中定义的参数,其作用域为整个函数。
[链接属性修饰符] 函数返回类型 函数名 (形参数据类型列表);
函数返回类型可以是任意的数据类型(包括基本类型、空类型、构造类型、指针类型、自定义类型),当返回类型为void
时,表明函数无返回值,函数体内可以无return
语句。函数体中return
语句返回的数据类型需与函数声明的函数返回类型相同。
函数的形参数据类型列表可以包含多个任意类型的参数,也可不写任何形参(不符合编程规范)。
在函数声明的形参数据类型列表中,可以只写明形参的数据类型,而不用书写变量名。
链接属性修饰符可不书写,默认为extern
,一般将具有外部链接属性的函数声明放在头文件中,供其它翻译单元引用。
1 | //声明返回类型为void,形参列表为void的函数func1,该函数无任何返回值以及需要传入的参数 |
函数的声明是非必须的,若需要在不同的翻译单元中引用某个函数或不在该函数的作用域内引用,则需要在未定义该函数的翻译单元中进行函数声明,函数的声明应与函数的定义保持一致性。
1.2. 定义
[链接属性修饰符] 函数返回类型 函数名 (形参列表) { 函数体 }
函数的定义即函数的实现,在一个程序所链接的所有的翻译单元中只能存在一个具有外部链接属性的函数实现(ODR:一个定义规则),否则在编译时会出现链接器链接报错,但可以在不同的翻译单元中存在同名的具有内部链接属性的函数实现。
1 | void func1 (void) |
1.3. 函数的作用域
就单一的翻译单元来说,其函数若未使用 static
关键字进行修饰和在任何其他地方进行声明,其作用域为从翻译单元的定义处一直到该翻译单元的结束位置。也可使用声明来扩展其作用域,函数被声明后,其作用域为声明处一直到该翻译单元的结束位置。
2. 函数调用
2.1. 堆与栈
2.1.1 栈
栈是程序运行内存中的一个特别区域,它用来存储被每一个函数(包括 mian
函数)创建的动态存储局部变量,包括函数本身。栈是 FILO
结构体(First input Last output),就是先进后出原则的结构体,它直接被CPU管理和充分利用。每次函数声明和定义一个新的局部变量时,它就会被 push
到栈中,然后在每次一个函数退出时,所有关于这个函数中定义的局部变量(包括形参)都会被释放(栈顶重新调整,释放处内存空间中的数据并不会被马上覆盖,而是等到下一个局部变量入栈时进行覆盖)。一旦栈中的变量释放,这块栈的区域就会变成可用的,提供给其他栈中的变量。用栈存储变量的好处是,其内存是被CPU直接管理的,不需要进行手动的创建内存和手动的释放内存。CPU组织和管理栈内存是很高效的,读出和写入栈中存储的变量是很快的。
栈的内存空间相对较小,在定义局部变量和调用函数时应考虑开辟的栈空间大小是否合适,避免造成栈溢出。
2.1.2 堆
堆也是程序运行内存中的一个特别区域,但不是被自动管理的内存区域,对堆内存的使用,需要进行手动的申请(C:malloc/C++:new)和手动释放(C:free/C++:delete)。如果手动申请一片堆区内存后,而不进行手动释放,在程序运行的过程中,此处堆内存不会被清理,其他变量也无法申请该区域的内存,此时会造成内存泄漏的问题。堆区内存比栈区内存空间大,但也是有限的,操作系统为程序分配的堆内存若被耗尽,则会被操作系统杀死该进程程序。若是在无操作系统管理的设备上造成内存泄漏,会使整个系统发生崩溃。
2.1.3. 栈和堆的优缺点
- 栈
- 快速访问。
- 没有必要明确的创建分类变量,因为它是自动管理的。
- 空间被CPU高效地管理着,内存不会变成碎片。
- 只有局部变量。
- 受限于栈大小(取决于操作系统)。
- 变量不能调整大小。
- 堆
- 变量可以被全局访问。
- 没有内存大小限制。
- 访问比较慢(相对栈区访问)。
- 没有高效地使用空间,随着块内存的创建和销毁,内存可能会变成碎片。
- 必须手动管理内存(变量的创建和销毁由开发人员负责)。
- 变量大小可以用realloc()调整。
2.2. 调用的本质
C语言通过函数名后跟括号加上实参的形式进行函数调用。函数的调用是消耗栈区内存空间的,调用函数时,将函数定义所在的地址压入栈顶,栈顶指针”上移”,函数返回时,将压入的函数定义的地址弹出,栈顶指针”下移”,此次栈区空间被释放。若存在函数的多级调用而函数一直未返回(嵌套函数,递归函数),栈区的内存空间会很快被消耗完,造成栈溢出。
2.3. 参数传递
2.3.1. 实参与形参
实参:实际定义的变量,定义后就会分配内存,出现在主调函数中,在被调用后,会将实参所对应类型的值赋值给形参,实参变量也不能使用
形参:在函数定义时,形式上的参数,只有在函数被调用时才分配内存单元(栈区),在调用结束时,即刻释放所分配的内存单元。因此,形参也是局部变量,作用域为在整个函数内部有效。函数调用结束返回主调函数后则不能再使用该形参变量。
- 参数的传递是单向的,仅能是从实参传递给形参,形参无法将值传递给实参。
- 实参传递给形参的参数个数、类型和顺序都应相同,否则会进行数据类型的强制转换,可能出现数据丢失或者”类型不匹配”的错误。
2.3.2. 值传递与引用传递
仅将实参的值拷贝给形参,称为值传递。由于参数传递的单向性,形参在函数内部被修改值后,无法传递给外部的实参,即值传递不影响实参的值。
C语言没有实质上的引用传递,而是通过传入变量的地址而达到修改实参的目的。将实参的地址传递给形参,由于形参所指向的地址为实参变量的地址,所以修改形参对应地址中存储的值,就是修改实参变量的值,从而达到引用传递的目的。在C++中存在引用传递的语法,在形参变量前面加上&
符号意为是对实参的引用,在函数内部对形参变量的操作,就是对实参变量的操作。
3. 函数指针
函数指针也是一种指针类型类型,可以通过函数指针进行函数的调用。
函数指针的定义语法为[链接属性修饰符] 函数返回类型 (*指针变量名)(函数形参类型列表);
将函数的地址赋值给函数指针后,就可以通过该函数指针进行函数的调用。
1 | //定义一个名为funcptr1的函数指针,未初始化 |
函数的定义的名称,实际上是一个符号,并非是真正意义上的函数地址,而直接通过函数名称对函数指针赋值实际是一种语法糖(减少程序书写量的写法),sizeof(func2) = 1
,而标准的写法即是对函数名进行取地址后赋值给函数指针。但在实际开发过程中,多数是funcptr2的赋值方式。
使用 typedef
定义自定义的函数指针类型
1 | //定义一个函数指针类型的自定义类型FUNC_TYPE |
4. 函数返回值
在函数调用过程中,未使用static修饰定义的变量是动态存储的局部变量,局部变量是被压入栈区中存储的,当函数返回时,这些局部变量出栈,即局部变量已经被销毁,局部变量所占用的内存空间没有被清空,但是已经可以被分配给其他变量了,所以有可能在函数退出时,该内存已经被修改了,对于此局部变量来说已经是没有意义的值了。
在C语言中,通过return
语句返回的值是被存储在寄存器中的,主调函数可以通过=
等号直接取得函数的返回值。在16位程序中,返回值保存在ax
寄存器中,32位程序中,返回值保持在eax
寄存器中,64位程序的返回值,edx
寄存器保存高32bit,eax
寄存器保存低32bit。在函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,与内存没有关系。当退出函数的时候,局部变量可能被销毁,但是返回值已被存储到寄存器中,已与局部变量的生命周期无关。
寄存器的储存空间有限,当函数需要返回占用内存较大的结构体类型时,应返回该结构体的地址已作废。
现代编译器会对内存大小小于指针类型所占用空间的返回值使用寄存器存储其返回值,而对于内存大小大于指针类型占用空间大小的返回值,编译器会根据自身的策略,决定是使用栈区内存来存储返回值还是申请堆区内存来存储返回值,然后将该内存的值通过拷贝赋值给调用方。相较于存储在寄存器中的变量的拷贝,这种从内存的拷贝赋值的方式是较慢的。所以在需要返回占用内存较大的结构时,应返回其对应结构体的地址,而不是结构体本身。
对于返回值是指针类型的,该指针不能指向的是动态存储的局部变量,否则,返回动态存储变量的地址是无效的,甚至会对程序带来灾难。
4.1. 指针函数
指针函数是指返回类型为指针的函数,指针则意味着其值是指向某个变量的地址,这里需要注意的是,若变量是在该函数中定义的栈区局部变量,返回该变量的指针是毫无意义的,且容易给程序带来严重的灾难,因为函数返回后,其栈顶指针下移,在这之上的栈区内存会被新定义的变量所覆盖,原变量的内存中的值以及毫无意义,甚至是错误的。
1 | char *get_file_name1(const char *path) |
在上述例子中 get_file_name1
和 get_file_name2
都为一个指针函数,都返回一个指向 char
类型的指针(字符串),但区别是 get_file_name1
返回的变量是定义在栈区内存的字符串变量,而 get_file_name2
返回的变量是定义在堆区内存的字符串变量,在函数返回后,栈顶指针下移,其上方内存区域的值会被后续新定义的变量所覆盖,其内存区域的值已变得毫无意义,而堆区中的内存在不被手动释放后是无法被其他变量进行覆盖的,但要记住在使用该变量后,释放掉该内存。
5. 内联函数
前面提到函数的调用是需要消耗栈内存的,也存在入栈和出栈的处理时间,若某函数需要频繁的被调用且该函数的功能实现较为简单,则可以使用内联函数 来提升程序的执行速度和减轻栈内存压力。
什么是内联函数?
- 编译器会直接将内联函数体的实现代码替换掉其函数调用代码,类似于宏展开的行为,但此过程发生在 编译阶段。
inline
关键字使用注意事项
- 必须与函数体定义一起使用,才能使函数成为内联函数,在函数声明时使用是无效的。
inline
关键字只是编译建议,具体是否进行内联,由编译器决定。内联函数的实现必须是简单可靠的,不可递归。- 当内联函数不与任何链接属性关键字一起使用且内联失效时,则此函数不会被定义,调用者在链接阶段会报
找不到该函数定义
的错误。 - 当内联函数与
static
关键字一起使用时,若内联失效,则此内联函数会被当做普通的具有内部链接属性的函数,不会出现未定义的情况。 - 当一个函数使用
extern
关键字声明或定义时,inline
关键字失效。
e.g. 内联函数示例
1 | inline int get_big(int a, int b) |
get_big
内联函数的定义简单,就一个比较语句,无论是否开启编译优化,该函数都可以成功内联。num_spin
内联函数的定义中存在循坏语句,是复杂语句,编译器在不加优化选项时会内联失败,可以使用-Winline
控制内联失败告警的输出。由于该内联函数是非静态的,编译器在会忽略其定义,调用者会找不到其定义编译报错。可使用 -O3
的优化选项,使编译器更为激进的将复杂的非静态内联函数进行内联展开,或定义为静态内联函数,即使编译器内联展开失败,也会存在该函数原型的定义。
6. 内建函数
内建函数是指由编译器提供的函数,这些函数像关键字一样可以直接使用,不需要包含对应的头文件。
内建函数的函数命名通常以__builtin
开头,其主要功能如下:
- 处理变长参数列表
- 处理程序运行异常
- 程序编译和性能优化
- 查看函数运行的堆栈、C标准库的内建版本等信息
6.1. 常用的内建函数
__builtin_return_address(level)
1 | void * __builtin_return_address (unsigned int level); |
该函数用于返回当前函数或调用者的返回地址。参数level
含义如下:
- 0: 返回当前函数的返回地址
- 1: 返回当前函数调用者的返回地址
- 2: 返回当前函数调用者的调用者的返回地址
- …开始套娃
__builtin_frame_address
1 | void * __builtin_frame_address (unsigned int level) |
该函数返回当前函数或调用者的栈帧地址。参数level
含义同上。
__builtin_constant_p
1 | int __builtin_constant_p(...) |
该函数用于判断参数是否为常量,若为常量则返回1,否则返回0,常用于编译优化和性能优化。根据操作的参数是常量从而走不同的分支来进行优化处理。
__builtin_expect
1 | long __builtin_expect(long exp, long c); |
在编译分支语句的过程中,编译器会将可能性更大的分支代码紧跟着前面的代码,从而减少指令跳转带来的性能上的下降, 达到优化程序的目的。函数__builtin_expect
中参数exp
就是你的表达表达式,而参数c
是你的期望 exp == c
,但整个函数的返回值与参数c
无关,该函数只是提醒编译器该表达式的值等于c
的可能性很大,从而使编译器对分支进行优化,将可能性大的分支放在前面。
例;下述代码中,预测表达式exp
的值等于0
的可能性比较大,即走else
分支的可能性大,编译器则会将调用func2
的代码放在紧接着上一个语句代码的地方。1
2
3
4
5if (__builtin_expect(exp, 0)) {
func1();
} else {
func2();
}
该函数便于让程序员进行分支预测,从而优化程序执行性能,在Linux内核中likely
和unlikely
的宏定义便是调用该函数实现的。1
2
7. 匿名函数(Lambda表达式)
匿名函数,即没有函数名的函数,是函数的一种形式。
在C语言中没有原生对匿名函数的支持,但GCC编译器的GNU扩展功能可以在函数中定义函数,因此可以用宏定义来模拟实现Lambda表达式
。1
2_return_type
即是该Lambda
表达式的返回类型,_arg_func_body
即是该匿名函数的参数列表和函数体,__anonymous_fn
是这个代码块中的局部变量,用以表示该Lambda
表达式的函数名称,并在后续进行调用。下面是使用示例:1
2
3
4
5
6
7int main(int argc, char **argv)
{
int result = LAMBDA(int, (int a, int b){return a * b;})(5, 6);
printf("result = %d\n", result);
return 0;
}
GCC编译器默认是使用GNU扩展的,你可以通过添加-std=cxx
和-pedantic
来强制GCC编译器使用C标准语法来编译程序。