首页 > 编程语言 >1.C++入门基础(上)

1.C++入门基础(上)

时间:2022-12-04 19:59:11浏览次数:59  
标签:入门 int cout 基础 C++ 引用 变量 函数

C++关键字

C++关键字全集(参考 C++ Primer ):

asm auto bad _cast bad _typeid
bool break case catch
char class const const _cast
continue default delete do
double dynamic _cast else enum
except explicit extern false
finally float for friend
goto if inline int
long mutable namespace new
operator private protected public
register reinterpret _cast return short
signed sizeof static static _cast
struct switch template this
throw true try type _info
typedef typeid typename union
unsigned using virtual void
volatile wchar_t while

命名空间

在C/C++中,变量、函数和后面要学到的类都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染,namespace关键字的出现就是针对这种问题的,std:c++标准库的命名空间。

在C语言中我们如果使用了同一个标识符定义了不同的函数或者是变量,会导致它们之间产生冲突,而C++为了解决这个问题,引入了命名空间的概念,不同命名空间的成员占有不同的内存空间,即使名称相同,但相互之间并不会受到影响。因此在C++中,库函数也是被定义在命名空间中的。

例如:C语言的头文件包含通常是 #include<xxx.h>,包含后我们便可以直接使用库函数,而在C++中我们的头文件通常是: #include<xxx>,并且无法直接使用库函数,必须要指定命名空间std才能使用。

不过C++兼容C几乎所以语法的,因此我们可以在C++中穿插C的代码,不过有一些混用是很容易出错的,要小心并且正确的使用。

命名空间定义

定义命名空间需要使用namespace关键字,后面跟上要定义的命名空间的名字,将命名空间成员定义在后面的{}内即可,类似于结构体和类的定义方式。

命名空间内可以定义变量,函数,类型,使用命名空间的类型定义出的变量不属于命名空间。

一般的命名空间定义方式

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		cout << " namespace:sx " << endl;
		int tmp = a;
		a = b;
		b = tmp;
	}
	struct Stu
	{
		char name[10];
		int age;
	};
}

命名空间的嵌套定义

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		cout << " namespace:sx " << endl;
		int tmp = a;
		a = b;
		b = tmp;
	}
	struct Stu
	{
		char name[10];
		int age;
	};
	namespace psm
	{
		int b;
		void print()
		{
			cout << "hello psm!" << endl;
		}
	}
}

同一个工程中允许存在多个相同名称的命名空间,编译器最后会合并成同一个命名空间中

namespace n1
{
	int a;
	void swap(int& a, int& b)
	{
		cout << "n1:swap" << endl;
	}
}
namespace n2
{
	int b;
}
namespace n1
{
	int c;
	void swap(int& a, int& b)
	{
		cout << "n1:swap" << endl;
	}
}

像这样子去定义编译时会报错:

函数“void n1::swap(int &,int &)”已有主体

删除其中一个即可正常编译,由此可见编译时两个名字相同的命名空间会合并,如果有重复的定义则会报错。

注意:一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中。

命名空间的使用

定义在命名空间中的变量、类型以及函数我们是无法直接使用的,由于命名空间就定义了一个新的作用域,而程序中默认是只使用两个作用域的内容的:

  1. 全局作用域
  2. 局部作用域

并且根据局部优先原则会程序会先检索当前作用域的内容,如果没有找到我们需要的,再到全局域去检索,所以默认情况下我们所定义的命名空间的作用域的内容我们是无法直接访问的。

比如:

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		cout << " namespace:sx " << endl;
		int tmp = a;
		a = b;
		b = tmp;
	}
	struct Stu
	{
		char name[10];
		int age;
	};
}
int main()
{
	cout << a << endl;//该语句编译出错,无法识别a
	return 0;
}

报错:“a”: 未声明的标识符

命名空间的使用方式有三种:

  • 加命名空间名称及作用域限定符
int main()
{
	cout << sx::a << endl;//指定使用在sx这个命名空间中的a
	return 0;
}

作用域限定符是临时的,因此每次使用时都需要加命名空间和作用域限定符(限定符限定的是成员的名称,因此限定符应紧挨着在成员名称的前面,例如:sx::Stu s

  • 使用using声明命名空间中的成员,声明我们可以不加限定符使用此成员
using sx::a;//指定地将sx中的a引入

int main()
{
	cout << a << endl;	//可以使用a
    Stu s;				//无法使用Stu类型
	return 0;
}

这样只能使用指定使用我们需要的成员,并且声明时指定的应是成员的名称(变量名吗,函数名,类型名)。

  • 使用using namespace 将命名空间中的成员引入至全局域
using namespace sx;

int main()
{
	cout << a << endl;
	Stu s;
	return 0;
}

这种方法会将命名空间的所有成员一次性引入,可以直接访问其中所有的成员,平时我们可以这样使用,但是着违背了命名空间诞生的初衷,容易产生命名冲突的问题,因此在工程中通常是使用第一种或者第二种方法。

如何证明命名空间是被引入至全局域的呢?

int a = 1;
using namespace sx;

int main()
{
	cout << a << endl;//“a”: 不明确的符号
	return 0;
}

using namespace sx;

int main()
{
	int a = 1;
	cout << a << endl;
	return 0;
}

第一个程序提示错误,而第二个程序正常运行。

引入至全局域后我们的程序即可在全局域中找到定义在命名空间sx中的变量 a ,而我们本身又在全局域中定义了一个变量 a ,那么自然如果不指定是哪个域中的也就产生了歧义,使得a变量名指代不明确。

可如果我们再次定义的a变量是局部的,即使命名空间中的a被引入至全局域,但并不会产生歧义,因为局部优先的原则,我们并不会去全局域中检索变量a,也就不存在命名冲突。

注意:即使是引入至全局域,命名空间sx中的a和本身定义在全局的a是拥有各自的内存空间的,"引入"仅仅是让其在全局域中可被检索,它仍然是属于命名空间sx的,有点像环境变量,引入就像是将某个命令所在的路径添加至环境变量,环境变量路径中的命令是可以在计算机任何路径下使用的,然而其被使用的命令可能并不在当前路径,它们之间是相互独立的。

指定使用全局域中的内容

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		int tmp = a;
		a = b;
		b = tmp;
	}
}
using namespace sx;

int main()
{
    int a = 1;
	cout << ::a << endl;//这里的a访问的是全局的
	return 0;
}

:全局变量 a 表达为 ::a,用于当有同名的局部变量时来区别两者。

命名空间是有一些比较坑的地方的,例如:

//代码1
int a = 1;
namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		int tmp = a;
		a = b;
		b = tmp;
	}
}

using namespace sx;
int main()
{
	cout << ::a << endl;
	return 0;
}

//代码2
int a = 1;
namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		int tmp = a;
		a = b;
		b = tmp;
	}
}
using sx::a;
int main()
{
	cout << ::a << endl;
	return 0;
}

代码1可以正常运行,而代码2却显示a多次定义,个人觉得还是有些奇怪的,不过项目中我们并不会将命名空间展开,更不会有这样的写法。使命名空间变量具有与全局变量相同的名称是错误的(参考微软官方文档https://docs.microsoft.com/zh-cn/cpp/cpp/namespaces-cpp?view=msvc-170)。因此不用过于纠结这里的差异。

总之,使用using将命名空间展开或者是声明成员,即代表着后续的代码可以使用此命名空间的成员。

C++输入输出

向世界打个招呼!

#include<iostream>
using std::cout;

int main()
{
	cout << "hello world!" << endl;
	return 0;
}
  1. 使用cout标准输出(控制台)和cin标准输入(键盘)时,必须包含< iostream >头文件以及std标准命名空间。

    注意:早期标准库将所有功能在全局域中实现,声明在.h后缀的头文件中,使用时只需包含对应头文件即可,后来将其实现在std命名空间下,为了和C头文件区分,也为了正确使用命名空间,规定C++头文件不带.h;旧编译器(vc 6.0)中还支持<iostream.h>格式,后续编译器已不支持,因此推荐使用<iostream> +std的方式。

  2. 使用C++输入输出更方便,它会自动识别类型(函数重载实现)而不需增加数据格式控制,比如:整形--%d,字符--%c

例如:

#include<iostream>
//using namespace std;
using std::cout;
using std::endl;
using std::cin;

int main()
{
	int i = 1;
	double d = 1.1;
	cout << "i =" << i << ",d =" << d << endl;
	return 0;
}

但是C++的这样的输入输出方式在有些场景下使用会非常麻烦,而C语言就会很方便,例如左对齐右对齐或者是保留几位小数这样的场景,推荐使用C语言的输出方式printf函数。

缺省参数

缺省参数即可有可无的参数,就像汽车备胎,带上备胎也能上路不带也不影响,除非运气实在太差。

缺省参数是声明或定义函数时为函数的参数指定一个默认值,在调用该函数时,如果没有指定实参则采用该默认值,否则使用指定的实参。

void testfunc(int t = 10)
{
	cout << t << endl;
}

int main()
{
	testfunc(100);//传入100,就使用指定的实参
	testfunc();//没有实参,就使用默认的形参10
	return 0;
}

缺省参数的分类

  • 全缺省参数

​ 即所有参数都有自己的默认值,传参时可以全部省略。

void FAll(int x = 1, int y = 2, int z = 3)
{
	cout << x << y << z << endl;
}

int main()
{
	FAll();//全缺省
	return 0;
}
  • 半缺省参数

​ 即只有部分参数都有自己的默认值,传参时一定需要传参。

void FHalf(int x, int y = 10, int z = 30)
{
	cout << x << y << z << endl;
}
int main()
{
	FHalf(5);//半缺省
	return 0;p
}

注意:

  1. 半缺省参数只能依次从右到左且连续,因为形参是从左往右依次传给实参,所以必须保证没有默认值的实参一定能有形参传值给它。
  2. 缺省参数不能在定义和声明中同时出现,以免给的默认值不同产生歧义。
void Test(int a = 10);

void Test(int a = 20)//报错
{
	cout << a << endl;
}
  1. 缺省值必须是常量或者是全局变量
  2. C语言不支持

注意:如果定义和声明分离,那么只能缺省在声明

如果缺省参数在定义中,而声明没有,那么声明的头文件展开后,由于声明和定义在不同的源文件中,它们会先分别编译,那么包含定义的那个源文件在编译时编译器认为该函数是没有缺省参数的,但是该源文件函数的调用却没有传入参数,就发生了编译错误。

函数重载

在我们的中文中常常会有一词多义的情况,但是我们可以通过上下文来帮助我们判断并确定它所表达意义而不是让我们无法识别。

讲个笑话:

我国有两个体育项目大家根本不用看,也不用担心。一个是乒乓球,一个是男足。前者是“谁也赢不了!”,后者是“谁也赢不了!

那么一个相同的函数名我们想让它不只是有一种功能或者是不止能处理一种特定情况呢,函数重载可以帮助我们解决这个问题。

函数重载的概念

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

例如:

int Add(int a, int b)
{
	cout << "int Add(int a, int b)" << endl;
	return a + b;
}
double Add(double a, double b)
{
	cout << "double Add(double a, double b)" << endl;
	return a + b;
}
float Add(float a, float b)
{
	cout << "float Add(float a, float b)" << endl;
	return a + b;
}

int main()
{
	int ret1 = Add(1, 2);
	int ret2 = Add(1.1, 2.2);
	int ret3 = Add((float)1.1, (float)2.2);
	return 0;
}

输出:

image-20220518230000150

相同的函数名传入不同类型的参数调用的函数实体不同。

注意:无法区分仅按返回类型区分的函数

例如:

short Add(short left, short right)
{
	return left + right;
}
int Add(short left, short right)
{
	return left + right;
}

因为函数调用时只能根据实参的类型去找相匹配的函数,而无法识别返回类型。

函数重载的底层实现

C语言是不支持函数重载的,但是C++却引入了这个特性,那么一定是因为底层实现有区别,于是我们从程序的编译和运行来探索一下,一个程序要运行起来,那么必须要经过预处理、编译、汇编、链接最终成可执行文件,在Windows中是后缀为 exe的文件,但由于VS是集成环境不方便查看,我们可以在Linux环境下尝试。

程序的编译过程:

image-20220518230007544 image-20220518230014172

符号表的合并

image-20220518230020676

  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函数,连接器会使用哪个名字去找呢?这里每个编译器都有自己的函数名修饰规则。

【程序的编译具体参见】:

【C语言进阶】程序的编译 – Sabrina

函数名修饰

  1. 由于Windows下vs的修饰规则过于复杂,而Linux下gcc的修饰规则简单易懂,下面我们使用了gcc演示了这个修饰后的名字。
  2. 通过下面我们可以看出gcc的函数修饰后名字不变。而g++的函数修饰后变成【_Z+函数长度+函数名+参数类型首字母】。

分别使用C的编译器和C++的编译器去编译并获得一个可执行文件

  • 使用C语言(gcc)编译器编译后结果

使用objdump -S 命令查看gcc生成的可执行文件:

image-20220518230028168

  • 使用C++编译器(g++)编译后结果

使用objdump -S 命令查看g++生成的可执行文件:

image-20220518230034081

linux下:修饰后的函数名= _Z + 函数名长度 + 形参类型首字母

通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载,另外我们也从底层理解了,为什么函数重载要求参数不同!而跟返回值没关系。

C++的编译和链接方式

采用g++编译完成后,函数的名字将会被修饰,编译器将函数的参数类型信息添加到修改后的名字中,因此当相同函数名的函数拥有不用类型的参数时,在g++编译器看来是不同的函数,而我们另一个模块中想要调用这些函数也就必须使用相对应的C++的规则去链接函数(找修饰后的函数名)才能找到函数的地址。

C的编译和链接方式

对于C程序,由于不支持重载,编译时函数是未加任何修饰的,而且链接时也是去寻找未经修饰的函数名。

C和C++直接混合编译时的链接错误

在C++程序中,函数名是会被参数类型信息修饰的,这就造成了它们之间无法直接相互调用。

例如:

print(int)函数,使用g++编译时函数名会被修饰为 _Z5printi,而使用gcc编译时函数名则仍然是print,如果直接在C++中调用使用C编译规则的函数,会链接错误,因为它会去寻找 _Z5printi而不是 print。

结论:在Linux环境下,采用g++编译完成后,函数的名字将会被修饰,编译器将函数的参数类型信息添加到修改后的名字中,因此当相同函数名的函数拥有不用类型的参数时,在g++编译器看来是不同的函数。

对重载函数的调用不明确

难道说有了重载函数那么函数在调用时即使函数名相同就一定能区分了吗?

来看看下面这种情况:

void test(int a = 1, int b = 2)
{
	cout << "testab" << endl;
}

void test()
{
	cout << "test" << endl;
}
int main()
{
	test();
	return 0;
}

那么在12行调用test函数,按照C++的链接规则,我们应该找的是_Z4test,这样的被修饰过的函数名。

第1行的test函数经过修饰是_Z4testii

第6行的test函数经过修饰是_Z4test

那是否意味着我们不传参调用时就一定去找的_Z4test呢?但是明明第1行的函数带有默认参数即使不传参也可以调用啊。

事实上这个程序是可以编译通过的因为被修饰后的函数名并不会产生冲突,只会在调用函数时会存在歧义,链接过程中,这两个重载的函数都会成为被调用的候选人,并且都符合调用的条件,多个匹配函数找到,调用将被拒绝,因此我们链接过程中不仅仅是寻找函数名那么简单,还有很多复杂的规范。

【拓展阅读】:C++的函数重载 - 吴秦 - 博客园

extern “C”

我们在写C++代码时,由于其兼容C语言,因此我们通常会使用一些C标准库里的函数,那如果它们的函数名修饰规则不同,那么C++编译器又是怎么去调用C的库的呢?

在C++出现以前,很多代码都是C语言写的,而且很底层的库也是C语言写的,为了更好的支持原来的C代码和已经写好的C语言库,需要在C++中尽可能的支持C,而extern "C"就是其中的一个策略.

在C++工程中需要将某些函数按照C的风格来编译,在函数前加extern "C",意思是告诉编译器,该函数是按照C语言规则来编译和链接的。

比如:tcmalloc是google用C++实现的一个项目,他提供tcmallc()和tcfree,两个接口来使用,但如果是C项目就没办法使用,那么他就使用extern “C”来解决。

源文件A(cpp):

int Add(int num1, int num2)
{
	return num1 + num2;
}

源文件B(cpp):

extern "C" int Add(int num1, int num2);
int main()
{
	Add(1, 2);//在模块B中调用A中的函数
	return 0;
}

error LNK2019: 无法解析的外部符号_Add,该符号在函数 _main 中被引用

注意:

这里的模块A的 Add函数仍然是按照C++规则去编译的,函数名仍会被修饰为_Z3Addii,不过在模块B 使用extern ”C“会让编译器让Add函数按照C的方式链接,所以在调用时用C的方式去寻找Add,所以会报错。

总结:

extern "C" 只是 C++ 的关键字,不是 C

所以,如果在 C 程序中引入了 extern "C" 会导致编译错误。

被 extern "C" 修饰的目标一般是对一个全局C或者 C++ 函数的声明

从源码上看 extern "C" 一般对头文件中函数声明进行修饰。 Ccpp 中头文件函数声明的形式都是一样的(因为两者语法基本一样),对应声明的实现却可能由于语言特性而不同了( C 库和 C++ 库里面当然会不同)。

extern "C" 这个关键字声明的真实目的,就是实现 C++ 与C及其它语言的混合编程

一旦被 extern "C" 修饰之后,它便以 C 的方式工作(编译阶段:以C的方式编译,链接阶段:寻找C方式编译生成的符号), C 中引用 C++ 库的函数,或 C++ 中引用 C 库的函数,都可以通过这个方式(即在C++文件中用extern "C" 声明,实现C与C++的兼容。

【关于extern “C”的具体使用】:

C++和C的混合编译(extern“C”) – Sabrina

引用

引用的概念

引用不是定义了一个新的变量,而是是一个别名,也就是说,它是某个已存在变量的另一个名字,它和被引用的对象共用同一块内存空间。一旦把引用初始化为某个变量,就可以使用该引用名称或变量名称来指向变量。

别名字面意思就是另一个名字,例如孙悟空,他的别名是孙行者,孙悟空也是他,齐天大圣也是它,一切可以指代他的名称都可以称作他的别名。

初始化引用格式

引用实体类型 & 引用变量名 = 引用实体

#include<iostream>
using namespace std;
int main()
{
	int a = 1;
	int& quote = a;//初始化quote为a的别名
	cout << a << quote << endl;
	quote = 2;
	cout << a << quote << endl;
	return 0;
}

我们进入调试窗口:

image-20220518230046095

通过调试可以看到,a和quote的地址是一样的,并且quote的类型就为int&,所以quote的改变一定会影响a。

引用特性

  1. 引用在定义时必须初始化;
  2. 一个变量可以有多个引用;
  3. 引用一旦引用一个实体,引用指向的对象就不能再改变;
  4. 引用的实体可以是另一个引用;

例如:

#include <iostream>

using namespace std;
//定义引用时未初始化
int main()
{
	int a = 1;
	int& quote;//未初始化引用,error: ‘rodents’ declared as reference but not initialized
	quote = a;
}
//修改引用实体
int main()
{
	int a = 1;
	int b = 2;
	int& quote = a;
	int& quote = b;//只能引用一个实体,编译出错
	return 0;
}

//引用另一个引用
int main()
{
    int a = 1;
    int& quote1 = a;
    int& quote2 = quote1;//它们的地址仍然相同,指向同一块空间
    return 0;
}

常引用

在C++中,与C语言不同,被const修饰的变量会被当做是一个常量(只对该变量内存空间有读权限,没有写权限),而不是常变量,因此引用的类型一定要和被引用的实体相匹配,可以有权限的缩小,但不能有权限的扩大。

例如:

//权限的缩小
int main()
{
	int a = 1;
	const int& quote = a;//从可读可写-》只可读
	return 0;
}
//权限的放大会报错
int main()
{
	const int a = 1;
	int& quote = a;//从只可读-》可读可写
	return 0;
}
//常引用
int main()
{
	const int a = 1;
	const int& quote = a;
	quote = 2;//不可赋值
	return 0;
}

使用场景

  • 做参数

​ 对于需要在函数内部修改函数外部实参的函数,让形参为实参的引用,就可以在函数内部修改外部变量,并且 还可以减少形参拷贝实参的开销。

void swap(int& num1, int& num2)
{
	int tmp = num1;
	num1 = num2;
	num2 = tmp;
}

int main()
{
	int n1 = 3;
	int n2 = 5;
	swap(n1, n2);
	cout << "n1=" << n1 << endl << "n2=" << n2 << endl;
	return 0;
}
  • 做返回值

如果返回的变量在函数调用结束后不会被自动销毁,则可以返回该变量的引用,减少返回值拷贝的开销

int& count()
{
	static int n = 1;
	++n;
	cout << "int& count()" << endl;
	return n;
}
  • 返回值不能是函数内创建的局部变量的引用

​ 否则会非法访问内存(访问不属于程序的内存)

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

int main()
{
	int& ret = Add(2, 8);//Add(2, 8)的类型是c的引用,当赋值给ret时,c变量已经销毁
	cout << ret << endl;
	return 0;
}

总结:如果函数调用结束后栈帧销毁但是返回对象仍未销毁,则可以使用引用返回,否则只能传值返回。

传值和传引用的区别

以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。

函数的传参如果是传值调用的话,形参实际上是实参的一份拷贝,也就是说每一次调用函数,都要将实参拷贝给形参,这也带来了资源的消耗,如果多次调用此函数,那么必定会导致效率的低下。

可以使用如下代码测试多次调用函数时传值调用和传引用调用的时间差异

#include<iostream>
#include<time.h>
using namespace std;

struct A
{
	A()
	{
		memset(arr, 0, sizeof(arr));
	}
	int arr[1000];
};

void TestFunc1(A p)
{}

void TestFunc2(A& p)
{}

void TestEfficiencyByCall()
{
	A p;
	size_t start1 = clock();
	for (int i = 0; i < 1000000; i++)
	{
		TestFunc1(p);
	}
	size_t end1 = clock();
	
	size_t start2 = clock();
	for (int i = 0; i < 10000; i++)
	{
		TestFunc2(p);
	}
	size_t end2 = clock();

	cout << "传值调用 void TestFunc1(A p):" << end1 - start1 << endl;
	cout << "传引用调用 void TestFunc1(A& p):" << end2 - start2 << endl;
}

int main()
{
	TestEfficiencyByCall();
	return 0;
}

运行结果如下:

image-20220518230055452

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

struct A
{
	A()
	{
		memset(arr, 0, sizeof(arr));
	}
	int arr[1000];
};
A a;
A TestFunc1()
{
	return a;
}

A& TestFunc2()
{
	return a;
}

void TestEfficiency()
{
	size_t start1 = clock();
	for (int i = 0; i < 1000000; i++)
	{
		TestFunc1();
	}
	size_t end1 = clock();
	
	size_t start2 = clock();
	for (int i = 0; i < 10000; i++)
	{
		TestFunc2();
	}
	size_t end2 = clock();

	cout << "值返回 void TestFunc1(A p):" << end1 - start1 << endl;
	cout << "引用返回 void TestFunc1(A& p):" << end2 - start2 << endl;
}

int main()
{
	TestEfficiency();
	return 0;
}

运行结果:

image-20220518230102548

可以看到无论是作为参数还是作为返回值,传递引用和值的时间的开销差异都是比较大的。

我们可以看一看函数返回值是如何传递的:

image-20220524080458920

函数返回值从被调用的函数的栈帧到调用方栈帧的传递过程大致如上。

通常我们会创建一个变量接收函数得返回值,在这里就是这个在main函数中预先开好空间的用于存储函数返回值的对象。

接下来看过程:

如果是传值返回,则产生的临时变量会是返回对象的一份临时拷贝,然后再拷贝给main函数中预先开好空间的用于存储函数返回值的对象,而如果是传引用返回,则临时变量会是a的引用,临时对象再赋值给main函数中预先开好空间的用于存储函数返回值的对象,那这里也会是一个引用,因此我们在main函数中就可以访问到a对象了。

不过不是每次都需要创建一个临时变量,对于一些比较小的变量,会直接用寄存器来传递值。

注:临时变量的类型就是定义的返回类型,此临时变量通常也是也是具有常性的,不过也有例外,那就是传引用返回的情况。

  1. 传值返回:那么该临时变量是有常性的。
  2. 传引用返回:无常性,只和返回的类型是否被const修饰有关。

临时变量存储于调用方函数的栈帧**。

引用和指针的区别

引用很容易与指针混淆,它们之间有三个主要的不同:

  • 不存在空引用(引用的对象必须存在)。引用必须连接到一块合法的内存。
  • 一旦引用被初始化为一个对象,就不能被指向到另一个对象。指针可以在任何时候指向到另一个对象。
  • 引用必须在创建时被初始化。指针可以在任何时间被初始化。

引用在语法层面上就是一个别名,别名是不单独享有内存空间的,它和被引用的实体共用同一块内存空间。

int main()
{
	int a = 9;
	int& ra = a;
	cout << "&a = " << &a << endl;
	cout << "&ra = " << &ra << endl;
	return 0;
}

这样的语法解释实在有些难以理解它在底层是如何做到的。

实际上在底层实现上引用还是有空间的,因为引用本质还是指针的方式来实现的。

int main()
{
	int a = 9;

	int& ra = a;
	ra = 99;

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

我们来看看汇编:

image-20220518231238489

汇编指令大致都是相同的,也就是说它和指针实际上是同根同源的。

指针和引用差异汇总:

  1. 引用在定义时必须初始化,而指针不需要;
  2. 引用在初始化引用一个实体后就不能再引用其他实体了,而指针指向的对象可以随意修改;
  3. 没有NULL引用,但是又NULL指针;
  4. 在sizeof中的含义不同,引用结果为被引用实体的类型大小,而指针的大小是地址空间所占的字节数;
  5. 引用在初始化后,一切对引用的操作都是对实体对象操作的,而指针可以操作指针变量本身,也可以操作被指向的对象;
  6. 多级指针但没有多级引用;
  7. 访问实体方式不同,指针需要我们显式的去解引用方能对指向的对象进行操作,而引用是编译器替我们处理;
  8. 引用相对于指针更加的安全,不存在野指针等潜在的风险;

一些引用的注意事项

类型转换实现方法以及临时变量的特性

看如下代码:

int main()
{
	double d = 9.9;
	int& a = d;
   	cout << a << endl;
	return 0;
}

报错:

“初始化”: 无法从“double”转换为“int &”

改动如下即可正常编译:

int main()
{
	double d = 9.9;
	const int& a = d;
   	cout << a << endl;
	return 0;
}

这是什么原因??const修饰过后为什么就能正常编译了呢???(warning)

这里不得不提到类型转换时发生的小动作;

类型转换是如何实现的呢?不论是显式的还是隐式的发生的类型转换,它这个类型转换的效果都是“临时”的,仅仅在当前行生效,也就是说本身发生转换的那个变量或者说是对象它的类型并没有改变。

既然如此,那么中间一定会有另一个临时变量的产生,而是这个我们看不到的临时变量在发挥让我们看起来像“类型转换”的作用。

那么再来看第4行代码,int&只能初始化为int类型的引用,因此这里会发生隐式类型转换,即产生一个int类型的变量,并且让这个临时变量在这一行中代替d来产生作用,a就被初始化为了这个临时变量的引用;

为什么不用const修饰就无法通过编译呢?

答案是:临时变量具有常性,也就是说临时变量具有只可读不可写的性质,那么如果不使用const对引用加以限制,就造成了权限的放大,而这是不被C++所允许的,因此必须加上const修饰a;

嘿,那么新问题来了,既然临时变量只在当前行生效,也就是程序走完这一行临时变量就销毁了,而a作为此临时变量的引用,却在第5行正常访问了a,那么这里我们还可以得出一个结论:

const修饰的引用的实体是临时变量时,临时变量的声明周期就会延长,直到引用的生命周期结束。

概括一下:

  • 类型转换伴随着临时变量的产生;
  • 临时变量具有常性;
  • const修饰的引用的实体是临时变量时,临时变量的声明周期就会延长,知道引用的生命周期结束;
  • 不会被修改的变量尽量用const修饰;

关于临时对象的类型的注意事项

如下两段代码有何差异?

//代码1
int main()
{
	double d = 9.9;
	const int& a = (int&)d;
    cout << "d = " << d << endl;
	cout << "a = " << a << endl;
	return 0;
}

输出:

d = 9.9
a = -858993459

//代码2
int main()
{
	double d = 9.9;
	const int& a = d;
	cout << "d = " << d << endl;
	cout << "a = " << a << endl;
	return 0;
}

输出:

d = 9.9
a = 9

出错了,奇怪,这两段代码的执行结果应该相同才对啊???不急我们耐心分析一下这两段代码的差异。

差异就只有第四行,我们来单独看看

代码1:

const int& a = (int&)d;

这行代码的意思应该是将 d (double类型)强制类型转换为 int&,我们都知道强转类型时会生成一个临时变量(int&),再初始化a为为这个临时变量的引用;

关系如图:

image-20220518230113331

这里的关系文字描述为d是double类型, tmp是d的引用(int&),而a又是tmp的引用(int&),可以直接认为a是d的引用,只不过引用的类型为 const int;

代码2:

const int& a = d;

这行代码的意思是初始化a为d的引用,不过类型并不匹配,int&需要一个引用一个int的实体或者一个int&的引用,因此,d会发生隐式类型转换,产生一个int类型的临时变量,即a会是这个临时变量的引用。

关系如图:

image-20220518230120469

这里的关系文字描述为d是double类型,而tmp是一个临时的int类型,a是tmp的引用;

这样就解释的通了,但是是否真是如此,我们需要通过地址来验证;

代码1:

image-20220518230127426

可以看到他d和a的地址是一样的,说明a是d的引用(指向d的地址),但只是引用的类型和d的类型不同。

代码2:

image-20220518230133326

a的地址和d不同,这是因为a是隐式类型转换所产生的临时变量的引用,而此临时变量是一个int类型,而非引用,具有自己独立的内存空间,而a指向这块临时变量的空间,因此地址不同。

这两段代码的唯一差异就是类型转换时生成的临时变量的类型不同,一个是int类型,一个是int&类型,即一个有自己的单独内存空间,而另一个与发生类型转换的对象共享一块空间(其实引用是有单独的内存空间,不过经过编译器处理,我们对引用操作时都是实际上操作的是被引用的实体,因此可以视作没有分配内存),而a都是临时变量的引用,就导致了最终结果的不同。

因此在使用引用时,一定要注意这些可能会遇到的问题,一不留神就可能掉坑了,要规范正确的使用引用。

标签:入门,int,cout,基础,C++,引用,变量,函数
From: https://www.cnblogs.com/ncphoton/p/16950510.html

相关文章

  • 3.C++和C的混合编译
    简介C++语言的创建初衷是"abetterC",但是这并不意味着C++中类似C语言的全局变量和函数所采用的编译和连接方式与C语言完全相同。作为一种欲与C兼容的语言,C++......
  • 2.C++入门基础(下)
    内联函数C++中函数的使用我们已经比较清楚了,与C语言中函数的使用大多相同,主要是增加了重载的特性,对C语言的函数的一些缺陷做了一些补充。那么对于一些比较简单却又经常使......
  • 7.C++拷贝构造函数
    拷贝构造函数我们经常会用一个变量去初始化一个同类型的变量,那么对于自定义的类型也应该有类似的操作,那么创建对象时如何使用一个已经存在的对象去创建另一个与之相同的对......
  • 6.C++构造函数
    类的6个默认成员函数如果我们写了一个类,这个类我们只写了成员变量没有定义成员函数,那么这个类中就没有函数了吗?并不是的,在我们定义类时即使我们没有写任何成员函数,编译器......
  • 5.C++类和对象(上)
    面向过程和面向对象初步认识C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。C++是基于面向对象的,关注的是对象,将一件事拆分成不同的对象......
  • 11.C++日期类的实现
    日期类的实现在前面学过默认成员函数后,我们就可以写一个简单的日期类了。如何写呢?我们可以先分析分析。日期类的成员变量都是int类型,那么构造函数是要显式定义的,成员变......
  • 10.C++类和对象(下)
    再谈构造函数之前讲过构造函数的一些特性,再在这里补充下。构造函数体赋值classDate{public: Date(intyear,intmonth,intday) { _year=year; _month=m......
  • 9.C++运算符重载
    运算符重载本文包括了对C++类的6个默认成员函数中的赋值运算符重载和取地址和const对象取地址操作符的重载。运算符是程序中最最常见的操作,例如对于内置类型的赋值我们直......
  • 8.C++析构函数
    析构函数既然在创建对象时有构造函数(给成员初始化),那么在销毁对象时应该还有一个清除成员变量数据的操作咯。概念析构函数:与构造函数功能相反,析构函数不是完成对象的销......
  • 13.C++模板初阶
    泛型编程如何实现一个通用的交换函数呢?voidSwap(int&left,int&right){ inttemp=left; left=right; right=temp;}voidSwap(double&left,double&ri......