C++  |  学习笔记 · 完整版

C++ 基础知识整理

从编译链接到移动语义与 STL,沿 The Cherno C++ 教程脉络,覆盖 35 个核心主题,配合代码示例、练习思考与速查表,便于复习与查阅。

// 该笔记由零涅夫整理,网页使用 AI 编写 // 整理自手写笔记 + The Cherno 教程 // YouTube 原版教程

01编译与链接基础P5-P7

理解 C++ 代码如何从源文件变成可执行程序,是学习后续所有内容的前置知识。

翻译单元(Translation Unit)

一个 .cpp 源文件经过预处理(展开 #include、替换宏)后,得到的单个文本就是翻译单元。编译器以翻译单元为单位进行编译,每个翻译单元独立编译为一个 .obj(目标文件)。

编译流程

// 编译器的工作流程:
// 1. 预处理:展开 #include、替换 #define、处理 #if/#ifdef
// 2. 生成抽象语法树(AST)
// 3. 生成汇编代码
// 4. 生成目标文件(.obj / .o)

// 单独编译一个文件:Ctrl+F7(VS)
// 编译整个项目:Build(会触发链接)

链接(Linking)

链接器将所有目标文件合并为一个可执行文件(.exe)。它的核心任务是找到每个符号(函数、变量)的定义,将调用处与定义处连接起来。

// main.cpp 中调用了 Log(),但 Log() 定义在 Log.cpp 中
// 编译时:main.cpp 只需要 Log() 的声明
// 链接时:链接器在 Log.obj 中找到 Log() 的定义并连接

// 声明(告诉编译器这个函数存在)
void Log(const char* message);

int main() {
    Log("Hello");  // 链接器负责找到 Log 的定义
}

常见链接错误

unresolved external symbol
链接器找不到函数定义。原因:函数声明了但没定义,或函数名拼写不一致。
重复定义
同一个符号在多个翻译单元中都有定义。解决:加 static(内部链接)、inline,或将定义放在 .cpp 中。
Debug vs Release

Debug 模式关闭优化,便于调试;Release 模式开启 Maximize Speed 优化,代码会被重排和精简,断点行为可能不同。

练习与思考

如果一个函数在 .h 文件中直接定义(非 inline),被两个 .cpp 包含,会发生什么错误?如何解决?

02变量与数据类型P8

C++ 基本数据类型按整数和小数两大类划分,不同类型占用不同字节数,取值范围也不同。

类型 类别 字节 取值范围 用途 / 示例
int 整数 4 约 -21 亿 ~ +21 亿 计数、循环、血量等
char 整数 / 字符 1 -128 ~ 127 单字符,单引号:'A' '1'
short 整数 2 -32768 ~ +32767 较小范围整数
long 整数 4 或 8 平台相关 Windows 上为 4 字节;Linux/macOS 上为 8 字节(遵循 LP64 数据模型)
long long 整数 8 超出 int 范围 极大整数(C++11 起保证 64 位)
float 小数 4 约 7 位有效数字 需加后缀 f,如 float v = 5.5f;
double 小数 8 约 15~16 位有效数字 双精度浮点
bool 整数 1 true / false 非零为 true,零为 false
long 的平台差异

long 的大小取决于平台和编译器:Windows(含 64 位)上为 4 字节,Linux/macOS 64 位系统上为 8 字节。如需固定大小的整数,推荐使用 <cstdint> 中的 int32_tint64_t 等。

unsigned 修饰符

unsigned 表示无符号(仅非负数),取值范围从 0 开始。例如 unsigned char 范围为 0 ~ 255。可加在各整数类型前。

sizeof 运算符

使用 sizeof(类型) 可查看某类型占用的字节数:

// 查看类型大小
sizeof(int);     // 4
sizeof(char);    // 1
sizeof(double);  // 8
sizeof(long);     // Windows: 4, Linux/macOS: 8
练习与思考

unsigned int 能存储的最大值是多少?如果将 -1 赋给 unsigned int,结果是什么?

03控制结构P12 · P37

条件判断语句根据条件的真假决定执行路径。

if 语句

条件为真时执行代码块。

if (条件) {
    // 条件为真时执行
}

if-else 语句

if (条件) {
    // ① 条件为真时执行
} else {
    // ② 条件为假时执行
}

三元运算符 ? :

条件表达式的简写形式:条件 ? 值A : 值B。常用于取最大值等场景:

int max = (a > b) ? a : b;

switch-case 语句

多分支选择结构,根据变量值跳转到对应 case。每个 case 后需加 break 防止贯穿。

switch (变量) {
    case 1:
        // 执行操作 1
        break;
    case 2:
        // 执行操作 2
        break;
    default:
        // 默认操作
        break;
}
练习与思考

如果忘记在 case 后写 break,会发生什么?这种现象叫什么?

04循环结构P14

三种循环方式,适用于不同的重复执行场景。

for
已知循环次数时使用。结构:for(初始化; 条件; 步进)
while
先判断条件再执行。条件为假时不执行。结构:while(条件)
do-while
先执行后判断,至少执行一次。结构:do{ }while(条件);
// for 循环
for (int i = 0; i < 10; i++) {
    // 重复执行 10 次
}

// while 循环
while (条件) {
    // 条件为真时重复执行
}

// do-while 循环(至少执行一次)
do {
    // 代码块
} while (条件);  // 注意末尾分号
提示

while(1)for(;;) 表示无限循环,需配合 break 跳出。

练习与思考

do-while 写一个"先执行一次再判断是否继续"的菜单循环,思考它和 while 的核心区别。

05流程控制关键字P15

用于在循环或函数中改变执行流程。

continue
跳过本次循环剩余部分,直接进入下一次循环。
break
跳出当前循环或 switch 结构。
return
从函数中返回,可携带返回值。遇到即结束函数。
goto
无条件跳转到标签处。一般不推荐使用
练习与思考

在一个嵌套循环中,break 能跳出几层循环?如果需要跳出多层,有什么替代方案?

06指针P16

指针是存储变量内存地址的特殊变量,是 C/C++ 的核心概念之一。

核心运算符

& 取地址

获取变量的内存地址。
&var → 返回 var 的地址

* 解引用

访问指针指向地址上的值。
*ptr → 返回 ptr 指向的值

使用示例

int var = 8;        // 声明变量
int* ptr = &var;      // 指针 ptr 存储 var 的地址

*ptr = 10;           // 通过指针修改变量的值
// 此时 var 的值变为 10
理解要点

指针本身也有类型(如 int*),决定了通过它访问内存时的数据解释方式。声明时 * 靠近类型名或变量名均可,效果相同。指针自身的大小取决于平台:64 位系统上为 8 字节,32 位系统上为 4 字节。

练习与思考

int* a, b; 声明了几个指针?b 的类型是什么?

07引用P17

引用是已有变量的别名,本质上是一个"扩展的指针",但使用更简洁。

基本用法

int a = 5;
int& ref = a;   // ref 是 a 的引用(别名)

ref = 3;        // 通过 ref 修改,a 也变为 3
引用 vs 指针

引用必须在声明时初始化,且不能重新绑定到其他变量;指针可以改变指向。引用使用时无需解引用,语法更直观,常用于函数参数传递。

练习与思考

为什么函数参数推荐用 const std::string& 而不是 std::string?从性能角度解释。

08内存管理:new / deleteP39

使用 new 在堆上动态分配内存,使用完毕后必须用 delete 释放,避免内存泄漏。

// 动态分配字符数组
char* buffer = new char[8];

// 使用完毕后释放
delete[] buffer;
注意事项

数组分配用 new[],释放时必须用 delete[](带方括号);单个对象用 newdelete。两者不可混用。new 实际做了两件事:①调用 malloc 分配内存 ②调用构造函数。

练习与思考

如果用 new[] 分配但用 delete(不带 [])释放,会发生什么?为什么?

09函数基础P9

函数是将代码封装为可复用模块的基本单元。

函数结构

返回类型 函数名(参数列表) {
    // 函数体
    return 返回值;
}

示例:

int add(int a, int b) {
    return a + b;
}

void sayHello() {   // void 表示无返回值
    // 执行操作,不需要 return
}
练习与思考

函数声明和函数定义的区别是什么?为什么头文件中放声明而不是定义?

10类与结构体P18-P19

类和结构体都是将变量(数据)和函数(方法)组合在一起的复合类型,区别在于默认访问权限。

class 类

默认访问权限为 private(私有)。

class 类名 {
    // 默认 private
    int data;
public:
    void method();
};
struct 结构体

默认访问权限为 public(公有),
为了兼容 C 语言。

struct 结构体名 {
    // 默认 public
    int data;
    void method();
};
练习与思考

什么时候用 struct,什么时候用 class?(提示:Cherno 的建议是纯数据用 struct,有逻辑用 class。)

11常用关键字P21 · P24 · P30 · P34

四个重要的类型与作用域相关关键字(后续章节有更深入的展开)。

static
静态成员属于类本身,而非某个实例对象。静态方法可不创建对象直接调用。
→ 详见第 12 章
extern
声明变量或函数在别处定义。告诉编译器"这个符号存在,但定义在别的文件中"。
const
常量修饰符,表示数据不可修改。如:const int a = 5;
→ 详见第 21 章
enum
枚举类型,将一组整数常量整合在一起,便于命名管理。
// const 常量
const int a = 5;   // a 不可被修改

// enum 枚举
enum Color {
    RED,      // 0
    GREEN,    // 1
    BLUE      // 2
};
扩展建议

现代 C++(C++11 起)推荐使用 enum class,它提供更强的类型安全,不会隐式转换为整数。

练习与思考

enum class 和普通 enum 的区别是什么?为什么说前者更安全?

12static 关键字深入P21-P23

static 在不同上下文有不同含义:翻译单元内的内部链接、类级别的共享成员、函数内的持久局部变量。

类外的 static:内部链接(P21)

类或结构体之外的 static 表示该变量或函数的链接性为内部链接,即只对定义它的翻译单元(.cpp 文件)可见。链接器不会在其他翻译单元中寻找该符号。

// static.cpp —— 定义
static int s_variable = 8;  // 仅本翻译单元可见
static void function() { }

// main.cpp —— 另一个翻译单元
int s_variable = 5;  // 全局变量,与上面的 static 互不冲突
int main() {
    LOG(s_variable);  // 输出 5
}
Cherno 的建议

当你不需要变量是全局变量时,就尽可能使用 static。这样可以避免跨文件链接冲突,也让意图更清晰。

类内的 static:共享成员(P22)

类或结构体内部的 static 变量在所有实例中只有一个副本。通过类实例引用静态变量没有意义,应通过 类名::变量名 引用。

struct Entity {
    static int x, y;
    static void Print() {
        std::cout << x << "," << y << std::endl;
    }
};

int Entity::x;  // 静态变量需在类外定义
int Entity::y;

int main() {
    Entity::x = 5;
    Entity::y = 8;
    Entity::Print();  // 输出 5,8
}

局部 static:持久局部变量(P23)

局部 static 变量的生存期等同于整个程序,但作用域仍限制在函数内。它只初始化一次,后续调用保留上一次的值。

// 使用 static —— 输出 0 1 2 3 4
void counter() {
    static int count = 0;  // 只初始化一次
    std::cout << count++ << std::endl;
}

// 不使用 static —— 输出 0 0 0 0 0
void counter_bad() {
    int count = 0;  // 每次调用都重新初始化
    std::cout << count++ << std::endl;
}
练习与思考

局部 static 变量存储在内存的哪个区域(栈/堆/静态存储区)?它的构造和析构时机是什么?

13构造函数P25

构造函数在创建对象时自动调用,用于初始化成员变量,确保对象创建时即处于有效状态。

class Entity {
public:
    float X, Y;

    Entity() {  // 默认构造函数
        X = 0.0f;
        Y = 0.0f;
    }

    Entity(float x, float y) {  // 带参数构造函数
        X = x;
        Y = y;
    }
};

int main() {
    Entity e;              // 调用默认构造函数
    Entity e2(5.0f, 6.0f);  // 调用带参数构造函数
}
练习与思考

如果你只定义了 Entity(int x),以下哪些写法会报错:Entity e;Entity e(5);Entity e = 5;

14析构函数P26

析构函数在对象销毁时自动调用,用于清理内存和释放资源。以 ~ 标识,函数名与类名一致。

class Entity {
public:
    Entity() {
        std::cout << "Created Entity!" << std::endl;
    }

    ~Entity() {  // 析构函数
        std::cout << "Destroyed Entity!" << std::endl;
    }
};
重要:虚析构函数

基类的析构函数应声明为 virtual,否则通过基类指针删除派生类对象时,派生类的析构函数不会被调用,导致内存泄漏。

练习与思考

如果一个类没有手动 new 任何内存,还需要写析构函数吗?编译器生成的默认析构会做什么?

15继承P27

继承允许子类从父类获得属性和方法,是减少代码重复的核心机制。

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 PrintName() {
        std::cout << Name << std::endl;
    }
};

int main() {
    Player player;
    player.Move(5, 5);    // 继承自 Entity
    player.X = 2;         // 继承的变量
    player.PrintName();   // Player 自己的方法
}
核心思想

子类是对父类的扩展(is-a 关系)。Player 是一个 Entity,但 Entity 不一定是 Player。

继承中的虚析构函数

如果基类指针可能删除子类对象(如 Entity* e = new Player(); delete e;),基类析构函数必须声明为 virtual,否则子类析构不会执行,导致资源泄漏。

练习与思考

Player 继承 Entity 后,Player 对象中包含哪些数据?sizeof(Player)sizeof(Entity) 的关系是什么?

16虚函数P28

虚函数允许子类重写父类方法,实现运行时多态(动态绑定)。这是面向对象编程的核心机制。

class Entity {
public:
    virtual std::string GetName() { return "Entity"; }
    // virtual 告诉编译器:这个方法可能被重写
};

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; }
    // override 关键字(C++11)帮助编译器检查重写
};

int main() {
    Player* p = new Player("Cherno");
    Entity* entity = p;  // 向上转型

    // 如果没有 virtual,这里输出 "Entity"
    // 有 virtual,输出 "Cherno"(多态)
    std::cout << entity->GetName() << std::endl;
}
性能开销

虚函数通过虚表(v-table)实现,每次调用需要一次虚表查找,且每个对象多一个虚表指针。如果不需要多态,就不要virtual

练习与思考

override 关键字不是必须的,那为什么推荐写?如果不写,什么错误会悄悄通过编译?

17纯虚函数与接口P29

纯虚函数是没有实现的虚函数,包含它的类成为抽象类(接口),不能被实例化。派生类必须实现所有纯虚函数。

class Printable {  // 接口:只包含纯虚函数
public:
    virtual std::string GetClassName() = 0;  // = 0 表示纯虚
};

class Entity : public Printable {
public:
    std::string GetClassName() override { return "Entity"; }
};

class Player : public Entity {
public:
    std::string GetClassName() override { return "Player"; }
};

void Print(Printable* obj) {  // 接受接口指针
    std::cout << obj->GetClassName() << std::endl;
}

int main() {
    Entity* e = new Entity();
    Player* p = new Player();
    Print(e);  // 输出 Entity
    Print(p);  // 输出 Player
    // Printable x;  // 错误!抽象类不能实例化
}
接口的本质

C++ 中的"接口"就是只包含纯虚函数的类。它定义了一个合约,所有子类必须遵守。接口不能实例化,但可以用接口指针/引用指向实现类对象。

练习与思考

一个类实现了部分纯虚函数但不是全部,这个类能被实例化吗?它还是抽象类吗?

18可见性P30

可见性控制类成员谁能访问:public(所有人)、private(仅类内)、protected(类内和子类)。

关键字类内子类类外
public可访问可访问可访问
protected可访问可访问不可访问
private可访问不可访问不可访问
class Entity {
public:
    int PublicVar;
protected:
    int ProtectedVar;
private:
    int PrivateVar;
};

class SubEntity : public Entity {
public:
    void Test() {
        PublicVar = 1;       // 可以
        ProtectedVar = 2;    // 可以(子类中)
        // PrivateVar = 3;   // 错误!
    }
};

int main() {
    Entity e;
    e.PublicVar = 1;         // 可以
    // e.ProtectedVar = 2;  // 错误!
    // e.PrivateVar = 3;    // 错误!
}
Cherno 的观点

可见性是编译时约束,不影响运行时性能。它主要是一种设计工具,实际上可以通过指针技巧绕过。类的默认可见性是 private,结构体的默认可见性是 public

练习与思考

protected 看似方便子类访问,但 Cherno 认为它有时不如 private。思考:为什么暴露 protected 成员可能破坏封装?

19数组P31

数组是连续内存中存储的一组相同类型元素。C++ 原生数组不记录自身大小,访问越界是未定义行为。

// 栈上数组
int array[5];
for (int i = 0; i < 5; i++) {
    array[i] = i * 2;
}

// 栈上数组初始化
int array2[5] = {1, 2, 3, 4, 5};

// 堆上数组
int* heapArray = new int[5];
delete[] heapArray;  // 必须用 delete[]

// 计算数组大小(仅对栈数组有效)
int size = sizeof(array) / sizeof(array[0]);  // 5
注意

sizeof(array)/sizeof(array[0]) 只对栈上数组有效。当数组退化为指针后(如传递给函数),sizeof 返回的是指针大小:64 位系统上为 8 字节,32 位系统上为 4 字节,而非数组总大小。原生数组越界访问不会报错,直接导致未定义行为。

推荐

使用 std::array<int, 5>(C++11)代替原生数组,它知道自身大小且支持边界检查。

练习与思考

将数组传给函数后,函数内 sizeof(arr) 返回什么?为什么 C++ 数组传参时会"退化"为指针?

20字符串与字符串字面量P32-P33

C++ 有两种字符串:C 风格 const char*(以 \0 结尾)和 std::string(封装类)。

C 风格字符串与 std::string

// C 风格字符串
const char* name = "Cherno";
// 实际存储: {'C','h','e','r','n','o','\0'},共 7 字节

// std::string
#include <string>
std::string str = "Cherno";
std::string concat = std::string("Hello") + " " + str;

// 传递给函数时用 const 引用避免拷贝
void PrintString(const std::string& str);

字符串字面量(P33)

字符串字面量存储在只读内存区域,不可修改。C++14 引入了 s 后缀可直接创建 std::string

using namespace std::string_literals;  // C++14

std::string s1 = "Cherno"s;       // s 后缀 → std::string
const char* s2 = "Cherno";        // const char*

// 不同前缀对应不同编码
// "Hello"    → const char*      (ASCII)
// L"Hello"   → const wchar_t*   (宽字符)
// u8"Hello"  → const char*      (UTF-8)
// u"Hello"   → const char16_t*  (UTF-16)
// U"Hello"   → const char32_t*  (UTF-32)
练习与思考

"Hello""Hello"s 的类型分别是什么?为什么后者在拼接时更方便?

21const 深入与 mutableP34-P35

const 承诺不会改变,mutable 允许在 const 方法中例外地修改特定成员。

const 与指针的三种组合

const int* a = new int;       // 指向的内容不能改,指针可以改
int* const b = new int;       // 指针不能改,指向的内容可以改
const int* const c = new int; // 都不能改

const 方法

class Entity {
private:
    int m_X, m_Y;
public:
    int GetX() const {  // const 方法:不修改成员
        // m_X = 2;  // 错误!
        return m_X;
    }
    void SetX(int x) { m_X = x; }
};

void PrintEntity(const Entity& e) {
    std::cout << e.GetX() << std::endl;
    // e.SetX(2);  // 错误!const 引用不能调用非 const 方法
}

mutable 关键字(P35)

mutable 允许在 const 方法中修改被修饰的成员变量。最常用的场景是缓存(lazy evaluation):第一次调用时计算并缓存结果,后续直接返回缓存值。

class Entity {
private:
    std::string m_FirstName = "Cherno";
    std::string m_LastName = "Dev";
    mutable bool m_Cached = false;
    mutable std::string m_CachedFullName;
public:
    const std::string& GetFullName() const {
        if (!m_Cached) {
            m_CachedFullName = m_FirstName + " " + m_LastName;
            m_Cached = true;  // 可以!mutable 允许在 const 方法中修改
        }
        return m_CachedFullName;
    }
};
练习与思考

除了缓存,mutable 还常用于修饰 std::mutex(互斥锁)。思考:为什么 Lock() 操作在 const 方法中是合理的?

22成员初始化列表P36

在构造函数中用初始化列表直接初始化成员,比在函数体中赋值更高效。

初始化列表(推荐)
Entity()
    : m_Name("Unknown"), m_Score(0)
{
    // 直接初始化
}
构造函数体赋值(低效)
Entity() {
    m_Name = "Unknown";
    m_Score = 0;
    // 先默认构造,再赋值
}
注意

成员变量按声明顺序初始化(不是初始化列表中的顺序)。如果成员没有默认构造函数(如 const 成员或引用成员),必须使用初始化列表。

练习与思考

如果初始化列表中的顺序和成员声明顺序不一致,编译器会怎么做?这可能导致什么隐蔽的 bug?

23隐式转换与 explicitP40

单参数构造函数可被编译器用于隐式类型转换。explicit 禁止这种隐式转换,要求显式构造。

class Entity {
private:
    std::string m_Name;
    int m_Age;
public:
    Entity(int age) : m_Age(age) { }  // 可隐式转换
    Entity(const std::string& name) : m_Name(name) { }
};

void PrintEntity(const Entity& e) { }

int main() {
    PrintEntity(22);  // int 隐式转换为 Entity
    Entity a = 22;  // 隐式转换:int → Entity

    // 加 explicit 后:
    // explicit Entity(int age) : m_Age(age) { }
    // PrintEntity(22);      // 错误!
    // PrintEntity(Entity(22)); // 正确!显式构造
}
何时使用 explicit

当构造函数只有一个参数(或其余参数有默认值)时,考虑加 explicit。它防止意外的隐式转换,是一种防御性编程手段。

练习与思考

Entity a = 22;Entity a(22); 有什么区别?加 explicit 后哪个会报错?

24运算符重载P41

运算符重载允许为自定义类型定义 +==[]()<< 等运算符的行为。运算符本质上是函数。

算术与比较运算符

struct Vector2 {
    float x, y;

    Vector2(float x, float y) : x(x), y(y) {}

    Vector2 operator+(const Vector2& other) const {
        return Vector2(x + other.x, y + other.y);
    }

    bool operator==(const Vector2& other) const {
        return x == other.x && y == other.y;
    }
};

// 重载 << 用于输出(非成员函数)
std::ostream& operator<<(std::ostream& stream, const Vector2& v) {
    stream << v.x << ", " << v.y;
    return stream;
}

下标运算符 operator[]

自定义容器类经常需要重载 [],使其像数组一样通过索引访问元素。

class IntArray {
    int m_Data[5];
public:
    int& operator[](int index) {
        return m_Data[index];  // 返回引用,允许赋值
    }
};

int main() {
    IntArray arr;
    arr[0] = 42;       // 通过 operator[] 赋值
    int val = arr[0];  // 通过 operator[] 读取
}

函数调用运算符 operator()

重载 () 使对象像函数一样可调用,这种对象称为仿函数(Functor)

struct Multiplier {
    int factor;
    Multiplier(int f) : factor(f) {}

    int operator()(int value) const {
        return value * factor;
    }
};

int main() {
    Multiplier triple(3);
    int result = triple(5);  // 像函数一样调用,输出 15
}
原则

不要滥用运算符重载(如不要让 + 做减法)。保持运算符语义与直觉一致。

练习与思考

为什么 operator[] 返回的是引用 int& 而不是值 int?如果返回值会发生什么?

25this 关键字P42

this 是指向当前对象的指针,在非静态成员方法中始终指向调用该方法的对象。

class Entity {
private:
    int x, y;
public:
    Entity(int x, int y) {
        this->x = x;  // 区分成员变量和参数
        this->y = y;
    }

    Entity& Add(const Entity& other) {
        x += other.x;
        y += other.y;
        return *this;  // 返回自身引用,支持链式调用
    }
};

int main() {
    Entity e(1, 2);
    e.Add(Entity(3, 4)).Add(Entity(5, 6));  // 链式调用
    // 结果: x=9, y=12
}
练习与思考

静态方法中没有 this 指针。思考:如果静态方法想访问某个实例的成员,该怎么做?

26对象生命周期(栈作用域)P43

栈上对象的生命周期由作用域决定:进入作用域创建,离开作用域自动销毁。这是 RAII 模式的基础。

class Entity {
public:
    Entity() { std::cout << "Created" << std::endl; }
    ~Entity() { std::cout << "Destroyed" << std::endl; }
};

int main() {
    {
        Entity e;  // 输出 "Created"
    }  // 离开作用域,自动析构,输出 "Destroyed"
}

RAII:用栈对象管理堆资源

class ScopedArray {
    int* m_Array;
public:
    ScopedArray(int size) : m_Array(new int[size]) {}
    ~ScopedArray() { delete[] m_Array; }
    int& operator[](int index) { return m_Array[index]; }
};

int main() {
    {
        ScopedArray arr(50);
        arr[0] = 5;
    }  // 自动 delete[] m_Array,无需手动释放
}
RAII 模式

Resource Acquisition Is Initialization:将资源绑定到对象生命周期。栈对象离开作用域时自动析构,保证资源释放。这是 C++ 最强大的特性之一,也是智能指针的底层原理。

练习与思考

为什么 RAII 被称为 C++ 区别于其他语言的核心特性?Java/C# 的 GC 能完全替代 RAII 吗?

27智能指针P44

智能指针是自动管理堆内存的类,使用 RAII 模式。需要 #include <memory>。三种主要类型各有不同所有权语义。

unique_ptr
独占所有权,不能复制,只能移动。开销为零(与裸指针相同)。优先使用。
shared_ptr
共享所有权,引用计数。引用计数归零时自动释放。有额外开销(控制块 + 原子操作)。
weak_ptr
不增加引用计数的观察引用。用于打破 shared_ptr 循环引用。
#include <memory>

// unique_ptr:独占所有权
{
    std::unique_ptr<Entity> entity = std::make_unique<Entity>();
    entity->Print();
    // 离开作用域自动 delete
    // std::unique_ptr<Entity> e2 = entity;  // 错误!不能复制
}

// shared_ptr:共享所有权,引用计数
{
    std::shared_ptr<Entity> sharedEntity = std::make_shared<Entity>();
    {
        std::shared_ptr<Entity> e2 = sharedEntity;  // 引用计数 +1 = 2
    }  // 引用计数 -1 = 1,不销毁
}  // 引用计数 = 0,销毁

// weak_ptr:不增加引用计数
{
    std::weak_ptr<Entity> weakEntity;
    {
        std::shared_ptr<Entity> sharedEntity = std::make_shared<Entity>();
        weakEntity = sharedEntity;  // 不增加引用计数
    }  // sharedEntity 销毁,weakEntity 失效
}
Cherno 的建议

尽可能使用 unique_ptr,只有在需要共享所有权时才用 shared_ptr。优先使用 make_uniquemake_shared 而非直接 new(更安全、更高效)。

练习与思考

两个 shared_ptr 互相持有对方(循环引用),会发生什么?如何用 weak_ptr 解决?

28拷贝构造与拷贝赋值P45

拷贝构造函数在创建新对象时调用,拷贝赋值运算符在已有对象之间赋值时调用。默认执行浅拷贝,如果类中有指针成员需要自定义深拷贝。

浅拷贝(默认)

只复制指针值,两个对象指向同一块内存。析构时 double free 崩溃。

深拷贝(自定义)

分配新内存并复制内容,两个对象独立。需要自定义拷贝构造函数和拷贝赋值运算符。

class String {
private:
    char* m_Buffer;
    unsigned int m_Size;
public:
    String(const char* string) {
        m_Size = strlen(string);
        m_Buffer = new char[m_Size + 1];
        memcpy(m_Buffer, string, m_Size + 1);
    }

    // ① 拷贝构造函数:创建新对象时调用
    String(const String& other) : m_Size(other.m_Size) {
        m_Buffer = new char[m_Size + 1];
        memcpy(m_Buffer, other.m_Buffer, m_Size + 1);
    }

    // ② 拷贝赋值运算符:已存在对象之间赋值时调用
    String& operator=(const String& other) {
        if (this != &other) {  // 防止自赋值
            delete[] m_Buffer;  // 释放旧内存
            m_Size = other.m_Size;
            m_Buffer = new char[m_Size + 1];
            memcpy(m_Buffer, other.m_Buffer, m_Size + 1);
        }
        return *this;
    }

    ~String() { delete[] m_Buffer; }

    char& operator[](unsigned int index) { return m_Buffer[index]; }
};

拷贝构造 vs 拷贝赋值

操作调用场景示例
拷贝构造用已有对象创建新对象String b = a;
拷贝赋值两个已存在对象之间赋值b = a;(b 已存在)
何时触发拷贝

拷贝不仅在 String b = a; 时发生,也在按值传递按值返回时发生。尽量用 const Type& 传递对象避免不必要的拷贝。

Rule of Three(三法则)

如果你需要自定义析构函数拷贝构造函数拷贝赋值运算符中的任何一个,通常三个都需要自定义。因为类中 likely 管理了资源(如动态内存)。

练习与思考

String b = a;String b; b = a; 分别调用哪个函数?如果只定义了拷贝构造没定义赋值运算符,后者会发生什么?

29模板P53

模板是编译器的"蓝图",根据使用方式自动生成代码。模板本身不是真实代码,只有被实例化(使用)时才生成实际代码。

函数模板

template<typename T>
void Print(const T& value) {
    std::cout << value << std::endl;
}

int main() {
    Print(5);               // 隐式推断 T=int
    Print("Hello");         // 隐式推断 T=const char*
    Print<float>(5.5f);     // 显式指定 T=float
}

类模板(类型参数 + 非类型参数)

template<typename T, int N>
class MyArray {
private:
    T m_Array[N];  // 类型 T 和大小 N 都在编译时确定
public:
    int Length() const { return N; }
    T& operator[](int index) { return m_Array[index]; }
};

int main() {
    MyArray<int, 4> array;  // int 数组,大小为 4
    array[0] = 42;
    std::cout << array.Length() << std::endl;  // 4
}

模板特化(Specialization)

有时通用模板对某些特定类型不适用,需要为该类型提供特殊实现,这称为模板特化。

// 通用模板
template<typename T>
void Print(const T& value) {
    std::cout << value << std::endl;
}

// 全特化:为 bool 类型提供特殊实现
template<>
void Print<bool>(const bool& value) {
    std::cout << (value ? "true" : "false") << std::endl;
}

int main() {
    Print(5);       // 调用通用模板,输出 5
    Print(true);     // 调用特化版本,输出 "true"
}
关键理解

模板不是真实代码,只有实际调用时才会真正生成源代码并编译。非类型模板参数(如 template<int N>)在编译时确定,可以在栈上分配变长数组。STL(标准模板库)就是基于模板编写的。全特化(template<>)为特定类型提供完全不同的实现;偏特化(partial specialization)则为部分模板参数提供特殊实现(仅类模板支持偏特化,函数模板不支持)。

Cherno 的告诫

不要过度痴迷模板。过于复杂的模板会让代码难以理解和维护。模板是强大的工具,但要适度使用。

练习与思考

为什么函数模板不支持偏特化?如果需要对 vector<T> 做特殊处理,应该用什么方式替代偏特化?(提示:函数重载)

30现代 C++ 特性P56 · P59-P60

C++11/14/17 引入的一系列特性,让代码更简洁、更安全、更高效。

auto
让编译器自动推导变量类型。减少冗长类型名,但不要滥用(影响可读性)。
nullptr
类型安全的空指针,替代 NULL0。消除了整数与指针的歧义。
range-based for
简化遍历:for (int v : vec)。配合 auto& 避免拷贝。
constexpr
指示编译期常量表达式。比 const 更强:值在编译期确定,可用于模板参数、数组大小等。
// auto 类型推导
auto x = 5;              // int
auto name = std::string("Cherno");
auto& ref = x;            // 引用,避免拷贝

// nullptr 替代 NULL
int* ptr = nullptr;      // 类型安全
// int* ptr = NULL;      // 旧写法,NULL 可能被定义为 0,有歧义

// range-based for 循环
int arr[] = {1, 2, 3, 4, 5};
for (auto val : arr) {
    std::cout << val << std::endl;
}
for (auto& val : arr) {  // 引用,可修改原值
    val *= 2;
}

// constexpr 编译期常量
constexpr int SIZE = 10;  // 编译期确定
int buffer[SIZE];           // 可用作数组大小
Cherno 的观点

auto 在类型名很长时(如迭代器)非常有用,但在类型不明显的地方(如 auto result = SomeFunction();)应显式写类型,让代码更易读。

练习与思考

constconstexpr 的区别是什么?const int x = 5; 中的 x 一定在编译期确定吗?

31常见陷阱

C++ 学习过程中最容易踩的坑,每个开发者都应该知道。

1. 浅拷贝导致 double free

类中有指针成员时,默认拷贝只复制指针值。两个对象析构时会对同一块内存 delete 两次,导致崩溃。解决:自定义拷贝构造函数和拷贝赋值运算符实现深拷贝,或使用 std::string/std::vector 代替裸指针。

2. 指针与数组的混淆

数组传给函数后退化为指针,sizeof 不再返回数组大小。在 64 位系统上 sizeof(指针) 返回 8,不是数组总字节。解决:额外传递数组大小参数,或使用 std::array

3. 忘记 virtual 析构函数

基类析构不是 virtual 时,delete 基类指针 不会调用子类析构,导致子类资源泄漏。解决:只要类可能被继承,析构函数就声明为 virtual

4. new[] 配 delete(不带 [])

new[] 分配的数组必须用 delete[] 释放。混用会导致未定义行为(只析构第一个元素或内存损坏)。解决:养成配对习惯,或直接用智能指针 / std::vector

5. 返回局部变量的引用/指针

函数返回局部栈变量的引用或指针,变量在函数结束后已销毁,引用/指针悬空。解决:返回值类型,或返回堆上对象(配合智能指针),或返回传入的引用。

6. 整数溢出与无符号数比较

unsigned int 减到负数会变成超大正数。for (unsigned i = 10; i >= 0; i--) 是死循环。解决:循环变量用 int,或改用 i > 0 后单独处理 i == 0

7. shared_ptr 循环引用

两个 shared_ptr 互相持有对方,引用计数永远不归零,内存永远不会释放。解决:其中一方改用 weak_ptr 打破循环。

8. 未初始化的变量

局部变量不初始化时是垃圾值。类成员在默认构造函数中如果不初始化,也是垃圾值。解决:始终初始化变量,或使用成员初始化列表。

32术语表

本笔记中出现的关键术语速查。

翻译单元(Translation Unit)
一个 .cpp 文件经过预处理(展开 #include、替换宏)后的结果,是编译器的基本编译单位。
内部链接 / 外部链接
内部链接(static)的符号仅在本翻译单元可见;外部链接的符号可被其他翻译单元引用。链接器只处理外部链接符号。
RAII
Resource Acquisition Is Initialization。将资源绑定到对象生命周期:构造时获取,析构时释放。是智能指针、文件流、锁_guard 的底层原理。
虚表(v-table)
每个含虚函数的类维护的函数指针表,用于实现运行时多态。每个对象额外存储一个虚表指针(vptr)。
override
C++11 关键字,标记派生类中重写基类虚函数的方法。帮助编译器检查签名是否匹配,防止拼写错误导致的隐蔽 bug。
explicit
禁止构造函数的隐式类型转换。要求必须显式构造对象,防止意外的隐式转换(如 PrintEntity(22) 将 int 转为 Entity)。
this 指针
非静态成员方法中指向当前对象自身的指针。用于区分同名参数、返回自身引用(链式调用)。静态方法没有 this。
深拷贝 / 浅拷贝
浅拷贝只复制指针值(两对象共享内存);深拷贝分配新内存并复制内容(两对象独立)。管理资源的类必须自定义深拷贝。
Rule of Three(三法则)
如果你需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,通常三个都需要自定义。C++11 后扩展为 Rule of Five(加入移动构造和移动赋值)。
模板特化
为特定类型提供与通用模板不同的实现。全特化(template<>)针对所有参数;偏特化针对部分参数(仅类模板支持)。
仿函数(Functor)
重载了 operator() 的类对象,可以像函数一样被调用。常用于 STL 算法的自定义比较器/谓词。
constexpr
C++11 关键字,指示函数或变量的值可在编译期求值。比 const 更强:const 只承诺不可变,constexpr 保证编译期确定。
LP64 / LLP64
64 位数据模型。LP64(Linux/macOS):long 和指针为 64 位。LLP64(Windows):long 保持 32 位,只有 long long 和指针为 64 位。这是 long 大小跨平台不一致的原因。

33移动语义P85 · P89-P90

移动语义是现代 C++ 最重要的性能优化手段之一:通过"偷取"临时对象的资源而非拷贝,避免不必要的内存分配和数据复制。

左值与右值(P85)

理解移动语义的前提是分清左值和右值。Cherno 的建议:不要纠结严格定义,知道它们是什么即可。

区分左值 (lvalue)右值 (rvalue)
示例变量、*ptrarr[i]、返回引用的函数42x+y、返回值的函数调用
可取地址
可放等号左边
绑定 T&否(const T& 可以)
绑定 T&&
int GetValue() { return 10; }       // 返回右值
int& GetLValue() { static int v = 10; return v; } // 返回左值引用

void PrintName(std::string& name) { }    // 左值引用版本
void PrintName(std::string&& name) { }   // 右值引用版本(&&)

int main() {
    int i = 10;             // i 左值,10 右值
    int j = GetValue();    // GetValue() 是右值
    GetLValue() = 5;       // 返回左值引用,可赋值

    std::string first = "Yan", last = "Chernikov";
    PrintName(first);              // 调用左值引用版本
    PrintName(first + last);       // 调用右值引用版本(临时值)
}
为什么需要右值引用?

有了 &&,就能检测临时值并对它们做特殊处理。如果知道传入的是临时对象,就不需要担心它后续是否存活,可以直接"偷"它的资源,避免拷贝。这就是移动语义的基础。

移动构造函数(P89)

移动构造函数与拷贝构造函数的区别:拷贝需要分配新内存并复制数据(O(n)),移动直接接管源对象的指针(O(1))。

拷贝构造
String(const String& other) {
    m_Size = other.m_Size;
    m_Buffer = new char[m_Size];
    memcpy(m_Buffer, other.m_Buffer,
           m_Size);
    // O(n):分配+复制
}
移动构造
String(String&& other) noexcept {
    m_Size = other.m_Size;
    m_Buffer = other.m_Buffer;  // 直接接管!
    other.m_Size = 0;
    other.m_Buffer = nullptr;
    // O(1):只改指针
}
为什么必须 other.m_Data = nullptr

移动后源对象 other 的析构函数仍然会被调用,执行 delete[] m_Data。如果不把源指针置空,会导致同一块内存被释放两次(double free)。而 delete[] nullptr 是安全的空操作。

noexcept 的重要性

std::vector 扩容时,如果元素的移动构造函数是 noexcept,就会用移动;否则会退回到拷贝(因为移动中途抛异常会导致数据丢失)。所以移动构造/赋值应尽量加 noexcept

std::move 与移动赋值(P90)

std::move 实际上并不移动任何东西,它只是把左值强制转换为右值引用(static_cast<T&&>)。真正的移动发生在移动构造/赋值函数接收这个右值时。

// 移动赋值运算符(给已存在的对象赋值)
String& operator=(String&& other) noexcept {
    if (this != &other) {       // 1. 防止自赋值
        delete[] m_Buffer;     // 2. 释放自身旧资源
        m_Size = other.m_Size;   // 3. 接管对方资源
        m_Buffer = other.m_Buffer;
        other.m_Size = 0;        // 4. 源对象置空
        other.m_Buffer = nullptr;
    }
    return *this;
}

int main() {
    String apple = "Apple";
    String dest = std::move(apple);  // 触发移动构造

    String banana = "Banana";
    String home;                     // 默认构造
    home = std::move(banana);           // 触发移动赋值
}

Rule of Five(五法则)

管理资源的类,C++11 后需要考虑五个特殊成员函数:

class Resource {
public:
    ~Resource();                              // ① 析构
    Resource(const Resource& other);          // ② 拷贝构造
    Resource& operator=(const Resource&);     // ③ 拷贝赋值
    Resource(Resource&& other) noexcept;      // ④ 移动构造
    Resource& operator=(Resource&&) noexcept; // ⑤ 移动赋值
};

常见错误

错误说明正确做法
移动后使用源对象源对象处于"有效但未指定"状态移动后不再使用,或重新赋值
return std::move(vec)破坏 RVO,反而多一次移动直接 return vec;
T&& 参数忘记 std::move右值引用参数在函数内有名字,变成左值函数体内用 std::move(vec)
移动构造缺 noexceptvector 扩容退回拷贝noexcept
练习与思考

std::move(apple) 之后 apple 还存在吗?能安全调用 apple.size() 吗?能调用 apple.clear() 吗?

34STL 常用容器P47-P48 · P100

标准模板库(STL)提供了大量现成的数据结构,实际开发中天天使用。掌握 vector、map、string 的常用操作是必备技能。

std::vector — 动态数组

头文件 <vector>。连续内存,O(1) 随机访问,尾部插入 O(1),中间插入 O(n)。

#include <vector>

// 初始化
std::vector<int> v1;                    // 空
std::vector<int> v2(5);                 // 5 个 0
std::vector<int> v3(5, 10);             // 5 个 10
std::vector<int> v4 = {1, 2, 3, 4, 5};  // 列表初始化(C++11)

// 增删访问
v1.push_back(42);         // 尾部插入
v1.pop_back();            // 删除尾部
v1[0] = 10;              // 下标访问(不检查越界)
v1.at(0) = 20;           // at 检查越界,抛 out_of_range
v1.front();               // 首元素
v1.back();                // 尾元素

// 大小与容量
v1.size();                // 当前元素个数
v1.capacity();            // 当前分配容量
v1.reserve(1000);        // 预分配,避免多次扩容(推荐!)
v1.clear();               // 清空(size=0,capacity 不变)

// 遍历
for (int x : v4) { }          // 范围 for(推荐)
for (auto& x : v4) { x *= 2; }  // 引用,可修改

// 中间插入/删除(O(n))
v4.erase(v4.begin() + 1);   // 删除索引 1
v4.insert(v4.begin() + 1, 99); // 在索引 1 插入 99
迭代器失效

扩容时所有迭代器、指针、引用全部失效erase/insert 后该位置之后的迭代器失效。如需在循环中删除元素,用 it = v.erase(it) 而非 erase(it); ++it;

std::map / std::unordered_map — 关联容器

std::map 底层是红黑树(有序,O(log n));std::unordered_map 底层是哈希表(无序,O(1) 平均)。

维度std::mapstd::unordered_map
底层结构红黑树哈希表
键是否有序有序(按 operator<无序
查找/插入O(log n)O(1) 平均,O(n) 最坏
适用场景需要有序遍历、范围查询快速单点查找
#include <map>
#include <unordered_map>
#include <string>

std::map<std::string, int> scores;

// 插入
scores["alice"] = 95;           // operator[]:不存在则插入,存在则覆盖
scores.insert({"bob", 87});    // insert:键已存在则不插入
scores.emplace("charlie", 92); // emplace:就地构造(最高效)

// 查找
auto it = scores.find("alice");
if (it != scores.end()) {
    std::cout << it->second;     // 95
}

// ⚠️ 访问不存在的键会插入默认值!
int v = scores["dave"];       // 输出 0,且 "dave" 被插入!

// 遍历(map 按键的字典序)
for (const auto& [name, score] : scores) {  // C++17 结构化绑定
    std::cout << name << ": " << score << '\n';
}

// 删除
scores.erase("dave");
operator[] vs at() vs find()

operator[]:键不存在时插入默认值(有副作用!)。at():键不存在时抛异常find():纯查询,不插入不抛异常。C++20 可用 contains() 只判断存在性。

std::string — 字符串

头文件 <string>。比 C 风格 char[] 更安全易用。

#include <string>

std::string s = "Hello";

// 长度
s.size();            // 5(与 length() 等价)
s.empty();           // false

// 增删改
s += " World";       // 追加 → "Hello World"
s.append("!!!");     // 追加
s.push_back('x');    // 追加单字符
s.insert(5, ",");     // 在位置 5 插入
s.erase(0, 2);        // 从位置 0 删除 2 个字符
s.substr(0, 5);       // 子串 "Hello"

// 查找
size_t pos = s.find("World");
if (pos != std::string::npos) { }  // 找到了

// 与 C 字符串互转
const char* c = s.c_str();   // 转 const char*

// 输入
std::getline(std::cin, s);  // 读整行(含空格,推荐)
性能提示

大字符串传参用 const std::string& 避免拷贝。频繁拼接时先 reserve() 预分配容量。c_str() 返回的指针在 string 被修改后失效。

练习与思考

为什么 scores["missing"] 会改变 map 的大小?如何在不修改 map 的情况下安全地查询一个键是否存在?

35速查表(Cheat Sheet)

将 34 章精华压缩为极简参考,方便面试前或写代码时快速扫一眼。

指针 vs 引用

特性指针 *引用 &
可为空是(nullptr否(必须初始化)
可重新绑定
使用语法需解引用 *ptr直接用 ref
函数参数可空、可改必非空、可改
推荐需要"可能不存在"时函数参数传值替代

const 位置含义(从右往左读)

写法含义
const int* p指向 const int 的指针:指针可改,指向的值不可改
int* const pconst 指针指向 int:指针不可改,指向的值可改
const int* const p都不可改
void f() constconst 方法:不修改成员变量
const T& paramconst 引用:只读,不拷贝(函数参数首选)

static 的三种用法

位置效果示例
类外 / 函数外内部链接(仅本翻译单元可见)static int x = 5;
类内所有实例共享一个副本static int count;
函数内持久生存,只初始化一次static int n = 0;

堆 vs 栈

特性栈(Stack)堆(Heap)
分配方式自动(编译器管理)手动 new / delete
速度快(移动栈指针)慢(搜索空闲块)
大小有限(通常 1-8MB)大(受物理内存限制)
生命周期作用域结束自动销毁手动释放或智能指针
推荐优先使用大对象或动态生命周期

智能指针选择

类型所有权可复制开销场景
unique_ptr独占否(可 move)默认首选
shared_ptr共享(引用计数)有(原子操作)需要共享所有权
weak_ptr不拥有极小打破循环引用、观察

拷贝 vs 移动

维度拷贝移动
内存分配分配新内存不分配,接管指针
数据复制memcpy 逐字节不复制
复杂度O(n)O(1)
源对象不变置空(有效但未指定)
触发T b = a;T b = std::move(a);

访问权限速查

类内子类类外
public
protected
private

容器选择速查

需求推荐容器
动态数组,随机访问vector
键值对,需要有序map
键值对,快速查找unordered_map
固定大小数组array(C++11)
字符串string
先进先出queue
后进先出stack
有序集合set

Rule of Five 速查

// 管理资源的类需要自定义这 5 个函数:
~Resource();                           // ① 析构:释放资源
Resource(const Resource&);            // ② 拷贝构造:深拷贝
Resource& operator=(const Resource&);   // ③ 拷贝赋值:深拷贝
Resource(Resource&&) noexcept;          // ④ 移动构造:偷资源
Resource& operator=(Resource&&) noexcept; // ⑤ 移动赋值:偷资源