从裸指针到 RAII:资源为什么会泄漏

写 C 时我们手动 new、手动 delete,一旦中途提前 return 或抛异常,delete 那行就被跳过,内存就此泄漏。C++ 的解法不是再催你记得释放,而是换个思路:把资源的生命周期交给一个栈上对象——对象离开作用域时,它的析构函数自动释放资源。这套机制叫 RAII(资源获取即初始化)。

智能指针就是 RAII 的标准实现:它长得像指针,可以解引用、可以指向堆对象,但它本身是个栈对象,出作用域时自动 delete。于是"忘记释放"在结构上就不再可能——异常、提前 return、多分支路径都由析构函数兜底。

RAII 的精髓一句话:用对象的生命周期去管理你申请到的每一样资源——内存、文件句柄、锁、网络连接,都适用。
本文核心知识点
  • RAII栈对象出作用域自动析构,把资源释放绑进对象生命周期。
  • unique_ptr独占所有权、轻量、可移动不可复制,是默认首选。
  • shared_ptr/weak_ptr引用计数共享所有权,用 weak_ptr 打破循环引用。
  • STL 容器按"读多写少、要不要排序、查找频率"选 vector 还是 map。
C++ 智能指针与 STL 容器配图:蓝青渐变背景上中央发光核心立方体被多层嵌套圆角容器方框包围,环形圆球节点箭头链条环绕,虚线箭头指向核心,旁有半透明盾牌与同心圆环
智能指针像一只守护容器的手:你只管把对象交出去,离开作用域时它替你把资源收拾干净。

unique_ptr:独占所有权的默认选择

std::unique_ptr<T> 表示"这块对象只归我一个人管"。它体积几乎和裸指针一样大,没有额外开销,但禁止拷贝、只允许移动——这正好从类型上保证了"独占"。创建它最推荐用 std::make_unique,一行搞定,且天然异常安全。

#include <memory>
#include <vector>
#include <iostream>

struct File {
    File(const char *n) { std::cout << "open " << n << "\n"; }
    ~File() { std::cout << "close\n"; }
};

int main() {
    auto p = std::make_unique<File>("data.txt");  // 独占
    // auto q = p;      // 错:不能拷贝
    auto r = std::move(p);   // 对:所有权移交,p 变空
    std::vector<std::unique_ptr<File>> box;
    box.push_back(std::make_unique<File>("a.txt"));
    box.push_back(std::move(r));   // 容器里也只存所有权
    return 0;   // 离开作用域,全部自动 close
}

现代 C++ 的经验法则是:凡是要在堆上分配对象,第一反应就是 unique_ptr。只有当你确实需要"多处共享同一份对象"时,才升级到 shared_ptr。不要因为 shared_ptr 看起来灵活就滥用——它带引用计数开销,还容易制造循环引用。

shared_ptr 与 weak_ptr:共享与打破循环

std::shared_ptr<T> 内部维护一个引用计数:每多一份拷贝,计数加一;每销毁一份,计数减一;归零的那一刻才真正 delete。它适合"一份资源被多个所有者共同持有、谁都不知道该谁最后释放"的场景。

#include <memory>
#include <iostream>

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node>   prev;   // 关键:反向用 weak,不增加计数
    ~Node() { std::cout << "~Node\n"; }
};

int main() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    a->next = b;          // b 的计数 +1
    b->prev = a;          // weak_ptr 不增加 a 的计数
    return 0;             // 计数正常归零,析构被调用
}

最经典的坑就是循环引用:两个对象互相用 shared_ptr 指向对方,计数永远停在 1,谁都不肯先析构,内存就这么漏了。解药是让其中一方改用 std::weak_ptr——它"看着"对象但不算一个持有者,需要用时先 lock() 提升成 shared_ptr,对象还活着才拿得到。

STL 容器怎么选:vector、map 与哈希表

STL 容器不是"随便挑一个",选错了性能差一大截。按使用场景对号入座即可:

容器底层典型场景查找/插入
vector连续数组绝大多数动态序列,末尾增删查找慢、尾部快
map平衡二叉树需要按键有序遍历O(log n)
unordered_map哈希表只按键查、不关心顺序平均 O(1)
set / unordered_set树 / 哈希去重、判存在同上
#include <vector>
#include <unordered_map>
#include <string>

int main() {
    std::vector<int> nums = {5, 2, 9, 1};
    nums.push_back(7);                 // 尾部追加,均摊 O(1)

    std::unordered_map<std::string, int> age;
    age["张伟"] = 20;                  // 按键存取,近似常数时间
    age["李娜"] = 22;
    return 0;
}

三条实战建议:第一,默认就用 vector,它内存连续、缓存友好,往往比你想象的快;第二,只有需要"按 key 排序输出"时才用 map,否则用 unordered_map;第三,提前 reserve 你知道的容量,避免 vector 反复扩容搬迁。

迭代器:别再手动写下标循环

迭代器是容器的"通用游标",把"遍历元素"这件事和具体是哪种容器解耦。现代 C++ 更推荐范围 for 循环,它本质上就是迭代器的语法糖,干净又不易出错。

#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {10, 20, 30};

    // 范围 for:最常用,读或改都方便
    for (auto &x : v) {
        x *= 2;
    }

    // 需要同时拿到位置时才用显式迭代器
    for (auto it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << " ";
    }
    return 0;   // 输出 20 40 60
}

两个容易踩的坑:一是迭代器失效——对 vector 做插入删除后,旧迭代器可能已经失效,继续用它就是未定义行为;二是 end() 指向"最后一个元素的下一个位置",可以比较、不能解引用。写循环用范围 for,绝大多数情况下能同时避开这两个坑。

算法与 lambda:std::algorithm 的正确打开方式

与其自己写一堆循环做查找、排序、过滤,不如直接用 <algorithm> 里的现成算法,再配一个 lambda 描述"你想要什么"。代码更短、意图更清楚,而且这些算法都经过高度优化。

#include <vector>
#include <algorithm>
#include <iostream>

int main() {
    std::vector<int> v = {5, 2, 9, 1, 7};

    std::sort(v.begin(), v.end());                 // 升序
    int cnt = std::count_if(v.begin(), v.end(),   // 统计大于5的个数
                            [](int x) { return x > 5; });

    auto it = std::find_if(v.begin(), v.end(),     // 找第一个偶数
                           [](int x) { return x % 2 == 0; });

    std::cout << cnt << " " << *it << "\n";         // 2 2
    return 0;
}

lambda 的方括号 [] 是"捕获列表":写 [&] 按引用捕获外部变量,写 [=] 按值捕获。规则很简单——你需要在 lambda 里改外部变量就用 &,只读就用 =,别图省事一律 [&],否则容易把不该改的状态也带进异步线程里。