CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
C++ 语言入门/课时/内存与 RAII
RawBlame
课时目录12 节

C++ 语言入门

  1. 01C++ 是什么
  2. 02基础语法
  3. 03指针与引用
  4. 04面向对象
  5. 05内存与 RAII
  6. 06STL 容器与算法
  7. 07模板基础
  8. 08现代 C++ 特性
  9. 09多线程
  10. 10构建与依赖
  11. 11调试与测试
  12. 12综合练习

内存与 RAII

new/delete、smart pointers、rule of three/five、RAII。

35 分钟

内存与智能指针

这一课你将能做到

  1. 区分栈、堆、静态区三块内存,并解释为什么 new/delete 手动配对迟早出错
  2. 用 unique_ptr 与 shared_ptr 托管堆对象,说清各自的所有权模型与适用场景
  3. 解释移动语义:为什么 std::move 之后原对象「被掏空」,以及 vector 扩容如何靠移动省去复制

为什么要学

Java 程序员从入职第一天就被 GC 托底:忘了置 null?没关系。C++ 没有 GC,new 出来的内存必须有人负责 delete——忘了就泄漏,删两次就崩,删了还用就是野指针。判题沙箱一天评测上千份提交,每份都要分配用户代码的输入缓冲、输出缓冲,任何一处泄漏累积起来就是内存耗尽、全站评测卡死。

现代 C++ 的答案是:不裸写 new/delete,用智能指针把「释放时机」编码进对象所有权里。这本质上是用 RAII(上一课)管理内存本身。学完这课你会明白:C++ 没有 GC,但有比 GC 更早、更确定性的资源回收方式——代价是你必须想清楚「这块内存归谁管」。

先建立图像

一个进程的内存布局:

高地址 ┌──────────────┐
       │   栈 Stack   │ ← 局部变量、函数参数;自动分配自动释放,MB 级
       │      ↓       │
       │   (空闲)     │
       │      ↑       │
       │   堆 Heap    │ ← new/delete 的地盘;手动管理,GB 级
       ├──────────────┤
       │  静态/全局区 │ ← 全局变量、static;程序全程存活
       │    代码区    │ ← 机器指令
低地址 └──────────────┘

Java 对比:JVM 把堆再切成新生代/老年代跑 GC;
C++ 的堆就是一块裸内存池,谁来 new 谁负责——或者交给智能指针。

跟着做

步骤 1:感受 new/delete 的脆弱

新建 memory.cpp,先看裸写法的三个死法:

#include <iostream>

void caseA_leak() {                    // 死法一:泄漏
    int* p = new int(42);
    // 函数结束 p 消失,堆上那 4 字节再也没人能释放
    delete p;                          // 补上这行才对;真实工程里常在分支/异常路径漏掉
}

void caseA_leak2() {
    int* p = new int(42);
    delete p;
    // delete p;        // 死法二:双重释放 → 立即崩溃或堆损坏
    // p 是野指针,又指向已释放内存。delete 后应 p = nullptr;
}

int* factory() {                       // 死法三:所有权不明
    return new int(7);                 // 调用方必须记得 delete,谁来删?文档说了不算
}

int main() {
    int* q = factory();
    std::cout << *q << "\n";
    delete q;                          // 忘了这行 → 泄漏;删两次 → 崩溃
    return 0;
}

编译能过、逻辑看着也对——内存 bug 的阴险就在「平时正常,压测爆炸」。

步骤 2:unique_ptr——独占所有权

#include <iostream>
#include <memory>

struct Report {
    int score;
    explicit Report(int s) : score(s) { std::cout << "Report 构造\n"; }
    ~Report() { std::cout << "Report 析构\n"; }
};

void process(Report* raw) { std::cout << "处理: " << raw->score << "\n"; }

int main() {
    // 创建:make_unique 优先(C++14),比直接 new 更安全
    std::unique_ptr<Report> r = std::make_unique<Report>(95);

    // 用起来像指针
    std::cout << r->score << "\n";
    process(r.get());          // get():借出裸指针给只用的函数,不转移所有权

    // r2 = r;                 // 编译错误!unique_ptr 禁止拷贝:独占就是独占
    std::unique_ptr<Report> r2 = std::move(r);   // 转移所有权:r 变为空
    if (!r) std::cout << "r 已交出所有权\n";

    return 0;
}   // r2 离开作用域 → 自动 delete → 打印「Report 析构」

输出:

Report 构造
95
处理: 95
r 已交出所有权
Report 析构

Java 对照:unique_ptr 之于裸指针,就像把「必须记得 close()」换成 try-with-resources——释放时机由语言机制保证,而且早于 GC,确定性更强。

步骤 3:shared_ptr——共享所有权

多个对象都要引用同一个评测报告、且谁也不能确定「自己是不是最后一个用的人」时:

#include <iostream>
#include <memory>

int main() {
    auto a = std::make_shared<int>(42);
    std::cout << "引用计数: " << a.use_count() << "\n";   // 1

    auto b = a;                            // 拷贝:计数 +1
    std::cout << "引用计数: " << a.use_count() << "\n";   // 2

    b.reset();                             // b 放手:计数 -1
    std::cout << "引用计数: " << a.use_count() << "\n";   // 1

    return 0;
}   // a 离开作用域,计数归 0 → 对象被销毁

引用计数不是免费的:每次拷贝/析构都是原子操作,比 unique_ptr 慢。工程默认 unique_ptr,确有共享需求才升级 shared_ptr。

循环引用是 shared_ptr 的经典陷阱:

struct Node {
    std::shared_ptr<Node> next;    // 两个节点互指 → 计数永远 ≥1 → 泄漏
    // std::weak_ptr<Node> next;   // 解法:一方用弱引用,不增加计数
};

步骤 4:移动语义——把「复制」换成「搬运」

#include <iostream>
#include <string>
#include <utility>

std::string makeSource() {
    std::string s = "这是一段可能非常长的评测输出……";
    return s;                 // 返回值优化(RVO):直接在调用方的内存上构造,零拷贝
}

int main() {
    std::string output = makeSource();

    std::string output2 = std::move(output);   // 移动构造:把 output 的内部缓冲区「过户」
    std::cout << "output2: " << output2 << "\n";
    // output 现在处于「有效但未定义」状态:能赋新值、能析构,别再读它的内容
    std::cout << "output 长度: " << output.length() << "\n";   // 通常为 0
    return 0;
}

移动 = 转移内部资源的所有权(偷指针),复制 = 深拷贝全部内容。对持大缓冲区的对象,移动是 O(1),复制是 O(n)。std::move 本身不移动任何东西,只是把表达式标记为「可被移动」(绑定到右值引用 T&&),真正干活的是移动构造/移动赋值函数。

步骤 5:vector 扩容里的移动语义

为什么 vector 能又快又安全?扩容时靠移动:

#include <iostream>
#include <string>
#include <vector>

class Log {
public:
    explicit Log(std::string s) : data_(std::move(s)) {}
    Log(const Log& o) : data_(o.data_) { std::cout << "复制\n"; }      // 拷贝构造
    Log(Log&& o) noexcept : data_(std::move(o.data_)) { std::cout << "移动\n"; }
private:
    std::string data_;
};

int main() {
    std::vector<Log> v;
    v.reserve(4);                        // 预留容量,避免反复扩容——性能敏感代码的标配
    for (int i = 0; i < 4; ++i) {
        v.emplace_back("log-" + std::to_string(i));   // 原地构造,不产生临时对象
    }
    return 0;
}

vector 扩容(2 倍增长)要把旧元素搬到新内存:元素支持移动构造时就逐个「移动」(O(n) 次廉价搬运),不支持就只能「复制」。这也是为什么要求移动构造标 noexcept——vector 只有确认移动不会抛异常,才敢用移动。

讲清楚要点

  1. 所有权的三个归属:裸指针时代「谁 new 谁 delete」靠纪律;现代 C++ 把所有权分成独占(unique_ptr)、共享(shared_ptr + 计数)、观察(weak_ptr 或裸指针只读不删)三种,代码即文档。
  2. 默认 unique_ptr,慎用 shared_ptr:unique_ptr 零开销(大小 = 裸指针)、编译期强制独占;shared_ptr 有原子计数开销,还可能循环引用。能用栈对象就不上堆,能 unique 就不 shared。
  3. make_unique / make_shared 优先于 new:一次分配、异常安全、代码里不出现裸 new。make_shared 还能把对象和控制块合并成一次内存分配。
  4. 移动是复制的廉价替代:右值(临时对象、被 std::move 标记的对象)触发移动构造。函数返回局部对象靠 RVO 连移动都省了,别画蛇添足写 return std::move(local);——反而阻止优化。
  5. const 正确性贯穿内存管理:const std::string& 参数、const 成员函数、unique_ptr<const T>——把「只读」写进类型系统,让编译器替你审内存安全。

你可能会懵的地方

  • C++ 没有 GC,那内存泄漏怎么发现? 工具链:AddressSanitizer(-fsanitize=address)编译即可在运行时抓泄漏与越界;Valgrind 事后分析;CI 里跑长测观察 RSS。判题沙箱上线前必过 ASan 这关。

  • unique_ptr 能放进容器吗?能作为函数返回值吗? 都能。std::vector<std::unique_ptr<Task>> 是存储多态对象的标配;从函数 return up; 会自动移动(隐式 move),不是拷贝。

  • 什么对象直接放栈上,什么才上堆? 小而短命 → 栈(连 new 都不用);需要跨函数存活、体积大、数量运行期才定、需要多态容器 → 堆 + 智能指针。Java 世界「万物 new」的直觉在 C++ 里要倒过来:默认栈,确实需要才堆。

  • shared_ptr 线程安全吗? 控制块计数是原子的(多线程拷贝/析构安全),但指向的对象本身不自动线程安全——多线程读写对象仍要自己加锁(第 9 课)。

  • delete 一个 void* 或未定义析构的基类指针会怎样? UB。前者不知道怎么析构,后者(基类析构非虚)只析构基类部分。用智能指针后这类错误基本绝迹,也是「禁裸 new」的理由之一。

错法对照

错法问题正解
unique_ptr<T> p = new T();构造函数是 explicit,编译不过auto p = std::make_unique<T>();
拷贝 unique_ptr编译错误:独占不可拷贝std::move 转移,或改用 shared_ptr
shared_ptr 互相引用计数永不归零 → 泄漏一方改用 weak_ptr,用时 lock()
return std::move(local);阻止 RVO,反而更慢直接 return local;
移动后继续读原对象内容「有效但未定义」状态,读到空/旧数据move 只用来「交出」资源,之后不再读取

卡点急救

  1. 程序退出时报 double free detected:同一块内存删了两次。排查对象是否被两个容器/智能指针共同持有,统一改成 unique_ptr 独占。
  2. use_count 比预期大,对象迟迟不析构:有循环引用或某处还持着 shared_ptr。用 weak_ptr 打破环,或梳理所有权图。
  3. 编译报 no matching function for call to 'make_unique':构造函数是 private 或参数不匹配。检查类是否把 make 需要的构造函数设为 public,或用 std::unique_ptr<T>(new T(args)) 兜底。

面试常问

  • unique_ptr 和 shared_ptr 的区别与选择? unique_ptr 独占所有权、零开销、不可拷贝可移动;shared_ptr 引用计数共享所有权、有原子操作开销。默认 unique_ptr,仅当所有权确实多方共享且释放时机不确定时用 shared_ptr;观察场景用 weak_ptr 防循环引用。

  • 什么是移动语义?解决了什么问题? C++11 引入,通过右值引用 T&& 把「深拷贝临时对象」变成「接管其内部资源」,使容器扩容、对象返回、智能指针转移都变 O(1)。std::move 只是无条件转为右值的 cast,真正转移由移动构造/赋值完成,后者应标 noexcept。

  • 智能指针是 GC 吗?和 Java 的 GC 区别? 不是。智能指针基于 RAII 与所有权:unique_ptr 作用域结束即析构(确定性),shared_ptr 计数归零即析构(准确定)。GC 在不可预知的未来批量回收且带停顿;智能指针没有扫描开销,但要求程序员显式表达所有权,且不能自动处理环引用。

自测三问

  1. r.get() 和 std::move(r) 分别对 unique_ptr 做了什么?
  2. make_shared 相比 shared_ptr<T>(new T) 多了什么优势?
  3. 为什么 vector 扩容要求元素的移动构造函数是 noexcept?

小结一句话

没有 GC 的 C++ 用「所有权」替代「回收器」:unique_ptr 独占、shared_ptr 共享、weak_ptr 观察,配合移动语义让资源转移零拷贝——想清楚「归谁管」,内存问题就少九成。

延伸

  • cppreference:智能指针
  • cppreference:std::move 与移动语义
  • 下一课:STL 容器与算法——标准库才是 C++ 的生产力所在。
加载本节练习…
← 上一课面向对象下一课 →STL 容器与算法