C++ Learning 函数重载•引用
函数重载
函数重载是 C++ 中的一种特性,它允许在同一个作用域中定义多个同名的函数,只要它们的参数列表(参数的数量或类型)不同。函数重载提供了根据不同的输入调用同一个函数名称的能力,从而使代码更加简洁和可读。
函数重载的基本概念
函数重载允许在同一个作用域中定义多个函数,这些函数具有相同的名称,但它们的参数列表不同。编译器会根据函数调用时传递的参数来决定调用哪个函数。
#include <iostream>
void print(int i) {
std::cout << "Integer: " << i << std::endl;
}
void print(double d) {
std::cout << "Double: " << d << std::endl;
}
void print(const std::string& str) {
std::cout << "String: " << str << std::endl;
}
int main() {
print(10); // 调用 print(int)
print(3.14); // 调用 print(double)
print("Hello"); // 调用 print(string)
return 0;
}
在这个例子中,print
函数被重载了三次:一个接受整数、一个接受浮点数、一个接受字符串。根据传递的参数类型,编译器会选择适当的重载版本。
函数重载的规则
C++ 中函数重载的规则:
- 参数数量不同:如果函数参数的数量不同,编译器可以根据参数的个数来决定使用哪个重载。
- 参数类型不同:如果函数参数的类型不同,编译器可以根据参数类型来选择适当的重载。
- 参数顺序不同:如果函数参数的类型相同,但顺序不同,也可以进行重载。
- 返回类型不能作为重载的区分依据:C++ 不允许仅通过返回类型不同来重载函数,函数的返回类型不能作为重载的依据
如何实现函数重载
函数重载是由 C++ 编译器在编译时通过名称修饰(Name Mangling)来实现的。编译器会在内部对函数的名称进行修改,以便能够区分不同的重载版本。
为了了解编译器是如何处理这些重载函数的,我们反编译下上面函数重载示例的代码,看下汇编代码。
由于Windows下vs的修饰规则过于复杂,而Linux下g++的修饰规则简单易懂,下面使用了g++演示这个修饰后的名字。
名称修饰是 C++ 编译器对函数名称进行修改的过程,它会将函数的名称与其参数的类型信息结合起来,生成一个唯一的标识符。这样即使多个函数有相同的名称,编译器仍然能够区分它们。
- 编译 C++ 程序
先将 C++ 源代码编译成目标文件:
g++ -c overload.cpp
这将生成 overload.o
- 查看名称修饰
使用 nm
命令查看目标文件中的符号表,显示修饰后的函数名称:
nm overload.o
会看到以下内容
可以发现编译之后,重载函数的名字变了不再都是print!这样不存在命名冲突的问题了。
但又有新的问题了——变名机制是怎样的,即如何将一个重载函数的签名映射到一个新的标识?
第一反应是:函数名+参数列表,因为函数重载取决于参数的类型、个数,而跟返回类型无关。
具体解释如下:
_Z
是 GCC 中的前缀,表示这是一个 C++ 名称修饰符。5
是函数名print
的长度。print
是函数的名称。d
表示参数的类型(d
代表double
)
总结::在linux下,采用g++编译完成后,函数名字的修饰发生改变,编译器将函数参数类型信息添加到修改后的名字中。
详细解释见C++的函数重载
C语言为什么不支持函数重载
在 C 语言中,函数的调用是通过其符号名称来确定的。链接器通过函数的名称查找符号表,并将正确的函数与调用代码链接起来。
如果按照gcc编译上述文件会得到
结论:在linux下,采用gcc编译完成后,函数名字的修饰没有发生改变。故无法支持函数重载、
extern "C"
关键字
在 C++ 中,可以使用 extern "C"
关键字将函数按照 C 语言的方式来编译,这样 C++ 编译器就会使用 C 的名称修饰规则(也叫名称链接规范)。这可以确保 C++ 编译器不会对函数名称进行 C++ 的名称修饰,而是按照 C 语言的标准方式处理函数。
主要用于:
- 与 C 代码互操作:例如,当 C++ 代码需要调用 C 库或者将 C++ 函数暴露给 C 代码时。
- 避免名称修饰:C++ 会对函数名进行名称修饰(mangling),使用
extern "C"
可以让函数按照 C 的方式进行链接,保持函数名不变
使用 extern "C"
来声明和定义函数:
extern "C" {
void foo(int a);
}
总结:C++ 可以通过 extern "C"
关键字将函数按照 C 的方式来编译和链接。extern "C"
会禁用 C++ 的名称修饰,并允许 C++代码与 C 代码进行兼容和互操作。
引用
引用是一个别名,它是某个变量的直接引用。在声明时,引用必须与一个已有的变量绑定,并且一旦绑定后就不能再改变指向其他对象。
语法:
type& reference_name = existing_variable;
type
:引用所绑定的变量类型。&
:表示引用的符号。reference_name
:引用变量的名字。existing_variable
:要引用的现有变量。
引用特性
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体后,不能再引用其他实体
使用场景
传递引用作为参数
通过引用传递参数,函数可以直接操作原始数据,而不是它的副本,这样不仅提高了效率,还可以使函数修改调用者的变量。
void increment(int& num) {
num += 1; // 直接修改调用者的变量
}
int main() {
int x = 5;
increment(x); // x 被修改为 6
std::cout << x; // 输出 6
}
传递常量引用
常量引用(const
引用)允许通过引用传递参数,但保证不会修改传入的对象。这对传递大的对象(如大数组或大类对象)很有用,因为常量引用避免了不必要的复制,同时保持对象不变。
void print(const std::string& str) {
std::cout << str << std::endl;
}
int main() {
std::string text = "Hello, World!";
print(text); // 通过引用传递,避免复制
}
引用作为函数返回值
引用可以作为函数的返回值,这样函数可以返回一个对象的别名,避免了复制操作。但是要注意返回的引用应该指向有效的对象,否则会导致未定义的行为。
int& getElement(int* arr, int index) {
return arr[index]; // 返回数组元素的引用
}
int main() {
int arr[] = {10, 20, 30};
int& ref = getElement(arr, 1);
ref = 40; // 修改 arr[1] 的值为 40
std::cout << arr[1]; // 输出 40
}
返回局部变量的引用是一个常见的错误,容易导致未定义行为。因为局部变量在函数结束时会被销毁,如果返回该变量的引用,那么返回的引用将指向一个已经被销毁的内存区域。
错误示例:
int& badFunction() {
int localVar = 10;
return localVar; // 错误,返回一个局部变量的引用
}
int main() {
int& ref = badFunction(); // 引用指向已销毁的变量
std::cout << ref; // 未定义行为,可能会出现一个随机值
}
为什么不行?
localVar
是badFunction
函数的局部变量,它的生命周期只在函数内部有效。- 当
badFunction
返回时,localVar
会被销毁,因此返回的引用指向一个已经无效的内存区域(悬空引用)。
正确做法:
如果你需要返回一个引用,确保返回的是一个静态存储区的对象(例如,全局变量、静态变量或者堆上的对象)。
总结:避免返回局部变量的引用:局部变量在函数退出时会被销毁,返回局部变量的引用是危险的。
传值、传引用效率比较
传值
传值是将函数参数的副本传递给函数。这意味着函数内部对参数的修改不会影响外部传入的值。
传值的特点:
- 内存开销:当传递的参数较大(如大数组、大对象)时,传值会创建该对象的副本,这会占用额外的内存。拷贝的过程可能会消耗较多的时间,尤其是当对象较大时。
- 开销: 传值时需要进行 拷贝构造 或 移动构造。如果拷贝构造函数比较复杂,传值就会比较慢,尤其是涉及大数据结构时。
- 修改局限:由于传值只是传递副本,函数内部对参数的修改不会影响原始数据。
传引用
传引用是将参数的引用传递给函数,函数内部操作的就是原始对象而非副本。因此,传引用的方式通常比传值更高效,尤其是在传递大型对象时。
传引用的特点:
- 内存开销:传引用时,只传递一个指向原始对象的指针(引用实际上是一个隐式的指针)。这样不需要额外的内存分配,避免了拷贝的开销。
- 效率高:传引用避免了拷贝构造的开销,特别适用于传递大型对象,如大型数组、容器、类等。
- 修改影响:函数内部对引用参数的修改会直接影响到外部的原始对象。
总结
- 小型数据类型:对于小型数据类型(如
int
、float
等),传值和传引用的性能差异不大。可以根据需求选择。 - 大型对象:对于大型对象,传值会涉及昂贵的拷贝构造,而传引用不会。因此,传引用通常更高效,特别是当你传递的是大型数据结构或类对象时。
引用和指针的区别
在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
在底层实现上实际是有空间的,因为引用是按照指针方式来实现的。
int main() {
int a = 10;
int& ra = a;
ra = 20;
int* pa = &a;
*pa = 30;
return 0;
}
int& ra = a;
00007FF6D7472B14 lea rax,[a]
00007FF6D7472B18 mov qword ptr [ra],rax
ra = 20;
00007FF6D7472B1C mov rax,qword ptr [ra]
00007FF6D7472B20 mov dword ptr [rax],14h
int* pa = &a;
00007FF6D7472B26 lea rax,[a]
00007FF6D7472B2A mov qword ptr [pa],rax
由此可见引用和指针的底层实现是一样的。
对比:
特性 | 引用 | 指针 |
---|---|---|
实现原理 | 引用通常通过指针实现 | 明确地使用指针来存储地址 |
内存占用 | 引用本身不占用额外的内存 | 指针本身需要占用内存来存储地址 |
是否可以为空 | 不能为空 | 可以为空(nullptr ) |
是否可以修改指向 | 不可以修改引用所指向的对象 | 可以随时修改指针指向的对象 |
语法 | 语法简洁,直接使用引用 | 需要使用解引用操作符 * 或者 & |
能否指向临时对象 | 可以,尤其是常量引用 | 不可以 |