写在前面
人生++
我真的厌倦了面向AI编程和学校政治性大于实用性的项目。学校里做的项目大多是带有理想主义色彩逗小孩玩的东西,几乎无法被市场认可也绝无可推广性。
黄仁勋老说未来的编程语言是英语,然而就我身边人的智商来说全民编程的时代远未到来,就算到来了,也总有人要做底层的东西。不管怎么讲,我想使用真正的human brains coding去开发至少是争取让市场认可的程序,或者only to satisfy myself
另外我更多的希望编程是用于开发而非只是做题。
我写这个博客只是为了记学习cherno的c++教程的笔记,方便以后复习。说不定会有别的用,比如教我的学弟,堂妹或者亲妹妹编程(后者概率比较小)
你可以在油管上关注他的频道TheCherno或者看墙内的汉化版C++ 教程 - 油管大佬The Cherno C++ 教程_哔哩哔哩_bilibili
以上
1、C++运行的深层原理,compiler和linker
这一part对你编程并没有什么用,对你做题考试也没有什么帮助,但是我觉得一定程度上可以增加你的学习热情,是有好处的。
1.1、Compiler编译器
编译器的工作步骤分为:
- 预处理(preprocess),也就是处理带#的header
- 把C++代码翻译成汇编语言(计算机科学专业可能要学这个),也就是.obj文件。
- 把汇编语言翻译成机器码(01)给CPU执行
编译器的原理和注意事项
实际上,#include的原理非常简单:就是单纯的copy&paste。以我们常用的#include<iostream>为例,这段代码实际上copy paste了大量原生自带的变量和代码等等,实际上,你的#include可以copy&paste任何东西,类,变量,函数,单纯的文字,甚至标点符号等等。
我们可以注意到带了#include的cpp文件比不带的cpp文件大了很多很多。
我们可以让预处理器生成预处理文件来查看这个过程

我们可以看到大量的前置代码
编译器是一根筋的人,他的执法范围只在当前文件,他只关心当前文件有没有编译错误。
举个例子:
int main()
{
Sum(0,1);
}
显然,我们并未声明函数Sum,因此我们发生了编译器错误:
error C3861: “Sum”: 找不到标识符
然而如果我们去声明这个函数(并没有定义它),并且创建个空的Sum.h让它看起来更真一点。
#include "Sum.h"
int main()
{
void Sum(int a,int b);
Sum(0,1);
}
然后按Ctrl+F7编译,可以发现编译是成功的,并没有报错。
我们不难发现:编译器只管声明不管实现,仅检查当前单个文本,如果通过审查,就不报错。换一句话说,我们说服编译器相信了有一个叫Sum的函数存在,即使我们欺骗了它,但是它就是相信了。
1.2、链接器Linker
在C++中,我们经常讲很多函数和代码实现分开来写,产生复数个文件。实际上,这些文件在被编译以后是没办法自己交流的,这个时候就要依靠链接器来链接。
在上一个例子中,因为代码合乎语法,所以编译器予以通过。但是链接器无法找到函数Sum,因此会发生链接器错误:
error LNK2019: 无法解析的外部符号
所以虽然编译器相信了有函数Sum存在,但是实打实的找到这个函数是链接器的工作。
现在我们已经对C++的运行原理有了基本的了解,我们可能会在后面正式学习代码的时候再碰到他们。
2、C++变量一览
2.1、变量的本质是什么
实际上,不同变量之间的区别只有其占用内存的大小。
我们简单谈谈大 小,在计算机中,最基础的大小单位是比特(bits),一个比特只有可能包含0或者1。
然而我们最常用的大小单位是字节(bytes),一字节包含8比特。我们通常看到比如某个文件有1kb大小,这个b就是指字节,1kb也就是一千(实际上是1024)字节大小。
让我们来研究一下大小:
我们创建一个整型变量int a
我们可以知道,一个整数占用的内存(memory)大小是4字节(bytes)。由此我们可以计算得出整数取值的范围大小。
已知,单个整数可占用内存大小是4*8=32bits。其中,有一个bit(0/1)要被拿来表示符号(+/-)因此我们还剩下31bits来表示数值。
而2的31次方大概就是20亿
这个数值。
然而,我们还有一个没有正负号的零,所以实际上要减去1。
当然,我们可以通过在变量前加unsigned修饰说明它是无号的,来让他的范围变得更多。
所以不同的变量short,long,double等等本质上只有其占用内存大小的区别。
2.2、特殊变量
关于char:
char是字符变量,然而其本质也是一个整数。只是某个字母绑定了这个整数。
#include<iostream>
int main()
{
char a = 'A';#notice here
std::cout << a << std::endl;
std::cin.get();
}
输出A
#include<iostream>
int main()
{
char a = 65;
std::cout << a << std::endl;
std::cin.get();
}
同样输出A
这正是因为:65就是”A“绑定的整数。
同理,以下两种情况都生成65
#include<iostream>
int main()
{
int a = 65;
std::cout << a << std::endl;
std::cin.get();
}
#include<iostream>
int main()
{
int a = 'A';
std::cout << a << std::endl;
std::cin.get();
}
关于bool:
另一个比较特殊的bool类型,它只有True/False两种情况(1/0)。
然而,它不能只占用一个bit,他至少要占用一个byte。但是可以用一个byte存储8个bool类型。
3、函数(Function)
3.1、初识函数
简单来说,函数就是你给它传入某些值然后它输出某个值。当我们重复多次执行某个操作的时候,我可以使用函数来提高效率降低错误率。
例如:
int Multiply(int a,int b)//这一段叫函数签名
{
int result = a*b;
return result;
}
在这个函数里,我们传入整数a,b并返回一个整数结果(int)。
3.2、为什么main函数没有返回值
比较特殊的函数是main函数。main函数并不需要返回值,虽然它的签名是int main()。实际是,main有默认的返回值也就是0,你也可以自己写一个return 0;不过没有任何必要。main存在的意义只是给编译器一个编译的入口而已。
除此特殊情况,你不应该在任何宣称函数有返回值的情况下不返回任何东西。
3.3、函数的运行逻辑和注意事项
请记住,函数要先声明再调用。如果你看过热血青年漫,你会发现一般他们使用技能之前会把技能名喊出来。原理是类似的。
函数的定义是告诉编译器函数到底在干什么,它可以在任何地方,但是当你在调用前定义这个函数时,可以不用声明它。声明是告诉编译器这个函数存在,它必须要在调用前。调用则是我们正式传参使用这个函数。
4、头文件(Header Files)
4.1、为什么需要头文件
我们已经知道,函数要先声明再调用。也就是说每次当我需要调用某个函数前我都需要进行声明。
假设我们在某个文件Log.cpp中存在一个函数Log:
//File Log.cpp
#include<iostream>
void Log(const char* message)//这一段就叫函数签名
{
std::cout<<message<<std::endl;
}
而我们在另一个文件Main.cpp中需要调用它,那我们需要先声明,也就是copy&paste他的函数签名。
//File Main.cpp
void Log(const char* message);//paste我们的函数声明
void InitLog()
{
Log("Initializing Log");
}
但是如果我们有很多个项目文件需要用到这个Log函数,那么用头文件(header files)可以使整个过程简单一点。
我们前面介绍过#include头文件的原理实际上就是copy&paste,因此我们可以创建一个头文件来存放我们的声明方便管理:
现在我们创建一个头文件log.h来存放我们的声明。
//File log.h
#pragma once
void Log(const char* message);
于是我们可以把Main.cpp改成这样:
//File Main.cpp
#include "log.h"//#include我们的头文件
void InitLog()
{
Log("Initializing Log");
}
4.2、头文件保护
#pragma once是干什么的呢:
它会防止我们多次#include同一个东西。
比如你在这个头文件中定义了某个函数或者类然后多次将它#include进不同的文件就会发生重定义的错误。
比如你不小心嵌套#include了这个文件也可能发生类似的错误。
其实我们还有另外一种过时的写法可以实现完全相同过的效果,也是华工国际校区高程课本上的写法。虽然这种写法已经过时了,又长又没有意义,但是鉴于中国的教育就是喜欢教给你已经死了的东西,所以我们还是简单介绍一下:
//File log.h
#ifndef LOG_H
#define LOG_H
void Log(const char* message);
#endif
二者没有任何区别,命名规则上宏名大概是全大写和拿“_“代替”.“
当前市面上绝大多数编译器都支持第一种写法
4.3、Why
为什么#include<iostream>
简单来说,iostream是c++原生库,所以没有后缀名。实际上你也可以写#include "iostream"
5、条件判断语句(Conditions&Branches)
我们这几趴简单一点因为你如果学过其他语言甚至小学学过scratch你会发现他们其实都差不多没什么难理解的。
5.1、条件判断原理
条件判断的本质无非是在if判断为是时跳跃到代码分支结构的某块内存从那开始执行。当然其实它挺占性能的,你慢慢会对性能占用形成感觉。
让我们来看一条代码:
int x=5;
bool ComparisonResult = x == 5;
在上述代码中,==运算符实际上调用了某个底层函数调出某块内存存储的整数与它对比,如果结果相等就返回true。再往下想一点,就是把内存中这四个字节抓出来,一个个比对,看它们是不是完全一致的。
5.2、关于if
if本质上也是bool检查。实际上if语句只是在检查数字,如果是0,就中断跳过这一段然后往下跑,如果是非0的任何数,执行。所以我们也可以写if(1)`` if(0)``if(x)等等都可以。
所以我们可以这样写:
if(ComparisonResult)
{
blahblahblah
}
也可以这样写:
if(ComparisonResult == true)
{
blahblahblah
}
前者的优势是比较简洁,后者的优势是更易读。写代码就像说话,你会发现每个人都有自己的特点。
当if假,就会继续往下检查else if,直到某项是真的,或者直到else为假。
其实这个else if()是
else
{
if()
{
blahblahblah
}
}
你会发现这俩是等价的,无非是简写了一下而已。
同时,每个if是平级的。
简单提一嘴switch语法的考点
switch(expression){
case constant-expression :
statement(s);
break; // 可选的
case constant-expression :
statement(s);
break; // 可选的
// 您可以有任意数量的 case 语句
default : // 可选的
statement(s);
}
可以有多个case满足,但是如果你不break它就会往下面的case跑。
6、循环(loops)
6.1、for循环
当我们需要多次运行某个代码,我们需要循环。
来看一个简单的for循环:
int main()
{
for(int i=0;i<5;i++)
{
blahblahblah
}
}
为什么里面用i有一种说法是iterator(迭代器)
这个代码的运行逻辑是这样的:
首先我们生成变量i=0
检查条件:i=0<5为真
执行括号内的内容
使i++
再检查条件:i=1<5为真
执行括号
......
直到条件为假跳过循环。
所以我们甚至可以这样写:
int main()
{
for(;i<5;)
{
blahblahblah
i++
}
}
for的括号里可以是任何东西。
6.2、While循环
对于while,这两段代码是完全等价的:
for(int i=0;i<5;i++)
{
blahblah
}
int i=0;
while(i<5)
{
blahblahblah
i++;
}
简单来说,当你知道具体循环次数并且没有新变量的时候可以用for,有新的变量可以用while来检查。
关于do while:
do
{
blahblahblah
}while(x < 5);
do while至少会被执行一次。
6.3、循环控制语句(Control Flow)
continue停止当前迭代,立刻进入到下一次循环的迭代中。
break立马跳出循环。
return立马跳出函数。
7、指针(pointers)
7.1、指针的本质
对于计算机和程序来说,最重要的东西就是内存(memory)。这也是为什么最近内存条价格疯涨。
指针,其本质也是一个变量(准确的说是一个派生变量)。也就是说,它是一个整数,一个数字,存储着某个内存地址,就跟我们之前学过的所有变量一样。
我们可以这样理解内存:一条单个街道上的一排房子。每个房子有它的门牌号(数字)和地址,每个房子的空间大小是一个字节(byte)。
7.2、创建指针
实际上,你也可以在完全不使用指针的情况下进行编程,但是指针是一个相当方便的工具。
指针必须初始化。
我们创建一个空指针:
void* ptr = 0;0不是一个有效的内存地址,所以该指针是无效的。
你也可以写成:
void* ptr = NULL;或者void* ptr = nullptr;
我们来看一个有意义的指针。
int var = 8;//创建一个变量var使其等于8
void* ptr = &var;
其中,前缀“&”代表我们在取址,也就是取var的内存地址。
你也可以定义指针类型为int
int var = 8;
int* ptr = &var;
没有任何区别,所以说指针的类型没有意义,不同类型没有本质区别。
7.3、使用指针
当我们需要对指针指向的内存地址进行操控时,我们需要用“*”前置修饰,也就是解引用
int var =8;
int* ptr = &var;
*ptr = 10;
我们就成功改变了var的值。
我们既然知道,指针本质也是变量,那也就意味着它也有自己的内存地址,只是它的工作是指向别的变量的内存地址,也就是存储着内存地址。
这也就是说,我们可以创建指向指针的指针。
char* buffer = new char[8];
char** ptr = &buffer;
8、引用(References)
8.1、引用是什么
引用其实也是和指针差不多的东西,它是基于指针创造的语法糖。
指针必须引用已经存在的变量。我们可以创建一个空指针,因为指针本质上也是一种变量。但是不存在“空引用”或者类似的东西。引用本身不是一个新的变量。
8.2、使用引用
来创建一个引用:
int a = 5;
int& ref = a;
于是我们创建了一个a的别名(alias)ref。这个ref不是真的变量,它只存在于代码中而不是内存中,它像是一个盘旋在内存上空的幽灵。
我们可以使用ref代替a,因为ref就是a。
ref = 6;
下面我们来看引用的优点。
void Increment(int value)
{
int value++;
}
int main()
{
int a =5;
Increment(a);
LOG(a);
}
在上述代码中,虽然我们传入a到函数中,但是我们实际上**创建了一个新的变量**value,实际上发生了:
int value = 5;
因此当我们输出a的时候,会发现a的值是没有变的。变的值是value。
这也就是值传递。
为了改变a,我们可以选择进行址传递:
void Increment(int* value)
{
(*value)++;
}
int main()
{
int a =5;
Increment(&a);
LOG(a);
}
我们也可以采取更简洁的引用传递达成这个效果。
void Increment(int& value)
{
value++;
}
int main()
{
int a =5;
Increment(a);
LOG(a);
}
所以引用就是简化版的指针。没有任何事情是引用能做但是指针不能做的。
8.3、使用引用的注意事项
值得注意的是,你不能多次初始化一个引用。
int a =5;
int b =8;
//错误操作
int& ref = a;
int& ref = b;
如果你要做类似的操作最好使用指针。
同时,鉴于引用不是一个真的变量,你也不能只声明不赋值。
int& ref;//错误操作
ref = b;

