c++与面向对象编程
前置
全文使用使用visual studio 2022
为什么不建议使用using namespace std;
因为这会造成命名空间的污染
比如你在你的一个文件当中引用了stl库和mystl库。你如果预处理声明这两个库的命名空间,之后便不用写std::和mystl::了,这好像很方便。但是有一种情况,比如说这两个库都恰好都包含有vector()这个函数,当你在文件中不加前缀直接使用vector的时候,你难以判断这个vector是对应哪个头文件下的function。而std::vector和mystd::vector就非常直观,这对于代码的可读性,尤其是到了大项目的时候是有很大影响的。所以这是为什么不建议使用using namespace std;
从0重新开始的c++学习之旅
C++的基本工作原理
以下面最简单的程序为例子。一开始你有一些源文件,上面有你写的文本,这些文本会通过编译器被转换成二进制文件,这个binary可以说某种库,或者是实际的可执行文件excutable。
#include <iostream>
int main()
{
std::cout << "Hello World!" << std::endl;
std::cin.get();//这个函数主要是等待一个回车执行下一行代码
}
首先我们有#include
任何以#开头的指令都是预处理指令。
当编译器收到一个源文件的时候它做的第一件事就是预处理你所有的预处理指令(preprocessor statement)
它们发生在真正的编译之前
include里提供流输入输出函数cout,cout可以使我们能够打印东西到控制台上,然后我们就有这个main函数,main函数非常的重要,因为每个c++程序都有类似这个main函数的东西,它又被称作入口点(entry point)
当我们要运行我们的程序时,计算机会从该函数的代码里开始执行,程序运行的时候,我们电脑会一行一行按照顺序运行我们写的代码。(control flow statement控制流语句)或者是调用其它的函数。
cout后的<<实际上是被重载的符号,可以把它视为作用为打印的function
源文件变为可执行文件:
1.首先是includeiostream这段preprocessor将会在编译之前被评估。
2.之后编译,c++代码转化为实际的机器码。
–在vs中有几个很重要的设置来决定这一切是如何发生的:–
在visual studio,我们可以看到debug和x86这两个选项,如果点开debug,会发现debug和release这两个选项,这两个选项在vs里是新建项目时候的默认设置。在x86我们可以看到x64和x86两个设置。
configuration是一系列规则用于如何build一个项目。
solution platform,是我们目前编译的目标平台。比如x86就是定位于32位windows,也就是说我们会生成一个用于windows的32位程序。
复杂的项目将会面向不同的平台。你可能会有一个安卓平台,然后如果你想build,部署,以及debug安卓程序,你就得把平台改成安卓。
至于solutionconfiguration是定义如何为这个平台编译的一系列规则。
属性properities板块:
首先要注意configuration和platform两个区域,确保你的configuration和platform有被设置为你确实想要修改的那个选项。
SDK,即Software Development Kit的缩写,即软件开发工具包
值得注意的是,vs默认将配置类型(configuration type)设置为应用程序(.exe),如果我们需要写一个库,我们可以在这里修改,都是这就是编译会产出的二进制文件了。
properties/C/C++是编译器设置。
注意Additional Include Directories,可以用这个来引用第三方库。属性里还有很多的其它东西,比如可能需要用到的优化设置,代码生成设置,预处理定义等等
在release的情况下,编译器会自动调整成O2优化,而在debug的情况下则是disabled状态,这就是debug模式默认比release慢的原因。
事实上,关闭优化更有助于我们进行debug。
所有cpp文件都会被编译,header file不会。
所有的cpp文件都会被编译成一个object文件,.obj为后缀名。当我们有了一个个obj文件,也就是cpp文件被编译之后的结果,我们需要有办法把它们联系起来,组成一个exe文件。这就是链接器的作用了。详情可以参考csapp上的内容。
你可以在linker标签下看到linker的设定,但是基本上linker就是把obj全部拿过来,然后把它们联系起来,组成一个exe。
vs中errorlist报错的直观性不如output窗口
vs中ctrl+f7进行compile,当你单独编译一个文件文件之间的linking(链接)显然不会发生(但是linker仍然会发生作用),因为你是单独编译一个文件。
项目里每一个文件都会生成一个obj。
build:不止编译一个文件而是整个项目,build之后我们可以在solution(x64)/debug文件夹里找到exe文件。*
出现LNK1168错误:检查先前程序是否未关闭,或者重启电脑(author没有找到解决方法),本质上是因为之前的程序没有完全关闭
出现LNK2019错误:
举一个教程中的例子
比如
*error LNK2019: unresolved external symbol “void __cdecl Log(char const )” (?Log@@YAXPEBD@Z) referenced in function main
它基本上在告诉你,你有一个没有被解析的外部标记(external symbol)叫log,unresolved external symblo意思是linker无法解析一个symbol记住,linker的工作就是resolve symbols,联通各个函数,而它无法找到log应该跟谁联系起来,因为我们没有log这个函数的定义,可以通过更正函数名来解决。
cpp的compile
cpp文件从text到可执行的bin,基本上有两个主要操作需要发生。一步是编译,一步是链接。
首先需要进行预处理,被预处理了之后,我们会进入tokenizing和parsing阶段。
基本结果就是创建某种**abstract syntax tree(抽象语法树),也就是我们代码的表达,但是是以抽象语法树的形式。
编译器的工作要么是把代码转换为constant data(常数资料),要么就是instruction(指令)**,当编译器创建了这颗抽象语法树之后,就可以产生代码了,这个代码是真正从cpu会执行的机器码。同时,我们也会得到一些其他数据,比如某个地方存储着我们所有的constant variables。
拿之前的例子来说,build项目之后i,项目目录的debug目录下生成了main.obj,log.obj,output的debug目录下生成了一个exe。
也就是编译器给每一个cpp,也就是每个translation unit(编译单元),生成了obj。事实上cpp根本不在乎文件,文件这种东西在cpp里根本不存在。举例说,在java里,你的class类名必须和文件名相同,而你的文件夹结构也得跟package一样。之所以如此是因为java需要某一些文件存在,而c++完全不是这么回事,没有文件这种东西,文件只是用来给编译器提供源码的某一种方法。
你需要告诉编译器文件类型和编译器该如何处理它。
你建一个cpp文件,编译器就会把它当作cpp文件,这只是默认的既成习惯,你可以通过改变编译器的设定来改变它们,我们同样可以设置编译器对.love进行编译。文件不代表任何东西。
我们有一个方法输出preprocessor的结果,可以在属性里将preprocess to a file修改为yes,这样就可以在文件夹中通过某个.i后缀文件查看编译前预处理后的文件。
“#if与#endif”语句,可以人我们依据特定条件包含或者剔除代码。
#if 表达式
printf("speaker");
#endif
如果表达式的结果为false,那么预处理后的文件当中便不会出现printf(“speaker”);这一行代码。
反之则会出现这一行代码。
之后我们在debug里打开filename.obj文件,会发现出现一堆16进制的代码,事实上这是二进制文件,只不过我们的打开方式以16进制的形式来呈现。
我们可以在属性里通过将assembla output设置为assembly-only listing,点ok,按ctrl+f7。之后在output目录下,我们可以看到一个叫filename.asm文件。这基本就是刚才那个obj所包含内容的一个可读版本。我们可以看到里面有一堆的汇编指令。
这些是我们运行函数的时候cpu真正会去运行的指令,所谓的o2优化直接体现在这些内容上。
如果我们直接打开release,会发现o2和rtc其实是不兼容的,所以我们需要回到code generation,确保basic runtime checks设置为default,也就是不会运行时检查(runtime checks)其实就是编译器会插入的一些代码来帮助我们debugging。
所谓常量折叠(constant folding),也就是任何常量都可以在编译的时候算出来。
在汇编文件当中,log函数会被装饰成带有随机字符和@符号的样子,这其实就是函数签名——它需要独一无二的定义你的函数。但是事实上当我们有多个obj的时候,我们的函数会在多个obj当中被定义,linker会负责将它们联系起来。它会查找这个函数签名来做到这一点。
cpp的linking
linking是从cpp源码到可执行二进制时的一个过程。编译了文件之后,我们需要一个叫做链接的过程。主要工作时找到每个符号和函数的位置,并且将它们链接在一起。
每个文件被编译成一个独立的obj文件作为translation unit它们之间没有关系,这些文件实际上无法进行沟通。
就是只有一个文件得时候你也会需要linking,因为应用程序需要知道入口点再拿里,也就是找到主函数的位置。
complie(ctrl + f7)的时候只有compile,当进行build(F5)的时候才会发生linking。
报错前缀为C是编译错误,LNK是链接错误。
每一个exe文件必须有一个入口点,不然就会报错。我们可以在属性的Linker>>Advanced中自定义entry point,入口点不一定是main函数。
比如这样的报错:
error LNK2019: unresolved external symbol “void __cdecl Log(char const *)” (?Log@@YAXPEBD@Z) referenced in function main
说明当主函数main需要调用Log这一个函数的时候,链接器找不到Log这一个函数,于是报错,然而如果文件里没有调用这个Log函数,也就不会产生对Log的链接,于是无事发生。但是如果在真的在a函数里调用了Log,即使你没调用过这个a函数,也会报linker错误。
因为虽然我们没有在这个函数中使用a函数,但是计数技术上来讲我们可能在另一个文件中使用它。因此linker确实需要链接它。
如果我们有办法告诉编译器这个a函数我只会在这个文件当中使用它,那么我可以消除这种linking的必要,因为a函数从未被调用过,它永远不需要调用log。
事实上如果我们在函数名的前面加上static这一个关键字,这基本上意味着这个a函数只是为了这个翻译单元声明的。因为a函数从来没有在这个文件里调用过,如果build,不会发生任何的linking错误。
在调用其它文件的函数时,必须要做到函数的签名相同,比如函数名,返回值的类型,参数类型。。。不然会报lnk错误。
void Log(const char* message);
另一种常见的链接错误是当我们有函数或者变量有相同的名字和签名的时候,也就是两个相同名称的函数具有相同的返回值和相同的参数。连接器不知道哪个链接到哪个。
比如当在两个文件中同时include一个头文件,里面有一个不带任何修饰的函数a,这个时候根据include的特性,两个编译单元(cpp文件)中会同时声明一个函数a,这时候就会爆LNK错误。
但是如果我们在函数a前加上static 修饰,就意味着这个log函数链接时链接只应该发生在该文件的内部。也就是说,当这个a函数被include到两个编译单元中的时候,只会对该文件的内部有效。
另一种方法是加上inline,所谓内联函数,就是直接把函数的身体拿过来进行调用,就不会出现引用函数名的问题。
再一种方法是把它的定义移动到另一个翻译单元当中。比如再翻译单元1中定义a函数,再到头文件里留下声明
cpp中的变量与函数
数据类型的实际大小取决于编译器。
直接声明float a = 4.5事实上4.5是一个double类型的数据,4.5f加上一个f之后才会变成float。
由于在内存当中,我们没有办法寻址到每一个bit,只能寻址到每一个字节,所以我们不能每bit地访问内存,于是bool事实上是1个字节的大小。
在调用函数的过程中,编译器会产生一个调用指令,为这个函数创建一整个栈框架,使速度变慢,除非是内联函数。
主函数是一个特殊的函数,可以不用写return 0。
cpp当中的头文件
事实上头文件是cpp比较特殊的一种特性,它本质上是代码的复制粘贴。
可以在头文件里定义函数的声明来避免函数签名的冲突。
“pragma once”
由于这个指令带了#号,说明这个指令是一个preprocessor指令。
pragma once其实意思是说只会include这个文件一次。这个命令有时候也被称为header guard(头文件保护符),能够防止我们把单个头文件多次include到一个单一翻译单元里。
比如你有Common.h这个头文件
#include "Log.h"
然后又有Log.h这个头文件
void Log(const char* message);
在一个编译单元里你同时包含
#include "Log.h"
#include "Common.h"
这个时候编译器就会报错,因为事实上,Log.h被编译单元引用了两次,如果加上pragma once,则比较容易能够避免这种问题。
”#ifndef“
这个指令也是保护头文件用的,
//Common.h
#ifndef _LOG_H
#define _LOG_H
void Log(const char* message);
#endif
如果在文件中检测到了_LOG_H这个宏定义,那么程序就会终止运行。这使得这个头文件事实上只会在编译单元里被包含最多一次。
include “”和include <>的区别:
“”会在文件的相对位置寻找头文件,<>会在所有include目录里寻找头文件。
vs的debug
断点
什么是断点。
断点是程序中调试器会中断的一个点,也就是暂停。我们可以在程序的任何一行代码上设置断点。当执行到达这一行的时候,它就会暂停。
debug:
确保断点设置在可执行的代码上。
确保处于debug模式,因为当处于release模式时,编译器可能会改变你的代码,你的断点可能永远不会被执行,因为你的程序被重新安排了。
点击Local windows debug
会显示一个黄色的箭头,指示当前指令指针所在的位置,但是这一行代码并没有被执行。
continue将继续执行程序。
step into会进入当前的函数(如果有函数)。
step over将转到当前函数的下一行代码。
step out实际上是要跳出当前的函数,让我们回到这个函数。
auto local watch
autos和locals基本上只是向你展示局部变量或者来说对你重要的变量。
watch从另一方面让我们实际监控变量。
我们也可通过菜单栏的Debug-window-memory视图来监控内存。
这个面板我们可以看到地址,地址下的值,以及对应的ascii信息。
在vs里,一般来说,在变量没有进行初始化的时候,变量会被会在每个字节赋值上cc(16进制数),这意味着它是一块未被初始化的堆栈内存。
你可以在auto,watch等调试窗口中右键选择十六进制的display。
如果需要快速查找某一个变量的地址,可以使用&取地址运算符。
当我们继续运行程序的时候,我们可以看见a在内存中的值发生了改变,变成了程序当中编写的数值。
可以通过设置断点在调试当中跳出循环
cpp的分支与循环
在设置断点进行调试的时候,可以通过右键某一行进行反汇编。
当然如果还没有学过汇编就可以试着跳过这一步,,,
nullptr作为一个关键字,用来表示空指针,一般值为0
所谓控制流语句,就是break,continue,return之流。
cpp的指针与引用
当我们说到编程当中最重要的东西,可能是说内存。
我们想象内存在计算机中是以一条链的形式表示,外界以逐字节的方式对内存进行访问。
cpp的指针本质上并没有类型,所谓的int* ptr只是说我们那个地址的数据可能是我们给它的类型int。同样一个类型不会改变一个指针。指针的类型,决定了它被+1的时候字节走多少个,以及被解引用后该如何进行处理等等。
初始化指针可以为NULL = 0,NULL无法在内存中被访问,也就是这个指针没有意义,但是在cpp中这样定义是完全可行的,除非你在之后的代码中试图解引用它。
指针变量也有自己的地址。
内存的栈区与堆区。
下面有一个例子
char* buffer = new char[8];
memset(buffer, 0, 8);
之后在memory视图当中我们可以看到buffer的头8个字节被赋值为了0。
事实上如果使用new来分配内存,数据是被分配在heap上的。我们还应该在使用过后删除这一块内存
delete[] buffer;
引用,在计算机处理这个关键字的角度看,基本上和指针是一回事。引用只是基于指针的一种syntax sugar(语法糖),来使得代码更易读而已。
对于指针来说,你可先创建一个指针变量并且给他赋值nullptr或者其他等于0的量,但是引用不能这么做。因为引用变量必须引用一个已经存在的变量,而不是一个新的变量,它们并不真正占用内存。
int a = 5;
int& ref = a;
ref = 2;
这个时候输出a,a的值就变成2了,我们相当于给a创建了一个别名ref。
可以在函数参数中使用引用传递。
void Increment(int& value)
{
value++;
}
这个时候在外部被传入的原参数也会发生改变。
当你声明一个引用的时候,你必须立刻给它赋值,因为它必须是某物的引用,不能随意进行改变。引用的本质就是常量指针。
cpp当中的类与结构体
面向对象编程只是你在编程的时候采用的一种风格关于如何编写你自己的代码。java和c#是面向对象编程的语言,对于这两种语言来说最好不要编写其它风格的程序(虽然事实上可以这样做)
但是cpp不同的地方在于它不仅仅支持面向对象编程,还支持面向过程,基于对象,泛型编程这3种。
instance实例化
class Player
{
int x , y;
int speed;
};
int main()
{
Player player;//对象的实例化。
player.x = 5;
player.y = 5;
player.speed = 4;
}
但是如果我们直接进行编译,编译器会报错,告诉我们player对象无法访问类中的私有成员。这是因为有一种东西叫做访问控制(或者可见性Visbility)。当你创建了一个类的时候你可以指定类中属性的可见性。默认情况下,类中成员的访问控制(可见性)都是私有的。这意味着只有类内部的函数才能够访问这些变量
class Player
{
public:
int x , y;
int speed;
};
int main()
{
Player player;//对象的实例化。
player.x = 5;
player.y = 5;
player.speed = 4;
}
通过添加public变量,设置变量为公有,这表示我们允许在类的外面访问这些变量。
引申到struct,struct的默认访问则是public
class Player
{
public:
int x , y;
int speed;
void Move(int xa , int ya)
{
x += xa * speed;
y += ya * speed;
}
};
int main()
{
Player player;//对象的实例化。
player.Move(1 , -1);
std::cin.get();
}
类内的函数被称为方法(methods)。
用class可以实现的事情,一定也可以通过非class的方式实现
结构体在cpp中存在主要是因为要保持与c语言的兼容性。使用结构体和类只是个人的代码风格问题,并没有优劣之分
前置(vs的文件设置):
在vs里的资源管理器里显示并不是文件夹,而是筛选器。筛选器以虚拟文件夹的形式工作,和真实的硬盘里的文件夹结构没有一点关系。筛选器只可以用来帮助你进行文件的分类。
可以在vs的设置里设置显示所有文件,以查看真实的文件夹结构。
如图,我们打开了【Show All Files】这以一个选项,并且在tourial2这个文件夹下创建了Main.cpp这个文件,同时我们可以在筛选器中看到,Main.cpp被放到了source Files这个文件夹下。
Filter是一个假象,你可自由地删除或者添加Filter,但是实际上并没删除文件夹或者创建新的文件夹。
由于微软的项目文件夹设置的并不直观,我们可以在属性里进行修改。
大佬的文件夹设置(我啥都不懂,跟着做就完事了):
(说起来Typora好像可以直接拖入图片,简直薄纱MarkdownPad好吗)
通过这种方式可以吧两个Debug文件夹放在一个比较合理的目录下。
Static
Static的含义:
就是所谓的静态,C++的静态关键字的使用实际上有两种含义取决于你要使用的情况。
其中一个是当你想要在一个类或者一个结构体使用关键字static的时候。另一个是当你在一个类或结构体当中使用static的时候。
类外的static修饰的符号在link阶段是局部的,也就是只对定义它的编译单元(.obj)可见,而类或者结构体里面的static则表示这部分内存是这个类的所有实例共享的。(简单来说,就算你实例化了很多次这个类或者结构体,但那个静态变量只会有一个实例)
类和结构体外部的static:
首先我们尝试着创建一个静态的变量:
//Static.cpp
static int s_Variable = 5;
它表示这个变量在link的时候只对这个编译单元(.obj)里的东西可见。static变量或者函数在link到它实际的定义的时候,linker并不会在这个编译单元.obj的外面去找它的定义。
之后我们再Main文件里再创建一个重名的全局变量。
//Main.cpp
#include <iostream>
int s_Variable = 10;
int main()
{
std::cout << s_Variable << std::endl;
std::cin.get();
}
这个时候我们进行build不会报出任何的问题。但是如果我们把Static.cpp中s_Variable前的static关键字去掉再进行build,就会产生lnk报错,这是因为两个全局变量的名字不能一样。
//Static.cpp
int s_Variable = 5;
一种解决方法是把Main里的变量变成另一个文件里的变量的引用。也就是去掉前者的赋值,再加上extern关键字。
//Main.cpp
#include <iostream>
extern int s_Variable;
int main()
{
std::cout << s_Variable << std::endl;
std::cin.get();
}
之后Main.cpp里的s_Variable就会在另外的编译单元里找定义,这也叫做外部链接。之后就可以正常进行build。
我们再进行修改
//Static.cpp
static int s_Variable = 5;
这个时候我们build就会发生报错(unresolved external symbol)。这有点像在class里面声明私有成员一样,其它的编译单元不能访问s_Variable,linker在全局作用域下找不到它,所以报错。
尽量让你的全局函数和变量static,除非你需要它们用在其它的编译单元。
类和结构体中的static
在几乎所有的面向对象的程序语言中,静态在一个类当中意味着特定的东西,如果你把它和变量一起使用,这意味着在类的所有实例当中这个变量只有一个实例。
类中的静态属性是所有实例共享的
也就是如果某个实例改变了这个静态变量,它会在所有实例当中反映这个变化。
而静态方法可以不需要通过类的实例实现调用。但是在静态方法的内部,你不能写引用到类的实例。
静态成员函数只能调用静态的成员变量和成员函数。静态方法没有类实例
下面是一个例子
//exaple
#include <iostream>
struct Entity
{
int x , y;
void Print()
{
std::cout << x << " " << y << std::endl;
}
};
int main()
{
Entity e1 , e2;
e1 = {2 , 3};
e2 = {3 , 4};
e1.Print();
e2.Print();
std::cin.get();
}
输出为:
2 3
3 4
//exaple
#include <iostream>
struct Entity
{
static int x , y;
void Print()
{
std::cout << x << " " << y << std::endl;
}
};
//定义静态变量,使连接器得以正常工作
int Entity::x;
int Entity::y;
int main()
{
Entity e1, e2;
e1 = {2 , 3};
e2 = {3 , 4};
e1.Print();
e2.Print();
std::cin.get();
}
输出为:
3 4
3 4
//exaple
#include <iostream>
struct Entity
{
int x , y;
static void Print()
{
std::cout << x << " " << y << std::endl;
}//会发生报错
};
int main()
{
Entity e1 , e2;
e1 = {2 , 3};
e2 = {3 , 4};
e1.Print();
e2.Print();
std::cin.get();
}
每个非静态方法总是能够获得当前类的一个实例作为参数。
把static放在Entity当中是有意义的,如果你有一个信息,你想要在所有的Entity实例当中共享数据。或者将它实际储存在Entity类当中是有意义的,因为它与Entity类有关。
要组织好的代码,那你最好在这个类当中创建一个静态变量。而不是将一些静态的或者全局的东西到处乱放。
Local Static
变量的生存期:某个变量被删除之前,会在我们的内存当中存在多久。
变量的作用域:我们可以访问变量的范围。
静态局部变量允许我们声明一个变量,它的生存期相当于整个程序的生存期,但是它的作用范围被限制在这个函数内。
#include <iostream>
void Function()
{
static i = 0;
i++;
std::cout << i << endl;
}
int main()
{
Function();
Function();
Function();
std::cin.get();
}
运行这个函数,会发现会输出1,2,3,这就是局部静态最好的例子。
#include <iostream>
int i;
void Function()
{
i++;
std::cout << i << endl;
}
int main()
{
Function();
Function();
i = 10;
Function();
std::cin.get();
}
我们不难发现全局变量可以实现相似的效果,但是这种方法的问题使,我们可以在任何的地方访问i,比如在Function函数的调用之间令i等于10。这便是与局部静态的区别。
单例类的写法(?):
class Singleton
{
private:
static Singleton* s_Instance;
public:
static Singleton& Get() {return *s_Instance;}
void ExampleFunction(){}
};
//在外部需要加上定义
Singleton* Singleton::s_Instance = nullprt;
或者这种形式(采用了局部静态)
class Singleton
{
public:
static Singleton& Get()
{
static Singleton instance;
return instance;
}
void ExampleFunction(){}
};
扩展:单例模式
单例模式,属于创建类型的一种常用的软件设计模式,通过单例模式的方法创建的类在当前进程当中只有一个实例。
以后有时间再做详细了解。
Cpp与枚举Enumeration
ENUM是enumeration的缩写。基本上就是一个数值的集合,枚举数实际上就是一个整数。当你想要使用整数来表示某些状态或者某些数值的时候,它会非常有用。
#include<iostream>
enum Example::unsigned char//申明枚举数的数据类型,你不能使用float。
{
A , B , C;//如果A不声明,则A为0,B,C的数值从上一个数依次递增。
//A = 0 , B = 5 , C = 6 可以这样声明
};
int a = 0;
int b = 1;
int c = 2;
int main()
{
//Example value = 5; 这样子声明会报错。
Example value = B;
if(value == 1)
{
//Do something here
}
std::cin.get();
}
构造函数与析构函数
constructor
基本上constructor就是一种特殊的method,它在被实例化的时候被调用。
1.构造函数没有返回类型
2.构造函数最重要的作用就时初始化类
3.构造函数的命名必须和类名意义
4.构造函数不会在你没有实例化对象的时候允许
5.用new关键字创建实例对象的时候也会调用constructor
引入:
class Entity
{
public:
float X , Y;
void Print()
{
std::cout << X << " " << Y << std::endl;
}
}
以教程中的这个例子,之后调用Print函数,会出现几乎是随机的数字。
这是因为,在实例化分配内存的时候,未初始化此例的内存。这意味着,X,Y的取值就是原始内存里的值(接近随机)
但是如果我们在主函数里使用这样的代码并且运行
std::cout << e.X << std::endl;
那就会发生报错,使用了未初始化的局部变量。
引入结束
于是我们现在需要一个方法,能够在初始化的时候就能够实例化它。我们不难想到在class的里面写一个init函数
void Init()//在主函数里调用它
{
X = 0.0f;
Y = 0.0f;
}
constructor就是一种特殊的method,它在实例化的时候就可以被调用以用来初始化实例。
我们可以把Init替换成这样的函数。
Entity()
{
X = 0.0f;
Y = 0.0f;
}
这个时候运行代码,就可正常输出了。
也可以这样声明构析函数,运用到了初始化列表这个操作,它将先于函数体进行执行:
class Entity
{
public:
int x, y;
Entity(){} //不带参数
Entity(int x, int y) : x(x), y(y) {} //用参数来初始化x和y
};
就算你不指出constructor,也会有一个默认的constructor(但是它不会去做任何的事),java会帮你设置成0,C++必须手动设置所有的内存空间。
我们可以写一个含参数的constructor,这同于函数重载,即相当于写个不同版本的同名函数(在class里是method)。
通过这样声明来实现初始化:
Entity e(10.0f, .3f);
若无实例,constructor不会被调用,如果只是使用static method,同理。
可以使得构造函数=delete,使得class不能被实例化,避免误用。
构造函数的成员初始化列表
这是我们在构造函数中初始化类成员(变量的一种方式),初始化在构造函数当中有两种方法
我们可以在构造函数当中初始化一个类成员来进行初始化。
class Entity
{
private:
std::string m_Name;
public:
Entity()
{
m_Name = "Unknown";
}
Entity(const std::string& name)
{
m_name = name;
}
}
另一种便是初始化列表了。
#include <iostream>
class Entity
{
private:
std::string m_Name;
int m_Score;
public:
Entity() : m_Name("Unknow"), m_Score(0)
{/*
如果你不按顺序写,编译器可能会爆出警告,这很重要因为不管你
怎么写初始化列表,他都会按照类成员的顺序进行初始化。在这个
例子当中,首先初始化整数然后初始化字符串。
如果你在初始化列表的时候,使用另一种方式来初始化列表,比如
先初始化字符串再初始化整数,这就会导致各种各样的依赖性问题
所以你要确保你做成员初始化列表时,要与成员变量声明的顺序一
致
*/
}
Entity(const std::string& name,int n) :m_Name(name), m_Score(100)
{
}
const std::string& GetName() const { return m_Name; };
const int& GetScore() const { return m_Score; };
};
int main()
{
Entity e0;
Entity e1("lk",50);
std::cout << e0.GetName() <<e0.GetScore() << std::endl;
std::cout << e1.GetName() <<e1.GetScore()<<std::endl;
}
使用初始化成员列表的理由(不仅仅是风格):
1.增强代码的可读性
2.如果写了这样的代码
private:
std::string m_Name;
int x , y , z;
public:
Entity() : x(0) , y(0) , z(0)
{
m_Name = "Unknow";//实际上发生的是 m_Name = std::string("Unknown");
}
实际上会发生m_Name对象被构造两次的情况。一个是使用默认构造函数,一个是使用unknown参数(初始化的构造函数),这事实上是对性能的浪费。
。在成员变量的区域也有可能允许代码并且创建对象
destructor
析构函数在销毁对象的时候运行。任何情况下,当一个对象被销毁的时候,析构函数将会被调用。同时适用于栈和堆分配的对象。如果使用new分配一个对象,当你调用delete的时候,析构函数就会被调用,如果是一个栈对象,当作用域结束的时候栈对象将会别删除。
~Entity()
{
//code
}
可以这样声明构析函数。
在堆上分配的对象,如果你已经在堆上手动分配了任何类型的内存,那么你就需要手动清理,如果在Entity类中使用或者构造中分配了内存。你可能要在构析函数时删除它们。
继承inheritance
继承这一特性允许我们拥有一个基础类,这个类包含有公共功能。然后它允许我们从这个类中分支出来并且从初始父类创建子类。
class Entity
{
public:
float X , Y;
void Move(float xa , float ya)
{
X += xa;
Y += ya;
}
}
class Player
{
public:
const char* Name;
float X , Y;
void Move(float xa , float ya)
{
X += xa;
Y += ya;
}
void PrintNmae()
{
std::cout << Name << std::endl;
}
}
可以看到我写了两个大部分结构非常相似的类,而Player类有自己的一些变量和Methods。我们可以在类型声明的后面写一个冒号,写下public Entity。
这个时候发生了一些事情,Player现在不仅拥有了Player类型,而且也拥有了Entity类型,意思就是现在是两种类型了。
class Entity
{
public:
float X , Y;
void Move(float xa , float ya)
{
X += xa;
Y += ya;
}
}
class Player : public Entity
{
public:
const char* Name;
void PrintNmae()
{
std::cout << Name << std::endl;
}
}
int main()
{
Player player;
player.Move(5 , 5);
std::cin.get();
}
重构我们的代码,更加的简洁了。
多态是一个单一类型,但是有多个类型的意思。
当你创建了一个子类时,这个子类包含了父类的所有东西。
虚函数VirtualFunction
虚函数
虚函数允许我们在子类中重写方法,假设我们有两个类A和B,B时A派生出来的。如果我们在A类中创建一个方法,标记为virtual,我们就可以在B类中重写那个方法,让它去做其它的事情
格式template:
class father
{
virtual datatype FunctionName()
{
//code...
}
}
1.定义基类,声明基类函数为virtual的。
2.定义派生类(继承基类),派生类实现了定义在基类的virtual函数
3.声明基类指针,并且指向派生类,调用virtual函数,此处虽然是基类指针,但是调用的是派生类实现的基类virtual函数。
//基类
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("cherno");
std::cout << p->GetName() << std::endl;
}
输出得到了Entity,Cherno。
但是如果我们使用多态的概念,那么到目前为止我们在这里编写的所有内容,就多少有一点问题了。
如果我们引用了这个Player并且将它当成了Entity类型,就会出现一些问题,比如
int main()
{
Entity* e = new Entity();
std::cout << e -> GetName() << std::endl;
Player* p = new Player("cherno");
std::cout << p -> GetName() << std::endl;//声明基类指针指向派生类
Entity* entity = p;//p是一个Player类型的指针,指向了entity。
std::cout << entity -> GetName() << std::endl;
}
我们得到输出Entity,cherno,Entity。
但是我们希望的是打印Player,因为虽然我们指向的是一个Entity*,但是实际上它是一个Player类的实例。
我们保留类不动,添加这样的代码:
void printName(Entity* entity){
std::cout << entity -> GetName() << std::endl;
}
int main(){
Entity* e = new Entity();
printName(e); //我们这儿做的就是调用entity的GetName函数,我们希望这个GetName作用于Entity
Player* p = new Player("cherno");
printName(p); //printName(Entity* entity),没有报错是因为Player也是 Entity类型。同样我们希望这个GetName作用于Player
}
输出了两个Entity
因为如果我们在类中正常声明函数或者是方法,当我们调用这个方法的时候,它总是回去调用属于这个类型的方法。而void printName(Entity* entity);
参数类型是Entity*
,意味着它会调用Entity内部的GetName函数,它只会在Entity的内部寻找和调用GetName。
但是我们希望C++能意识到,我们传入的其实是一个Player,所以请调用Player的GetName。这个时候就需要使用虚函数了。
虚函数引入了一种叫做**Dynamic Dispatch(动态联编)的东西,它通常会通过v表(虚函数表)**来实现编译。
v表就是一个表,它包含基类中所有虚函数的映射。这样我们可以在它运行的时候将它们映射到正确的覆写(overrride)函数。
如果你想覆写一个函数,必须将基类中的基函数标记为虚函数。
class Entity
{
public:
virtual std::string GetName() {return "Entity";}//生成v表,如果它被重写了,你可以指向正确的函数。
};
之后我们运行,会打印出Entity和 Cherno这两个字符串。
我们可以使用override,将覆写函数标记为关键字”override“
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;}
};
这不是必需的,但是这让它更加具有可读性,让我们知道这实际上是一个覆写的函数,而且会vs编译器会根据override这个关键字帮你报错。
但是使用虚函数也有额外的成本
1.我们需要额外的内存来存储v表,包括基类中需要有一个成员指针指向v表。
2.每次我们使用虚函数的时候,我们都需要遍历这个表来确定映射到了哪个函数。
纯虚函数与Interface接口
cpp中的纯虚函数的本质犹如其它语言中的抽象方法和接口(例如java和csharp)
原理上来讲,纯虚函数允许我们定义一个在基类中没有实现的函数,迫使在子类中实际实现。
在面向对象程序设计当中,创建一个只包含为实现方法并且交由子类去实际实现功能的类是非常普遍的。这通常被称为接口(interface)接口是Cpp中的一种类,在接口当中,类仅仅包含未实现的方法并且充当一种勉强的模板。并且由于此接口类实际上不包含实现方法,所以我们无法实例化这个类。
只能实例化一个实现了所有纯虚函数的类,纯虚函数必须被实现,然后我们才能创建这个类的实例。
class Entity
{
public:
virtual std::string GetName() = 0; //定义了一个纯虚函数,这意味着如果你想实例化那个类,那么这它必须在子类当中实现。
};
这个时候,我们不可能重新到main函数当中实例化一个Entity类。
我们必须给他写一些实际上实现了该功能的子类。
class Entity
{
public:
std::string GetName() = 0;
};
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;}//如果少了这一行,我们便不能本质上实例化Player类。
};
void PrintName(Entity* entity)
{
std::cout << entity -> GetName() << std::endl;
}
int main(){
//Entity* e = new Entity();编译器会直接报错
Entity* e = new Player("");
PrintName(e);
Player* p = new Player("cherno");
PrintName(p);
std::cin.get();
}
你只能在实现了所有的纯虚函数之后才能够进行实例化,或者在更上层次的类:
比如,Player类是另一个类(Entity的子类)的子类,而这个类实现了GetName函数。那也是可以的,我们的想法是,纯虚函数必须被实现,才能创建这个类的实例。
看一个更好一点的例子:
我们想要实现打印这些类的类名。
class Printable
{
public:
virtual std::string GetClassName() = 0;//设置一个新类,并且设置一个纯虚函数
};
class Entity : public Printable //让Entity实现这个接口(interface)
{
public:
virtual std::string GetName() {return "Entity"};
std::string GetClassName() override {return "Entity";}
};
class Player : public Entity, Printable //事实上通过继承Entity,Player类已经继承了Printable,但是我们仍然可以用这种方式进行继承
{
private:
std::string m_Name;
public:
Player(const std::string& name) : m_Name (name) {}
std::string GetClassName() override {return "Player";}
std::string GetName() override {return m_Name;}
};
void PrintName(Entity* entity)
{
std::cout << entity -> GetName() << std::endl;
}
void Print(Printable* obj)//我们这里需要的是一个type,保证我们有这个GetClassName函数,这就是所谓的接口
{
std::cout << obj -> GetClassName() << std::endl;
}
int main(){
//Entity* e = new Entity();编译器会直接报错
Entity* e = new Player("");
//PrintName(e);
Player* p = new Player("cherno");
//PrintName(p);
Print(e);
Print(p);
std::cin.get();
}
通过这样的修改,我们最后打印出了两个正确的类名,所有这些都来自一个Print函数,这个函数接受Printable作为参数,它事实上并不关心传递的具体是什么类,但是它知道任何一个Printable的东西,都有一个GetClassName函数去调用,这就够了。
(因为就比如上面的例子中你不实现SetClassName就不能实例化这个类,只有实现了这个函数,菜鸟实现实例化。)
可见性Visbility
事实上,在之前讲类的时候就强调过这方面的问题了。可以参考之前的文章。
可见性是对程序实际运行方式和程序性能等等类似的东西完全没有影响的东西。
它纯纯是语言中存在的东西,让你能够写出更好的代码或者帮助你组织代码。
C++中有三个基础的可见性修饰符:
private:只有自己的类和它的友元才能访问(继承的子类也不行,友元的意思就是可以允许你标记一个类或者函数作为这个类的友元,允许访问这个类的私有成员)。
protected:这个类以及它的所有派生类都可以访问到这些成员。(但在main函数中new一个类就不可见,这其实是因为main函数不是类的函数,对main函数是不可访问的)
public:谁都可见。
cpp与数组
概览
- C++数组就是表示一堆的变量组成的集合,一般是一行相同给类型的变量。
- 内存访问违规(Memory access violation):在debug模式下,你会得到一个程序崩溃的错误消息,来帮助你调试那些问题;然而在release模式下,你可能不会得到报错信息。这意味着你已经写入了不属于你的内存。
- 循环的时候涉及到性能问题,我们一般是小于比较而不是小于等于(少一次等于的判断)
//定义一个含5个整数的数组
int a[5];
//访问
a[5],a[-1]; //内存访问违规(Memory access violation)
#include <iostream>
int main()
{
int ex[5];
for(int i = 0 ; i < 5 ; i++)
ex[i] = 2;
std::cin.get();
}
允许以上的代码,观察内存分配:
可以看到5个2,每个占据4个字节,因为它们是整型的int
int main()
{
int example[5];
int* ptr = example;//数组名代表数组第一个元素的地址
for (int i = 0; i< 5;i++)
example[i] = 2;
example[2] = 5; //第三个元素设置为5
*(ptr + 2) = 6; //第三个元素设置为6。因为它会根据数据类型来计算实际的字节数,所以在这里因为这个指针是整形指针所以会是加上2乘以4,因为每个整形是4字节
*(int*)((char*)ptr + 8) = 7; //第三个元素设置为5。因为每个char只占一个字节
std::cin.get();
}
栈数组与堆数组
#include <iostream>
int main()
{
int example[5];//栈数组,在跳出作用域的时候会自动销毁
for(int i = 0 ; i < 5 ; i++) example[i] = 2;
int* another = new int[5];//使用new关键字创建了一个堆上的数组
for(int i = 0 ; i < 5 ; i++)
another[i] = 2;
delete[] another;//将堆上的数组从内存当中清除
std::cin.get();
}
为什么要动态地使用new来进行分配,而不是在栈的上面进行创建呢。
最大的原因就是生存期,用new来分配的内存,它将会一直存在,直到你删除它。如果你有一个函数返回一个数组,你必须使用一个new关键字来分配它,除非你传入一个数组的地址参数。如果你想返回一个数组,这个数组实是函数当中创建的,你就需要使用new关键字。
使用new的时候,还需要考虑内存间接寻址,我们实际上一个指针这个指针会指向另一个内存块。这个内存块保存了我们实际的数组。这将会造成某种内存碎片(memory fragmentation),缓存丢失(cache miss),。
所谓的内存间接寻址:
意思是,有个指针,指针指向另一个保存着我们实际数组的内存块(p-> p-> array),这会产生一些内存碎片和缓存丢失。
class Entity
{
public:
int example[5]; //栈数组
Entity() //创建一个构造函数,用来初始化所有值为2
{
for (int i = 0; i< 5;i++)
example[i] = 2;
}
};
int main()
{
Entity e; //实例化一个对象。如果我们查看Entity e的内存地址,可以看到Entity的内存上实际就是一行,包含了数组中所有的2,所有的数据都在这儿
std::cin.get();
}
class Entity
{
public:
int* example = new int[5]; //堆数组
Entity()
{
for (int i = 0; i< 5;i++)
example[i] = 2;
}
};
int main()
{
Entity e; //这时我们查看Entity e的内存地址,可以看到这里的内存根本没有2。我看到另一个内存地址,其实这就是个指针。我可以复制该地址然后粘贴查找(因为endian(字节序)的原因我必须要反转它们),然后就可以看到我真正的数据。这就是memory indirection(内存间接寻址)
std::cin.get();
}
C++11中的std:array
这是一个内置数据结构,定义在C++11的标准库中。很多人喜欢用它来代替这里的原生数组,因为他有很多优点,它有边界检查,有记录数组的大小
实际上我们没有办法计算原生数组的大小,但可以通过一些办法知道大小(例如因为当你删除这个数组时,编译器要知道实际上需要释放多少内存)。
计算原生数组大小
方法一:通过依赖编译器,它有可能会在数组中存储一个负索引(-1),但应该永远都不要在数组内存中访问数组的大小,这很危险。
创建一个栈数组,你不知道他的实际大小,因为它是在栈上分配的,也就是说这是栈上的地址加上偏移量
int a[5]; //栈数组
所以如果你写:
sizeof(a); //20bytes
如果你想知道有多少个元素,可以用sizeof(a)除以数据类型int的大小,得到5
int count = sizeof(a) / sizeof(int); //5
但是如果你用堆数组example做同样的事:
int* example = new int[5]; //堆数组
int count = sizeof(example) / sizeof(int); //1
你再这里得到的实际上是一个整形指针的大小(int* example),就是4字节,4/4就是1。
所以只能在栈分配的数组上用这个技巧,但是你真的不能相信这个方法!当你把它放在函数或者它变成了指针,那你完蛋了(因为“栈上的地址加上偏移量”)。
所以你要做的就是自己维护数组的大小。
如何维护呢?方法有两个
方法一:
class Entity
{
public:
static constexpr const int size = 5;//在栈中为数组申请内存时,它必须是一个编译时就需要知道的常量。constexpr可省略,但类中的常量表达式必须时静态的
int example[size]; //此时为栈数组,
Entity()
{
for (int i = 0; i<size;i++)
example[i] = 2;
}
};
方法二:std:array
include <array> //添加头文件
class Entity
{
public:
std::array<int,5> another; //使用std::array
Entity()
{
for (int i = 0; i< another.size();i++) //调用another.size()
example[i] = 2;
}
};
这个方法会安全一些。
该部分笔记大部分refer自最好的C++学习教程(上篇)——The Cherno CppSeries - 知乎 (zhihu.com)
cpp字符串
字符串/字符数组的工作原理
字符串实际上就是字符数组。
C++中有一种数据类型叫做char,它很可以把指针转换为char类型指针,所以你可利用字节来做指针运算。它对于动态分配内存缓冲区也很有作用,比如分配1024个char就相当于1KB的空间。它对于字符串和文本也非常有用,因为C++对待字符的默认方式是通过ascii字符进行文本编码,我们在cpp中处理字符是一个接着一个字符,ascii可以扩展比如UTF-8,UTF-16,UTF-32,我们有wide string(宽字符串)等,我们有两个字节的字符,三个字节,四个字节的等等。
cpp中默认的双引号就是一个字符数组const char*,并且末尾会补’\0’(空终止符),而cout会输出直到’\0’就会终止。
const char* name = "cherno"; //c风格字符串
char* name = "cherno" //报错,因为C++中默认的双引号就是一个字符数组const char*
char name[3] = {'l','i','u'};//报错,缺少空终止符
char name[3] = {'l','i','u',0};//正确
char name[3] = {'l','i','u','\0'};//正确,因为ascii码'\0'就是null
如果我们这样声明一个char数组:
const char* name = {'C','h','e','n','o'};
将这个字符串输出,会得到这样类似地结果
这是因为字符串没有终止符,导致字符串读入了额外的内存空间,最终输出了一个长度为31的字符串
string
C++标准库里有个类叫string,实际上还有一个模板类basic_string。std::string 本质上就是这个basic_string的char作为模板参数的模板类实例。叫模板特化(template specialization),就是把char作为模板类basic string的模板参数,意味着char就是每个字符背后的的数据类型。
在C++中使用字符串时你应该使用std::string。
string有个接受参数为char指针或者const char指针的构造函数。在C++中用双引号来定义字符串一个或者多个单词时,它其实就是const char数组,而不是char数组。
std::string本质上它就是一个char数组,一个char的数组和一些内置函数
追加字符串
std::string name = "Cherno" + "hello!";//ERROR!
原因是在将两个const char数组相加,因为,双引号里包含的内容是const char数组,它不是真正的string;它不是字符串,你不能将两个指针或者两个数组加在一起,它不是这么工作的。
所以如果你想这么做,要么就是把它们分成多行,然后name+=”hello!”
std::string name = "Cherno"";
name += "hello! //OK
这样做是在将一个指针加到了字符串name上了,然后+=这个操作符在string类中被重载了,所以可以支持这么操作。
或者经常做的是显式地调用string构造函数将其中一个传入string构造函数中,相当于你在创建一个字符串,然后附加这个给他。
std::string name = std::string("Cherno") + "hello!";//OK
bool contains = name.find("no") != std::string::npos;//用find去判断是否包含字符“no”
cpp字符串字面量
这是一种基于字符串的东西。
所谓字符串字面量,是在双引号之间的一串字符。
字符串字面量永远保存在内存的只读区域,记住,是永远。因为我们写一个char name数组
如图:双引号的内部是一个const char,而且大小为7(计算了’\0’的情况)。
const char name[8] = "Che\0rno";
而这样的代码在内存中是这样显示的:
可以发现在内存中显示的倒也符合正常人的思维,但是在编译器中却显示value为”Che”:
最后通过strlen得到的字符串的长度是3。得到这个结果的原因,是它只计算直到反斜杠0之前的字符数,一到\0,编译器就会认为这个字符串结束了。
const char name[8] = "Cherno";
const char* name = "Cherno";
char* name = "name";
name[2] = 'k';
我们可以用第一行第二行两种方式声明一个字符串,最好不要把const去掉,因为这样一容易导致所谓的**未定义行为(Undefined behaviour)**。
这是因为C++标准并没有定义在这种情况下应该发生什么。因此,一些编译器可能会为此生成有效的代码,但是你不能依赖它,其它的编译器,有的甚至不会让这种代码通过(比如Clang)。
事实上因为你取了一个指向那个字符串字面量内存位置的指针,而字符串字面量是储存在内存的只读部分的。
char name[] = "Cherno";
name[2] = 'a';
我们可以定义字符数组,字符数组是支持修改的。
从C++11开始,有些编译器,比如clang,只会让你编译const* char,如果你要编译char,你必须手动将其转换为char
char* name = (char*) "Cherno";
还有一种字符叫做宽字符
const char* name = "lk";
const wchar_t* name2 = L"lk";//注意语法,前面要交一个特定字母,这表示下面的字符由宽字符组成
const char16_t* name3 = u"lk";
const char32_t* name4 = U"lk";
const char* name5 = u8"lk";
基本上,char是一个字节的字符,char16_t是两个字节的16个比特的字符(utf16),char32_t是32比特4字节的字符(utf32)
现在的问题是wchar和char16的区别是什么?因为它们似乎都是两个字节的字符。
string_literals
#include <iostream>
#include <string>
int main()
{
using namespace std::string_literals;
std::string name0 = "hbh"s + " hello";
std::cin.get();
}
string_literals中定义了很多方便的东西,这里字符串字面量末尾加s,可以看到实际上是一个操作符函数,它返回标准字符串对象(std::string)
然后我们就还能方便地这样写等等:
std::wstring name0 = L"hbh"s + L" hello";
string_literals也可以忽略转义字符
#include <iostream>
#include <string>
int main()
{
using namespace std::string_literals;
const char* example =R"(line1
line2
line3
line4)"
std::cin.get();
}
const
1.有人称”const”为伪关键字,因为它在改变生成代码方面做不了什么。他有点像类和结构体的可见性,这是一个机制,使得我们的代码更加“干净”。
2.const基本上就像你做出的承诺,他承诺某些东西将会是不变的。也就是说它不会改变。都是承诺是可以打破的。
3.const适合生成一个常量。
const int Age =90;
int* a = new int;
*a = 2;
a = &Age //error!
//a value of type "const int" cannot be assigned to an entity of type "int*"不能直接进行引用
a =(int*)&Age //逆向引用
这个方法可绕开const进行强制转换,但是不建议这么做,因为很有可能编译器只是把const当作成一个只读的常量,如果你试着做逆向引用然后写入,你很可能程序会崩溃。
const指针
const int Age =90;
const int* a = new int;//添加了一个const
*a = 2;//由于声明了一个常量指针,不能修改内存所指向的值
a =(int*)&Age //当尝试改变a本身的时候不会报错
std::cout << *a << std::endl;//读取a没有任何问题,可以在这里逆向引用他,并且打印它(指针的*运算符通常被称为dereference运算符,某些翻译叫做逆向引用)
(const int* a)常量指针这意味着你不能你不能修改这个指针所指向的内容。
const int Age =90;
int* const a = new int;//改变了const的位置,但是意思却完全不一样了
*a = 2;//可以改变指针所指向的内容
a =(int*)&Age //报错
std::cout << *a << std::endl;
int* const a和 int const* a一样**(指针常量**)可以改变指针所指向地址的内容,但是不可随意改变指针所指向的地址。
在类和方法当中的const
const的第三种用法,他和变量没有什么关系,而是用在方法名的后面(—只有类有这样的写法)这意味着这个方法不会修改任何实际的类,因此这里你可以看到我们不能修改类的成员变量。
class Entity
{
private:
int m_x , m_y;
public:
int Getx() const//这个const使得类中的成员不能发生修改
{
return m_x;
m_x = 2;
}
void Setx(int a)
{
m_x = a;
}
};
void PrintEntity(const Entity& e)
{//使用了常量引用的方式,
std::cout << e.Getx() << std::endl;
}
int main()
{
Entity e;
}
有时候我们会写两个Getx版本,一个有const一个没有。(const可以做同签名重名函数的重载)
上面这个PrintEntity方法会调用const的GetX版本。
所以,我们把成员方法标记为const是因为如果我们真的有一些const Entity对象,我们可以调用const方法。如果没有const方法,那const Entity&对象就掉用不了该方法。
- 如果实际上没有修改类或者它们不应该修改类,总是标记你的方法为const,否则在有常量引用或类似的情况下就用不了你的方法。
mutable
在const函数中, 如果要修改别的变量,可以用关键字mutable:
一般来说是用作debug用的
把类成员标记为mutable,意味着类中的const方法可以修改这个成员。
class Entity
{
private:
int m_x,m_y;
mutable var;
public:
int Getx() const
{
var = 2; //ok mutable var
return m_x; //不能修改类的成员变量
m_x = 2; //ERROR!
}
};
注意:
int* X , Y; //X是指针,Y不是指针
int* X , *Y; //x和y都是指针
1,修饰class const方法中class成员变量,使其可以修改。
2,修饰lambda表达式,值捕获时可以直接操作传入参数。(并非引用捕获,依旧值捕获,不修改原值)
lambda基本上就像一个一次性的小函数,你可以写出来并且赋值给一个变量。
#include <iostream>
int main()
{
int x = 8;
auto f = [=]() mutable
{
x++;
std::cout << x << std::endl;
};
f();
}
创建一个对象
当我们创建一个类的时候,我们就需要进行实例化了,实例化总是会占用内存,就算我们实例化一个空的对象,也至少要占用一个字节的内存。
我们有两种方式进行实例化,区别是内存从哪里来,我们在哪里创建对象。
应用程序会吧内存分成两个主要的部分:堆和栈,还有其它的部分,比如说源代码部分,此时他是机器码。
栈分配
内存的栈和堆
当程序启动后,操作系统所要做的就是将整个程序加载到内存,并且分配一大堆的物理ram,使我们的实际应用程序可以运行。栈和堆是ram当中实际存在的两个区域,stack通常是一个预定义大小的内存区域,通常约为2MB左右,heap也是一个预定义了默认值的区域,但是它可以生长,并且可以随着应用程序的进行而改变。
这两个内存区域的实际位置,在我们的ram当中是完全一样的
很多人倾向于认为栈可能是存储在cpu缓存中或者类似的地方,或者是它活跃在缓存cache当中,因为我们在不断访问它。
事实上不是所有的栈内存都会存储在这个场合当中。这不是它的工作方式。
在我们程序当中,内存是用来实际存储数据的。我们需要一个地方来存储运行程序所需的数据,不管是局部变量还是从文件当中读取的东西。
栈和堆的工作原理非常的不同,但是本质上它们所做的事情是一样的。
区别:
1.定义的方式不同
struct Vector2
{
int x , y;
};
int value = 5; //栈
int array[5];
array[0] = 1;
array[1] = 1;
array[2] = 1;
array[3] = 1;
array[4] = 1;
Vector2 vector;
int *hvalue = new int; //堆,使用new关键字
int* harray = new int[5];
harray[0] = 1;
harray[1] = 1;
harray[2] = 1;
harray[3] = 1;
harray[4] = 1;
Vector2* hvector = new Vector2();
*hval = 5;
delete havlue;
delete harray[];
delete hvector;
2.内存的分配方式不同
在栈上,分配的内存都是连续的。添加一个int,则栈指针(栈顶部的指针)就会移动四个字节。连续分配的数据在内存当中都是连续的,栈分配数据是直接吧数据堆在一起(移动栈指针),所以栈分配数据会比较快。
如果离开作用域,在栈中分配的所有内存都会弹出,内存被释放。
我们输入了array的地址,可以发现array的数据被排布地整整齐齐,但是稍微细心就可以发现这一行又两个重复的5
这其中一个(后面那个)事实上是value变量地址下的值,由于是栈内存分配,这两个值是紧紧挨在一起的。
我们可能会发现这其中有一些字节,这是因为我们在调试模式下运行,它添加了安全守卫(safety guards)在所有的变量周围,以确保我们不会溢出所有的变量
在堆上,分配的内存实际不连续的,new实际上做的就是在内存块的空闲部分”见缝插针”找到空闲的内存块,然后把它用一个指针来圈起来,然后返回这个指针。(都是如果空闲链表找不到合适的内存块,则会想操作系统索要更多的内存,而这种操作是很麻烦的,而且潜在成本是巨大的)
离开作用域之后,堆中的内存不会被释放。
建议能在站上分配就在栈上分配,不能够在栈上分配或者是有特殊需求的时候(比如需要生存周期比函数作用域更长,或者需要分配一些更大的数据),才在堆上分配。
new关键字实际上调用了一个叫做malloc的函数,memory allocate的缩写。这样做通常会调用底层操作系统或者平台的特定函数,这将在堆上为你分配内存。当你启动你的应用的时候,你会的到一定数量的ram分配给你,你的程序会维护一个叫做空闲列表(free list)的东西,它可以跟踪哪些内存块是空闲的,malloc将会调用这些地方的内存。
3.cache miss
栈分配比堆分配更好。
new
new的主要目的就是在堆上分配内存
new + 数据类型/类/数组…
new后面的东西决定了必要的分配大小。
new需要花费时间,因为它需要在空闲列表当中寻找到一个符合要求的内存块。
调用new的时候,事实上也会发生调用malloc,malloc需要传入一个size(多少字节),然后返回一个指针。
使用new的时候,一定要使用delete,不然内存就无法回到空闲列表,内存无法得到释放
CPP隐式转换以及重载
cpp事实上允许编译器堆代码进行一次隐式转换。
如果我们一开始有一个数据类型,然后有另一个类型,在两者之间,CPP允许隐式进行转换,而不需要一个cast强制进行转换。
#include <iostream>
class Entity
{
private:
std::string m_Name;
int m_Age;
public:
Entity(const std::string& name)
: m_Name(name), m_Age(-1) {}
Entity(int age)
: m_Name("Unknown"), m_Age(age) {}
};
int main()
{
Entity test1("cherno");
Entity test2(22);
Entity test3 = "cherno"; //报错
Entity test4 = std::string("cherno");
Entity test5 = 22; //发生隐式转换
std::cin.get();
}
如上,在test5中,int型的23就被隐式转换为一个Entity对象,这是因为Entity类中有一个Entity(int age)构造函数,因此可以调用这个构造函数,然后把23作为他的唯一参数,就可以创建一个Entity对象。
我们也能看到,对于语句Entity test3 = "lk";
会报错,原因是只能进行一次隐式转换,"lk"
是const char
数组,这里需要先转换为std::string
,再从string转换为Entity变量,两次隐式转换是不行的,所以会报错。但是写为Entity test4 = std::string("lk");
就可以进行隐式转换。
尽量避免隐式转换,隐式转换可读性较差
explicit
explicit禁用这个隐式implicit的功能,explicit关键字放在构造函数的前面,如果你有一个explicit的构造函数,这意味着没有隐式的转换,必须显示调用此构造函数
#include <iostream>
class Entity
{
private:
std::string m_Name;
int m_Age;
public:
Entity(const std::string& name)
: m_Name(name), m_Age(-1) {}
explicit Entity(int age) //声明为explicit
: m_Name("Unknown"), m_Age(age) {}
};
int main()
{
Entity test1("cherno");
Entity test2(22);
Entity test3 = "cherno";
Entity test4 = std::string("cherno");
//Entity test5 = 23; 报错
std::cin.get();
}
加了explicit后还想隐式转换,则可以:
Entity test5 = (Entity)22;
运算符重载
CPP允许在程序中定义或者更改运算符的行为
事实上运算符就是一个函数
#include <iostream>
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 Multiply(const Vector2& other) const
{
return Vector2(x * other.x, y * other.y);
}
Vector2 operator*(const Vector2& other) const //定义*操作符
{
return Multiply(other);
}
};
int main()
{
Vector2 position(4.0f, 4.0f);
Vector2 speed(0.5f, 1.5f);
Vector2 powerup(1.1f, 1.1f); //改变speed
Vector2 result1 = position.Add(speed.Multiply(powerup)); //无重载方式
Vector2 result2 = position + speed * powerup; //重载方式
std::cin.get();
}
this关键字
通过this,可以访问成员函数(属于一个类的函数)或者方法。在方法内部,我们可以引用this。
this是一个指向当前对象实例的指针,该方法属于这个对象实例
this关键字存在的理由就是这样不用每个对象都要划分空间去保存函数的声明和定义,只需要在保存一次,然后调用函数的时候传入不同的this就可以了
在CPP中,我们可以写一个非静态方法,为了调用这个方法,我们实例化一个对象来调用。关键字this是指向该对象的指针,这实际上堆方法的一般工作方式非常重要。
#include<iostream>
#include<cstring>
class Entity
{
public:
int x , y;
Entity(int x , int y)
{
//Entity* e = this;
this -> x = x;
this -> y = y;
}
//delete this
//最好不要这样写,如果你delete this之后,试图访问任何类的成员数据,你就会嗝屁,因为内存已经被释放了。
}
int main()
{
std::cin.get();
}
另一个有用的场合是,如果我们想调这个类之外的函数,那就不是类函数了。如果我们想在
#include<iostream>
#include<cstring>
void PrintEntity(const Entity& e)
class Entity
{
public:
int x , y;
Entity(int x , int y)
{
//Entity* e = this;
this -> x = x;
this -> y = y;
Entity& e = *this;
PrintEntity(*this);
//如果在一个const方法中,我们可以将*this赋值给const Entity&。(吧指针传给引用)
}
}
void PrintEntity(const Entity& e)
{
//print
}
int main()
{
std::cin.get();
}
对象生存周期
关于生存期对于基于栈的变量意味着什么
每次我们在c++进入一个作用域,都是在进入一个栈帧,他不一定非得是将数据推进一个栈帧。
作用域可以是任何东西,比如说函数的作用域,或者for和while的作用域。或者空作用域,类作用域。
关于栈堆的详细介绍可以见之前的笔记。
int CreateArray()
{
int array[50]; //在栈上创建的
return array;
}
int main()
{
int* a = CreateArray(); //不能正常工作
}
智能指针
智能指针本质上是一个原始指针的包装,当你调用一个智能指针,他就会调用new并且为你分配内存。这些内存会在某一个时刻自动释放。
unique_ptr
智能指针在C++14被引入
unique_ptr是作用域指针,是超出作用域的时候,它会被销毁,然后调用delete。
你不能复制一个unique_ptr,因为如果你复制一个unique_ptr,你会有两个指针,两个unique_ptr指向同一个内存块。
如果其中一个指针释放内存了,也就是说,你指向同一块内存的第二个unique_ptr指向了已经被释放的内存。
unique_ptr的constructor是explicit的,不能使用隐式转换
最好使用std::unique_prt
#include<iostream>
#include<string>
#include<memory>
class Entity
{
public:
Entity()
{
std::cout << "Create Entity!" << std::endl;
}
~Entity()
{
std::cout << "Destroyed Entity!" << std::endl;
}
};
int main()
{
{//在特定的作用域下创建一个智能指针
std::unique_ptr<Entity> entity = std::make_unique<Entity>();
std::unique_ptr<Entity> e0 = entity;//exception:cant copy
entity->Print();
}
std::cin.get();
}
shared_ptr
与unique有点不同,shared_ptr实现的方式实际上取决于编译器和你在编译器当中使用的标准库。shared_ptr的工作原理是通过引用计数,引用计数基本上是一种方法,可以跟踪你的指针有多少个引用,一旦引用数为0,他就会被删除。
int main()
{
{
std::shared_ptr<Entity> e0;
{
std::shared_ptr<Entity> sharedEntity = std::make_shared<Entity>();//标准格式
e0 = sharedEntity;//可以进行复制。
}//此时sharedEntity已经“无了”,但是并没有调用析构函数,因为e0仍然是活的,并且持有对该Entity的引用,此时计数由2->1
}//析构被调用,因为所有的引用都消失了,计数由1->0,内存被释放
std::cin.get();
}
shared_ptr需要分配另一块内存,叫做控制块,用来储存引用计数。也就是做两次分配,先做一次new Entity的分配,然后就是shared_ptr的控制内存块的分配。如果你使用make_shared你就可以把它们组合起来。这样更加有效率
优先选择unique_ptr,然后选择shared_ptr。
weak_ptr
可以与shared_ptr一起使用。
weak_ptr可以被复制,但是不会增加额外的控制块来控制计数,仅仅是声明整个指针还活着。
当你将一个shared_ptr赋值给另外一个shared_ptr,引用计数会增加,但是把一个shared_ptr赋值给了一个weak_ptr的时候,他不会增加引用计数。
使用场景:如果你想要Entity的所有权,就像你可能在排序一个Entity列表,你不关心它们是否有效,你只需要存储它们的一个引用就好了。
{
std::weak_ptr<Entity> e0;
{
std::shared_ptr<Entity> sharedEntity = std::make_shared<Entity>();
e0 = sharedEntity;
} //此时,此析构被调用,内存被释放
}
CPP的赋值与拷贝构造函数
拷贝指的是要求复制数据,复制内存。当我们想要把一个对象或一段数据从一个地方复制到另一个地方的时候。我们实际上有两个副本。
#include<iostream>
#include<cstring>
struct Vector2
{
float x , y;
};
int main()
{
Vector2* a = new Vector2();
Vector2 b = a;//我们复制两个指针,本质上有相同的值
b.x = 5;//但是如果我访问这个内存地址,设置为某一个值,在这种情况下是会同时影响a和b的,因为它们指向的是同一个内存地址
std::cin.get();
}
class String
{
private:
char* m_Buffer;
unsigned m_Size;
public:
String(const char* string)
{
m_Size = strlen(string);
m_Buffer = new char[m_Size+1];
memcpy(m_Buffer,string,m_Size);
m_Buffer[m_Size] = 0;
}
~String()
{
delete [] m_Buffer;
}
friend std::ostream& operator<<(std::ostream& stream, const String& string);//把<<操作符重载函数声明为String类的友元,这样就可以在该重载函数中访问m_Buffer
};
std::ostream& operator<<(std::ostream& stream, const String& string) //<<操作符重载,用来打印我创建的字符串
{
stream << string.m_Buffer;
return stream;
}
{
String string = "Cherno";
String second = string;
std::cout << string << std::endl;
std::cout << second << std::endl;
std::cin.get();
}
运行会报错
当我们复制string的时候,cpp会自动复制所有的类成员变量,而这些成员变量组成了类(实际的内存空间),它是由一个char*和一个unsigned int组成的,他将这些值复制到一个新的内存地址里面,这包含了second字符串。
现在内存有两个string,因为他们直接进行了复制。这种复制被称为“浅拷贝”
对于char* m_Buffer,他所作的是去复制这个指针。
在内存中的两个string有相同的char*的值,即相同的内存地址。
下面是将cherno中的e换成a的实现
class String
{
private:
char* m_Buffer;
unsigned m_Size;
public:
String(const char* string)
{
m_Size = strlen(string);
m_Buffer = new char[m_Size+1];
memcpy(m_Buffer,string,m_Size);
m_Buffer[m_Size] = 0;
}
~String()
{
delete [] m_Buffer;
}
char& operator[](unsigned int index)//重载运算符,目的是获取index下的reference
{
return m_Buffer[index];
}
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;
}
{
String string = "Cherno";
String second = string;
second[2] = 'a';//替换
std::cout << string << std::endl;
std::cout << second << std::endl;
std::cin.get();
}
输出:
Charno
Charno
这个例子已经很明显了
我们真正需要做的是,分配一个新的char数组,来存储复制的字符串。而我们现在做的是复制指针,两个字符串对象指向完全相同的内存缓冲区。
我们需要进行深拷贝这个方法
深拷贝复制整个对象。
我们可以使用拷贝构造函数:当你复制第二个字符串或者什么时,它会被调用
cpp在默认情况会给你一个拷贝构造函数。
class String
{
private:
char* m_Buffer;
unsigned m_Size;
public:
String(const char* string)
{
m_Size = strlen(string);
m_Buffer = new char[m_Size+1];
memcpy(m_Buffer,string,m_Size);
m_Buffer[m_Size] = 0;
}
/*
String(const String& other) : m_Buffer(other.m_Buffer) , m_Size(other.m_Size)//默认下的拷贝构造函数
{
}
String(const String& other) = delete;//禁用拷贝构造函数,不允许复制
*/
String(const String& other) : m_Size(other.m_Size)
{
m_Buffer = new char[m_Size + 1]);//细节\0
memcpy(m_Buffer , othter.m_Buffer, m_Size + 1);
}
~String()
{
delete [] m_Buffer;
}
char& operator[](unsigned int index)//重载运算符,目的是获取index下的reference
{
return m_Buffer[index];
}
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;
}
void Print(String string)
{
std::cout << string << std::endl;
}
{
String string = "Cherno";
String second = string;
Print(string);
Print(second);
second[2] = 'a';
std::cin.get();
}
运行这个代码,输出:
Cherno
Charno
好像已经成功了,但是如果我们修改一下拷贝构造函数再运行
String(const String& other) : m_Size(other.m_Size)
{
std::cout << "Copied" << std::endl;
m_Buffer = new char[m_Size + 1]);//细节\0
memcpy(m_Buffer , othter.m_Buffer, m_Size + 1);
}
输出:
Copied
Copied
Cherno
Copied
Charno
c出现了三个string的复制,这看上去有些荒谬,因为我们不需要做这些复制。
当我们复制一个字符串的时候,我们再堆上分配内存,复制所有的内存,最后释放。但是我们事实上不需要这样做,我们想做的是,将现有的字符串直接进入这个Print函数。
做下面的修改就行:
void Print(const String& string)
{
std::cout << string << std::endl;
}
成员不包括指针和引用时,浅拷贝和深拷贝没区别。
cherno:“在基础使用的时候,使用const引用更好,总是通过const引用传递对象”
箭头操作符
箭头运算符必须是类的成员。
- 一般将箭头运算符定义成了const成员,这是因为与递增和递剑运算符不一样,获取一个元素并不会改变类对象的状态。
int main()
{
Entity e;
e.Print();
Entity* ptr = &e;
ptr -> Print();
(*ptr).Print();
std::cin.get();//这样就可以调用
}
- ->的重载。
#include <iostream>
class Entity
{
private:
int x;
public:
void Print()
{
std::cout << "Hello!" << std::endl;
}
};
class ScopedPtr
{
private:
Entity* m_Ptr;
public:
ScopedPtr(Entity* ptr)
: m_Ptr(ptr)
{
}
~ScopedPtr()
{
delete m_Ptr;
}
Entity* operator->()//重载操作符
{
return m_Ptr;//返回一个Entity的指针
}
};
int main()
{
{
ScopedPtr entity = new Entity();
entity->Print();
}
std::cin.get();
}
计算成员变量的offset(偏移量)
引自B站评论:
因为”指针->属性”访问属性的方法实际上是通过把指针的值和属性的偏移量相加,得到属性的内存地址进而实现访问。 而把指针设为nullptr(0),然后->属性就等于0+属性偏移量。编译器能知道你指定属性的偏移量是因为你把nullptr转换为类指针,而这个类的结构你已经写出来了(float x,y,z),float4字节,所以它在编译的时候就知道偏移量(0,4,8),所以无关对象是否创建
struct vec2
{
int x,y;
float pos,v;
};
int main()
{
int offset = (int)&((vec2*)nullptr)->x; // x,y,pos,v的offset分别为0,4,8,12
std::cout<<offset<<std::endl;
std::cin.get();
}
std::vector
动态数组
template(模板)
模板:模板允许你定义一个可以根据你的用途进行编译的模板(有意义下)。故所谓模板,就是让编译器基于DIY的规则去为你写代码 。
函数的模板(对形参)
不使用模板
void Print(int temp) {
cout << temp;
}
void Print(string temp) {
cout << temp;
}
void Print(double temp) {
cout << temp;
}
int main() {
Print(1);
Print("hello");
Print(5.5);
//如果要用一个函数输出三个类型不同的东西,则要手动定义三个不同重载函数
//这其实就是一种复制粘贴就可以完成的操作
}
使用模板
格式: template
template<typename T> void Print(T temp) {
//把类型改成模板类型的名字如T就可以了
cout << temp;
}
//干净简洁
int main() {
Print(1);
Print("hello");
Print(5.5);
}
通过
template
定义,则说明定义的是一个模板,它会在编译期被评估,所以template后面的函数其实不是一个实际的代码,只有当我们实际调用时,模板函数才会基于传递的参数来真的创建 。 只有当真正调用函数的时候才会被实际创建 。
模板参数
template<typename T> void Print(T temp) {
cout << temp;
}
int main() {
Print(96);//这里其实是隐式的传递信息给模板,可读性不高
Print<int>(96);//可以显示的定义模板参数,声明函数接受的形参的类型!!!
Print<char>(96);//输出的可以是数字,也可以是字符!这样的操纵性强了很多!!!
}
CPP的宏(macro)
它能够将代码当中的文本替换为其它的东西,这基本上像是遍历我们的代码之后执行查找和替换
它可以使用形式参数,实参和变量这些来进行查找和替换。我们可以自定义调用和宏的方式。
以日志系统作为例子
你可以在日志中使用宏,你记录日志的方法可能基于你的设置会发生变化。
在我们的程序中有两种设置“debug”和“release”,debug模式用来调试。
我们可能在debug模式下,我们可能想将所有的东西日志记录下来,但是在release模式我们不想这么做。
我们可以通过宏来做到这一点。
到项目的properties-c/c++-Preprocessor里,在前面定义一个DEBUG.
(你的编译器可能有,也可能没有定义debug或者_DEBUG。但是我们可以自定义)
如图,之后我们可以切换到release模式添加PR_RELEASE并且保存
然后写这样的代码,并且在debug模式下编译运行,可以发现LOG被使用了
接下来的方法可以在properties中吧PR_DEBUG定义为PR_DEBUG=1;
宏定义的多行写法
具体做法是在行的某位添加一个\换行符并且另起一行
auto关键字
auto可以让c++自动推导出数据的类型。不管是创建、初始化变量数据的时候,还是将一个变量对另一个变量进行赋值的时候。
假设我们有一个string,我们创建了一个string,然后我们可能在类当中有一个函数,返回一个string。当我们对返回类型赋值的时候,比如返回我们现在这个函数的局部变量。我们不需要实际去输入string,我们只需要输入auto,然后就会计算出应该有的类型。
std::string GetName()
{
return "Cherno";
}
int main()
{
std::string name = GetName();
auto name = GetName();
//都可以
cin.get();
}
int main()
{
auto a = 5; //一个int
auto b = 5L; //一个long
auto c = 5.5f; //一个float
auto d = "cherno"; //一个const char*
//不管右边是什么,我们都不需要对auto改变类型。
}
在使用iterator的时候:
在使用iterator 的时候,如:
std::vector<std::string> strings;
strings.push_back("Apple");
strings.push_back("Orange");
for (std::vector<std::string>::iterator it = strings.begin(); //不使用auto
it != strings.end(); it++)
{
std::cout << *it << std::endl;//肉眼可见的麻烦
}
for (auto it = strings.begin(); it != strings.end(); it++) //使用auto
{
std::cout << *it << std::endl;
}
当类型名过长的时候可以使用auto
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
class Device{};
class DeviceManager
{
private:
std::unordered_map<std::string, std::vector<Device *>> m_Devices;
public:
const std::unordered_map<std::string, std::vector<Device *>> &GetDevices() const
{
return m_Devices;
}
};
int main()
{
DeviceManager dm;
const std::unordered_map<std::string, std::vector<Device *>> &devices = dm.GetDevices();//不使用auto
const auto& devices = dm.GetDevices(); //使用auto
std::cin.get();
}
除此之外类型名过长的时候也可以使用using或typedef方法:
using DeviceMap = std::unordered_map<std::string, std::vector<Device*>>;
typedef std::unordered_map<std::string, std::vector<Device*>> DeviceMap;
const DeviceMap& devices = dm.GetDevices();
auto使用建议:如果不是上面两种应用场景,请尽量不要使用auto!能不用,就不用!降低代码的可读性。
库
静态库
c++中使用库(静态链接)
很多语言都有包管理器。
windows在下载库的时候可能可以下载二进制文件,有时候这些二进制文件分了32/64位,这事实上与你的操作系统位数没有任何的关系。这与你的目标程序有关。
以下是chatgpt给出的一些QA:
C++的库是什么:
C++库是一个预先编写好的程序代码集合,可以在C++编程中被调用并重复使用。C++库包含了一些预先定义好的函数和类,这些函数和类可以让程序员在编写C++程序时更加高效、简单和易于维护。C++库可以分为标准库和第三方库两种类型。
标准库是由C++编程语言定义的一组库,它包含了一些常用的函数和类,例如输入/输出流、字符串处理、数学计算等。标准库被定义为C++语言的一部分,因此可以在任何支持C++编程的环境中使用,无需安装或下载任何其他库。
第三方库是由独立开发人员或组织编写的C++库,通常包含一些更特定的功能或领域的工具,例如图形用户界面(GUI)、网络编程、数据库访问、机器学习等。这些库通常需要安装或下载,并在程序中显式地链接使用。常见的第三方C++库包括Boost、OpenCV、Qt、STL等。
什么是静态链接和动态链接,这些概念与库有什么关系吗,标准库是动态库还是静态库,为什么
静态链接和动态链接是两种不同的将库文件与可执行文件组合成最终可执行程序的方式。
静态链接是将库文件的代码与可执行文件的代码合并成一个文件,生成一个包含所有代码和数据的单一可执行文件。这种方式的优点是程序的可移植性更好,因为所有的代码都在一个文件中,不需要依赖外部库文件;缺点是可执行文件较大,占用空间较多,而且每个使用该库的程序都需要拷贝一份该库的代码,造成重复浪费。
动态链接是将库文件的代码单独存放在磁盘上,程序在运行时动态地加载库文件。这种方式的优点是可执行文件较小,占用空间较少,多个程序可以共享同一个库文件,节省空间和内存;缺点是程序对外部库文件的依赖性较强,如果库文件不存在或版本不兼容,程序将无法运行。
这些概念与库密切相关。库可以是静态库,也可以是动态库。标准库在不同的操作系统中的实现方式可能有所不同,但通常都是动态库,因为标准库代码非常庞大,使用静态库会导致可执行文件非常大。此外,动态库还可以在运行时进行更新,使得操作系统或者其他应用程序能够共享该库,提高资源利用率。
有些库分普通文件和二进制文件bin,这两种文件有什么区别吗
普通文件和二进制文件是两种不同的文件类型。
普通文件(text file)是以文本形式编写的文件,通常由ASCII码字符组成。这种文件包含可读的字符和控制字符,例如换行符、制表符等。普通文件可以使用文本编辑器打开和编辑。
二进制文件(binary file)是由计算机程序生成的文件,其中包含了机器语言指令或者其他二进制数据。这种文件不能以文本形式编辑或者阅读,必须使用特定的二进制文件编辑器或者程序进行处理。
对于库文件来说,普通文件和二进制文件的区别在于它们存储的内容不同。普通文件存储的是库的源代码,需要被编译成二进制文件才能被计算机执行。而二进制文件存储的是编译后的库文件,可以直接被计算机加载和执行。因此,在使用库文件时,通常需要使用二进制文件来链接程序。普通文件通常只用于库的源代码阅读或者修改。
需要注意的是,有些库文件可能同时提供普通文件和二进制文件,例如一些开源库,这些文件通常被放置在不同的目录下。普通文件和二进制文件都是库的重要组成部分,它们共同协作构成了一个可被程序调用和使用的库。
库通常包含两个部分:includes和library,包含目录与库目录。
包含目录是一堆头文件,这样我们就可以实际使用预构建的二进制文件中的函数。
然后lib目录有那些预先构建的二进制文件。
这里通常有两个部分,有动态库和静态库,但是并不是所有的库都为你提供了这两种库。但是glwf库(后续用这个库举例子)为你提供了两种使你选择静态链接或者是动态链接。
动态链接是在运行的时候被链接的,所以你仍然有一些链接,你可以选择在程序运行的时候,装载动态链接库。
静态链接在技术上更加快,因为编译器或者链接器实际上可以执行链接时优化之类的。静态链接在技术上可以产生更快的应用程序,因为有几种优化方法可以应用。在链接时候我们要链接的函数,对于动态库来说,我们不知道会发生什么,我们必须保持它的完整,当库被运行时的程序装载时,程序的部分将被补充完整。通常静态链接是最好的选择
但是为了演示这两种策略,我们有包含文件,然后我们有库文件,两种文件都需要设置。对于我们的编译器,在我们的visualstudio项目中,我们必须把它指向头文件,这样我们就知道哪些函数是可用的,然后我们就有了这些函数声明。实际上是符号声明,因为它们也可以是变量。
进入visualstudio创建project,创建一个dependencies文件夹。
在里面导入glwf库。
一般来说要导入include和lib两种类型的文件夹。
在include文件夹里,我们可以发现一些头文件
而在lib文件夹里,我们可以发现三个文件
其中第一个文件是一个动态链接库,下面两个文件都是静态库。glfw3dll.lib是和这个glwf3.dll一起用的,这样我们就可以不需要实际询问dll,它包含了dll里所有的函数,符号的位置,所以我们可以在编译的时候链接它们。但事实上我们没有这个lib文件,我们也可以直接使用这个dll文件,我们要通过函数名来访问dll文件内的函数。但是这个lib文件已经包含了所有这些函数的位置,链接器可以直接链接到它们。
glwf3.lib是最大的文件,是静态链接库,如果我们不想要编译的时候链接,我们就可以链接这个lib。如果我们这样做,在exe运行的时候,我们就不需要这个dll。
接下来,右键项目名称进入properties,在c/c++下的genetal,additional include directories,我要指定附加的包含目录。记住注意你的配置与平台,确保你的编辑是正确的。
设置选择all configuration。
包含目录是这个include文件夹的实际目录。
但是如果你直接使用c盘路径,别人在github上获得你的代码后编译不会成功,我们需要设置相对路径。
从选择方案文件开始,输入$(SolutionDir)\Dependencies\GLWF\include,这是一个宏,你可以在visualstudio中使用,在这个下面,你还可以编辑,你可以看到这个宏实际上是一个值,也就是解决方案目录。点击macro,我们可以看到所有的宏,比如ProjectDir它是项目的目录。
头文件””会先检查相对路径再检查编译器
我们再代码中添加了glfwinit函数,但是编译器却报了链接错误,我们进入glfwinit,发现源代码只提供了函数的声明,没有函数的定义,这是我们不想要的。
再到properties下-linker-input。我们要包含glfw3.lib文件。
我们事实上可以先到linker-general下复制相对路径,然后再input下直接填写glfw3.lib
也可与通过将#include “GLFW/glfw3.h”替换成
extern “C” int glfwInit();
这种方式进行函数的声明
动态库
静态链接允许更多优化发生,因为编译器和链接器可以从静态链接中看到更多的东西。
动态链接发生在运行的时候进行链接,将另一个文件加载到内存中。可以完全动态的加载动态库,这样可执行文件就与动态库没有任何关系了,你可以启动你的可执行文件,你的应用程序,他甚至不会要求你包含一个特定的动态库,但是在你的可执行文件中,你可以写代码,去查找并在运行时加载某些动态库,然后获得函数指针或任何你需要的那个动态库中的东西,然后使用那个动态库。
引用之前的例子,动态库的函数声明与静态链接有所不同,但是glfw像大多数库一样,同时支持静态和动态链接,使用相同的头文件。
需要再重新设置linkder的input设置,将第三方库加入glfw3.dll,glfw3dll.lib。后者是一大堆前者函数的指向。
如果只载入glfw3dll.lib,编译程序会报错找不到第三方库,比较简单的解决方法是我们可以通过把dll文件放到可执行文件的目录下,这是一种自动搜索路径,这样会编译成功。
综上,我们要确保一个可访问的地方有dll文件。
我们可以进入头文件查看静态链接与动态链接之间的区别。
我们可以看到这里的任何函数,你会看到它在反悔类型和实际函数名之前定义了GLFWAPI
创建一个库
先创建空项目,再添加new project
看到一个solution下有两个文件。libtst将会成为我们的可执行文件。右键属性,确保libtst属性页面下,我们的配置类型,在general属性下,设置为应用程序。然后我们看engine,将其的configuration type设置为static library。
game项目会有一个application.cpp文件,这基本上事我们应用的源文件,也就是主文件。
可以通过右键libtst-properties-add-reference
引入engine库,这是visualstudio简化操作的一种方式。
这也给了我们另一个好处,除了不需要处理链接设置输入文件外,很明显,我们如果把engine的名字改成core什么的,编译器会帮助你自动化处理。
engine现在是libtst的依赖,这意味着如果engine内部的某些东西发生了变化,然后我们去编译libtst,实际上会编译engine和libtst,所以我们知道我们总是在处理最新的代码,而不是,哦等等,我忘记编译engine了,然后就是各种不能用
C++中处理多返回值
可以通过函数参数引用处理多返回值。
tuple返回多个不同类型的变量
tuple本质上是一个类,他可以包含x个变量,但是它不关心类型
#include<iostream>
#include<utility>
#include<tuple>
std::tuple < std::string, std::string, int> Get()
{
return std::make_tuple("小明", "男", 18);
}
int main()
{
std::tuple < std::string, std::string, int> guy = Get();
std::string name;
std::string gender;
int age;
std::tie(name, gender, age) = guy;
std::cout << name << std::endl << gender << std::endl << age << std::endl;
return 0;
}
pair返回两个不同类型的变量
#include<iostream>
#include<utility>
std::pair<std::string, int> Get()
{
return std::make_pair("小明", 18);
}
int main()
{
std::pair < std::string, int> guy = Get();
std::string name;
int age;
std::tie(name, age) = guy;
std::cout << name << std::endl << age << std::endl;
return 0;
}
vector返回多个相同的变量
函数指针
函数指针是将一个函数赋值给一个变量的方法
auto关键字对于函数指针之类的东西非常有用
#include<iostream>
void HelloWorld()
{
std::cout << "Hello World!" << std::endl;
}
int main()
{
void(*cherno)();//实际的类型
cherno = HelloWorld;
auto function = HelloWorld;//去掉括号之后就不是调用这个函数了,我们实际上是在获取函数指针,function获得了函数的地址
function();
return 0;
}
可以使用typedef来写
#include<iostream>
void HelloWorld(int a)
{
std::cout << "Hello World : " << a << std::endl;
}
int main()
{
typedef void(*HelloWorldFunction)(int);
HelloWorldFunction function = HelloWorld;
function(5);
return 0;
}
void Print(int val) {
std::cout << val << std::endl;
}
//下面就将一个函数作为形参传入另一个函数里了
void ForEach(const std::vector<int>& values, void(*function)(int)) {
for (int temp : values) {
function(temp); //就可以在当前函数里用其他函数了
}
}
int main() {
std::vector<int> valus = { 1, 2, 3, 4, 5 };
ForEach(values, Print); //这里就是传入了一个函数指针进去!!!!
}
for (int temp : values) 是C++11引入的新的for循环语法,称为“范围for循环”(range-based for loop)。
它的语法格式是 for (element : range)
,其中 element
表示要遍历的元素, range
表示要遍历的范围。在本例中,values
是一个 std::vector<int>
类型的容器对象, temp
是要遍历的元素。
这个语法让代码更加简洁易读,避免了手动处理迭代器的麻烦。在循环的每个迭代中, temp
将被赋值为 values
中的下一个元素。在本例中, ForEach
函数使用范围循环遍历 values
,将每个元素传递给函数指针 function
,并对其进行操作,最终输出到控制台。
优化:lambda
lambda本质上是一个普通的函数,只是它不像普通函数这样声明,它是我们的代码在过程中生成的,用完即弃的函数,不算一个真正的函数,是匿名函数 。
格式:[] ({形参表}) {函数内容}
void ForEach(const std::vector<int>& values, void(*function)(int)) {
for (int temp : values) {
function(temp); //正常调用lambda函数
}
}
int main() {
std::vector<int> valus = { 1, 2, 3, 4, 5 };
ForEach(values, [](int val){ std::cout << val << std::endl; }); //如此简单的事就交给lambda来解决就好了
}
匿名函数
Lambda表达式是C++11引入的一种新的语法,可以用来创建匿名函数对象,常用于STL算法、函数对象等场景。
Lambda表达式的基本语法如下:
bashCopy code
[capture list](params list) -> return type { function body }
其中,capture list
表示捕获列表,用于指定lambda表达式中需要捕获的外部变量;params list
表示参数列表,用于指定lambda函数的参数;return type
表示返回值类型,用于指定lambda函数的返回类型;function body
表示函数体,用于指定lambda函数的实现。
例如,下面的lambda表达式用于计算两个整数的和:
auto sum = [](int a, int b) -> int { return a + b; };
std::cout << sum(1, 2) << std::endl; //输出3
在这个例子中,[ ]
表示空的捕获列表,即lambda表达式不需要捕获任何外部变量;(int a, int b)
是参数列表,用于指定两个整型参数;-> int
是返回类型,指定lambda函数返回一个整型值;{ return a + b; }
是函数体,实现了计算两个整数的和的功能。
除了基本语法外,lambda表达式还可以使用自动类型推导、省略参数列表、省略返回类型等特性,让代码更加简洁。例如,下面的例子使用自动类型推导,省略了参数列表和返回类型,实现了一个简单的打印字符串的lambda函数:
auto print = [](auto&& message) { std::cout << message << std::endl; };//完美转发,涉及到左值和右值的概念
print("hello, lambda!"); //输出hello, lambda!
Lambda表达式是C++11引入的重要特性之一,使用得当可以使代码更加简洁、清晰。
如果使用捕获,则:
- 添加头文件:
#include <functional>
- 修改相应的函数签名
std::function <void(int)> func
替代void(*func)(int)
- 捕获[]使用方式:
[=]
,则是将所有变量值传递到lambda中[&]
,则是将所有变量引用传递到lambda中[a]
是将变量a通过值传递,如果是[&a]
就是将变量a引用传递
它可以有0个或者多个捕获
我们有一个可选的修饰符mutable,它允许lambda函数体修改通过拷贝传递捕获的参数。若我们在lambda中给a赋值会报错,需要写上mutable 。
int a = 5;
auto lambda = [=](int value) mutable { a = 5; std::cout << "Value: " << value << a << std::endl; };