我以前非常坚持自己手写代码。写一个容器,想清楚结构,自己处理类型、索引和生命周期,遇到问题再自己一点点查。这是我习惯的开发方式。AI 可以回答问题,但那次经历之前,我还没有因为一次具体的排错,对它产生过这么大的改观。

后来,我自己写的一个多类型容器,在删除一个 int 的时候崩了。

代码接近 500 行,调用栈有二十多层。我找了半天,也没有找到究竟错在哪里。几个 AI 的回答让我经历了怀疑、烦躁,最后还有一句很直接的:

“我去,牛逼啊,还真是。”

这篇文章记录那次经历,也补上后来重新核对代码、在本地编译器上验证得到的技术结论。

整理说明:经历和感受来自当时的聊天记录与 QQ 动态;技术补记来自后续对原始成员结构的简化实验。正文用 Bucket 等通用名称展示问题,省略原容器的完整接口。原记录中的模型称呼保留当时写法,不据此确认产品版本,也不把一次体验当作模型排名。

删除 int,为什么插入 double 会影响它?

当时,我在实现一个支持多类型数据的容器。它的思路大致是:

  • 每种类型有自己的 std::list<T>,负责实际存储元素。
  • 用 std::any 包装不同类型的链表。
  • 用一个外层 std::vector 保存各个类型的数据结构。
  • 在另外的索引中保存元素指针和 list<T>::iterator,方便查找与删除。

保存迭代器是有目的的:找到元素以后,可以直接删除对应节点,无需再从链表头部遍历寻找。原设计中的有序映射查找仍有自己的开销,这里避免的是额外一次 O(n) 的链表搜索。

但程序偏偏在 list::erase() 里崩了。调用栈一路经过链表节点释放、operator delete、_free_base,最后进入系统库。

删掉无关部分后,测试顺序是这样的:

// 接口示意,展示当时的操作顺序。
container.insert<int>("123", -1);
container.insert<int>("", 7);
container.insert<double>("456", 1.0);
container.erase<int>("123"); // 在这里崩溃

最奇怪的线索是:只要删掉插入 double 的那一行,删除 int 就没有问题。

这也是后来真正推进排查的线索。两个类型的元素分别存放在不同链表中,插入 double 为什么会影响 int?它们之间共享的东西,是外层的类型表。

我也曾以为自己已经找到原因

排查过程中,我问过 AI:std::list::iterator 到底存了什么?

得到的解释是,它可以理解为一个轻量的节点指针包装。于是,我开始怀疑链表插入时,前后节点的变化是不是会影响保存下来的迭代器,甚至觉得自己忘记更新相邻迭代器了。

这个猜测是错的。

std::list 插入元素不会使已有迭代器失效;删除元素只使指向被删元素的迭代器和引用失效。相邻节点的连接关系会变化,但指向其他元素的迭代器仍然有效。C++ 工作草案:list.modifiers

后来我把完整实现和“删掉插入 double 就正常”的测试一起发了过去。

千问把注意力转向了外层 vector:新增一种类型会创建一个新的类型项,可能触发扩容;扩容又会影响已经存在的类型项,以及其中被 std::any 包装的链表。

它给出了一个非常具体的解释,还建议用 shared_ptr 保存整个链表,让外层类型表扩容时不再改变实际的数据池。

我照着试了,问题消失了。

那一刻最让我震撼的,是它把“插入另一种类型”和“删除现有类型时崩溃”这两个看起来隔得很远的现象联系到了一起,而且给出的改动保留了我原来的迭代器索引设计。

当时发在 QQ 动态里的话

下面保留我当时发出的原话,包括当时对根因的理解。后面的技术补记会修正其中不够准确的部分。

刚写代码,遇到一个运行时错误。

我代码接近500行,报错有20多层堆栈调用,找了半天,没找到错哪。

问了几个AI,体验是这样的:

  • 千问3.7Plus(深度思考):

思考了5分钟,精确的告诉:MSVC 的 Debug 模式下,std::vector 重分配导致 std::list 容器对象内存地址改变,使得先前存储在 std::any 中的迭代器因内部 _Mycont(容器指针)失效而触发堆校验崩溃。

我试了下,居然是对的?!

我去千问满血版是真牛逼啊。从我报错的堆栈调用里得知我使用的是 MSVC ,然后他甚至能分析出问题出自 MSVC 的 Debug 底层,而且甚至精确到“内部 _Mycont 指针”,连内部指针名都说出来了。

而且改法也是挺好的,加了个 std::shared_ptr 让 std::list 不移动就行了。

原聊天记录里,我还记下了其他几次体验:有回答把 list 插入的迭代器失效规则说错了;有回答在被纠正后接近了问题,却把我保存迭代器的方案换成了 O(n) 搜索;还有回答挑了一堆外围代码,却始终没有解释关键现象。

推荐内存诊断工具当然可以有价值。只是那时我最需要的是有人解释:为什么加了一行插入 double,原来可以删除的 int 就不能删了?

千问给出的排查方向和修改方式,确实帮我走出了这个问题。这就是那条动态里兴奋和惊讶的来源。

技术补记:改法有效,解释还需要再核对

重新整理这次经历时,我又看了一遍原代码,并使用本地的 MSVC 与 GNU C++ 验证了简化案例。

结果发现,原回答抓住了关键触发点,给出了有效修复,但它对“为什么失效”的解释有一个重要偏差:

在这份成员结构和实测 MSVC 配置下,外层 vector 扩容选择的是复制类型项,随后 std::any 复制了整个 list。旧链表销毁,外部索引仍然保存旧节点的迭代器和指针。

这比“list 移动后,Debug 忘记更新容器指针”更准确。

一个容易漏掉的成员:map

原来的类型项可以缩减成下面的结构:

struct Bucket {
    std::map<std::string, void*> keys;
    std::any data;                // 包装 std::list<T>
    std::vector<std::any> erased; // 原结构中还有这一成员
};

std::vector<Bucket> buckets;

外层总容器禁止复制,并不意味着里面的 Bucket 也禁止复制。它是另一个类型,有自己生成的复制、移动操作。

更关键的是,vector 扩容不一定调用元素的移动构造。在对应的 MSVC 实现中,尾部插入触发扩容时,如果元素类型可复制、而移动构造不保证不抛异常,就走复制路径。MSVC STL:vector 的扩容分支

原类型项里含有 std::map。本地 MSVC 14.44.35207 的检查结果是:

map nothrow move=false
Bucket nothrow move=false
Bucket copyable=true

这与对应版本的 MSVC 树容器移动构造需要分配哨兵及代理、没有声明为 noexcept 的实现相符。MSVC STL:xtree 的移动构造

复制 Bucket 时,其中的 std::any 也被复制。复制 any 会复制其包装的值;包装的是 std::list<int>,就会产生另一条拥有新节点的链表。C++ 工作草案:any 的复制构造

于是,真正的问题链条是:

插入新的类型
    ↓
外层 vector 扩容
    ↓
MSVC 选择复制 Bucket
    ↓
复制 any 中的 list,生成新的节点
    ↓
销毁旧 Bucket、旧 list、旧节点
    ↓
外部索引没有随之重建,仍指向旧节点
    ↓
用旧迭代器删除新链表的元素,发生未定义行为

std::any 本身没有把正确的迭代器“存坏”。问题是它包装的链表被复制,而另一个地方保存的迭代器并不会自动转而指向新链表的对应节点。

保留关键结构的简化案例

下面这个例子保留了影响复制、移动选择的成员。它强制触发一次扩容,并在扩容后重新获取链表引用,避免额外引入“继续使用旧对象引用”的问题。

#include <any>
#include <cstdint>
#include <iostream>
#include <list>
#include <map>
#include <memory>
#include <string>
#include <type_traits>
#include <vector>

struct Bucket {
    std::map<std::string, void*> keys;
    std::any data;
    std::vector<std::any> erased;
};

int main() {
    std::cout << std::boolalpha
              << "Bucket nothrow move: "
              << std::is_nothrow_move_constructible_v<Bucket> << '\n';

    std::vector<Bucket> buckets(1);
    buckets[0].data = std::list<int>{42};

    std::any saved =
        std::any_cast<std::list<int>&>(buckets[0].data).begin();

    const auto old_address = reinterpret_cast<std::uintptr_t>(
        std::addressof(std::any_cast<std::list<int>&>(buckets[0].data).front()));

    const auto old_capacity = buckets.capacity();
    while (buckets.capacity() == old_capacity) {
        buckets.emplace_back();
    }

    auto& current = std::any_cast<std::list<int>&>(buckets[0].data);
    const auto new_address = reinterpret_cast<std::uintptr_t>(
        std::addressof(current.front()));

    std::cout << "Same element address: "
              << (old_address == new_address) << std::endl;

    // MSVC 实测中 saved 已悬空;取消注释用于观察错误。
    // current.erase(std::any_cast<std::list<int>::iterator&>(saved));
}

只比较地址不足以证明发生了复制。因此,验证时还使用了一个记录复制、移动次数的元素类型:MSVC 扩容后记录到一次元素复制、零次元素移动,元素地址也改变了。

原对话后来给出的 vector<any> 示例不能直接替代这个案例:它删掉了影响移动异常保证的 map 成员,而且扩容后还继续使用先前取得的链表引用,可能混入另一种悬空引用问题。提取最小案例时,必须保留真正影响行为的结构。

本地验证结果

验证日期:2026 年 10 月 10 日。环境为 Windows x64,使用 C++20 编译选项;简化案例本身只需要 C++17。

配置 Bucket 是否可无异常移动 扩容时的元素复制次数 节点地址是否保持 使用保存的迭代器删除
MSVC,工具集 14.44.35207,/MDd /Zi,迭代器检查级别 2 否 1 否 断言失败并退出
同一 MSVC,/MD /O2,迭代器检查级别 0 否 1 否 异常退出
Scoop MinGW GNU C++ 15.2.0,-O2 是 0 是 通过
改存 shared_ptr<list<T>>,以上三种配置 依各实现而定 0 是 全部通过

MSVC Debug 的简化案例报告:

Assertion failed: list erase iterator outside range

原始记录中的崩溃则到达了堆释放路径。两者的报错位置并不完全相同:这些实验验证了保留关键成员结构后的失效路径与修复效果,没有重新运行当年接近 500 行的完整程序,也没有证明原调用栈的每一层都由某个特定内部指针直接触发。

GNU C++ 在这个案例中表现不同,是因为实测的 Bucket 可以无异常移动,扩容走了移动路径,元素节点保持原样。这里不能简单概括成“GCC 没检查,所以碰巧没崩”。

_Mycont 确实存在,但它不证明原解释完整正确

从原调用栈中的 MSVC\14.44.35207\include\list 等路径,可以识别使用的标准库。仅凭堆释放栈,还不足以断言迭代器检查的配置,更不能直接证明 _Mycont 就是这次崩溃的唯一原因。

MSVC 的调试迭代器确实维护容器关联。更具体地说,迭代器通过 _Myproxy 关联到容器代理,代理里有 _Mycont;不能简单描述成每个迭代器直接塞了一个 _Mycont 成员。MSVC STL:容器代理与迭代器关联

而正常移动 list 时,MSVC 实现会转移节点并交换代理、更新关联。源码中能看到 _Swap_proxy_and_iterators 这条处理路径。MSVC STL:list 的移动构造与 _Swap_val

本地也额外验证了普通的、使用默认分配器的 list 移动构造:把保存在 any 中的元素迭代器传给移动后的目标链表删除,MSVC Debug、Release 和 GNU C++ 都通过了。

所以,当时那句“容器 move 后所有迭代器必然失效”不能当作标准规则。移动构造、移动赋值、分配器条件和 end() 迭代器需要分别讨论,原回答给出的那句英文也不应作为标准原文引用。

同样,标准没有禁止长期保存 iterator。是否能保存,取决于后续操作的失效规则、元素与容器的生命周期,以及实际使用时的归属。

为什么 shared_ptr 修复仍然成立?

当时采用的修复很小:把 any 包装的内容,从链表本身改成链表的智能指针。

// 修改前:复制 any 会复制整条链表。
bucket.data = std::list<T>{};

// 修改后:复制 any 会复制 shared_ptr,共享同一条链表。
bucket.data = std::make_shared<std::list<T>>();

获取数据时,对应改为:

auto& list_ptr =
    std::any_cast<std::shared_ptr<std::list<T>>&>(bucket.data);
std::list<T>& data_list = *list_ptr;

这同时应对了类型项的复制与移动:无论外层 vector 选择哪条路径,都不会因此复制实际链表、销毁原来的数据节点。旧类型项释放自己的 shared_ptr 后,新类型项仍持有同一条链表。

原来的迭代器索引因此可以继续使用,删除节点也仍然无需重新遍历链表。当然,元素真正被删除时,相关索引仍然需要同步清理。

这里不要把“链表地址稳定”扩大成“迭代器永远有效”。删除对应元素、清空链表、释放最后一个所有者,都会使相关引用失去有效性。

shared_ptr 有控制块与引用计数开销,make_shared 通常会合并对象与控制块的分配,但实际字节数和内存碎片影响取决于实现与负载,不能用固定数字保证它“没有性能影响”。对这个设计来说,它解决的是每种类型数据池的身份和生命周期问题;链表每个元素原本也有自己的节点分配。

如果整个设计只需要唯一所有权,也可以通过类型擦除的唯一所有者等方式实现。不过 std::any 要求所包装的值可复制,不能直接把 unique_ptr<list<T>> 当作等价替换塞进去。

提前 reserve 可以作为定位实验:在保存索引之前预留足够容量,若问题暂时消失,就进一步支持“扩容相关”的判断。但超出容量后仍会扩容;对已经悬空的迭代器,后来再调用 reserve 也不会修好它。

这次经历改变了什么

回头看,我仍然觉得当时的震撼很真实。

我自己写的容器,接近 500 行的代码,二十多层的调用栈,一个看起来毫无关系的 double 插入。千问把这些线索串了起来,给出可以尝试的方向,并提出一个保留原设计的修复。实际修改后,问题也确实消失了。

这让我开始重新看待 AI 在写代码这件事中的作用。自己手写代码的习惯还在,但遇到复杂问题时,我愿意让 AI 一起检查那些我可能遗漏的关系:谁拥有数据,谁保存引用,哪一层发生扩容,哪一种操作会复制对象。

后来再次验证,也让我看到另一件事:一个回答可以同时包含有价值的洞察、有效的改法,以及需要纠正的解释。尤其是内部成员名、标准条款和很有说服力的类比,仍然需要证据支撑。

这次我想记录下来的,就是那一刻的“居然真的解决了”,以及继续追问以后,对这个问题更准确的理解。

附:自行验证

随文的 iterator_case.cpp 提供诊断版与修复版。默认只观察扩容和元素地址,不执行已经失效的迭代器删除操作。

在 Visual Studio x64 开发者命令提示符中,可以这样验证修复版:

cl /nologo /utf-8 /std:c++20 /EHsc /MDd /DUSE_SHARED=1 iterator_case.cpp /Fe:case_fixed.exe
case_fixed.exe

使用 GNU C++:

g++ -std=c++20 -Wall -Wextra -O2 -DUSE_SHARED=1 iterator_case.cpp -o case_fixed.exe
case_fixed.exe

去掉 USE_SHARED=1 可以观察原存储结构。定义 RUN_INVALID_ERASE=1 则会执行删除;原结构在本地 MSVC 验证中会失败。Debug 验证使用了调试运行库,不是仅加上 /Zi 就认定启用了迭代器检查。