Skip to content

C++ 必背

指针和引用

pointer-vs-reference

指针和引用是什么?

指针是一个变量,它里面存的是另一个对象的地址。 比如 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++ 语义上它不是普通指针,重点区别是是否可空、是否能改指向、使用语法和表达意图不同。

栈和堆

cpp-stack-vs-heap

栈和堆是什么?

栈 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_ptrshared_ptr 让对象生命周期自动管理。

new/deletemalloc/free

new-delete-vs-malloc-free

new/delete 和 malloc/free 是什么?

new/deleteC++ 运算符。 它们不仅管理内存,还会处理对象生命周期。

malloc/freeC 标准库函数。 它们只负责申请和释放一块原始内存,不懂 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 要配 deletenew[] 要配 delete[]malloc 要配 free

混用会导致未定义行为,可能崩溃,也可能看起来暂时没事但埋下大坑。

其他区别

new 返回具体类型指针,不需要强转。 malloc 返回 void*,C++ 中通常需要强转。

new 失败默认抛出 std::bad_allocmalloc 失败返回 NULLnullptr

new/delete 可以被重载。 malloc/free 是库函数,不能按类重载成对象语义。

面试高分回答

IMPORTANT

new/delete 是 C++ 的对象创建和销毁机制,new 会先分配内存再调用构造函数,delete 会先调用析构函数再释放内存;而 malloc/free 是 C 的内存管理函数,只申请和释放原始字节内存,不会调用构造和析构。它们不能混用,new 必须配 deletenew[]delete[]mallocfree。在 C++ 里如果是对象,优先用 new/delete,现代 C++ 更推荐用 RAII 和智能指针;malloc/free 更适合 C 风格接口、底层内存池或只处理原始内存的场景。

构造、析构、拷贝构造

cpp-constructor-destructor-copy-constructor

构造函数是什么?

构造函数是在对象“出生”时自动调用的函数,用来初始化对象。

比如:

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 都有关。

深拷贝和浅拷贝

deep-copy-vs-shallow-copy

一句话理解

浅拷贝:只复制对象里的成员值,如果成员是指针,就只复制“地址”。

深拷贝:不仅复制指针成员,还会重新申请一块内存,把指针指向的数据也复制过去。

为什么浅拷贝危险?

如果两个对象的指针指向同一块堆内存,那么一个对象修改数据,另一个对象也会受影响。更严重的是,两个对象析构时都会 delete 同一块内存,可能造成重复释放、程序崩溃、悬空指针。

什么时候默认是浅拷贝?

C++ 编译器默认生成的拷贝构造函数和赋值运算符,都是“成员逐个复制”。普通 intfloat 没问题,但如果类里有 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 的原因:写了析构函数,通常也要考虑拷贝构造和赋值运算符。

虚函数和虚表

cpp-virtual-function-vtable

一句话理解

虚函数是 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

虚析构

cpp-virtual-destructor

一句话理解

虚析构就是把基类析构函数声明成 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(),保证资源完整释放。否则可能只执行基类析构,导致子类资源泄漏,行为不安全。

多态实现

cpp-polymorphism-implementation

一句话理解

多态就是:同一个调用方式,在不同对象上表现出不同的行为。

比如同样调用:

c
animal->Speak();

如果真实对象是 Dog,就叫狗叫。 如果真实对象是 Cat,就叫猫叫。

C++ 多态分两类

编译期多态:编译时就知道调用哪个函数。

常见形式:

  • 函数重载
  • 运算符重载
  • 模板

运行期多态:运行时才知道调用哪个函数。

常见形式:

  • 继承
  • 虚函数 virtual
  • 子类重写 override
  • 父类指针或引用调用

运行期多态底层怎么实现?

主流编译器通常用 vptr + vtable 实现。

如果一个类有虚函数,对象内部通常会多一个隐藏指针:vptrvptr 指向这个类对应的虚表:vtable。 虚表里存的是虚函数地址。

调用:

c
Base* p = new Dog();
p->Speak();

底层大概是:

  1. 通过 p 找到 Dog 对象。
  2. 从 Dog 对象里拿到 vptr
  3. vptr 指向 Dog 的虚表。
  4. 从虚表中找到 Speak 的函数地址。
  5. 调用 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 底层

cpp-vector-internals

一句话理解

vector 底层就是一块“可以自动扩容的连续数组”。

它的核心优势是:内存连续,所以随机访问快、缓存友好。 它的核心代价是:容量不够时要重新分配内存,并把旧元素搬到新内存。

vector 底层通常有什么?

主流实现里,vector 对象内部通常可以理解成三个指针:

c
begin    // 指向第一个元素
end      // 指向最后一个元素的后一个位置
cap_end  // 指向容量末尾的后一个位置

所以:

c
size = end - begin
capacity = cap_end - begin

size 是当前已有元素数量。 capacity 是当前这块内存最多能放多少元素。

push_back 时发生什么?

如果 size < capacity

直接把新元素构造到 end 指向的位置,然后 end++。 这个时候很快,通常是 O(1)。

如果 size == capacity

说明空间满了,vector 会:

  1. 申请一块更大的连续内存。
  2. 把旧元素移动或拷贝到新内存。
  3. 在新内存里插入新元素。
  4. 析构旧内存里的元素。
  5. 释放旧内存。
  6. 更新内部指针。

扩容那一次是 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 扩容后迭代器为什么会失效? 因为扩容会换一块新内存,旧迭代器、旧指针、旧引用还指向原来的内存地址,所以会失效。

reserveresize 区别? reserve(n) 只改容量,不改元素个数。 resize(n) 会改元素个数,可能会构造或销毁元素。

面试高分回答

WARNING

vector 底层是连续内存的动态数组,内部通常用类似 beginendcap_end 三个指针维护数据区。size 表示已经构造的元素数量,capacity 表示当前已申请的容量。尾部插入在容量足够时是 O(1),容量不足时会重新分配更大的连续空间,把旧元素移动或拷贝过去,所以扩容那次是 O(n),但整体尾插是均摊 O(1)。它适合频繁随机访问和尾部插入,不适合频繁中间插入删除。

mapunordered_map

cpp-map-vs-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

智能指针

cpp-smart-pointers

一句话理解

智能指针就是“会自动释放资源的指针对象”。 它把裸指针 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 循环引用

cpp-shared-ptr-cycle

一句话理解

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

cpp-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:构造时接管堆对象,析构时 deletestd::shared_ptr:引用计数归零时释放对象。 std::lock_guard:构造时加锁,析构时解锁。 std::fstream:构造或打开文件,析构时关闭文件。

面试高分回答

TIP

RAII 是 Resource Acquisition Is Initialization,资源获取即初始化。它的核心是把资源生命周期绑定到对象生命周期:构造函数负责获取资源,析构函数负责释放资源。这样即使函数中途 return 或抛异常,局部对象在栈展开时也会自动析构,从而保证资源释放。C++ 的智能指针、文件流、锁对象都是 RAII 的典型应用。实际写 RAII 类时,要注意所有权问题,如果类独占资源,通常要禁用拷贝,或者正确实现移动语义,析构函数也不要抛异常。

移动语义

cpp-move-semantics

一句话理解

移动语义不是复制资源,而是转移资源所有权。

拷贝是“我也要一份”。 移动是“这份资源归我了,你别再管了”。

为什么需要移动语义?

如果一个对象里有堆内存,比如 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 本身不移动资源,只是把对象转换成右值表达式,真正移动发生在移动构造或移动赋值中。移动后源对象必须仍然处于可析构、可赋值的合法状态,但它的具体值不应该再被依赖。

右值引用

cpp-rvalue-reference

一句话理解

右值引用就是用 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 本身不移动资源,只是类型转换;另外,有名字的右值引用变量本身仍然是左值。

模板实例化

cpp-template-instantiation

一句话理解

模板实例化就是:模板本身只是“代码蓝图”,当你真正用 intfloatstd::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> 这样的具体类型使用时,会根据模板定义生成对应的具体版本。模板通常写在头文件里,是因为实例化时编译器必须看到完整定义。模板的好处是类型安全、复用性强、运行期无额外多态开销;缺点是可能导致编译变慢、错误信息复杂、实例化过多时造成代码膨胀。

静态库和动态库

cpp-static-vs-dynamic-library

一句话理解

静态库:链接时把库代码合进可执行文件。 动态库:可执行文件只记录依赖,运行时再加载库文件。

静态库是什么?

静态库本质是一堆 .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 兼容、符号导出等问题。实际项目里,基础稳定模块可以静态链接,常更新、可共享、插件化的模块更适合动态库。

编译链接流程

cpp-compile-link-flow

一句话理解

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++ 的构建流程通常分为预处理、编译、汇编和链接。预处理负责展开头文件、替换宏和处理条件编译;编译负责语法语义分析、优化并生成汇编代码;汇编把汇编代码转换成目标文件;链接器再把多个目标文件和静态库、动态库关联起来,解析符号、做重定位,最终生成可执行文件。编译错误发生在单个翻译单元内部,链接错误通常发生在多个目标文件合并时,比如声明存在但定义缺失。

多线程锁和死锁

cpp-lock-and-deadlock

一句话理解

锁是为了保护共享资源,避免多个线程同时修改同一份数据。 死锁是多个线程互相等对方释放锁,结果谁也继续不了。

锁是什么?

多线程同时访问共享数据时,可能出现数据竞争。

比如两个线程同时执行:

c
gold++;

看起来是一行,底层可能是:

c
读 gold ->1 -> 写回 gold

如果两个线程交叉执行,结果可能丢失一次修改。

所以要用锁保护临界区。临界区就是访问共享资源的那一段代码。

死锁是什么?

经典死锁场景:

线程 A:

c
先拿 lock1
再等 lock2

线程 B:

c
先拿 lock2
再等 lock1

A 手里拿着 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::lockstd::scoped_lock 一次性锁多把锁,并尽量用 RAII 管理锁的释放。

原子变量

cpp-atomic-variable

一句话理解

原子变量就是多线程里“不会被打断的变量操作”。 比如普通 counter++ 可能被拆成“读、加、写”三步,两个线程交叉执行就会丢数据;std::atomic<int> 可以保证这个自增操作不会互相覆盖。

为什么普通变量不安全?

counter++ 看起来是一行,但底层大概是:

c
读取 counter
1
写回 counter

如果线程 A 和线程 B 同时读到 0,它们都写回 1,最后结果就是 1,不是 2。这就是数据竞争。

原子变量适合什么?

适合简单共享状态:

  • 计数器
  • 是否退出的标志位
  • 简单状态切换
  • 引用计数
  • 无锁结构里的 CAS 操作

但如果你要同时保护多个变量的一致性,比如 hpmp 必须一起改,通常还是用 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++ 不是原子操作,可能被拆成读、加、写,多线程交叉执行会丢失更新;而 atomicfetch_addloadstorecompare_exchange 等操作可以保证单个变量操作的原子性。它适合计数器、标志位、简单状态等场景,但不适合维护多个变量之间的复杂不变量,这种情况应该用互斥锁。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0