文章目录
- 一、函数基础
- 1.基本函数定义
- 2.函数的声明与定义
- 3.函数调用
- 二、函数详解
- 1.参数
- 2.函数体
- 3.返回类型
- 三、函数重载与重载解析
- 1.函数重载
- 2.重载解析
- 四、函数相关的其他内容
- 1.递归函数
- 2.内联函数
- 3.constexpr函数(C++11起)
- 4.consteval 函数 (C++20 起 )
- 5.函数指针
一、函数基础
函数:封装了一段代码,可以在一次执行过程中被反复调用。函数包括函数头(函数定义)与函数体。引入函数实现了逻辑封装与逻辑复用。
1.基本函数定义
基本的函数定义包括函数名称、形式参数、返回类型
-
函数头
-
函数名称-标识符,用于后续的调用
C++标识符的一些主要要求:
- 字母数字组成:标识符只能由字母(大小写区分)、数字和下划线组成。
- 不以数字开头:标识符不能以数字开头,必须以字母或下划线开始。
- 大小写敏感:C++是大小写敏感的语言,因此
identifier
和Identifier
是两个不同的标识符。 - 保留字:不能使用C++的保留字作为标识符。例如,不能使用
if
、for
、int
等关键字作为函数名或变量名。 - 长度限制:标识符的长度可能受到编译器和系统的限制,但至少前31个字符在C++标准中是有定义的。
- 标准符合性:标识符应当遵循C++标准,避免使用与系统或库函数同名的标识符,以防止潜在的冲突。
- 可读性:尽管C++允许使用下划线以外的特殊字符(如
$
或@
)作为标识符,但通常建议避免这样做,以保持代码的可读性和跨平台兼容性。 - 命名约定:C++社区有一些非强制性的命名约定,例如:
- 驼峰命名法:第一个单词的首字母小写,后续单词的首字母大写,如
myFunction
。 - 下划线命名法:所有单词都小写,并用下划线分隔,如
my_function
。 - 匈牙利命名法
- 驼峰命名法:第一个单词的首字母小写,后续单词的首字母大写,如
-
形式参数-代表函数可以接受的输入值,它定义了参数的类型和名称。参数列表可以为空,即函数不接受任何输入。
-
返回类型-函数执行完成后所返回的结果类型
-
-
函数体
- 为一个语句块( block ),包含了具体的计算逻辑
returnType functionName(parameterType1 param1, parameterType2 param2) {// 函数体return returnValue;
}
2.函数的声明与定义
函数的声明与定义:
-
函数声明只包含函数头,不包含函数体,通常置于头文件中,用于声明在其他文件中定义的函数。这允许在编译时建立函数的接口,而不必暴露其实现细节。
头文件通常使用预处理指令
#ifndef
、#define
和#endif
来避免重复包含。// 在头文件 myMathFunctions.h 中的函数声明 #ifndef MYMATHFUNCTIONS_H #define MYMATHFUNCTIONS_Hint add(int a, int b); double multiply(double a, double b);#endif // MYMATHFUNCTIONS_H
-
函数声明可出现多次,但函数定义通常只能出现一次(一次定义原则,存在例外(内联函数可以在多个翻译单元中出现多次,每个翻译单元出现一次))。它通常放在源文件(
.cpp
文件)中。// 在源文件 myMathFunctions.cpp 中的函数定义 #include "myMathFunctions.h"int add(int a, int b) {return a + b; }double multiply(double a, double b) {return a * b; }
在主程序
main.cpp
中,可以通过包含头文件来使用这些函数:#include <iostream> #include "myMathFunctions.h"int main() {std::cout << "5 + 3 = " << add(5, 3) << std::endl;std::cout << "5.5 * 2 = " << multiply(5.5, 2) << std::endl;return 0; }
3.函数调用
函数调用的基本语法:
returnType functionName(arguments);
returnType
是函数的返回类型,functionName
是函数的名称,arguments
是传递给函数的实际参数列表
-
函数调用需要提供函数名与实际参数
-
实际参数拷贝初始化形式参数
实际参数和形式参数:
- 实际参数(Actual parameters):在函数调用中提供的参数,它们是传递给函数的值或变量。
- 形式参数(Formal parameters):在函数定义中声明的参数,用于接收传递给函数的实际参数。
-
返回值会被拷贝给函数的调用者
-
栈帧结构
在C++中,函数调用是通过使用调用栈(call stack)来实现的。每当一个函数被调用时,一个新的栈帧(stack frame)会被创建并推入调用栈。栈帧是存储在调用栈上的一块内存,它包含了关于函数调用的所有信息,包括:
- 参数:传递给函数的参数值。
- 局部变量:函数内部定义的变量。
- 返回地址:函数执行完毕后返回到调用它的代码位置的地址。
- 寄存器状态:函数调用可能会改变一些寄存器的值,这些值需要在函数返回之前保存并恢复。
- 函数的返回值:如果函数有返回值,返回值也会存储在栈帧中。
C++函数调用时栈帧示例:
#include <iostream>void functionA(int a, int b) {int localVar = a + b;std::cout << "In functionA: " << localVar << std::endl;// 当前栈帧包含参数a, b, localVar, 以及返回地址 }int main() {int arg1 = 5;int arg2 = 10;functionA(arg1, arg2);return 0; }
当
main
函数调用functionA
时,以下步骤发生:arg1
和arg2
的值被推送到调用栈上,作为functionA
的参数。- 为
functionA
创建一个新的栈帧,并将其推入调用栈。 - 在
functionA
的栈帧中,局部变量localVar
被创建并初始化。 functionA
执行并打印localVar
的值。- 一旦
functionA
执行完毕,它的栈帧被弹出调用栈,控制权返回到main
函数,并恢复main
函数的状态。 main
函数继续执行,直到程序结束。
栈帧的生命周期:
栈帧的生命周期与它所属的函数调用相同。当函数调用开始时,栈帧被创建;当函数返回时,栈帧被销毁。栈帧的创建和销毁是自动进行的,由编译器和底层硬件共同管理。
-
函数的外部链接
C++支持两种链接类型:内部链接(internal linkage)和外部链接(external linkage)。默认情况下,函数具有外部链接。
具有外部链接的函数可以在程序的不同编译单元中被定义和使用。这意味着,如果一个函数在一个源文件中定义,它可以在其他源文件中被调用,即使这些源文件是分开编译的。
// file1.cpp int add(int a, int b) {return a + b; }// file2.cpp extern int add(int a, int b); // 声明函数,告诉编译器是一个外部链接的函数,它的定义在程序的其他地方。 int main() {int result = add(1, 2); // 调用函数return 0; }
在这个例子中,
add
函数在file1.cpp
中定义,并在file2.cpp
中通过extern
关键字声明。extern
关键字告诉编译器,该函数的定义在程序的其他部分。
二、函数详解
1.参数
-
函数可以在函数头的小括号中包含零到多个形参
-
包含零个形参时,可以使用 void 标记
void func(void) {}
-
对于非模板函数来说,其每个形参都有确定的类型,但形参可以没有名称(类型用于编译器检查)
-
形参名称的变化并不会引入函数的不同版本(函数的名称相同且形参的类型相同即重复定义)
-
实参到形参的拷贝求值顺序不定
函数调用时,使用实参拷贝初始化形参,但多个形参时,拷贝初始化执行顺序不确定(在不同编译器上不确定),因此,下面代码的写法就非常危险
#include <iostream>void fun(int x, int y) {std::cout << y; }int main() {int x = 0;fun(x++, x++); }
-
若实参为临时对象,C++17 强制省略复制临时对象
-
函数传值、传址、传引用:
-
传值
传值是将参数的值复制到函数内部的局部变量中。这种方式下,函数内部对参数值的修改不会影响原始变量。
void incrementByValue(int n) {n += 1; // 修改的是局部变量的副本,不影响传入的原始变量 }int main() {int a = 10;incrementByValue(a);// a 仍然是 10return 0; }
-
传址
传址是将参数的内存地址直接传递给函数。这种方式允许函数直接修改原始变量的值。
void incrementByAddress(int* p) {*p += 1; // 通过指针修改原始变量的值 }int main() {int a = 10;incrementByAddress(&a);// a 现在是 11return 0; }
-
传引用
传引用是通过引用传递参数,即传递一个对象的别名。这种方式也允许函数修改原始变量的值,并且比传址更安全,因为它避免了指针的使用。
void incrementByReference(int& r) {r += 1; // 直接修改原始变量的值 }int main() {int a = 10;incrementByReference(a);// a 现在是 11return 0; }
选择哪种方式
- 当你需要函数内部修改原始变量时,使用传址或传引用。
- 当你希望函数接收一个值,并且不关心原始变量时,使用传值。
- 当你传递大型对象或数组时,为了避免复制开销,通常使用传址或传引用。
- 使用传引用可以提高代码的可读性,并减少对指针的错误使用。
函数传参(拷贝初始化)过程中的类型退化:
-
数组类型退化
当你将一个数组作为参数传递给函数时,数组类型退化为指针类型。这意味着你失去了数组的原始大小信息。
一维数组
多维数组
为了避免数组类型退化,传递数组的引用,确保数组的尺寸信息传递给函数
-
函数类型退化
当函数类型用作参数或返回类型时,它们退化为指针类型
void callFunction(void (*func)()) {// func 是一个指向无参数无返回值函数的指针 }void a() {}int main() {callFunction(a); }
对于函数类型退化,通常不需要特别处理。
变长参数:允许函数接收任意数量的参数
-
initializer_list
,要求变长参数类型一致,包含两个指针begin
与end
(迭代器),可查看https://zh.cppreference.com/w/cpp/utility/initializer_list#include <iostream> #include <initializer_list>void fun(std::initializer_list<int> par) {}int main() {fun({1, 2, 3, 4, 5}); }
-
可变长度模板参数
变长参数类型可以不一致,在模板章节具体讲解
-
使用省略号表示形式参数
不建议在C++中使用省略号表示形式参数
函数可以定义缺省实参:
在C++中,函数的缺省(默认)实参允许你在函数声明或定义时为某些参数提供默认值。这意味着在调用函数时,如果没有为这些参数提供值,编译器会自动使用这些默认值。推荐的做法是在函数定义中指定默认参数,这样只会有一个函数版本,避免了链接错误。
-
如果某个形参具有缺省实参,那么它右侧的形参都必须具有缺省实参
#include <iostream> #include <initializer_list>//编译会出错,y也必须有缺省实参 void fun(int x = 0, int y) {}int main() {fun(1,2); }
-
在一个翻译单元中,每个形参的缺省实参只能定义一次
-
具有缺省实参的函数调用时,传入的实参会按照从左到右的顺序匹配形参
-
缺省实参为对象时,实参的缺省值会随对象值的变化而变化
不建议这么干
#include <iostream>int x = 3; void fun(int y = x) {std::cout << y; //4 }int main() {x = 4;fun(); //fun(x) }
main 函数的两个版本:
在C++中,main
函数是程序的入口点,它可以有两种形式:
-
无形参版本
int main() {// 程序代码return 0; }
main
函数只返回一个整数,通常返回0
表示程序成功执行。C++标准规定
main
函数必须返回一个整数值,如果返回0
,表示程序成功结束;否则,表示程序异常结束。 -
带两个形参的版本
这种形式的
main
函数接受两个参数:argc
和argv
。这两个参数提供了对命令行参数的访问。int main(int argc, char* argv[]) {// 程序代码return 0; }
argc
(argument count)是一个整数,表示命令行中传递给程序的参数个数(包括程序名称)。argv
(argument vector)是一个指向字符串数组的指针,包含了命令行参数。第一个元素argv[0]
是程序的名称,随后的元素argv[1]
、argv[2]
等是传递给程序的其他参数。argv
数组中的最后一个元素之后有一个以nullptr
(在C++11及以后版本中)或NULL
(在旧版C++中)结尾的指针,表示参数列表的结束。
示例:
#include <iostream>int main(int argc, char* argv[]) {if (argc != 3){std::cerr << "arg invalid!";return -1;}std::cout << "Number of command line arguments: " << argc << std::endl;for (int i = 0; i < argc; ++i) {std::cout << "Argument " << i << ": " << argv[i] << std::endl;}return 0; }
运行结果:
Number of command line arguments: 1 Argument 0: ./output.s
2.函数体
函数体定义了函数的行为,即当函数被调用时所执行的一系列操作。函数体内部可以包含变量声明、控制流语句(如if
、for
、while
等)、函数调用、表达式等。
函数体形成域:
- 其中包含了自动对象(内部声明的对象以及形参对象)
- 也可包含局部静态对象
函数体执行完成时的返回:
-
隐式返回(不推荐的做法)–执行到函数的右大括号自动跳出函数回到函数的调用者
在C++中,如果一个函数的返回类型不是
void
,那么在函数体执行完成时,如果没有显式地使用return
语句返回一个值,编译器会根据函数的返回类型进行隐式返回。对于非void
返回类型的函数,如果没有return
语句,或者return
语句没有返回值,函数的执行流程结束时,编译器会执行以下操作:- 如果函数的返回类型是内置类型(如
int
,float
,double
,char
等),编译器会生成一个未定义的值(undefined value)作为返回值。这是因为C++标准规定,未初始化的局部变量的值是未定义的。 - 如果函数的返回类型是类类型,编译器会构造一个临时对象作为返回值。这个临时对象的值同样是未定义的,因为对象的构造函数没有被显式调用。
- 对于数组类型,编译器无法隐式返回一个数组,因为数组不能被拷贝。如果函数声明为返回数组,那么这本身就是一个语法错误。
- 对于指针类型,编译器会生成一个未定义的指针值作为返回值。
- 对于引用类型,编译器会生成一个未定义的引用目标。
需要注意的是,使用隐式返回是不推荐的做法,因为它会导致不可预测的行为,可能会引发难以发现的错误。最佳实践是在所有非
void
类型的函数中都使用显式的return
语句来返回一个明确的值。这样可以提高代码的可读性和可维护性,并避免潜在的错误。隐式返回示例:
#include <iostream>int implicitReturn() {// 函数体为空,没有显式的 return 语句 }int main() {int result = implicitReturn();// result 的值是未定义的std::cout << result; //4198762//return 0; //main()函数是一个特殊的函数,可以使用隐式返回,相当于返回0,表明执行成功 }
main()
函数是一个特殊的函数,可以使用隐式返回,相当于返回0,表明执行成功。其他所有函数要想使用隐式返回,返回类型要求void
。 - 如果函数的返回类型是内置类型(如
-
显式返回关键字: return
-
return;
语句–返回类型必须是void
void fun() {//...语句return; }
-
return 表达式 ; — 用于从函数返回一个值给调用者的语句
示例:
int add(int a, int b) {return a + b; // 返回两数之和 }int main() {int sum = add(5, 10);// sum 的值现在是 15return 0; }
return
表达式的重要点:- 返回值类型:
return
表达式的类型必须与函数的返回类型兼容。如果函数返回一个非void
类型,return
表达式必须产生一个与返回类型匹配的值。 - 构造函数:在构造函数中,
return
表达式是可选的,并且通常不使用。如果需要返回一个对象,通常是通过构造函数的初始化列表来完成的。 - 析构函数:析构函数不能返回任何值,因为它们的返回类型是
void
。 - 表达式求值:
return
表达式会被求值,其结果会被返回。如果表达式是复杂的,它可能包含变量、函数调用、算术运算等。 - 控制流:
return
语句可以作为控制流的一部分,用于在满足特定条件时提前退出函数。 - 返回局部对象:返回一个局部对象的引用或指针是不安全的,因为局部对象的生命周期在函数返回后就结束了。
- 返回动态分配的内存:返回动态分配的内存(如指针)需要确保内存在适当的时候被释放,否则会导致内存泄漏。
- 返回值类型:
-
return 初始化列表 ;
在C++中,返回初始化列表允许你直接在函数返回值时初始化对象(初始化列表是自动对象,在
fun()
函数结束后,自动对象就会被销毁)。这种语法特别适用于那些需要返回复杂类型(如类对象或结构体)的函数。返回初始化列表的基本语法如下:
ReturnType FunctionName(Parameters) {return {arg1, arg2, ...}; }
示例:
struct Point {int x, y;Point(int x, int y) : x(x), y(y) {} };Point createPoint(int x, int y) {return {x, y}; // 使用返回初始化列表 }
注意:
- 返回初始化列表不能用于虚函数,因为虚函数的返回类型是多态的。
- 如果函数的返回类型是引用类型,不能使用返回初始化列表。
- 返回初始化列表在某些情况下可以提高效率,因为它可以直接在栈上构造对象,避免了不必要的拷贝。
-
-
小心返回自动对象的引用或指针
在C++中,返回一个局部对象的引用或指针(通常称为自动对象,因为它们的生命周期仅限于函数的执行过程)是一个常见的错误,因为它会导致悬挂引用(dangling reference)或无效指针(invalid pointer)问题。尝试通过悬挂引用或无效指针访问这些内存区域将导致未定义行为。
- 悬挂引用指的是一个引用指向一个已经销毁的对象,
- 无效指针指的是一个指针指向一个已经释放的内存区域
返回局部对象的引用或指针是一个常见的陷阱,应该通过返回对象的拷贝、使用智能指针、引用参数、静态局部变量等方法来避免。
-
返回值优化(
RVO
)—— C++17 对返回临时对象的强制优化返回值优化(Return Value Optimization,简称RVO)是一种编译器优化技术,用于改善含有返回对象的函数的性能。在C++中,当一个函数返回一个对象时,编译器通常会在内存中创建一个临时对象,然后将这个临时对象复制或移动到调用者的内存空间。这个过程可能会因为不必要的复制操作而降低程序的效率。
返回值优化的工作原理:
RVO
的目的是避免这种不必要的复制,它允许编译器在函数返回时直接在调用者的内存空间中构造返回的对象。这样,就不需要创建一个临时对象,从而提高了程序的性能。
3.返回类型
-
返回类型表示了函数计算结果的类型,可以为 void
在C++中,函数的返回类型确实表示了函数计算结果的类型。返回类型可以是基本数据类型、类类型、结构体类型、枚举类型、指针类型、引用类型等,或者是
void
。当函数的返回类型是void
时,它表示该函数不返回任何值。 -
返回类型的几种书写方式
-
经典方法:函数返回类型位于函数头的头部
ReturnType FunctionName(ParameterList) {// 函数体 }
-
C++11 引入的方式:位于函数头的后部
C++11标准引入了尾随返回类型,允许将返回类型放在函数头的后部,用
->
符号指明:auto FunctionName(ParameterList) -> ReturnType {// 函数体 }
这种写法在模板编程和Lambda表达式中特别有用,因为它允许编译器根据函数体中的返回语句来推导返回类型。
-
C++14 引入的方式:返回类型的自动推导
C++14进一步简化了返回类型的书写,引入了返回类型自动推导。使用
auto
关键字,编译器会根据函数体中的代码自动推导返回类型:auto FunctionName(ParameterList) {// 函数体 }
这在定义计算并返回特定类型的值的函数时非常有用,因为程序员不需要显式声明返回类型。
-
C++17使用
constexpr if
构造具有不同返回类型的函数C++17引入了
constexpr if
,这允许在编译时根据条件选择不同的执行路径。虽然constexpr if
本身并不直接影响函数的返回类型,但它可以用于创建在不同条件下返回不同类型值的函数。这通常与返回类型自动推导结合使用://条件condition为常量表达式 auto FunctionName(bool condition, int a, double b) {if constexpr (condition) {// 当condition为true时,返回整数类型return a;} else {// 当condition为false时,返回浮点数类型return b;} }
-
-
函数返回类型与结构化绑定( C++ 17 )
在C++17中,引入了结构化绑定(Structured Bindings)特性,它允许从具有多个成员的复合数据类型中提取多个变量,简化了对结构体、类、对(pair)、元组等类型的访问。然而,结构化绑定本身并不直接影响函数的返回类型,只是提供了一种使用返回值的方式,它主要用于简化对这些类型的使用。
结构化绑定的基本用法:
结构化绑定允许你从一对括号中指定多个变量名,这些变量名对应于结构体或元组中的成员:
struct Point {int x, y; };auto [x, y] = Point{1, 2};
函数返回类型与结构化绑定:
当你使用结构化绑定时,通常是为了简化对复合类型中数据的访问,而不是改变函数的返回类型。不过,你可以设计一个函数,使其返回一个结构体或元组,然后使用结构化绑定来提取返回的值。
#include <utility>std::pair<int, int> getPair() {return {10, 20}; }int main() {auto [first, second] = getPair();// 使用first和second变量return 0; }
函数返回类型自动推导与结构化绑定:
结合使用C++14的返回类型自动推导和结构化绑定,可以创建简洁且易于使用的函数:
auto getPoint() {return Point{3, 4}; }int main() {auto [x, y] = getPoint();// 使用x和y变量return 0; }
-
[[nodiscard]]
属性( C++ 17 )在C++17中,标准库引入了一个新的属性
[[nodiscard]]
,它用于标记函数,以指示函数的返回值不应该被忽略。这个属性是编译器的一个提示,它告诉程序员调用这些函数时应该使用返回值。[[nodiscard]]
通常用于以下几种情况:- 资源获取即初始化:当一个函数返回一个资源,比如打开文件返回的文件句柄,或者创建动态内存返回的指针,程序员应该使用这个返回值,否则可能会造成资源泄露。
- 错误报告:某些函数用于错误报告,比如返回错误码或异常对象,这些返回值不应该被忽略,因为它们包含了重要的状态信息。
- 重要的计算结果:当一个函数进行了重要的计算,并且这个结果对于程序的逻辑至关重要时,可以使用
[[nodiscard]]
来确保程序员不会忘记使用这个结果。
示例:
#include <iostream> #include <memory>[[nodiscard]] std::unique_ptr<int> createResource() {std::cout << "Resource created.\n";return std::make_unique<int>(42); }int main() {auto resource = createResource(); // OK: 使用了返回值// auto ignoredResource = createResource(); // Warning: 忽略了返回值return 0; }
注意:
[[nodiscard]]
是一个属性,不是函数的一部分。它不改变函数的签名,只是对编译器的一个额外说明。- 忽略带有
[[nodiscard]]
属性的函数的返回值将导致编译器警告,但不是错误。这允许库的作者提示用户注意返回值,而不强制他们在所有情况下都必须使用返回值。 [[nodiscard]]
可以用于任何返回非void类型的函数,包括构造函数、析构函数、以及普通成员函数。[[nodiscard]]
可以与模板函数一起使用,以确保在模板实例化时也考虑返回值。- 某些编译器可能提供了类似的编译器特定属性,如
[[gnu::warn_unused_result]]
,但在C++17中,[[nodiscard]]
是标准属性,被所有遵循标准的编译器支持。 - 通过使用
[[nodiscard]]
,开发者可以提高代码的安全性和健壮性,确保重要的返回值不会被无意中忽略。
三、函数重载与重载解析
1.函数重载
函数重载(Function Overloading)是C++语言的一个特性,它允许在同一个作用域内定义多个具有相同名称但参数列表不同的函数。函数重载使得同一个函数名可以用不同的参数类型或数量来调用,增加了语言的灵活性。
函数重载的规则与要点:
- 参数列表不同:重载的函数必须在参数的类型、数量或两者方面有所不同。
- 返回类型不参与重载:函数的返回类型不能作为重载的依据。仅根据返回类型不同而参数列表相同的函数,编译器会报错。
- 模板函数:模板函数的实例化可以产生看似重载的效果,但实际上它们是通过模板参数来实现不同的行为。
- const修饰符:对于成员函数,是否在参数后添加
const
关键字也会影响重载解析,因为const
成员函数和非const
成员函数被视为不同的函数。 - 函数签名:函数签名包括函数名和参数列表,但不包括函数返回类型。
2.重载解析
在C++中,当对一个函数进行调用时,编译器需要确定应该执行哪个函数版本,这一过程称为重载解析(Overload Resolution)。
名称查找(Name Lookup)–重载解析第一步
限定查找( qualified lookup )与非限定查找( unqualified lookup )
限定查找是指在查找名称时,明确指定了名称所在的命名空间或类作用域。这种查找方式通常使用作用域解析运算符(
::
)来实现。限定查找可以避免名称冲突,并确保引用的是特定命名空间或类中的实体。namespace MyNamespace {int myVar = 10; }int main() {int myVar = 20;// 使用限定查找访问MyNamespace中的myVarint value = MyNamespace::myVar;return 0; }
非限定查找是指在查找名称时,不指定名称所在的命名空间或类作用域。这种查找方式在没有明确指定作用域的情况下进行,编译器会根据当前的作用域和
using
声明来确定标识符的实体。namespace MyNamespace {int myVar = 10; }int main() {int myVar = 20;// 使用非限定查找访问myVarint value = myVar; // 这里的myVar指的是main函数中的局部变量,而不是MyNamespace中的myVarreturn 0; }
在这个例子中,由于没有使用作用域解析运算符,编译器默认查找的是当前作用域中的
myVar
。非限定查找会进行域的逐级查找–名称隐藏( hiding )
- 当前作用域:查找首先在当前作用域(例如,一个函数或一个类的成员函数内部)开始。如果在当前作用域中找到了标识符,查找结束。
- 局部作用域:如果当前作用域中没有找到标识符,查找会扩展到包含当前作用域的局部作用域。
- 命名空间:如果查找还没有结束,编译器会检查当前作用域所在的命名空间。如果在命名空间中找到了标识符,查找结束。
- 全局作用域:如果以上步骤都没有找到标识符,查找会扩展到全局作用域。
名称隐藏是指在更内层的作用域中定义的标识符会隐藏同名的外层作用域中的标识符。这意味着,如果内层作用域和外层作用域中存在同名的标识符,内层作用域中的标识符会优先被访问。
int globalVar = 10; // 全局变量void function() {int globalVar = 20; // 局部变量,隐藏了全局变量// 在这个作用域内,globalVar 指的是局部变量 20 }int main() {int globalVar = 30; // main 函数的局部变量,隐藏了全局变量function(); // 在 function 内部,globalVar 是 20// 在 main 函数内部,globalVar 是 30return 0; }
globalVar
在不同的局部作用域中被定义,每个局部定义都隐藏了外层作用域中的同名标识符。查找通常只会在已声明的名称集合中进行
实参依赖查找( Argument Dependent Lookup: ADL )
当编译器在解析函数调用时,如果函数名没有被限定(即没有使用作用域解析运算符
::
),编译器会执行以下查找步骤:
非限定查找:首先在当前作用域(包括局部作用域、命名空间、类作用域等)中查找函数名。
实参依赖查找:如果非限定查找没有找到匹配的函数,编译器会根据函数参数的类型,在参数所属的命名空间和类作用域中进行查找。
应用场景:重载运算符:当定义了类的重载运算符(如
operator+
)时,即使没有使用类的作用域,编译器也能通过ADL找到正确的运算符函数。class MyClass { public:MyClass operator+(const MyClass& other) const {// 实现两个MyClass对象的加法} };void someFunction(const MyClass& obj) {MyClass result = obj + MyClass(); // ADL 允许编译器找到 MyClass::operator+() }
重载解析:在名称查找的基础上进一步选择合适的调用函数
函数重载解析的一般步骤与规则:
过滤不能被调用的版本 (non-viable candidates)
在重载解析的开始阶段,编译器会排除那些明显不能匹配调用的函数版本。这些版本可能由于以下原因被认为是不可行的:
参数个数不对
调用时提供的参数数量与任何重载版本的参数数量都不匹配
无法将实参转换为形参
提供的实参类型无法通过任何方式转换为对应的形参类型
实参不满足形参的限制条件
例如,形参有特定的类型限定(如
const
限定),实参不满足这些限定。在剩余版本中查找与调用表达式最匹配的版本,匹配级别越低越好
一旦过滤掉不可行的候选版本,编译器会尝试在剩余的候选版本中找到与调用表达式最匹配的函数版本。匹配的级别分为几个等级,从最好到最差:
级别1
完美匹配(Perfect Match)或平凡转换(Trivial Conversion,例如添加或移除
const
或volatile
限定,或者数组或函数指针到它们的指针类型)。级别2
提升(Promotion,如从
int
到long
)或提升加平凡转换。级别3
标准转换(Standard Conversion,如从派生类指针到基类指针,或者从
float
到double
)或标准转换加平凡转换。级别4
自定义转换(Conversion Function,如使用构造函数或转换操作符)或自定义转换加平凡转换或自定义转换加标准转换。
级别5
形参为省略号(Ellipsis,即变参函数,如
int foo(...)
)。匹配级别规则:
当函数包含多个形参时,编译器将选择所有形参的匹配级别都优于或等于其他函数的函数版本。如果所有候选函数的匹配级别都相同,那么:
- 如果存在完美匹配,则优先选择完美匹配的版本
- 如果没有完美匹配,但有多个平凡匹配,选择第一个声明的版本
- 如果没有完美匹配或平凡匹配,编译器将尝试找到匹配级别最低的版本
编译器会根据以下步骤和规则来选择正确的函数版本:
-
函数匹配过程
- 候选函数集合:编译器首先生成一个候选函数集合,包含所有名称匹配的函数。
- 参数匹配:编译器尝试将传递给函数的实参与候选函数的形参进行匹配。
- 标准转换:编译器允许一些标准的类型转换,如整数到浮点数的转换、数组到指针的转换、类对象到其基类指针的转换等。
- 构造函数调用:如果实参不直接匹配任何形参,编译器会考虑使用构造函数创建临时对象,以匹配相应的形参。
- 模板匹配:如果候选函数中包含模板函数,编译器会尝试实例化模板以找到匹配的函数。
- 引用绑定:对于通过引用传递的参数,编译器会尝试绑定到实参的引用。
- const和volatile修饰符:最佳匹配的函数应该符合
const
和volatile
的约束。
-
函数选择过程
- 最佳匹配:编译器尝试找到与实参最匹配的函数,即需要最少的用户定义转换的函数。
- 引用相加性:如果一个函数的参数可以通过添加或移除const或volatile来匹配,而另一个函数不需要这样的转换,那么后者会被优先选择。
- 转换成本:如果存在多个函数都可以匹配,编译器会根据转换的成本来选择最佳匹配,成本较低的转换会被优先选择。
- 函数的const正确性:如果函数的参数是const或volatile修饰的,调用时传递的对象也应该是const或volatile的,或者通过const_cast进行显式转换。
- SFINAE(Substitution Failure Is Not An Error):在模板函数的情况下,如果实例化失败,编译器会忽略该模板函数。
-
编译器警告与错误
- 歧义调用:如果编译器无法确定唯一的最佳匹配,它会产生歧义调用错误。
- 未使用的返回值:如果函数返回一个非void类型,并且调用它的返回值被忽略,编译器会发出警告,除非函数被标记为
[[nodiscard]]
。 - 函数签名不匹配:如果实参与所有候选函数的形参都不匹配,编译器会报错。
示例:
#include <iostream>
#include <string>void print(int a) {std::cout << "int: " << a << std::endl;
}void print(double a) {std::cout << "double: " << a << std::endl;
}void print(const std::string& a) {std::cout << "string: " << a << std::endl;
}int main() {print(10); // 匹配第一个版本print(3.14); // 匹配第二个版本print("hello"); // 匹配第三个版本,使用string类的构造函数进行匹配return 0;
}
四、函数相关的其他内容
1.递归函数
递归函数:在函数体中调用其自身的函数。基本思想是它允许通过将问题分解为更小的子问题来解决复杂问题。在C++中,递归函数的使用非常普遍,尤其是在处理如树结构遍历、排序算法、图搜索等场景时。
递归函数的基本构成:
一个递归函数通常包含两个主要部分:
- 基本情况(Base Case):这是递归终止的条件,防止无限递归。在每个递归调用的最底层,函数将达到一个不再进行递归调用的状态。
- 递归情况(Recursive Case):这是函数调用自身的情况,它逐渐将问题分解成更小的问题,直到达到基本情况。
使用递归函数的步骤:
- 定义基本情况:确定函数何时不再递归调用自身,而是返回一个直接的答案。
- 确保递归有进展:确保每次递归调用都向基本情况靠近一步,以避免无限递归。
- 考虑性能:递归可能会带来额外的内存开销(因为每次递归调用都需要存储在调用栈上),并且有时可以通过迭代方法更高效地实现相同的结果。
示例:计算阶乘
#include <iostream>// 递归函数计算阶乘
unsigned long factorial(unsigned int n) {// 基本情况:如果n为0或1,阶乘为1if (n == 0 || n == 1) {return 1;}// 递归情况:n! = n * (n-1)!else {return n * factorial(n - 1);}
}int main() {unsigned int number = 5;std::cout << "Factorial of " << number << " is " << factorial(number) << std::endl;return 0;
}
递归函数的注意事项:
- 避免重复计算:在某些情况下,递归可能导致重复计算,例如在没有记忆化的情况下多次计算相同的子问题。这可以通过记忆化技术(也称为缓存或动态规划)来解决。
- 栈溢出风险:如果递归太深,可能会耗尽程序的调用栈,导致栈溢出错误。
- 效率问题:递归可能比迭代解决方案更慢,因为它涉及更多的函数调用和返回操作。
2.内联函数
在C++中,内联函数(inline function)是一种特殊的函数,它可以在编译时被插入到每个调用该函数的地方,而不是通过常规的函数调用机制执行。使用内联函数的目的是为了减少函数调用的开销,尤其是在函数体较小且调用频繁的情况下。
内联函数的定义:
内联函数通常使用inline
关键字定义。当你在一个类定义中或者在函数声明的同时提供函数体时,可以使用inline
关键字。
类定义中:
class MyClass {
public:inline void myFunction() {// 函数体}
};
在函数声明的同时提供定义:
inline int add(int a, int b) {return a + b;
}
内联函数的工作原理:
当编译器处理内联函数时,它会尝试将函数的代码直接插入到每个调用点,从而避免了生成函数调用的机器代码。这可以减少函数调用的开销,包括参数传递、栈帧的创建和销毁等。
内联函数的使用场景:
内联函数最适合于小型、频繁调用的函数
- 访问器和修改器函数:这些函数通常只包含一行或几行代码,用于获取或设置对象的成员变量。
- 小型工具函数:一些小型的工具函数,如简单的数学运算或类型转换函数,可能从内联中受益。
内联函数的注意事项:
- 编译器优化:
inline
关键字只是一个请求,编译器可以选择忽略它。编译器会根据自己的优化策略和函数的复杂性来决定是否将函数内联(展开)。 - 多文件定义:如果一个内联函数在多个编译单元(通常是多个不同的
.cpp
文件)中定义,可能会导致链接错误。为了解决这个问题,通常将内联函数的定义放在头文件中,并在头文件中使用inline
关键字,使函数从程序级别的一次定义原则变成翻译单元级别的一次定义原则。 - 过度使用:过度使用内联可能会导致代码膨胀,从而增加缓存失效的可能性,反而降低程序的性能。
3.constexpr函数(C++11起)
C++11标准引入了constexpr
函数的概念,它允许在编译时计算并确定函数的返回值。constexpr
函数通常用于定义那些不会修改程序状态且所有操作都是已知的编译时表达式的函数。一般的函数是在运行期进行求值,constexpr
函数是在编译期进行求值,因此,函数体中所有表达式不能有只能在运行期进行的语句。
constexpr函数的基本用法:
constexpr
关键字用于声明一个函数,使其可以在编译时求值。
constexpr int add(int a, int b) {return a + b;
}
add
函数被声明为constexpr
,这意味着它只能包含一个返回语句,并且所有的操作都应该是编译时可知的。
constexpr函数的限制:
- 简单函数体:
constexpr
函数只能有一个语句,通常是返回语句。 - 编译时求值:函数内的所有表达式都应该是可以在编译时求值的。
- 不修改程序状态:
constexpr
函数不能有副作用,如修改全局变量或进行I/O操作。
constexpr函数的用途:
- 定义常量表达式:
constexpr
函数常用于定义数学常量或物理常量等。 - 模板元编程:在模板编程中,
constexpr
函数可以用于在编译时计算模板参数。 - 优化性能:由于
constexpr
函数可以在编译时求值,它可以减少运行时计算,从而提高程序性能。
constexpr与字面类型:
从C++14开始,constexpr
还可以用于声明变量(编译期常量),并且如果一个变量被声明为constexpr
,那么它的类型必须是一个字面类型(literal type)。字面类型的数据成员也必须是字面型,这意味着它们的值可以在编译时确定。
constexpr int value = add(3, 4); // 在C++14及以后,value的值在编译时确定
constexpr与lambda表达式:
C++14标准允许使用constexpr
lambda表达式,这允许创建更复杂的编译时计算。
auto constexpr_constant = [](int a, int b) -> int {return a + b;
};
constexpr int result = constexpr_constant(1, 2); // 在C++14及以后,result的值在编译时确定
4.consteval 函数 (C++20 起 )
consteval
是C++20引入的一个特性,它允许声明可以在编译时执行的复杂函数。consteval
函数是constexpr
函数的一个扩展,它允许更复杂的编译时计算,包括但不限于:
- 非内联的函数体。
- 使用循环和分支语句。
- 调用其他
consteval
函数。
consteval 函数的基本用法:
consteval int expensive_computation() {// 执行一些复杂的计算int result = 0;for (int i = 0; i < 1000000; ++i) {result += i;}return result;
}constexpr int const_value = expensive_computation();
expensive_computation
函数被声明为consteval
,这意味着它可以包含循环和分支语句,并且可以在编译时执行。然后,我们可以在constexpr
上下文中使用这个函数来初始化一个常量。
consteval 函数的限制:
尽管consteval
函数比constexpr
函数更加灵活,但它们仍然有一些限制:
- 编译时求值:
consteval
函数的所有操作都必须在编译时完成,不能有任何运行时的行为。 - 不能抛出异常:
consteval
函数不能包含任何可能抛出异常的代码。 - 不能修改程序状态:
consteval
函数不能有副作用,如修改全局变量或进行I/O操作。
consteval函数的用途:
- 执行复杂的编译时计算:
consteval
函数可以执行复杂的计算,包括循环和分支,这些在constexpr
函数中是不允许的。 - 生成编译时常量:
consteval
函数可以用于生成更复杂的编译时常量,这些常量可以在程序的其他地方使用。 - 模板元编程:在模板元编程中,
consteval
函数可以用于在编译时执行更复杂的逻辑。
consteval与constexpr的区别:
虽然consteval
函数是constexpr
函数的扩展,但它们之间有一些关键的区别:
-
在C++20中,
constexpr
函数既能在编译期执行,又能在运行期执行。而consteval
函数只能在编译期执行。 -
函数体的复杂性:
constexpr
函数只能有一个语句,通常是返回语句,而consteval
函数可以包含更复杂的函数体,包括循环和分支语句。 -
调用限制:
constexpr
函数只能在constexpr
上下文中调用,而consteval
函数既可以在constexpr
上下文,也可以在非constexpr
上下文中调用。
内联函数、
consteval
与constexpr
函数都是翻译单元一次定义原则。根据不同的需求(是要展开还是要编译期运行),选择不同的关键字。
5.函数指针
在C++中,函数指针是一种指向函数的指针,它允许你将函数作为参数传递给其他函数,或者将函数赋值给指针变量。
在C++中,函数指针的应用范围比较小。因为在C++中有很多方式可以作为函数指针的代替品,这些代替品功能更加强大,使用起来更加安全。如:Lambda表达式
函数指针的声明:
声明函数指针时,你需要指定函数的返回类型、参数类型以及指针的名称。
int (*functionPtr)(int, int);
这个声明表示functionPtr
是一个指向接受两个int
参数并返回int
的函数的指针。
函数指针的初始化:
可以使用函数名来初始化函数指针。
int add(int a, int b) {return a + b;
}int main() {int (*functionPtr)(int, int) = add; // 将函数的地址赋给指针//另一种写法using FuncPtr = int (*)(int, int);FuncPtr functionPtr = add;return 0;
}
使用函数指针:
一旦函数指针被初始化,你可以像调用普通函数一样调用它所指向的函数,又或者解引用函数指针再调用
int result = functionPtr(3, 5); // 调用add函数int result = (*functionPtr)(3, 5); // 调用add函数
函数指针与重载:
当你尝试使用函数指针指向一个重载函数时,问题出现了:由于编译器需要根据参数类型来解析重载函数,所以你必须指定函数指针指向确切的函数签名。
这意味着,如果你有两个重载的函数,你不能直接声明一个指向这些重载函数的通用函数指针。你必须为每个不同的函数签名声明一个不同的函数指针类型。
void print(int i) {std::cout << "Integer: " << i << std::endl;
}void print(double d) {std::cout << "Double: " << d << std::endl;
}void (*ptr1)(int) = &print; // OK: 指向print(int)的函数指针
// void (*ptr2)(double) = &print; // Error: &print是模糊的,因为print是重载的void (*ptr2)(double) = (void (*)(double))&print; // OK: 明确指出是指向print(double)的函数指针
ptr1
正确地指向了print(int)
,但是直接将&print
赋值给ptr2
会引发错误,因为编译器不知道指向哪个print
函数。通过强制类型转换(void (*)(double))
,你明确指出了函数指针应该指向的函数签名。
解决方案:函数指针到函数对象
在C++中,解决函数重载和指针问题的一个常用方法是使用函数对象或仿函数(functors)。这在标准模板库(STL)中非常常见,例如std::function
。
#include <functional>
#include <iostream>void print(int i) {std::cout << "Integer: " << i << std::endl;
}void print(double d) {std::cout << "Double: " << d << std::endl;
}int main() {std::function<void(int)> func1 = print; // 绑定到print(int)std::function<void(double)> func2 = print<double>; // 模板参数指定重载版本func1(10); // 调用print(int)func2(20.5); // 调用print(double)return 0;
}
通过使用std::function
,你可以存储指向不同重载函数的指针,并通过调用func1
和func2
来解决重载问题。
函数指针作为函数参数:
函数指针可以作为参数传递给其他函数,这在实现回调函数时非常有用:
void doWork(int (*operation)(int, int), int a, int b) {int result = operation(a, b);// 使用result做一些操作
}int main() {doWork(add, 3, 5); // 传递add函数的指针作为参数return 0;
}
将函数指针作为返回值:
#include <iostream>int inc(int x)
{return x + 1;
}int dec(int x)
{return x - 1;
}auto fun(bool input)
{if(input)return inc;elsereturn dec;
}int main()
{std::cout << (*fun(true))(100); //101
}
函数的指针和数组:
函数指针可以用于创建函数数组,这在实现多态或者分派机制时很有用:
void (*actions[])(int) = {&doWork1, &doWork2, &doWork3};int main() {actions[0](10); // 调用doWork1函数return 0;
}
函数指针和const限定符:
函数指针可以与const
限定符一起使用,以表明指针指向的函数不会修改它所操作的对象的状态:
void (*const constFunctionPtr)(int) = doWork1;
函数指针与C++标准库:
函数指针在C++标准库中也有广泛应用,例如在sort
算法中使用比较函数:
void sort(int arr[], int n,bool (*compare)(int, int)) {for (int i = 0; i < n - 1; ++i) {for (int j = 0; j < n - i - 1; ++j) {if (compare(arr[j], arr[j + 1])) {// 交换 arr[j] 和 arr[j + 1]}}}
}bool compare(int a, int b) {return a > b; // 降序排序
}int main() {int arr[] = {1, 2, 3, 4, 5};int n = sizeof(arr) / sizeof(arr[0]);sort(arr, n, compare);return 0;
}
注意:小心Most vexing parse
https://en.wikipedia.org/wiki/Most_vexing_parse