首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >给前端同学的 C++ 悬垂指针入门:为什么 Crash 会概率性发生

给前端同学的 C++ 悬垂指针入门:为什么 Crash 会概率性发生

原创
作者头像
骑猪耍太极
发布2026-06-01 16:00:36
发布2026-06-01 16:00:36
1410
举报
文章被收录于专栏:AI编程之旅AI编程之旅

本文不使用任何项目代码,所有例子都是独立的简化示例。目标读者是熟悉 JavaScript / TypeScript / React / Vue,但较少接触 C++ 内存模型的前端同学。

1. 先理解几个概念

1.1 指针:一个内存地址

C++ 指针可以简单理解为“某个对象所在的地址”。

代码语言:cpp
复制
User user;
User* p = &user;

这里:

  • user 是对象;
  • &user 是对象地址;
  • p 保存这个地址;
  • p->name 表示“去这个地址上访问 Username”。

前端类比:指针有点像对象引用,但更底层。JS 引用由 GC 管理,C++ 指针经常需要开发者自己保证“它指向的对象还活着”。

1.2 生命周期:对象从创建到销毁

代码语言:cpp
复制
User* user = new User(); // 创建
user->SayHello();        // 使用
delete user;             // 销毁

delete user 之后,User 对象已经不存在了。

危险的是:

代码语言:cpp
复制
delete user;
user->SayHello(); // 错误:对象已经销毁

user 这个变量里还保存着旧地址,但这个地址上的对象已经没了。

1.3 悬垂指针

对象生命周期已经结束,但仍有指针指向原来的内存地址,这类指针更正式地称为悬垂指针(dangling pointer),也常被口语化地称为野指针。

代码语言:cpp
复制
User* user = new User();
delete user;

user->SayHello(); // use-after-free

1.4 Use-After-Free

Use-After-Free,简称 UAF

对象释放之后,又继续使用它。

这是 C++ 里非常常见、也非常危险的一类问题。

1.5 Undefined Behavior:未定义行为

C++ 对 UAF 这类错误不保证固定结果。它可能:

  • Crash;
  • 不 Crash;
  • Debug 不 Crash,Release Crash;
  • 本地不 Crash,线上 Crash;
  • 加一行日志后不 Crash;
  • Crash 在离根因很远的地方。

所以,C++ 悬垂指针问题天然可能是“概率性”的。

2. 最简单的例子:为什么不是必现

代码语言:cpp
复制
#include <iostream>

class User {
public:
    void SayHello() {
        std::cout << "hello" << std::endl;
    }
};

int main() {
    User* user = new User();
    delete user;

    user->SayHello(); // 错误,但不一定每次都崩
    return 0;
}

这段代码一定是错的,但不一定每次 Crash。

原因是:delete user 后,这块内存只是“归还给内存分配器”。它可能暂时还没被别人复用,旧内容还在,所以看起来还能跑。

但只要这块内存被复用,结果就不可预测:

代码语言:cpp
复制
User* user = new User();
delete user;

std::string* text = new std::string("abc");

user->SayHello(); // user 指向的地址可能已经不是 User 了

可能的结果包括:

  • 立刻 Crash;
  • 调用到错误地址;
  • 破坏其他内存;
  • 暂时没表现。

这就是线上概率性 Crash 的常见原因。

3. 前端同学更熟悉的例子:事件监听忘记解绑

前端里常见写法:

代码语言:js
复制
useEffect(() => {
  window.addEventListener('resize', onResize)
  return () => window.removeEventListener('resize', onResize)
}, [])

如果组件卸载时忘记 removeEventListener,可能出现内存泄漏或访问已卸载组件的问题。

C++ 里类似问题更危险,因为监听列表里可能保存的是原始指针(raw pointer)。如果它只是观察对象、不负责对象生命周期,更准确地说就是非拥有型原始指针(non-owning raw pointer)。

3.1 一个按钮监听示例

代码语言:cpp
复制
#include <vector>
#include <algorithm>
#include <iostream>

class ClickListener {
public:
    virtual void OnClick() = 0;
    virtual ~ClickListener() = default;
};

class Button {
public:
    void AddListener(ClickListener* listener) {
        listeners_.push_back(listener);
    }

    void RemoveListener(ClickListener* listener) {
        listeners_.erase(
            std::remove(listeners_.begin(), listeners_.end(), listener),
            listeners_.end()
        );
    }

    void Click() {
        for (auto* listener : listeners_) {
            listener->OnClick();
        }
    }

private:
    std::vector<ClickListener*> listeners_;
};

页面监听按钮点击:

代码语言:cpp
复制
class Page : public ClickListener {
public:
    explicit Page(Button& button) : button_(button) {
        button_.AddListener(this);
    }

    ~Page() {
        button_.RemoveListener(this);
    }

    void OnClick() override {
        std::cout << "clicked" << std::endl;
    }

private:
    Button& button_;
};

这是安全的,因为注册和反注册成对。

错误版本:

代码语言:cpp
复制
class Page : public ClickListener {
public:
    explicit Page(Button& button) : button_(button) {
        button_.AddListener(this);
    }

    ~Page() {
        // 忘记 RemoveListener(this)
    }

    void OnClick() override {}

private:
    Button& button_;
};

触发问题:

代码语言:cpp
复制
Button button;

{
    Page page(button);
} // page 已经销毁,但 button 里还保存着 page 的地址

button.Click(); // 访问已销毁对象,可能 Crash

这就是一个非常典型的 UAF。

4. 更隐蔽的情况:不是忘记解绑,而是解绑被跳过

实际问题里,代码往往不是完全忘记解绑,而是某个条件导致解绑没有执行。

代码语言:cpp
复制
class Page : public ClickListener {
public:
    explicit Page(Button* button) : button_(button) {
        if (button_) {
            button_->AddListener(this);
        }
    }

    ~Page() {
        if (button_ == nullptr) {
            return; // 这里跳过了 RemoveListener
        }
        button_->RemoveListener(this);
    }

    void DetachButton() {
        button_ = nullptr;
    }

    void OnClick() override {}

private:
    Button* button_ = nullptr;
};

问题路径:

代码语言:cpp
复制
Button button;

{
    Page page(&button); // 注册 listener
    page.DetachButton(); // button_ 被置空
} // 析构时提前 return,没有反注册

button.Click(); // button 里残留 Page*,可能 Crash

这类代码看起来“有清理逻辑”,但清理依赖的状态已经变了。

更稳妥的原则是:

注册到谁,就明确记录谁;反注册时不要重新猜。

代码语言:cpp
复制
class Page : public ClickListener {
public:
    explicit Page(Button* button) : registeredButton_(button) {
        if (registeredButton_) {
            registeredButton_->AddListener(this);
        }
    }

    ~Page() {
        Unregister();
    }

    void Unregister() {
        if (registeredButton_) {
            registeredButton_->RemoveListener(this);
            registeredButton_ = nullptr;
        }
    }

    void OnClick() override {}

private:
    Button* registeredButton_ = nullptr;
};

5. 另一个常见坑:多份索引没有同步

再看一个和 UI 完全无关的学生系统例子。

代码语言:cpp
复制
#include <unordered_map>
#include <vector>
#include <string>

struct Student {
    int id;
    std::string name;
};

struct ClassRoom {
    std::vector<int> studentIds;
};

std::unordered_map<int, Student*> studentsById;

这里同一份关系被保存了两次:

  • studentsById:通过 id 找学生;
  • ClassRoom::studentIds:班级里有哪些学生 id。

错误写法:只更新了 map,忘了更新班级里的 id。

代码语言:cpp
复制
void ChangeStudentIdWrong(ClassRoom& room, int oldId, int newId) {
    auto student = studentsById[oldId];
    studentsById.erase(oldId);
    studentsById[newId] = student;
    student->id = newId;

    // 漏了:room.studentIds 里还保存 oldId
}

后续遍历班级:

代码语言:cpp
复制
for (int id : room.studentIds) {
    Student* student = studentsById[id];
    // id 还是旧的,可能查不到对象
}

正确做法:所有相关结构一起更新。

代码语言:cpp
复制
void ChangeStudentId(ClassRoom& room, int oldId, int newId) {
    auto it = studentsById.find(oldId);
    if (it == studentsById.end()) {
        return;
    }

    Student* student = it->second;
    studentsById.erase(it);
    studentsById[newId] = student;
    student->id = newId;

    for (int& id : room.studentIds) {
        if (id == oldId) {
            id = newId;
        }
    }
}

这个例子说明:

如果同一份关系被多个数据结构记录,更新时必须全部同步。

6. 排查这类问题时怎么想

不要只看 Crash 的那一行。Crash 行通常只是“最后踩雷的地方”,不是“埋雷的地方”。

可以按这几个问题往回查:

代码语言:bash
复制
1. Crash 是否发生在回调、事件、observer、listener、虚函数附近?
2. 是否有 `vector<Listener*>` 这类原始指针容器?
3. 谁把这个指针放进去的?
4. 对象销毁时,是否一定会把指针移除?
5. 清理逻辑里是否有提前 return?
6. 是否有多份 id / parent / child / map / list 需要同步?

尤其要警惕这种代码:

代码语言:cpp
复制
void Cleanup() {
    if (!parent) {
        return;
    }
    parent->RemoveListener(this);
}

要问:

parent 不存在,是否真的代表“没有注册过 listener”?

很多问题就藏在这个假设里。

7. 如何避免

7.1 注册和反注册必须成对

代码语言:cpp
复制
button.AddListener(this);
button.RemoveListener(this);

有注册,就必须能证明一定会反注册。

7.2 清理函数要幂等

幂等就是调用多次也安全。

代码语言:cpp
复制
void Unregister() {
    if (!registeredButton_) {
        return;
    }
    registeredButton_->RemoveListener(this);
    registeredButton_ = nullptr;
}

这样析构、手动 detach、异常路径都可以调用它。

7.3 尽量用 RAII

RAII 的意思是:资源在对象构造时获取,在析构时释放。

简化示例:

代码语言:cpp
复制
class Subscription {
public:
    explicit Subscription(std::function<void()> cancel)
        : cancel_(std::move(cancel)) {}

    ~Subscription() {
        Cancel();
    }

    void Cancel() {
        if (cancel_) {
            cancel_();
            cancel_ = nullptr;
        }
    }

private:
    std::function<void()> cancel_;
};

组件持有 Subscription,组件销毁时自动取消订阅。

7.4 明确所有权

写 C++ 时经常要问:

代码语言:bash
复制
谁拥有这个对象?
谁负责销毁它?
别人保存的是拥有关系,还是观察关系?
对象销毁前,所有观察关系是否会被清理?

常见工具:

  • std::unique_ptr:唯一拥有;
  • std::shared_ptr:共享拥有;
  • std::weak_ptr:只观察,不延长生命周期;
  • 原始指针:只适合生命周期非常明确的场景;如果不拥有对象,最好明确标注为非拥有型原始指针。

8. 总结

C++ 悬垂指针问题之所以难,是因为它不一定稳定复现。

但不稳定不代表没问题。只要满足:

代码语言:bash
复制
对象已经销毁
仍有地方保存它的地址
未来可能继续访问这个地址

这就是确定的代码风险。

给前端同学的最终心智模型:

C++ 里的指针只是一个地址。地址还在,不代表对象还活着。

排查时重点看三件事:

  1. listener / observer 是否可靠反注册;
  2. 清理逻辑是否可能被提前 return 跳过;
  3. 多份索引或关系是否同步更新。

做到这三点,大部分概率性 UAF Crash 都能更快定位和规避。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 先理解几个概念
    • 1.1 指针:一个内存地址
    • 1.2 生命周期:对象从创建到销毁
    • 1.3 悬垂指针
    • 1.4 Use-After-Free
    • 1.5 Undefined Behavior:未定义行为
  • 2. 最简单的例子:为什么不是必现
  • 3. 前端同学更熟悉的例子:事件监听忘记解绑
    • 3.1 一个按钮监听示例
  • 4. 更隐蔽的情况:不是忘记解绑,而是解绑被跳过
  • 5. 另一个常见坑:多份索引没有同步
  • 6. 排查这类问题时怎么想
  • 7. 如何避免
    • 7.1 注册和反注册必须成对
    • 7.2 清理函数要幂等
    • 7.3 尽量用 RAII
    • 7.4 明确所有权
  • 8. 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档