【C++修炼之路】C++入门(中)—— 函数重载和引用

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

如果无聊的话就来逛逛 我的博客栈 吧! 🌹

一、前言

小伙伴们好我是 a n d u i n anduin anduin . 今天我们继续讲解 C++ 入门的知识内容主要为两大块函数重载和引用 这两块在 C++ 中可谓是重量级选手特别是引用学完使用会很舒适。虽然引用的点很多但是没关系 a n d u i n anduin anduin 对它全方面进行了讲解。

话不多说我们开始学习吧

二、函数重载

函数重载是函数的一种特殊情况C++允许在同一作用域中声明几个功能类似的同名函数这些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同常用来处理实现功能类似数据类型不同的问题。

对于c语言是不允许重名函数的存在的当函数名字相同时就会报错。但是对于 c++ 可以。

1、重载规则

当函数重载条件满足如下三条时则可以构成函数重载

  1. 参数类型不同
  2. 参数个数不同
  3. 参数类型顺序不同

对于 c++ 同名函数是允许存在的

// 参数类型不同
int add(int left, int right)
{
	return left + right;
}

int add(double left, double right)
{
	return left + right;
}

// 参数个数不同
int add(double left, double right, int mid)
{
	return left + right;
}

// 参数类型顺序不同
int add(int left, char right)
{
	return left + right;
}

int add(char right, int left)
{
	return left + right;
}

函数重载需要在同一个命名空间。

对于相同类型的数据顺序不同不构成函数重载因为编译器无法识别

int add(int right, int left)
{
	return left + right;
}

int add(int left, int right)
{
    return left + right;
}

因为函数重载是根据类型识别的。

2、函数名修饰规则

对于函数重载后的函数执行会不会变慢不会因为不是在运行时匹配而是在编译时。

编译时如何进行识别

对于编译而言调用函数处会变成 call + add(地址) 的形式然后通过汇编指令完成调用。

那么调用函数时如何找到这个函数

对于C语言来说就是依靠函数名去找函数的如果函数名相同则会冲突因为不知道找哪个.

证明修饰规则

image-20230128181759027

对于C++来说不同平台就有不同的修饰规则对于 vs 上比较复杂暂且不谈这里我们讲 linux 上的

int add(int left, int right)
{
	return left + right;
}

对于这个函数就会被修饰为 _Z3addii

  • _Z 是前缀
  • 3 是函数名长度
  • ii 代表参数类型的首字母

当编译时就拿修饰以后的函数的名字去找找到了就可以调用了。所以只要参数类型个数顺序不同均可以满足因为此刻修饰后的函数名是可以被区分的。

从这里也可以看出为什么参数类型相同但是顺序不同不可以构成重载因为识别不了。

Linux 下修饰规则重要格式 _ Z + 函数名称长度 + 函数名 + 类型首字母 \_Z+函数名称长度+函数名+类型首字母 _Z+函数名称长度+函数名+类型首字母

证明

code

#include <iostream>

using namespace std;

int add(int left, int right) {}

int main()
{
	return 0;
}

编译自定义名称为 mytest

image-20230128182829828

使用 objdump -S exeName 查看修饰规则exeName 为可执行程序名称

image-20230128183043943

发现名字是符合修饰规则的。

修改代码再次验证

#include <iostream>

using namespace std;

int add(int left, int right) {}

int adddd(int left, double right) {}

char subb(double* left, int right) {} // 验证指针

int main()
{
	return 0;
}

对于指针参数则会在参数类型首字母前加上大写P修饰P代表point表示它是个指针

image-20230128184359089

对于相同名字的函数函数重载就根据参数的类型顺序个数以这些为基准来区别不同的函数。

而根据上面的验证我们也知道为什么 返回值不同参数类型相同但顺序不同 为什么不能构成函数重载的原因

因为对于 参数类型相同但顺序不同形成的后缀还是一样的 并不能区分该调用哪个函数而对于返回值不同的其他都相同的函数来说则是因为分不清调用哪个函数不仅仅是因为函数返回值不在修饰规则内。

比如 int add() 和 double add() 在函数调用时我该调用哪个编译器在这时候就错乱了根本上是语法层面的问题。

Windows 下修饰规则简单了解

image-20230128182025880

去除函数定义主函数调用后报错

image-20230128181924616

三、引用

引用不是新定义一个变量而是给已存在变量取了一个别名编译器不会为引用变量开辟内存空间它和它引用的变量共用同一块内存空间。

别名又可以说是外号代称比如水浒传里几乎是别名最多的地方。李逵在家称为"铁牛"江湖上人称"黑旋风"。铁牛和黑旋风就是李逵的别名。

1、区分

& 就是引用但是& 这个操作符和取地址 & 操作符是重叠的。

所以它们需要不同的场景规范

&b 单独存在时这时就代表取地址为取出变量的地址。

但是如果这样

int main()
{
	int a = 10;
	int& b = a; // 引用

	int* p = &b; // 取地址

	return 0;
}

& 位于类型和变量名之间时为引用。

2、本质

调试查看一下 a 和 b 的关系

image-20230128200705864

我们发现a和b不仅值相等连地址也是相同的。而这就说明b 就是 a 在语法层面上这里 b 并不是开辟的新空间而是对原来的 a 取了一个新名称叫做 b

就好比李逵被叫做黑旋风一样李逵还是李逵黑旋风也是它而 a 就是 a 但是 b 也是 a

image-20230128201339079

而如果这时候对 a 或 b 任意一个修改那么 a 和 b 都会发生修改。

image-20230128201712339

3、特性

引用有三个注意点。

1) 引用必须在定义时初始化

image-20230128203648434

引用是取别名所以在定义的时候必须明确是谁的别名。

2一个变量可以有多个引用

就和李逵一样他可以叫黑旋风也可以叫铁牛这都是它。

所以一个变量也可以有多个别名。

image-20230128203843816

而对于一个起过别名的变量对它的别名取别名也是可以的。

就好比说有人可能知道李逵也叫铁牛并不知道他真实姓名但是他觉得李逵很黑于是叫他黑旋风这也没问题因为这里描述的都是一个人同理描述的也是同一个变量。

image-20230128204130467

而从根本上看就可以这么理解

image-20230128204221383

本质上还是一个变量。

但是别名不能和正式名字冲突就比如取过别名就不能定义和别名重命的变量即使它们的类型不同。

image-20230128205100610

但是这里的报错信息并不准确实际上为命名冲突。

3引用一旦引用一个实体就不能引用其他实体

int main()
{
	int a = 10;
	int& b = a;

	int c = 20;
	b = c;

	return 0;
}

对于下一组代码有什么含义

  1. 让 b 变成 c 的别名
  2. 还是把 c 赋值给 b

这里的代码意思是第二个含义就是赋值我们调试看看

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ewhy1pGV-1675308403233)(https://anduin.oss-cn-nanjing.aliyuncs.com/%E5%BC%95%E7%94%A81.gif)]

调试我们也可以看到我们只是把 c 的值赋值给了 b b 的地址还是没变的 并且 a 的值也改变了。

这就说明引用一旦引用一个实体就不能引用其他实体引用是不会发生改变的

因为它们是完全独立的两个变量仅有的关联也只是值相等改变 b 并不能影响 c 但是此时 b 是 a 的别名所以改变 b 会影响 a 。

image-20230128211658314

但是对于指针则是截然不同的

int main()
{
	int a = 10;
	int c = 20;

	int* p = &a;
	p = &c;

	return 0;
}

对于指针来说指针可以时刻修改

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9KSddSMf-1675308403233)(https://anduin.oss-cn-nanjing.aliyuncs.com/%E5%BC%95%E7%94%A82.gif)]
p原本指向 a 现在指向 c .

但是引用也有局限性因为引用之后的变量是不可修改引用的比如链表节点是要不断更替迭代的所以还需要指针配合C++才可以写出一个链表

4、应用

a、做参数

我们知道实参的改变不影响实参所以这种写法并不能改变值因为此刻是 传值调用

image-20230128212733541

按照之前 c 的写法我们使用 传址调用 用指针修改

image-20230128212838245

但是学习引用之后完全可以用引用修改

image-20230128213028237

x 和 y 分别是 a 和 b 的引用对 x 和 y 进行修改就是对 a 和 b 进行修改所以值也被修改成功了。

调试看一下

image-20230128213641052

它们的地址是完全相同的。而这里这里既不是传值调用也不是传址调用而是 传引用调用

思考上面三个函数是否构成函数重载 构成但无法调用

根据函数名修饰规则传值和传引用的是不一样的比如会加上 R 做区分

但是不能同时调用传值和传引用因为有歧义因为 调用不明确 编译器并不知道调用哪个:

image-20230128214924534

引用解决二级指针生涩难懂的问题

讲单链表时我们写的由于是没有头结点的链表所以修改时需要二级指针对于指针概念不清晰的小伙伴们可能比较难理解。

但是学了引用就可以解决这个问题

结构定义

typedef struct SListNode
{
	int data;
	struct SListNode* next;
}SLTNode;

原代码

void SListPushFront(SLTNode** pphead, SLTDateType x)
{
	SLTNode* newnode = BuyListNode(x);
	newnode->next = *pphead; 
	*pphead = newnode;
}

// 调用
SLTNode* pilst = NULL;
SListPushFront(&plist);

修改后:

void SListPushFront(SLTNode*& pphead, SLTDateType x) // 改
{
	SLTNode* newnode = BuyListNode(x);
	newnode->next = *pphead; 
	*pphead = newnode;
}

// 调用
SLTNode* pilst = NULL;
SListPushFront(plist); // 改

修改之后的代码里的二级指针被替换成了引用。

而这里的意思就是给一级指针取了一个别名传过来的是plist而plist 是一个一级指针所以会出现 * 而这里就相当于 pphead 是 plist 的别名。而这里修改 pphead 也就可以对 plist 完成修改。

但是有时候也会这么写

结构改造

typedef struct SListNode
{
	int data;
	struct SListNode* next;
}SLTNode, *PSLTNode;

这里的意思就是将 struct SListNode* 类型重命名为 PSLTNode

代码

void SListPushFront(PSLTNode& pphead, SLTDateType x) // 改
{
	PSLTNode newnode = BuyListNode(x);
	newnode->next = pphead; 
	pphead = newnode;
}

// 调用 
PSLTNode plist = NULL;
SListPushFront(plist);

在 typedef 之后PSLTNode 就是结构体指针所以传参过去只需要在形参那边用引用接收随后进行操作就可以达成目的。

而形参的改变影响实参的参数叫做输出型参数对于输出型参数使用引用十分舒适

如果了解引用那么这一部分是相当好理解的一些数据结构教科书上也是这么写的但是如果不懂引用甚至会觉得比二级指针还难以理解。

在我们学习了引用之后之后也可以这么写代码更加方便。

b、做返回值

要搞清楚这一块我们先进行一些铺垫。

int add(int a, int b)
{
	int c = a + b;
	return c;
}

int main()
{
	int ret = add(1, 2);
	cout << ret << endl;

	return 0;
}

这里看似很简单就是把add函数计算结束的结果返回但是这里包含了 传值返回

若从栈帧角度看会先创建 main 函数的栈帧里面就会有 call 指令开始调用 add 函数。而 add 函数也会形成栈帧而栈帧中也有两块小空间用来接受参数分别为 a 和 b而里面的 c 则用来计算结果并返回。

image-20230128224420670

而对于传值返回返回的并不是 c 而是返回的是 c 的拷贝。而这其中会有一个临时变量返回的是临时变量(见函数栈帧)

如果返回的是 c 的话由于 add 的函数栈帧已经销毁了就会产生很多奇怪的问题。c 能不能取到都是未知而这时都是非法访问因为空间已经被归还给系统了所以必定是c拷贝后的数据被返回。

但是临时变量在哪

  • 如果 c 比较小(4/8 byte)一般是寄存器充当临时变量例如eax
  • 如果 c 比较大临时变量放在调用 add 函数的栈帧中

最后将临时变量中的值赋值给ret

image-20230128225254172

所有的传值返回都会生成一个拷贝

便于理解看一下汇编

image-20230128225920666

看第四句话这里是说把 eax 中的值拷贝到 ret 中。

而再函数调用返回时

image-20230128230035833

这里是将 c 的值放到 eax 中的。

这也就印证了返回时是以临时拷贝形式返回的由于返回值是 int 所以是直接用的 eax 寄存器。

而不论这个函数结束后返回的那个值会不会被销毁都会创建临时变量返回例如这段代码

int c()
{
	static int n = 0;
    n++;
    return n;
}

int main()
{
	int ret = c();
	cout << ret << endl;

	return 0;
}

对于该函数编译器仍然是创建临时变量返回因为编译器不会对其进行特殊处理。

看一下汇编

image-20230202090124151

仍然是放到 eax 寄存器中返回的。

埋个伏笔:你觉不觉的这个临时变量创建的很冤枉明明这块空间一直存在我却依然创建临时变量返回了能不能帮它洗刷冤屈。


如果我改成引用返回会发生什么情况吗

int& add(int a, int b)
{
	int c = a + b;
	return c;
}

int main()
{
	int ret = add(1, 2);
	cout << ret << endl;

	return 0;
}

引用返回就是不生成临时变量直接返回 c 的引用。而这里产生的问题就是 非法访问

造成的问题

  1. 存在非法访问因为 add 的返回值是 c 的引用所以 add 栈帧销毁后会访问 c 位置空间而这是读操作不一定检查出来但是本质是错的。
  2. 如果 add 函数栈帧销毁空间被清理那么取 c 值时取到的就是随机值取决于编译器的决策。

ps虽然vs销毁栈帧没有清理空间数据但是会二次覆盖

来看个有意思的

image-20230128231841269

例如这里当调用 add 函数之后返回 c 的引用接收返回值是用的ret相当于是 c 的引用这时由于没有清理栈帧数据所以打印3

但是第二次调用重新建立栈帧由于栈帧大小相同第二次建立栈帧可能还是在原位置之前空间的数据被覆盖继续运算但是此时ret 那块空间的值就被修改了而这时没有接收返回值但是原先的那块 c 的值被修改所以打印出来 ret 是 30 。

所以使用引用返回时一旦返回后返回值的空间被修改那么都可能会造成错误使用要小心

引用返回有一个原则如果函数返回时出了函数作用域如果返回对象还在(还没还给系统)则可以使用引用返回如果已经还给系统了则必须使用传值返回。

它俩的区别就是一个生成拷贝一个不生成拷贝。

而这时 static 修饰的静态变量不委屈了

int& c()
{
	static int n = 0;
    n++;
    return n;
}

因为 static 修饰的变量在静态区出了作用域也存在这时就可以引用返回。

我们可以理解引用返回也有一个返回值但是这个返回值的类型是 int& 中间并不产生拷贝因为返回的是别名。这就相当于返回的就是它本身。

有时引用返回可以发挥出意想不到的结果

#include <cassert>
#define N 10

typedef struct Array
{
	int a[N];
	int size;
}AY;

int& PostAt(AY& ay, int i)
{
	assert(i < N);

	return ay.a[i];
}

int main()
{
	AY ay;
	PostAt(ay, 1);	
    // 修改返回值
	for (int i = 0; i < N; i++)
	{
		PostAt(ay, i) = i * 3;
	}
	
	for (int i = 0; i < N; i++)
	{
		cout << PostAt(ay, i) << ' ';
	}

	return 0;
}

由于PostAt 的形参 ay 为 main 中 局部变量 ay的别名所以 ay 一直存在这时可以使用引用返回。

引用返回 减少了值拷贝 不比将其拷贝到临时变量中返回并且由于是引用返回我们也可以 修改返回对象

image-20230202094500414

总结提炼如果出了作用域返回变量静态全局上一层栈帧malloc等仍然存在则可以使用引用返回

5、效率比较

值和引用的作为返回值类型的性能比较

#include <time.h>
struct A { int a[10000]; };
A a;
// 值返回
A TestFunc1() { return a; } // 拷贝
// 引用返回
A& TestFunc2() { return a; } // 不拷贝
void TestReturnByRefOrValue()
{
	// 以值作为函数的返回值类型
	size_t begin1 = clock();
	for (size_t i = 0; i < 100000; ++i)
		TestFunc1();
	size_t end1 = clock();
	// 以引用作为函数的返回值类型
	size_t begin2 = clock();
	for (size_t i = 0; i < 100000; ++i)
		TestFunc2();
	size_t end2 = clock();
	// 计算两个函数运算完成之后的时间
	cout << "TestFunc1 time:" << end1 - begin1 << endl;
	cout << "TestFunc2 time:" << end2 - begin2 << endl;
}

int main()
{
	TestReturnByRefOrValue();

	return 0;
}

由于传值返回要拷贝所以当拷贝量大次数多时比较耗费时间而传引用返回就不会因为返回的就是别名。

image-20230129092445760

对于返回函数作用域还在的情况引用返回优先。

引用传参和传值传参效率比较

#include <time.h>
struct A { int a[10000]; };
void TestFunc1(A a) {}
void TestFunc2(A& a) {}
void TestRefAndValue()
{
	A a;
	// 以值作为函数参数
	size_t begin1 = clock();
	for (size_t i = 0; i < 10000; ++i)
		TestFunc1(a);
	size_t end1 = clock();
	// 以引用作为函数参数
	size_t begin2 = clock();
	for (size_t i = 0; i < 10000; ++i)
		TestFunc2(a);
	size_t end2 = clock();
	// 分别计算两个函数运行结束后的时间
	cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
	cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}

int main()
{
	TestRefAndValue();
}

还是引用快因为引用减少拷贝次数

image-20230129100552326

总结引用的作用主要体现在传参和传返回值

  1. 引用传参和传返回值在有些场景下可以提高性能大对象 and 深拷贝对象 – 之后会讲。
  2. 引用传参和传返回值在对于输出型参数和输出型返回值很舒服。说人话就是形参改变实参也改变 or 返回对象返回值改变。

6、常引用

const 修饰的是常变量不可修改。

image-20230129101402496

a 本身都不能修改b 为 a 的引用那么 b 也不可以修改这样就没意义了。a 是只读但是引用 b 具有 可读可写 的权利该情况为 权限放大 所以错误了。

这时只要加 const 修饰 b 让 b 的权限也只有只读使得 权限不变 就没问题了

image-20230129101640052

而如果原先变量可读可写但是别名用 const 修饰也是可以的这种情况为 权限缩小

image-20230129101753498

对于函数的返回值来说也不能权限放大例如

int c()
{
	static int n = 0;
    n++;
    return n;
}

int main()
{
    int& ret = c(); // error
    
    return 0;
}

这样也是不行的因为返回方式为 传值返回 返回的是临时变量具有 常性 是不可改的而引用放大了权限所以是错误的这时加 const 修饰就没问题const int& ret = c(1, 2)

那么这种情况为什么不可以

image-20230202110312938

而这样就可以了

image-20230202110348444

因为类型转换会产生临时变量

对于类型转换来说在转换的过程中会产生一个个临时变量例如 double d = i把i转换后的值放到临时变量中把临时变量给接收的值d

而临时变量具有常性不可修改引用就加了写权限就错了因为 权限被放大了

提炼对于引用引用后的变量所具权限可以缩小或不变但是不能放大指针也适用这个说法

作用

在一些场景下假设 x 是一个大对象或者是深拷贝对象那一般都会用引用传参减少拷贝如果函数中不改变 x 尽量用 const 引用传参。

image-20230129102412361

这样可以防止 x 被修改 而对于 const int& x 也可以接受权限对等或缩小的对象甚至为常量

image-20230129102604491

结论

  • const type& 可以接收各种类型的对象变量、常量、隐式转换。对于输出型参数用引用否则用 const type&更加安全。

7、指针和引用区别

从语法概念上来说引用是没有开辟空间的而指针是开辟了空间的但是从底层实现上来说则又不一样

int main()
{
	int a = 10;
	
	int& ra = a;
	ra = 20;

	int* pa = &a;
	*pa = 20;
	return 0;
}

汇编

image-20230202111422435

lea 是取地址我们发现无论引用和指针都会取地址且这些过程和指针一样。

其实从汇编上引用其实是开空间的并且实现方式和指针一样引用其实也是用指针实现的

区别汇总

  1. 引用概念上定义一个变量的 别名 指针存储一个变量 地址
  2. 引用 在定义时 必须初始化 指针最好初始化 但是不初始化也不会报错
  3. 引用在初始化时引用一个实体后 就不能再引用其他实体 而指针可以在任何时候指向任何一个同类型
  4. 没有NULL引用但有NULL指针
  5. 在sizeof中含义不同引用结果为 引用类型的大小但指针始终是 地址空间所占字节个数 (32位平台下占4个字节)
  6. 引用自加即引用的实体增加1指针自加即指针向后偏移一个类型的大小
  7. 有多级指针但是没有多级引用
  8. 访问实体方式不同指针需要显式解引用引用编译器自己处理
  9. 引用比指针使用起来相对更安全

7, 8, 9点说明指针使用起来更复杂一些更容易出错而引用则相对简单。

四、结语

到这里本篇文章就到此结束了。

今天的内容就两块其实讲讲还是很多的特别是引用要点很多但是用起来那是相当的舒服。所以小伙伴们赶快用起来吧

如果觉得 a n d u i n anduin anduin 写的不错的话可以点赞 + 收藏 + 评论支持一下哦

那么我们下期见

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++