去年年初,有人找我帮忙看一个项目。说是“看”,其实就是“你帮我们看看这玩意儿还能不能救”。
这项目是用 AI 助手写的——不是“AI 辅助”,是“AI 全程代笔”。团队挺自豪,说程序员负责提需求,AI 负责写代码,效率拉满。然后我打开了代码库。
果然很满。
几千行代码,全都能编译。全都能通过测试。但有一半左右的函数,你根本无法在代码里找到任何调用它们的痕迹。
举个例子:一个叫processLegacyOrder的函数,定义得整整齐齐,文档也写了,参数也传了,返回值也设计了——然后它这辈子都没被叫过。还有个normalizeInputV2,后面又出现了V3和V4,三个同时存在,各自独立演化,互不干扰。
说实话,看到这种情况的第一反应不是“这帮人怎么会写出这种代码”,而是“我很清楚这是怎么发生的”:AI 助手写了一个函数,用了一次,然后 refactor 把调用删了。但函数还在,因为 AI 不会回头去删“自己写的但不再需要的东西”。它没有“记忆”,它只管生成,不管清理。
编译器不会告诉你什么代码在“装死”
Go 的编译器不是侦探,是检查员。它能告诉你类型对不对、语法通不通,但它不关心某个函数有没有被调用。你写一百个没人调的函数,它照样给你编译成功。
go vet不会眨眼,go build不会抱怨,测试会全绿。代码“正确”地躺在那里,只是毫无意义。
这就是 AI 编程时代的一个“脏秘密”:AI 写出了函数,然后忘记了。你合并了这些函数,然后也忘记了。最后代码库里的“僵尸代码”比活代码还多。
一个救了我的工具:deadcode
在花了一个周末徒手分析哪些函数还活着之后,我发现了 Go 团队自己写的一个工具,名字很直白,就叫deadcode。
它干的事很简单:从你的程序入口点出发,构建整个调用图,然后告诉你:“这个函数,没有任何代码能到达它。”
比如你写了这样一段代码:
packagemainimport("fmt""strings")functoUpper(sstring)string{returnstrings.ToUpper(s)}funcgreet(namestring)string{returnfmt.Sprintf("hello, %s",name)}funcmain(){fmt.Println(greet("world"))}toUpper没被调用,但它能编译。你猜编译器会不会提醒你?不会。
运行deadcode:
go run golang.org/x/tools/cmd/deadcode@latest ./...它直接告诉你:
main.go:7:6: unreachable: function toUpper is unreachable就这一行。完事。
为什么现在这比以前重要得多
我后来总结了一下:AI 生成的代码里,死代码的比例明显更高。不是因为它写得差,而是因为它没有长期记忆。一个 AI 生成函数的时候,它不知道五分钟前自己已经生成过一个类似的函数,也不知道那个函数后来被删掉了。
换句话说:AI 每次会话都是“第一次见面”。而代码库偏偏是个需要长期记忆的东西。
这个问题最麻烦的地方不是技术,是认知——你不确定代码库里有 50 个还是 500 个“没用的函数”。你不敢删,因为“也许某个地方用到了”。你也不敢留,因为它们在消耗你的注意力。
那应该怎么办?
别自己写一个 deadcode 分析器。我干过这件事,花了一个周末写 AST 遍历器,然后发现
go/types跨包分析有多复杂。最后用官方工具一分钟解决。官方工具已经写了,直接用。在 CI 里加一步:
go run golang.org/x/tools/cmd/deadcode@latest ./...。如果发现有死代码,让 CI 失败。这样 AI 生成的死代码就不会悄无声息地进主分支。Code Review 的时候多问一句:“这个函数谁在用?”如果没人用,删掉。AI 不会自己清理自己留下的垃圾——但你作为代码的最终负责人,有这个能力。
说到底,AI 写死代码不是 bug,是特性——它本来就没有记忆。但你作为最终 push 代码的人,有。既然你有,就别让它继续装死了。