CPP笔记
本文最后更新于:2025年1月23日 晚上
非语言概念
编译
编译工具
-
头文件:能够通过
#include
来引用(本质上是直接复制其内容)的文件 -
源文件/编译单元(compilation unit):用于生成目标文件的基本单元
-
预处理器(preprocessor):能由源文件和头文件生成处理好的编译单元的程序;主要工作是处理源程序中的预处理语句
- **预处理语句(preprocessor statement):**主要包括
#include
,#define
等语句
- **预处理语句(preprocessor statement):**主要包括
-
编译器(compiler):能根据处理好的编译单元生成目标程序的程序,包括编译程序和汇编程序
-
链接器(linker):能根据目标程序生成可执行程序的程序;主要工作是进行符号解析,地址映射
-
目标程序(.obj):可重定位的(relocatable)(指令中的地址还不是物理地址)、未链接的(unlinked)(引用了其他目标程序或库中的函数、数据,但尚未与它们链接,尚未确定函数具体实现、变量的逻辑地址)的机器码(中间文件,最终不会保留)
-
链接库:预编译的二进制文件,包含类、函数、变量的定义等数据;程序会在某些时机链接到库,及其他目标程序
- 静态链接库(.lib):包含若干目标文件,编译时将其中的目标文件链接起来
- 动态链接库(.dll):根据目标文件导出符号,使得可执行文件能直接访问(而不是被编译器访问)
- 动态链接库编译得到的程序更小,但运行时有额外开销(编译结果中不会包含函数具体实现等数据,运行时才确定);多个项目可以共用动态链接库(共用一块内存)
- 如果一个项目仅包含头文件,直接引用即可;包含源文件则必须生成可执行程序/目标程序/链接库才可访问
编译过程
- 编译是由一个解决方案(包含若干项目,每个项目中包含若干源文件、头文件、链接库等)生成包括可执行文件(.exe)文件在内的若干文件的过程
- 狭义的编译指编译过程中,由源程序生成汇编语言程序这一步
- 编译一个解决方案的步骤:
- 输入若干源文件
- 每个源文件经预处理器生成一个处理好的编译单元
- 每个处理好的编译单元经编译器生成一个目标文件
- 各个项目中的若干个目标文件分别合成为一个静态链接库
- 链接静态链接库,所有目标程序经链接器生成一个可执行程序(编译完毕后才可能访问动态链接库)
Visual Studio项目
- Visual Studio编辑器及其项目文件仅能在Windows平台运行;如果不使用任何第三方插件,仅能构建运行于Windows平台的程序
解决方案与项目
- 一个解决方案中包含若干个项目
- 项目有独立的设置、独立的开发语言
- 通常将其中一个设置为应用程序(生成.exe),其余设置为库(生成.dll或.lib),并令应用程序引用各个库
- 设置了项目间引用,本质上是将.lib、.dll生成到输出目录(因此不必设置附加依赖项)
包含头文件
-
头文件(.h或无后缀)仅在预处理阶段发挥作用(本质上是单纯的复制),头文件缺失或内容错误可能在编译前或编译时被发现
-
头文件通过#include指令引用,需指定文件路径,编译器尝试用若干种方式检索指定的头文件
路径类型 示例 备注 标准库目录下的相对路径 #include<iostream>
附加包含目录下的相对路径 #include"glfw3.h"
必须设置附加包含目录 从当前源文件所在文件夹出发的相对路径 #include"..\Dependencies\glfw3.h"
..\
表示退回上一级文件目录绝对路径 #include"D:\memo\编程\C++\Test\Dependencies\glfw3.h"
预编译头(Precompiled Header)
- 令一个头文件包含其他所有文件所需的部分头文件,并告知编译器,首先预编译这一头文件
- 其他文件不必再重复编译预编译头中的头文件,减少编译时间
- 预编译的结果会被保留下来,修改源文件后,不必再重新编译预编译头,减少重编译时间

- 预编译头以项目为单位;将项目设置为使用预编译头,然后设置从项目根路径开始的相对路径以指明预编译头文件的位置
- 其他文件如果要引用预编译头,必须放在其他头文件之前
依赖静态链接库
-
静态链接库(.lib)仅在编译阶段发挥作用,编译得到的.exe文件无需依赖.lib文件即可运行
-
默认情况下,在编译时,会自动查询输出目录下的静态链接库,使之参与编译
- 令A项目引用B项目,并将B项目设为静态链接库,本质上是在输出目录中创建B编译得到的.lib文件

- 也可以人为设置静态链接库引用(在附加库目录中查找附加依赖项)
依赖动态链接库

- 动态链接库(.dll)仅在exe文件运行时发挥作用,编译器不能访问dll文件
- 为了正确地编译,仍然需要引用lib文件(C++程序使用的dll文件总是伴随着lib文件)
- exe文件运行时,会自动在所在文件夹中查询依赖的dll文件
- 令A项目引用B项目,并将B项目设为动态链接库,本质上是在输出目录中创建B编译得到的.dll文件
- 多个应用程序可以共用.dll文件,这需要通过设置环境路径、注册表等方式实现
模块定义文件
- 模块定义文件(.def)用于规定.dll文件中需要导出的对象,及其导出顺序、别名等
- .def文件可以覆盖__declspec(dllexport)的作用,也可以一起使用
- 某些情况下[1],无法使用__declspec(dllexport)规定需要导出的对象,必须使用.def文件
- 需要人为设置模块定义文件引用(在附加库目录中查找模块定义文件)
[1]:有源文件A,.dll文件B,.lib文件C。A依赖B,B依赖C,而A不直接依赖C。如果用于生成B的源代码中没有使用C中的某个API,那么B中不会包含该API,此情况下如果在A中使用C中的某个API,会引发链接错误
内存分区
- 现代操作系统使用虚拟内存,内存分区是基于虚拟地址划分的(下以Linux系统为例,地址从低到高)
代码段(.text)
- 机器码形式的程序文件
只读数据(.rodata)段
- 常量。可以通过特殊方法修改
- 虚函数表也存放在常量区,因为虚函数表的内容也是常数
已初始化数据(.data)段
- 静态常量,已初始化的静态/全局变量
未初始化数据(.bss)段
- 未初始化的静态/全局变量的”元数据“(仅记录接下来要为各个静态变量分配多少空间,没有为这些未初始化变量分配内存)
堆(Heap)段
- 由new/malloc等关键字分配,由delete/free回收
- 占用的总空间可以动态增长(从低地址向高地址增长)
- 堆分配需要在堆空间中查询足够大的一块内存空间,所以效率较低
文件映射段
- 临时存放进程要访问的文件
- 占用的空间由低地址向高地址增长
栈(Stack)段
- 不由new/malloc等关键字分配,编译后自动确定回收时机
- 栈分配只需要维护一些指针,以确定的顺序分配和回收内存,所以效率较高
- 栈区的大小通常是固定的(高地址为栈底,低地址为栈顶)
- “栈区”不同于数据结构中的“栈”,其中变量不一定是连续分配的
内核空间(Kernel Space)
- 存放与操作系统有关,各种进程都有的数据(PCB,页表,中断向量表等)
错误处理
异常
- 抛出异常,本质上是生成一个异常对象,储存错误信息
- 抛出异常后,在当前函数中查找可以处理该异常的catch语句块,找不到,就释放当前函数调用栈,将异常抛到上层函数调用,重复此过程。这个过程是栈展开(unwind);如果直到最上层也无法处理异常,则中止程序
- 析构函数不能将抛出抛出到上层,有异常必须在析构函数内部处理掉
断言
- 通常用宏实现,内容通常是:如果满足某种条件,就终止程序
- 性能开销较小
- 仅适合处理严重错误,仅适合处理程序员错误而不是用户错误
调试
- Visual Studio提供了Debug模式和Release模式
- Debug模式:编译器执行一些额外的工作,使调试得以进行
- Release模式:编译器会忽略一些bug,试图使程序继续运行下去
语言概念
头文件
- C++要求在使用一个类/函数/变量之前必须声明
- 对于仅在本文件中使用的对象,通常会直接在使用前就定义;或是先声明,在之后的代码中定义
- 对于使用的外部对象,需要将其一一声明。可以将声明(或定义)写在头文件中,然后调用#include指令
- 引用头文件,本质上是将头文件的内容复制进来
- 头文件之间,不可以相互包含、循环包含,必须确保依赖关系是单向的(
#pragma once
仅能防止一个文件多次包含某头文件)- 如果一个头文件中的类相互依赖,则可以在该头文件中使用前向声明
- 如果两个头文件中的类相互依赖,则可以在一个头文件中使用前向声明,然后不再包含另一个头文件
- 源文件中通常不需要使用前向声明(因为依赖的对象通常包含在头文件中)
1 |
|
内联
-
内联发生在编译阶段,因此需要内联的函数必须存在于源代码中
- 通常,头文件中声明的函数均需要以inline修饰(头文件中声明与定义重合的函数默认以inline修饰,否则应当用inline修饰没有内联的函数定义)
- 通常,源文件中的声明函数均不能以inline修饰(除非仅在本文件内部使用)
-
inline只是一种建议,由inline修饰且确实能够内联的函数才会内联
- 带有递归、分支、循环的函数可能无法内联,虚函数通常无法内联
- 内联意味着编译得到的文件增大,函数体较大的函数必须谨慎内联
- 一个函数被调用的次数越多,内联效果越明显,对代码占用空间的影响也越大
宏
#define,#ifdef,#endif
等指令,作用于预处理阶段- 一条宏定义的生效范围为一个编译单元(即一个头文件);因此**
#if
,#ifdef
类指令通常定义在头文件中,使各个源文件共享,而#define
等指令通常定义在源文件中,表示各个源文件的不同设置** - 定义宏时,如果要表示换行,使用
\
- 常见用途:
- 类似枚举常量的作用
- 简化多次出现的重复代码
- 尽可能在编译时(而不是运行时)确定不同上下文对应的代码(如Debug和Release,不同平台)
1 |
|
1 |
|
- 可以设置项目的预处理指令(省略
#define
,不能有空格),其优先级低于人为编写的预处理指令 - 与平台有关的预处理指令必须在项目设置中分别设置
命名空间
- 仅辅助命名,不与访问修饰符产生协同作用
using namespace
指令根据在代码中位置的不同,在不同作用域内生效(文件内部/类内部/函数内部)- 使用时必须避免混淆,绝对不能在头文件的全局范围中使用
using namespace
1 |
|
关键字
auto
- 自动确定的变量类型
- auto一定不会自动确定成引用(但可以自动确定成指针),必要时在auto后加上引用符号
- 有利于减少代码修改,但同时影响可读性、可靠性;适用于代替名称过长的类型名
extern
- 修饰函数或变量,表示该对象既不存在于本文件中,也不存在于引用的头文件中
- 预处理时,对于extern对象,编译器会忽略其没有声明/定义的问题
- 链接时,编译器会在所有被链接在一起的文件中查找该extern对象
1 |
|
decltype
- 用于确定现存实例或方法的类型,可以与指针/引用配合使用
- 某些情况下无法使用auto,但可以使用decltype
1 |
|
friend
-
friend关键字总是用在类中,且不被访问修饰符修饰
-
修饰全局函数:该全局函数可以访问此类的私有成员
-
修饰类:该类中的函数可以访问此类中的私有成员
-
修饰函数:该函数可以访问此类中的私有成员(这个类应当为其他类中的某个函数,如
friend void Student::F(int)
for
- for兼具C#中foreach的作用
- 如果容器中的元素为类实例,通常在foreach循环中使用引用以避免拷贝(一般的for循环通常就是按地址访问)
1 |
|
- foreach循环中,不能增删容器元素;for循环中,必须考虑增删元素对循环的影响
1 |
|
__declspec
__declspec(dllexport)
:修饰类或全局方法(的声明),需要导出到dll文件的对象必须以此关键字修饰__declspec(dllimport)
:修饰类或全局方法(的声明),具体实现从dll文件导入的对象必须以此关键字修饰
1 |
|
变量
声明/定义
- 变量声明:规定变量的类型和名称
- 变量定义:规定变量的类型和名称,并为某个变量分配内存(是否分配内存,是区分声明和定义的唯一标准)
- 初始化:为变量分配内存并设定初始值(定义是初始化的一部分)
- 声明时初始化:声明语句和初始化语句重合
- 每个翻译单元必须各自包含要使用的变量声明,但变量定义在所有相互链接的目标文件中只能出现一次
- 如果变量只有声明而没有定义,并且确实试图访问该变量,链接时会产生错误(某些类型的变量能够自动隐式初始化,便不会出现这种情况)
变量类型
- C++中没有引用类型/值类型之分
- 所有类型的数据(包括数组、基本数据类型),都可以分配到栈上或堆上,关键是看分配内存时有没有使用new、malloc等API
类型转换
-
类型转换可能会改变数据(的二进制表示)
-
可以利用类型转换运算符(配合指针、引用等)进行各种类型转换,其行为可能是不确定、不安全的
-
C++中添加了四个类型转换函数:
- static_cast:隐式类型转换,安全,可以省略(如子类指针转父类指针,某些数值类型之间转换)
- dynamic_cast:指针、引用之间不安全的类型转换(如父类指针转子类指针)
- reinterpret_cast:相当于改用不同类型的指针指向一个地址,不改变该地址上的二进制数据
- const_cast:将常量指针/常量引用强制转换为非常量指针/非常量引用
-
相比于类型转换运算符,类型转换函数并没有实现额外的转换,而是额外进行了合法性检测等工作
- 能够使用static_cast的上下文中,使用dynamic_cast不会引发异常,但开销更高
- 使用dynamic_cast时,若类型转换不合理,则会返回空指针或抛出引用不可为空异常
全局变量
- 作用域为全局
- 访问其他文件中的全局变量前必须声明(使用extern关键字)
静态全局变量
- 作用域为本文件
指针
指针类型
- (在64位系统上)指针本身只是一个表示地址的无符号8字节整数(相当于size_t),不与特定的数据类型绑定
- 指针类型代表以何种方式访问该指针指向的数据,且可以用不同类型的指针指向同一地址
- 但指针类型的改变不一定是合法的,编译器对非法操作的处理是不确定的,要谨慎使用
- void*也是合法的指针类型,没有指明以何种方式访问该指针指向的数据
- 所有指针类型可以static_cast为void*
- void*须reinterpret_cast为其他指针
指针运算
- 指针可以reinterpret_cast为size_t
- 指针加/减常数时,实际的变化量要乘以指针指向数据占用的空间
- 两指针相加无意义,两指针相减,返回值为size_t,结果要除以指针指向数据占用的空间
NULL和nullptr
1 |
|
- *(void )0可能导致指针和整数的混淆,而nullptr不可能隐式被转换成整数0(nullptr需要通过模板类来实现)
常量指针
-
不能通过指针来修改其指向的内容
- 对于某个类的常量指针,不能修改该类的成员变量,且只能访问有const说明符的成员函数
-
对常量取地址会得到常量指针;不能把常量指针赋值给非常量指针(会破坏常量的约束)
指针常量
- 不能修改指针指向的地址,不能为空指针(不需要检查常量指针或引用是否为空)
1 |
|
引用
-
引用是变量的“别名”,有类似指针常量的特性,在引用符号右侧再加const修饰没有意义
- 对已初始化的引用赋值,实际上是修改引用指向的内容(这要求被引用实例实现了=运算符),而不是修改地址本身
-
引用和指针的许多语法有所不同,某些情况下不能将其视为常量指针(如参数,返回值,sizeof等)
-
引用不同于C#的引用类型,后者指向的地址是可变的,且可以设为空
1 |
|
数组
- 创建数组,本质上是分配一块连续的、存放多个同种元素的内存,并用指针指向起始地址
- 数组可以分配在栈上或堆上;但如果编译时数组大小不确定,只能分配在堆上;利用模板参数可以在编译时确定数组大小
- 利用sizeof运算符能够计算数组元素个数
二维数组
- 每个元素为指向数组的指针
- 各数组在内存中不连续,改用一维数组实现通常更高效
1 |
|
左值/右值
- 左值:可以出现在赋值号左右两侧的对象(≠确实出现在赋值号左侧的变量)
- 占用一块内存空间,可以取地址,未以const修饰时可修改
- 右值:只能出现在赋值号右侧的对象(≠确实出现在赋值号右侧的变量)
- 临时存在于内存或寄存器中,不可取地址,被视为常量
move
-
B=move(A)
将左值A转换为右值,然后赋值给B,这通常意味着A变为“临时变量”,即接下来不会再使用A- 但A不会被立即释放,依然是在离开作用域时释放
-
move函数会以A为参数调用移动构造函数,通常实现方式为把A的所有成员浅拷贝给B,后将A的指针成员必须全部设为nullptr
- A的成员原本指向的对象不会在A析构时析构,而是在B析构时析构
- 本质上是避免深拷贝,降低开销。但这种降低开销的做法是有前提的,即确实不需要再使用A
-
特别地,如果B不是一般的左值,而是右值引用,这种情况下不会移动A,而是仅仅为A增加一个新的引用
左值引用/右值引用
- 不能将左值赋值给右值引用
- 不能将右值赋值给左值引用,但可以赋值给常量左值引用(类似常量和常量指针的关系)
- 左值引用和右值引用均可以出现在赋值号左侧,均属于左值的一种
- 用const修饰右值引用没有意义,和一般的右值引用没有区别
左值 | 左值引用 | 常量左值引用 | 右值引用 | 右值 | |
---|---|---|---|---|---|
类实例左值 | 分配内存,调用构造函数[1] | 同左 | 同左 | 同左 | 分配内存,调用构造函数[2] |
左值引用 | 新增指针,指向已分配内存 | 同左 | 不允许 | 同左 | 不允许 |
常量左值引用 | 新增指针,指向已分配内存 | 同左 | 同左 | 同左 | 分配内存,调用构造函数[3] |
右值引用 | 不允许 | 不允许 | 不允许 | 不允许 | 同上 |
上表中,第一行表示赋值号右侧对象的类型,第一列表示赋值号左侧对象的类型
[1]:调用哪个构造函数视情况而定,还可能会调用等号重载;无论如何都会调用至少一次某种构造函数
[2]:只有在这种情况下,才可能调用移动构造函数
[3]:如果右值不是临时量,而是原有左值移动得到的,那么便不会分配内存,仅新增指针,原有左值也不会真的被移动
1 |
|
右值引用←右值
1 |
|
常量左值引用←右值
1 |
|
以上代码为上表注释[3]的特殊情况
左值←右值
1 |
|
- 将原本为左值的类实例移动后赋值给另一个左值时,会调用移动构造函数
- 将字面量赋值给类实例也可能表示调用带参构造函数(构造时隐式类型转换)
函数
定义/声明
- 函数声明:规定函数的类型和名称
- 函数定义:规定函数的类型和名称,及其具体实现(是否有具体实现,是区分声明和定义的唯一标准)
- 声明时定义:声明时直接包含函数定义
- 每个翻译单元必须各自包含要使用的函数声明,但函数定义在所有相互链接的目标文件中只能出现一次
- 头文件被多个文件引用,因此如果要在头文件中包含函数定义,必须使用内联
- 如果函数的定义和声明不分离,默认就是内联的;否则,应当在定义时内联
- 如果函数只有声明而没有定义,并且确实试图调用该函数,链接时会产生错误
- 如果函数不在声明时定义,**一部分说明符(static,virtual,override…)**仅出现在声明中
静态本地变量
- 作用域为函数体内部
- 多次执行函数的过程中,静态本地变量的值会被保留下来
- 定义静态本地变量的语句实际上只会被执行一次(所以通常在定义时赋初值)
1 |
|
签名
- 包括以下内容:
- 函数名
- 参数列表
- noexpect说明符(C++17以后)
函数栈帧
- 函数调用过程:
- 预分配用于存放返回值的空间
- 参数从左往右逐个入栈
- 保存现场(通用寄存器,栈地址寄存器,代码段地址等)
- 分配本地变量
- 执行函数体,计算出返回值
- 将返回值复制到返回值寄存器中,然后再复制到外部函数栈中接收返回值的地址
- 释放本地变量
- 恢复现场
- 回到外部函数
- 某些情况下,返回值寄存器中存放地址,地址传递到外部函数栈后,可能会依据地址调用拷贝构造函数/移动构造函数
参数
-
传递参数时,一定会为参数栈分配,并调用该类的拷贝构造函数
-
传指针或传引用时,本质上是拷贝地址(一般指针可为空,而常量指针和引用不可为空)
-
参数的生命周期是函数体内部
-
-
将参数定义为实例常量(而不是一般实例)意义不大,因为修改实例本身不影响函数外部
-
默认参数的语法与C#相同;函数定义和声明中,默认参数只需要且只能出现一次
-
parameter表示**“形参”(比如用于讨论函数的参数列表如何定义),argument表示“实参”**(比如用于给具体参数取名)
类实例 | 左值引用 | 常量左值引用 | 右值引用 | |
---|---|---|---|---|
外部类实例 | 拷贝到函数栈 | 仅拷贝地址 | 仅拷贝地址 | 编译错误 |
左值引用 | 拷贝到函数栈 | 仅拷贝地址 | 仅拷贝地址 | 编译错误 |
常量左值引用 | 拷贝到函数栈 | 编译错误 | 仅拷贝地址 | 编译错误 |
右值引用 | 拷贝到函数栈 | 仅拷贝地址 | 仅拷贝地址 | 编译错误 |
临时类实例 | 直接在函数栈内构造 | 编译错误 | 直接在函数栈内构造 | 直接在函数栈内构造 |
移动后的类实例 | 正确地移动到函数栈 | 编译错误 | 仅拷贝地址,不移动 | 仅拷贝地址,不移动 |
上表中,第一行为定义函数时规定的参数类型,第一列为实际传入的参数;上表仅表示传参时发生的行为,在函数内部还可能发生其他拷贝行为
返回值
- 函数返回时的行为,和返回值本身的类型、生命周期有关,也和定义的返回值类型有关,还涉及返回值优化
- 返回值为基本数据类型时,直接复制
- 返回值为指针时,行为明确,即复制地址;必须确保该地址指向的对象没有被释放(堆上变量,全局变量,静态本地变量,所在类成员变量等)
- 返回引用时,行为不明确;但无论如何必须确保返回的对象没有被释放
- 返回类实例时,会正确地调用某种构造函数,可以用于返回会被释放的对象
- 外部如果不接收返回值,那么返回时的行为不会执行,也不会进行返回值调用,正常地分配和释放函数本地变量
- 返回指针或引用时,函数调用本身可能会成为左值(只是通常会将其赋值给一个有名称的左值,而不是直接访问这个匿名左值)
- 将返回值定义为实例常量(而不是一般实例)意义不大,因为返回的实例是右值
- 如果将返回值定义为常量引用,不能赋值给非常量引用,不能通过返回值修改实例成员
- 习惯上,如果函数外部需要人为释放返回值指向的内存,便将返回值定义为指针(而不是引用)
类实例 | 左值引用 | 常量左值引用 | 右值引用 | |
---|---|---|---|---|
本地类实例 | 不构造本地变量,直接构造外部变量 | 违法操作[1] | 同左 | 编译错误 |
临时类实例 | 不构造本地变量,直接构造外部变量 | 编译错误 | 违法操作[1] | 违法操作[2] |
参数类实例[5] | 调用移动构造函数构造外部变量[3] | 违法操作[1] | 同左 | 编译错误 |
全局类实例等 | 调用拷贝构造函数构造外部变量[4] | 同左 | 同左 | 编译错误 |
移动后的本地/参数类实例 | 编译错误 | 编译错误 | 违法操作[1] | 违法操作[2] |
移动后的全局类实例等 | 编译错误 | 编译错误 | 正确地移动 | 正确地移动 |
上表中,第一行为定义函数时规定的返回值类型,第一列为实际返回的对象;规定函数外部均以类实例(而不是引用)接收返回值
[1]:调用拷贝构造函数时,本地变量已被释放,导致违法内存访问(不一定引发异常或数据错误)
[2]:会构造本地变量,并试图调用移动构造函数,但此时本地变量已被释放。外部变量复制了已被释放的指针,而外部变量析构时,试图再次释放这些指针,导致违法内存访问(一定会引发异常)
[3]:正确地先调用移动构造函数,再释放本地变量
[4]:正确地先调用拷贝构造函数,再释放本地变量
[5]:特指参数定义成了类实例,并返回参数的情况;如果参数定义成了引用或指针,再返回参数,实际上会导致[4]
1 |
|
- 返回多个变量的方式:
- 定义包含各个变量的结构体
- 改用引用参数或指针参数(此方式一定不需要拷贝类实例)
- 返回数组(实际上不能直接返回数组,只能返回指针,这意味着必须进行堆分配)
- 返回array、vector等容器(不需要进行堆分配)
- 返回元组
const说明符
- 如果用const修饰成员函数:
- 表示本函数不会修改所在类的所有非静态成员变量
- 此函数不能调用无const修饰的成员函数
- 某个类的常量引用或常量指针只能访问以const修饰的成员方法
1 |
|
以上代码中,如果删掉第二个operator->,或删掉其返回值中的const,那么TestPtr的const约束便无法按照设想那样延申到Test上*
- 有时一个函数应当提供有/无const说明符的两个版本,确保常量引用和常量指针正确地发挥约束作用
虚函数
- 以virtual修饰的函数一定是虚函数
- 若一个函数是虚函数,其子类中同签名的函数,无论是否以virtual修饰,均为虚函数
纯虚函数
- 纯虚函数是特殊的虚函数,不包含实现(相当于C#的abstract)
- 以virtual修饰,函数名后附加“=0”的,一定是纯虚函数
- 若一个函数是纯虚函数,其子类中同签名的函数,如果函数名后依然附加“=0”,则依然为纯虚函数;否则为一般的虚函数
- 如果一个函数不是纯虚函数,其子类中同签名的函数,不能是纯虚函数
重载/重写/覆盖
- 只有同一个类或有继承关系的若干个类中同名且签名不同的函数才可能发生重载
- 只有有继承关系的若干个类中的同签名函数才可能发生重写或覆盖(相当于C#中的override和new)
- 只有使用指针,才有可能正确地调用被重写的函数
- override是说明符,表示该函数可能发生重写,非必须,主要作用是帮助编译器检查
- 判断重写/覆盖的步骤:
- 假设构造了B类型的实例,B继承A,通过A类型指针调用F函数
- 确定A中的F函数是否为虚函数(与A的子类无关,但可能与A的父类有关)
- 是虚函数时,发生重写;否则发生覆盖
1 |
|
运算符
- 运算符重载如果依附于单个实例,则定义在该实例类的内部,否则定义为全局函数
- 全局函数和成员函数的调用方法不同(C#中,可以通过this参数统一两者,但C++中不行)
- 如果全局函数需要访问private成员,应使用友元
new
-
为类实例分配内存空间。指针类型固定,大小自动计算,然后会自动调用该类型的构造函数
- 从底层来看,调用名为operator new的标准库函数分配内存
- 是类型安全的,试图把new对赋值给其他类型会编译错误
-
new[]用于分配多个连续的内存块(数组)
-
可以使用placement new这一语法,在事先分配好的内存上调用构造函数来完成初始化
-
重载new运算符,可用于判断是否发生堆分配
delete
-
回收一个类实例的内存空间。会自动调用析构函数
- 从底层来看,为operator delete的标准库函数回收内存
-
delete[]用于回收多个连续的内存块(数组)
=
- 如果赋值号左侧是尚未构造的对象,那么等号必然会调用某种构造函数,否则调用赋值运算符
- 赋值运算符必须人为定义,可能需要定义多个不同参数的重载
- 未定义则不可使用等号运算符,这时该类实例具有**“不变性”**(见以下代码)
- 最常见的以常数引用为参数,与拷贝构造函数共同发挥作用
- 以右值引用为参数的等号运算符也被称为移动赋值运算符(见移动构造函数)
1 |
|
不建议像上面这样定义Vector2,无法修改x和y会带来很大的麻烦(毕竟不像C#那样class默认为引用类型)
类 | 调用赋值运算符时行为 |
---|---|
C#中的string | 该指针(引用类型)指向一个新的地址 |
Unity中的Vector2 | 该指针(引用类型)指向一个新的地址 |
C++中的string | 该实例的一个成员(指向字符串的指针)的值发生改变 |
上面代码中的Vector2 | 该实例所有成员的值不会发生改变 |
<<
- 全局函数,第一个参数为ostream&(不可用const修饰),返回值为ostream&
1 |
|
->
- 此运算符无参数,默认情况下,此运算符返回*this
- 常用于智能指针这种在核心功能上“包装”了一层的情况,目的是简化调用
- 通常应该提供有const说明符和无const说明符的两个重载
1 |
|
==
- 要比较两个自定义类型的实例是否相等,要将==运算符的重载定义为成员函数(如果是非成员函数的运算符重载,不能两个参数都是自定义类型)
- 要比较两个自定义类型的指针是否相等(比较其具体内容,乃至多态相关的问题),不能通过重载该类型的运算符;对两个指针使用运算符始终意味着比较地址是否相等
函数指针
- 除了直接调用,函数只能以指针的形式存在(一般情况下,函数的取地址符可省略)
- 如果两个函数的参数列表和具体实现完全相同,可能会被优化成同一个函数,具有相同的地址
1 |
|
- 无法直接获取成员函数的地址
- 成员函数指针、函数指针、数据指针间不可相互转换/比较
1 |
|
lambda表达式
- 用完即弃的匿名方法
1 |
|
类
- 类不能加修饰符,相当于固定为public
成员变量
-
如果将成员变量定义为实例:
- 若来源为外部实例,会发生拷贝
- 类析构时自动释放
-
如果将成员变量定义为指针:
- 若在类内部分配地址,一般必须在类析构时回收(注意避免接收外部传入的地址)
- 若来源为外部地址,可以深拷贝,析构时释放目的地址,源地址在外部人为释放(效率低但通用的做法)
- 若来源为外部堆地址,可以浅拷贝,可以选择析构时释放或由外部释放(效率高,必须向外部说明,必须避免传入栈地址)
- 若来源为外部栈地址,可以浅拷贝,等外部自动释放(效率高,必须确保使用时外部尚未释放该成员的空间)
- 构造时可以初始化为空,之后重新赋值
-
如果将成员变量定义为引用:
- 类析构时自动释放(释放指针本身的空间,而不是被引用对象占用的空间)
- 若来源为类外部对象,仅浅拷贝地址(必须确保使用时外部尚未释放该成员的空间)
- 构造时必须引用现存对象
可变成员变量
- 以mutable修饰的成员变量
- 带有const说明符的成员函数也可以修改可变成员变量
成员初始化
- 静态对象通常在调用全局语句时初始化,调用构造函数后所有对象必然初始化完成
全局语句 | 声明时 | 初始化成员列表 | 构造函数内部 | 不初始化[1] | |
---|---|---|---|---|---|
一切静态实例 | 可行 | 编译错误[9] | 编译错误 | 链接错误[2] | 链接错误[3] |
实例 | 编译错误 | 可行但少见 | 可行 | 可行 | 自动调用无参构造函数[4] |
常量实例 | 同上 | 可行 | 可行 | 编译错误[5] | 编译错误[6] |
指针 | 同上 | 可行但少见 | 可行 | 可行 | 运行时错误[7] |
常量指针 | 同上 | 可行 | 可行 | 编译错误[5] | 编译错误[6] |
引用 | 同上 | 可行 | 可行 | 不是初始化[8] | 编译错误[6] |
常量引用 | 同上 | 可行 | 可行 | 编译错误[5] | 编译错误[6] |
上表中,第一行表示初始化时机,第一列表示成员类型
[1]:没有在任何时机人为初始化(注意,某个构造函数可能仅初始化了部分成员,某个成员可能仅被一部分构造函数初始化)
[2]:实质上是试图重新赋值;然而该成员实际上只有声明没有定义;如果试图访问该成员,会导致链接错误
[3]:只有声明没有定义;如果试图访问该成员,会导致链接错误
[4]:如果该成员没有无参构造函数,会导致编译错误
[5]:实质上是试图重新赋值;不提供等号运算符重载会导致编译错误;即使提供了,试图对常量重新赋值也会导致编译错误
[6]:包含此类型的成员时,无法生成默认构造函数;如果又没有人为定义构造函数并初始化该成员,则试图构造实例时导致编译错误
[7]:构造类时,分配内存后,该指针成员占用的内存区域没有初始化,试图访问该成员则导致运行时错误
[8]:实质上是重新赋值(修改引用的对象),仍然得在初始化列表中先初始化
[9]:int,bool类型的静态实例可以在声明时初始化
1 |
|
构造函数
- 定义类实例而不赋值也会调用默认构造函数
- 调用构造函数时,当且仅当使用new运算符时,才进行堆分配,才会返回指针
- 构造函数执行顺序:
- 如果有父类,先执行其父类的构造函数(头递归)
- 按照初始化成员列表,执行各个成员变量的构造函数(按照定义顺序)
- 执行自身的构造函数内部的代码(各成员之前已构造好,只可能发生重新赋值)
1 |
|
默认构造函数
- 未定义构造函数时,(编译时)自动生成默认构造函数,某些情况下无法生成默认构造函数(见成员初始化)
- 无论参数列表如何,人为定义的任何构造函数均覆盖默认构造函数
- 利用default关键字可以定义与默认构造函数相同的无参构造函数
1 |
|
拷贝构造函数
- 拷贝构造函数可分为三类:
- A:默认拷贝构造函数(只进行浅拷贝)
- B:以该类常量引用为参数的拷贝构造函数
- C:以该类引用为参数的拷贝构造函数
- 永远使用B,而不是C(见传参);更不要让B和C共存,会导致问题变得复杂
1 |
|
移动构造函数
- 参数列表为右值引用,必须添加noexcept说明符
- 人为定义的移动构造函数会覆盖默认移动构造函数,仅定义拷贝构造函数而不定义移动构造函数则会导致该类没有移动构造函数
- 默认的移动构造函数仅执行浅拷贝,而不会将指针成员设为nullptr(见move)
- 默认移动赋值运算符不存在,用到时必须人为定义
1 |
|
初始化成员列表
-
如果一个成员没有出现在初始化成员列表中,必定要调用该成员的无参构造函数
- 没有无参构造函数的成员必须出现在初始化成员列表中
- 直接初始化比初始化后再赋值开销更低
- 可以用于初始化常量成员、引用成员,但不可用于初始化静态成员
-
初始化成员列表按照定义变量的顺序执行,与列表中的顺序无关
-
如果构造函数的声明与定义分离,初始化成员列表应当仅出现在定义中
1 |
|
1 |
|
构造时隐式类型转换
- 栈分配类实例,且该类构造函数仅有一个参数时,可以简写为形如
Test t1 = "Hello";
- 函数的某个参数为类实例或类引用,且该类构造函数仅有一个参数时,可以简写为形如
Print("Hello");
- 使用explicit修饰构造函数可禁用构造时隐式类型转换(但依然可以显式类型转换)
1 |
|
析构函数
- 分配在栈上的类实例被自动释放时,或分配在堆上的类实例被delete时,会调用析构函数
- 类实例被释放时,类成员本身占用的空间会自动释放,需要在析构函数中释放的是类成员中指针(包括数组)指向的空间
1 |
|
- 有可能被继承的类,应当定义虚析构函数(子类析构函数自动为虚析构函数,不需要继续用virtual修饰)
- 子类中的析构函数并不会重写父类中的,而是确保二者先后被调用
继承
访问权限
- 类继承时默认采用私有继承(C#默认采用公有继承)
- 如果不是非公有继承,不能直接实现多态(子类指针不能隐式转换为父类指针)
- C++允许多重继承,且没有接口的概念
修饰符 | public继承后 | protected继承后 | private继承后 |
---|---|---|---|
public | public | protected | private |
protected | protected | protected | private |
private | 不可见 | 不可见 | 不可见 |
- private表示子类可见,但不能继续继承;prtoteted表示,子类可见,且可以继续继承
成员函数
- 在子类中,要调用父类中被重写/覆盖的函数时,需指明父类名
- 调用子类的构造函数前,必须先调用父类的某个构造函数(如果没有指定,则尝试调用默认构造函数)
- 如果有可能将子类实例赋值给父类指针,父类的析构函数必须用virtual修饰
1 |
|
多态
-
如果将某个变量定义为类实例,就一定表示那个类本身,而不是其派生类的实例
- 不能定义包含纯虚函数的类实例
-
而某个类的指针可以指向其派生类的实例,因此要实现多态必须使用指针
- 需要实现多态的父类必须有至少一个虚函数(否则多态无意义)
1 |
|
内存布局
- 类实例总是占用连续的一块内存,包括非静态成员变量和虚函数表指针(如果有)
- 特别地,空类固定占用1B空间
- 将内存对齐时的基本单位称为“块”,块大小取决于成员变量中占用空间最大的一个,但不超过8B
- 小于等于一块的变量不能横跨两个块;大于等于一块的变量的起始地址必须是一个块的起始地址
- 变量按照定义顺序分配内存,满足上述规则的前提下尽量紧密排列
- 子类可以继续使用父类占用空间最后不满一块的部分
虚函数表
- 每个有虚函数的类分配一个虚函数表(父类与子类的虚函数表是独立的),位于只读数据区
- 虚函数仅与类定义有关,与类实例无关,因此编译时就已生成好
- 虚函数表的结构类似数组,其中包含若干个虚函数指针,每个指针指向该类中的虚函数实现(本质上是代码段的一个地址)
- 每个有虚函数的类实例持有一个虚函数表指针,是类占用的空间的一部分
- 构造类实例时,调用哪个构造函数,就令该实例的虚函数表指针指向该类的虚函数表首地址(赋值给父类指针不改变虚函数表指针)
结构体
- 结构体和类几乎一样,除了以下区别:
- 结构体的成员默认为public,且默认采用公有继承
- 结构体无法实现模板类等特殊功能
- 习惯上,单纯包含一些public数据的对象会被定义为结构体
- 可以定义匿名类/结构体(指成员变量没有名称,而不是结构体没有类型名):
- 如果定义了全局匿名类/结构体,便只能在定义结构体时一并构造其实例
- 在类/结构体中定义匿名类/结构体,便可以不指名结类/构体名,直接访问其中的成员
联合体
- 各个成员占用相同的一块内存空间:
- 第一个成员规定了整个联合体占用的空间
- 联合体中每个成员可以视为指向同一个地址的不同指针
- 可以定义匿名联合体**(指成员联合体变量没有名称,联合体是否有类型名不影响)**:
- 非匿名联合体中可以像类一样定义成员方法,但无法使用继承、虚函数等特性
- 不能定义全局匿名联合体,要么命名,要么改变作用域(设为静态也是改变作用域)
- 在类/结构体中定义匿名联合体,便可以不指名联合体名,直接访问其中的成员
1 |
|
枚举
- 访问枚举常量时,仅需要给出枚举常量名,而不用给出枚举名(与C#不同),这意味着枚举常量不能和其他作用域重合的对象重名
- 可以利用namespace、using指令辅助命名
模板
- 模板比泛型的功能更强大,且限制更宽松(编译前不检查语义上的错误,编译时才会发现),模板中的“T”称为”模板参数“
- 模板参数不仅可以表示单个未确定的类型(以typename修饰),还可以表示若干个未确定的类型(模板参数列表),及未确定的值(以具体变量类型名修饰)
- 模板本质上是编译时执行的自动代码生成手段;定义模板函数后,只有以具体模板参数调用了该函数,编译后才包含对应版本的函数
模板函数
- 模板函数(及模板类中的函数)的定义一般位于头文件中(如果位于源文件中,编译器无法自动确定需要编译哪些版本的模板函数)
- 模板参数用于函数内部
- 模板函数可以与非模板版本同时存在,调用函数时优先选择非模板版本
- 需要访问模板类实例时且无法确定某模板参数时,使用模板方法
1 |
|
模板类
- 模板参数用于类内部
1 |
|
- 模板类中还可能包含模板类和模板参数,即模板嵌套
1 |
|
- 定义模板类实例时,如果还不能具体确定模板参数,则必须使用
typename
修饰
1 |
|
- 利用using指令可以给模板类取别名
1 |
|
- 如果父类为模板参数不确定的模板类,访问父类成员时必须使用this->,否则编译器无法正确编译
1 |
|
is_same
std::is_same<A,B>::value
:A与B相同时,此表达式返回true,否则返回false
1 |
|
is_base_of
std::is_base_of<A,B>::value
:A是B的基类,或A与B是同一个类时,此表达式返回true,否则返回false
enable_if
- 用于仅在符合条件时定义模板函数(使用不符合条件的模板参数则编译时报错)
typename std::enable_if<A, B>::type
:当模板参数A的值为true时,此表达式表示B这个类型;当模板A的值为false时,包含此表达式的模板函数/类不编译
1 |
|
API
通用
string
- iostream头文件中包含string的定义,但string的一些成员被定义在string头文件中
- string类本身可以分配在栈上或堆上,包含一个指向字符串的指针,该字符串默认情况下分配在堆上
- 使用char*指向存放字符串的空间(通常分配在堆上),并额外包含长度等成员变量
- 对字符串使用赋值运算符,通常会在堆上重新分配一块空间存放字符串,然后释放原本的空间
- 小字符串优化:字符数量少于某个预设值(通常为15)的字符串(不是指string类本身)会被分配到某个栈上一块预分配好的缓冲区中,以降低开销
内存分配
malloc
-
在堆上分配一块指定大小的内存空间,不会进行任何初始化
- 没有指定指针类型,返回void*,需要人为类型转换,并不类型安全
-
调用malloc,再在分配的内存上根据类的内存布局复现构造函数中的行为,可以取得和new相同的效果
-
除了分配对象本身占用的空间,还会分配一块额外的空间,并写入描述对象占用空间的元数据(
new
也类似)- 如何写入、如何访问元数据都是定死的(取决于操作系统,而不是IDE),因此不需要为元数据再准备元数据
free
- 单纯的回收一块堆上内存空间。传入的指针类型仅仅指明了要回收的空间大小,不会自动调用相应的析构函数
- 强行让某类指针指向一块地址,然后人为调用该类的析构函数,再调用free,可以取得和delete相同的效果
- 根据元数据确定需要释放的内存大小(
delete
也类似)
alloca
- 在栈上分配一块指定大小的内存空间
- 分配的空间会被自动回收(因此禁止在作用域外使用
alloca
返回的指针)
sizeof
- 获取指定对象或指定类型占用的字节数
- 用于数组:返回数组占用的总空间(数组作为指针参数传递后,在函数内部无法正确获取数组占用的总空间)
- 用于指针:64位计算机上总是返回8
- 用于引用/实例/类型:返回该类型的实例占用的空间
1 |
|
strlen
- 获取char* 所指的字符串的字符个数(不含结束符)
1 |
|
数据结构
- 各类容器作为参数传递时,大部分情况下传引用(传实例则发生深拷贝)
- 各类容器的元素分配在堆上,容器析构时自动释放
- 如果元素是指针,仅指针本身分配在堆上,仅释放指针本身(注意内存泄漏问题)
- 元素不能定义为引用
- 如果元素是实例,访问元素时考虑使用引用来避免拷贝
array
- 大小固定且类型可变(利用模板参数),分配在栈上的数组
iterator
- 有类似于指向容器元素的指针的功能和语法,但不能转换为容器元素指针
- 能够执行自增、自减、加法、减法,但底层实现和指针不同
1 |
|
list
- 存放同种类型元素的链表
- 支持在头部/尾部删除/添加元素
vector
- 存放同种类型元素、可动态增长的数组
- push_back通常会调用拷贝构造函数;如果传入的是右值,则优先调用移动构造函数(若存在)
- emplace_back如果直接传入构造函数的参数,则直接在vector容器内构造实例,避免拷贝或移动;传入实例则与push_back一致
- 删除元素、数组扩容时,可能会引发元素移动,此时优先调用移动赋值运算符
- 移动vector实例时
A=move(B)
,将B的所有元素浅拷贝给A,对B可能不会执行任何操作(故谨慎使用move) - 优化策略:
- 条件允许时,尽可能预设好vector的容量
- 确保正确定义了存入对象的拷贝构造函数、移动构造函数、移动赋值运算符
- 视情况选择存入实例(分配回收开销低,复制开销高)还是存入指针
1 |
|
sort
- 对标准库中的一些容器进行排序
- 通过迭代器指定参与排序的元素范围
- 通过函数指针指定排序方式(返回true表示第一个参数排在前面)
- 如果是成员函数,需要bind或lambda表达式来绑定,以获取函数指针
- 未指定排序函数时,使用该类型的**<运算符(升序)**
- 提供的函数必须有**严格弱排序(如果传入某对参数时返回true,那么交换这对参数后一定不能依然返回true)**的性质
1 |
|
set
- 通过红黑树实现,每个元素仅包含值
- 元素是始终有序的,可以在构造时传入比较方法以自定义顺序,以及按顺序访问
- 存放自定义类型时,必须确保该类型重载了<
- 插入、删除的时间复杂度为O(log N)
map
- 与set类似,通过红黑树实现,每个元素包含键值对
unordered_set
- 通过散列实现,每个元素仅包含值
- 使用拉链法避免冲突
- 元素是无序的
- 存放自定义类型时,必须确保该类型重载了==,并在头文件中为该类型定义了hash模板结构体
- 插入、删除的时间复杂度近似为O(log 1)
1 |
|
unordered_map
- 与unordered_set类似,通过散列实现,每个元素包含键值对
- 必须确保key重载了==,并定义了hash模板结构体(通常用内置类型作为key,自然满足此条件)
- 使用[]赋值时,不需要检查是否存在key(不存在key则直接新增)
- insert以pair类型对象为参数;而emplace有key和value两个参数(开销更低)
- find返回一个迭代器,找不到时返回end,应当用此方法判断是否包含某个键(要查找值则只能遍历)
1 |
|
1 |
|
智能指针
- 智能指针本质上是包含指针的模板类,其本身不是指针
- 通常在栈上分配智能指针类实例,在堆上分配则仍需要主动释放
unique_ptr
- 指针指向堆上的地址,unique_ptr实例离开作用域时,释放指针指向的地址
- 地址不可复制(可能造成内存泄漏)
- 可以由release函数手动回收,相当于提前结束作用域
1 |
|
shared_ptr
- 指针指向堆上的地址,且额外包含一个int,指向计数器*
- 构造新的shared_ptr时,额外给计数器分配内存,初值设为1
- 复制shared_ptr时,拷贝两个指针,计数器+1
- 修改shared_ptr时(只能将另一个shared_ptr赋值给shared_ptr),先使当前计数器-1(以确定是否需要释放当前指向的内存),然后指向新的内存,并指向新的计数器
- shared_ptr离开作用域时析构,析构函数中使计数器-1
- 计数器归0时释放指针指向的内存,以及计数器的内存
- 不能手动回收(可能造成内存泄漏)
1 |
|
weak_ptr
- 指针指向堆上的地址,可以读引用计数器,但不会修改引用计数器,不确保地址没有被回收
- weak_ptr可以赋值给另一个weak_ptr,但不能赋值给另一个shared_ptr(因为指向的地址可能已被释放)
- shared_ptr总是可以赋值给weak_ptr(因此也可以通过make_shared函数构造)
1 |
|
函数式编程
function
- function类实例包含一个函数指针,可以通过该实例调用函数
- 通过模板参数规定函数的返回值、参数列表
- 通过
target<?>()
成员函数获取指向函数指针的指针(取内容后可以判断是否为同一函数) - 通过
target_type()
成员函数获取其函数指针的类型(可以比较参数列表及返回值是否相同)
1 |
|
- 成员函数不能直接赋值给function
1 |
|
mem_fn
- 通过成员函数生成可调用对象(确定返回值的具体模板参数很麻烦)
- 实质上是在成员函数的参数列表开头添加一个参数,类型为该函数所属类指针,由此将成员函数转换为非成员函数
1 |
|
bind
- 通过现有函数生成新的可调用对象
- 可以固定若干个参数
- 可以调整未被固定的参数的顺序
1 |
|
- bind可以在mem_fn的基础上,固定住“成员函数转换而得的非成员函数”的第一个参数
1 |
|
- 如果将bind返回的可调用对象赋值给function:
- function模板中的参数列表在“合理范围”内是可变的,不需要与bind返回的可调用对象完全对应(编译器无法检查是否对应)
target<?>()
的模板参数难以确定(?)
1 |
|
并发编程
- C++中的线程有以下几种状态:
- 就绪态:尚未运行;有空闲的CPU资源便自动转为运行态
- 运行态:正在运行
- 阻塞态:不满足运行条件,暂停运行;等待的事件发生后,转为就绪态
- 可连接态:运行完毕,资源尚未释放
- 终止态:资源已释放,线程本身的生命周期尚未结束
thread
1 |
|
计时
high_resolution_clock
- 精确获取时间
1 |
|