4723 字
24 分钟
C++基础四:函数与引用
2025-03-02
2025-03-04

1. 函数#

C++中,函数由两部分组成

  • 函数原型/函数声明:提供函数名、返回值和参数列表,每个调用函数的文件中都要声明函数,一般写在头文件里
  • 函数定义/函数实现:提供函数的具体内容,每个程序中每个函数必须且只能有一个函数实现

1.1. 函数原型#

函数原型是编译器与函数之间的接口。一个项目中函数往往分布在不同的文件中,甚至有时候是在编译好的库文件中,在链接之前,编译器需要为这些函数预留位置,这就必须知道函数的名称、参数和返回值,这样才能保证与外部函数正常链接。

函数原型的语法如下

type func(type,type,...);

其中,每个函数有一个返回值,也是就函数调用语句的类型,如果设置成void则表示没有返回值。函数名必须是一个合法的标识符,而且不能与其它标识符重复,函数参数则可以只提供类型,也可以提供类型和名称,设置成void或者留空表示没有参数

1.2. 函数定义#

函数定义需要放在函数原型之后。它根据原型的描述,提供一个函数的具体代码,由函数头和函数体组成,函数头与函数原型一致,唯一不同的是,为了使用参数中的变量,函数头必须为参数提供标识符,而函数原型不必要,函数头不是完整的语句,不用加分号,后面紧跟一个大括号,括号中就是函数的内容,叫做函数体,函数头的参数列表相当于定义了一系列作用域为函数体的局部变量,函数体中可以正常使用这些变量

void函数函数体的结尾(包括所有分支的结尾)必须有返回语句return name;,返回一个与函数的返回值类型相匹配的值,为了照顾一些编译器无法识别的复杂的情形,有的编译器中有返回值的函数没有返回语句不会产生错误,而是产生一个警告,继续编译,程序员应当确保每个可能的分支都以return语句结尾,以免引发不必要的问题

结合上述内容,函数定义的标准形式如下

type func(type name,type name2,...)
{
    //...
    return name3;
}

return语句有时还会用作强行终止函数用,在使用return语句后,无论函数运行到什么情况,都会强行弹出栈空间并返回,void函数也可以使用return语句终止函数,此时不用提供返回值

函数原型和函数定义可以合并在一起,即忽略函数原型,但是不能写在头文件中,否则会导致函数的多定义,外部文件需要使用该函数的话需要在对应文件中额外提供函数原型,而且由于调用必须在声明之后,原型与定义合并的写法会导致越后调用的函数越在前定义,不符合阅读习惯,在大型的C语言程序源代码中经常出现要翻页很久才能找到main函数开始读的情况,对程序员极不友好,C++推荐将原型和定义分开的方法

1.3. 函数调用#

使用函数的语句,我们称为函数调用,语法如下:

func(value1,value2,...);

函数调用是一个表达式,它的类型就是函数返回值的类型(void也可以视为一种类型),从逻辑上可以完全用返回值替代函数调用,自然,它也可以直接作为一个语句使用,也可以忽略掉返回值不作处理

参数列表则要提供符合原型的值,可以是一个变量,一个表达式,一个字面值,函数调用时会对这些值进行复制,然后传给新函数,不会更改原函数中变量的值,同时调用结束后也会把返回值复制给原函数,并清空新函数的栈空间

在函数调用过程中,传递参数和返回值时,系统都会对数值自动进行隐式类型转换,需要额外注意规避可能存在的问题

函数中可以调用自身,我们把这样的操作称作递归,递归可以实现很多数学上的递归算法,非常常用

IMPORTANT

为了查看函数调用的具体过程,我们编写如下代码:

int func(int x);

int main()
{
    int i = func(1);
    return 0;
}

int func(int x)
{
    return ++x;
}

然后使用以下命令关闭优化编译,并查看汇编代码:

g++ -O0 t.cpp
objdump -S a.out

两个函数对应汇编代码如下:

0000000000001119 <main>:
    1119:	55                   	push   %rbp
    111a:	48 89 e5             	mov    %rsp,%rbp
    111d:	48 83 ec 10          	sub    $0x10,%rsp
    1121:	bf 01 00 00 00       	mov    $0x1,%edi
    1126:	e8 0a 00 00 00       	call   1135 <_Z4funci>
    112b:	89 45 fc             	mov    %eax,-0x4(%rbp)
    112e:	b8 00 00 00 00       	mov    $0x0,%eax
    1133:	c9                   	leave
    1134:	c3                   	ret

0000000000001135 <_Z4funci>:
    1135:	55                   	push   %rbp
    1136:	48 89 e5             	mov    %rsp,%rbp
    1139:	89 7d fc             	mov    %edi,-0x4(%rbp)
    113c:	83 45 fc 01          	addl   $0x1,-0x4(%rbp)
    1140:	8b 45 fc             	mov    -0x4(%rbp),%eax
    1143:	5d                   	pop    %rbp
    1144:	c3                   	ret

可以看到,调用每个函数时,函数会将原先的堆栈基指针rbp压入栈,然后将当前堆栈顶指针的值赋予rbp作为新函数的栈底,接着从寄存器取出参数,定义变量,都依次放在栈顶之后(栈地址从高向低生长),最后把返回值放在edi寄存器,将原先压入栈的旧堆栈基指针重新弹出到rbp中,并更改程序计数器返回原先的函数中

在这个过程中,每次调用函数,都会把原先函数的堆栈基指针和程序计数器信息(在callret指令中被操作)保存在栈中,函数没有结束时,局部变量也会一直保存在栈中不清除,而栈的空间往往是有限的,过深的递归会导致栈空间被占满,程序异常退出,而且递归过程反复移动值,效率也不高,因此只要能用循环代替递归就尽量不要用大规模的递归

2. 函数实用技术#

2.1. 内联函数#

对于一些非常小且经常调用的函数,反复调用函数会产生相对很大的额外开销,我们就会使用内联函数

在函数原型和函数定义前都加上inline关键字以将函数变为内联函数,内联函数会在编译时被直接合并到代码内,不进行实际的函数调用,如

inline add(int x,int y){return x+y};

int main()
{
   	int x = 0,y = 0;
    int z = add(x,y);
}

等同于

int main()
{
    int x = 0,y = 0;
    int z = x + y;
}

内联函数可以被写在头文件中,因为它更类似于一种文本替换的宏,而不是实际的定义(只是类似),但是,内联函数不能递归调用

inline关键字只是一种建议,如果编译器发现该函数内部存在递归,或者编译器认为这个函数非常庞大,函数最后可能不会被编译为内联函数,inline关键字会被忽略

2.2. main函数#

每个可执行程序都应该有(且只有)一个main函数,无论该程序由多少个源文件和库文件(当然,库文件不应该有main函数)组成,该函数会作为系统调用这个可执行文件时的入口

main函数返回一个int值,表示程序执行的结果,0表示正常退出

TIP

main函数可以省略return语句,编译器会默认返回0,值得注意的是,只有main函数这一个特殊的函数可以省略返回语句,编译器会为其加上默认的return 0;,其他函数省略返回语句时,编译器不会为其加上返回的操作,这会导致程序出现错误并退出

main函数接收两个参数(如果不需要,也可以省略),一个int,一个char**,分别称argcargvargc表示程序参数的数目,argv是一个二维char数组,也是一个字符串组成的一维数组,存储argc个字符串,即系统调用该可执行程序时传入的参数

2.3. 默认参数#

在函数原型(没有原型则在函数头)中,我们可以为参数赋予默认的参数

type fun(type name,type name2 = value2,type name3 = value3)

在函数原型里也一样可以省略标识符

在调用函数时,对于有默认值的函数,我们可以不给出这些参数,编译器会给它一个默认值

为了避免歧义C++规定,有默认值的参数后面的参数必须都是有默认值的,在调用时也会按顺序匹配参数,不能跳过某个参数,比如对于上面的函数fun(value1,,value3)这样的调用是不合法的

要实现上面这种跳过某个参数的功能,需要使用函数重载的功能

2.4. 函数重载#

C++允许一定程度上允许相同名字而参数、返回值不同的函数在不产生歧义的条件下存在,具体来说,在不考虑默认参数的情况下,C++允许参数不同、参数和返回值都不同的同名函数存在,这些函数函数体没有要求,可以相似,也可以完全不同,在调用函数时,编译器会决定到底使用函数的哪个版本,这个功能通常用来实现更灵活的默认参数,或者面向对象编程的多态功能

TIP

C++中运算符实际上也是函数,如两个int x,yx*y实际上对应函数int operator*(int x,int y),基于此,我们就可以重载这些运算符函数,为任意类型定义这些运算,通常会用在我们自己定义的数据类型,如结构体、联合、类等

运算符重载有很大自由,我们可以为任意类型定义,哪怕对多元运算符不同操作数采取不同类型

运算符重载也有一定限制,我们无法更改运算符的操作数个数与顺序,也无法更改运算符的优先级与结合性,这一特点使我们在被重载的运算符用途与原先用途逻辑上区别很大时要格外注意优先级,如C++中对流的操作重载的左右移位运算符,它们的优先级不是最低的,在操作数是复杂的表达式时,稍不小心就会产生优先级问题

在上一节的例子中,汇编代码中函数名相比源代码加了一些字符,这就是编译器为了区别重载函数而做的名称修饰

2.5. 函数模板#

有时我们需要对很多种变量类型进行相同的操作,如果对每种可能得变量都编写一个函数,会十分复杂,于是C++中引入了对泛型的支持,具体到函数中就是函数模版

函数模版允许我们规定一个抽象的类型,当调用时,再根据具体使用的类型生成特定的函数,语法如下

template <typename T>//此处的typename是关键字,T才是模版类型的名字
void templ(T,T);

template <typename T>
void templ(T a,T b)
{
 	//...
}

函数内就将T视为一种类型用,也可以在尖括号里定义多个模版类型

NOTE

函数模版不是通常意义上的函数,编译器在遇到函数调用时才会根据函数调用的类型生成对应类型的函数实例,我们叫做实例化,如果调用模板的类型不支持模版中某种操作也会在此时报错

同时,模版在调用时才实例化,也就是说在调用时才定义函数的实例,模版本身并不定义函数,只是生成函数的模版,因此,函数模版一般被放在头文件中,以供各个文件实例化自己的函数,这时,函数模版的原型和定义分开就没什么必要性了,一般不会分开写

除了在调用时进行隐式实例化,也可以手动进行显式实例化定义

template void templ<type>(type,type);

显式实例化定义会定义一个函数,它不应该被放在头文件中

更常用的一种操作是,在调用函数时进行显式实例化

templ<type>(a,b);

这样不管a,b是什么类型,编译器都会实例化一个type类型的模版,然后正常调用这个函数

最后,如果某个类型需要特殊处理,我们还可以对模版进行具体化

template<> void templ<type>(type,type);

template<> void templ<type>(type a,type b){
    \\...
}

和普通函数一样,具体化函数也有函数原型和函数体,函数原型放在头文件中,而函数体放在某一个文件中即可,上面的<type>是可选的,编译器会根据后面的类型自动推断

NOTE

加入模版后,我们的函数还是免不了产生很多歧义

这种时候,编译器会将所有隐式类型转换范围内允许的函数列表,之后按顺序匹配:

  • 值到引用、引用到值、数组到指针、指针到类型,以及变量到常量这些无关紧要的转换
  • 从小到大的类型提升
  • 从大到小,产生精度损失的转换
  • 自定义的类型转换

如果最佳匹配中还是有多个,则

  • 如果需要转换的数目不一样,选择需要转换数目最小的
  • 模版函数与普通函数,优先选择普通函数
  • 具体化函数与实例化函数,优先选择具体化函数
  • 显式实例化函数与隐式实例化函数,优先选择隐式实例化函数

如果还是有多个匹配的,编译器将会产生二义性错误,终止编译

TIP

在使用模版时,还可能出现不知道返回值类型的情况

我们在模版中调用与模版类型相关的函数或者运算符时,并不知道返回的值的类型,无法确定保存这个值用的变量的类型,一个解决方案是用auto关键字,另一个方案是使用decltype

decltype()可以当作一个类型名使用,如果括号里是标识符,则是标识符对应的类型,如果括号里是函数调用,则是函数的返回值,如果是非标识符的左值(左值在第3节介绍),如括号括起来的标识符,则是左值类型的引用,如果括号里是表达式,则是表达式的类型

搭配auto和后置返回值语法,还可以有如下用法

template<typename T1,typename T2>
auto add(T1 x,T2 y) -> decltype(x+y)
{
    return x+y;
}

3. 引用变量#

在传递函数的参数时,程序内部会将函数参数复制一份,这种做法效率较低,同时,函数也不能改变原先传参给函数那个变量的值,要是引入指针又未免过于复杂,于是C++引入了引用的概念

我们可以使用如下语法创建一个引用:

type & name = var;

这将创建一个type类型的引用,指向var这个变量

这里的type &是一个完整的类型,&是类型名的一部分,而不是运算符

函数左边必须是一个左值

NOTE

左值原本指在赋值语句左边的值,相对应的是右值,但是出现const常量后,这一定义产生了分歧,最后的结论是,左值指在内存中有实体的,可被引用的值,比如变量,常量,指针等等,而右值就是在内存中没有实体,不可被引用的值,如表达式,常量字面值等

一旦引用声明后,它就不能更改引用对象,对它的所有操作都等同于对引用对象的操作,它就是引用对象的另一个标识符,它们代表相同的一块内存区域,这实际上和常量指针是一样的

引用可以被隐式转换成普通类型,而普通类型只有左值会被隐式转换成引用,右值只能被隐式转换成常量指针(这会创建一个临时变量),如果试图将右值转换成变量指针,一些编译器仍然会创建临时变量,并抛出一个警告,另一些则会直接抛出警告终止编译

可以将引用作为函数的参数,此时调用函数时,编译器会自动对左值创建引用,函数通过操作参数就可以直接操作调用函数时作为参数的左值

如果只是将引用用来避免值传递,建议将参数设为const,这样编译器会自动为右值也创建临时变量,而且常量也可以用作参数了(区别于值传递,值传递时仅传递数值,不对原先的变量产生影响,故不用管是左值还是右值,是常量还是变量),在此基础上如果返回值也是参数之一,可以返回一个常量引用,以此避免值传递(返回变量引用会导致函数调用变成左值,非常奇怪,而且返回的这个引用和参数完全一样,要更改可以更改原先的参数,不必是变量)

TIP

在传递大型对象,像结构体、类的时候,我们一般会选择引用传递,以此避免大量值复制过去复制过来,如果只是个基本变量的话,使用引用往往是为了更改变量的值,不用更改值的话没必要用引用

WARNING

使用引用作为返回值时要注意,只能引用参数或全局变量,不能引用函数内部定义的量,这些量在函数结束后就会被回收,会导致引用指向未分配的内存区域,产生严重后果

C++基础四:函数与引用
https://ssl.ztsubaki.xyz/posts/14/14/
作者
ZTsubaki
发布于
2025-03-02
许可协议
CC BY-SA 4.0