目录
1.函数重载
自然语言中,一个词可以有多重含义,人们可以通过上下文来判断该词真实的含义,即该词被重载了。
比如:以前有一个笑话,国有两个体育项目大家根本不用看,也不用担心。一个是乒乓球,一个是男足。前者是“谁也赢不了!”,后者是“谁也赢不了!
1.1 函数重载的概念
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表**(参数个数或类型或顺序)必须不同**,常用来处理实现功能类似数据类型不同的问题。
代码示例:
int Add(int left, int right)
{
return left+right;
}
double Add(double left, double right)
{
return left+right;
}
long Add(long left, long right)
{
return left+right;
}
int main()
{
Add(10, 20);
Add(10.0, 20.0);
Add(10L, 20L);
return 0;
}
思考:为什么以下代码就不属于函数重载
short Add(short left, short right)
{
return left+right;
}
int Add(short left, short right)
{
return left+right;
}
**原因:**这两个函数仅仅只有返回值不同,虽然现在编译器层面上可以实现,但在语法调用层面,无法进行区分,带有严重的歧义,函数调用时,不知道调用哪一种返回值类型的函数 。
short Add(short left, short right)
{
return left+right;
}
int Add(short left, short right)
{
return left+right;
}
int main(){
Add(1,2)//该调用哪一个,不知道
return 0;
}
注意:C语言并不支持函数重载
1.2 C不支持重载,C++支持函数重载原因
在C/C++中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编、链接。
1.预处理---->头文件展开/宏替换/条件编译/去掉注释
2.编译------->检查语法,符号汇总,生成汇编代码(出错会报错)
3.汇编------->将汇编代码,转化为二进制机器码,生成符号表,生成目标文件
4.链接------->将目标文件链接到一起,生成可执行程序(exe),合并段表,合并符号表和符号表重定义
1. 实际我们的项目通常是由多个头文件和多个源文件构成,而通过我们C语言阶段学习的编译链接,我们可以知道,【当前a.cpp中调用了b.cpp中定义的Add函数时】,编译后链接前,a.o的目标文件中没有Add的函数地址,因为Add是在b.cpp中定义的,所以Add的地址在b.o中。那么怎么办呢?
2. 所以链接阶段就是专门处理这种问题,链接器看到a.o调用Add,但是没有Add的地址,就会到b.o的符号表中找Add的地址,然后链接到一起。
3. 那么链接时,面对Add函数,链接器会使用哪个名字去找呢?这里每个编译器都有自己的函数名修饰规则。
4. 由于Windows下vs的修饰规则过于复杂,而Linux下gcc的修饰规则简单易懂,下面我们使用了gcc演示了这个修饰后的名字。
在Linux下,通过下面我们可以看出gcc(C编译器)的函数修饰后名字不变。而g++(C++编译器)的函数修饰后变成【_Z+函数长度+函数名+类型首字母】。
- 采用C语言编译器编译后结果:
**结论:在Linux下,采用gcc编译完成后,**函数名字的修饰没有发生改变
- 采用C++语言编译器编译后结果:
结论:在Linux下,采用g++编译完成后,函数名字的修饰发生改变,编译器将函数参数类型信息
添加到修改后的名字中。
总结:为什么C语言为何不支持函数重载,C++支持函数重载?
C编译器直接通过函数名进行关联,当函数名相同时,C编译器就无法区分
C++编译器不是直接使用函数名,而是通过命名修饰规则,带入参数对函数名进行修饰,函数名相同,参数不同,修饰出来的名字也不同,就可以区分,就支持函数重载
1.3 extern “C”
C++编译器能识别C++函数名修饰规则,也能识别C的修饰规则——因为C++本来就兼容C
对于C++项目,可能会用到一些C实现的库,那么Ok,可以直接使用,因为C++本身就兼容C
但对于C来说,想要使用C++编写的库,那么麻烦了,由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名,而C语言只识别函数名,C语言不能用,因此需要在前面加上extern “C”,这样会指示编译器这部分代码按C语言的进行编译,而不是C++的,这样C就可以用这些库了,比如:tcmalloc是google用C++实现的一个项目,他提供tcmallc()和tcfree两个接口来使用,但如果是C项目就没办法使用,那么他就使用extern “C”来解决。
2.引用
2.1 引用的概念
引用也是C++引入的概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
比如李逵,小名叫铁牛,江湖人称黑旋风,上应天杀星,其实就是一个人,只不过称呼不同
2.2 引用的用法
有了引用的概念,我们如何使用呢?
使用方法:类型& 引用变量名(对象名) = 引用实体
示例1:
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& b = 20;
printf("a的内存地址:%p\n",a);
printf("b的内存地址:%p\n",b);
return 0;
}
结果:
这就证明了引用变量并不开辟新空间,它和引用的变量共用一个空间
**示例2: **
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& ra = a;//ra 就是a的别名,并不会为其开辟新的空间
//一旦做了某个变量的引用,就不能做其他变量的引用(不做三姓家奴)
int b = 20;
int c = 30;
int& rb = b;
rb = c;//将C的值赋值给rb,同时也改变了b的值
cout<<a<<endl;//输出30
ra = 20;
cout<<a<<endl;//输出20
return 0;
}
注意:
- 这里"引用操作"使用了C中取地址同一个符号& ,但在这里并无关联,各有各自用处,
&
有且只有在定义的时候才用作引用,其他 的时候都是用作取地址符 - 引用类型必须和引用实体是同种类型的
2.3 引用特征
-
引用在定义时必须初始化,`int& r`这种写法是错误的
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其他实体
void TestRef()
{
int a = 10;
int b = 20;
// int& ra; // 该条语句编译时会出错,引用未初始化
int& ra = a;
int& ra = b;// 该条语句编译时会出错,ra是a的引用,一旦使用,便不能对别人进行引用
int& rra = a;//a变量,被ra和rra同时引用,相当于a有多个别名
printf("%p %p %p\n", &a, &ra, &rra);//输出同一地址
return 0;
}
引用实际上和C语言中的指针很像,实现的功能也大致相同,引用在使用的时候可以用作函数参数实现和指针传地址一样的效果,引用做参数有一下几个优点:
- 输出型参数
- 当函数参数比较大的时候,相比于传值,引用做参数可以较少拷贝
2.4 常引用
常引用是对常数的直接的引用。
如果需要对常数进行引用,这时就不能直接对其进行,而是要在类型前面加上关键字const来区分
const 类型& 引用变量 = 常数;
int main()
{
int& a = 10; //错误的,常量必须要加const
const int& b = 10;
int& c = b; //错误的,常量必须要加const
const int& d = b;//正确
int x = 20;
const int& y = x; //这里其实将权限进行缩小,原本是可以x是可以修改的,引用y可以缩小权限使其无法被修改,反过来就不行了
x = 30;
y = 40;//const限制了y的权限,所以引用y是不可以修改的
const int p = 20;
const int& ps = p; //这里因为p是const类型,他的引用就必须是const类型,p和ps都无法修改
return 0;
}
注意:这里还有一种特殊的常引用:
int main()
{
short a = 1;
int& b = a;//编译错误
const int& ra = a;//编译通过
return 0;
}
这里涉及到整型提升的过程,在short 向 int 类型转换时会生成一个临时变量,而临时变量也会被当成常量来对待,所以这里必须要加上**const。
**
常引用应用:
-
我们在函数传参的时候如果不想参数在函数中被无意修改,可以使用常引用来避免这种情况例如:要求计算一个数的平方。
#include<iostream>
using namespace std;
int square1(int x)
{
x *= x;
return x;
}
int square2(int& x)
{
x *= x;
return x;
}
int main()
{
int x1 = 3;
int x2 = 3;
cout << square1(x1) << endl;
cout << "x1=" << x1 << endl;
cout << square2(x2) << endl;
cout << "x2=" << x2 << endl;
return 0;
}
传引用造成了x2的值也被修改这是我们不想看到的,所以这里最好使用const int& x
来避免这种无意中产生的错误。
- 传入的参数类型正确,但是不是左值
#include<iostream>
using namespace std;
int square1(int& x)
{
return x * x;
}
int square2(const int& x)
{
return x * x;
}
int main()
{
int x1 = 3;
int x2 = 3;
cout << square1(x1+1) << endl;//编译错误
cout << square2(x2+1) << endl;//编译正确
return 0;
}
解释一下左值 和 右值:
左值:是可以被引用的对象,例如:变量、数组元素、结构成员
右值:字面常量、表达式
上述两种情况必须使用常引用来解决。
- 传入的参数类型不正确,但是能发生转换
int add(const int& x)
{
return x * x;
}
int main()
{
double x = 3.0;
cout << add(x) << endl;
}
这里发生了一个隐式类型转换从double -> int,而转换的实质是生成一个临时变量,临时变量具有常数性,有这个临时变量在进行传参,对临时变量的引用必须使用常引用。所以修改引用并不会影响本身值的改变,就相当于C语言的传值传参传过去的是本身的一个拷贝,但是有规定:常引用是const 类型无法修改,所以就不会出现这种问题了
引用做参数要尽可能使用const的原因
- 使用const可以避免无意中修改引用实参
- 使用const之后,就可以接收const 和 非const 值
- 使用const可以使函数生成正确的临时变量
2.5 引用的使用场景
- 引用做参数
#include<iostream>
using namespace std;
int Add(int& x, int& y)
{
return x+y;
}
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
int main(){
int a = 10;
int b = 20;
Swap(a,b);
cout<<a<<" "<<b<<endl;// 20 10
cout<<Add(1,2)<<endl;//3
return 0;
}
这里和C语言中传地址实现的时同一个道理
-
做返回值
int& Count()
{
static int n = 0;
n++;
// ...
return n;
}
做返回值时可以进行进一步探究
int add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int ret = add(1, 2);
cout << ret << endl;
return 0;
}
这个程序很简单也很容易理解,对其进行改造一下,用引用接收返回值
int add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = add(1, 2);//编译错误
const int& ret = add(1, 2);//正确写法
cout << ret << endl;
return 0;
}
因为我们知道在调用函数的时候会创建函数栈帧,函数调用完的时候函数栈帧就会被销毁,返回值实际上是编译器生成的一个临时变量返回给ret的,由上面的知识可知临时变量具有常性,要用常引用接收。
如果我们把返回值的类型改成引用类型:
int& add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = add(1, 2);
add(3, 4);
cout <<"Add(1,2) is "<< ret << endl;
return 0;
}
这里 函数的返回值是 一个引用,所以这里返回的是c的一个别名,所以这里ret实际上也就是c的一个别名,共用一个地址空间。但是这里实际上是一个非法访问问题,ret是c的一个别名,但是函数结束后,函数空间就会被销毁,
但是为什么c的值还被保留下来了?
这是vs编译器的特殊原因,vs编译器对待函数栈帧销毁并不会对其内存清空,所以c的值虽然还存在内存中,但是函数运行结束按理说就无法访问那片内存了,但是ret是c的别名对齐非法访问。
为什么最后ret的值为7?
了解了上面的原理之后,其实就很好理解了。add(3, 4); 在开辟函数栈帧的时候和add(1, 2); 开辟的函数栈帧是完全一样的,只是传入的值不一样,所以两个函数栈帧储存c的内存位置都是相同的,顾会修改c的值,c的别名ret也会跟着修改。
int& add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = add(1, 2);
printf("hello world\n");
cout << ret << endl;//会输出任意值,原因printf()栈帧占用了c值空间
return 0;
}
如果在输出ret前随便加一个和add不同的函数,就会破坏c内存空间储存的值,其栈帧占用了c值的空间。
函数引用返回总结:
- 出了函数的作用域,变量会被销毁的(临时变量),就不能用引用返回
- 出了函数的作用域,变量不会被销毁(静态变量),可以使用引用返回
2.6 引用和指针区别
指针 | 引用 |
可以不初始化 | 必须初始化 |
指针存在空指针 | 引用不存在空引用 |
用sizeof计算时,地址空间始终是地址空间所占的字节数 | sizeof引用的时候计算的是引用类型的大小 |
有多级指针 | 没有多级引用 |
指针自加即指针向后偏移一个类型的大小 | 引用自加即引用实体增加1 |
访问实体,指针进行解引用 | 编译器自己处理 |
由此可以看出引用比指针更加安全,既避免了空指针的问题,还简化了多级指针解引用的问题。在C++中引用是对指针不错的一个替代。
3.内联函数
3.1 概念
用inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。
在这里先了解函数调用的底层原理:
计算机在运行程序时,操作系统将这些指令载入内存中,因此每条指令都在内存中有特定的内存地址。
在执行到函数调用指令的时候,程序将在函数调用后立即储存该指令的地址,并在堆栈上开辟新的空间,执行函数内部的代码指令
执行完毕后,跳回到地址被保存的指令处(有个形象的比喻是:类似于看书看到一个注释,然后跳转到去看注释,看完之后跳转后继续阅读正文)
所以函数调用,记录位置会有一定程度上的开销,所以C++提出了内联函数。内联函数直接在调用的地方展开,就省去了储存函数调用指令
3.2 引用特性
- inline是一种以空间换时间的做法,省去调用函数额外开销。所以代码很长或者有循环/递归的函数不适宜作为内联函数
- inline对于编译器只是一个建议,至于编译器会不会对内联函数进行展开,编译器会自己做出判断。
- inline函数 声明 和 定义 放在一起,在定义和声明的函数名前面都加上关键字inline。不然程序会出现链接失败。
// F.h
#include <iostream>
using namespace std;
inline void f(int i);
// F.cpp
#include "F.h"
void f(int i)//错误
inline void f(int i)//正确
{
cout << i << endl;
}
// main.cpp
#include "F.h"
int main()
{
f(10);
return 0;
}
// 链接错误:main.obj : error LNK2019: 无法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z), 该符号在函数 _main 中被引用
** 注意:内联函数使用过程和宏差不多**
思考:宏的优缺点?
优点:
- 增强代码的复用性。
- 提高性能。
缺点:
- 不方便调试宏。(因为预编译阶段进行了替换)
- 导致代码可读性差,可维护性差,容易误用。
- 没有类型安全的检查 。
使用内敛函数(inline)可完美解决以上问题
3.3 内联函数和宏的区别
与内联函相比 宏的缺点很明显:
宏没有类型检查,但是内联函数有类型检查,同时还可以发生类型转换,例如函数形参的类型是int,而传进来的参数是double,此时就会发生类型转换使double转换成int类型。宏 不是基于按值传递,本质上还是一种替换的思想 例如:
#define fun(x) x*x*x
int main()
{
int a = 1;
cout << fun(++a) << endl;
return 0;
}
本来想传入的使++a的值也就是2,本意想输出 8,但实际上 表达式为:++a * ++a * ++a
结果是:64
所以内联函数在 宏的基础上改进了不少
C++有哪些技术替代宏?
- 常量定义 换用const
- 函数定义 换用内联函数
拓展:
C++用inline内联函数代替宏
C++编译器在调用内联函数时,会在调用处展开,不会有调用时建立栈帧时的开销
inline 在debug下默认是不会展开的,支持调试
release或者debug设置以下优化等级才会展开
4.auto关键字
4.1 auto简介
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它,因为在早期的C/C++中,auto被用来修饰局部变量,表明该变量是一个自动变量,这就意味着在函数结束之后,函数的栈帧就被收回了,创建的空间也被释放了,局部变量也就被销毁了,那既然变量都已经被销毁了,对于函数体中的局部变量来说,在定义时是否用auto修饰,也就没有意义了,从这个层面来讲,一直没有人使用它,也就解释的通了。
因此在auto这个关键字存在的意义就不大了,于是在C++11中,对其进行了新的定位
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符
auto是一种新的类型,用来让编译器推导变量的类型。推导的过程在编译阶段,使用auto必须初始化,,在编译时期编译器会根据变量的类型推导出变脸的类型并将其替换。auto类似于一个待定符号,编译会自动将其替换成正确的类型
4.2 auto关键字使用规则
- 用auto声明指针类型时,用
auto p
或auto *p
没有任何区别,但用auto声明引用类型时则必须加&
int main()
{
int x = 10;
auto x2 = 20;
auto a = &x;
auto* b = &x;
auto& d = x;
return 0;
}
- 在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
注意:
-
auto不能作为函数参数,因为编译器无法对形参进行类型推导
-
auto不能直接用来声明数组。
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
void TestAuto()
{
int a[] = {1,2,3};
auto b[] = {4,5,6};//错误
}
auto使用代码示例:
int main()
{
int a = 10;
char c = 'A';
//通过右边赋值对象,自动推导变量类型
auto b = a;
auto ch = c;
auto pa1 = &a; //pa1类型int*
auto* pa2 = &a; //pa2类型int*
auto& ra = a; //ra类型int
//可以查看变量实际类型
cout << typeid(a).name() << endl;
cout << typeid(b).name()<< endl;
cout << typeid(c).name() << endl;
cout << typeid(ch).name() << endl;
cout << typeid(pa1).name() << endl;
cout << typeid(pa2).name() << endl;
cout << typeid(ra).name() << endl;
map<string, string> dict;
//map<string, string>::iterator it = dict.begin();
//类型太复杂太长,auto自动推导简化代码
//缺点:牺牲了代码可读性
auto it = dict.begin();
cout << typeid(it).name() << endl;
return 0;
}
注意:
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型
5. 基于范围的for循环
C++中提出了一种新的基于for 的循环
在C++98中如果要遍历一个数组,可以按照以下方式进行:
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
array[i] *= 2;
for (int* p = array; p < array + sizeof(array)/ sizeof(array[0]); ++p)
cout << *p << endl;
}
C++11提供了一种新的访问数组的形式 ,范围for自动依次取数组中的值赋给e,自动判断结束
使用方法:for(auto e : arr)
int main()
{
int arr[] = { 1, 2, 3, 4, 5 }; //C++提供了一种新的访问数组的形式 范围for //自动依次取数组中的值赋给e,自动判断结束
for (auto& e : arr)
{
//这里只将数组中值依次赋值给e,
//e 改变并不影响数组的值,
//要想改变,传引用才行
e *= 2;
}
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
return 0;
}
** 注意:与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环**
范围for的使用条件:
- for循环迭代的范围必须是确定的 对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围
以下代码就有问题,因为for的范围不确定
void TestFor(int array[])
{
for(auto& e : array)
cout<< e <<endl;
}
6.指针空值(nullptr)
C++中对于NULL的定义其实是一个宏,而宏的值恰好为零。空指针实际上是内存按字节为单位空间的编号,空指针并不是不存在的指针而是内存第一个字节的编号。
NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
由于NULL是一个宏,代表0,会带来一系列问题,C++11定义了nullptr
//指针空值nullptr(C++11)
void f(int)
{
cout << "f(int)" << endl;
}
void f(int*)
{
cout << "f(int*)" << endl;
}
int main()
{
//C++98 //实际上空指针并不是不存在,而是内存第一个地址,通常不用此地址存储有效数据
int* p1 = NULL;//NULL其实就是宏 代表0
int* p2 = 0;
f(0); //f(int)
f(NULL); //f(int)
//C++11以后推荐使用nullprt初始化空指针变量
int* p3 = nullptr;
f(0); //f(int)
f(nullptr); //f(int*)
return 0;
}
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是**编译器默认情况下,将其看成是一个整形常量 ,**如果非要让0当作指针使用必须写成(void *)0
。所以C++为了避免这种情况引入了nullptr。
注意:
- 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
- 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
- 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。