本篇笔记将记录 Cherno 的 C++ 系列的所有令初学 C++ 者眼前一亮的知识点,而简单的语法知识和基本操作在此不做笔记,强烈建议新手完整地观看全系列教程。
注意,每 P 的知识点不是孤立的,可能会在后面更加深入地、全面地拓展讲解,有些简单的知识点可能会更多地讲底层和优化,因此都是值得认真学习和细细琢磨的。
P5. C++ 是如何工作的
#
符号后都是预处理语句,他们都会在编译之前执行,如如下代码中的 include
语句,它会寻找一个叫 iostream
的文件,找到后会将 iostream
文件内的所有内容完整地拷贝到当前文件中。此外,这种文件又被称为头文件 (head file)。
#include <iostram>
#define
#ifndef
#endif
复制代码
P6. C++ 编译器是如何工作的
在 C++ 中任何符号都需要声明,我们对每个文件都会单独编译,如果调用一个外部函数,却不在当前文件进行声明,那么当前文件就不知道还有这样的一个函数存在。如果你声明了却没有定义函数,编译器依旧完全相信你,但链接器试图寻找你定义的函数时却找不到该函数就会报链接错误。如果都正确了,编译器会将每个文件但单独编译为 .obj 文件,而链接器会将他们合并为一个可执行文件,例如 exe 文件。
编译器将文本转换成一种称为目标文件的中间格式,在这个过程中,编译器会先预处理代码,将我们的代码转为抽象的语法树,然后生成 CPU 所执行的代码.
P7. C++ 链接器是如何工作的
C++ 程序必须有一个入口函数,如 main
函数,你也可以在 IDE 中修改这个函数名。但链接器总会寻找你所指定的入口函数,如果没有定义它,就会报链接器错误。
由于编译器是相信你的,但假如你在文件中声明了函数,却没有真正地定义,此时只有链接器才会找到这个错误。即使你并没有调用这个未定义地函数,链接器依旧会报错,因为链接器不能确定别的文件就不会调用。但如果用 static
关键字修饰函数,那么该函数就只在当前文件生效,也就是说链接器就不会认为由外部文件调用该函数。
链接错误
如果在同一的文件存在相同的定义,那么编译器就会检查到该错误,但如果是不同的文件存在相同的定义,只能由链接器检查到该错误。当使用 #include
语句时可能会导致头文件多次被拷贝在不同文件中,也就会触发这种链接错误。我们可以通过将声明都放在一个头文件中,不同的翻译单元分别包含头文件,避免定义被拷贝。
P.8 C++ 中的变量
变量的类型大小和它能存储多大的数字时直接相关的。一个整型是 4 字节,也就是 32 位的数据,如果这个类型有符号,那么需要其中 1 位表示正负,也就是说该类型实际能表示 个可能的值,那么整型变量的取值范围就是 。如果将符号去掉,就是无符号类型 unsigned
,其取值范围增加,但只能是大于等于 0
的值。值得注意的是,bool
类型的值非 0
即 1
,理论上只需要 1bit 的内存空间,但是在内存中寻址无法寻找只有 1bit 的内存,因此,声明一个 bool
类型变量时,依旧会占用 1 个字节的内存。注意,一个类型到底有多大,这取决于编译器,但我们可以使用 sizeof
操作符查询类型大小。
sizeof(int);
复制代码
P9. C++ 中的函数
函数的目的是减少重复,不要为频繁地创建函数,因为每次调用函数,编译器都会生成一个 call 指令。编译器需要为每个函数创建一个栈结构,然后把返回地址和函数参数等压入栈。然后运行时就会跳转到二进制执行文件的不同部分,以执行我们的函数命令。
main
函数没有返回值这是现代 C++ 的一个特性,编译器会为你在末尾处添加一行 return 0;
这只是为了让你的代码更加干净。但别的函数如果没有返回值,在 Release 模式下是不会报错的,如果真的调用这个函数,依旧会报 “未定义的行为” 的错误,在 Debug 模式下,就会直接提示你没有返回值。
P10. 头文件
C++ 中头文件通常用于声明某些类型的函数,以便于程序中的调用。而预处理语句 #pragma once
监督当前头文件只被包含一次。例如该命令会检查 _LOG_H
是否被定义,如果被定义了,其包括的代码都不会包含。
相比较 #ifndef
和 #endif
的形式,实际上二者没什么区别,只是 #pragma once
预处理语句更加简洁。
#ifndef _LOG_H
#define _LOG_H
//...
#endif
复制代码
头文件的包含符号
<>
符号引入的头文件,会搜索该绝对路径下的头文件,''
符号引入的头文件,会包含相对于当前文件的头文件。而 Cherno 本人的风格是要包含自己写的头文件是,一律使用 ''
。此外,类似 iostream
的这中头文件没有文件扩展名,这是因为 C++ 标准库为了和 C 标准库进行区分而规定的,但这不影响编译器能识别出它是一个头文件。
P12. C++ 中的条件语句和分支
if
条件语句只是在处理数字,是 0
就是 false
,不是 0
就是 true
。当我们创造了一个布尔值,实际上会占用 1 字节的空间,不一定要知道该字节中哪个 bit 位被设为了 1
,只要有 1 个不是 0
,那么这个 bool
类型的 1 字节内存就代表了真。其底层过程就是 MOVE
指令将 0
加载到内存中寄存器中,它等于 false
,于是将该布尔值设为 false
,IF
指令某些值加载到 EAX
寄存器中。如果编译器自己就能确定比较结果,不需要再运行时在做比较,这种情况被称为常数折叠,优化会自动去掉与之相关的 bool
值、if
条件语句等等,直接跳过。
int x = 6;
bool result = x==5;
复制代码
else if
并不是一个关键字,而是一个语法糖。
else if(){
}
//equals
else{
if(){
}
}
复制代码
P16. C++ 中的指针
指针是一个整数,一种存储内存地址的数字,把内存想象成一条直线,一排房子,每个房子是一个字节能住 8bit 的 0
或 1
,每个房子都有一个地址。我们需要一种方式来寻址任意的字节。而类型只是让我们为了让生活更容易而创造的虚构的概念,任何类型的指针,它都是一个保存着内存地址的整数。此外,0
不是一个有效的内存地址,给指针赋值 0
意味着这是一个无效的指针,事实上 NULL
常量就是 0
,我们也可以赋值为 nullptr
P17. C++ 中的引用
引用是指针的伪装,只是在指针之上的语法糖,使指针更易阅读和理解。我们可以将一个指针的值设为 0
无效指针),但不能对一个引用这样做,因为引用必须要能 “引用” 一个变量,必须是一个已经存在的变量。引用本身并不占用内存,没有自己的存储空间,编译器也不会新建一个变量,会直接用原变量替代引用,换句话说引用只是变量的别名。
fuction(&var);
void fuction(int* value){
(*value)++;
}
//equals
function(var);
void fuction(int& value){
value++;
}
复制代码
P.19 C++ 中的类 VS 结构体
class
和 struct
二者的唯一区别就是 class
默认成员为私有, struct
默认成员为公开。二者之所以这么相似而不统一,是为了兼容 C 语言,才保留了 struct
关键字。
P.21 C++ 中的静态
C++ 中的 static
关键字的意思取决于上下文,在类或者结构体外部使用 static
关键字和内部使用是不一样的。在类外使用 static
这意味着该成员只在内部生效,意味着其作用域仅限于该 cpp
文件内,意味着只对该编译单元可见,链接器不会再该编译单元外寻找它的定义。
如在两个独立文件中分别定义了相同变量,这显然不会被链接器通过。如果加上 static
关键字修饰其中一个变量,那么该变量就不会被链接器所搜寻,只在自己的文件内生效,有点类似于声明了一个私有变量,别的翻译单元都不能看到这个变量但如果我们将其中一个的赋值删掉,加上 extern
关键字,那么编译器就会在外部的翻译单元中寻找该变量,这也能避免重名变量的冲突。
P22. 类和结构体的静态
而类或者结构体内的 static
代表着被修饰的成员会被该类型的所有实例共享这块内存。静态的类成员变量或方法是无法直接访问类的非静态变量或方法的。例如一个类的静态方法,只能通过函数参数传入类实例的形式进行访问,而不能直接在函数内访问类非静态成员变量或方法。
P23. 局部的静态
声明一个变量时我们需要考虑变量的作用域和生命周期,而局部的静态变量就是只在作用域中生效,但其生命周期直到程序终止才结束。
当函数内存在静态变量时,函数第一次被调用,该静态变量被初始化,并可供该函数的后续所有的调用提供同一变量,而不是创建新的变量。后者也能提供相同的作用,但区别就是后者的方式还会导致该静态变量在文件中全局可调用,而前者只在声明它的作用域中生效。
void function(){
static int i = 0;
i++;
}
static int i = 0;
void function(){
i++;
}
复制代码
P28. C++ 中的虚函数
虚函数引入了动态联编 (dynamic dispatch),通过虚函数表来实现编译。虚函数表就是一个表,其包含基类中所有虚函数的映射,以在运行时能正确地覆写 (override) 函数。
虚函数并不是没有开销地,我们需要额外的内存来存储虚函数表,以正确地覆写函数;基类中还要有一个成员指针指向虚函数表。当我们调用虚函数时,就需要遍历一次虚函数表以确定映射到哪个函数,这些都是额外的性能损失,虽然损失非常小。
P31. C++ 中的数组
C++ 数组就是表示一堆变量组成的集合,通常是一行相同数据类型的内存。当是你通过数组索引访问不存在数组元素时,就会产生内存访问违规 (Memory access violation) ,在 Debug 模式下会提示你,但在 Release 模式不会产生报错信息。所以使用原生数组要时刻留意边界问题。
在内存中,数组会被连续地分配在一段内存中,其大小为以数据类型的的大小乘以数组长度。而数组的变量实际上就是一个指针,指向了数组的第一个元素的地址。因此我们直接将指针的地址+n,就能移动到数组的其他成员的地址。
int array[5];
int* ptr = array;
array[2] = 5;
//equals
*(ptr + 2)=6;
复制代码
这里的 +n
并不是增加 n 个字节,实际上会自动乘上类型的字节大小。例如我们可以先将指针地址转为 char*
指针,由于 char
类型只占 1 个字节,因此需要 +8
才能移动到数组的第 3 个元素的地址。然后将已经指向数组第三元素地址的 char*
指针转换为 int*
指针,最后解引用,对该地址指向的内存中的数值进行修改。
*(int*)((char*)ptr + 8) = 6;
复制代码
数组只是一个连续的数据块,我们可以想索引一本书一样索引它们。在堆上创建数组,会导致间接寻址,栈上存储的数组变量不再直接指向数组,而是数组的内存在堆上的地址,需要进行跳转,显然这会影响性能。
P32. C++ 中的字符串是如何工作的 & 如何使用它们
字符串是不可变的,会被固定分配一块内存块。现在已经不允许在声明如下形式字符串时,不加上 const
关键字了。字符串在内存中除了自身的字符之外还会再结尾多一个 00
字节,这是终止符。通过终止符我们能告诉编译器字符串有多长,看到终止符字符串就结束了。
const char* name = "Cherno";
复制代码
在内存视图中,我们能看存储的数据周围有很多值为 cc
的字节,这些字节被称为数组守卫,在 Debug 模式下分配数组等会插入栈守卫之类的,这样可以判断我们是否将数据分配在内存之外。此外,我们要注意在函数参数中字符串要尽量写成引用形式,因为字符串的复制是有消耗的,避免不必要的性能浪费是值得的。
void Print(const std::string& string){
string += "h";
}
复制代码
P33. C++ 中的字符串字面量
例如 "Cherno"
就是一个字符串字面量,但其长度是 7 而不是 6,这是因为会自动加一个空终止符 \n
。
"Cherno"
//equals
"Cherno\n"
复制代码
当我们使用 strlen
函数输出字符串长度时会发现,遇到终止符后长度就停止了,而不是和声明的字符串数组长度有关。同样的,即使只字符串中没有终止符,其仍不会记录字符串末尾的终止符为长度,因此长度会比你声明的数组大小少 1。
const char name[8] = "Che\nrno";
复制代码
字符串字面量会存储在二进制文件的 CONST
部分,当我们引用这个字符串字面量时,实际上指向的时一个我们不能编辑的常量区域。但如果使用数组的形式声明字符串,就不会指向常量区域而是一个正常的内存块,那么我们就可以修改该字符串了。用指针的形式声明的字符串常量,对其修改是一个未定义行为,可能在 Release 模式下不会警告你,但运行时不会通过该行为的。
其他类型的字符
char16_t
是两个字节 16bit 的字符,char32_t
是四个字节 32bit的字符,也就是 utf8 和 utf16。wchar_t
是宽字符,一般也是两个字节 16bit,但该类型所占字节实际是多少还是要取决于平台和编译器。
const char* name = u8"Cherno";
const wchar_t* name = L"Cherno";
const char16_t* name = u"Cherno";
const char32_t* name = U"Cherno";
复制代码
由于字符串字面量本质上是 char*
指针,不能通过对两个指针的直接相加来实现字符串的拼接,我们可以通过将前者用 std::string
类型的构造器将字符串字面量传入的形式,使前者成为一个 std::string
对象。
std::string name = std::string("Cherno") + " hello";
复制代码
而 C++14 提供了一个新特性使得字符串能够相加,在前者的末尾加上 s
就成为 std::string
类型的对象,在前面加上 u8
、 L
等前缀就成了其他字符类型的对象。
using namespace std::string_literals;
std::string name = "Cherno"s + " hello";
复制代码
P34. C++ 中的 const
const
是一个假的关键字,什么也没做,只是让你作出承诺不会改变。但承诺是可以被打破的,是否遵守承诺这取决于你。
const 与 指针的组合
如下代码中,前两者相同,都不能修改 *a
。而第三者不能修改 a
,最后一个是*a,a
都不能修改。我认为就看const
和 *a,a
的关系,const
在 *a
前就是 *a
(地址指向的值)不能修改,在 a
前就是指针本身不能修改。而 const int* const a;
就是 *a,a
都被 const
修饰,都不能修改,因此第四个还能写成这种形式:int const * const a = new int;
。
const int* a'
int const* a;
int* const a;
const int* const a;
复制代码
const 在类和函数中的应用
对函数用 const
关键字修饰后,将不能在函数中修改类的成员变量,如果非要修改需要为该变量添加一个 mutable
关键字的修饰。如果一个 const
的类型实例需要调用类的非静态函数,这是不被允许的,因为你无法保证该函数没有修改类型的成员变量,而类型中 const
修饰的函数则可以保证,因此开发中往往会提供一个函数的 const
关键字修饰的版本和一个普通的版本。
class Entity{
private:
int x;
mutable int y;
public:
int GetX(){return x;}
int GetX() const {
y = 0;
return x;
}
}
void PrintEntity(const Entity& e){
std::cout<<e.GetX()<<std::endl;
}
复制代码
const 函数参数下的指针和引用
参数中的 const Entity* e
是一个不能修改指针指向内容但可以修改指针的参数。而参数中的 const Entity& e
就是传入函数的变量本身,只是在函数中使用引用而不是变量就不会复制一份实例,以减少开销。因此在函数中 e
就是实例的内容,可以直接调用。
void PrintEntity(const Entity* e) {
std::cout << e->GetX() << std::endl;
}
//equals
void PrintEntity(const Entity& e) {
std::cout << e.GetX() << std::endl;
}
复制代码