← 全部文章

让 AI 改我的 C++ 代码,一路都是坑

技术笔记 OutlandC++踩坑

先交代一下背景,不然会显得这些坑很蠢。

游戏的主文件(src/game/game.cpp)两万八千行,加上其他部分,整个项目 C++ 大约 4.75 万行。

这个量级下有个很实际的约束:不能整个文件读进来改。 实际工作流是 grep 定位行号、只读那一段、然后精确替换。项目里还有个 AGENTS.md,写的就是给 AI 协作者的守则。

“AI 协作者”包括我自己——我平时也是用脚本和 patch 工具在改这个仓库的。所以这篇不是吐槽某个工具,是记录一种改代码的方式会踩什么坑。

这些坑有个共同形状,我把它放在最前面:

工具回报”写入成功”,代码看着也对,然后编译炸在一个无辜的地方——或者更糟,编译全过,游戏一启动就崩。


一、传输层:我的代码在写入过程中被改了

① C 字符串里的 \n 变成真实换行

写了一句:

std::printf("[FURN] x\n");

经过脚本/长 patch 传输之后,那个 \n 变成了真实的换行符——字符串跨行,MSVC 报:

C2001 常量中有换行符

这条的麻烦在于你看代码的时候它是可疑的、但很难一眼看出。编辑器里那一行的结尾确实有个断掉的样子,但人的眼睛会自动补全。

规矩:凡是通过脚本/长 patch 写入含 \n、\t、\x 的 C 字符串,写后必须核对该行是不是单行(cat -A 或 repr());更稳的做法是用显式控制转义的方式整块写入,别依赖 shell 把转义原样传过去。

② 参数被截断,残缺片段当成字面量写进了源码

一次 replace 的替换文本太长,传输中被截断,残缺片段——常带着 ...[truncated] 字样——被原样写进了 .cpp:

C2143 语法错误: 缺少";"

然后连锁引发后面所有函数的 C2601 本地函数定义非法,文件尾再来一个 C1075 未找到匹配 }。

一个被截断的参数,能报出五个地方的错。

自己检查的办法很简单:grep -rn "truncated" src/ —— 正常的源码里不可能有这个词。

但更阴的是截断可能不带标记词。残缺行可以只是 ... 接半句:

ttf.draw_text(ren, 12, 12...

这个 grep truncated 抓不到,编译报 C2059 语法错误: ".".

排查方法:grep -n '\.\.\.' src/*.cpp 然后排除合法的 catch (...) 和中文文案里的省略号(那是合法内容)。任何非注释代码行出现裸 ...,就是截断或手写残留。

③ 单次调用有大小上限,超限是整条调用失败

这条和第②条不一样,值得分开:当替换内容超过大约 8K token,整个工具调用会超时,参数不落盘。

第②条是”残缺的内容被写进去了”;这条是整轮白干,重来一遍才行。

规矩:大改动拆成多次小替换,每块独立核对返回的 diff 再发下一块。一次只改一个逻辑单元——一个函数、一个表格段。


二、锚点层:脚本自己把代码结构改坏了

这一类全都是”脚本成功写入、编译才炸”,而且报错会指向无关的函数。

④ 插入块的首部换行被 strip 掉了

我写了个插入函数,在函数定义行前面插代码块,顺手把插入内容开头的 \n 去掉了(add.lstrip("\n"))。

结果新块和原来的 { 粘在了同一行:

void Game::foo() const {// 注释

新函数被嵌进了上一个函数的函数体 → C2601 本地函数定义非法,并且后面所有函数连锁报错。

规矩:插入块保留首尾换行;插入点选上一行注释或空行当锚点,不要拿函数定义行当锚点。

⑤ 锚点包含了结尾大括号

如果锚点以 }; 结尾,而插入块自己也带收尾括号,插入后就会出现一个孤立的 } → 结构提前闭合。

规矩:锚点取字段行或语句行本身,收尾括号由原文件保留;插入后数一下目标结构里的 }; 个数。

⑥ 锚点是一条带 { 的控制语句

这条最阴,我当时盯着一堆”看着完全正确”的代码查了很久。

我把新代码块插在这样一行后面:

if (const char* X = std::getenv("OUTLAND_FARM"); X && *X) {

于是新代码落进了那个 if 块内部——只有当设置了 OUTLAND_FARM 时才执行。

症状是:某个测试钩子完全没有任何输出,而我把那段代码逐行看过三遍,怎么看都对。

规矩:新块插在同级语句之间(上一块的 } 之后,或者注释/空行作锚点),插完 grep -n -A3 "<新符号>" <file> 看缩进和它所属的块,再编译。

⑦ 补丁脚本必须幂等

改完参数重跑同一个脚本是常态。没有守卫的”插入声明/插入字段”会插第二遍:

C2086/C2374 重定义

实测:某个稀有弹计数成员被插了两次。

规矩:插入前先判存在(if 已存在: 跳过),且重跑后同样跑一遍自检。

⑧ 写完之后的自检动作:数定义次数

这个办法比看编译报错快得多:

grep -c "void Game::print_vitals() const {" src/game/game.cpp

必须等于 1。

每个改动过的函数都跑一次。不要靠读代码和编译报错来确认”改上了没有”。

顺带一条同源的经验:新增 CLI 开关或局部变量之前,先 grep -n "<名字>" src/main.cpp 看有没有重名。我撞过一次——新变量和一个已有参数重名,报 C2374/C2086。换一个不撞名的名字,并确认声明、赋值、调用三处一致。


三、最狠的一条:把整个文件写成了残片

前面几条都还能救,这条差点让我丢一天。

脚本是这么写的:

L = s.split("\r\n")
for t in L:
    if "某片段" in t:
        continue
    out.append(t)

而那个文件实际是 LF 行尾。

于是 split("\r\n") 只切出一整块——当然是整个文件。那个块里当然包含”某片段”→ 命中 continue → 整个文件被丢弃。

写回之后,几千行的文件变成几十行的残片。

最坏的是:脚本打印了”改动: […]”,看着像成功。 只有编译报 main.cpp(1,5): 语法错误: "if" 才暴露。

规矩有四条,我一条条都补上了:

  1. 不做”按行循环 + 子串判断”的批量改写。 精确替换就用工具的 old_string/new_string;脚本里就做成成对全文替换,并打印命中次数——0 命中就是没改成,不是成功。
  2. 真要按行处理,先判定真实行尾("\r\n" in s)再决定分隔符;判行内容用 t.strip() == 精确文本,别用 "片段" in t(后者会把整文件当命中行)。
  3. 每个大改动前留一个干净的 git 检查点。 写坏之后的正确恢复是 git checkout -- <file> 然后重跑那几个补丁,不要手工补残片。
  4. 写完立刻核 wc -l 和关键符号计数,和写前对照。只信数字,不信脚本打印的”成功”。

⑨ 行尾混用,之后所有锚点集体失配

跟着上面那条来的一课。

脚本写回时用了 newline='',插入块用 '\n'.join(...) 拼装,插进一个原本 CRLF 的文件 → 文件变成逐行行尾不一致。

从此之后,所有”看起来完全正确”的 old_string 都 0 命中——而 cat 和编辑器里看不出任何差别。同一处锚点连试好几轮还是失配,基本都是这个原因。

规矩:

  1. 改文件前先统一行尾:读入后 s.replace('\r\n', '\n') 再匹配和替换;写盘固定一个行尾,别一处一种。

  2. 锚点反复失败先查行尾,而不是换锚点:s.count('\r\n') 和 s.count('\n') 两个数字不等就是混用;归一化之后用同一个锚点重试。

  3. 替换必须 assert:assert old in s, "锚点未命中"。静默 0 命中会让后续所有步骤建立在错误前提上——实测有一次在”门槛打了补丁”的前提下继续做了好几步,其实一行都没进。

  4. 还有一个副作用值得单独记:整文件换行重写 = 提交里的巨型 diff 噪音。 实测某个文档文件报 1814 行变动,忽略空白之后其实只有 32 行。

    git diff --stat -w HEAD~1 HEAD
    

    加 -w 之后行数骤降,就是换行重写,不是内容改动。


四、语义层:编译全过,运行时才炸

⑩ 对 JSON 的字符串值做 static_cast<char> 是未定义行为

我在一个数据解析器里写了:

d.symbol = static_cast<char>(e.value("symbol", '?'));

当 symbol 字段是字符串("T")时,static_cast<char> 一个 JSON 字符串值是 UB——不是编译错,是运行时崩溃。

症状:游戏一启动就崩,没有清晰的报错,很容易误判成别处的问题。

正确写法:

const std::string s = e.value("symbol", std::string{"?"});
d.symbol = s.empty() ? '?' : s[0];

单字符 glyph 绝对不要直接 static_cast<char> 一个 JSON 值。 对照项目里既有的解析器写法抄。

⑪ std::string(getenv(...)) 没判空 → 必段错误

std::string s = getenv("X");    // 环境变量没设时,getenv 返回 nullptr

用 nullptr 构造 std::string 是必现段错误(退出码 139),没有异常码、没有崩溃日志可读栈。用户侧看到的现象是”点某个页面,窗口直接消失”。

规矩:getenv 结果先存 const char*,判 if (v && *v) 之后再用。测试钩子同理:默认态必须是”禁用”,只在环境变量存在且非空时才进入待触发分支。

⑫ windows.h 的 min/max 宏吃掉 std::min

cy3 = std::min(cy3, py0 + std::max(0, ph - ch3));

报:

C2589 "(" : 非法标记

同一个函数里连锁一串 C2589/C3878/C2760,全都源自这一行。

原因:平台层 include 了 windows.h,它定义了 min/max 函数式宏,宏匹配到 std::min(...) 里的 min 记号,把它展开成了非法表达式。

规矩:凡 include 了 windows.h(直接或间接)的文件,一律不用 std::min/std::max(包括 std::min({a,b,c}) 那种初始化列表版),统一改三目运算符或写个局部 helper。

这条我踩了两次(一次 std::max 版、一次 std::min 版)。项目没定义 NOMINMAX,所以别依赖”上次编译过了”。

⑬ 类作用域:返回类型写在 Game:: 之前时,嵌套类型要全限定

FarmPlot* Game::plot_at(int, int)          // 编译不过:C2143 缺少类型说明符
Game::FarmPlot* Game::plot_at(int, int)    // 对

参数表和函数体里用非限定名就行(已经进入类作用域了)。

同一个坑的兄弟:要被 main.cpp 调用的新方法/新嵌套类型必须落在 public: 区。 类里的顺序常常是”public 方法 → private 数据成员”,我把新结构体定义和方法声明插在私有数据成员附近(想着”挨着相关字段”),外部调用就报 C2248 无法访问 private 成员。

自检:改完在类里 grep -n "public:\|private:" 看新块落在哪个段内——比等编译报错快。

⑭ 批量把 fp. 改成 fp-> 之前,先分清指针和引用

同一批代码里两种写法并存:

FarmPlot* fp = plot_at(...);        // 指针,用 ->
for (auto& fp : farm_plots_) {...}  // 引用,用 .

正则一把梭全改成 ->,引用那几处就报 C2819 类型没有重载成员 operator ->。

规矩:按函数边界分别处理——先定位函数行范围,只在该函数体内按该函数的变量语义替换,再编译验证。不要全库替换。


五、编译全过、日志全绿,中文是乱码

⑮ 手写 \uXXXX 码位,凭记忆抄必错

这个坑很离谱,但它是整个列表里我最想写下来的。

有一批中文文案是用 \uXXXX 转义字面量写在 C++ 源码里的。我手写这些码位——凭印象。

结果:

想写实际写成游戏里显示
掷\u6314投挔
睡\u89D9睡觙
滚\u7C4D籍动
饥\u9990解馐

编译全过、构建全绿、测试钩子不覆盖渲染文本——只有截图或者日志里看到乱码才发现。来回好几轮才修净。

规矩:

  1. 码位由脚本生成,不手写。 用 Python 把整句中文转成 \uXXXX 串再整行替换;查单个字的码位也走脚本。
  2. 写后必过机器解码比对。 把源码里的 \uXXXX 字面量抽出来,转录成中文,和期望文案逐条字符串断言——用脚本,不是肉眼扫。一次批量写 N 条就跑一次全量比对,比截图发现乱码省好几轮往返。
  3. 重建之后跑一次截图 + 视觉确认关键行中文正常,作为最后一道闸。

教训:凡是”人能看出来但工具看不出来”的错误,都要有一条机器判据。 不然它就会一直躺在仓库里,直到某个巧合让你看见。


六、数据层的静默退出

⑯ 新加的 JSON 被通配加载器误读 → 未捕获异常 → 静默退出

我新加了一张数据表 data/base/recipes_<新名>.json。

启动即退:stdout 只剩最后一两行、退出码非 0、没有 crash.dmp、Windows 事件日志没有记录、crash.log 里只有一行 code=0xe06d7363。

根因:既有的”附加配方加载器”扫的是 data/base/recipes_*.json(这是 Mod 扩展点),把新表也当配方读了。而新表的顶层是个对象({"tech": {...}, "note": "..."}),迭代到字符串值的时候抛了 nlohmann::json::type_error → 未捕获 → std::terminate。

0xe06d7363 这个码是 MSVC 的 C++ 异常。

这类崩溃最难查的地方是它不给你现场:没有栈、没有 dump、只有一行码。而且它会伪装成”某个无关的东西坏了”,因为崩在加载阶段,看起来哪儿都像坏的。

规矩:新增数据文件先看清命名前缀会不会落进某个通配加载器的扫描范围(recipes_*、items_* 这类)。要扩展就扩展加载器,别在别人的通配命名空间里放一种形状不同的东西。


七、我现在改这个仓库时的固定动作

写到这里整理一下,其实就八条:

  1. 改大段之前留一个干净的 git 检查点。 写坏的恢复方式是 git checkout -- <file>,不是手工补。
  2. 匹配前先统一行尾。 锚点反复失配,第一个怀疑对象是行尾,不是锚点本身。
  3. 替换必须 assert 命中,且要打印命中次数。 0 命中 = 没改,不是”改好了”。
  4. 大改动拆小,每块独立核对 diff 再发下一块。 一次一个逻辑单元。
  5. 改完立刻回读校验。 脚本报错就等于没落盘;wc -l + 符号计数与写前对照。
  6. 每个改动过的函数数一遍定义次数,必须等于 1。
  7. 任何”人眼能看出、工具看不出”的错误,配一条机器判据(grep -c "truncated"、定义次数、\uXXXX 解码比对)。
  8. 只信数字,不信工具打印的”成功”。

八、这类坑的共性

回到最前面那个形状。把它们摆在一起看,其实只有三件事:

一、报错位置会撒谎。 一个被截断的参数,能在五个无关的地方报语法错误;一次粘行,能让后面所有函数连锁报”本地函数定义非法”。报错行号指向哪里,不代表问题在哪里——所以我学会了数定义次数,而不是看报错。

二、有一整类错误是”编译期不可见”的。 UB、getenv 空指针、未捕获异常、错误的转义码位——它们全都能通过编译、通过构建、通过一批测试。这时候唯一的办法是把判据往下压一层:不只问”编译过了吗”,还要问”启动了吗、崩溃日志有没有、中文对不对、数值变了吗”。

三、工具的”成功”是它自己的视角。 脚本写了文件 = 文件变了;但文件变了 ≠ 内容对。所以规矩只有一条是真正通用的:写完自己回读,用数字对照写前。

最后一条我觉得对人和对 AI 是同一条:

不要用”看起来对”当验收标准。 因为这类错误的特点恰恰是——它看起来是对的。