函数重载
什么是函数重载
c++允许在同一作用域中定义多个 名字相同 的函数.
比如定义多个Swap函数用于交换整数、浮点数或字符.
编译器可以根据传参的不同来调用对应的函数.
#include <iostream>
using namespace std;
void Swap(int* a, int* b)
{
cout << "交换整数" << endl;
}
void Swap(double* a, double* b)
{
cout << "交换浮点数" << endl;
}
void Swap(char* a, char* b)
{
cout << "交换字符" << endl;
}
int main()
{
int a1 = 0;
int a2 = 1;
double a3 = 1.5;
double a4 = 6.6;
char a5 = 'b';
char a6 = 'c';
Swap(&a1, &a2);
Swap(&a3, &a4);
Swap(&a5, &a6);
return 0;
}
三个好处:
1 可以处理实现功能类似,数据类型不同的问题.
2 不用为取名发愁.例如c语言就要用swapi(交换int型的函数),swapd(交换double型的函数);
3 使用的地方更方便,就像用同一个函数一样.
构成函数重载的条件
条件1: 在同一作用域中定义的多个同名函数.
不同作用域间的同名函数之间不构成函数重载.(参考命名空间)
条件2:同名函数的 参数类型 或 参数类型的顺序 或 参数数目 不同.
(条件2里的任意情况发生即可)
void Swap(int* a,double* b)
{}
void Swap(double* a,double* b)//参数类型不同(有一个类型不同即可)
{}
void Swap(char* a, char* b,int* c)
{}
void Swap(char* a, char* b)//参数数目不同
{}
void Swap(char* a, double* b)
{}
void Swap(double* a, char* b)//参数类型相同,参数的顺序不同
{}
//以上6个Swap函数相互之间构成函数重载
注意:只有返回值不同的同名函数不能构成函数重载,
因为调用函数时我们只会传参,编译器只能根据参数来判断是调用哪个函数.
c/c++程序编译链接的基本情况
可以先看我之前的一篇文章:
C程序的编译和链接初步讲解 - 掘金 (juejin.cn)
在C/C++中,程序要运行,要经历4个阶段(分细):
预处理/预编译、编译、汇编、链接.
以以下三个文件为例:
test.h
test1.cpp
test2.cpp
预处理/预编译主要工作:
头文件展开、宏替换、条件编译、去掉注释.
test1.cpp->test1.i; test2.cpp->test2.i
此时参加编译的就只有test1.i和test2.i,因为test.h已经在这两个文件展开了.
编译主要工作:
1 词法分析、语义分析、语义分析、符号汇总(将出现的全局变量名,函数名梳理出来).
2 将c语言代码翻译成汇编代码.
注意:文件只会汇总自己文件内部的符号.
test1.i ->test1.s;test2.i -> test2.s
test1.s 里会汇总print1 和print2.
test2.s 里会汇总print1、print2、printf.
汇编主要工作:
1 将汇编指令翻译成二进制指令,生成目标文件(Linux下.o文件,Window下.obj文件).
2 给每个文件形成一个符号表,将汇总的符号填进去,同时赋一个有效或无效的地址.
(比如这个文件中只有函数的声明,就赋无效地址;有函数定义,就直接添上函数地址)
test1.o:
| print1 | 无效地址 |
|---|---|
| print2 | 无效地址 |
test2.o:
| print1 | 有效 |
|---|---|
| print2 | 有效 |
| printf | 无效地址 |
链接主要工作:
1 合并目标文件.
2 符号表的合并和重定位.(将所有文件的符号表合并,将无效地址覆盖)
如果还存在无效地址,说明有函数或全局变量只有声明没有定义.
3 链接外部的库和C语言库,比如库里有printf的实现代码(二进制),
相当于库里才有printf的地址/具体实现.
4 生成可执行程序.
c++如何实现函数重载的(重点)
关键在于编译时汇总的符号.
C语言为什么不支持函数重载?
因为C语言放在符号表里的函数名就单单只是函数的名字!
如果定义了两个同名的函数(C语言只有全局域或局部域),
那符号表中就会出现相同符号、(多个有效地址)地址不同的情况,这显然自相矛盾.
C++如何支持函数重载?
C++为了支持函数重载,引入了函数名修饰规则,即把参数类型作为后缀来修饰函数名.
这样符号表里的符号实际上是函数名+参数类型后缀.
C++程序即使函数名相同,但参数不同,也能在符号表中区分不同的函数,找到函数的正确地址.
_Z5printic:_Z是前缀,5表示真正函数名print的长度,
i即第一个参数是int类型,c即第二个参数是char类型,ic是后缀.
//test.h
#include <stdio.h>
void print(int a1,char a2);
void print(char a1,int a2);
//test1.cpp
#include "test.h"
int main()
{
print(1,'a');
print('b',2);
return 0;
}
//test2.cpp
#include "test.h"
void print(int a1, char a2)
{
printf("%d %c\n",a1,a2);
}
void print(char a1, int a2)
{
printf("%c %d\n",a1,a2);
}
引用
引用的概念
给已有的变量取别名/外号.
编译器不会为引用变量开辟内存空间.
它和它引用的变量共用一块空间.
类型& 引用变量名 = 引用实体
#include <iostream>
using namespace std;
int main()
{
int a = 0;
int& b = a;//此时b是a的别名
cout << "a的地址是:" << &a << endl;
cout << "b的地址是:" << &b << endl;
return 0;
}
注意:&单独跟变量时才是取地址,而上面是引用.
此时修改a也就是修改b,因为a和b共用一块内存空间.
引用的使用细节
1 引用在定义时必须初始化,指定要引用的变量.
2 一个变量可以有多个引用
就像人可以有多个外号一样
也可以给别名取别名
#include <iostream>
using namespace std;
int main()
{
int a = 0;
int& b = a;
int& c = a;
int& d = b;//给别名取别名
cout << "a的地址是:" << &a << endl;
cout << "b的地址是:" << &b << endl;
cout << "c的地址是: " << &c << endl;
cout << "d的地址是:" << &d << endl;
return 0;
}
3 引用一旦引用一个实体,就不能再引用其它实体
引用的使用场景
函数参数
1 做输出型参数
平时的参数是输入型参数,形参是实参的临时拷贝,形参的改变不影响外面任何值.
而输出型参数,是能通过形参影响外面的值.
以交换函数为例:
A 普通的函数参数
a、b的交换不会影响实参,交换失败
void swap(int a, int b)//形参是实参的临时拷贝
{
int tmp = a;
a = b;
b = tmp;
}
int main()
{
int c = 1;
int d = 2;
swap(c, d);
cout << "c = " << c << endl;
cout << "d = " << d << endl;
return 0;
}
B 引用做函数参数
此时形参是实参的别名,形参的交换就是实参的交换.
形参的改变就是实参的改变.
void swap(int& a, int& b)
{
int tmp = a;
a = b;
b = tmp;
}
int main()
{
int c = 1;
int d = 2;
swap(c, d);
cout << "c = " << c << endl;
cout << "d = " << d << endl;
return 0;
}
2 大对象传参,可以提高效率
因为平时的传参,形参是实参的临时拷贝.
若实参的类型是很大的结构体类型,拷贝会有不少消耗.
而引用做形参,可以减少拷贝,提高效率.
做返回值
1 传值返回
int func()
{
int a = 0;
return a;
}
int main()
{
int ret = func();
cout << ret << endl;
return 0;
}
问题:func()是用a进行返回吗?
编译器不会用返回对象作为函数的返回值,
而是先生成一个拷贝的临时对象.
即拷贝a后,再拿这个拷贝做函数的返回值,再把这个值给ret.
问题:编译器为什么要这么做?
函数调用完成后是先销毁栈帧,再进行返回
在返回之前,这块空间(栈帧)已经被销毁了,a的值自然也是不确定的
因此要生成临时对象作为函数的返回值.
问题:临时对象在哪里?
若返回对象很小,临时对象一般存放在寄存器中.
否则,就会在调用方的栈帧里提前在一个位置开好,作为临时对象的位置;
即在栈帧销毁之前,把它拷贝到临时对象的空间上,临时对象再做func()的返回值.
问题:如果返回对象在函数栈帧销毁后不销毁,比如存放在静态区,仍然生成临时对象做返回值吗?
编译器只看是不是传值返回,只要是传值返回,就会生成一个拷贝的临时对象作为函数的返回值.
传值返回总结:
会生成一个返回对象的拷贝作为函数调用的返回值.
若返回对象很小,那临时变量存放在寄存器中;
否则,栈帧销毁前,会在调用方的栈帧中开好一块空间,将返回对象的值拷贝上去.
这个临时对象做函数的返回值.
2 传引用返回
int& func()
{
static int a = 0;//a保存在静态区,不在栈区
return a;
}
int main()
{
int ret = func();
cout << ret << endl;
return 0;
}
问题:什么是传引用返回?
函数调用完成后,返回返回对象的别名(也是临时对象),但不再生成拷贝.
问题:什么时候使用传引用返回?
当函数栈帧销毁后(出了函数作用域后),返回对象仍然没有销毁.
比如返回对象在静态区、堆区.
错误使用1:
int& func()
{
int a = 0;
return a;
}
int main()
{
int ret = func();
printf("%d\n", ret);
return 0;
}
函数栈帧销毁后,返回对象a也已经销毁,
但由于传引用返回,就会返回a这块空间的别名(假设返回的别名叫tmp).
要将返回值给ret,就相当于要去a这块被销毁的空间上取值,
越界访问,结果是没有保障的.
问题:为什么这里的运行结果却是正确的
我们到酒店去订房,把手机放在房间里,理论上是安全的,手机不会丢;
然而当把房间退了,手机被遗漏在房间里,过几天你偷进到这个房间去找,
你能保证你一定能找到这部手机吗?
如果能找到,说明工作人员在你退房后还没有清理这个房间,
也没有其它人占了这个房间,纯属侥幸.
如果不能找到,说明被其它人带走了………………
因此,建立函数栈帧就像申请房间一样,在里面放一个变量,
理论上不会自动被修改,但栈帧一旦被销毁:
系统如果会清理栈帧置成随机值,那么这里的ret取到的是随机值;
暂时不会清理就还是原来的值;
但越界访问是没有保障的.
错误使用2:
int& func()
{
int a = 0;
return a;
}
int main()
{
int ret = func();
printf("%d\n", ret);
printf("%d\n", ret);
return 0;
}
函数调用完成后,返回返回对象a的别名,所以会去访问一次a的空间,
把这块空间的值赋给ret,之后ret的值就是固定的.
上例的函数栈帧销毁后并没有立刻清理栈帧,所以值是正确的.
错误使用3:
int& func()
{
int a = 0;
return a;
}
int main()
{
int& ret = func();//如果ret也是引用呢?
printf("%d\n", ret);
printf("%d\n", ret);
return 0;
}
func()返回返回对象a的别名,而ret却也是引用,间接导致ret是a这块空间的别名,
而此时a这块空间已经销毁了(不属于用户申请的空间,没有使用权).
问题:为什么第一次打印是正确的,第二次打印却是随机值?
调用printf()时,要先传参,取ret的值传给printf().
所以在调用printf()时,先要去a的空间里取值,此时这个空间还没有被清理.
把参数传给printf()后,此时传的参数仍是未清理的值,接着建立printf()的栈帧
此时这块空间会覆盖掉原来的func()的栈帧,导致a的空间被置成随机值.
第二次调用printf(),继续去a的空间上取值给printf()传参,打印出来的值是随机值.
传引用返回总结
出了函数作用域或者函数栈帧销毁后:
返回对象会销毁 —— 不能使用传引用返回,一定要用传值返回.
不会销毁 —— 返回返回对象的别名,减少拷贝,提高效率.
常引用
权限问题
c++中,const修饰的变量名是直接视为常量,可读不可写.
int a = 0;//可读可修改
const int b = 0;//可读不可修改
b = 2;//会报错
权限的平移
b是a的引用:
a的权限是可读可修改的,
b的权限也是可读可修改的.
d是c的引用:
c的权限是只读的,
d的权限也是只读的.
int a = 0;
int& b = a;
const int c = 1;
const int& d = c;
权限的放大
编译会报错.
b是a的引用:
a的权限是只读的,
b的权限却是可读可写的.
const int a = 0;
int& b = a;
权限的缩小
权限较小的b引用了权限较大的a,编译成功.
int a = 0;
const int& b = a;
因为b是a的别名,所以它们共用一块内存空间.
但使用b无法对这块空间进行修改,而用a可以修改.
int main()
{
int a = 0;
const int& b = a;
cout << b<<endl;
a = 10;
cout << b;
return 0;
}
总结:
权限不能放大,但可以缩小.
类型转换生成的临时变量
隐式类型转换和强制类型转换中间会产生临时变量
为什么中间会生成临时变量?
char类型赋值给int类型会发生隐式类型转化,但为了不改变ch的类型,
需要中间产生临时变量,将ch先赋给临时变量,按提升的规则去补位,
提升完的值再赋给i;
那不同类型进行引用会发生什么?
这里不同类型进行引用失败了.
可是引用变量的类型加const修饰,编译成功了?
隐式类型转换中间会生成临时变量,不同类型之间进行引用也会生成临时变量,
但该临时变量具有常性,b引用的实际上是该临时变量,
但由于临时变量是只读的,所以导致权限的放大.
强制类型转换也会生成临时变量,引用变量也要加const才能成功引用:
总结
一开始不能引用的本质原因是:引用的是临时变量,临时变量具有常性,导致权限的放大.
const引用的使用
const引用还可以直接引用一个常量.
const double& b = 1.1;
const引用还可以接受要进行隐式类型转换的参数,直接接受常量,
可见const引用有很强的接收度,作为函数参数时能接收各种传过来的实参
void func(int& a){}
int main()
{
int a = 0;
double b = 1.2;
const int c = 2;
func(a);
//错误,这下面传参导致权限的放大,导致编译错误
func(b);
func(c);
return 0;
}
如果把形参改为const int& a,这些参数全都能传递过去.
总结:
1 引用做参数时,若参数不会在函数体内被改变,建议加上const,否则有些参数可能传不进去.
2 传值传参不涉及权限放大缩小问题,因为仅仅是拷贝(实参拷贝给形参).
指针和引用的区别
使用场景
指针和引用的使用场景是很相似的,
做函数参数:都可以做输出型参数;大对象传参,提高效率.
做函数返回值:减少拷贝,提高效率.
但指针有一个引用无法替代的场景:链式结构.
struct ListNode
{
struct ListNode* next;
int val;
};
1 指针有NULL,对于尾结点的next可以置空,而引用必须在定义的时候初始化;
2 引用一旦引用了一个实体,就不能引用其它实体,当往链表插入一个新结点,
如果是指针next可以直接改指向,而引用不能;
语法细节
从使用场景里已经有两个不同点.
3 指针有一级指针、二级指针…………而引用只是给变量取别名.
4 sizeof(指针)永远是4/8byte,sizeof(引用)永远是被引用类型的大小.
5 指针+1是向后偏移指向的类型大小,引用变量+1是引用实体大小+1.
6 访问实体方式不同,指针需要解引用,由于引用变量就是别名所以不需要解引用*.
底层原理
7 语法角度而言:引用没有开空间,指针开了4byte/8byte;
底层实现的角度:引用其实是用指针实现的.
使用引用的汇编代码:
使用指针的汇编代码:
其它
内联函数
什么是内联函数?
以inline修饰的函数叫内联函数.
编译时c++编译器会在调用内联函数的地方展开内联函数体的代码,
没有建立栈帧的开销.
inline void swap(int& a, int& b)
{}
内联函数的意义
短小的函数(只有1~5行左右),频繁调用,多次建立栈帧,效率不高.
场景:在堆排序中,要排10W个数据,里面的Swap()要调用10W次.
此时把Swap()设置为内联能有效提高效率,避免多次建立栈帧的消耗.
内联函数的特性
1 编译时,编译器会在调用内联函数的地方展开函数体里的代码,进行编译.
代码的指令长度会增大,inline是一种以空间换时间的做法.
因此当代码很长或有递归时不适合作为内联函数.
2 inline对编译器仅仅只是建议,它会自动优化,
如果函数内有递归或函数内部实现代码的指令长度很长时,会忽略掉内联.
即加了inline不一定会去展开.
3 inline不能声明和定义分离,分离会导致链接错误(inline放声明里)
在test.cpp文件中只有声明,在文件里调用该函数.
由于只有声明,没办法展开,只能去call函数地址,
但实现为内联的函数名不会被放到符号表中,就会导致链接错误.
建议:内联函数的实现直接放到头文件中.
auto关键字与for循环的新用法(c++11)
auto关键字(c++11)
可用于自动推导变量
struct ListNode
{
struct ListNode* next;
int val;
};
int main()
{
struct ListNode a;
a.next = NULL;
a.val = 0;
auto x = a;//自动推导x的类型为struct ListNode
cout << x.next << " " << x.val << endl;
cout << "a的地址是:" << &a << endl;
cout << "x的地址是:" << &x << endl;
return 0;
}
auto应用场景
1 如果类型写起来比较长,可以考虑用auto自动推导.
如上述x的类型为struct ListNode,用auto自动推导会更方便.
2 与for循环的新用法结合
遍历数组
int arr[] = { 1, 2, 3, 4, 5, 6 };
for (auto x : arr)
{
cout << x;
}
auto —— 自动推导类型.
auto x : arr —— 自动依次取arr里面的每个数据的值,依次赋值给x.
自动迭代,自动++,自动判断结束.
不管数组里面的值是什么类型,auto x都能自动推导.
但是x只是数组里面每个数据的拷贝,x的改变不会影响数组里的值.
3 如果想用上述遍历方式修改数组里的值,使用auto& x:arr.
int arr[] = { 1, 2, 3, 4, 5, 6 };
for (auto& x : arr)
{
x++;
cout << x;
}
auto& x : arr —— 遍历数组arr时,x依次是数组中每个数据的别名,修改x也能修改数组里的值.
auto注意事项
1 在同一行声明多个变量时,必须是相同类型,否则编译器报错.
auto a = 3, b = 4;
auto c = 3.3, d = 4.4;
//auto e = 7, f = 1.2;这里会报错
2 auto不能作为函数形式参数的类型.
3 auto不能用来声明数组.
4 auto* 、auto、auto&区别
int a = 1;
auto b = a;//int类型
auto c = &a;//int*类型
auto* d = &a;//int*类型
auto& e = a;//int类型
cout << typeid(b).name() << endl;//用来打印变量的类型
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
cout << typeid(e).name() << endl;
auto* d:显式写一个*,表示d一定是一个指针类型,强调一定要用指针初始化d,否则报错.
auto& e:显示写一个&,表示e是一个引用.
5 auto 变量必须在定义时初始化,否则不知道这个变量是什么类型.
指针空值nullptr(c++11)
在c++中,NULL是宏,可能被定义为0,或者被定义为(void*)0.
但编译器默认情况下会将它看成是一个整型常量0.
void func(int* a)
{
cout << "int*" << endl;
}
void func(int a)
{
cout << "int" << endl;
}
int main()
{
int a = 0;
int* p = NULL;
func(NULL);//本应该调用func(int*a)
func(p);
func(&a);
return 0;
}
于是c++11引入了关键字nullptr,用于替代NULL.
c++11建议空指针统一用nullptr.