Appearance
C++ 必背
指针和引用
指针和引用是什么?
指针是一个变量,它里面存的是另一个对象的地址。 比如 int* p = &a;,意思是:p 保存了变量 a 的地址。
引用是一个对象的别名。 比如 int& r = a;,意思是:r 就是 a 的另一个名字。
一句话记:
指针是“拿着地址的变量”;引用是“对象的别名”。
核心区别
指针可以为空:
c
int* p = nullptr; // 定义一个空指针,表示当前不指向任何对象引用必须初始化:
c
int a = 10; // 定义一个整数变量 a
int& r = a; // 定义引用 r,并让 r 绑定到 a指针可以改指向:
c
int a = 10; // 定义变量 a
int b = 20; // 定义变量 b
int* p = &a; // 指针 p 一开始指向 a
p = &b; // 指针 p 后来改为指向 b引用不能改绑:
c
int a = 10; // 定义变量 a
int b = 20; // 定义变量 b
int& r = a; // 引用 r 绑定到 a
r = b; // 这里不是让 r 改绑到 b,而是把 b 的值赋给 a使用语法区别
指针访问对象需要解引用:
c
int a = 10; // 定义变量 a
int* p = &a; // 定义指针 p,并保存 a 的地址
*p = 30; // 通过指针 p 修改 a 的值引用访问对象就像普通变量:
c
int a = 10; // 定义变量 a
int& r = a; // 定义引用 r,r 是 a 的别名
r = 30; // 通过引用 r 修改 a 的值函数传参怎么选?
如果参数一定有对象,用引用:
c
void AddHp(Player& player) // 用引用表示 player 必须是一个有效对象
{ // 函数开始
player.hp += 10; // 直接修改传入玩家对象的血量
} // 函数结束如果参数可能为空,用指针:
c
void AddHp(Player* player) // 用指针表示 player 可能为空
{ // 函数开始
if (player == nullptr) // 判断指针是否没有指向对象
{ // if 开始
return; // 如果为空,直接返回
} // if 结束
player->hp += 10; // 通过指针访问玩家对象并增加血量
} // 函数结束面试高分回答
NOTE
指针是一个保存地址的变量,可以为空,也可以重新指向其他对象,访问目标对象时需要解引用。引用是已有对象的别名,定义时必须初始化,之后不能重新绑定,使用起来就像原对象本身。函数传参时,如果参数一定存在,我更倾向用引用,语义更清晰;如果参数可能为空,或者需要表达“可选对象”,就用指针。底层实现上,引用很多时候也可能通过地址实现,但 C++ 语义上它不是普通指针,重点区别是是否可空、是否能改指向、使用语法和表达意图不同。
栈和堆
栈和堆是什么?
栈 Stack:主要用于函数调用、局部变量、函数参数、返回地址。 它由系统自动管理,函数进入时分配栈帧,函数退出时自动释放。
堆 Heap:主要用于运行时动态申请的对象。 在 C++ 里通常通过 new 申请,通过 delete 释放,现代 C++ 更推荐用智能指针和 RAII 管理。
核心区别
栈的生命周期由作用域控制:
c
void Func() // 定义一个函数
{ // 函数开始
int a = 10; // a 是局部变量,通常在栈上分配
} // 函数结束后,a 自动销毁堆的生命周期由程序控制:
c
int* p = new int(10); // 在堆上申请一个 int 对象,p 保存它的地址
delete p; // 手动释放堆上的 int 对象
p = nullptr; // 把指针置空,避免悬空指针栈通常更快,因为它像“压栈 / 出栈”,移动栈指针就行。 堆通常更慢,因为它要从内存管理器里找合适空间,还可能产生碎片。
常见区别表
栈:自动分配,自动释放,速度快,空间较小,适合局部小对象。 堆:手动或 RAII 管理,生命周期灵活,空间较大,但可能泄漏和碎片化。
完整代码示例
c
#include <iostream> // 引入标准输入输出库
#include <memory> // 引入智能指针库
void DemoStack() // 定义一个演示栈内存的函数
{ // 函数开始
int a = 10; // 定义局部变量 a,通常存放在当前函数的栈帧中
int array[3] = {1, 2, 3}; // 定义局部数组,数组元素通常也在栈上
std::cout << a << std::endl; // 输出栈上变量 a 的值
std::cout << array[0] << std::endl; // 输出栈上数组的第一个元素
} // 函数结束,a 和 array 自动销毁
void DemoHeapRawPointer() // 定义一个演示堆内存和裸指针的函数
{ // 函数开始
int* p = new int(20); // 在堆上动态申请一个 int,p 这个指针变量本身通常在栈上
std::cout << *p << std::endl; // 通过解引用指针访问堆上的 int 值
delete p; // 手动释放堆上的 int 对象
p = nullptr; // 把指针置空,避免继续指向已经释放的内存
} // 函数结束,p 这个局部指针变量自动销毁
void DemoHeapRAII() // 定义一个演示 RAII 管理堆内存的函数
{ // 函数开始
std::unique_ptr<int> p = std::make_unique<int>(30); // 创建智能指针管理堆上的 int 对象
std::cout << *p << std::endl; // 通过智能指针访问堆上的 int 值
} // 函数结束,unique_ptr 自动释放堆上的 int 对象
int main() // 程序入口函数
{ // main 函数开始
DemoStack(); // 调用栈内存示例函数
DemoHeapRawPointer(); // 调用裸指针堆内存示例函数
DemoHeapRAII(); // 调用 RAII 堆内存示例函数
return 0; // 返回 0 表示程序正常结束
} // main 函数结束常见坑
栈的坑:递归太深会栈溢出,大数组放栈上也可能爆。 堆的坑:new 后忘记 delete 会内存泄漏,delete 两次会崩,释放后继续访问会变成悬空指针。
面试高分回答
TIP
栈是函数调用时自动管理的内存区域,局部变量、参数、返回地址通常在栈帧里,进入作用域分配,离开作用域自动释放,所以速度快但空间有限。堆是运行时动态分配的内存区域,生命周期更灵活,适合大对象或需要跨作用域存在的对象,但申请释放成本更高,可能产生碎片,也容易出现内存泄漏、重复释放和悬空指针。现代 C++ 里不推荐到处裸 new/delete,而是用 RAII、unique_ptr、shared_ptr 让对象生命周期自动管理。
new/delete 和 malloc/free
new/delete 和 malloc/free 是什么?
new/delete 是 C++ 运算符。 它们不仅管理内存,还会处理对象生命周期。
malloc/free 是 C 标准库函数。 它们只负责申请和释放一块原始内存,不懂 C++ 构造函数和析构函数。
最核心一句话:
new/delete 管对象;malloc/free 管裸内存。
new 做了什么?
new 通常做两件事:
先申请足够的内存。 再调用对象的构造函数。
c
Player* p = new Player(); // 申请 Player 对象内存,并调用 Player 构造函数delete 做了什么?
delete 也通常做两件事:
先调用析构函数。 再释放对象占用的内存。
c
delete p; // 调用 Player 析构函数,并释放对象内存
p = nullptr; // 把指针置空,避免悬空指针malloc 做了什么?
malloc 只申请一块指定大小的内存。 它不知道你要放什么对象,也不会调用构造函数。
c
Player* p = (Player*)malloc(sizeof(Player)); // 只申请一块能放 Player 的原始内存这时 p 指向的只是“能放 Player 的空间”,不代表真正构造好了一个 Player 对象。
free 做了什么?
free 只释放 malloc 申请的内存。 它不会调用析构函数。
c
free(p); // 只释放原始内存,不会调用 Player 析构函数
p = nullptr; // 把指针置空,避免悬空指针完整代码对比
c
#include <cstdlib> // 引入 malloc 和 free 所在的头文件
#include <iostream> // 引入标准输入输出流头文件
class Player // 定义一个 Player 类
{ // 类开始
public: // 公开访问区域开始
Player() // 定义构造函数
{ // 构造函数开始
std::cout << "Player 构造" << std::endl; // 输出构造函数被调用的信息
} // 构造函数结束
~Player() // 定义析构函数
{ // 析构函数开始
std::cout << "Player 析构" << std::endl; // 输出析构函数被调用的信息
} // 析构函数结束
}; // 类结束
int main() // 程序入口函数
{ // main 函数开始
Player* p1 = new Player(); // 使用 new 创建对象,会申请内存并调用构造函数
delete p1; // 使用 delete 销毁对象,会调用析构函数并释放内存
p1 = nullptr; // 把 p1 置空,避免悬空指针
Player* p2 = (Player*)std::malloc(sizeof(Player)); // 使用 malloc 只申请一块原始内存,不会调用构造函数
std::free(p2); // 使用 free 只释放原始内存,不会调用析构函数
p2 = nullptr; // 把 p2 置空,避免悬空指针
return 0; // 返回 0 表示程序正常结束
} // main 函数结束为什么不能混用?
不能这样:
c
Player* p = new Player(); // 用 new 创建对象
free(p); // 错误:new 出来的对象不能用 free 释放也不能这样:
c
Player* p = (Player*)malloc(sizeof(Player)); // 用 malloc 申请原始内存
delete p; // 错误:malloc 申请的内存不能用 delete 释放原因是:
new 要配 delete。 new[] 要配 delete[]。 malloc 要配 free。
混用会导致未定义行为,可能崩溃,也可能看起来暂时没事但埋下大坑。
其他区别
new 返回具体类型指针,不需要强转。 malloc 返回 void*,C++ 中通常需要强转。
new 失败默认抛出 std::bad_alloc。 malloc 失败返回 NULL 或 nullptr。
new/delete 可以被重载。 malloc/free 是库函数,不能按类重载成对象语义。
面试高分回答
IMPORTANT
new/delete 是 C++ 的对象创建和销毁机制,new 会先分配内存再调用构造函数,delete 会先调用析构函数再释放内存;而 malloc/free 是 C 的内存管理函数,只申请和释放原始字节内存,不会调用构造和析构。它们不能混用,new 必须配 delete,new[] 配 delete[],malloc 配 free。在 C++ 里如果是对象,优先用 new/delete,现代 C++ 更推荐用 RAII 和智能指针;malloc/free 更适合 C 风格接口、底层内存池或只处理原始内存的场景。
构造、析构、拷贝构造
构造函数是什么?
构造函数是在对象“出生”时自动调用的函数,用来初始化对象。
比如:
c
Player p;这行代码创建对象时,C++ 会自动调用 Player() 构造函数。
构造函数的特点:
- 函数名和类名相同。
- 没有返回值,连
void都不能写。 - 可以重载,也就是可以有多个构造函数。
- 常用于初始化成员变量、申请资源、打开文件、建立连接等。
析构函数是什么?
析构函数是在对象“死亡”前自动调用的函数,用来释放资源。
比如对象离开作用域:
c
{
Player p;
}出了 {} 以后,p 生命周期结束,C++ 会自动调用 ~Player()。
析构函数的特点:
- 名字是
~类名()。 - 没有返回值。
- 不能重载,一个类只能有一个析构函数。
- 常用于释放堆内存、关闭文件、释放句柄等。
拷贝构造函数是什么?
拷贝构造函数是“用一个已有对象创建一个新对象”时调用的构造函数。
典型写法:
c
Player(const Player& other);什么时候会调用?
Player b = a;Player b(a);- 函数参数按值传递对象。
- 函数按值返回对象时,某些情况下可能触发。
完整代码示例
c
#include <iostream> // 引入输入输出库,用来打印日志
#include <cstring> // 引入字符串内存操作库,用来复制内存
class Buffer // 定义一个 Buffer 类,用来演示资源管理
{ // 类定义开始
private: // 私有成员区域开始
char* data; // 指向堆内存的指针
int size; // 记录堆内存大小
public: // 公有成员区域开始
Buffer(int n) : data(new char[n]), size(n) // 构造函数:对象创建时申请堆内存
{ // 构造函数函数体开始
std::memset(data, 0, size); // 把申请到的内存全部初始化为 0
std::cout << "构造函数:申请资源" << std::endl; // 打印构造函数调用信息
} // 构造函数函数体结束
Buffer(const Buffer& other) : data(new char[other.size]), size(other.size) // 拷贝构造函数:用已有对象创建新对象
{ // 拷贝构造函数函数体开始
std::memcpy(data, other.data, size); // 深拷贝:复制内容,而不是只复制指针地址
std::cout << "拷贝构造:复制资源" << std::endl; // 打印拷贝构造调用信息
} // 拷贝构造函数函数体结束
~Buffer() // 析构函数:对象销毁前自动调用
{ // 析构函数函数体开始
delete[] data; // 释放构造函数中申请的数组内存
data = nullptr; // 把指针置空,避免悬空指针
std::cout << "析构函数:释放资源" << std::endl; // 打印析构函数调用信息
} // 析构函数函数体结束
}; // 类定义结束
int main() // 程序入口函数
{ // main 函数开始
Buffer a(10); // 创建对象 a,调用构造函数
Buffer b = a; // 用 a 创建 b,调用拷贝构造函数
return 0; // main 结束,对象 b 和 a 会自动析构
} // main 函数结束最容易错的点
如果拷贝构造只是简单复制指针地址,就是浅拷贝。这样两个对象会指向同一块堆内存,析构时会释放两次,可能导致崩溃。
面试高分回答可以这样说:
WARNING
构造函数负责对象初始化,析构函数负责对象销毁前释放资源,拷贝构造函数负责用已有对象创建新对象。只要类里管理了堆内存、文件句柄、Socket 等资源,就不能随便使用默认拷贝构造,否则容易浅拷贝,导致重复释放。C++ 里这和 RAII、深拷贝、Rule of Three 都有关。
深拷贝和浅拷贝
一句话理解
浅拷贝:只复制对象里的成员值,如果成员是指针,就只复制“地址”。
深拷贝:不仅复制指针成员,还会重新申请一块内存,把指针指向的数据也复制过去。
为什么浅拷贝危险?
如果两个对象的指针指向同一块堆内存,那么一个对象修改数据,另一个对象也会受影响。更严重的是,两个对象析构时都会 delete 同一块内存,可能造成重复释放、程序崩溃、悬空指针。
什么时候默认是浅拷贝?
C++ 编译器默认生成的拷贝构造函数和赋值运算符,都是“成员逐个复制”。普通 int、float 没问题,但如果类里有 char*、数组指针、文件句柄、Socket 等资源,就要自己写深拷贝逻辑。
代码示例:深拷贝写法
c
#include <iostream> // 引入输入输出库,用来打印信息
#include <cstring> // 引入内存复制函数 memcpy
class Buffer // 定义一个 Buffer 类,模拟管理堆内存的对象
{ // 类体开始
private: // 私有成员开始
char* data; // 指针成员,指向堆内存
int size; // 记录堆内存大小
public: // 公有成员开始
Buffer(int n) : data(new char[n]), size(n) // 构造函数,申请一块堆内存
{ // 构造函数体开始
std::memset(data, 0, size); // 把堆内存初始化为 0
std::cout << "构造函数" << std::endl; // 打印构造信息
} // 构造函数体结束
Buffer(const Buffer& other) : data(new char[other.size]), size(other.size) // 拷贝构造,重新申请内存
{ // 拷贝构造函数体开始
std::memcpy(data, other.data, size); // 把 other 的数据复制到新内存里
std::cout << "深拷贝构造" << std::endl; // 打印拷贝构造信息
} // 拷贝构造函数体结束
Buffer& operator=(const Buffer& other) // 赋值运算符,处理 a = b 这种情况
{ // 赋值运算符函数体开始
if (this == &other) // 判断是否自己给自己赋值
{ // 自赋值判断代码块开始
return *this; // 如果是自赋值,直接返回自己
} // 自赋值判断代码块结束
char* newData = new char[other.size]; // 先申请一块新的堆内存
std::memcpy(newData, other.data, other.size); // 先把对方的数据复制过来
delete[] data; // 再释放自己原来的旧内存
data = newData; // 让自己的指针指向新内存
size = other.size; // 更新自己的内存大小
return *this; // 返回当前对象,支持连续赋值
} // 赋值运算符函数体结束
~Buffer() // 析构函数,对象销毁时自动调用
{ // 析构函数体开始
delete[] data; // 释放自己拥有的堆内存
data = nullptr; // 指针置空,避免悬空指针
std::cout << "析构函数" << std::endl; // 打印析构信息
} // 析构函数体结束
}; // 类定义结束
int main() // 程序入口函数
{ // main 函数开始
Buffer a(10); // 创建对象 a,调用构造函数
Buffer b = a; // 用 a 创建 b,调用拷贝构造函数
Buffer c(5); // 创建对象 c,调用构造函数
c = a; // 已有对象 c 被赋值,调用赋值运算符
return 0; // 程序结束,局部对象自动析构
} // main 函数结束面试高分回答
CAUTION
浅拷贝是成员级复制,指针只复制地址;深拷贝会重新申请资源并复制资源内容。普通值类型浅拷贝通常没问题,但类里如果管理堆内存或系统资源,就必须考虑深拷贝,否则容易出现多个对象共享同一资源,最终导致重复释放或数据互相影响。这也是 C++ 里 Rule of Three 的原因:写了析构函数,通常也要考虑拷贝构造和赋值运算符。
虚函数和虚表
一句话理解
虚函数是 C++ 实现运行时多态的机制。 虚表是保存虚函数地址的表。 对象内部通常会有一个隐藏指针 vptr,指向它真实类型对应的虚表。
虚函数是什么?
如果父类函数加了 virtual,子类可以重写它。 当你用父类指针或父类引用调用这个函数时,C++ 不只看指针类型,还会看对象真实类型。
比如:
c
Base* p = new Derived();
p->Speak();虽然 p 的类型是 Base*,但它实际指向 Derived 对象,所以会调用 Derived::Speak()。
虚表是什么?
虚表可以理解成一张“函数地址表”。
如果类里有虚函数,主流编译器通常会为这个类生成一张虚表:
c
Derived_vtable:
slot 0 -> Derived::Speak
slot 1 -> Derived::Move
slot 2 -> Derived::~Derived对象里通常会多一个隐藏成员 vptr,它指向当前真实类型的虚表。
代码示例
c
#include <iostream> // 引入输入输出库,用来打印函数调用结果
class Base // 定义父类 Base
{ // 父类类体开始
public: // 公有成员开始
virtual void Speak() // 定义虚函数,允许子类重写
{ // Base::Speak 函数体开始
std::cout << "Base Speak" << std::endl; // 打印父类版本的 Speak
} // Base::Speak 函数体结束
void Normal() // 定义普通函数,不走虚函数动态绑定
{ // Normal 函数体开始
std::cout << "Base Normal" << std::endl; // 打印父类普通函数
} // Normal 函数体结束
virtual ~Base() // 定义虚析构函数,保证父类指针删除子类对象安全
{ // Base 析构函数体开始
std::cout << "Base Destructor" << std::endl; // 打印父类析构信息
} // Base 析构函数体结束
}; // 父类定义结束
class Derived : public Base // 定义子类 Derived,继承 Base
{ // 子类类体开始
public: // 公有成员开始
void Speak() override // 重写父类虚函数,override 用来检查签名是否正确
{ // Derived::Speak 函数体开始
std::cout << "Derived Speak" << std::endl; // 打印子类版本的 Speak
} // Derived::Speak 函数体结束
~Derived() override // 重写虚析构函数,释放子类资源时会被正确调用
{ // Derived 析构函数体开始
std::cout << "Derived Destructor" << std::endl; // 打印子类析构信息
} // Derived 析构函数体结束
}; // 子类定义结束
int main() // 程序入口函数
{ // main 函数体开始
Base* p = new Derived(); // 父类指针指向子类对象
p->Speak(); // 调用虚函数,运行时调用 Derived::Speak
p->Normal(); // 调用普通函数,编译期绑定到 Base::Normal
delete p; // 通过父类指针删除对象,因为析构是 virtual,所以先析构 Derived 再析构 Base
return 0; // 程序正常结束
} // main 函数体结束输出大概是
c
Derived Speak
Base Normal
Derived Destructor
Base Destructor面试高分回答
NOTE
虚函数用于实现运行时多态。父类中声明 virtual 后,子类可以 override。当通过父类指针或引用调用虚函数时,程序会根据对象真实类型,通过对象中的 vptr 找到对应的 vtable,再从虚表中取出函数地址进行调用。普通函数是静态绑定,虚函数是动态绑定。
补一句会很加分:C++ 标准没有强制规定一定叫虚表和虚指针,但主流编译器基本都用类似 vptr + vtable 的方式实现。虚函数有一点额外开销:对象可能多一个指针,调用时多一次间接寻址。基类如果要被多态删除,析构函数通常必须声明为 virtual。
虚析构
一句话理解
虚析构就是把基类析构函数声明成 virtual,保证通过父类指针删除子类对象时,能先调用子类析构,再调用父类析构。
为什么需要虚析构?
看这个场景:
c
Base* p = new Derived();
delete p;p 的类型是 Base*,但真实对象是 Derived。
如果 Base 的析构函数不是虚函数,那么 delete p 时可能只调用 Base::~Base(),导致 Derived::~Derived() 没有执行。 如果子类里申请了堆内存、打开了文件、持有纹理、Socket、句柄等资源,这些资源就可能泄漏。
正确代码示例
c
#include <iostream> // 引入输入输出库,用来打印析构顺序
class Base // 定义父类 Base
{ // 父类类体开始
public: // 公有成员开始
virtual void Speak() // 定义虚函数,让 Base 成为多态基类
{ // Speak 函数体开始
std::cout << "Base Speak" << std::endl; // 打印父类函数信息
} // Speak 函数体结束
virtual ~Base() // 定义虚析构函数,保证 delete Base* 时析构完整
{ // Base 析构函数体开始
std::cout << "Base Destructor" << std::endl; // 打印父类析构信息
} // Base 析构函数体结束
}; // 父类定义结束
class Derived : public Base // 定义子类 Derived,继承 Base
{ // 子类类体开始
private: // 私有成员开始
int* data; // 子类独有资源,模拟堆内存
public: // 公有成员开始
Derived() : data(new int[10]) // 子类构造函数,申请堆内存
{ // Derived 构造函数体开始
std::cout << "Derived Constructor" << std::endl; // 打印子类构造信息
} // Derived 构造函数体结束
void Speak() override // 重写父类虚函数
{ // 子类 Speak 函数体开始
std::cout << "Derived Speak" << std::endl; // 打印子类函数信息
} // 子类 Speak 函数体结束
~Derived() override // 子类析构函数,override 表示重写父类虚析构
{ // Derived 析构函数体开始
delete[] data; // 释放子类申请的堆内存
data = nullptr; // 指针置空,避免悬空指针
std::cout << "Derived Destructor" << std::endl; // 打印子类析构信息
} // Derived 析构函数体结束
}; // 子类定义结束
int main() // 程序入口函数
{ // main 函数体开始
Base* p = new Derived(); // 父类指针指向子类对象
p->Speak(); // 调用虚函数,实际执行 Derived::Speak
delete p; // 通过父类指针删除对象,会先调用 Derived 析构,再调用 Base 析构
return 0; // 程序正常结束
} // main 函数体结束输出顺序
c
Derived Constructor
Derived Speak
Derived Destructor
Base Destructor重点顺序
构造顺序:先父类,再子类。 析构顺序:先子类,再父类。
所以 delete Base* 时,必须先进入子类析构释放子类资源,再进入父类析构释放父类资源。
面试高分回答
IMPORTANT
如果一个类要作为多态基类使用,也就是它有虚函数,并且可能通过基类指针或引用操作派生类对象,那么基类析构函数通常应该声明为 virtual。这样通过 Base* 删除 Derived 对象时,析构也会发生动态绑定,先调用 Derived::~Derived(),再调用 Base::~Base(),保证资源完整释放。否则可能只执行基类析构,导致子类资源泄漏,行为不安全。
多态实现
一句话理解
多态就是:同一个调用方式,在不同对象上表现出不同的行为。
比如同样调用:
c
animal->Speak();如果真实对象是 Dog,就叫狗叫。 如果真实对象是 Cat,就叫猫叫。
C++ 多态分两类
编译期多态:编译时就知道调用哪个函数。
常见形式:
- 函数重载
- 运算符重载
- 模板
运行期多态:运行时才知道调用哪个函数。
常见形式:
- 继承
- 虚函数
virtual - 子类重写
override - 父类指针或引用调用
运行期多态底层怎么实现?
主流编译器通常用 vptr + vtable 实现。
如果一个类有虚函数,对象内部通常会多一个隐藏指针:vptr。 vptr 指向这个类对应的虚表:vtable。 虚表里存的是虚函数地址。
调用:
c
Base* p = new Dog();
p->Speak();底层大概是:
- 通过
p找到 Dog 对象。 - 从 Dog 对象里拿到
vptr。 vptr指向 Dog 的虚表。- 从虚表中找到
Speak的函数地址。 - 调用
Dog::Speak()。
代码示例:运行期多态
c
#include <iostream> // 引入输入输出库,用来打印结果
class Animal // 定义父类 Animal
{ // Animal 类体开始
public: // 公有成员开始
virtual void Speak() const // 定义虚函数,允许子类重写
{ // 父类 Speak 函数体开始
std::cout << "Animal Speak" << std::endl; // 打印父类默认行为
} // 父类 Speak 函数体结束
virtual ~Animal() // 定义虚析构函数,保证通过父类指针删除子类对象安全
{ // 父类析构函数体开始
std::cout << "Animal Destructor" << std::endl; // 打印父类析构信息
} // 父类析构函数体结束
}; // Animal 类定义结束
class Dog : public Animal // 定义 Dog 类,继承 Animal
{ // Dog 类体开始
public: // 公有成员开始
void Speak() const override // 重写父类虚函数
{ // Dog 的 Speak 函数体开始
std::cout << "Dog Bark" << std::endl; // 打印狗的行为
} // Dog 的 Speak 函数体结束
~Dog() override // 重写父类虚析构函数
{ // Dog 析构函数体开始
std::cout << "Dog Destructor" << std::endl; // 打印 Dog 析构信息
} // Dog 析构函数体结束
}; // Dog 类定义结束
class Cat : public Animal // 定义 Cat 类,继承 Animal
{ // Cat 类体开始
public: // 公有成员开始
void Speak() const override // 重写父类虚函数
{ // Cat 的 Speak 函数体开始
std::cout << "Cat Meow" << std::endl; // 打印猫的行为
} // Cat 的 Speak 函数体结束
~Cat() override // 重写父类虚析构函数
{ // Cat 析构函数体开始
std::cout << "Cat Destructor" << std::endl; // 打印 Cat 析构信息
} // Cat 析构函数体结束
}; // Cat 类定义结束
void MakeSound(const Animal& animal) // 定义函数,使用父类引用接收任意子类对象
{ // MakeSound 函数体开始
animal.Speak(); // 调用虚函数,运行时根据真实对象决定调用哪个版本
} // MakeSound 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
Dog dog; // 创建 Dog 对象
Cat cat; // 创建 Cat 对象
MakeSound(dog); // 父类引用绑定 Dog,调用 Dog::Speak
MakeSound(cat); // 父类引用绑定 Cat,调用 Cat::Speak
Animal* p = new Dog(); // 父类指针指向 Dog 对象
p->Speak(); // 通过父类指针调用虚函数,实际执行 Dog::Speak
delete p; // 通过父类指针删除对象,因为析构是 virtual,所以会完整析构
return 0; // 程序正常结束
} // main 函数体结束面试里要强调的条件
运行期多态必须满足:
- 有继承关系。
- 父类函数声明为
virtual。 - 子类重写这个函数。
- 通过父类指针或父类引用调用。
如果你写:
c
Animal a = Dog();这不是多态的正确用法,因为会发生对象切片,Dog 的子类部分会被切掉。
面试高分回答
WARNING
C++ 多态分为编译期多态和运行期多态。编译期多态通过函数重载、模板等方式实现,编译器在编译阶段就能确定调用目标。运行期多态通过继承和虚函数实现,调用方使用父类指针或引用,实际执行哪个函数由对象真实类型决定。底层通常是对象中有一个隐藏的 vptr 指向虚表 vtable,虚表里保存虚函数地址,调用虚函数时通过虚表找到真正要执行的函数。所以虚函数有一点额外开销,但换来了解耦、扩展性和面向接口编程能力。
vector 底层
一句话理解
vector 底层就是一块“可以自动扩容的连续数组”。
它的核心优势是:内存连续,所以随机访问快、缓存友好。 它的核心代价是:容量不够时要重新分配内存,并把旧元素搬到新内存。
vector 底层通常有什么?
主流实现里,vector 对象内部通常可以理解成三个指针:
c
begin // 指向第一个元素
end // 指向最后一个元素的后一个位置
cap_end // 指向容量末尾的后一个位置所以:
c
size = end - begin
capacity = cap_end - beginsize 是当前已有元素数量。 capacity 是当前这块内存最多能放多少元素。
push_back 时发生什么?
如果 size < capacity:
直接把新元素构造到 end 指向的位置,然后 end++。 这个时候很快,通常是 O(1)。
如果 size == capacity:
说明空间满了,vector 会:
- 申请一块更大的连续内存。
- 把旧元素移动或拷贝到新内存。
- 在新内存里插入新元素。
- 析构旧内存里的元素。
- 释放旧内存。
- 更新内部指针。
扩容那一次是 O(n),但平均到多次 push_back 上,尾插是均摊 O(1)。
代码示例
c
#include <iostream> // 引入输入输出库
#include <vector> // 引入 vector 容器
int main() // 程序入口函数
{ // main 函数体开始
std::vector<int> nums; // 创建一个空 vector
nums.reserve(4); // 提前申请容量 4,减少扩容次数
nums.push_back(10); // 尾部插入 10
nums.push_back(20); // 尾部插入 20
nums.push_back(30); // 尾部插入 30
std::cout << nums.size() << std::endl; // 输出当前元素个数
std::cout << nums.capacity() << std::endl; // 输出当前容量
std::cout << nums[1] << std::endl; // 下标访问第 2 个元素,时间复杂度 O(1)
nums.push_back(40); // 插入第 4 个元素,此时还没有超过容量
nums.push_back(50); // 插入第 5 个元素,可能触发扩容
std::cout << nums.size() << std::endl; // 输出扩容后的元素个数
std::cout << nums.capacity() << std::endl; // 输出扩容后的容量
return 0; // 程序正常结束
} // main 函数体结束常见面试追问
vector 为什么随机访问快? 因为底层是连续内存,nums[i] 可以直接通过 起始地址 + i * 元素大小 算出地址。
vector 中间插入为什么慢? 因为中间插入后,后面的元素要整体往后移动,所以通常是 O(n)。
vector 扩容后迭代器为什么会失效? 因为扩容会换一块新内存,旧迭代器、旧指针、旧引用还指向原来的内存地址,所以会失效。
reserve 和 resize 区别? reserve(n) 只改容量,不改元素个数。 resize(n) 会改元素个数,可能会构造或销毁元素。
面试高分回答
WARNING
vector 底层是连续内存的动态数组,内部通常用类似 begin、end、cap_end 三个指针维护数据区。size 表示已经构造的元素数量,capacity 表示当前已申请的容量。尾部插入在容量足够时是 O(1),容量不足时会重新分配更大的连续空间,把旧元素移动或拷贝过去,所以扩容那次是 O(n),但整体尾插是均摊 O(1)。它适合频繁随机访问和尾部插入,不适合频繁中间插入删除。
map 和 unordered_map
一句话理解
map 是有序字典,底层通常是红黑树。 unordered_map 是无序字典,底层通常是哈希表。
核心区别
map 会按照 key 自动排序,查找、插入、删除都是稳定的 O(log n)。
unordered_map 不保证顺序,通过 hash(key) 找桶,平均查找、插入、删除是 O(1),但发生大量哈希冲突时可能退化。
底层原理
map:
- key 有序。
- 通常用红黑树实现。
- 查找时不断比较 key,往左子树或右子树走。
- 适合范围查询、按顺序遍历、找最大最小附近元素。
unordered_map:
- key 无序。
- 通常用哈希表实现。
- 先算
hash(key),再定位到某个 bucket。 - 如果桶里有多个元素,还要继续比较 key。
- 适合只关心快速查找,不关心顺序的场景。
代码示例
c
#include <iostream> // 引入输入输出库
#include <map> // 引入 std::map
#include <unordered_map> // 引入 std::unordered_map
#include <string> // 引入 std::string
int main() // 程序入口函数
{ // main 函数体开始
std::map<std::string, int> ordered; // 创建有序 map
ordered["banana"] = 3; // 插入 banana,值为 3
ordered["apple"] = 5; // 插入 apple,值为 5
ordered["orange"] = 2; // 插入 orange,值为 2
for (const auto& item : ordered) // 遍历 map,会按照 key 的字典序输出
{ // map 遍历循环体开始
std::cout << item.first << " " << item.second << std::endl; // 输出 key 和 value
} // map 遍历循环体结束
std::unordered_map<std::string, int> hashed; // 创建无序 unordered_map
hashed["banana"] = 3; // 插入 banana,值为 3
hashed["apple"] = 5; // 插入 apple,值为 5
hashed["orange"] = 2; // 插入 orange,值为 2
if (hashed.find("apple") != hashed.end()) // 查找 apple 是否存在
{ // if 代码块开始
std::cout << hashed["apple"] << std::endl; // 输出 apple 对应的 value
} // if 代码块结束
return 0; // 程序正常结束
} // main 函数体结束怎么选择?
需要有序遍历、范围查询、找上下界,用 map。 只需要根据 key 快速查 value,不关心顺序,用 unordered_map。
面试高分回答
CAUTION
map 是有序关联容器,底层通常是红黑树,所以元素按 key 排序,查找、插入、删除复杂度是 O(log n)。unordered_map 是无序关联容器,底层通常是哈希表,通过 hash(key) 定位桶,平均复杂度是 O(1),但哈希冲突严重时可能退化,并且 rehash 会导致迭代器失效。实际选择时,如果需要有序性、范围查询、lower_bound,选 map;如果只追求快速 key-value 查找,通常选 unordered_map。
智能指针
一句话理解
智能指针就是“会自动释放资源的指针对象”。 它把裸指针 T* 包装起来,用对象生命周期管理资源,这就是 C++ 里的 RAII 思想。
三种常见智能指针
unique_ptr:独占所有权。 一个资源只能被一个 unique_ptr 拥有,不能拷贝,只能 std::move 转移。最推荐优先使用。
shared_ptr:共享所有权。 多个 shared_ptr 可以共同管理同一个对象,内部有引用计数。强引用计数变成 0 时,对象释放。
weak_ptr:弱引用。 它观察 shared_ptr 管理的对象,但不增加强引用计数,主要用来解决 shared_ptr 循环引用。
代码示例
c
#include <iostream> // 引入输入输出库
#include <memory> // 引入智能指针库
#include <string> // 引入字符串库
class Texture // 定义一个 Texture 类,模拟游戏里的贴图资源
{ // 类体开始
private: // 私有成员开始
std::string name; // 保存资源名字
public: // 公有成员开始
explicit Texture(const std::string& n) : name(n) // 构造函数,创建资源时调用
{ // 构造函数体开始
std::cout << "加载资源: " << name << std::endl; // 打印资源加载信息
} // 构造函数体结束
~Texture() // 析构函数,资源释放时调用
{ // 析构函数体开始
std::cout << "释放资源: " << name << std::endl; // 打印资源释放信息
} // 析构函数体结束
void Use() const // 定义使用资源的函数
{ // Use 函数体开始
std::cout << "使用资源: " << name << std::endl; // 打印资源使用信息
} // Use 函数体结束
}; // Texture 类定义结束
int main() // 程序入口函数
{ // main 函数体开始
std::unique_ptr<Texture> u = std::make_unique<Texture>("Hero"); // 创建 unique_ptr,独占 Hero 资源
u->Use(); // 通过 unique_ptr 使用资源
std::unique_ptr<Texture> u2 = std::move(u); // 转移所有权,u 变空,u2 接管资源
if (u == nullptr) // 判断原来的 unique_ptr 是否已经为空
{ // if 代码块开始
std::cout << "u 已经不再拥有资源" << std::endl; // 打印所有权转移结果
} // if 代码块结束
std::shared_ptr<Texture> s1 = std::make_shared<Texture>("Boss"); // 创建 shared_ptr,管理 Boss 资源
std::shared_ptr<Texture> s2 = s1; // 拷贝 shared_ptr,强引用计数加 1
std::weak_ptr<Texture> w = s1; // 创建 weak_ptr,只观察资源,不增加强引用计数
std::cout << s1.use_count() << std::endl; // 输出强引用计数,此时通常是 2
if (std::shared_ptr<Texture> locked = w.lock()) // 尝试从 weak_ptr 获取可用的 shared_ptr
{ // if 代码块开始
locked->Use(); // 如果对象还活着,就使用资源
} // if 代码块结束
return 0; // main 结束,智能指针离开作用域后自动释放资源
} // main 函数体结束面试重点
智能指针不是“更高级的裸指针”,而是表达所有权的工具。
能明确独占,就用 unique_ptr。 确实多个地方共同拥有生命周期,才用 shared_ptr。 只是观察、不拥有,尤其是要打破循环引用,就用 weak_ptr。
常见坑
shared_ptr 可能循环引用:
c
A 持有 shared_ptr<B>
B 持有 shared_ptr<A>这样 A 和 B 的引用计数都不会归零,析构函数不会执行。解决方式是把其中一边改成 weak_ptr。
面试高分回答
NOTE
智能指针是 C++ 用 RAII 管理动态资源的工具。unique_ptr 表示独占所有权,开销小,不能拷贝只能移动;shared_ptr 表示共享所有权,底层有控制块,里面维护强引用计数和弱引用计数,强引用计数为 0 时释放对象;weak_ptr 不拥有对象,不增加强引用计数,常用来解决 shared_ptr 循环引用。实际开发中应优先使用 unique_ptr,只有生命周期确实共享时才使用 shared_ptr。
shared_ptr 循环引用
一句话理解
shared_ptr 循环引用就是:两个对象互相用 shared_ptr 持有对方,导致双方引用计数都无法变成 0,析构函数不会执行,最终造成内存泄漏。
为什么会泄漏?
shared_ptr 的释放条件是:强引用计数 strong_count 变成 0。
如果:
c
A 里面有 shared_ptr<B>
B 里面有 shared_ptr<A>那么即使外部的 shared_ptr<A> 和 shared_ptr<B> 都离开作用域,A 和 B 内部仍然互相持有对方。 所以 A 觉得“还有 B 持有我”,B 也觉得“还有 A 持有我”,谁都不会释放。
错误代码:循环引用
c
#include <iostream> // 引入输入输出库
#include <memory> // 引入智能指针库
class B; // 前向声明 B,因为 A 里面要使用 B
class A // 定义类 A
{ // A 类体开始
public: // 公有成员开始
std::shared_ptr<B> b; // A 强引用 B,会增加 B 的引用计数
~A() // A 的析构函数
{ // A 析构函数体开始
std::cout << "A 析构" << std::endl; // 打印 A 析构信息
} // A 析构函数体结束
}; // A 类定义结束
class B // 定义类 B
{ // B 类体开始
public: // 公有成员开始
std::shared_ptr<A> a; // B 强引用 A,会增加 A 的引用计数
~B() // B 的析构函数
{ // B 析构函数体开始
std::cout << "B 析构" << std::endl; // 打印 B 析构信息
} // B 析构函数体结束
}; // B 类定义结束
int main() // 程序入口函数
{ // main 函数体开始
std::shared_ptr<A> pa = std::make_shared<A>(); // 创建 A 对象,pa 强引用 A
std::shared_ptr<B> pb = std::make_shared<B>(); // 创建 B 对象,pb 强引用 B
pa->b = pb; // A 内部强引用 B,让 B 的引用计数增加
pb->a = pa; // B 内部强引用 A,让 A 的引用计数增加
return 0; // main 结束后 pa 和 pb 释放,但 A 和 B 仍互相强引用
} // main 函数体结束这段代码里,A 析构 和 B 析构 很可能不会打印,因为循环引用让它们释放不了。
正确做法:一边改成 weak_ptr
c
#include <iostream> // 引入输入输出库
#include <memory> // 引入智能指针库
class B; // 前向声明 B,因为 A 里面要使用 B
class A // 定义类 A
{ // A 类体开始
public: // 公有成员开始
std::shared_ptr<B> b; // A 强引用 B,表示 A 拥有 B
~A() // A 的析构函数
{ // A 析构函数体开始
std::cout << "A 析构" << std::endl; // 打印 A 析构信息
} // A 析构函数体结束
}; // A 类定义结束
class B // 定义类 B
{ // B 类体开始
public: // 公有成员开始
std::weak_ptr<A> a; // B 弱引用 A,只观察 A,不增加 A 的强引用计数
~B() // B 的析构函数
{ // B 析构函数体开始
std::cout << "B 析构" << std::endl; // 打印 B 析构信息
} // B 析构函数体结束
}; // B 类定义结束
int main() // 程序入口函数
{ // main 函数体开始
std::shared_ptr<A> pa = std::make_shared<A>(); // 创建 A 对象,pa 强引用 A
std::shared_ptr<B> pb = std::make_shared<B>(); // 创建 B 对象,pb 强引用 B
pa->b = pb; // A 强引用 B,表示 A 拥有 B
pb->a = pa; // B 弱引用 A,不增加 A 的强引用计数
if (std::shared_ptr<A> locked = pb->a.lock()) // 使用 weak_ptr 前先 lock,尝试获得 shared_ptr
{ // if 代码块开始
std::cout << "A 还活着" << std::endl; // lock 成功说明 A 还没有被释放
} // if 代码块结束
return 0; // main 结束后引用计数可以归零,A 和 B 会正常析构
} // main 函数体结束什么时候用 weak_ptr?
父子关系里,父对象通常用 shared_ptr 持有子对象,子对象如果要指回父对象,可以用 weak_ptr。 观察者模式里,被观察对象不应该被观察者强行延长生命周期,也常用 weak_ptr。
面试高分回答
TIP
shared_ptr 通过强引用计数管理对象生命周期,当强引用计数为 0 时释放对象。循环引用的问题是两个对象互相持有 shared_ptr,即使外部引用释放了,它们内部仍然互相增加对方的强引用计数,导致计数无法归零,析构函数不会执行。解决方式是把不拥有生命周期的一方改成 weak_ptr,因为 weak_ptr 不增加强引用计数,使用前通过 lock() 临时获取 shared_ptr,这样既能访问对象,又不会阻止对象释放。
RAII
一句话理解
RAII 是 C++ 管理资源生命周期的核心思想:构造函数获取资源,析构函数释放资源。
对象活着,资源可用。 对象销毁,资源自动释放。
RAII 管的资源是什么?
不只是内存,还包括:
- 堆内存
- 文件句柄
- 互斥锁
- Socket
- 数据库连接
- 纹理、Mesh、GPU 资源句柄
为什么 RAII 很重要?
因为 C++ 没有 GC。 如果你手动 new 了内存、打开了文件、加了锁,就必须释放。 但代码里可能有多个 return,也可能中途抛异常,手动释放很容易漏。
RAII 的做法是:把释放逻辑放到析构函数里。 只要对象离开作用域,析构函数自动执行,资源就会被释放。
代码示例:自己写一个 RAII 文件管理类
c
#include <cstdio> // 引入 C 风格文件操作函数
#include <iostream> // 引入输入输出库
#include <stdexcept> // 引入异常类型
class FileGuard // 定义一个 RAII 类,用来管理 FILE* 文件资源
{ // 类体开始
private: // 私有成员开始
FILE* file; // 保存文件指针资源
public: // 公有成员开始
FileGuard(const char* path, const char* mode) : file(std::fopen(path, mode)) // 构造函数中打开文件
{ // 构造函数体开始
if (file == nullptr) // 判断文件是否打开失败
{ // if 代码块开始
throw std::runtime_error("open file failed"); // 打开失败就抛异常
} // if 代码块结束
std::cout << "文件打开成功" << std::endl; // 打印文件打开成功信息
} // 构造函数体结束
FileGuard(const FileGuard&) = delete; // 禁止拷贝构造,避免两个对象管理同一个 FILE*
FileGuard& operator=(const FileGuard&) = delete; // 禁止拷贝赋值,避免重复关闭文件
void Write(const char* text) // 定义写文件函数
{ // Write 函数体开始
std::fputs(text, file); // 把字符串写入文件
} // Write 函数体结束
~FileGuard() // 析构函数中释放资源
{ // 析构函数体开始
if (file != nullptr) // 判断文件指针是否有效
{ // if 代码块开始
std::fclose(file); // 关闭文件,释放文件资源
file = nullptr; // 把指针置空,避免悬空指针
std::cout << "文件自动关闭" << std::endl; // 打印文件关闭信息
} // if 代码块结束
} // 析构函数体结束
}; // FileGuard 类定义结束
int main() // 程序入口函数
{ // main 函数体开始
try // 开始异常捕获代码块
{ // try 代码块开始
FileGuard file("test.txt", "w"); // 创建 RAII 对象,构造函数打开文件
file.Write("hello raii"); // 使用文件资源写入内容
throw std::runtime_error("some error"); // 模拟中途发生异常
} // try 代码块结束,file 离开作用域会自动析构
catch (const std::exception& e) // 捕获标准异常
{ // catch 代码块开始
std::cout << e.what() << std::endl; // 打印异常信息
} // catch 代码块结束
return 0; // 程序正常结束
} // main 函数体结束标准库里的 RAII
std::unique_ptr:构造时接管堆对象,析构时 delete。 std::shared_ptr:引用计数归零时释放对象。 std::lock_guard:构造时加锁,析构时解锁。 std::fstream:构造或打开文件,析构时关闭文件。
面试高分回答
TIP
RAII 是 Resource Acquisition Is Initialization,资源获取即初始化。它的核心是把资源生命周期绑定到对象生命周期:构造函数负责获取资源,析构函数负责释放资源。这样即使函数中途 return 或抛异常,局部对象在栈展开时也会自动析构,从而保证资源释放。C++ 的智能指针、文件流、锁对象都是 RAII 的典型应用。实际写 RAII 类时,要注意所有权问题,如果类独占资源,通常要禁用拷贝,或者正确实现移动语义,析构函数也不要抛异常。
移动语义
一句话理解
移动语义不是复制资源,而是转移资源所有权。
拷贝是“我也要一份”。 移动是“这份资源归我了,你别再管了”。
为什么需要移动语义?
如果一个对象里有堆内存,比如 char* data,拷贝对象时通常要重新申请内存,再复制数据。
这很安全,但可能很慢。
移动语义的目标是:当源对象以后不用了,比如临时对象、函数返回值、std::move(obj),就不用复制整块资源,而是直接把指针、句柄、资源所有权拿过来。
std::move 是什么?
std::move 本身不移动任何东西。
它只是把一个左值强制转换成右值,让编译器有机会调用移动构造或移动赋值。
真正移动资源的代码,是你写在移动构造函数和移动赋值运算符里的。
代码示例
c
#include <iostream> // 引入输入输出库
#include <cstring> // 引入 memcpy 和 memset
class Buffer // 定义一个 Buffer 类,用来演示移动语义
{ // 类体开始
private: // 私有成员开始
char* data; // 指向堆内存的指针
int size; // 保存内存大小
public: // 公有成员开始
Buffer(int n) : data(new char[n]), size(n) // 构造函数,申请堆内存
{ // 构造函数体开始
std::memset(data, 0, size); // 把堆内存初始化为 0
std::cout << "构造函数" << std::endl; // 打印构造信息
} // 构造函数体结束
Buffer(const Buffer& other) : data(new char[other.size]), size(other.size) // 拷贝构造,重新申请资源
{ // 拷贝构造函数体开始
std::memcpy(data, other.data, size); // 复制 other 的资源内容
std::cout << "拷贝构造" << std::endl; // 打印拷贝构造信息
} // 拷贝构造函数体结束
Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) // 移动构造,直接接管 other 的资源
{ // 移动构造函数体开始
other.data = nullptr; // 把源对象指针置空,避免源对象析构时释放资源
other.size = 0; // 把源对象大小置 0,让它处于安全状态
std::cout << "移动构造" << std::endl; // 打印移动构造信息
} // 移动构造函数体结束
Buffer& operator=(Buffer&& other) noexcept // 移动赋值,已有对象接管右值资源
{ // 移动赋值函数体开始
if (this == &other) // 判断是否自己移动给自己
{ // 自移动判断代码块开始
return *this; // 如果是自己给自己赋值,直接返回
} // 自移动判断代码块结束
delete[] data; // 先释放自己原来拥有的资源
data = other.data; // 接管 other 的资源指针
size = other.size; // 接管 other 的资源大小
other.data = nullptr; // 把源对象指针置空,避免重复释放
other.size = 0; // 把源对象大小置 0
std::cout << "移动赋值" << std::endl; // 打印移动赋值信息
return *this; // 返回当前对象
} // 移动赋值函数体结束
~Buffer() // 析构函数,释放资源
{ // 析构函数体开始
delete[] data; // 释放当前对象拥有的堆内存
data = nullptr; // 指针置空,避免悬空指针
std::cout << "析构函数" << std::endl; // 打印析构信息
} // 析构函数体结束
}; // Buffer 类定义结束
int main() // 程序入口函数
{ // main 函数体开始
Buffer a(100); // 创建对象 a,调用构造函数
Buffer b = a; // 用 a 创建 b,调用拷贝构造
Buffer c = std::move(a); // 把 a 转成右值,调用移动构造
Buffer d(50); // 创建对象 d,调用构造函数
d = std::move(c); // 把 c 的资源移动给 d,调用移动赋值
return 0; // 程序结束,局部对象依次析构
} // main 函数体结束移动后对象还能用吗?
能析构,能重新赋值,但不要依赖它原来的值。
比如:
c
Buffer c = std::move(a);这之后 a 仍然是一个合法对象,但它内部资源可能已经被拿走了。
面试高分回答
TIP
移动语义是 C++11 引入的性能优化机制,核心是把资源所有权从一个即将不用的对象转移到另一个对象,避免昂贵的深拷贝。它通常通过右值引用 T&&、移动构造函数和移动赋值运算符实现。std::move 本身不移动资源,只是把对象转换成右值表达式,真正移动发生在移动构造或移动赋值中。移动后源对象必须仍然处于可析构、可赋值的合法状态,但它的具体值不应该再被依赖。
右值引用
一句话理解
右值引用就是用 T&& 接收“快要消失的临时对象”,从而允许我们安全地“偷走”它的资源,用来实现移动语义。
左值和右值先分清
左值:有名字、能取地址、可以在后面继续使用。
比如:
int x = 10;x 是左值。
右值:临时值、表达式结束后通常就没了。
比如:
c
10
x + 1
MakeObject()这些通常是右值。
右值引用是什么?
普通左值引用:
c
int& a = x;右值引用:
c
int&& b = 10;int&& 可以绑定右值。它的意义不是为了单纯引用一个数字,而是为了告诉编译器:这个对象可能快不用了,可以考虑移动它的资源。
代码示例
c
#include <iostream> // 引入输入输出库
#include <utility> // 引入 std::move
void Check(int& value) // 定义接收左值引用的函数
{ // 左值引用函数体开始
std::cout << "调用左值引用版本" << std::endl; // 打印左值引用版本
} // 左值引用函数体结束
void Check(int&& value) // 定义接收右值引用的函数
{ // 右值引用函数体开始
std::cout << "调用右值引用版本" << std::endl; // 打印右值引用版本
} // 右值引用函数体结束
int main() // 程序入口函数
{ // main 函数体开始
int x = 10; // x 有名字,所以 x 是左值
Check(x); // x 是左值,所以调用 int& 版本
Check(20); // 20 是临时值,所以调用 int&& 版本
Check(x + 1); // x + 1 是临时结果,所以调用 int&& 版本
int&& rr = 30; // rr 是右值引用,可以绑定右值 30
Check(rr); // rr 有名字,所以 rr 本身是左值,调用 int& 版本
Check(std::move(rr)); // std::move(rr) 转成右值表达式,调用 int&& 版本
return 0; // 程序正常结束
} // main 函数体结束最重要的坑
c
int&& rr = 30;这里 rr 的类型是右值引用。 但是 rr 这个变量有名字,所以表达式 rr 本身是左值。
所以:
c
Check(rr);调用的是左值引用版本。
如果你想继续把它当成右值传出去,要写:
c
Check(std::move(rr));std::move 是什么?
std::move 不移动对象。 它只是把一个左值转换成右值表达式。
真正移动资源的是:
- 移动构造函数:
T(T&& other) - 移动赋值运算符:
operator=(T&& other)
面试高分回答
IMPORTANT
右值引用是 C++11 引入的特性,写法是 T&&,主要用于绑定右值和实现移动语义。它让我们可以区分“还能继续使用的对象”和“即将销毁的临时对象”。当对象内部持有堆内存、文件句柄等资源时,右值引用可以配合移动构造和移动赋值,把资源所有权直接转移过去,避免昂贵的深拷贝。需要注意的是,std::move 本身不移动资源,只是类型转换;另外,有名字的右值引用变量本身仍然是左值。
模板实例化
一句话理解
模板实例化就是:模板本身只是“代码蓝图”,当你真正用 int、float、std::string 等具体类型去调用时,编译器才会生成对应版本的函数或类。
比如:
c
Add<int>()
Add<float>()它们不是同一个真正函数,而是编译器根据模板生成出来的两个版本。
模板为什么常写在头文件?
因为模板实例化发生在编译期。 编译器看到:
c
Add<int>(1, 2)它必须能看到 Add 的完整模板定义,才能生成 int Add(int, int)。
如果头文件里只有声明,定义放在 .cpp 里,别的文件可能实例化不了,就容易链接错误。
代码示例
c
#include <iostream> // 引入输入输出库
#include <string> // 引入字符串类型
template <typename T> // 定义函数模板,T 是待替换的类型参数
T Add(T a, T b) // 定义通用 Add 函数,参数类型和返回值类型都是 T
{ // 函数体开始
return a + b; // 返回两个 T 类型对象相加的结果
} // 函数体结束
template <typename T> // 定义类模板,T 是类中保存的数据类型
class Box // 定义一个 Box 容器类模板
{ // 类体开始
private: // 私有成员开始
T value; // 使用模板参数 T 作为成员变量类型
public: // 公有成员开始
explicit Box(T v) : value(v) // 构造函数,接收一个 T 类型的值
{ // 构造函数体开始
} // 构造函数体结束
T Get() const // 返回 Box 内部保存的值
{ // Get 函数体开始
return value; // 返回成员变量 value
} // Get 函数体结束
}; // 类模板定义结束
template int Add<int>(int, int); // 显式实例化 Add<int> 版本
template class Box<double>; // 显式实例化 Box<double> 版本
int main() // 程序入口函数
{ // main 函数体开始
int a = Add<int>(1, 2); // 使用 int 类型实例化 Add 函数模板
float b = Add<float>(1.5f, 2.5f); // 使用 float 类型实例化 Add 函数模板
Box<int> intBox(10); // 使用 int 类型实例化 Box 类模板
Box<std::string> strBox("hello"); // 使用 string 类型实例化 Box 类模板
std::cout << a << std::endl; // 输出 int 版本 Add 的结果
std::cout << b << std::endl; // 输出 float 版本 Add 的结果
std::cout << intBox.Get() << std::endl; // 输出 Box<int> 保存的值
std::cout << strBox.Get() << std::endl; // 输出 Box<string> 保存的值
return 0; // 程序正常结束
} // main 函数体结束隐式实例化和显式实例化
隐式实例化:你用到什么类型,编译器自动生成什么版本。
比如:
c
Add<int>(1, 2);显式实例化:你手动告诉编译器生成某个版本。
比如:
c
template int Add<int>(int, int);面试高分回答
WARNING
模板实例化是 C++ 模板在编译期生成具体代码的过程。模板本身不是一个真正可执行的函数或类,而是代码生成规则。当编译器看到 Add<int>、Box<float> 这样的具体类型使用时,会根据模板定义生成对应的具体版本。模板通常写在头文件里,是因为实例化时编译器必须看到完整定义。模板的好处是类型安全、复用性强、运行期无额外多态开销;缺点是可能导致编译变慢、错误信息复杂、实例化过多时造成代码膨胀。
静态库和动态库
一句话理解
静态库:链接时把库代码合进可执行文件。 动态库:可执行文件只记录依赖,运行时再加载库文件。
静态库是什么?
静态库本质是一堆 .obj/.o 目标文件的打包集合。
常见后缀:
- Windows:
.lib - Linux/macOS:
.a
链接时,链接器会把程序用到的库代码拷贝进最终的可执行文件。
优点:部署简单,运行时不依赖额外库文件。 缺点:可执行文件更大;库更新后,需要重新链接并重新发布程序。
动态库是什么?
动态库是在运行时被加载的共享库。
常见后缀:
- Windows:
.dll - Linux:
.so - macOS:
.dylib
链接时,程序通常只记录“我要用哪个动态库里的哪些符号”。运行时,系统加载器再把动态库加载进进程地址空间。
优点:多个程序可以共享同一份库;库可以单独更新。 缺点:部署时要带对版本;动态库缺失、路径错误、ABI 不兼容都可能导致启动失败或运行崩溃。
代码示例:库函数
c
#ifndef MATH_LIB_H // 防止头文件被重复包含
#define MATH_LIB_H // 定义头文件保护宏
int Add(int a, int b); // 声明 Add 函数,供外部模块调用
#endif // 结束头文件保护宏
#include "MathLib.h" // 引入函数声明头文件
int Add(int a, int b) // 定义 Add 函数
{ // 函数体开始
return a + b; // 返回两个整数相加的结果
} // 函数体结束
#include <iostream> // 引入输入输出库
#include "MathLib.h" // 引入库函数声明
int main() // 程序入口函数
{ // main 函数体开始
int result = Add(1, 2); // 调用库里的 Add 函数
std::cout << result << std::endl; // 输出 Add 的返回结果
return 0; // 程序正常结束
} // main 函数体结束面试里怎么对比?
静态库像“把工具直接装进你的背包”。程序运行时不需要再找它,但背包会变大,工具升级也要重新打包。
动态库像“运行时去公共工具间拿工具”。程序本身更小,多个程序能共享,但运行时必须能找到这个库,而且版本要匹配。
面试高分回答
WARNING
静态库和动态库的核心区别在链接和加载时机。静态库在链接阶段把目标代码合并进最终可执行文件,所以部署简单、运行时依赖少,但可执行文件体积更大,库更新后需要重新链接发布。动态库在链接阶段只记录依赖关系和导入符号,运行时由系统加载器加载到进程地址空间,所以便于共享和热更新,但会带来库路径、版本兼容、ABI 兼容、符号导出等问题。实际项目里,基础稳定模块可以静态链接,常更新、可共享、插件化的模块更适合动态库。
编译链接流程
一句话理解
C++ 从源码到程序,大概经历:
预处理 -> 编译 -> 汇编 -> 链接 -> 加载运行每一步做什么
预处理:处理 #include、#define、#if。 它把头文件内容展开,把宏替换掉,生成预处理后的文件,比如 .i。
编译:把预处理后的代码做语法分析、语义分析、优化,生成汇编代码,比如 .s。
汇编:把汇编代码变成机器指令,生成目标文件,比如 .o 或 .obj。
链接:把多个目标文件和库合并起来,解析函数、全局变量等符号地址,最终生成可执行文件。
命令示例
c
g++ -E main.cpp -o main.i # 只做预处理,生成 main.i
g++ -S main.i -o main.s # 只做编译,生成汇编文件 main.s
g++ -c main.s -o main.o # 只做汇编,生成目标文件 main.o
g++ main.o math.o -o app # 链接多个目标文件,生成可执行程序 app代码示例
c
#ifndef MATH_H // 防止头文件被重复包含
#define MATH_H // 定义头文件保护宏
int Add(int a, int b); // 声明 Add 函数,告诉编译器有这个函数
#endif // 结束头文件保护宏
#include "Math.h" // 引入 Add 函数声明
int Add(int a, int b) // 定义 Add 函数,链接器会在这里找到符号定义
{ // 函数体开始
return a + b; // 返回两个整数相加的结果
} // 函数体结束
#include <iostream> // 引入标准输入输出头文件
#include "Math.h" // 引入 Add 函数声明
int main() // 程序入口函数
{ // main 函数体开始
int result = Add(1, 2); // 调用 Add 函数,编译时先认为它存在
std::cout << result << std::endl; // 输出 Add 的结果
return 0; // 程序正常结束
} // main 函数体结束编译错误和链接错误区别
编译错误:编译器看不懂你的代码。 比如语法错、类型不匹配、头文件找不到。
链接错误:编译器看得懂声明,但链接器找不到定义。 比如你声明了:
c
int Add(int a, int b);但是没有提供 Add 的函数实现,就可能出现 unresolved external symbol。
面试高分回答
NOTE
C++ 的构建流程通常分为预处理、编译、汇编和链接。预处理负责展开头文件、替换宏和处理条件编译;编译负责语法语义分析、优化并生成汇编代码;汇编把汇编代码转换成目标文件;链接器再把多个目标文件和静态库、动态库关联起来,解析符号、做重定位,最终生成可执行文件。编译错误发生在单个翻译单元内部,链接错误通常发生在多个目标文件合并时,比如声明存在但定义缺失。
多线程锁和死锁
一句话理解
锁是为了保护共享资源,避免多个线程同时修改同一份数据。 死锁是多个线程互相等对方释放锁,结果谁也继续不了。
锁是什么?
多线程同时访问共享数据时,可能出现数据竞争。
比如两个线程同时执行:
c
gold++;看起来是一行,底层可能是:
c
读 gold -> 加 1 -> 写回 gold如果两个线程交叉执行,结果可能丢失一次修改。
所以要用锁保护临界区。临界区就是访问共享资源的那一段代码。
死锁是什么?
经典死锁场景:
线程 A:
c
先拿 lock1
再等 lock2线程 B:
c
先拿 lock2
再等 lock1A 手里拿着 lock1,等 B 放 lock2。 B 手里拿着 lock2,等 A 放 lock1。 两边互相等待,程序就卡住了。
死锁四个必要条件
互斥条件:资源一次只能被一个线程占用。 持有并等待:线程拿着一把锁,还想继续等另一把锁。 不可抢占:锁不能被别人强行拿走,只能持有者释放。 循环等待:线程之间形成等待环。
破坏其中一个条件,就能避免死锁。实际最常用的是破坏“循环等待”:所有线程按固定顺序加锁。
错误代码:可能死锁
c
#include <iostream> // 引入输入输出库
#include <mutex> // 引入互斥锁
#include <thread> // 引入线程库
std::mutex lock1; // 定义第一把互斥锁
std::mutex lock2; // 定义第二把互斥锁
void ThreadA() // 定义线程 A 的执行函数
{ // ThreadA 函数体开始
std::lock_guard<std::mutex> guard1(lock1); // 线程 A 先拿 lock1
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 睡眠一下,模拟业务耗时
std::lock_guard<std::mutex> guard2(lock2); // 线程 A 再等 lock2,可能被线程 B 占着
std::cout << "ThreadA finished" << std::endl; // 打印线程 A 完成信息
} // ThreadA 函数体结束
void ThreadB() // 定义线程 B 的执行函数
{ // ThreadB 函数体开始
std::lock_guard<std::mutex> guard2(lock2); // 线程 B 先拿 lock2
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 睡眠一下,模拟业务耗时
std::lock_guard<std::mutex> guard1(lock1); // 线程 B 再等 lock1,可能被线程 A 占着
std::cout << "ThreadB finished" << std::endl; // 打印线程 B 完成信息
} // ThreadB 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
std::thread t1(ThreadA); // 创建线程 A
std::thread t2(ThreadB); // 创建线程 B
t1.join(); // 等待线程 A 结束
t2.join(); // 等待线程 B 结束
return 0; // 程序正常结束
} // main 函数体结束正确代码:统一加锁顺序
c
#include <iostream> // 引入输入输出库
#include <mutex> // 引入互斥锁
#include <thread> // 引入线程库
std::mutex lock1; // 定义第一把互斥锁
std::mutex lock2; // 定义第二把互斥锁
void SafeThreadA() // 定义安全线程 A 的执行函数
{ // SafeThreadA 函数体开始
std::lock_guard<std::mutex> guard1(lock1); // 所有线程都先拿 lock1
std::lock_guard<std::mutex> guard2(lock2); // 所有线程都再拿 lock2
std::cout << "SafeThreadA finished" << std::endl; // 打印线程 A 完成信息
} // SafeThreadA 函数体结束
void SafeThreadB() // 定义安全线程 B 的执行函数
{ // SafeThreadB 函数体开始
std::lock_guard<std::mutex> guard1(lock1); // 线程 B 也先拿 lock1,避免顺序相反
std::lock_guard<std::mutex> guard2(lock2); // 线程 B 再拿 lock2,保持顺序一致
std::cout << "SafeThreadB finished" << std::endl; // 打印线程 B 完成信息
} // SafeThreadB 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
std::thread t1(SafeThreadA); // 创建安全线程 A
std::thread t2(SafeThreadB); // 创建安全线程 B
t1.join(); // 等待线程 A 结束
t2.join(); // 等待线程 B 结束
return 0; // 程序正常结束
} // main 函数体结束更推荐:std::scoped_lock
c
#include <iostream> // 引入输入输出库
#include <mutex> // 引入互斥锁和 scoped_lock
#include <thread> // 引入线程库
std::mutex lock1; // 定义第一把互斥锁
std::mutex lock2; // 定义第二把互斥锁
void SafeWork() // 定义安全工作函数
{ // SafeWork 函数体开始
std::scoped_lock guard(lock1, lock2); // 一次性锁住多把锁,标准库内部避免死锁
std::cout << "safe work" << std::endl; // 在两把锁保护下执行临界区代码
} // SafeWork 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
std::thread t1(SafeWork); // 创建第一个线程
std::thread t2(SafeWork); // 创建第二个线程
t1.join(); // 等待第一个线程结束
t2.join(); // 等待第二个线程结束
return 0; // 程序正常结束
} // main 函数体结束面试高分回答
TIP
多线程锁主要用于保护共享资源,避免数据竞争。互斥锁保证同一时刻只有一个线程进入临界区。死锁是多个线程互相持有资源并等待对方释放,导致程序无法继续执行。死锁通常需要四个条件:互斥、持有并等待、不可抢占、循环等待。避免死锁最常用的方法是统一加锁顺序、减少锁粒度、避免持锁调用外部逻辑、使用 std::lock 或 std::scoped_lock 一次性锁多把锁,并尽量用 RAII 管理锁的释放。
原子变量
一句话理解
原子变量就是多线程里“不会被打断的变量操作”。 比如普通 counter++ 可能被拆成“读、加、写”三步,两个线程交叉执行就会丢数据;std::atomic<int> 可以保证这个自增操作不会互相覆盖。
为什么普通变量不安全?
counter++ 看起来是一行,但底层大概是:
c
读取 counter
加 1
写回 counter如果线程 A 和线程 B 同时读到 0,它们都写回 1,最后结果就是 1,不是 2。这就是数据竞争。
原子变量适合什么?
适合简单共享状态:
- 计数器
- 是否退出的标志位
- 简单状态切换
- 引用计数
- 无锁结构里的 CAS 操作
但如果你要同时保护多个变量的一致性,比如 hp 和 mp 必须一起改,通常还是用 mutex 更清楚。
代码示例
c
#include <atomic> // 引入原子变量库
#include <iostream> // 引入输入输出库
#include <thread> // 引入线程库
#include <vector> // 引入 vector 容器
std::atomic<int> counter{0}; // 定义原子计数器,初始值是 0
void AddMany() // 定义线程执行函数
{ // 函数体开始
for (int i = 0; i < 100000; ++i) // 循环执行十万次
{ // 循环体开始
counter.fetch_add(1, std::memory_order_relaxed); // 原子加 1,不会被其他线程覆盖
} // 循环体结束
} // 函数体结束
int main() // 程序入口函数
{ // main 函数体开始
std::vector<std::thread> threads; // 创建线程数组
for (int i = 0; i < 4; ++i) // 创建 4 个线程
{ // for 循环体开始
threads.emplace_back(AddMany); // 启动一个线程执行 AddMany
} // for 循环体结束
for (std::thread& t : threads) // 遍历所有线程
{ // for 循环体开始
t.join(); // 等待线程执行结束
} // for 循环体结束
std::cout << counter.load() << std::endl; // 原子读取 counter,结果应该是 400000
return 0; // 程序正常结束
} // main 函数体结束memory_order 是什么?
std::atomic 不只保证“单个操作原子”,还涉及“不同线程看到内存操作的顺序”。
默认是 memory_order_seq_cst,最直观、最安全,初学面试先说它就够。 memory_order_relaxed 只保证这个变量本身操作原子,不保证和其他内存操作的顺序,适合纯计数器。 memory_order_release/acquire 常用于一个线程发布数据,另一个线程读取数据的场景。
面试高分回答
IMPORTANT
std::atomic 用来在多线程中对单个变量进行原子读写或读改写,避免数据竞争。普通的 counter++ 不是原子操作,可能被拆成读、加、写,多线程交叉执行会丢失更新;而 atomic 的 fetch_add、load、store、compare_exchange 等操作可以保证单个变量操作的原子性。它适合计数器、标志位、简单状态等场景,但不适合维护多个变量之间的复杂不变量,这种情况应该用互斥锁。