c++与面向对象编程1
前置
全文使用使用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语言的兼容性。使用结构体和类只是个人的代码风格问题,并没有优劣之分