从编译链接到移动语义与 STL,沿 The Cherno C++ 教程脉络,覆盖 35 个核心主题,配合代码示例、练习思考与速查表,便于复习与查阅。
理解 C++ 代码如何从源文件变成可执行程序,是学习后续所有内容的前置知识。
一个 .cpp 源文件经过预处理(展开 #include、替换宏)后,得到的单个文本就是翻译单元。编译器以翻译单元为单位进行编译,每个翻译单元独立编译为一个 .obj(目标文件)。
// 编译器的工作流程: // 1. 预处理:展开 #include、替换 #define、处理 #if/#ifdef // 2. 生成抽象语法树(AST) // 3. 生成汇编代码 // 4. 生成目标文件(.obj / .o) // 单独编译一个文件:Ctrl+F7(VS) // 编译整个项目:Build(会触发链接)
链接器将所有目标文件合并为一个可执行文件(.exe)。它的核心任务是找到每个符号(函数、变量)的定义,将调用处与定义处连接起来。
// main.cpp 中调用了 Log(),但 Log() 定义在 Log.cpp 中 // 编译时:main.cpp 只需要 Log() 的声明 // 链接时:链接器在 Log.obj 中找到 Log() 的定义并连接 // 声明(告诉编译器这个函数存在) void Log(const char* message); int main() { Log("Hello"); // 链接器负责找到 Log 的定义 }
static(内部链接)、inline,或将定义放在 .cpp 中。Debug 模式关闭优化,便于调试;Release 模式开启 Maximize Speed 优化,代码会被重排和精简,断点行为可能不同。
如果一个函数在 .h 文件中直接定义(非 inline),被两个 .cpp 包含,会发生什么错误?如何解决?
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 的大小取决于平台和编译器:Windows(含 64 位)上为 4 字节,Linux/macOS 64 位系统上为 8 字节。如需固定大小的整数,推荐使用 <cstdint> 中的 int32_t、int64_t 等。
unsigned 表示无符号(仅非负数),取值范围从 0 开始。例如 unsigned char 范围为 0 ~ 255。可加在各整数类型前。
使用 sizeof(类型) 可查看某类型占用的字节数:
// 查看类型大小 sizeof(int); // 4 sizeof(char); // 1 sizeof(double); // 8 sizeof(long); // Windows: 4, Linux/macOS: 8
unsigned int 能存储的最大值是多少?如果将 -1 赋给 unsigned int,结果是什么?
条件判断语句根据条件的真假决定执行路径。
条件为真时执行代码块。
if (条件) { // 条件为真时执行 }
if (条件) { // ① 条件为真时执行 } else { // ② 条件为假时执行 }
条件表达式的简写形式:条件 ? 值A : 值B。常用于取最大值等场景:
int max = (a > b) ? a : b;
多分支选择结构,根据变量值跳转到对应 case。每个 case 后需加 break 防止贯穿。
switch (变量) { case 1: // 执行操作 1 break; case 2: // 执行操作 2 break; default: // 默认操作 break; }
如果忘记在 case 后写 break,会发生什么?这种现象叫什么?
三种循环方式,适用于不同的重复执行场景。
for(初始化; 条件; 步进)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 的核心区别。
用于在循环或函数中改变执行流程。
在一个嵌套循环中,break 能跳出几层循环?如果需要跳出多层,有什么替代方案?
指针是存储变量内存地址的特殊变量,是 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 的类型是什么?
引用是已有变量的别名,本质上是一个"扩展的指针",但使用更简洁。
int a = 5; int& ref = a; // ref 是 a 的引用(别名) ref = 3; // 通过 ref 修改,a 也变为 3
引用必须在声明时初始化,且不能重新绑定到其他变量;指针可以改变指向。引用使用时无需解引用,语法更直观,常用于函数参数传递。
为什么函数参数推荐用 const std::string& 而不是 std::string?从性能角度解释。
使用 new 在堆上动态分配内存,使用完毕后必须用 delete 释放,避免内存泄漏。
// 动态分配字符数组 char* buffer = new char[8]; // 使用完毕后释放 delete[] buffer;
数组分配用 new[],释放时必须用 delete[](带方括号);单个对象用 new 和 delete。两者不可混用。new 实际做了两件事:①调用 malloc 分配内存 ②调用构造函数。
如果用 new[] 分配但用 delete(不带 [])释放,会发生什么?为什么?
函数是将代码封装为可复用模块的基本单元。
返回类型 函数名(参数列表) { // 函数体 return 返回值; }
示例:
int add(int a, int b) { return a + b; } void sayHello() { // void 表示无返回值 // 执行操作,不需要 return }
():参数列表,可为空{}:函数体,包含具体代码void:表示函数无返回值函数声明和函数定义的区别是什么?为什么头文件中放声明而不是定义?
类和结构体都是将变量(数据)和函数(方法)组合在一起的复合类型,区别在于默认访问权限。
默认访问权限为 private(私有)。
class 类名 { // 默认 private int data; public: void method(); };
默认访问权限为 public(公有),
为了兼容 C 语言。
struct 结构体名 { // 默认 public int data; void method(); };
什么时候用 struct,什么时候用 class?(提示:Cherno 的建议是纯数据用 struct,有逻辑用 class。)
四个重要的类型与作用域相关关键字(后续章节有更深入的展开)。
const int a = 5;// const 常量 const int a = 5; // a 不可被修改 // enum 枚举 enum Color { RED, // 0 GREEN, // 1 BLUE // 2 };
现代 C++(C++11 起)推荐使用 enum class,它提供更强的类型安全,不会隐式转换为整数。
enum class 和普通 enum 的区别是什么?为什么说前者更安全?
static 在不同上下文有不同含义:翻译单元内的内部链接、类级别的共享成员、函数内的持久局部变量。
类或结构体之外的 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 }
当你不需要变量是全局变量时,就尽可能使用 static。这样可以避免跨文件链接冲突,也让意图更清晰。
类或结构体内部的 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 }
类名::方法名() 直接调用this 指针)局部 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 变量存储在内存的哪个区域(栈/堆/静态存储区)?它的构造和析构时机是什么?
构造函数在创建对象时自动调用,用于初始化成员变量,确保对象创建时即处于有效状态。
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 e; 会报错如果你只定义了 Entity(int x),以下哪些写法会报错:Entity e;、Entity e(5);、Entity e = 5;?
析构函数在对象销毁时自动调用,用于清理内存和释放资源。以 ~ 标识,函数名与类名一致。
class Entity { public: Entity() { std::cout << "Created Entity!" << std::endl; } ~Entity() { // 析构函数 std::cout << "Destroyed Entity!" << std::endl; } };
delete 时调用析构new 分配了内存,必须在析构函数中 delete基类的析构函数应声明为 virtual,否则通过基类指针删除派生类对象时,派生类的析构函数不会被调用,导致内存泄漏。
如果一个类没有手动 new 任何内存,还需要写析构函数吗?编译器生成的默认析构会做什么?
继承允许子类从父类获得属性和方法,是减少代码重复的核心机制。
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) 的关系是什么?
虚函数允许子类重写父类方法,实现运行时多态(动态绑定)。这是面向对象编程的核心机制。
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 关键字不是必须的,那为什么推荐写?如果不写,什么错误会悄悄通过编译?
纯虚函数是没有实现的虚函数,包含它的类成为抽象类(接口),不能被实例化。派生类必须实现所有纯虚函数。
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++ 中的"接口"就是只包含纯虚函数的类。它定义了一个合约,所有子类必须遵守。接口不能实例化,但可以用接口指针/引用指向实现类对象。
一个类实现了部分纯虚函数但不是全部,这个类能被实例化吗?它还是抽象类吗?
可见性控制类成员谁能访问: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; // 错误! }
可见性是编译时约束,不影响运行时性能。它主要是一种设计工具,实际上可以通过指针技巧绕过。类的默认可见性是 private,结构体的默认可见性是 public。
protected 看似方便子类访问,但 Cherno 认为它有时不如 private。思考:为什么暴露 protected 成员可能破坏封装?
数组是连续内存中存储的一组相同类型元素。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++ 数组传参时会"退化"为指针?
C++ 有两种字符串:C 风格 const char*(以 \0 结尾)和 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);
字符串字面量存储在只读内存区域,不可修改。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 的类型分别是什么?为什么后者在拼接时更方便?
const 承诺不会改变,mutable 允许在 const 方法中例外地修改特定成员。
const int* a = new int; // 指向的内容不能改,指针可以改 int* const b = new int; // 指针不能改,指向的内容可以改 const int* const c = new int; // 都不能改
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 允许在 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 方法中是合理的?
在构造函数中用初始化列表直接初始化成员,比在函数体中赋值更高效。
Entity() : m_Name("Unknown"), m_Score(0) { // 直接初始化 }
Entity() { m_Name = "Unknown"; m_Score = 0; // 先默认构造,再赋值 }
成员变量按声明顺序初始化(不是初始化列表中的顺序)。如果成员没有默认构造函数(如 const 成员或引用成员),必须使用初始化列表。
如果初始化列表中的顺序和成员声明顺序不一致,编译器会怎么做?这可能导致什么隐蔽的 bug?
单参数构造函数可被编译器用于隐式类型转换。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。它防止意外的隐式转换,是一种防御性编程手段。
Entity a = 22; 和 Entity a(22); 有什么区别?加 explicit 后哪个会报错?
运算符重载允许为自定义类型定义 +、==、[]、()、<< 等运算符的行为。运算符本质上是函数。
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; }
自定义容器类经常需要重载 [],使其像数组一样通过索引访问元素。
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[] 读取 }
重载 () 使对象像函数一样可调用,这种对象称为仿函数(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?如果返回值会发生什么?
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 用于区分同名成员变量和参数*this 实现链式调用this 指针(这也是静态方法不能访问非静态成员的原因)静态方法中没有 this 指针。思考:如果静态方法想访问某个实例的成员,该怎么做?
栈上对象的生命周期由作用域决定:进入作用域创建,离开作用域自动销毁。这是 RAII 模式的基础。
class Entity { public: Entity() { std::cout << "Created" << std::endl; } ~Entity() { std::cout << "Destroyed" << std::endl; } }; int main() { { Entity e; // 输出 "Created" } // 离开作用域,自动析构,输出 "Destroyed" }
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,无需手动释放 }
Resource Acquisition Is Initialization:将资源绑定到对象生命周期。栈对象离开作用域时自动析构,保证资源释放。这是 C++ 最强大的特性之一,也是智能指针的底层原理。
为什么 RAII 被称为 C++ 区别于其他语言的核心特性?Java/C# 的 GC 能完全替代 RAII 吗?
智能指针是自动管理堆内存的类,使用 RAII 模式。需要 #include <memory>。三种主要类型各有不同所有权语义。
#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 失效 }
尽可能使用 unique_ptr,只有在需要共享所有权时才用 shared_ptr。优先使用 make_unique 和 make_shared 而非直接 new(更安全、更高效)。
两个 shared_ptr 互相持有对方(循环引用),会发生什么?如何用 weak_ptr 解决?
拷贝构造函数在创建新对象时调用,拷贝赋值运算符在已有对象之间赋值时调用。默认执行浅拷贝,如果类中有指针成员需要自定义深拷贝。
只复制指针值,两个对象指向同一块内存。析构时 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]; } };
| 操作 | 调用场景 | 示例 |
|---|---|---|
| 拷贝构造 | 用已有对象创建新对象 | String b = a; |
| 拷贝赋值 | 两个已存在对象之间赋值 | b = a;(b 已存在) |
拷贝不仅在 String b = a; 时发生,也在按值传递和按值返回时发生。尽量用 const Type& 传递对象避免不必要的拷贝。
如果你需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,通常三个都需要自定义。因为类中 likely 管理了资源(如动态内存)。
String b = a; 和 String b; b = a; 分别调用哪个函数?如果只定义了拷贝构造没定义赋值运算符,后者会发生什么?
模板是编译器的"蓝图",根据使用方式自动生成代码。模板本身不是真实代码,只有被实例化(使用)时才生成实际代码。
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 }
有时通用模板对某些特定类型不适用,需要为该类型提供特殊实现,这称为模板特化。
// 通用模板 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)则为部分模板参数提供特殊实现(仅类模板支持偏特化,函数模板不支持)。
不要过度痴迷模板。过于复杂的模板会让代码难以理解和维护。模板是强大的工具,但要适度使用。
为什么函数模板不支持偏特化?如果需要对 vector<T> 做特殊处理,应该用什么方式替代偏特化?(提示:函数重载)
C++11/14/17 引入的一系列特性,让代码更简洁、更安全、更高效。
NULL 和 0。消除了整数与指针的歧义。for (int v : vec)。配合 auto& 避免拷贝。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]; // 可用作数组大小
auto 在类型名很长时(如迭代器)非常有用,但在类型不明显的地方(如 auto result = SomeFunction();)应显式写类型,让代码更易读。
const 和 constexpr 的区别是什么?const int x = 5; 中的 x 一定在编译期确定吗?
C++ 学习过程中最容易踩的坑,每个开发者都应该知道。
类中有指针成员时,默认拷贝只复制指针值。两个对象析构时会对同一块内存 delete 两次,导致崩溃。解决:自定义拷贝构造函数和拷贝赋值运算符实现深拷贝,或使用 std::string/std::vector 代替裸指针。
数组传给函数后退化为指针,sizeof 不再返回数组大小。在 64 位系统上 sizeof(指针) 返回 8,不是数组总字节。解决:额外传递数组大小参数,或使用 std::array。
基类析构不是 virtual 时,delete 基类指针 不会调用子类析构,导致子类资源泄漏。解决:只要类可能被继承,析构函数就声明为 virtual。
用 new[] 分配的数组必须用 delete[] 释放。混用会导致未定义行为(只析构第一个元素或内存损坏)。解决:养成配对习惯,或直接用智能指针 / std::vector。
函数返回局部栈变量的引用或指针,变量在函数结束后已销毁,引用/指针悬空。解决:返回值类型,或返回堆上对象(配合智能指针),或返回传入的引用。
unsigned int 减到负数会变成超大正数。for (unsigned i = 10; i >= 0; i--) 是死循环。解决:循环变量用 int,或改用 i > 0 后单独处理 i == 0。
两个 shared_ptr 互相持有对方,引用计数永远不归零,内存永远不会释放。解决:其中一方改用 weak_ptr 打破循环。
局部变量不初始化时是垃圾值。类成员在默认构造函数中如果不初始化,也是垃圾值。解决:始终初始化变量,或使用成员初始化列表。
本笔记中出现的关键术语速查。
PrintEntity(22) 将 int 转为 Entity)。template<>)针对所有参数;偏特化针对部分参数(仅类模板支持)。operator() 的类对象,可以像函数一样被调用。常用于 STL 算法的自定义比较器/谓词。long 大小跨平台不一致的原因。移动语义是现代 C++ 最重要的性能优化手段之一:通过"偷取"临时对象的资源而非拷贝,避免不必要的内存分配和数据复制。
理解移动语义的前提是分清左值和右值。Cherno 的建议:不要纠结严格定义,知道它们是什么即可。
| 区分 | 左值 (lvalue) | 右值 (rvalue) |
|---|---|---|
| 示例 | 变量、*ptr、arr[i]、返回引用的函数 | 42、x+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); // 调用右值引用版本(临时值) }
有了 &&,就能检测临时值并对它们做特殊处理。如果知道传入的是临时对象,就不需要担心它后续是否存活,可以直接"偷"它的资源,避免拷贝。这就是移动语义的基础。
移动构造函数与拷贝构造函数的区别:拷贝需要分配新内存并复制数据(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 是安全的空操作。
std::vector 扩容时,如果元素的移动构造函数是 noexcept,就会用移动;否则会退回到拷贝(因为移动中途抛异常会导致数据丢失)。所以移动构造/赋值应尽量加 noexcept。
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); // 触发移动赋值 }
管理资源的类,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) |
移动构造缺 noexcept | vector 扩容退回拷贝 | 加 noexcept |
std::move(apple) 之后 apple 还存在吗?能安全调用 apple.size() 吗?能调用 apple.clear() 吗?
标准模板库(STL)提供了大量现成的数据结构,实际开发中天天使用。掌握 vector、map、string 的常用操作是必备技能。
头文件 <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 底层是红黑树(有序,O(log n));std::unordered_map 底层是哈希表(无序,O(1) 平均)。
| 维度 | std::map | std::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[]:键不存在时插入默认值(有副作用!)。at():键不存在时抛异常。find():纯查询,不插入不抛异常。C++20 可用 contains() 只判断存在性。
头文件 <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 的情况下安全地查询一个键是否存在?
将 34 章精华压缩为极简参考,方便面试前或写代码时快速扫一眼。
| 特性 | 指针 * | 引用 & |
|---|---|---|
| 可为空 | 是(nullptr) | 否(必须初始化) |
| 可重新绑定 | 是 | 否 |
| 使用语法 | 需解引用 *ptr | 直接用 ref |
| 函数参数 | 可空、可改 | 必非空、可改 |
| 推荐 | 需要"可能不存在"时 | 函数参数传值替代 |
| 写法 | 含义 |
|---|---|
const int* p | 指向 const int 的指针:指针可改,指向的值不可改 |
int* const p | const 指针指向 int:指针不可改,指向的值可改 |
const int* const p | 都不可改 |
void f() const | const 方法:不修改成员变量 |
const T& param | const 引用:只读,不拷贝(函数参数首选) |
| 位置 | 效果 | 示例 |
|---|---|---|
| 类外 / 函数外 | 内部链接(仅本翻译单元可见) | static int x = 5; |
| 类内 | 所有实例共享一个副本 | static int count; |
| 函数内 | 持久生存,只初始化一次 | static int n = 0; |
| 特性 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 分配方式 | 自动(编译器管理) | 手动 new / delete |
| 速度 | 快(移动栈指针) | 慢(搜索空闲块) |
| 大小 | 有限(通常 1-8MB) | 大(受物理内存限制) |
| 生命周期 | 作用域结束自动销毁 | 手动释放或智能指针 |
| 推荐 | 优先使用 | 大对象或动态生命周期 |
| 类型 | 所有权 | 可复制 | 开销 | 场景 |
|---|---|---|---|---|
unique_ptr | 独占 | 否(可 move) | 零 | 默认首选 |
shared_ptr | 共享(引用计数) | 是 | 有(原子操作) | 需要共享所有权 |
weak_ptr | 不拥有 | 是 | 极小 | 打破循环引用、观察 |
| 维度 | 拷贝 | 移动 |
|---|---|---|
| 内存分配 | 分配新内存 | 不分配,接管指针 |
| 数据复制 | 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 |
// 管理资源的类需要自定义这 5 个函数: ~Resource(); // ① 析构:释放资源 Resource(const Resource&); // ② 拷贝构造:深拷贝 Resource& operator=(const Resource&); // ③ 拷贝赋值:深拷贝 Resource(Resource&&) noexcept; // ④ 移动构造:偷资源 Resource& operator=(Resource&&) noexcept; // ⑤ 移动赋值:偷资源