目录
前言
函数重载
概念
重载函数的条件
C++支持重载函数的原理--名字修饰
引用
概念
特性
常引用(const引用)
使用场景
传值,传引用效率比较
引用和指针的区别
内联函数
概念
特性
auto关键字(C++11)
auto简介
auto的使用规则
指针空值nullptr(C++11)
C++98中的指针空值
结语
前言
这里是C++入门的第二课,主要还是补补C语言之前遗留下来的缺陷,在学习完本篇博客的内容之后,就可以进入激动人心的类和对象环节了。看过标题大家应该也知道要讲什么,话不多说,咱们开始今天的内容。
函数重载
自然语言中,一个词可以有多重含义,人们可以通过上下文来判断该词真实的含义,即该词被重
载了。比如:以前有一个笑话,国有两个体育项目大家根本不用看,也不用担心。一个是乒乓球,一个是男足。前者是“谁也赢不了!”,后者是“谁也赢不了!”
函数重载,简单来说就是允许定义同名函数,可以通过所传参数的数量和类型来判断运行哪一个函数,接下来咱们看具体定义。
概念
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这
些同名函数的形参列表(参数个数或类型或类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。
注:形参列表(参数 1.类型 2.个数或或 3.类型顺序)不同,都可以是重载的条件。
重载函数的条件
1.重载参数类型不同
#include<iostream>
using namespace std;
int Add(int left, int right)
{
cout << "int Add(int left, int right)" << endl;
return left + right;
}
double Add(double left, double right)
{
cout << "double Add(double left, double right)" << endl;
return left + right;
}
int main()
{
cout << Add(1, 2) << endl;
cout << Add(1.1, 1.2) << endl;
return 0;
}
代码的形参一份为整型,另一份为浮点型。当调用函数传入的是整型数据时,调用第一份声明形参为整型的代码;如果调用函数传入的是浮点型数据,则调用声明形参为浮点型的代码。
2.参数个数不同
#include<iostream>
using namespace std;
void f()
{
cout << "f()" << endl;
}
void f(int a)
{
cout << "f(int a)" << endl;
}
int main()
{
f();
f(3);
return 0;
}
当调用函数不传入参数时,调用无参版的函数;当调用函数传入参数时,调用含参的重载函数。
3.参数类型顺序不同
#include<iostream>
using namespace std;
void f(int a, char b)
{
cout << "f(int a,char b)" << endl;
}
void f(char b, int a)
{
cout << "f(char b, int a)" << endl;
}
int main()
{
f(1, 'm');
f('m', 1);
return 0;
}
运行时会自动匹配最合适的重载函数。
满足上述三点条件中的任意一点且重载函数都定义在同一作用域内,就可以构成重载。
注:返回值不同不能构成重载,只能通过上述讲的参数不同进行重载。
如果返回值可以重载,会造成巨大的问题,程序无法从调用处区分最终函数运行结束后会产生什么类型的返回值,因此用返回值区分重载本身就是不可行不可靠的。
调用函数查找匹配的时候,编译器自动会匹配最符合的重载函数。
C++支持重载函数的原理--名字修饰
为什么C++支持函数重载,而C语言不支持函数重载呢?
在C/C++中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编、链接。
C++支持函数重载的原理,很大程度上依赖于“名字修饰”(name Mangling)的过程。这种机制使得编译器能够区分同名但参数列表不同的函数。
名字修饰是编译器自动进行的一种处理过程,它将C++源码中的函数名和变量名转换成包含更多信息的唯一标识符。这些信息通常包括函数的参数类型,参数数量等,甚至可能包括所属的类名(对于类成员函数),通过这种方式,每个重载的函数都会被赋予一个独一无二的名字,确保在最后链接的时候能够区分出它们。
C++中允许函数重载,也就是允许一个作用域中存在多个同名函数,只要他们的参数列表不同,在编译成目标代码之后,所有的函数名都能通过修饰出的不同的名字而区分开来,确保了每个函数的调用都能显示的映射到正确的函数体上。名字修饰通过在函数名中编码函数类型等信息,实现了这一点。
名字修饰实例
下面有一组重载函数:
void Fun(int a, int b);
void Fun(double a, double b);
经过编译器的处理修饰,这些函数最后可能被处理为(不同编译器修饰规则不同,具体修饰结果取决于编译器)
- Fun(int, int) 可能被修饰为 _23Funii_
- Fun(double, double) 可能被修饰为 _23Fundd_
通过这种修饰方式,尽管这两个函数的名字相同,但在编译器的处理后它们获得了不同的名字,使得编译后的代码能够轻松识别出不同的重载函数。
名字修饰使得C++能够有效的支持重载和模板等功能,虽然这种机制对程序员来说是透明的,但理解其背后的原理对于深入掌握C++很有帮助。同时,这也是C++与C语言的一个重要区别:C语言不支持重载函数,大部分原因在于它没有采用类似的名字修饰机制。
我们来看看在Linux环境下用不同编译器编译相同代码对函数名的处理结果:
采用C语言编译器编译后的结果:
在Linux下,采用gcc编译完成后,函数名字的修饰没有发生改变。
采用C++编译器编译的结果:
在linux下,采用g++编译完成后,函数名字的修饰发生改变,编译器将函数参数类型信息添加到修改后的名字中。
通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修
饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载。
到这里应该可以感受到,在C++中,编写代码方便的原因是大部分工作C++的编译器都帮你做掉了。这也使得匹配类型变慢,编译速度降低。但代码运行的是指令,这种匹配只会影响一部分编译速度,而不用担心会拖累代码运行的速度。
引用
概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空
间,它和它引用的变量共用同一块内存空间。
比如:一个男孩,在家中被父母叫“儿子”,在学校里被叫“同学”。虽然称呼不同,但是最终的对象还是同一个人。引用就是一种给变量取别名的语法。
类型& 引用变量名(对象名) = 引用实体;
#include<iostream>
using namespace std;
int main()
{
int a = 8;
int& b = a;//定义引用类型
cout << a << " " << b << endl;
++a;
cout << a << " " << b << endl;
++b;
cout << a << " " << b << endl;
cout << &a << " " << &b << endl;
return 0;
}
要注意的是,引用的类型必须和引用实体是同种类型的。
特性
引用有几点特性:
- 引用在定义时必须要初始化
- 一个变量可以有多个引用
- 一旦引用一个实体,不能再引用其他实体(就跟变量不能重定义一样)
void TestRef()
{
int a = 10;
// int& ra; 这句语句在引用时未初始化,报错
int& b = a;
int& c = a;
int& d = b;
// int& d = a; 报错,重定义
}
常引用(const引用)
关于常引用,就是用const修饰的引用,只需要记住,在使用引用的时候,权限可以不变和缩小,但一定不能放大。这里的规则和指针极其相似。
void TestConstRef()
{
const int a = 10;
//int& ra = a; 该语句编译时会出错,a为常量
const int& ra = a;
// int& b = 10; 该语句编译时会出错,b为常量
const int& b = 10;
double d = 12.34;
//int& rd = d; 该语句编译时会出错,类型不同
const int& rd = d;//const引用支持不同类型
}
注:创建引用类型前加上const,可以在不同类型之间取别名,第三个案例中的rd里面的值为12。
使用场景
1.做参数
在用C语言中编写函数交换两个变量之间的值时,需要传指针才能实现,这时因为C语言函数传参是传值传递,传过去的只是原数据的拷贝,只有通过传址直接找到存储数据空间的地址,才能从函数内部影响到外部的数据。C++的引用成功的解决了此类问题,也就是常说的传引用。
#include<iostream>
using namespace std;
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
int main()
{
int a = 5;
int b = 8;
cout << a << " " << b << endl;
Swap(a, b);
cout << a << " " << b << endl;
return 0;
}
引用也是极大的减少了代码中指针的使用比例,也是补了C语言指针难用复杂的一个大坑。
2.做返回值
#include<iostream>
using namespace std;
int& Count()
{
static int digit = 0;
digit++;
return digit;
}
int main()
{
int& d = Count();
cout << d << endl;
Count();
cout << d << endl;
d++;
cout << d << endl;
return 0;
}
main函数中的变量d和函数内部的digit使用的空间是同一块,当里面digit++的时候外面d的值也会++,得益于Count函数的传引用返回和static静态变量的创建(静态变量的生命周期存在于整个程序运行当中),这里传引用返回是可行的。
再来看这样一个案例,下面的代码中想想会输出什么结果?
#include<iostream>
using namespace std;
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作为函数中的一个临时变量,在出了Add函数后会自动销毁,保存c数据的那部分空间此时就不存在了,这时候使用传引用返回,接收时就是一块未定义的空间,其性质就跟野指针一样。
如果想更深入的理解此部分内容,可以看看我曾写过的一篇文章,里面细致讲解了函数栈帧的创建和销毁:
关于函数栈帧的创建和销毁-CSDN博客
注:函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用
引用返回,如果已经还给系统了,则必须使用传值返回。
传值,传引用效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直
接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效
率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
下方TestFun1是传引用,TestFun2是传值:
下方TestFun1是传引用返回,TestFun2是传值返回:
通过上述代码的比较,发现传值和指针在作为传参以及返回值类型上效率相差很大。
这种效率差距在传递次数多,拷贝量大的情况下变得尤为明显。
引用和指针的区别
在语法概念上引用就是一个别名,没有独立的空间,和其引用实体共用一块空间。
但其在底层上实际是有空间的,因为是按照指针的方式来实现的。
int main()
{
int a = 10;
int& ra = a;
ra = 20;
int* pa = &a;
*pa = 20;
return 0;
}
我们可以来看看上面这段代码的汇编:
会发现,引用和指针的汇编代码竟然一模一样!
引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求。
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。
- 没有NULL引用,但有NULL指针。
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)。
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小。
- 有多级指针,但是没有多级引用。
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
- 引用比指针使用起来相对更安全。
内联函数
概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调
用建立栈帧的开销,内联函数提升程序运行的效率。
如果在上述函数前增加inline关键字将其改成内联函数,在编译期间编译器会用函数体替换函数的
调用,其功能和C语言的宏相似。
在用inline修饰Add函数之后,代码就不会通过call调用函数,而是用函数体代码直接做添加。
特性
- inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会
用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运
行效率。 - inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建
议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不
是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。《C++prime》第五版关于inline的建议: - inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址
了,链接就会找不到。
内联是一种用空间换取时间的方式,编译出来的可执行程序可能会变大:
举个例子,Swap函数如果有10行代码:
- 如果不选择使用inline:则代码量为---> Swap + 调用(call) = 10 + 1000(call) 行指令。
- 如果选择使用inline:则代码量为---> Swap * Swap指令数 = 10 * 1000 行指令。
故内联适合小函数,如果调用函数代码量大且调用次数多还选择使用内联,会导致代码量爆炸式增长。但是这个问题其实不用我们操心,编译器会自动选择是否将函数内联,你提供的inline对编译器来说只是一个建议,不起决定性作用。
内联函数其实是补C语言中宏的一个坑。由于宏的暴力替换,没有类型安全的检查,同时导致代码可读性差,可维护性差等原因,C++的祖师爷想到了用内联函数这样的方法去解决此问题。
auto关键字(C++11)
随着程序变得越来越复杂,程序中用到的类型也越来越复杂,经常会出现,类型难以拼写或含义不明确导致出错的情况。
#include <string>
#include <map>
int main()
{
std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange",
"橙子" },{"pear","梨"} };
std::map<std::string, std::string>::iterator it = m.begin();
while (it != m.end())
{
//....
}
return 0;
}
在上面的这一段代码中,std::map<std::string, std::string>::iterator 就是一个类型,但是其类型太长,写起来非常麻烦且及其容易写错。虽然可以使用typedef取别名的方式,像这样:
typedef std::map<std::string, std::string> Map;
但是在实际中还是会遇到,typedef也解决不了的问题。
typedef char* pstring;
int main()
{
//const pstring p1; 编译失败,p1底层为char* const p1而非const char* p1
const pstring* p2; // 编译成功,底层为char * const* p2
return 0;
}
虽然第二条命令编译成功了,但p2的类型显然不是我们想要的。
在编程时,常常需要把表达式的值赋值给变量,这就要求在声明变量的时候清楚地知道表达式的
类型。然而有时候要做到这点并非那么容易,因此C++11给auto赋予了新的含义(之前版本auto有一个定义,但是基本没人用)。
auto简介
C++11中,标准委员会赋予了auto全新的含义即:作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
#include<iostream>
using namespace std;
int TestAuto()
{
return 10;
}
int main()
{
int a = 10;
auto b = a;
auto c = 'a';
auto d = TestAuto();
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
//auto e; 无法通过编译,使用auto定义变量时必须对其进行初始化
return 0;
}
typeid(变量).name()是一个返回变量类型字符串的函数,我们可以借此观察 b c d 用auto声明过后的类型。
注:使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
auto的使用规则
可以
1.auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&。
2.在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译
器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
不可以
1.auto不能作为函数参数
2.auto不能直接用来声明数组
3.为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法
指针空值nullptr(C++11)
C++98中的指针空值
在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现
不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下
方式对其进行初始化:
int* p1 = NULL;
int* p2 = 0;
NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何
种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,就比如:
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的
初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器
默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void
*)0。
所以在C++11中,就引入了nullptr指针空值,作为一个新关键字引入到C++体系之中。同时,为了提高代码的健壮性,后续使用指针空值时建议最好使用nullptr。
结语
到这里,C++的入门部分总算是结束了。本篇博客讲到了C++相比于C新增的内容:函数重载,可以编写同名函数,但需要参数类型有区分度;讲到了引用,提供一种取别名的方式减少指针的使用,以及与传值相比的效率优越性;内联函数,用空间换时间,但是否内联取决于编译器本身;auto关键字,简化我们声明函数类型的过程,减少错误发生;指针空值nullptr,替换NULL提高程序的健壮性。
感谢大家的支持,博主后续会产出更多有意思的内容!♥