📋 摘要

C++ 中三种智能指针的使用场景?

概述

C++11 引入智能指针的核心目的是用 RAII 机制自动管理动态内存,避免内存泄漏。三种智能指针各有分工:

智能指针所有权引用计数开销典型场景
unique_ptr独占无几乎为零默认首选
shared_ptr共享有(原子操作)中等多所有者共享对象
weak_ptr无(弱引用)不增加计数小解决循环引用

std::unique_ptr(独占所有权)

unique_ptr 是最轻量的智能指针,同一时间只有一个 unique_ptr 指向对象。它不支持拷贝,只支持移动。

C++
#include <memory>

// 创建(C++14 推荐 make_unique,C++11 需手动 new)
auto ptr = std::make_unique<int>(42);
auto arr = std::make_unique<int[]>(10);  // 数组版本

// 不支持拷贝
auto ptr2 = ptr;        // ❌ 编译错误

// 支持移动
auto ptr3 = std::move(ptr);  // ✅ 所有权转移到 ptr3,ptr 变为空

使用场景:

  • 对象只有一个所有者时(默认首选)
  • 替代裸 new/delete,自动管理资源
  • 函数返回值(避免资源泄漏)
  • 非堆资源管理(自定义删除器:文件句柄、互斥锁等)
C++
// 自定义删除器示例:管理文件句柄
FILE* open_file(const char* path) {
    return std::fopen(path, "r");
}

auto file = std::unique_ptr<FILE, decltype(&std::fclose)>(
    open_file("test.txt"), &std::fclose
);
// 作用域结束时自动调用 fclose
💡 unique_ptr 是默认首选

如果你不确定该用哪种智能指针,选 unique_ptr。它的开销几乎为零(只存储一个指针),并且可以通过 std::move 转移到 shared_ptr。

std::shared_ptr(共享所有权)

shared_ptr 允许多个指针共享同一个对象,内部使用引用计数来管理生命周期。只有当最后一个 shared_ptr 被销毁时,对象才被释放。

C++
#include <memory>

// 推荐用 make_shared(一次内存分配,性能更好)
auto ptr1 = std::make_shared<int>(42);
auto ptr2 = ptr1;       // ✅ 拷贝,引用计数 +1
{
    auto ptr3 = ptr2;   // ✅ 引用计数 = 3
}                       // ptr3 析构,引用计数 = 2
// ptr1, ptr2 析构后引用计数归零,对象才被释放

使用场景:

  • 多个所有者共享同一个对象
  • 需要通过复制/赋值传递所有权
  • 观察者模式、回调函数中保持对象存活
C++
// 引用计数查询
auto ptr = std::make_shared<std::string>("hello");
std::cout << ptr.use_count();  // 1
{
    auto ptr2 = ptr;
    std::cout << ptr.use_count();  // 2
}
std::cout << ptr.use_count();  // 1

shared_ptr 底层原理:控制块(Control Block)

shared_ptr 的核心设计是控制块(Control Block)。理解这个结构,面试时就能讲清楚它的内存模型。

Text
┌─────────────────────────────────────────────────┐
│                  控制块 (堆上)                    │
│                                                 │
│  强引用计数 (strong_count) ──── shared_ptr 引用它  │
│  弱引用计数 (weak_count)   ──── weak_ptr 引用它    │
│  删除器 (deleter)                                │
│  分配器 (allocator)                              │
│                                                 │
└─────────────────────────────────────────────────┘
         ▲                   ▲
         │                   │
         │ 控制块指针          │ 对象指针
    ┌────┴─────┐        ┌────┴─────┐
    │shared_ptr│        │  对象     │
    │   #1     │        │  (堆上)   │
    └──────────┘        └──────────┘
    ┌────┴─────┐
    │shared_ptr│
    │   #2     │
    └──────────┘
    ┌────┴─────┐
    │weak_ptr  │
    └──────────┘

控制块的结构:

成员作用
强引用计数记录当前有多少个 shared_ptr 指向该对象。归零时释放对象
弱引用计数记录有多少个 weak_ptr + shared_ptr 引用控制块。归零时才释放控制块本身
删除器自定义删除逻辑(默认 delete)
分配器内存分配策略

控制块的生命周期:

  1. 创建 shared_ptr 时,在堆上分配控制块 + 对象(如果用 make_shared,两者在一次分配中完成)
  2. 每个 shared_ptr 拷贝时,控制块的强引用计数 +1
  3. 每个 shared_ptr 析构时,强引用计数 -1;归零时调用删除器释放对象
  4. weak_ptr 不增加强引用计数,但增加弱引用计数
  5. 只有当强引用计数 和 弱引用计数都归零时,控制块才被释放
C++
// 控制块生命周期示意
auto sptr = std::make_shared<int>(42);
// 控制块: strong=1, weak=0

auto wptr = std::weak_ptr<int>(sptr);
// 控制块: strong=1, weak=1

auto sptr2 = sptr;
// 控制块: strong=2, weak=1

sptr2.reset();
// 控制块: strong=1, weak=1  (对象还在)

sptr.reset();
// 控制块: strong=0, weak=1  (对象已释放,但控制块还在!)
// 因为 weak_ptr 还在引用控制块,所以控制块不能销毁

wptr.reset();
// 控制块: strong=0, weak=0  (控制块释放)
ℹ️ 为什么要有两个引用计数?

因为 weak_ptr 不拥有对象,但需要访问控制块来判断对象是否还存活(lock() 操作)。如果只有一个引用计数,当最后一个 shared_ptr 销毁后控制块就被释放了,weak_ptr 将指向悬空内存。

为什么 make_shared 比 new 更高效?

C++
// make_shared:一次内存分配
auto ptr1 = std::make_shared<int>(42);
// 堆上布局:[控制块 + 对象] ← 一块连续内存

// new + shared_ptr:至少两次内存分配
auto ptr2 = std::shared_ptr<int>(new int(42));
// 第一次分配:[int 对象]
// 第二次分配:[控制块]

make_shared 把控制块和对象分配在同一块内存中:

  • 减少一次堆分配(性能提升)
  • 更好的局部性(CPU 缓存友好)
  • 保证异常安全(new A() 成功但 shared_ptr 构造失败时不会泄漏)

但注意:如果对象很大但很快被释放,控制块会因为 weak_ptr 的存在而占用内存(无法释放大块对象内存直到最后一个 weak_ptr 也销毁)。这种情况下用 std::allocate_shared 或手动 new 可能更合适。

⚠️ shared_ptr 的性能开销

shared_ptr 的引用计数是原子操作(atomic),因为多个线程可能同时修改计数。频繁拷贝/析构 shared_ptr 会有 CAS 操作开销。性能敏感场景下,考虑用 unique_ptr 来减少计数操作。

std::weak_ptr(弱引用)

weak_ptr 不拥有对象所有权,指向由 shared_ptr 管理的对象,不增加引用计数。它专门用于解决 shared_ptr 的循环引用问题。

C++
#include <memory>

auto sptr = std::make_shared<int>(42);
std::weak_ptr<int> wptr = sptr;  // weak_ptr 从 shared_ptr 创建

// 访问前必须先升级为 shared_ptr
if (auto locked = wptr.lock()) {  // lock() 返回 shared_ptr
    std::cout << *locked << std::endl;
} else {
    std::cout << "对象已被销毁" << std::endl;
}

解决循环引用

这是 weak_ptr 最重要的使用场景:

C++
// ❌ 循环引用:A 和 B 互相用 shared_ptr 持有对方,引用计数永远不为零
class B;
class A {
public:
    std::shared_ptr<B> b;
    ~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
    std::shared_ptr<A> a;
    ~B() { std::cout << "B destroyed" << std::endl; }
};

// ✅ 用 weak_ptr 打破循环
class B;
class A {
public:
    std::shared_ptr<B> b;
    ~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
    std::weak_ptr<A> a;  // 用 weak_ptr,不增加引用计数
    ~B() { std::cout << "B destroyed" << std::endl; }
};

使用场景:

  • 解决 shared_ptr 之间的循环引用(最常见)
  • 缓存场景:观察对象是否存在,不延长其生命周期
  • 父-子关系中,子持有父的弱引用(父用 shared_ptr 拥有子,子用 weak_ptr 观察父)

make_shared / make_unique vs new

C++
// 推荐:用 make 工厂函数
auto ptr = std::make_shared<int>(42);   // C++11
auto uptr = std::make_unique<int>(42);  // C++14

// 不推荐:手动 new
auto ptr2 = std::shared_ptr<int>(new int(42));
对比make_shared / make_uniquenew + 智能指针
内存分配一次分配(控制块 + 对象)至少两次分配
异常安全是new A() 成功但 shared_ptr 构造失败 → 泄漏
代码简洁✅冗长
💡 面试加分项

提到智能指针时,如果面试官追问性能,可以说 shared_ptr 的引用计数操作是原子操作(有 CAS 开销),在高性能场景中尽量用 unique_ptr。另外,make_shared 比 new 少一次内存分配,同时保证了异常安全。


C++ 中 vector 的原理?resize 和 reserve 的区别?size 和 capacity 的区别?

vector 底层原理

std::vector 是动态数组:在堆上分配一块连续内存,元素连续存储,支持 O(1) 随机访问。当元素数量超过容量时,自动扩容(重新分配更大内存,拷贝/移动旧元素)。

Text
vector 内部维护三个指针:
┌──────────────┬──────────────┬────────────────┐
│    start     │    finish    │ end_of_storage │
│  指向首元素  │  指向末尾+1  │  指向容量末尾   │
└──────────────┴──────────────┴────────────────┘
  size = finish - start
  capacity = end_of_storage - start

扩容机制:容量不够时,通常按倍数增长(如 2 倍或 1.5 倍),重新分配更大内存,把旧元素移动/拷贝过去,释放旧内存。因此扩容会导致迭代器失效。

C++
std::vector<int> v;
v.push_back(1);  // 可能触发扩容

size vs capacity

sizecapacity
含义当前元素个数已分配内存能容纳的元素个数
关系size ≤ capacitycapacity ≥ size
查询.size().capacity()
C++
std::vector<int> v(5);
v.size();       // 5(当前有 5 个元素)
v.capacity();   // 可能 >= 5(分配的内存容量,实现相关)

resize vs reserve

resize(n)reserve(n)
作用改变元素个数为 n预留容量为 n,元素个数不变
size变为 n不变
capacity可能变化(若 n > capacity 会扩容)至少变为 n
新元素会构造新元素(值初始化)不构造元素
C++
std::vector<int> v;

v.reserve(100);   // 预留 100 容量,size 仍是 0,无元素
v.size();         // 0
v.capacity();     // >= 100

v.resize(50);     // 改变 size 为 50,新元素值为 0
v.size();         // 50
v.capacity();     // >= 100(未变)

v.resize(200);    // size 变 200,超过 capacity 触发扩容

使用建议:

  • 已知元素数量 → 先 reserve 预留容量,避免反复扩容(提升性能)
  • 需要指定元素个数 → 用 resize
💡 面试加分项

回答时先讲 vector 是”堆上连续内存的动态数组”,再讲扩容机制(倍数增长、拷贝旧元素、迭代器失效),最后清晰区分 size(元素个数)vs capacity(容量) 和 resize(改元素个数、会构造)vs reserve(只预留容量、不构造)。能提到”reserve 避免反复扩容提升性能”加分。


C++ 中 deque 的原理?它内部是如何实现的?

deque 是什么

std::deque 是双端队列(double-ended queue),支持在头尾两端高效地插入/删除(O(1)),同时支持 O(1) 随机访问。

内部实现:分段连续

deque 的核心是分段连续(map + 多个缓冲区):由一块中控 map(指针数组)和多个固定大小的缓冲区组成。map 中的每个指针指向一个缓冲区,数据分散在这些缓冲区里,逻辑上连续。

Text
           map(指针数组)
        ┌────┬────┬────┬────┬────┐
        │ p0 │ p1 │ p2 │ p3 │ p4 │
        └─┬──┴─┬──┴─┬──┴─┬──┴─┬─┘
          │    │    │    │    │
          ▼    ▼    ▼    ▼    ▼
        ┌───┐┌───┐┌───┐┌───┐┌───┐
        │buf││buf││buf││buf││buf│  每个缓冲区固定大小(如 512 字节)
        └───┘└───┘└───┘└───┘└───┘
        首端                        尾端
  • 头端插入:若首缓冲区没满,直接插入;满了就分配新缓冲区,map 向前扩展
  • 尾端插入:同理向后扩展
  • 随机访问:先算出在第几个缓冲区(i / 缓冲区大小),再算缓冲区内的偏移(i % 缓冲区大小),两次寻址

与 vector 的对比

vectordeque
内存布局一块连续内存分段连续(多个缓冲区)
头端插入O(n)(需移动所有元素)O(1)
尾端插入O(1) 均摊O(1)
随机访问O(1),更快(连续)O(1),略慢(两次寻址)
扩容整体重新分配 + 拷贝新增缓冲区,不移动旧元素
迭代器简单指针更复杂(需知道缓冲区)
C++
#include <deque>

std::deque<int> d;
d.push_front(1);  // ✅ 头端插入,O(1)
d.push_back(2);   // ✅ 尾端插入,O(1)
d[0];             // ✅ 随机访问
💡 面试加分项

回答时重点讲清 “分段连续” 这个核心设计:map(指针数组)+ 多个固定大小缓冲区。头尾插入直接操作对应缓冲区(O(1)),不像 vector 头插要移动所有元素;随机访问是”先算缓冲区、再算偏移”的两次寻址。对比 vector 说明为什么 deque 适合头尾频繁操作。


C++ 中 map 和 unordered_map 的区别?分别在什么场景下使用?

核心区别

mapunordered_map
底层结构红黑树(平衡二叉搜索树)哈希表(桶 + 链表/树)
有序性有序(按键排序)无序
查找/插入/删除O(log n)平均 O(1),最坏 O(n)
键要求支持 < 比较即可需要哈希函数 + ==
内存占用较低较高(哈希表开销)
迭代器稳定,中序遍历有序顺序不定
C++
#include <map>
#include <unordered_map>

std::map<int, std::string> m;            // 有序,按 key 排序
std::unordered_map<int, std::string> um; // 无序,哈希

m[1] = "one"; m[3] = "three"; m[2] = "two";
for (auto& [k, v] : m) { /* 1, 2, 3 有序 */ }

um[1] = "one"; um[3] = "three"; um[2] = "two";
for (auto& [k, v] : um) { /* 顺序不定 */ }

使用场景

用 map(需要有序):

  • 需要按键有序遍历(如按时间、按字母序输出)
  • 需要范围查询(lower_bound/upper_bound,如”所有 >= x 的键”)
  • 对最坏情况性能有要求(不接受哈希退化成 O(n))

用 unordered_map(追求性能):

  • 只做查找/插入/删除,不关心顺序
  • 平均 O(1) 的快速查找,数据量大时性能优势明显
  • 键可以用哈希(自定义类型需提供哈希函数)
C++
// 需要范围查询 → map
auto it = m.lower_bound(100);   // 第一个 >= 100 的键

// 只查找 → unordered_map
auto found = um.find(100);      // 平均 O(1)
💡 面试加分项

回答时先讲底层结构差异(map 红黑树有序 O(log n) vs unordered_map 哈希无序均摊 O(1)),再讲场景选择——需要有序遍历/范围查询用 map,只做快速查找用 unordered_map。能补充”自定义类型做 unordered_map 键需提供哈希函数 + == 操作符”加分。


C++ 中 list 的使用场景?

list 是什么

std::list 是双向链表,元素在内存中不连续,每个节点存数据和前后指针。

特性

操作复杂度
头/尾插入删除O(1)
任意位置插入/删除(已知迭代器)O(1)
随机访问(下标)O(n)(需遍历)
元素查找O(n)
C++
#include <list>

std::list<int> l;
l.push_back(1);
l.push_front(2);
auto it = l.begin();
++it;
l.insert(it, 10);   // 已知位置插入 O(1)
l.erase(it);        // 删除 O(1)
// l[0];            ❌ 不支持随机访问

使用场景

  1. 频繁在中间插入/删除(尤其已知迭代器位置),且不关心随机访问
  2. 插入/删除不使其他迭代器失效(区别于 vector,vector 扩容会使迭代器失效)
  3. 需要在两端操作(但两端操作用 deque 也可以)
C++
// list 的优势场景:频繁中间插入,且迭代器稳定
std::list<int> l = {1, 2, 3, 4, 5};
auto it = l.begin();
++it; ++it;          // 指向 3
l.insert(it, 99);    // O(1),且其他迭代器不失效

何时不用 list

  • 需要随机访问 → 用 vector
  • 需要顺序遍历 + 尾部操作 → 用 vector(连续内存缓存友好,实际往往更快)
  • 头尾两端操作 → 用 deque
⚠️ list 的实际性能

list 虽然插入/删除理论 O(1),但节点分散在内存中,缓存局部性差,遍历慢。现代 C++ 中,除非确实频繁在中间插入删除,否则 vector 往往因为缓存友好而更快。

💡 面试加分项

回答时先讲 list 是双向链表(插入删除 O(1)、随机访问 O(n)),再讲使用场景(频繁中间插入删除、迭代器稳定不失效),最后点出缓存局部性差这个实际性能坑——很多场景下 vector 反而更快。


C++ 中为什么要使用 std::array?它有什么优点?

std::array 是 C++11 引入的固定大小数组容器,把 C 风格数组封装成标准库容器:大小在编译期确定,元素连续存储。

std::array vs C 风格数组 vs vector

特性C 风格数组std::arraystd::vector
大小固定固定(编译期)动态可变
内存位置栈(局部变量)栈(局部变量)堆
退化为指针会不会不会
.size()无(需 sizeof 计算)✅✅
标准库算法需指针✅✅
整体拷贝赋值❌✅✅
边界检查无at() 有at() 有

核心优点

  1. 不会退化为指针:C 数组作为函数参数会退化为指针丢失大小信息,std::array 保留数组语义
  2. 知道自己大小:.size() 编译期可知,可用于 for 循环、算法
  3. 零动态内存开销:局部 std::array 在栈上分配,性能等同原生数组
  4. 支持标准库接口:begin()/end()、std::sort、std::get、C++17 结构化绑定
  5. 值语义:可以整体拷贝、赋值、作为返回值
C++
#include <array>
#include <algorithm>

std::array<int, 5> arr = {1, 2, 3, 4, 5};   // 固定大小 5

arr.size();          // 5(编译期已知)
arr[2];              // 随机访问 O(1),不检查边界
arr.at(2);           // 带边界检查,越界抛 std::out_of_range

// 支持标准库算法
std::sort(arr.begin(), arr.end());

// C++17 结构化绑定(需要 <tuple> 的 get 接口)
auto [a, b, c, d, e] = arr;

// 不会退化为指针,需显式 .data()
int* p = arr.data();   // ✅ 显式拿裸指针
// int* p2 = arr;      // ❌ 编译错误(C 数组则会隐式退化)

// 值语义:整体拷贝
std::array<int, 5> arr2 = arr;   // ✅ 深拷贝每个元素
💡 面试加分项

回答时先讲 std::array 是”固定大小、栈上分配、封装 C 数组的容器”,再对比三点:不会退化为指针(保留大小)、支持标准库算法与结构化绑定、零堆分配开销。能提到”局部 std::array 在栈上、vector 在堆上”以及”at() 有边界检查、operator[] 无”加分。


请介绍 C++ 中 shared_ptr 的原理?shared_ptr 线程安全吗?

shared_ptr 的原理(引用计数 + 控制块)已在上面《C++ 中三种智能指针的使用场景?》章节详细讲解,这里聚焦本题的新增点:线程安全性。

线程安全性分两个层面

层面是否线程安全说明
引用计数(控制块内的 strong_count / weak_count)✅ 原子操作多个线程各自拷贝、析构不同的 shared_ptr 副本是安全的
shared_ptr 对象本身❌ 不是线程安全多个线程同时读写同一个 shared_ptr 对象(如同时 reset、赋值、拷贝)是数据竞争
  • 引用计数是原子的:控制块里的强/弱引用计数用 std::atomic 实现(CAS 操作)。因此多个线程同时拷贝同一个 shared_ptr 到各自的局部变量、或各自销毁自己的副本,引用计数的增减是原子的,不会计数错误。
  • 对象本身非原子:shared_ptr 对象内部有两个指针(指向对象 + 指向控制块),更新它们是两步操作,不是原子的。两个线程同时对一个 shared_ptr 变量读写会撕裂(torn read/write)。
C++
#include <memory>
#include <thread>
#include <mutex>

std::shared_ptr<int> g;   // 全局共享的 shared_ptr 对象
std::mutex mtx;

// ✅ 方案一:用 std::atomic_load / atomic_store 原子地读写
void reader_atomic() {
    std::shared_ptr<int> p = std::atomic_load(&g);   // 原子读,得到局部副本
    if (p) { /* 使用 p,p 保证对象存活 */ }
}
void writer_atomic() {
    std::shared_ptr<int> p = std::make_shared<int>(42);
    std::atomic_store(&g, p);   // 原子写
}

// ✅ 方案二:加锁保护同一个 shared_ptr 对象
void reader_lock() {
    std::lock_guard<std::mutex> lk(mtx);
    std::shared_ptr<int> p = g;   // 加锁后安全读
}

// ✅ 各线程持有自己的副本 → 引用计数原子增减,天然安全
void worker() {
    std::shared_ptr<int> local = std::make_shared<int>(1);
    auto another = local;      // 只动原子引用计数
    // ... 拷贝/析构都安全
}
⚠️ 常见误区

“shared_ptr 是线程安全的”这句话不完整。准确说法是:引用计数(控制块)是线程安全的,但 shared_ptr 对象本身不是。多线程共享同一个 shared_ptr 变量时必须加锁或使用 std::atomic_load/std::atomic_store。

💡 面试加分项

能分层回答”引用计数原子安全 vs 对象本身非原子”是本题关键。补充一句”控制块里用 atomic 实现计数,拷贝/析构有 CAS 开销”呼应性能话题,面试官会很认可。


C++ 中 shared_from_this 的作用是什么?它有什么优点?

std::enable_shared_from_this<T> 是标准库提供的基类,让类在其成员函数内安全地获得一个指向自身 this 的 shared_ptr,即调用 shared_from_this()。

要解决的问题:从 this 裸指针构造 shared_ptr 会双重释放

如果直接用 std::shared_ptr<T>(this) 从裸 this 指针构造,会为同一对象新建一个控制块,导致对象同时被两个独立的引用计数管理,最终双重释放(double free):

C++
#include <memory>

// ❌ 错误:从裸 this 构造 shared_ptr → 两个控制块 → double free
class Bad {
public:
    std::shared_ptr<Bad> get_shared() {
        return std::shared_ptr<Bad>(this);   // 新建控制块,与外部已有的 shared_ptr 不共享
    }
};

// ✅ 正确:继承 enable_shared_from_this,用 shared_from_this()
class Good : public std::enable_shared_from_this<Good> {
public:
    std::shared_ptr<Good> get_shared() {
        return shared_from_this();   // 返回共享同一个控制块的 shared_ptr
    }
};
C++
auto w = std::make_shared<Good>();
auto w2 = w->get_shared();   // w2 与 w 共享同一控制块,use_count == 2
std::cout << w.use_count();  // 2

优点

优点说明
避免双重释放返回的 shared_ptr 与已存在的 shared_ptr 共享同一个控制块
引用计数一致use_count() 正确反映真实持有者数量
安全获取所有权成员函数/回调里安全地把 this 交出去,延长对象生命周期
异常安全不会因为多套引用计数导致提前/重复销毁
⚠️ 使用前提

shared_from_this() 只能在已经被 shared_ptr 管理的对象上调用;否则行为未定义(通常抛出 std::bad_weak_ptr)。也就是先 std::make_shared<Good>(),再在成员函数里调 shared_from_this()。

💡 面试加分项

回答要点:shared_from_this() 返回与已有 shared_ptr 共享同一控制块的指针,避免从裸 this 构造出第二个控制块导致 double free。能补充”前提是对象已被 shared_ptr 管理,否则抛 bad_weak_ptr”加分。


C++ 的 std::string 内部使用的是堆内存还是栈内存?

答案取决于字符串长度:短字符串用 SSO(Small String Optimization,小字符串优化)存在 std::string 对象内部,长字符串才在堆上分配。

SSO 原理

std::string 对象内部预留了一块小缓冲区(各实现不同,如 libstdc++ 约 15 字节、libc++/MSVC 更大)。当字符串长度不超过该缓冲区时,字符直接存进对象内部,不做任何堆分配;只有超过容量才在堆上 new 一块内存。

情况存储位置是否堆分配
短字符串(≤ SSO 阈值)string 对象内部(对象在栈上则存栈)否
长字符串(> SSO 阈值)堆上动态分配是
C++
#include <string>
#include <iostream>

std::string s1 = "hi";           // 短字符串 → SSO,存在对象内部,无堆分配
std::string s2(1000, 'a');       // 长字符串 → 堆分配

std::cout << s1.capacity();      // 如 15(实现相关,SSO 缓冲区大小)
std::cout << s2.capacity();      // >= 1000

// 小字符串的数据在对象内部,长字符串的数据在堆上
std::cout << static_cast<const void*>(s1.data());  // 指向对象内部
std::cout << static_cast<const void*>(s2.data());  // 指向堆
ℹ️ SSO 的好处

大部分实际字符串都很短,SSO 让它们零堆分配,显著减少 new/delete 开销、提升缓存局部性。这也是为什么现代 C++ 中按值传 std::string 不再像想象中那么贵。注意:SSO 是实现约定而非标准强制,但 C++11 起主流实现都支持,且 string 的连续存储(.data() 返回连续缓冲区)是标准保证的。

💡 面试加分项

先说”短字符串用 SSO 存在对象内部(栈),长字符串在堆”,再解释 SSO 阈值因实现而异(约 15 字节)、好处是避免小字符串的堆分配。能区分”标准不强制 SSO 但实现普遍支持”是加分项。


C++ 中 vector 的 push_back 和 emplace_back 有什么区别?

两者都在 vector 末尾追加元素,区别在于构造方式:

push_backemplace_back
参数接受已构造好的对象(或可隐式转换的对象)接受构造参数
构造方式先构造对象,再拷贝/移动进 vector原地构造(完美转发参数)
临时对象可能有(拷贝/移动)无
性能多一次拷贝/移动更优(少一次拷贝/移动)
C++
#include <vector>
#include <string>

struct Point {
    int x, y;
    Point(int a, int b) : x(a), y(b) {}
};

std::vector<Point> v;
v.push_back(Point(1, 2));   // 先构造临时 Point(1,2),再移动进 vector
v.emplace_back(1, 2);       // 直接用 (1,2) 在 vector 末尾原地构造,无临时对象

// 复杂/昂贵类型差距更明显
std::vector<std::string> vs;
vs.push_back(std::string(10, 'a'));  // 先构造临时 string,再移动
vs.emplace_back(10, 'a');            // 直接原地构造 string(10,'a')

emplace_back 通过完美转发(std::forward + 可变参数模板)把参数原样转给元素的构造函数,直接在 vector 已分配的内存位置上构造,省去一次临时对象的拷贝/移动。

⚠️ emplace_back 的两个注意点
  1. 参数不能是 vector 自身的元素引用:v.emplace_back(v[0]) 这类写法在扩容时可能因元素被搬移而悬空,需谨慎。
  2. 不要依赖它代替拷贝语义:当传入的就是已构造好的对象时,两者等价;emplace_back 的优势仅在”传构造参数”时体现。
💡 面试加分项

核心区别一句话:push_back 传对象(拷贝/移动),emplace_back 传构造参数(原地构造、省一次拷贝移动)。补充”emplace_back 基于完美转发”以及”传入现成对象时两者等价”加分。


C++ 的迭代器和指针有什么区别?

指针是 C++ 内建类型,本质是内存地址;迭代器是抽象出来的概念,是”泛化的指针”,用统一接口屏蔽不同容器的底层实现差异。

核心区别

特性指针迭代器
本质内建类型,内存地址类/抽象,泛化的”指针”
指向连续内存(数组、单个对象)任意容器(连续或不连续)
操作接口*p、p->、++、+n、[]统一接口 *it、it->、++(依类别而定)
类型T*容器::iterator(如 vector<int>::iterator)
抽象程度依赖具体内存布局屏蔽实现差异,可移植

实际上,指针本身是随机访问迭代器的一种:T* 满足随机访问迭代器的全部要求,因此数组的指针可以直接用于 std::sort 等算法。std::vector 的迭代器在许多实现里就是 T* 的别名;而 std::list 的迭代器则是包装了节点指针的类对象。

C++
#include <vector>
#include <list>
#include <algorithm>

// 指针:指向连续内存的数组
int arr[] = {1, 2, 3};
int* p = arr;
*(p + 1);            // 2(指针支持 +n 随机访问)

// vector 迭代器:连续内存,随机访问(常实现为指针)
std::vector<int> v = {1, 2, 3};
auto vit = v.begin();
*(vit + 1);          // 2,随机访问迭代器支持 +n

// list 迭代器:非连续内存,只能 ++/--,不支持 +n
std::list<int> l = {1, 2, 3};
auto lit = l.begin();
++lit;               // ✅ 双向迭代器支持 ++
// *(lit + 1);      // ❌ list 迭代器不支持随机访问

// 指针可作为迭代器直接用于算法
std::sort(arr, arr + 3);   // 数组指针满足随机访问迭代器要求

迭代器的价值

迭代器把”访问元素”这一操作从”容器如何存储”中解耦出来:算法(如 sort、find)只依赖迭代器接口,而不用关心容器是连续数组、链表还是树。这样同一套算法可作用于多种容器。

💡 面试加分项

先说”指针是内存地址、迭代器是泛化的指针”,再点出”指针本身是随机访问迭代器的一种(vector 迭代器常就是指针)“,最后讲迭代器的价值是统一接口、解耦算法与容器。能举例”list 迭代器不能 +n、只能 ++“说明类别差异加分。


请介绍下 std::sort 的原理?底层是快速排序吗?

std::sort 底层不单纯是快速排序,而是内省排序(Introsort)——一种以快速排序为主、堆排序和插入排序为辅的混合算法,保证平均和最坏复杂度都是 O(n log n)。

三个组成部分

排序在 introsort 中的角色
快速排序主力:分区递归,平均 O(n log n)
堆排序兜底:递归深度超过阈值(约 2·log₂n)时切换,保证最坏 O(n log n)
插入排序小区间(如 < 16 个元素)直接插入排序,常数小、缓存友好

纯快速排序在极端情况(如已有序 + 固定选 pivot)会退化到最坏 O(n²)。introsort 通过”递归过深就换堆排序”彻底避免这个最坏情况,同时保留快排的平均高效和插入排序的小区间优势。

C++
#include <algorithm>
#include <vector>
#include <functional>

std::vector<int> v = {5, 3, 8, 1, 9, 2};

std::sort(v.begin(), v.end());                       // 默认升序,introsort
std::sort(v.begin(), v.end(), std::greater<int>());  // 自定义比较器

// introsort 的思路(示意):
// 1. 区间较小时 → 插入排序
// 2. 递归深度 > 2*log2(n) → 堆排序
// 3. 否则 → 快速排序(三数取中选 pivot)

相关对比:sort vs stable_sort

std::sortstd::stable_sort
底层introsort(快排为主)归并排序
稳定性不稳定稳定
复杂度O(n log n)O(n log n),可能需额外 O(n) 内存
要求随机访问迭代器随机访问迭代器
💡 面试加分项

关键点是”不是纯快排,是 introsort(快排 + 堆排兜底 + 插入排序优化小区间)“,这样能保证最坏 O(n log n)、避免快排退化到 O(n²)。补充”std::sort 不稳定、要求随机访问迭代器;std::stable_sort 用归并、稳定但可能多占内存”加分。


std::optional、std::variant、std::any 的区别与使用场景?

C++17 引入了三种”值容器”类型,用于表达和存储不确定的 / 多种可能的 / 任意类型的值。三者语义不同,不能混用。

类型语义能存什么内存占用访问方式
std::optional<T>可空单值:要么有 T,要么没有一个 T 或空(nullopt)sizeof(T) + 对齐 + 一个 bool*opt、opt.value()、opt.value_or()
std::variant<Ts...>类型安全 union:某一时刻是其中一种类型多个类型中的恰好一个最大成员类型 + 索引(tag)std::get<T>、std::visit
std::any任意类型:类型擦除任意可拷贝构造的类型堆分配(小对象可能 SSO)std::any_cast<T>(失败抛 bad_any_cast)

std::optional:表达”可能没有值”

典型场景是”函数可能没有返回值”,替代传统的”哨兵值 + 空指针”写法,显式表达”无值”这个状态,避免魔法数字(如用 -1 表示不存在)。

C++
#include <optional>
#include <vector>
#include <iostream>

// 查找:找不到时返回空 optional,而不是返回 -1 或空指针
std::optional<int> find_even(const std::vector<int>& v) {
    for (int x : v) {
        if (x % 2 == 0) return x;   // 隐式构造 optional<int>
    }
    return std::nullopt;            // 显式"无值"
}

auto r = find_even({1, 3, 5});
if (r) {                            // 判断是否有值
    std::cout << *r << std::endl;   // 解引用取值
}
std::cout << r.value_or(-1) << std::endl;   // 无值时给默认值 -1
💡 面试加分项

optional 解决”函数可能无返回值”的语义表达,比返回 -1/nullptr/std::pair<bool,T> 更清晰安全。注意 value() 在无值时会抛 std::bad_optional_access,value_or() 给默认值更安全。

std::variant:类型安全的 union

variant 同一时刻只持有列出的多个类型中的一个,用**索引(index)**记录当前是哪种类型。它替代了传统 union,杜绝了访问错误成员的未定义行为。

C++
#include <variant>
#include <string>
#include <iostream>
#include <type_traits>

std::variant<int, std::string> v;

v = 42;                                  // 现在是 int
std::cout << std::get<int>(v);           // 42
std::cout << v.index();                  // 0(当前是第 0 个类型 int)

v = std::string("hello");                // 现在是 string
std::cout << std::get<1>(v);             // 按索引取值:hello
// std::get<int>(v);                     // ❌ 当前是 string,抛 std::bad_variant_access

// std::visit:统一处理所有可能类型
std::visit([](auto&& arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>)
        std::cout << "int: " << arg;
    else
        std::cout << "string: " << arg;
}, v);

典型场景: 解析器返回多种结果、AST 节点值、消息/事件的多态载荷——需要在编译期已知的有限类型集合里二选一/多选一,且希望类型安全。

⚠️ variant 的内存与限制

variant 内存大小 ≈ 最大成员类型 + 用于存索引的 tag(通常是 1~8 字节对齐),没有堆分配。它的类型集合在编译期固定,不能运行时动态增删;若需表达”空”状态或类型重复,可用 std::monostate 占位。

std::any:任意类型的类型擦除

any 可以存任意可拷贝构造的对象,类型在运行时可变化,通过类型擦除实现。它适合”确实无法在编译期确定类型”的场景。

C++
#include <any>
#include <string>
#include <vector>
#include <iostream>

std::any a;

a = 42;                            // 存 int
a = std::string("hello");          // 换成 string,类型可任意变化
a = std::vector<int>{1, 2, 3};     // 换成 vector

std::cout << std::any_cast<std::string>(a).size();  // 需要知道当前类型才能取值
// std::any_cast<int>(a);          // ❌ 当前不是 int,抛 std::bad_any_cast

典型场景: 消息队列/插件系统里传递异构数据、配置项的通用容器、需要把不同类型塞进同一个容器(如 std::vector<std::any>)。

⚠️ any 有堆分配与类型检查开销

any 通常把对象放堆上(大对象必堆分配),取值必须 any_cast 到精确的原始类型(不会自动转换),类型不匹配抛 std::bad_any_cast。能用 variant(类型集合有限已知)就别用 any——variant 无堆分配且 visit 能编译期穷举类型。

三者选择原则

需求选择
值可能没有std::optional<T>
多个已知类型选一个std::variant<Ts...>
类型运行时任意/未知std::any
💡 面试加分项

一句话总结:optional = 可空单值,variant = 类型安全 union(编译期已知集合、无堆分配),any = 任意类型(类型擦除、通常堆分配)。能说出”variant 用 index 记录当前类型、any_cast 类型不匹配抛 bad_any_cast、优先用 optional/variant 而非 any”是加分项。


std::string_view 的原理?使用时有什么坑?

std::string_view(C++17)是只读的、非拥有的字符串视图:它不存储字符串内容,只存一个指向字符串数据的指针 + 一个长度。因为不拷贝数据,构造和传递都是零拷贝、零堆分配。

原理:指针 + 长度

string_view 内部只有两个成员:指向字符数据的 const char* 指针和长度 size_t,典型大小就是 16 字节(8 字节指针 + 8 字节长度)。它不拥有底层数据,也不负责其生命周期,只是一个”窗口”。

C++
#include <string_view>
#include <string>
#include <iostream>

std::string s = "hello world";

std::string_view sv = s;            // 只存指针+长度,不拷贝字符串
std::cout << sv;                    // hello world
std::cout << sv.size();             // 11

// 零拷贝切片:substr 只移动指针、改长度,不新建字符串
std::string_view sub = sv.substr(0, 5);   // "hello",O(1),无分配
std::cout << sub;                   // hello

// 直接指向字符串字面量,也无拷贝
std::string_view lit = "hello";     // 不分配、不拷贝

相比 std::string,string_view 适合做函数参数:调用方无论是传 std::string、const char* 还是字面量,都不发生拷贝。

C++
// 用 string_view 做参数:接受 string / const char* / 字面量,均零拷贝
void print(std::string_view s) {
    std::cout << s;
}
print(std::string("abc"));   // 隐式转 string_view,不拷贝
print("abc");                // 直接是 string_view

三大坑

坑说明
悬垂引用string_view 不拥有数据,指向的底层对象被销毁后它就悬空,访问是未定义行为
不保证 \0 结尾string_view 不以 \0 结尾;substr 得到的子串尤其不是 null 结尾
不能传给 C 接口需要 const char* 的 C 风格接口(如 printf)不能直接传 string_view,需 .data() 并保证 \0
C++
#include <string_view>
#include <string>
#include <cstdio>

// ❌ 坑一:悬垂引用——view 指向临时 string,临时对象析构后 view 悬空
std::string_view make_view() {
    std::string s = "temporary";
    return s;             // 隐式转 string_view,但 s 在返回后析构 → 悬垂!
}

// ❌ 坑二/三:string_view 不保证 \0,不能直接给 printf
std::string s = "hello world";
std::string_view sv = s;
std::string_view sub = sv.substr(0, 5);   // "hello",但 sub 后面不是 \0
// printf("%s", sub);          // ❌ 不能隐式转 const char*,且不保证 \0 结尾
printf("%.*s", (int)sub.size(), sub.data());  // ✅ 显式用长度打印
⚠️ 悬垂引用是最大的坑

string_view 只”借用”数据,不延长底层对象的生命周期。绝不要从一个临时 std::string 返回 string_view,也别在底层 string 被修改/销毁后继续用 view。把 string_view 当”只读视图”,永远保证它指向的数据在 view 使用期间一直存活。

💡 面试加分项

先讲本质:string_view = 指针 + 长度、非拥有、零拷贝,做函数参数能统一接收 string/const char*/字面量且不拷贝。再讲三个坑:悬垂引用、不保证 \0 结尾、不能直接传 C 接口(printf 需 .data() + 长度)。能补充”substr 是 O(1) 零分配”加分。


C++ 中 set 和 multiset 的原理和使用场景?

底层原理

std::set 和 std::multiset 底层都是红黑树(一种平衡二叉搜索树),元素有序存储。区别在于键的唯一性:

容器底层结构有序性键唯一性查找/插入/删除
std::set红黑树有序唯一(不可重复)O(log n)
std::multiset红黑树有序可重复O(log n)

红黑树保证树始终接近平衡,因此查找、插入、删除都是 O(log n),且中序遍历天然得到有序序列。

C++
#include <set>
#include <iostream>

std::set<int> s;
s.insert(3);
s.insert(1);
s.insert(3);          // 重复插入无效,set 只保留一个 3
for (int x : s) { /* 输出 1, 3,有序且去重 */ }

std::multiset<int> ms;
ms.insert(3);
ms.insert(1);
ms.insert(3);          // 允许重复,ms 里有两个 3
for (int x : ms) { /* 输出 1, 3, 3,有序但可重复 */ }

// multiset 统计与查找
ms.count(3);                       // 2(出现次数)
auto range = ms.equal_range(3);    // 返回 [first, last),覆盖所有等于 3 的元素

使用场景

用 set:

  • 需要去重 + 有序(如”统计出现过的不同数字并排序”)
  • 需要快速判断是否存在(find O(log n))
  • 需要范围查询(lower_bound/upper_bound)

用 multiset:

  • 需要有序 + 允许重复(如”维护一个可重复的有序序列/排行榜”)
  • 需要快速统计某元素出现次数(count)
C++
// 场景一:去重排序
std::set<int> unique_sorted = {5, 1, 5, 3, 1};
// 遍历得到 1, 3, 5(自动去重 + 升序)

// 场景二:允许重复的有序序列
std::multiset<int> ms2 = {100, 95, 100, 88};
ms2.erase(ms2.find(100));   // 只删除一个 100
// ms2.erase(100);          // 注意:会删除所有 100
⚠️ multiset 的 erase 陷阱

ms.erase(value) 会删除所有等于 value 的元素;只想删一个时用 ms.erase(ms.find(value))(find 返回一个迭代器,只删这一个)。

💡 面试加分项

先讲”两者底层都是红黑树、有序、O(log n)“,再点出唯一区别是键是否可重复(set 去重、multiset 允许重复)。能补充”multiset 用 count 统计出现次数、用 equal_range 拿区间、erase(value) 会删光而 erase(find(value)) 只删一个”加分。


C++ 中各种容器的迭代器失效场景总结?

迭代器失效是指:容器结构发生改变后,之前保存的迭代器不再指向有效元素,继续使用是未定义行为。核心规律:连续内存容器(vector/deque/string)扩容或中间插入删除会导致失效;节点式容器(list/map/set)只有被删除的那个迭代器失效。

总结表

容器失效操作是否失效说明
vectorpush_back 触发扩容全部失效重新分配内存,旧地址作废
vector中间插入/删除失效点之后全部失效后续元素整体搬移
vectorerase/insert用返回值继续erase 返回被删元素的下一个
deque两端插入(push_front/back)迭代器失效,引用不失效map 可能重分配
deque中间插入/删除全部失效元素搬移
list任意插入/删除其他迭代器不失效只被删的迭代器失效
map/set插入不失效树结构,节点不搬移
map/set删除仅被删的迭代器失效其他仍有效
unordered_map/unordered_set插入触发 rehash全部失效桶重新分布
unordered_*删除仅被删的迭代器失效rehash 除外
C++
#include <vector>
#include <list>
#include <set>
#include <unordered_map>

// ❌ vector:push_back 触发扩容后,旧迭代器失效
std::vector<int> v = {1, 2, 3};
auto it = v.begin();
v.push_back(4);          // 可能扩容 → it 失效
// *it;                  // ❌ 未定义行为

// ❌ vector:中间删除,删除点之后全部失效
auto it2 = v.begin() + 2;
v.erase(it2);            // it2 及其之后的迭代器失效
// 正确做法:用 erase 的返回值继续遍历
for (auto it = v.begin(); it != v.end(); ) {
    if (*it % 2 == 0) it = v.erase(it);   // erase 返回下一个有效迭代器
    else ++it;
}

// ✅ list:插入删除不影响其他迭代器
std::list<int> l = {1, 2, 3};
auto lit = l.begin();
++lit;                   // 指向 2
l.erase(lit);            // 只 lit 失效,其他仍有效

// ✅ set:删除只使被删迭代器失效
std::set<int> s = {1, 2, 3};
auto sit = s.find(2);
s.erase(sit);            // 只 sit 失效

// ❌ unordered_map:rehash 导致全部失效
std::unordered_map<int, int> um;
um.reserve(100);         // 提前预留,避免后续 rehash 失效

核心规律

  1. 连续存储(vector):扩容 → 全部失效;中间插入删除 → 之后全部失效。用 reserve 预留容量可避免扩容失效。
  2. 分段连续(deque):两端操作迭代器失效但引用/指针不失效;中间操作全部失效。
  3. 节点式(list/map/set):只有被删除节点的迭代器失效,插入不影响任何迭代器。
  4. 哈希容器(unordered_*):rehash 全部失效,删除只失效被删者。提前 reserve 可避免 rehash。
⚠️ 已失效迭代器绝不能继续使用

失效后的迭代器(尤其 vector 扩容后的旧迭代器)继续解引用是未定义行为,可能读到垃圾数据甚至崩溃。养成习惯:erase 后用返回值更新迭代器,或用 reserve 提前保证容量。

💡 面试加分项

按”连续/分段连续/节点式/哈希”四类归纳记忆:vector 扩容全失效、中间删除后半失效;deque 两端引用不失效、中间全失效;list/map/set 只删谁谁失效;哈希 rehash 全失效。补充”用 reserve 避免 vector 扩容和 rehash 失效、erase 用返回值继续遍历”是加分项。


STL 空间配置器 allocator 的作用?它如何管理内存?

作用

allocator(空间配置器)是 STL 容器的内存管理组件,负责容器的内存分配与释放。它的核心设计是把内存分配/释放与对象构造/析构分离:

职责说明
allocate(n) / deallocate(p, n)只负责原始内存的分配与释放(不构造对象)
construct(p, args...) / destroy(p)在已分配内存上构造/析构对象

这样容器可以”先申请一块裸内存,再逐个构造元素”,避免为每个元素都单独 new(new 会同时做分配 + 构造,无法批量控制)。

C++
#include <memory>
#include <new>
#include <utility>

// 自定义 allocator 的核心接口示意
template <class T>
struct MyAllocator {
    using value_type = T;

    // 分配 n 个 T 的裸内存(不构造对象)
    T* allocate(std::size_t n) {
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    // 释放裸内存(不析构对象)
    void deallocate(T* p, std::size_t) {
        ::operator delete(p);
    }
    // 在 p 处原地构造对象
    template <class U, class... Args>
    void construct(U* p, Args&&... args) {
        ::new (static_cast<void*>(p)) U(std::forward<Args>(args)...);
    }
    // 析构对象(不释放内存)
    template <class U>
    void destroy(U* p) {
        p->~U();
    }
};

经典实现:SGI STL 二级配置器(内存池)

历史上最经典的 allocator 实现是 SGI STL 的二级配置器(std::alloc),它用**内存池 + 自由链表(free list)**来减少小额内存分配的碎片与 malloc 开销:

级别处理范围策略
一级配置器大块内存(> 128 字节)直接用 malloc/free
二级配置器小块内存(≤ 128 字节)内存池 + 16 条自由链表(8/16/24…128 字节)

二级配置器把小块内存按 8 字节对齐划分成 16 种尺寸,每种尺寸维护一条自由链表。分配时若链表有可用块就直接取下,否则向内存池申请;释放时把块挂回链表复用,避免频繁 malloc 和内存碎片。

C++
// 二级配置器思想示意(简化)
// 16 条自由链表:8, 16, 24, ..., 128 字节
// 分配 30 字节 → 向上取整到 32 字节档 → 从 32 字节链表取一块
// 释放 30 字节 → 挂回 32 字节链表,供后续复用
ℹ️ 为什么分离”分配/释放”与”构造/析构”?

因为 new T() 做了两件事:operator new 分配内存 + 调用构造函数。STL 容器(如 vector 的 reserve)往往需要只分配内存、不构造元素(等真正插入时才构造),所以必须把这两步拆开,让容器精确控制对象生命周期,减少不必要的构造开销。

⚠️ allocator 在现代 C++ 中的地位

现代标准库(libstdc++/libc++)的 std::allocator 通常已简化为对 operator new/delete 的封装,不再用 SGI 那套内存池(部分实现仍保留)。但**“内存分配与对象构造分离”的设计思想以及内存池减少小对象碎片**的思路,仍是面试考察重点。

💡 面试加分项

先说清 allocator 的作用是把”内存分配/释放”与”对象构造/析构”分离(allocate/deallocate vs construct/destroy),再讲 SGI STL 的二级配置器:>128 字节用 malloc,≤128 字节用内存池 + 16 条自由链表减少碎片。能解释”vector reserve 只分配内存不构造对象,正是依赖这个分离”加分。