C++学习笔记(上)

目录

Visual Studio设置

静态与动态链接库,创建与使用库

分支与三元操作符

指针与引用

C++类与成员初始化列表

C++中的静态(static)

C++枚举(enumeration)

构造函数(constructor)和折构函数(destructor)

C++继承,虚函数与纯虚函数

C++数组

C++字符串及其字面量

const与mutable

创建C++对象与new关键字

C++运算符重载以及this关键字

C++的栈作用域生存期与智能指针

复制与拷贝构造函数

箭头操作符

C++动态数组 std::vector 的使用及优化


Visual Studio设置

以下设置是为了方便项目组织,平时学习可以不用这些设置

1.在一个新项目中添加src文件夹

下图是解决方案资源管理器初始界面

C++学习笔记(上)_第1张图片

为了方便在文件夹中查看项目,我们可以添加src(source)文件夹存放主要的程序,过程如下C++学习笔记(上)_第2张图片

C++学习笔记(上)_第3张图片

现在我们有一个资源文件,接下来点击显示所有文件按钮,进行接下来的操作

C++学习笔记(上)_第4张图片

C++学习笔记(上)_第5张图片

C++学习笔记(上)_第6张图片

C++学习笔记(上)_第7张图片

C++学习笔记(上)_第8张图片

接下来用鼠标将Main.cpp文件拖入src文件夹中

C++学习笔记(上)_第9张图片

C++学习笔记(上)_第10张图片

C++学习笔记(上)_第11张图片

在文件资源管理器中查看该项目,可以看到src文件夹,至此,文件分类的问题就解决了。 

2.更改中间文件等文件放置的目录设置

C++学习笔记(上)_第12张图片

 以下为初始设置

C++学习笔记(上)_第13张图片

 接下来按如下步骤更改设置

C++学习笔记(上)_第14张图片

C++学习笔记(上)_第15张图片 修改输出目录(Output Directory),如下$(SolutionDir)bin\$(Platform)\$(Configuration)\

                                                                   二进制文件夹 X32、X64平台 debug、release模式

上一步的目的是让所有输出文件都在一个文件下保存,如.dll文件,而非与其他文件混杂 

接下来更改中间目录的属性为$(SolutionDir)bin\intermediaties\$(Platform)\$(Configuration)\

如下

C++学习笔记(上)_第16张图片最后点击 确定

C++学习笔记(上)_第17张图片

点击清理,重新生成(build)查看效果

C++学习笔记(上)_第18张图片

静态与动态链接库,创建与使用库

在自己的项目中如何使用:静态和动态

使用visual studio时,(1)可以添加另一个项目,该项目包含了你的依赖库的源代码,然后将其编译为静态或动态库,(2)也可以直接链接二进制形式的文件。

先来讲述(2)直接连接二进制形式的文件,本例将使用GLFW库作为外部库链接的例子。

先下载32位二进制windows文件,选取32或64位取决于与库连接的目标项目是以X32或X64进行debug的。进入GLFW官网:www.glfw.org,点击download,点击32-bit Windows binaries。

压缩包下载完成后,解压至C盘GLFW目录下,打开解压后的文件夹,布局如下:

C++学习笔记(上)_第19张图片

库通常分为两部分,include 和 library,即包含目录和库目录。包含目录是一堆头文件,如下

C++学习笔记(上)_第20张图片

这样我们就可以使用预构建的二进制文件中的函数,然后lib目录有那些预先构建的二进制文件。其中又又静态库和动态库,(不是所有库都两种都有)可以选择静态或动态链接,打开库2022

C++学习笔记(上)_第21张图片

 其中  .dll (dynamic link library)和 ~dll.lib与动态链接有关,其他 .lib与静态链接有关

简单说明静态链接和动态链接的区别:

静态链接说明这个库会被放到你的可执行文件(.exe)中,而动态链接是在运行时链接的,也可以在程序运行时,加载你的 .dll文件。主要的区别在于,库是否被编译到可执行文件或链接到可执行文件中;还是只是一个单独的文件,需要把他放在你的exe文件旁或某个地方,然后你的exe文件可以加载它,因为这种依赖性,库文件需要和exe文件放在一起(同个文件夹中)。

静态链接技术上可以产生更快的应用,因为编译器和链接器可以在链接时做优化,不过动态链接可以占用更小的内存(因为更小的二进制文件)。

1.接下来演示静态链接的过程。

首先按”VisualStudio设置“章节准备一个HelloWorld程序,在包含解决方案的目录下创建一个名为Dependencies(依赖)的文件夹,如下图所示C++学习笔记(上)_第22张图片

这是在项目中管理依赖项,也就是库文件的目录。在该目录下创建一个名为GLFW的文件夹,如下

C++学习笔记(上)_第23张图片

在该目录下将GLFW中的 include 和 lib-vc2022 文件复制进来,如下图所示

C++学习笔记(上)_第24张图片

右键项目,点击属性,如下图

C++学习笔记(上)_第25张图片

 C/C++下的general(常规)- Addtional include directories(附加包含目录),我们要指定附加的包含目录,注意配置和平台。

C++学习笔记(上)_第26张图片

附加包含目录即刚才复制的include,它的完整路径为D:\cppCode\cppSeries\LinkExternalLib\Dependencies\GLFW\include

但是我们并不需要输入完整的目录,而只需要一个相对路径。

LinkExternalLib是我的解决方案的目录,如下

C++学习笔记(上)_第27张图片

从这个解决方案文件开始,可以输入$SolutionDir(solution所在目录),它是一个宏,选中该行,点击向下展开箭头,点击编辑,如下图

C++学习笔记(上)_第28张图片

可以看到该行实际的值,如下图

C++学习笔记(上)_第29张图片

点击宏(Macros)的展开按钮,可以看见所有的宏,在搜索框可以输入宏查看对应的值,如下两图

C++学习笔记(上)_第30张图片

C++学习笔记(上)_第31张图片

以此相对路径,再输入剩余的尾部路径Dependencies\GLFW\include即可

C++学习笔记(上)_第32张图片将相对路径粘贴,点击确定或按下ENTER,如下图

include目录下的GLFW文件中有如下两个头文件C++学习笔记(上)_第33张图片

我们可以在Main.cpp文件中#include相对于include的头文件路径,按Ctrl+F7编译成功,如下图

C++学习笔记(上)_第34张图片

在这里引出了使用尖括号<> 还是双引号""引用文件的问题,它们实际上没有很多区别,如果是引号,会检查相对路径,如果它没有找到相对这个路径,也就是相对于本例中的Main.cpp文件,它才会去寻找编译器的include路径。所有,在选用时可以用如下方案,如果这个被引用文件(如glfw3.h)在解决方案中,即如下图,左边的解决方案下可以看见该文件,可以选用双引号。

C++学习笔记(上)_第35张图片

如果它是一个完全的外部依赖或外部的库,如本例中的头文件就是,可以选用双引号。

到当前这一步为止,还没有链接到库文件。按下图操作转到头文件源代码中,可以看到很多函数。

C++学习笔记(上)_第36张图片

我们可以试着引用其中的glfwInit()函数,在Main.cpp中输入int a = glfwInit();代码。Ctrl+F7编译成功,Ctrl+B 生成(build)失败,如下图,产生链接错误unresoveled external symbol(无法解析的外部符号)

C++学习笔记(上)_第37张图片

这意味着并没有链接到实际的库中,虽然编译器在头文件中找到了实际的glfwInit函数,但链接器并没有找到该函数的实际定义。右键项目,点击属性,确认在正确的配置和平台,选择Linker(链接器)- input(输入),在输入-附加依赖项(Additional Dependencies)中,我们需要添加glfw3.lib库文件的完整路径,我们也可以输入(SolutionDir)的相对路径,但我们只想写glfw3.lib几个字保持干净易读,则需要回到Linker-general,设置附加库目录(Additional Library Directories),该路径应该是包含glfw3.lib文件的根目录,它的相对路径为$(SolutionDir)Dependencies\GLFW\lib-vc2022,如下

C++学习笔记(上)_第38张图片

所以,我们已经指定了一个库目录,还指定了相对于该库目录的库文件的名称(如下图)

C++学习笔记(上)_第39张图片

 再次生成,运行,成功链接该库并调用库中函数,运行结果如下

C++学习笔记(上)_第40张图片

2.接下来演示动态链接的过程:

动态链接同样需要include对应的头文件,即属性中的C/C++-常规-附加包含目录中同样需要include的相对路径,如下 

C++学习笔记(上)_第41张图片

打开lib文件夹,如下两个文件都与动态链接有关,.dll是动态链接库文件后缀,dll.lib(引导库)中则是一堆指向 .dll文件中函数位置的指针,可以不用再检索位置,在动态链接时要确保它们都被编译

C++学习笔记(上)_第42张图片

设置附加库目录(Additional Library Directories),该路径应该是包含glfw3dll.lib文件的根目录,它的相对路径为$(SolutionDir)Dependencies\GLFW\lib-vc2022,如下

C++学习笔记(上)_第43张图片

在链接器-输入-附加依赖项中加入glfw3dll.lib文件,如下图

C++学习笔记(上)_第44张图片

即有了指向dll文件中内容位置的指针,接下来需要将.dll文件与本程序的可执行文件(.exe)放在同一目录下(是一种自动搜索路径的方式),复制glfw3.dll文件,并将其复制,如下图

C++学习笔记(上)_第45张图片

 运行,成功,动态链接完成。

3.创建与使用自己的库:

创建一个空项目,创建Game.cpp其中包含main函数。

再按如下操作新建一个项目,命名为Engine。

C++学习笔记(上)_第46张图片

此时我们有两个项目,确保Game所在项目属性界面下General-Configuration Type的类型为Application(.exe),如下图

C++学习笔记(上)_第47张图片

再打开Engine项目的属性界面,设置配置类型为Static Library,注意配置和平台,如下图

C++学习笔记(上)_第48张图片

在Engine工程下,按下面步骤创建一个头文件Engine.h和一个cpp文件Engine.cpp

C++学习笔记(上)_第49张图片

C++学习笔记(上)_第50张图片

因为之前没有将两个项目放入同一个文件夹,我按上述步骤大致重新建立了该项目,如下图

C++学习笔记(上)_第51张图片

Engine和Game文件夹都在Create&UseMyLib文件夹下,然后按项目设置章节重新清理了两个项目的结构。随后执行如下步骤写入基本代码。

C++学习笔记(上)_第52张图片

C++学习笔记(上)_第53张图片

C++学习笔记(上)_第54张图片

如上图,include后输入相对于Game.cpp而言Engine.h的相对路径,报错消失

C++学习笔记(上)_第55张图片

但是这样在文件夹中寻找看起来很乱。更好的方式是使用绝对路径,特别是使用编译器的包含路径。右键Game项目,单击属性,C/C++-常规-附加包含目录,将要包含Engine.h的源目录,D:\cppCode\cppSeries\Create&UseMyLib\Engine\src 如下图

C++学习笔记(上)_第56张图片

此时直接去掉刚才的#include路径,添加 #include "Engine.h" 即可,Engine.h是解决项目中的文件,所以采用""引用。

注:由于到目前出现了很多bug,重新创建文件夹和两个项目,且不采用项目设置中除了添加src文件的设置方式,而选用默认,然后重复前面的步骤,随后进行接下来的操作

接下来是静态链接的步骤,先build Engine项目,如下图,生成了.lib文件。

C++学习笔记(上)_第57张图片

复制它的全部路径,到Game属性-链接器-输入-附加依赖项中添加Engine.lib的路径加分号D:\cppCode\cppSeries\Create&UseMyLib\Game\x64\Debug\Engine.lib;作为输入。

随后build Game项目,运行结果如下图

C++学习笔记(上)_第58张图片

但我们不需要做这个,因为Engine依赖与Game在同个项目中,visual studio可以自动化这些操作。

确保不受前面操作的干扰,先删除链接器输入属性中添加的路径,随后build,报错。

右键Game项目,如下图操作。

C++学习笔记(上)_第59张图片

C++学习笔记(上)_第60张图片

生成,运行成功。它自动化按顺序完成了以下操作:生成.lib,链接,生成.exe


流程中出现的bug以及解决方法

1. build Engine项目时出现了main函数的错误,这是因为没有在Engine属性-常规中将它设置为.lib

2.警告TargetName与链接器对应输出不一致,这是因为设置输出文件路径时多加了个空格

分支与三元操作符

if判断语句在实际写代码过程中比较耗时,所以可以尽量不写if语句,而选择用更优效率语句代替

else if 实际上是else 和 if 两个语句,所以else加不加无所谓,只是形式上更规范

例如,下面的代码段

if (a == 1)
	a++;
else if(a == 2)
	a--;
else if(a == 3)
	a = b;

和下面的代码段是一样的

if (a == 1)
	a++;	
else 
{
	if(a == 2)
		a--;
	else 
	{
		if(a == 3)
			a = b;
	}
}	

三元操作符:

三元操作符的语法是由:?构成条件判断执行语句,它实际上是if语句的语法糖。

    if (s_Level > 5)
	{
		s_Speed = 10;
	}
	else
	{
		s_Speed = 5;
	}

上述语句的效果与下面语句的效果相同

s_Speed = s_Level > 5 ? 10 : 5;

最终目的是给s_Speed赋值,将条件判断再赋值的逻辑语句简洁化为三元操作符语句

如下的语句在实际写代码中就很方便,代码量大大缩减,简洁且易读。

std::string rank = s_Level > 5 ? "master" : "beginner";

三元操作符也可以进行嵌套,如下面的语句可以避免大量冗杂的if 语句。

s_Speed = s_Level > 5 ? s_Level > 10 ? 15 : 10 : 5;

s_Speed = s_Level > 5 ? [s_Level > 10 ?] 15 : 10 : 5; 先进行[ ]中语句的判断,Level大于10则Speed等于15,否则再进行s_Level >5?的判断,大于5则等于10,否则等于5。

更复杂的三元操作符嵌套就会大大降低可读性,意义较小。

指针与引用

指针 是 一种存储内存地址的整数,

指针的数据类型(type)不影响指针的本质。只是为了使代码在语法层面更容易理解。

创建一个无类型空指针的语句

void* ptr = nullptr;

创建一个指针,用以 保存 创建的var变量的 内存地址,即把var变量的内存地址存在了ptr变量中

int var = 8;
void* ptr = &var; //&取址符可以获得某变量的内存地址

顺便一提,如果在一行语句同时定义两个指针,正确的格式是在每个的左边都要加*,如下。

	int* j=&var, *k=&var;
	std::cout << "j = " << j << ",k = " << k << std::endl;

指针的一个重要作用是逆向引用

在指针前插入一个星号就可以逆向引用ptr指针,意味着 此时正在访问我可以读取或写入数据的地址

先将ptr的类型转化为=整型,才能对内存中存储的数字修改为整型数据,如下,原本存储8的地址存储的数据变为10.

int var = 8;
int* ptr = &var;
*ptr = 10;

当想要电脑分配固定大小尺寸的内存空间,可以用char类型指针,因为char占一字节内存空间,

char* buffer = new char[8]; //分配了8字节的内存,并返回一个指向那块内存开始的指针
memset(buffer, 0, 8); //memset函数可以用我们指定的数据填充一个内存块,它接收一个指针,指向内存块开始的位置,我们将数据0填入buffer开始连续8字节的内存块中。
delete[] buffer; //delete可以删除数据

双指针以及更多的指针:也就是指向指针的指针,例如

char** ptr2 = &buffer;

储存的是buffer指针的内存地址

C + + 引 用:

引用 是 指针的伪装,在指针上的语法糖,让它更容易阅读和理解,是一种我们引用已经存在的变量的方式(就像变量的别名)

在变量的旁边,它是取值符义,,在类型(如整型int)的旁边,它是引用。

引用某变量,对给变量起的别名操作可以获得对变量操作的相同效果

int a = 5;
int& ref = a;
ref = 2;
std::cout << a << std::endl;

如上述代码对ref赋值为2,相当于对a赋值为2

C++学习笔记(上)_第61张图片

 下列Incre函数的目的是增加value变量的值,但是发现定义完下列代码后通过Incre语句不能实现对value数据的更改

void Incre(int value)
{
	value++;
}

上述功能可以通过指针的逆向引用实现即下列代码

void Incre(int* value)
{
	(*value)++;// 添加括号防止先增加传入地址的值,后逆向引用该增加后的地址
}
Incre(&a);

但是用引用解决该问题会方便的多,无需传递a的内存地址,只需要传递a即可,代码如下

void Incre(int& value)
{
	value++; 
}
Incre(a); 

下列代码的运行结果为

C++学习笔记(上)_第62张图片

C++学习笔记(上)_第63张图片

通过观察上例可以看出多指针和多引用的效果和使用方法

如果可以使用引用而不使用指针,那就使用引用,因为它可以让代码更加简洁和简单

C++类与成员初始化列表

类 是 把数据和功能组合在一起的一种方法,本质是一种Type

通过类可以把混乱的各种定义简化和组织,类本身只是语法糖,使用它来组织代码,可以使代码更利于维护

比如,我们可以创建一个Player(玩家角色)类

class Player
{
	int x, y;
	int speed;
};

int main()
{
	Player player;
}

由类 类型构成的变量称为对象(object),新的对象变量称为实例(instance)

所以上述代码实例化了一个Player对象,因为我们为Player类型创建了一个新实例

执行 player.x = 5; 语句,发现报错,原因是类的默认设置是类内部的变量都是私有的,类的内部函数才可以引用,要在外部函数main函数中使用,需要在对x,y,speed的定义前添加public

我们创建一个Move方法,表示人物实例移动的距离

C++学习笔记(上)_第64张图片

类内的函数被称为方法,将Move函数移动到Player类内

C++学习笔记(上)_第65张图片

C++学习笔记(上)_第66张图片类与结构体的区别 :

本质上技术上,类与结构体的唯一区别是,类中定义的变量默认为private,而结构体中的变量默认为public,我们可以将player类改为player结构体,如下

C++学习笔记(上)_第67张图片

在visual studio中定义一个简单的日志类,代码如下 :

#include 

class Log
{
public:
	const int LogLevelError = 0;
	const int LogLevelWarning = 1;
	const int LogLevelInfo = 2;
private:
	int m_LogLevel = LogLevelInfo;

public:
	void setLogLevel(int LogLevel)
	{
		m_LogLevel = LogLevel;
	}

	void LogError(const char* message)
	{
		if (m_LogLevel <= LogLevelError)
			std::cout << "[ERROR]:" << message <

注意上述代码中m_前缀表示它为Log类中的成员变量,即只在Log内部使用,加此前缀有利于将成员变量与其他的局部变量和全局变量进行区分,使代码可读性和整洁性更好

设置的日志等级中,ERROR最高,WARNING次之,INFO最低,在main函数中设置日志等级为
log.setLogLevel(log.LogLevelWarning);        可以打印出ERROR和WARINING两个等级的信息,运行结果如下:

C++学习笔记(上)_第68张图片

成员初始化列表:

在类中初始化我们可以使用构造函数,如下。

class Entity
{
private:
	std::string m_Name;

public:
	Entity()
	{
		m_Name = "Unknown";
	}

	Entity(const std::string name)
	{
		m_Name = name;
	}

	const std::string& GetName() const
	{
		return m_Name;
	}

};

另一种即成员初始化列表语句,在构造函数后加冒号,用小括号代替等于号。每次执行构造函数都会执行依次冒号后的语句。而且,像这样初始化成员变量,需要按照定义这些变量的顺序初始化,否则会产生各种依赖性问题,编译器也会报错。

class Entity
{
private:
	std::string m_Name;
	int m_Score;

public:
	Entity()
		: m_Name("Unknown"), m_Score(3)
	{

	}

	Entity(const std::string name)
		: m_Name(name)
	{

	}
};

使用成员初始化列表的方式不仅能使代码风格干净,而且能避免在构造函数中初始化可能造成的性能浪费。比如在类中定义成员变量为另一个类,在构造函数中再添加初始化语句,则声明该类时它会被构造两次,如果是初始化成员列表,则它只会被构造一次。

C++中的静态(static)

静态的相当于在某范围内共同继承的变量(局部的全局变量)

C++中的静态大致可分为两种情况:结构体和类内部的static或结构体和类外部的static

1)结构体和类内部的static

类内部创建一个静态变量时,静态变量只有一个,它的内存与该类创建的所有实例共享。被static修饰的变量、被static修饰的方法统一属于类的静态资源,是类实例之间共享的,换言之,一处变、处处变,一个实例中静态变量的变化会反映在所有实例中

我们定义一个Entity结构体,并输出x,y的结果

struct Entity
{
	int x, y;

	void Print()
	{
		std::cout << "x = " << x << ", " << "y = " << y << std::endl;
	}
};
Entity e1;
e1.x = 2;
e1.y = 3;
Entity e2 = {5, 8};
e1.Print();
e2.Print();

在定义实体内x,y时将它们定义为静态 static int x, y; 编译,初始化时会失败,因为x,y不再是结构体成员,所以将e2的赋值方式更改为        Entity e2;        e2.x = 5;        e2.y = 8;        编译,会得到错误:unresolved external symbols

 因为我们需要在某个地方定义结构体中的静态变量,如下

struct Entity
{
	static int x, y;

	void Print()
	{
		std::cout << "x = " << x << ", " << "y = " << y << std::endl;
	}
};
int Entity::x;
int Entity::y;

即在结构体后加上了定义静态变量在Entity作用域上的语句(先写作用域,再写变量名),这样连接器可以连接到合适的变量,生成成功,运行结果如下图

可以看到,实例e1和e2的x,y实际上指向的是同一个x和y的内存,最后修改了e2的x,y,修改了该内存的数据为5和8。这里的不同实例创建的静态x,y变量,类似于引用中起别名的效果。这就像我们在名为Entity的命名空间中创建了两个变量,他们实际上并不属于类(结构体),此变量会被类的所有对象共享。

类中定义静态方法(函数)同理,类中静态变量或函数的意义在于如果你需要将类中所有实例的某数据共享,你可以定义一个静态变量方便管理,简化代码。

虽然上述代码可以运行,但更规范的定义方式是在后面为变量赋值时也在作用域上定义,如

Entity e1;
Entity::x = 2;
Entity::y = 3;
Entity e2;
Entity::x = 5;
Entity::y = 8;
Entity::Print();
Entity::Print();

(2)结构体和类外部的static

全局变量只在某一个翻译单元(约等于一个.cpp)内可见,可共享。局部变量可在该局部内可见和共享,如,同一个.cpp中Log函数中定义的变量不能在main函数中被引用。

普通全局变量对整个工程可见,其他文件可以使用extern外部声明后直接使用。也就是说其他文件不能再定义一个与其相同名字的变量了(否则编译器会认为它们是同一个变量)。

静态全局变量仅对当前文件可见,其他文件不可访问,其他文件可以定义与其同名的变量,两者互不影响。即只会在当前翻译单元内链接

在定义不需要与其他文件共享的全局变量时,加上static关键字能够有效地降低程序模块之间的耦合,避免不同文件同名变量的冲突,且不会误使用。

C++学习笔记(上)_第69张图片

C++学习笔记(上)_第70张图片

如上分别在两个.cpp中创建同名全局变量,进行连接,发现 

原因是static相当于把该变量在该.cpp内进行私有化,在全局作用域下,连接器将不会看到该变量

但在其中一个前加上static关键字,连接成功,另一种可以连接成功的方法是使用extern关键字,并且不对该变量进行赋值,那么连接时它会寻找外部其他文件中同名变量的值引用到该文件中。

 两个翻译单元的同名函数也是同理,不加static会产生连接错误,加上static可以使函数在当前翻译单元内私有化(注:静态方法的参数只能是静态的变量)。

当你不需要在全局作用域范围内定义某全局变量或函数时,你需要尽可能的在前加上static,保证代码的安全性,严谨性。

(3)C++中的局部静态(Local Static)

变量的作用域:我们可以访问变量的范围,例如某个函数的范围内,某个循环结构中,某个if语句中。如果你在一个函数作用域中声明一个静态变量,那么它将是那个函数的局部变量

定义为静态的变量我称之为数据可继承变量,实质上他们每次都在同一个内存块中写入和读取该变量的数据。定义一个函数如下:

void Fun()
{
	static int i = 0;
	i++;
	std::cout << "i = " << i << std::endl;
}

运行结果如下图:

它的运行效果与非静态变量 i 定义在函数体外的运行效果相同,即下列代码

int i = 0;
void Fun()
{
	i++;
	std::cout << "i = " << i << std::endl;
}

 如果去掉static,则运行结果如下:

C++学习笔记(上)_第71张图片

相当于调用该函数都重新定义了i变量为0。

综上所述,在作用域内部定义的静态变量的效果相当于在全局定义的非静态变量,只是它仅在作用域内部可见,若在外部直接调用该变量,会找不到。如下图。

C++学习笔记(上)_第72张图片

因此,当你需要使用可继承数据的变量而不希望别人,或别的作用域能看见该变量时,可以使用局部静态变量。

C++枚举(enumeration)

枚举简称enum,它是给一个值命名的方法,它还能将一组数值集合作为类型,而不仅仅是一个整型作为类型,用整数表示某些状态或数值时,为他们指定一个名称可以使代码更便于阅读。

建立一个程序

int A = 0;
int B = 1;
int C = 2;

int main()
{
	int value = B;

	if (value == B)
	{
		// Do something
	}
}

我们希望在某组数据范围内,比如A,B,C,当目标值等于他们中的一个时,触发目标动作,但如果我们像上述代码这样定义,当取值为5时,也无法检测出来,这个取值就没有任何意义。我们希望这个取值是有意义的,此时用枚举就会指出无意义的数据,如下代码:

enum Example
{
	A, B, C
};
int main()
{
	Example value = E;

	if (value == B)
	{
		// Do something
		std::cout << "1";
	}
}

给value的赋值不在ABC范围内,出现报错如下图:

 将E改为B,运行成功:

注意,枚举定义里显示的只可以是整型数据的名字,如果我们将if语句中的判断条件改为:

if (value == 1)

 同样会运行成功并输出1,我们把鼠标移到定义EXAMPLE中的B上,可以看到它的数值就是1

C++学习笔记(上)_第73张图片

 同样的,按顺序,A的值是0,C的值是2,这是默认的枚举定义,定义中的值从0开始,依次递增,可以更改默认值,如下

C++学习笔记(上)_第74张图片

C++学习笔记(上)_第75张图片

 我们也可以为枚举中的整型数据定义数据类型,这不会改变它是整型数据,因为unsigned char实际上就是8位的整型数据 ,这只会改变它所占用的内存大小,比如本例中不需要使用默认int的32位的整型,我们设定unsigned char,则此枚举中的每个整型数据占有的内存为8位。但不能使用float或double。

enum Example : unsigned char
{
	A = 4, B, C =7
};

 在C++类章节中创建的log类就可以很好的应用枚举,使代码简洁干净。

在那一章中我们创建了三个日志级别,并赋值

class Log
{
public:
	const int LogLevelError = 0;
	const int LogLevelWarning = 1;
	const int LogLevelInfo = 2;

应用枚举:

class Log
{
public:
	/*const int LogLevelError = 0;
	const int LogLevelWarning = 1;
	const int LogLevelInfo = 2;*/
	enum Level
	{
		LogLevelError = 0, LogLevelWarning , LogLevelInfo
	};
private:
	Level m_LogLevel = LogLevelInfo;

除了使代码更简洁,枚举比之前的数据定义方法还多了一层作用,即当给Level型变量m_LogLevel赋值不是Level定义中的三个日志等级的名称或数值时,会提示错误。因此,枚举很适合需要限定范围的场景。

同样的,可以在setLogalLevel中将传入参数的类型int改为Level来限制传入参数的范围,当超过范围会报错。

public:
	void setLogLevel(Level LogLevel)
	{
		m_LogLevel = LogLevel;
	}

构造函数(constructor)和折构函数(destructor)

构 造 函 数

构造:用以分配类空间,并初始化类的成员变量

我们创建一个Entity类,如下:

class Entity
{
public:
	float x, y;

	void Print()
	{
		std::cout << "x = " << x << " y = " << y;
	}
};
int main()
{
	Entity e;
	// std::cout << e.x << std::endl;
	e.Print();

}

可以看到,未初始化x,y的情况下它们的值。若我们将被注释的行去掉双斜杠,再编译,会报错我们未进行初始化。这是对类中变量初始化的简单展示。

那么,如果我们想要将变量x,y的值都初始化为0,就需要添加初始化语句,比如,我们创建一个Entity类中定义的函数Init

void Init()
{
	x = 0.0;
	y = 0.0;
}

再调用该函数,则上面的两种打印方法都可以成功打印出x,y为0

Entity e;
e.Init();

然而,我们每次要创建一个新的实体,都要调用该方法,这会是相当多额外的代码量。这时,我们可以使用构造函数。

构造函数是一种特殊类型的方法,它在类内被定义,每次你实例化一个新的对象时它都会被调用,省去了每次调用初始化数据方法的代码量。它没有返回类型,并且它的名称必须与类的名称相同,比如写此Entity类的构造方法如下:

Entity()
{
	x = 0.0;
	y = 0.0;
}

Init()方法被构造函数取代了,运行

 其他某些语言如Java中,数据基本类型为如int,float的变量,会自动初始化为0,但C++中的所有基本类型都要进行手动初始化,否则变量会被初始化为留在该内存中的其他的值。

只要构造函数中的参数不一样,我们可以在一个类中定义多个构造函数(函数重载,即有相同函数或方法名[同域]下,但是有不同参数的不同函数版本)。比如我们又定义了一个参数为两个参数的构造函数。但是因为要传入参数,所以该构造函数也需要在main函数中调用以写入参数值。比如我们在声明Entity e时加上括号,传入两个float参数。

Entity(float x, float y)
{
	X = x;
	Y = y;
}
Entity e(10.0f, 5.0f);

结果如下图,因为传入的参数不再是空参数,所以如此声明e时发现x,y并没有被初始化为0。可以看出,直接声明e,Entitiy e; 语句时自动调用了空参数构造函数,即Entitiy e; 语句的效果相当于Entitiy e(); 语句。但是我们发现执行Entitiy e();该语法实际上会报错,所以无参数构造函数是声明e但不带参数时默认调用的,无需写该语句。

折 构 函 数

与构造函数一样是特殊的函数(方法)。创建语法是在类名前加波浪号(tilde),同样无返回值。

构造函数在对象被创建时自动调用,而折构函数在对象被销毁时自动调用,手动调用是可以的,但是手动调用和其他非特殊函数一样只是执行一次其中的代码,所以手动调用一般意义不大。

创建一个Obj类和一个Fun函数,如下。

class Obj
{
public:
	Obj()
	{
		std::cout << "a Obj_entity is created" << std::endl;
	}
	
	~Obj()
	{
		std::cout << "a Obj_entity is destryed" << std::endl;
	}
};

void Fun()
{
	Obj o;
	std::cout << "destructor" << std::endl;
}

在main中调用Fun函数,结果如下

 在Fun函数中创建Obj类的实体o,则在Fun作用域外,即Fun函数执行完(执行到Fun下面的花括号)后,e实体销毁,此时自动调用折构函数。不用main测试是因为,main执行完就会退出程序,无法观察折构函数的结果。

C++继承,虚函数与纯虚函数

C++中有三种可见性

private:类内与友元可见,类外不可见
protected:继承体系可见,类外不可见。
public:可见

若在基类中定义了函数和变量,在可见性设置为protected时,它不能被别的作用域看见,但可以被它派生的子类看见和调用,而private的函数和变量不能被子类看见或调用。

继 承:

继承允许我们有一个相互关联的类的层次结构。即从最初的父(基)类创建(派生)子类。

它可以帮助我们避免代码重复。把类之间的所有公共功能放在一个父类中,然后从基类创建一些类,在子类中可以引入全新的功能,或稍微改变之前的功能。父类类似一个类的模板。

创建一个Entity父类,并让一个Player子类继承,代码如下。

class Entity
{
public:
	float X = 0 , Y = 0;

	void Move(float xa, float ya)
	{
		X += xa;
		Y += ya;
	}
};

class Player : public Entity
{
public :
	const char* Name;

	void PrintName()
	{
		std::cout << Name << std::endl;
	}
};

int main()
{
	Player player;
	player.Move(5, 5);
	player.X = 2;
	std::cout << "x:" << player.X << ",y:" << player.Y;
}

运行结果如下

C++学习笔记(上)_第76张图片  

可以看出,任何在Entity类中不是私有的变量或方法,都可以被Player直接访问(父类是子类的子集,所以Player总是Entity的超集)。Player拥有Entity的一切,所有需要用Entity的地方都可以用Player替代即便一个函数是接受Entity作为参数的,可以传入Player对象到相同的函数中。

虚 函 数:

虚函数允许我们在子类中重写方法。

如下,Player类是Entity类的子类。

#include 
#include 

class Entity
{
public:
	std::string GetName() { return "Entity"; }
};

class Player : public Entity
{
private:
	std::string m_Name;

public:
	Player(const std::string& name)
		: m_Name(name) {}	//[冒号后是变量列表,括号内是赋的值,不同成员的初始化通过逗号隔开]

	std::string GetName() { return m_Name; }
};

int main()
{
	Entity* e = new Entity();
	std::cout << e->GetName() << std::endl;

	Player* p = new Player("Tom");
	std::cout << p->GetName() << std::endl;
}

分别实例化Entity和Player对象,运行它们的GetName函数,运行结果如下

C++学习笔记(上)_第77张图片

是我们预期的结果。但如果我们使用多态的概念,那么就会出现问题。例如我们指向一个Player,就像它是Entity一样:

Entity* entity = p;
std::cout << entity->GetName() << std::endl;

虽然我们使用的是Entity指针,但它指向的是Player类的一个实例,所以我们期望它输出的是Tom而不是运行图第三行的Entity

再举一个例子,创建一个接收Entity指针参数的方法

void PrintName(Entity* entity)
{
	std::cout << entity->GetName() << std::endl;
}

int main()
{
    Entity* e = new Entity();
	PrintName(e);
	
	Player* p = new Player();
	PrintName(p);
}

我们同样希望第二行会输出Tom。但结果仍是Entity。

发生上述两种情况的原因是,通常我们声明函数时,我们的方法在类内部起作用,使用方法时调用属于该类型的方法。PrintName函数接收的参数是Entity,那么调用该函数时,它会从Entity类中找到名为Get Name的函数。如果我们希望C++认识到我们这里传递的Entity实际上是Player,然后调用Player子类中的GetName方法,我们就需要用使用虚函数。

虚函数引入了动态联编(Dynamic Dispatch),它通常通过v表(虚函数表)实现编译。v表就是一个包含基类中所有虚函数的映射的表,这样我们就可以在它运行时,将他们映射到正确的覆写(override)函数。如本例中,在Entity中的方法中标记virtual就可以在Player类中重写该方法:

class Entity
{
public:
	virtual std::string GetName() { return "Entity"; }
};

class Player : public Entity
{
private:
	std::string m_Name;

public:
	Player(const std::string& name)
		: m_Name(name) {}	//[冒号后是变量列表,括号内是赋的值,不同成员的初始化通过逗号隔开]

	std::string GetName() override { return m_Name; }
};

int main()
{
	Entity* e = new Entity();
	PrintName(e);
	
	Player* p = new Player("Tom");
	PrintName(p);
}

如上,在Player中的Get Name()后加上override关键字(C++11)即可重写(override)该方法,运行结果如下:

 有两种与虚函数相关的运行成本:(1)额外的内存存储v表(2)每次调用虚函数时,需要遍历v表来确定要映射到哪个表。

纯 虚 函 数:

纯虚(pure virtual)函数在别的语言中也叫接口,但在C++中并没有interface关键字,它仍属于类的范畴。

纯虚函数也可以叫抽象函数,一般来说它只有函数名、参数和返回值类型,不需要函数体。 纯虚函数是一种特殊的虚函数, 在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。若某类中定义了纯虚函数,它的对象将永远不能实例化,它的存在就是为了在子类中去重写该函数。它可以增加代码的严谨性和避免错误。

如下定义了几个类,其中第一个类中定义了一个纯虚函数。在后两个类中,对该函数进行覆写。

#include 
#include 

class PrintClassName
{
public:
	virtual std::string GetClassName() = 0; //virtual关键字 加=0表示为纯虚函数
};

class Entity : public PrintClassName 
{
	std::string GetClassName() override { return "Entity"; };
};

class Player : public Entity
{
	std::string GetClassName() override { return "Player"; };
};

void Print(PrintClassName* obj)
{
	std::cout << obj->GetClassName() << std::endl;
}

int main()
{
	Entity* e = new Entity();
	Print(e);
	
	Player* p = new Player("Tom");
	Print(p);
}

若继承了PrintClassName而不对它的纯虚函数进行覆写,编译器会报错,它的存在可以保证代码的正确性。上述代码的运行结果如下图。

C++数组

数组是同类型数据的集合,其本质主要与指针的使用有关

主要分为栈上创建数组和堆上创建数组,主要区别是它们的生存周期不同。栈上创建数组在其作用域外自动销毁,如在Log函数中创建数组,那么在Log函数结束该数组会自动销毁。

栈上创建数组如下

Debug模式为X64时example的输出的结果为16位16进制数(64bits),而X86下为8为16进制数。

example的实质是一个指针,而[ ]方括号中的数字代表偏移量,为[0]时代表example指向的内存本身,内存中的数据为数组的第一个数。

int main()
{
	int example[5]; //栈上创建
	for (int i = 0; i < 5; i++)
	{
		example[i] = 0;
	}
		int* ptr = example;
		example[0] = 1;
		example[4] = 5;
		std::cout << example << " " << ptr << std::endl;
		//*(ptr + 2) = 3;效果等于下一行的语句 //指针类型为int,偏移量增加根据类型增加2*int(4bytes)大小内存
		*(int*)((char*)ptr + 8) = 3;
		std::cout << example[2] << std::endl;
}

上述代码的运行结果如下图所示

C++学习笔记(上)_第78张图片

 堆上创建数组在整个程序结束运行之前都会存在于内存中,需要手动销毁需用 delete[] 关键字销毁。代码于运行结果如下。

C++学习笔记(上)_第79张图片

如果你要返回一个数组,而该数组在函数中被创建,你要在堆上创建该数组(new一个),否则可能面临该数组内容提前销毁的问题。

而若创建一个类,然后在该类中用两种不同方式创建数组

class Entity
{
public:
	int example[5]; //栈上创建

	Entity()
	{
		for (int i = 0; i < 5; i++)
		{
			example[i] = 3;
		}
	}
};

栈上创建的数组在Entity内存中显示为33333 [其他内容]....而堆上创建的数组在Entity内存中是不规则十六进制数,实际上它们反过来就是一个内存地址,访问该地址会得到33333[其他内容]....而像这样内存跳跃容易出现一些内存问题和影响性能,所以一些情况下要避免在堆上而选择在栈上创建内存。

C++11标准库中的std::array数组(内置的数据结构)相比原始数组更安全,且有许多原始数组没有的优点,如边界检查,记录数组大小。代码如下,.size()函数可以返回数组的大小。

C++学习笔记(上)_第80张图片

C++字符串及其字面量

字符串实际上是字符数组。

C++学习笔记(上)_第81张图片

如上,如果确定一个字符串的值,可以用const修饰 ,而以下代码不用const char*建立的字符串可以正常被改变其中字符

int main()
{
	char Name[5] = "cuze"; //const修饰的变量不能改变
	Name[2] = 's';
	std::cout << Name << std::endl;
}

依然用const char*表示字符串,访问该字符数组的内存,得到的数据是文本“cuze”的ASCII码值。

启动程序,cuze字符串被打印出来,既然是指针,程序如何知道它何时终止呢?指针会依次访问字符,当访问到数据0时,它就会自动终止,0即字符串的终止符,该终止符是""定义时自动生成的。

C++学习笔记(上)_第82张图片

 如上图,以一个一个字符创建Name2,且不设置终止符时,会得到一大串ascii码对应的字符

而写上终止符'\0'(反斜杠0)或0'\0 的真实值,在ASCII码表中表示null',会判断终止并打印字符串

char Name2[5] = { 'T','o','m','e','\0'};

C++11标准库中同样有模板类std::string,模板参数为char。string有一个构造函数,它接收char*或const cahr*作为参数。

获得字符串的长度如下

	const char* Name = "cuze"; 
	std::string Name3 = "Steph";

	std::cout << "lengthOfName:" << strlen(Name) << std::endl;
	std::cout << "lengthOfName3:" << Name3.size() << std::endl;

 +=操作符在string类被重载了,可以使两个字符串相连,如下

 也可以创建一个字符串,如:

 要寻找某字符串是否在Name3中,可以用下列语句,代码和结果如下

bool contains = Name3.find("lo") != std::string::npos; 
//std::string::npos代表的是一个不存在的位置,Name3.find("lo")将返回lo字符开始的位置
std::cout << "is 'lo' in Name3? ->" << contains << std::endl;

 输入下列语句,发现“lo”从字符数组Name3的下标10开始

 当我们需要调用字符串时,不会使用如下简单的引用:

void PrintString(std::string SomeString){ }

因为传入参数为std::string时,传入的实际上是std::string的副本,当你把这个类(对象)传递给一个函数时,你是在复制这个对象。在函数中添加如下的语句时,并不会影响到传递的原始字符串。

void PrintString(std::string SomeString)
{
	SomeString += ",nice";
	std::cout << SomeString << std::endl;

}

结果如下,原字符串并没有被改变。只是将参数值分配到一个全新的char数组来存储该文本。

所有我们若需要一个如上的只读函数(不修改字符串参数的值),可以通过常量引用传递它,节省性能(字符串操作经常被使用,总是复制参数消耗较大)。如下,类型前加const,类型后加&:

void PrintString(const std::string& SomeString)
{
	//SomeString += ",nice"; //常量引用传入参数时,加此语句会发生错误
	std::cout << SomeString << std::endl;
}

告诉编译器它是一个引用,即承诺我们无需修改参数,也就无需进行复制。

传入指针就可以修改原变量的值了,如下。

void PrintString(std::string* SomeString)
{
	(*SomeString) += ",nice";
	std::cout << *SomeString << std::endl;
}
int main()
{
    PrintString(&Name3);
	std::cout << Name3 << std::endl;
}

这里可以再次发现引用是包装后的代码简洁的指针,引用的代码及运行结果如下。

C++学习笔记(上)_第83张图片

 字 符 串 字 面 量:

字符串字面量永远保存在内存的只读区域。

char *p = "hello"; // p是一个指针,直接指向常量区,修改p[0]就是修改常量区的内容,这是不允许的。
char []  = "hello"; // 编译器在栈上创建一个字符串p,把"hello"从常量区复制到p,修改p[0]就相当于修改数组元素一样,是可以的

C++学习笔记(上)_第84张图片

 如上,hello有五字符,但显示6长度,这是因为'\0'终止符占一个字符但不显示,写一个"hello\0"字符串显示的也是"hello",但却占7字符,如下

C++学习笔记(上)_第85张图片

 关于终止符对字符串判定的影响,如下图,用strlen(A)检测长度结果与size相同

C++学习笔记(上)_第86张图片

 字符数组定义为const char* A类型或char* A类型时不可被修改。但定义为char a[]时可以

C++学习笔记(上)_第87张图片

 原因是用数组时是开辟了一个新空间存放字符串,而用指针是在存放"hello"常量的内存上进行操作,常量不可被更改。字符串字面量永远保存在内存的只读区域,复制到新空间才能修改。

char是单字节的字符类型,同时还有一种宽字符类型:wchar_t*(我的理解是type),定义的该类型字符串前要加大写L,表示该字符串由宽字符组成。同时,还有char16_t,char32_t,分别在字符串前用u和U表示,特别的,普通char类型可以用u8进行强调,如下

	const char* A = u8"hello";
	const wchar_t* B = L"Hi";
	const char16_t* C = u"Hi";
	const char32_t* D = U"Hi";

下面的语句会发生报错,两个指针变量不能相加

	std::string str = "ab" + "cd";

通常使用如下语句可以使两个字符串相连,因为第一个指针变量被转为字符串类实体。

std::string str = std::string("ab") + "cd";

使用命名空间literals会更简单,表示如下,只需加后缀s。

using namespace std::string_literals;
std::string str2 = "ab"s + "cd";

若为双字节,需加L前缀,string前加w,同时输出的时候需使用wcout,更高字节长度的字符类比。如下,但u16和u32无法正确输出。

	std::wstring str3 = L"oj"s + L"bk";
	std::wcout << str3 << std::endl;
    std::u16string str4 = u"oj"s + u"bk";//无法正确输出

不加转义字符时,多字符串字面量在一起定义结果在同一行,如下。

 加转义字符换行的结果与加前缀R然后在不同行定义字符串作用相同,前缀R的作用是忽略字符,如下。

C++学习笔记(上)_第88张图片

const与mutable

const(constant)有两层含义:编译器常量和只读变量。

包含const的定义意为着承诺该变量定义后不会被改变,但该承诺也是可以绕过的,比如const int,视作一个变量类型,它可以进行强制类型转换,如下。

    const int MAX_AGE = 100;
	int* a = new int;
	*a = 2;
	a = (int*)& MAX_AGE;

常量指针(const int*)(int const*)(可以理解为指向某个常量的指针,指向的数据是常量因此不可修改)。如下,在a指针的定义前加const关键字可以看到第16行语句报错,但第17行没有报错,也就是说const指针指向的地址可以改变,但不能修改它指向的内容(即不能让它重新申请内存)。

C++学习笔记(上)_第89张图片

指针常量(int* const)(可以理解为指针本身是一个常量,即指针代表的内存不可更改)。它的作用与指针常量恰好相,指针的指向的地址不可改变,但它指向的内存中的数据可以修改。它与常量指针的判断通过const后跟的是指针还是变量就可以判断,跟指针时指针数据(内存)不可改,跟变量,指针内存中的数据不可改。

C++学习笔记(上)_第90张图片

接下来介绍类中的const,在类中的定义的方法后面加上const变量,这意味着我们不能修改类成员变量(this指针指向一个常量对象),只能读取,我们若在该方法中输入修改m_X的语句会报错。

class Entity
{
private:
	int m_X, m_Y;
public:
	int GetX() const
	{
		return m_X;
	}
};

但如果我们的方法要修改成员变量的值,该方法就不适合加上const关键字了,所以const一般用在Get方法这种只读取数据的方法上。

参数为const Entity* e,即一个常量指针,指向常量的指针,指针本身可以修改,指向内存中的值不可修改

void PrintEntity(const Entity* e)
{
	e = nullptr;
}

若是引用const Entity& e,虽然你只是它的引用,但你就被视为是它本身,所以不能更改e的值。

C++学习笔记(上)_第91张图片

 常对象只能调用常函数,GetX()函数已有const的声明,若去掉则不能在传入const  Entity* e参数的函数中调用。因为引用的是不能修改成员变量的参数,但若调用的方法不是定义为const 的方法,意味着这个方法中可能会有修改成员变量的行为,所以报错。

void PrintEntity(const Entity* e)
{
	e = nullptr;
	std::cout << e->GetX() << std::endl;
}

mutable允许函数是常量方法,但可以修改变量,它需要被用在我们既要声明函数是const的,但又要修改其中变量时使用,比如下列代码中本来不可被改变的m_Z就可以被改变了。

class Entity
{
private:
	mutable int m_Z;

public:
	int ChangeZ() const
	{
		m_Z = 2;
		return m_Z;
	}
};

接下里展示mutable(中文可变的,易变的)的用法:

1,修饰class const方法中class成员变量,使其可以修改。如上面的例子。
2,修饰lambda表达式,值捕获时可以直接操作传入参数。(并非引用捕获,依旧值捕获,不修改原值),如下面的例子。

C++学习笔记(上)_第92张图片

[ ]里=是值引用,&是参数引用。值引用x,可以看到不能修改x的值,要做到这件事,只能读取x的数据到另一个局部变量,如下

    int x = 7;
	auto f = [=]()
	{
		int y = x;
		y++;
		std::cout << "x = " << x << std::endl;
	};

然而加上mutable关键字具有相同效果的同时可以简化代码,即不用再定义一个中间变量。

C++学习笔记(上)_第93张图片

 可以看到本质也是在lambda中将x的值复制,并没有改变原x变量的值

创建C++对象与new关键字

通常有两种创建C++对象的方式:在栈上创建和在堆上创建

栈上创建:      优点:自动化创建,便捷,性能上更有优势,占用内存和时间更少

                        缺点:内存较小,不利于大规模使用的情况,需要注意在作用域外会自动销毁

堆上创建:      优点:适应大规模使用的情况,可以显式地控制对象的生存期

                        缺点:消耗更大,忘记手动释放被分配的内存会造成内存泄漏

不需要再堆上创建时,一般选用在栈上创建。下面举一个分别创建的代码示例

#include 
#include 

using String = std::string;

class Entity
{
private:
	String  m_Name;
public:
	Entity() : m_Name("Unknown") {  }
	Entity(String name) : m_Name(name) {  }

	const String& GetName() const { return m_Name; }
};

int main()
{
	//栈上创建对象
	Entity e1; 
	Entity e2("Celine"); 
	std::cout << e1.GetName() << std::endl;
	std::cout << e2.GetName() << std::endl;

	//堆上创建对象
	Entity* e3 = new Entity("Yoda");
	std::cout << e3->GetName() << std::endl; // |(*e3).GetName()
	delete e3;

	//堆上创建对象
	Entity entity = Entity("Jerry");
	Entity* e4 = new Entity;
	e4 = &entity;
	std::cout << (*e4).GetName() << std::endl;
	//delete e4; 加此行会报错,应该是堆上创建对象指针指向栈上对象后删除该指针造成的内存问题
}

关于加上delete e4的报错,应该是堆上创建对象指针指向栈上对象后删除该指针造成的内存问题。错误如下, "debug assertion failed" 通常出现在程序运行时出现了错误,需要进行调试和修复。

C++学习笔记(上)_第94张图片

new 关 键 字:new时必须使用指针的主要原因是,new中自带malloc()函数,返回的是分配的内存的首地址,需要一个指针指向这个首地址。

使用new的主要目的是在堆上分配内存

    int* b = new int[50]; 在堆上分配50个int(50*4字节)大小的内存
    Entity* c = new Entity[50]; 在堆上分配50个Entity类大小的内存

比如一个 new int 语句,首先要找到一个连续四字节的内存块,找到后,它会返回一个指向该内存块的指针,然后就可以使用该数据,进行读取访问等操作,可以看到步骤较多,所以比较占用资源。如何找到该内存?有一个叫空闲链表的东西,它会维护那些有空闲字节的地址,比一行行寻找要快,不过仍然比在栈上分配内存要慢。

new一个类除了为它内存,同时还会调用该类的构造函数。即

	Entity* n = new Entity();

语句相当于在

Entity* n = (Entity*)malloc(sizeof(Entity)); //不建议在C++中使用该语句

语句的基础上还调用了类中的构造函数。

一定要记住的是,为了避免内存泄漏,一定要在每个new之后加上对应的delete关键字。delete的定义中调用了c函数free,free可以释放malloc申请的内存。如果不delete,该内存块不会回到空闲链表中,也就不能被再次分配,直到我们调用delete。

使用new [ ] 分配数组时,对应的使用delete[],如下。

	int* b = new int[50];
	Entity* c = new Entity[50];
	delete[] b;
	delete[] c;

也有一些让delete操作自动化的策略,比如基于作用域的指针,引用计数等

C++运算符重载以及this关键字

运算符是一些符号,通常用来代替函数执行一些功能。不仅仅有数学运算符,还有逻辑运算符,逆向引用运算符(*)(dereference),箭头运算符(->),用于内存地址的&运算符,左移运算符<<(用于cout打印),还有写看起来不像运算符的运算符,如new和delete,逗号,圆括号等,运算符的b本质是函数,它的定义有参数传递,函数体和返回值,但它的调用通常不像函数那样后面带括号。

运算符的重载:重载运算符赋予其新的含义,或是增加参数,或是创造新功能等。运算符重载在Java等语言中并不被支持,在C#中部分支持(较好的部分被支持),但在C++中可以完全控制。运算符重载在自己写库时可能会用到。

下面重写四个符号+ * << == !=,如下:

#include 

struct Vector2
{
	float x, y;

	Vector2(float x, float y)
		: x(x), y(y) {}

	Vector2 Add(const Vector2& other) const
	{
		return Vector2(x + other.x, y + other.y);
	}

	Vector2 operator+(const Vector2 other) const
	{
		return Add(other);
	}
	
	Vector2 Mutiply(const Vector2& other) const
	{
		return *this * other;
		//return operator+(other);
	}

	Vector2 operator*(const Vector2& other) const
	{
		return Vector2(x * other.x, y * other.y);
	}

	bool operator==(const Vector2& other)
	{
		return x == other.x && y == other.y;
	}

    bool operator!=(const Vector2& other)
	{
		return !(*this == other);
	}
};

std::ostream& operator<<(std::ostream& stream, const Vector2& other)
{
	stream << other.x << "," << other.y;
	return stream;
}

int main()
{ 
	Vector2 position(4.0f, 4.0f);
	Vector2 speed(0.5f, 1.5f);
	Vector2 powerup(1.1f, 1.1f);

	Vector2 result1 = position.Add(speed.Mutiply(powerup));
	std::cout << "x1 = " << result1.x << ",y1 = " << result1.y << std::endl;

	Vector2 result2 = position + speed * powerup;
	std::cout << "x2 = " << result2.x << ",y2 = " << result2.y << std::endl;

	std::cout << "result1: " << result1 << std::endl;
	if (result1 == result2) std::cout << "Equal" << std::endl; else std::cout << "NotEqual" << std::endl;
}

可以看到,此处的重载只是在传入参数类型不合法(如  + 一般参数为整型,浮点型等参数)时,添加对于该参数的结构体。比如增加一个传入参数类型为Vector2的函数体定义,则可以对两个Verctor类型进行+操作。

可以看到Multiply的定义中使用了this关键字,它有什么作用呢?

t h i s:

通过this,可以访问成员函数(属于某个类的函数或方法)。this对象存在就不用每个对象都要划分空间去保存函数的声明和定义,只需保存一次,然后传入this即可。

this是一个指向当前对象实例的指针

C++学习笔记(上)_第95张图片

如上图,const代表GetX函数不会对GetX所在的类进行修改,此时对this关键字的引用也是const的,所以它赋给的变量也应该是const的,如下:

    int GetX() const
	{
		const Entity* e = this;
	}

另一个需要用到this的场合是,我们想在这个类内调用在这个类之外定义的函数,使用this可以传入当前的对象实例,如下:

class Entity
{
public:
	int x, y;

	Entity(int x, int y)
		//: x(x), y(y)
	{
		this->x = x;
		this->y = y;

		PrintEntity(this);//(*this)
	}
};

void PrintEntity(Entity* e)//(const Entity& e)
{
	//print
}

C++的栈作用域生存期与智能指针

栈作用域上变量在栈销毁后就会销毁,而堆上始终存在,我们可以通过作用域指针使堆上变量自动化创建(new)和销毁(delete)。

作用域指针是一个类,一个指针的包装器,在构造时用堆分配指针,折构时删除指针,unique_ptr就是库中自带的作用域指针,但是此例中我们要自己写一个ScopePtr类

class Entity
{
public:
	Entity()
	{
		std::cout << "Created" << std::endl;
	}

	~Entity()
	{
		std::cout << "Destryed" << std::endl;
	}
};

class ScopePtr
{
private:
	Entity* m_Ptr;

public:
	ScopePtr(Entity* ptr)
		: m_Ptr(ptr)
	{
	}

	~ScopePtr()
	{
		delete m_Ptr;
	}
};

int main()
{
	{
		//Entity* e = new Entity();
	}
	{
		ScopePtr p = new Entity();
		//隐式转换,正常用构造函数ScopePtr p(new Entity());
	}
}

可以看到虽然Entity是用new在堆上分配的,但是它仍然在作用域结束后销毁,就是因为我们在自己在栈上创建的ScopePtr类实例p中创建了该Entity实例。

智 能 指 针:

可以自动化new和delete,防止遗忘写delete产生内存问题。比如unique_ptr(作用域指针),如果复制一个unique_ptr,两个unique_ptr指向同一个内存块,一个unique_ptr结束后,它会释放指向的内存,则第二个指针指向同一个内存块的指针指向的是已经释放的内存,所以复制unique_ptr是不允许的。接下来看一个unique_ptr的例子(需要include memory头文件),make_unique是C++14引入的特性,否则要用被注释语句,但被注释语句的new没有make_unique的异常安全的特性。

int main()
{
//smart pointer
    {
    std::unique_ptr entity = std::make_unique();
	//std::unique_ptr entity(new Entity());
    } //<>内是类模板,此为Entity类
}

与unique_ptr相对的是共享指针shared_ptr,shared_ptr需要分配另一块内存,叫做控制块,用来存储引用计数。它的工作方式是通过引用计数(reference counting),可以追踪你的指针有多少次引用,一旦引用计数达到0,它就被删除了。比如创建了一个shared_ptr,然后又将它复制给另一个创建的shared_ptr,引用计数就为2,一个指针结束,计数为1,另一个也结束,此时为0,内存被释放。用make_shared比new更有效率。

    {
		std::shared_ptr e0; //外层作用域,该作用域结束后内存释放
		{ //内层作用域,该作用域结束后,内存还没有被释放
			std::shared_ptr sharedEntity = std::make_shared();
			e0 = sharedEntity;
		}
	}

如上的代码,内层作用域内的语句执行完成后,Created显示,外层结束后,Destroyed才显示。

可以与shared_ptr一起使用的是weak_ptr弱指针,它与shared_ptr做相同的事,但它不会增加引用计数,当你只想要存储某类实例的一个引用时,它就会有用,因为它不会改变引用计数,所以它不会影响某类实例的存亡,不会底层对象保持存活。

优先使用unique_ptr,需要共享该类实例时,选择shared_ptr,需要显式的管理内存时,选择new delete,按此顺序选择,也可以使内存管理更有逻辑性。

复制与拷贝构造函数

本节主要讲述什么时候该和不该复制,与怎样避免不必要的复制(const reference),自创一个String类,然后创建该类的first 和 second 实例,代码如下:

#include 

class String
{
private:
	char* m_Buffer;
	size_t m_Size;

public:
	String(const char* string)
	{
		m_Size = strlen(string);
		m_Buffer = new char[m_Size + 1]; //加一是因为终止符也需要占一位,手动增加终止符也可以,即m_Buffer[m_Size] = 0;
		
		/*for (int i = 0; i < m_Size; i++)
		{
			m_Buffer[i] = string[i];
		}*/
		memcpy(m_Buffer, string, m_Size + 1); //加一是因为终止符也需要占一位
	}

	~String()
	{
		delete[] m_Buffer;
	}

	friend std::ostream& operator<<(std::ostream& stream, const String& string); //将该重载运算符的定义在类中声明为友元,则它的具体定义中可以引用类中的私有成员变量

};

std::ostream& operator<<(std::ostream& stream, const String& string)
{
	stream << string.m_Buffer;
	return stream;
}

int main()
{
	String first = "Heap Stack";
	String second = first;
	std::cout << first << std::endl;
	std::cout << second << std::endl;

	std::cin.get();
}

其中,给second实例赋值为first实例,运行出错。

C++学习笔记(上)_第96张图片

 first中所有成员变量都被复制到second中,这种直接复制另一个类实例的复制为浅拷贝,m_Buffer是指针类型,这个m_Buffer的内存地址对这两个String类对象来说是相同的,所有程序会崩溃,这是因为当达到其中一个作用域的末端时,触发折构函数的delete释放该实例所占的内存块,该内存已经被释放,程序不能释放一个已经被释放过的内存。浅拷贝不会深入到指针指向的内容,因此不能完成正确的复制类实例的操作。

 要使复制出的类实例也拥有属于自己成员指针变量的唯一内存块,需要实现深度复制(深拷贝),即真正的“复制”整个对象,具体的实现可以依靠拷贝构造函数来完成,拷贝构造函数是构造函数的一类,当你复制第二个同类的对象实例时,它会被调用。

C++在默认情况会提供一个拷贝构造函数,拷贝函数的函数签名,对同样的类的对象的常引用const&,因为要拷贝,它的参数就是同类类型的另一个实例,如下:

    String(const String& other)
	{
		memcpy(this, &other, sizeof(String));
	}

它所作的就是内存复制,将other所有成员变量的内存浅层拷贝进目标对象。如果我们决定不需要拷贝构造函数,不允许复制时,可以将这个拷贝构造函数声明为delete,如下:

	String(const String& other) = delete; 
    //定义为删除即代表不允许复制,复制会报错

但是仅仅依靠默认的拷贝构造函数在此情况不行,因为此例中我们为了完成“复制”,需要复制指针指向内存的内容,而不是指针本身。所有我们需要自己定义拷贝构造函数,完成深拷贝的操作。

    String(const String& other)
		: m_Size(other.m_Size) //非指针变量同样可以使用变量初始化列表完成复制
	{
		m_Buffer = new char[m_Size + 1]; //先分配与被复制的原实例指针相同的内存块
		memcpy(m_Buffer, other.m_Buffer, m_Size + 1); 终止符也要复制进去,所以+1
	}

如上的代码后,  String second = first; 的操作就不会出现释放已释放内存块的内存问题了。如下:

class String
{
    ......
    char& operator[](unsigned int index)
	{
		return m_Buffer[index]; 
	} //重载[]运算符这样可以在直接使用second[2] = 'm'时可以执行
}

int main()
{
	String first = "Heap Stack";
	String second = first; 
	second[2] = 'm';

	std::cout << first << std::endl;
	std::cout << second << std::endl;
}

但是因为拷贝构造函数的存在,我们非引用传递自己定义的String类参数的行为,都会造成一次复制,同时也会在执行一次构造和折构函数,比如如下的PrintString函数:

void PrintString(String string)
{
	std::cout << string << std::endl;
}

这是没必要的浪费,也可能造成不好的效果,所以当我们不需要复制该类,仅需要该类的一个副本时,需尽量使用常量引用(const reference)传递参数,即将String string改为const String& string。若它没有标记为const,不仅意为着它可以被编辑(如string[2] = 'q'),也意为着我们可以将临时的右值传递到实际的函数中,所以,在基础使用时,总是用const更好。

本节全部代码如下:

#include 
#include 

class String
{
private:
	char* m_Buffer;
	size_t m_Size;

public:
	String(const char* string)
	{
		m_Size = strlen(string);
		m_Buffer = new char[m_Size + 1]; //加一是因为终止符也需要占一位,手动增加终止符也可以,即m_Buffer[m_Size] = 0;
		
		/*for (int i = 0; i < m_Size; i++)
		{
			m_Buffer[i] = string[i];
		}*/
		memcpy(m_Buffer, string, m_Size + 1); //加一是因为终止符也需要占一位
	}

	/*默认拷贝构造函数
	String(const String& other)
	{
		memcpy(this, &other, sizeof(String));
	}*/

	//String(const String& other) = delete; 定义为删除即代表不允许复制,复制会报错

	String(const String& other)
		: m_Size(other.m_Size) //非指针变量同样可以使用变量初始化列表完成复制
	{
		m_Buffer = new char[m_Size + 1]; //先分配与被复制的原实例指针相同的内存块
		memcpy(m_Buffer, other.m_Buffer, m_Size + 1); //终止符也要复制进去,所以+1
	}

	~String()
	{
		delete[] m_Buffer;
	}

	friend std::ostream& operator<<(std::ostream& stream, const String& string); //将该重载运算符的定义在类中声明为友元,则它的具体定义中可以引用类中的私有成员变量

	char& operator[](unsigned int index)
	{
		return m_Buffer[index]; 
	} //重载[]运算符这样可以在直接使用second[2] = 'm'时可以执行

};

void PrintString(String string)
{
	std::cout << string << std::endl;
}

std::ostream& operator<<(std::ostream& stream, const String& string)
{
	stream << string.m_Buffer;
	return stream;
}

int main()
{
	String first = "Heap Stack"; //隐式转换
	String second = first; 
	second[2] = 'm';

	std::cout << first << std::endl;
	std::cout << second << std::endl;

	PrintString(first);

	std::cin.get();
}

箭头操作符

我们将讨论类和结构体的指针可以用箭头操作符做到什么。

 如下代码,对于类指针ptr来说要访问其中成员本来需要逆向引用或需要添加中间变量e1,但通过箭头操作符-> ,可以直接访问,代码可读性更好,使用也更方便。

class Entity
{
public:
	void Print() const { std::cout << "HEY" << std::endl; }
};

int main()
{
	Entity e;
	e.Print();

	Entity* ptr = &e;
	(*ptr).Print();
	Entity& e1 = *ptr;
	e1.Print();
    ptr->Print();
}

我们还可以重载箭头运算符让它可以满足其他我们需要的操作,先写一个ScopePtr类如下:

class ScopePtr
{
private:
	Entity* m_Obj;

public:
	ScopePtr(Entity* entity)
		:m_Obj(entity)
	{
	}

	~ScopePtr()
	{
		delete m_Obj;
	}

	Entity* GetObject()
	{
		return m_Obj;
	}
};

但是要通过ScopePtr类调用Entity中的Print方法较为麻烦,需要再定义一个GetObject方法。

    ScopePtr entity = new Entity();
	entity.GetObject()->Print();

通过重载->运算符,我们希望可以直接使用entity->Print();语句(要在SopePtr类中重载)

	Entity* operator->()
	{
		return m_Obj;
	}

通过上面例子可以看出,通过自己定义ScopePtr和重载运算符,我们可以通过看起来简单的语句实现自动化创建和删除实例,并且更方便地使用自己的类。

接下来演示如何通过箭头操作符获取内存中成员变量的偏移值,建立如下结构体:

struct Vector3
{
	float x, y, z;
};

一个浮点数占四字节,所以理论上x的偏移量为0,y的偏移量为4,z的偏移量为8 ,我们可以通过以下两行语句中的一句完成取成员变量偏移量的操作,如下:

int offset_X = (int)&((Vector3*)0)->x; //类指针从偏移量0到x的偏移量转换为int
int offset_Y = (int)&((Vector3*)nullptr)->y; //nullptr空指针即偏移量为0就是空指针,其他同上

C++动态数组 std::vector 的使用及优化

标准模板库 Standard Template Library,重要的是该库的模板化

C++ STL(标准模板库)是一套功能强大的 C++ 模板类,提供了通用的模板类和函数,这些模板类和函数可以实现多种流行和常用的算法和数据结构,如向量、链表、队列、栈。

C++ 标准模板库的核心包括以下三个组件:

组件 描述
容器(Containers) 容器是用来管理某一类对象的集合。C++ 提供了各种不同类型的容器,比如 deque、list、vector、map 等。
算法(Algorithms) 算法作用于容器。它们提供了执行各种操作的方式,包括对容器内容执行初始化、排序、搜索和转换等操作。
迭代器(iterators) 迭代器用于遍历对象集合的元素。这些集合可能是容器,也可能是容器的子集。

模板化意为它只是一个模板,容器底层的数据类型由自己决定。vector容器装的是数组,但因为它的一端可以无限延长,所以它取名为向量。

先创建一个vertex(顶点)类以及重写它的输出方法,如下:

#include 
#include 

struct Vertex
{
	float x, y, z;
}; 

std::ostream& operator<<(std::ostream& stream, const Vertex& vertex)
{
	stream << "x = " << vertex.x << ",y = " << vertex.y << ",z = " << vertex.z;
	return stream;
}

一般数组创建后始终与它的大小挂钩,比如下面这样,当它的容量达到你创建的顶点,需要手动扩展容量,或是创建时分配足够大的容量(比如10000),但这会占用很多内存,造成内存浪费。

	Vertex* verticies = new Vertex[5];

使用动态数组中的vector容器,注意能使用对象尽量不使用指针,消耗更小,就像栈分配和堆分配,使用栈不能达到要求才用堆一样,代码如下:

	std::vector verticies;
	//std::vector verticies; //带星号不代表存了很多指针,而是在一段连续内存上存储了很多该结构体实例

vector中添加数据用.push_back() 函数,如下为添加代码及其执行结果:

    verticies.push_back({ 1,2,3 }); //初始化数据操作,结构体或者类,可以按成员声明的顺序用列表构造
	verticies.push_back({ 4,5,6 });
	for (int i = 0; i < verticies.size(); i++)
	{
		std::cout << verticies[i] << std::endl;
	}

C++学习笔记(上)_第97张图片

也可以使用增强for循环(底层是迭代器),输出结果相同,代码如下:

    for (Vertex v : verticies) // Vertex类型v : Vertex的Vector类型数组实例
	{
		std::cout << v << std::endl;
	}

但这实际上是将每个vertex复制到这个for循环中,浪费性能,所以可以采用引用来避免复制

	for (Vertex& v : verticies) // Vertex类型v : Vertex的Vector类型数组实例
	{
		std::cout << v << std::endl;
	}

如果想清除整个Vector数组,使用clear即可,如下:

    verticies.clear();

如果我们想清楚该数组中的第某个元素,就需要使用earse

观察如上图的函数签名可以发现erase函数需要传入的参数是迭代器,则若我们想删除第二个元素,需要写下面的代码:

	verticies.erase(verticies.begin() + 1); //begin( ) 函数返回一个指向向量开头的迭代器。

需要注意的是当我们再某个函数中需要调用vector数组作为参数,一定要注意引用传递:

void Fun(const std::vector& verticies) {}
/*引用传递确保不会复制整个vector进来,不需要修改则加上const*/

优化:

优化的前提是我们了解运行的过程。此例代码如下:

#include 

struct Vertex
{
	float x, y, z;
}; 

int main()
{
    std::vector verticies;

	verticies.push_back({ 1, 2, 3 }); 
	verticies.push_back({ 4, 5, 6 });
}

创建一个vector,然后push_back进新的元素,使vector完整,在这个过程中,如果vector的大小不够存储,那么会找到一个新的空间,将旧内存中的内容复制到新内存中,再删除之前的旧空间。不停的重新分配空间是一个缓慢的过程,也是影响vector性能的主要原因之一,因此,我们将从减少复制的角度进行优化。

我们可以创建一个拷贝构造函数,这样可以观察到上面的main中的代码运行时,vector数组的复制情况。同时,设计一个三参数构造函数,方便后续观察,如下:

struct Vertex
{
	float x, y, z;

	Vertex(float x, float y, float z)
		: x(x), y(y), z(z)
	{
	}

	Vertex(const Vertex& vertex)
		: x(vertex.x), y(vertex.y), z(vertex.z)
	{
		std::cout << "another is copyed!" << std::endl;
	}
}; 

仅有一个    verticies.push_back(Vertex(1, 2, 3));  的push_back语句,运行结果如下

有两个 push_back语句,运行结果如下

 三个,如下:

C++学习笔记(上)_第98张图片

 随着push_back语句增加,复制的次数会越来越多。

原因是,我们push_back新元素,即创建新Vertex对象时,我们是在main函数的当前栈帧中创建对象,然后我们再把它复制到Vector数组,这也就是一个push_back语句时产生一个复制的原因。

那么进行第二个push_back语句时,理应只新增一个复制,为什么新增了两次复制呢?原因是,创建新对象前vector数组的容量大小为1,检测到容量不足时,又新分配了一个空间,将第一个元素复制到该空间,再从main栈中将第二个元素复制到该空间,产生了两次复制。

第三个push_back语句同理。创建新对象前vector数组的容量大小为2,检测到容量不足时,又新分配了一个空间,将第一个元素复制到该空间,然后第二个元素复制到该空间,再从main栈中将第三个元素复制到该空间,产生了三次复制。

由此,我们产生了两个减少复制的策略:

(1)在已经知道我们需要三个对象大小的内存空间时,直接告诉vector我们需要它有三个空间,即提前创造三空间大小的内存区域,以避免重新分配内存空间再进行移植产生的额外复制。

	verticies.reserve(3); //reserve:储备,保留。reserve方法可以提前保留确定的内存空间

 添加上述代码后,看到如下的运行结果,减少了三次复制,此时有几个push语句就有几次复制。

 (2)我们不想再main中,而是直接在vector中添加新元素,以避免从main到vector的额外复制,我们需要使用emplace_back(置,放列)来代替push_back(观察push_back词语组成也可以看出它与先在栈中创建然后返回给源容器有关)。

emplace_back() 和 push_abck() 的区别是:push_back() 在向 vector 尾部添加一个元素时,首先会创建一个临时对象,然后再将这个临时对象移动或拷贝到 vector 中(如果是拷贝的话,事后会自动销毁先前创建的这个临时元素);而 emplace_back() 在实现时,则是直接在 vector 尾部创建这个元素,省去了移动或者拷贝元素的过程。

	verticies.reserve(3); //reserve:储备,保留。reserve方法可以提前保留确定的内存空间
	verticies.emplace_back(1, 2, 3);
	verticies.emplace_back(4, 5, 6);
	verticies.emplace_back(7, 8, 9);

注意,参数一定是纯数字(1, 2, 3),而不是构造函数对象(比如Vertex(1, 2, 3))通过emplace_back我们在main中传递的不再是临时对象,而是构造函数的参数列表,它告诉编译器,在vector的内存中使用以下参数,构造一个vertex对象,这样的话,剩下的三次复制也被清除了。通过此两种方法成功优化掉所有的vector复制。

用结构体处理多返回值

当我们需要返回多返回值时,有多种方法。其中,用结构体返回多返回值有很多优点,比如可以清晰地按顺序返回,不用像元组一样写.first,.second。下面是用结构体返回水果及其价格的示例:

#include 

struct Fruit
{
	std::string name;
	std::string price;
};

Fruit BuyApple(std::string name, std::string price)
{
	return { name, price };
}

std::ostream& operator<<(std::ostream& stream, Fruit fruit)
{
	stream << fruit.name << ":" << fruit.price << std::endl;
	return stream;
}

int main()
{ 
	Fruit fruit = BuyApple("Apple", "10");
	std::cout << fruit << std::endl;

	std::cin.get();
}

可以看到,返回的数据类型为Fruit时,直接返回 { name, price } 即可,清晰易读。

(元组及其他处理多返回值的方式待扩充QAQ)

模板

模板类似于java等语言中的泛型,但功能要更强大。它的功能是让编译器根据你制定的规则写代码

下面给出一个例子,比如以下的三个只是参数传递不同的Print函数

void Print(int a)
{
	std::cout << a << std::endl;
}
void Print(float a)
{
	std::cout << a << std::endl;
}
void Print(std::string a)
{
	std::cout << a << std::endl;
}

想要编写一个Print模板就可以同时运行参数不同的函数,可以写如下代码取代上面的函数:

template 
void Print(T value)
{
	std::cout << value << std::endl;
}

然而模板并不是运行代码,模板仅仅是模板,在编译期间调用基于该模板的函数时编译器会基于实际传递的参数创造运行代码,相当于在模板中填空。调用函数时,可以显式或隐式地传递参数,如下:

	//隐式
    Print(5);
	Print(3.3f);
	Print("HI");
    //显式
	Print(5);
	Print(3.3f);
	Print("HI");

typename改为class也可以,但class在这里并没有上面特殊意义,传递int时还是可以编译运行,所以还是用typename。可以隐式传递参数的原因是编译器可以计算出传递参数的实际类型。

模板还可以应用在很多地方,比如创建一个数组类时不指定确定大小无法创建,但利用模板就可以在创建该类的实例时指定不同类型和大小了。代码如下:

template
class Array
{
private:
	T m_array[size];

public:
	int GetSize() { return size; }
};

int main()
{
	Array array;
	array.GetSize();
	std::cout << std::endl << "size:" << array.GetSize() << std::endl;
}

栈和堆的内存对比及查看内存窗口和反汇编

结论:能使用栈创建变量和函数时尽量不使用堆创建,除非遇到创建内存大小较大或需要手动控制对象生存期等场景

源代码如下:

#include 

int main()
{
	int a;
	int value[5];
	value[0] = 10;
	value[1] = 11;
	value[2] = 12;
	value[3] = 13;
	value[4] = 14;

	int* b = new int;
	int* value2 = new int[5];
	value2[0] = 20;
	value2[1] = 21;
	value2[2] = 22;
	value2[3] = 23;
	value2[4] = 24;
	delete b;
	delete[] value2;
}

接下来进行调试。首先了解栈和堆都是ram中的部分,而不是在不同的地方,区别在于栈是连续的内存空间而堆是由系统维护的内存空间空闲链表,即堆中创建可能不是连续地址。在int a; 语句左侧设置断点,点击“本地Windows调试器”左侧绿色启动按钮,启动调试。按下图找到内存视图。

C++学习笔记(上)_第99张图片

在地址中输入&value,按回车键,再按下F10逐过程调试,观察地址变化。如下系列图:
C++学习笔记(上)_第100张图片

C++学习笔记(上)_第101张图片 C++学习笔记(上)_第102张图片

C++学习笔记(上)_第103张图片如上,在X64模式下调试,每个变量占用两个字节大小(16bits),且在栈上,存储它们的内存地址是连续的。接下来鼠标地址中&value2,因为value2是一个指针,即它是一个地址,如下

C++学习笔记(上)_第104张图片

16进制先输入0x,再根据地址高低顺序将地址输入,f10逐过程调试,如下图

C++学习笔记(上)_第105张图片

C++学习笔记(上)_第106张图片

此例所需内存小,观察到找到的也是连续地址。接下来,通过反汇编查看为什么堆的性能没有栈好。在调试过程中,在代码上右键,点击转到反汇编,如下图:

C++学习笔记(上)_第107张图片

C++学习笔记(上)_第108张图片

C++学习笔记(上)_第109张图片

C++学习笔记(上)_第110张图片

观察到,在栈上创建的 汇编指令甚至没有,相当于一条cpu指令直接创建,移到栈的下一位。而堆上创建(new)需要5条指令,delete操作则需要更多。所以,栈上直接创建无疑是效率更高的方式。

宏是在编译之前,也就是预处理阶段完成的。值得注意的是要保证代码的清晰易读,不能滥用宏。

如下定义了宏变量和宏函数,有的可以看成单纯的复制粘贴,但是宏函数显然有函数的基本功能。

#define PAUSE std::cin.get()
#define MAX_AGE 30
#define LOG(x) std::cout << x << std::endl


int main()
{
	LOG(MAX_AGE);

	PAUSE;
	//std::cin.get();
}

宏函数的优点:没有普通函数保存寄存器,参数传递和返回值的消耗,展开后效率高,速度快。

假如在日志系统中,不想让别人看到打印的日志信息(LOG函数),可以在debug模式下打印日志而不再release模式中打印,代码如下:

#ifdef PR_DEBUG
#define LOG(x) std::cout << x << std::endl
#else
#define LOG(x)
#endif

要做到这一点,首先要在预处理器中定义所需的if。点击属性-C/C++-预处理器,在配置为DEBUG模式下在预处理器定义中加入PR_DEBUG语句,在配置为RELEASE模式下在预处理器定义中加入PR_RELEASE语句,如下两图:

C++学习笔记(上)_第111张图片

C++学习笔记(上)_第112张图片

分别在DEBUG和RELEASE模式下编译运行,发现在RELEASE模式下没有打印日志信息。 

也可以定义为=1,如下:

C++学习笔记(上)_第113张图片

然后检查是否等于1,这样的好处是将定义的1改为0就可以改变定义 。

#if PR_DEBUG == 1
#define LOG(x) std::cout << x << std::endl
#elif defined(PR_RELEASE)
#define LOG(x)
#endif

 利用宏也可以删除特定的代码,如下图代码的#if 0,可以看到代码被折叠。

C++学习笔记(上)_第114张图片

一般时候一条宏语句只能写在同一行,但是利用反斜杠可以写多行语句,如下面的代码:

#define initHELLO void Hello()\
{\
	std::cout << "hello" << std::endl;\
}

initHELLO

int main()
{
	Hello();
}

宏在调试中作用不小,但是不能滥用宏,否则会降低代码可读性。

AUTO关键字

auto的常规用法简单,它的作用就是根据你的语句自动推断变量的类型,如auto a = "HEY",将鼠标悬停在a上,会显示它的类型是const char*。为什么要使用它呢,举个例子。

下面是使用迭代器遍历vector的基本语句:

	std::vector fruits;
	fruits.reserve(2);
	fruits.emplace_back("Apple");
	fruits.emplace_back("Orange");

	for (std::vector::iterator it = fruits.begin();
             it != fruits.end(); it++)
	{
		std::cout << *it << std::endl;
	}

可以看见it的类型是冗杂的一段,而如果将其改为auto it = fruits.begin(),非常便捷,可读性也强。

C++学习笔记(上)_第115张图片

再比如说我们有一个设备类

class Device {};

class DeviceManager
{
private:
	std::unordered_map> m_Devices;

public:
	const std::unordered_map>& GetDevices() const
	{
		return m_Devices;
	}
};

int main()
{
	// using DeviceMap = std::unordered_map>;
	// typedef std::unordered_map> DeviceMap;
	DeviceManager dm;
	// const std::unordered_map>& devices = dm.GetDevices();
	// const DeviceMap& devices = dm.GetDevices();
	const auto& devices = dm.GetDevices();
}

可以使用using / typedef 来代替冗长的类型std::unordered_map> ,也可以使用auto来自动化这个长类型。

综上,长类型和模板等应用场景中,使用auto便捷且可以提高一定的可读性,而对于短类型如int float std::string 等,还是避免auto带来的类型混杂和可读性降低较好。

函数指针与lambda函数

函数指针是将一个函数赋值给一个变量的方法,也可以将函数作为一个参数传递,以此可以做到通过函数调用函数等事情。

auto关键字在函数指针的应用中就很有作用,比如下面的代码:

void helloworld() {	std::cout << "Hi,im BOb" << std::endl; }

int main()
{
	auto fun1 = helloworld;
}

fun1变量储存的就是helloworld函数的地址(此处为隐式转换,因为函数无法直接作为值被传递,所以要传递它的指针),所以先反向引用该指针,再在后面加括号(里面应当对应函数的参数,此函数返回值为空,所以括号里不加),或者直接隐式调用就可以函数的形式运行函数指针了,如下:

    (*fun1)();
	fun1();

C++学习笔记(上)_第116张图片

另一种赋值的方式如下,也就是真正书写函数指针类型变量的标准形式:

	void(*fun2)();
	fun2 = helloworld;

可以看到形式是比较奇怪的,所以用auto比较方便。或者,使用using 或 typedef,如下:

	typedef void(*fun)();
	fun bob = helloworld;
	fun cery = hello;
	bob();
	cery();

	using fun3 = void(*)();
	fun3 BOB = helloworld;
	fun3 CERY = hello;
	BOB();
	CERY();

C++学习笔记(上)_第117张图片

函数指针还可以用于将函数作为参数传递,如下面的例子:

void PrintValue(int value)
{
	std::cout << "value:" << value << std::endl;
}

void ForEach(const std::vector& values, void(*fun)(int))
{
	for (int value : values)
	{
		fun(value);
	}
}

int main()
{
   	std::vector values = { 1, 3, 5, 7, 9 };
	ForEach(values, PrintValue);
}

定义了ForEach函数,它的第二个参数是一个参数为int的函数指针。调用此函数时,传入PrintValue函数作为参数,运行效果如下:

C++学习笔记(上)_第118张图片


l a m b d a 匿 名 函 数 :

不过PrintValue函数比较简单,不需要专门为止创建函数,此时可以使用lambda函数。lambda本质就是一个普通函数,形如[ ](int value){ std::cout << "value:" << value << std::endl; }

ForEach(values, [](int value){ std::cout << "value:" << value << std::endl; });

这里的[ ]叫捕获方式,即传入和传出参数的方式;()中同样是传入的参数,花括号中是函数体。

lambda函数不需要创建一个实际的函数,更像是一个变量,需要临时调用的快速函数。

只要有函数指针,就可以在C++中使用lambda函数,这就是它的工作原理,不需要定义一个具体的函数,就可以实现一个函数的定义

关于捕获,cppreference有详细解释,以下是关于捕获的部分解释:

捕获 - 包含零或更多个捕获符的逗号分隔列表,可以 默认捕获符 起始。

有关捕获符的详细描述,见下文。 如果变量满足下列条件,那么 lambda 表达式在使用它前不需要先捕获:

  • 该变量是非局部变量,或具有静态或线程局部存储期(此时无法捕获该变量),或者
  • 该变量是以常量表达式初始化的引用。

如果变量满足下列条件,那么 lambda 表达式在读取它的值前不需要先捕获:

  • 该变量具有 const 而非 volatile 的整型或枚举类型,并已经用常量表达式初始化,或者
  • 该变量是 constexpr 的且没有 mutable 成员。

捕获是一个含有零或更多个捕获符的逗号分隔列表,可以 默认捕获符 开始。默认捕获符只有

  • &(以引用隐式捕获被使用的自动变量)和
  • =(以复制隐式捕获被使用的自动变量)。

这里可以看到不管是引用传递还是复制传递都会在ForEach函数出错,这是因为ForEach函数定义的第二个参数是原始函数指针,将其改为std::function即可,如下:

#include 

void ForEach2(const std::vector& values, const std::function& fun)
{
	for (int value : values)
	{
		fun(value);
	}
}

int main()
{
    int c = 8;
	auto lambda2 = [&c](int value) { std::cout << "value:" << c << std::endl; };
	ForEach2(values, lambda2);
}

C++学习笔记(上)_第119张图片

若捕获中只有=或&而没有捕获的具体变量,代表值传递或引用传递了所有变量。不过在值传递变量时,在函数体内修改c的数值发生报错,在引用传递时却可以成功运行,这是lambda函数的机制

C++学习笔记(上)_第120张图片

  • mutable:允许 函数体 修改复制捕获的对象,以及调用它们的非 const 成员函数

上面为cppreference中截取的 lambda机制之一,即加上mutable才可以修改复制捕获(值传递)的对象,如下。

auto lambda2 = [=](int value) mutable{  c = 4; std::cout << "value:" << c << std::endl; };

为什么需要使用lambda呢?比如以下这种情况,我们可能需要lambda函数完成一个筛选数组中大于3的第一个数字,如下:

#include     // find_if函数

int main()
{
	std::vector values = { 1, 3, 5, 7, 9 };
	auto it = std::find_if(values.begin(), values.end(), [](int value) { return value > 3; });
	std::cout << *it << std::endl;
}

find_if包含在algorithm头文件中,可以用于在某种迭代器中找到某个值,遍历迭代器的过程会将值自动传给lambda函数。让lambda函数的函数体逐一进行判断,返回一个bool值,即是否满足条件。满足后,返回它的迭代器给it变量。

namespace

namespace即命名空间,它的存在用以避免命名冲突,如两个库中都有init函数,则使用命名空间可以避免它们的冲突,与其他C++特性相同,他也不能被滥用。

以下为一个关于namespace的例子和它的运行结果,可以看出它的基本用法有哪些。

#include 
#include 
#include 
//---------------------------------------------------------------------------------------
namespace apple {
	
	namespace funs {
		
		void print(const char* text)
		{
			std::cout << text <

C++学习笔记(上)_第121张图片

类本身也是命名空间,所以有 类名::类内符号 的用法。

线程

如果有一个程序,它的某个操作需要一直执行,它的简单实现大概会像下面这样

int main()
{
	while (true)
	{
		std::cout << "Working" << std::endl;
		std::cin.get();
	}
}

但是如果我们需要同时做另一件事,按下enter时结束其中的一个操作。此时我们可以引用线程来完成这一目的。

如,我们需要在主线程运作的同时做线程1(worker)的任务,同时监测:按下Enter结束线程1。从头至尾主线程都在工作,按下第二次Enter和第三次Enter后整个程序即主线程结束。程序如下:

#include 
#include 

static bool s_finished = true;
using namespace std::literals::chrono_literals; //使sleep_for()的参数可以是1s

void DoWork()
{
	std::cout << "--------------------thread_1's ID is: " << std::this_thread::get_id() << std::endl;

	std::this_thread::sleep_for(1.5s);

	while (s_finished)
	{
	std::cout << "Thread 1 is Working..." << std::endl;
	std::this_thread::sleep_for(1s);	//即1秒
	}
}

int main()
{
	std::cout << "想要结束线程1,您可以输入Enter" << std::endl;
	std::cout << "--------------------MainTread's ID is: " << std::this_thread::get_id() << std::endl;

	std::thread worker(DoWork);

	int i = 0;
	while (i < 5)
	{
		std::cout << "MainThread is Working..." << std::endl;
		std::this_thread::sleep_for(1s);	//即1秒
		i++;
	}
	
	std::cin.get();

	s_finished = false;
	
	worker.join();
	//join的目的是在主线程上等待工作线程worker完成所有的执行后再继续执行主线程

	std::cout << "Thread 1 Finished---!" << std::endl << std::endl;
	std::cout << "--------------------MainTread's ID is: " << std::this_thread::get_id() << std::endl;

	while (i < 10)
	{
		std::cout << "MainTread is still Working..." << std::endl;
		std::this_thread::sleep_for(1s);	//即1秒
		i++;
	}

	std::cin.get();
}

运行效果如下图所示: 

C++学习笔记(上)_第122张图片

通过检测进程ID可以观察到完成了目的需求。除了进行这样的目的之外,线程的主要作用之一还有优化。 这一点之后再进行补充。

计时

有时我们需要知道某一操作或方法执行所需要的时间,这时需要用到c++计时。其中,chrono库就与计时相关(chronological:按时间先后的),它并非完全精准,但也有较高的精度,如,下面的休眠1s的操作耗时应该在1s左右:

#include 
#include 	//计时需要的库
#include 

int main()
{
	using namespace std::literals::chrono_literals;
	
	auto start = std::chrono::high_resolution_clock::now();
	std::this_thread::sleep_for(1s);
	auto end = std::chrono::high_resolution_clock::now();

	std::chrono::duration duration = end - start;	//或者用auto
	std::cout << duration.count() << "s" << std::endl;

	std::cin.get();
}

运行结果如下:
 

C++学习笔记(上)_第123张图片

实际计时中,为了编写方便,也可以使用基于构造函数和折构函数的结构体来同一计时,比如在Function函数开头加入创建Timer结构体实例timer的语句,则在Function的函数作用域内即timer的对象生存期。Function开始,则timer自动创建。Function结束,则timer自动销毁。以此计算出Functino函数执行的大致时间。具体代码如下:

#include 
#include 	//计时需要的库
#include 

struct Timer
{
	std::chrono::time_point start;
	std::chrono::time_point end;
	std::chrono::duration duration;

	Timer()
	{
		start = std::chrono::high_resolution_clock::now();
	}

	~Timer()
	{
		end = std::chrono::high_resolution_clock::now();
		duration = end - start;

		float ms = duration.count() * 1000.0f;

		std::cout << "Timer took " << ms << "ms" << std::endl;
	}

};

void Function()
{
	Timer timer;

	for (int i = 0; i < 1000; i++)
	{
		std::cout << i << std::endl;
	}
}

int main()
{
	Function();
}

 与上面不同的是,这次我们要求的精度是毫秒,再上例的基础上乘以1000即可。运行结果如下:

C++学习笔记(上)_第124张图片

你可能感兴趣的:(c++)