接手一个遗留系统,打开仓库里那些核心文件的一瞬间,我经常觉得不是在读代码,而是在考古。缩进随心所欲,变量名从a、b、tmp1排到tmp9,一个函数五百行起步,注释写的是“这里别动,会炸”——这种代码风格,我一般叫它 caveman 风格。不是石器时代的那种浪漫想象,就是字面意思:代码像穴居人一样野,能跑、能活,但完全没法体面地见人。
这篇文章我想把这种“caveman 代码”从头到尾聊透:它到底长什么样、为什么会大面积存在、接手之后该怎么下手收拾、以及在什么情况下你应该捏着鼻子不去动它。不管你是刚入行被人甩了一脸祖传代码的新手,还是正带着团队在历史债务里挣扎的研发负责人,这篇文章应该都能给你一点顺手能用的东西。
1. 什么是 caveman 代码风格:先从一段“野生代码”说起
1.1 一个典型的 caveman 现场
先看一段我前一阵处理过的真实代码,是内部一个库存对账模块的核心函数。原代码经过脱敏,但结构一字未动:
def chk(a, b, c=0): x = [] for i in a: tmp = i.get('id') tmp2 = get_stock(tmp) if tmp2 is None: tmp2 = 0 for j in b: if j['pid'] == tmp: if j['qty'] > tmp2: x.append({'sk': tmp, 'diff': j['qty'] - tmp2}) if c > 0: x = [d for d in x if d['diff'] > c] return x这个函数的功能其实很简单:比对系统库存和台账库存,筛出差异超过阈值的商品。但你第一眼看得出来吗?看不出来。a是什么、b是什么、c是什么、返回值里的sk和diff是什么意思,全靠上下文猜。缩进还算给面子,要是再遇到那种 tab 和空格混合的,光对齐就能看掉半条命。
这种代码就是典型的 caveman 风格:能跑,且可能跑了很久都没出过事,但阅读成本高得吓人。如果你要改它的逻辑——比如把“仅筛选差异”改成“同时记录库存不足的商品”——你得先花半天时间搞清楚tmp、tmp2们分别是什么。而这半天时间,就是 caveman 代码最核心的隐性成本。
1.2 caveman 风格的五宗罪
我把这些年见过的 caveman 代码总结了五个高频特征,各位可以对号入座:
- 命名全凭手感:函数叫
do()、handle()都是常事,变量名多用data、list、tmp轮流顶班。你问作者为什么叫tmp?他会说:反正就临时用一下,谁知道这个“临时”用了五年。 - 函数长到没边:一个函数里既有数据库查询、又有状态判断、还混合着日志格式化和手工拼 SQL。逻辑上明明可以拆成五个函数,全部堆在一个流程里方便。真要拆分时发现里面大量依赖局部变量,牵一发动全身。
- 复制粘贴四处开花:同一个分页逻辑在三个模块里各写了一遍,改 bug 的时候只改了一个地方,另外两个继续带病运行,直到某天被业务数据打脸。
- 状态全部靠外部变量:模块级别的
flag、count、last_time满天飞,函数之间隐形耦合。A 函数改了一个全局标志位,B 函数的行为瞬间改变,排查起来跟破案一样。 - 注释不是怕别人看不懂,是怕自己三天后看不懂:大段大段的自然语言描述塞在代码里,真正该写的“为什么”全被“做什么”掩盖了。
1.3 一个好朋友,还是你认知里的第一位
caveman 风格的最大问题不是丑,而是不可维护性。丑只是审美问题,不可维护是实打实的经济问题。一个需求通常需要三天开发,其中两天花在读代码上。每次变更都要在脑内模拟整个上下游的因果关系,模拟不了就靠猜,猜错了就出生产事故。
你可能觉得:这不就是“代码质量差”换个说法吗?不完全是。caveman 代码的核心特征是没有“契约意识”——作者只考虑自己当下怎么写顺手,不考虑三个月后的维护者能不能理解。正常代码是给机器执行、给人阅读的双重文本,caveman 代码只剩下“机器能跑”这一条底线。
2. 为什么程序员会写出 caveman 代码:这不是能力问题
2.1 环境与工期:能跑就行的压力
我见过不止一个团队,业务方给的需求排期是“明天上线”,代码自然就只能按“今天写出来”的标准来做。这种情况下别说命名规范,能想起给函数加个注释已经算非常负责了。
项目早期,系统里到处是“临时方案”。临时方案一旦上线,就会被业务逻辑层层包裹,想拆也不敢拆。我有个朋友说自己的系统里有个模块,代码注释写着“这里原先是临时用的,后来业务离不开了,别动”。这个注释距今已经有三年。
所以我说,caveman 代码很多时候不是作者蠢,而是环境把“能跑就行”变成了唯一可行的解。关键问题在于,“能跑就行”会形成路径依赖:只要线上没炸,代码就永远没有重写的理由,于是它就在仓库里长成了一棵遮天蔽日的老树。
2.2 心理账户与技能断点:看不懂就改不动的死循环
caveman 代码还有一个更隐蔽的成因——技术在变,人的技能栈没有跟上。你让一个写了十年 PHP 的老手去接一个 Python 服务,他大概率会顺着惯性写出 PHP 味儿的 Python;让他来改一个 Go 程序,他可能连 context 什么时候该传、error 怎么包装都拿不准。看不懂就走不动,走不动就只能绕着走,绕不过去就贴着原来的风格继续往下糊。
这个死循环在代码层面非常明显:老代码没有类型注解,新的技术债也补不上类型;老代码全局变量满天飞,新代码也慢慢学会了共用模块级状态。表面上看是技术债滚雪球,本质上是这个模块的知识没有任何人完全掌握——第一个作者早就走了,后来的维护者只是缝缝补补。
我见过最典型的一次:一个排序逻辑里有个flag = 0的默认值,业务上从来没被改变过,后来新来的人以为它是某种可开关的开关,就加了个开关逻辑进去。上线一周后,业务方发现有单子排序混乱,排查了半天才找到那个从来没人动过的flag。caveman 代码就是这么可怕:它能让每一个后来者都变成“盲人摸象”,每个人摸到的都不是全貌。
2.3 从“能跑”到“能维护”的认知转变
回到本质,代码其实有两个身份:一个是给机器执行的指令序列,另一个是人与人之间传递意图的载体。caveman 代码只保证了第一个身份,扔掉了第二个。
想清楚这件事,你就明白为什么大家都讨厌重构祖传代码——因为重构的本质,是在第二个身份上重建沟通能力,而第一个身份的正确性你一根汗毛都不敢动。这就像房子住久了要重新布线,所有电器都得断电,重新走线之后的每一盏灯都必须重新验证亮不亮。代价肉眼可见,收益看不见摸不着,自然没人愿意主动干。
3. 接手 caveman 代码的实战:从识别到重构
如果你现在必须去动一段 caveman 代码,我建议你按下面的流程来,能少走很多弯路。
3.1 先“考古”再动手,把上下文搞清楚
任何修改的第一步,不是改代码,是把这段代码的“考古现场”摸清楚。
先看版本历史。用git log --follow -p <文件路径>翻一下这个文件每次变更的原因,重点关注几点:当初为什么加这段逻辑、有没有修过什么线上的 bug、哪些提交和紧急发布有关。git blame也能帮你定位到某一行是哪次提交引入的,结合 commit message 来猜作者当时的意图。
再看数据流。不要只看函数内部,要顺着函数调用链往上往下都捋一遍,确认调用方对这个函数的返回值有什么期待。caveman 代码里经常藏着隐式约定,比如调用方可能依赖返回值里某个奇奇怪怪的字段,而这个字段在函数内部根本没有注释说明。
最后看测试。有测试的代码是亲妈生的,没测试的代码是石头里蹦出来的。如果这段代码完全没有测试覆盖,建议你在动手之前先补一层“黄金测试”——也就是把当前行为用测试锁定下来,之后怎么改都有底。
3.2 渐进式重构的四个步骤
确认好上下文之后,真正的重构阶段我建议分四步走,每一步都能独立上线、独立验证,而不是憋一个大版本重写然后祈祷不出事。
第一步叫行为冻结。不管代码多丑,先把它的输入输出边界用测试钉死。对纯函数直接写单元测试,有副作用的就做接口测试或者快照测试。这个阶段代码的内部的丑可以先不处理,只要行为不变量测试全绿,后续的每一步操作都有安全网。
第二步叫小步拆分。每次只拆一层:把一个五百行的函数按“读数据、算逻辑、写结果”切成三个函数,或者把一段重复出现三次的片段提取成公共函数。注意,这个阶段不要随手改逻辑,拆分完跑一遍测试,行为没变就算成功。我见过很多人栽在这一步——自觉拆分的时候顺手优化了一个“看起来很蠢”的判断,结果行为变了,测试挂了,然后就开始后悔。
第三步叫防御性改名。把tmp、data、flag这些含糊的名字改成有语义的名字。这个动作看着简单,实际上对理解成本的影响最大。改名之前先用全局搜索确认没有其他地方引用,改完之后再跑测试。这一步同样不建议和第二步在同一次提交里混着做,不然到时候说不清楚是哪个改动引入了回归。
第四步叫治理依赖。这一步最难,把模块级的可变状态收拢到最小的可见范围,把隐式的全局变量改成显式的参数传递,把依赖顺序理清楚。这一步做好了,后续新功能的开发效率会有质的提升,但它也是最考验整体理解的一步,建议放到最后做,而且一次只动一个依赖。
3.3 我常用的优先级判断清单
不是所有 caveman 代码都值得立刻重构,动手之前我习惯先回答这几个问题:
- 这段代码改动的频率高吗?一年不改一次的老代码,风险和收益都不成正比,留着就行。
- 它在核心链路上吗?带订单、支付、库存相关的深喉逻辑,值当投入;内部工具和一次性报告,看情况再后期为。
- 有测试覆盖吗?没测试的高风险代码,重构前必须先补测试;有测试覆盖的,可以直接小步走。
- 团队里有人完全理解这段逻辑吗?如果没有,建议先安排一次技术分享,一边讲代码一边画时序图,等团队形成统一认知再动手。盲改是最贵的。
提示:如果你要给一段 caveman 代码做重构,无论如何都要先保证当前行为已经被测试锁定。没有测试保护的重构不是重构,是赌命。请不要问我是怎么知道的。
4. caveman 代码的“无罪辩护”:什么情况下别急着重构
4.1 稳定运行且无人再改的边缘部件
不是所有丑代码都需要被拯救。如果一段代码已经稳定运行三五年、每次改动的风险大于收益、而且每个月也没人碰它,那它就算像原始人做饭一样粗犷,也可以原样放着。
我家里有一个内部报表的生成脚本,逻辑说不上复杂,但代码写成了一大坨:全局变量套循环、硬编码的文件路径、英文注释和中文拼音混在一起。有一次我想整理它,回头想了想:它每一个月跑一次,每次成功,输入输出格式业务方早就烂熟于胸,没有任何扩展需求。我花半天整理它,整理完之后还得花半天回归验证,对公司来说这笔账怎么都算不过来。
这种“边缘部件”是 caveman 代码的天然栖息地。我在这里想强调的是:尽量别用一套审美标准去丈量所有代码。如果一段代码永远没有新需求、没有 bug、没有变化需求,那它客观上就不需要被“维护”——一个不需要维护的东西,内在质量再差对你的项目也没有实际危害。
4.2 一次性脚本的天然豁免权
还有一种代码天然豁免:就是那些“跑完就扔”的脚本。比如从某个历史数据源抽取数据然后写入临时表的脚本、临时修数据的一次性 SQL、自己写来核对一份报表的小工具。这类代码最常见的命运是“说是一次性,结果连续跑了三年”。但至少在前面三个月里,你确实不应该给一个临时的脚本上全套的开发基建。
真正聪明的做法是:一上来就给它划定“实验区”。只要把它放在scripts/experiments/这类目录里,或者用// TEMP标记一下,至少从仓储结构上明确这块地方的临时地位。哪怕后来它被冠上“一次性”之大名而长期运行,别人也不会误以为它是被精心设计过的正式系统,从而降低踩坑概率。
4.3 性能敏感代码的权衡
caveman 也不是全无优点:紧凑、直接、函数调用层数少,在某些场景下反而比优雅的分层结构跑得更快。比如一个热路径上的循环,拆成七八个函数各有各的清晰度,但每层函数调用都有开销;再比如一个对象池里全是模板、多态、组合,性能调试起来难度直线上升。
在这种场景下,真没必要为了“可读性”去牺牲运行效率。我的建议是:性能敏感的小函数、小逻辑,如果本身的复杂度可接受,就保持紧凑的写法;但如果它足够复杂、承担的责任足够重大,就值得单独抽出来配上基准测试,保留清晰结构的同时,用测试和基准来保证性能不劣化。
5. 我踩过的坑与排查实录
5.1 坑一:重构完发现行为变了,收尾差一点
有次我给一个对账模块做拆分,把原来的五层循环嵌套拆成了三个独立函数。逻辑上我是完全对照着拆的,测试也全绿。但上线当天,业务方就反馈对账结果和以前不一样了。
排查之后发现:原来代码里有一个变量覆盖的陷阱——在循环内部对外部total变量做了重新赋值,我在拆分时把“累加逻辑”提成了一个不返回值的修改函数,测试数据里恰好没有命中那个会改变total的分支。拆法本身没问题,问题是行为冻结的测试数据不够全,漏掉了边界分支。
现在我的习惯是:重构之前,先看 git 历史里修过 bug 的那些 commit,把对应场景全部补成测试用例。光靠正常路径的正例测试根本不够,bug 回归场景才是真正的行为边界。
5.2 坑二:用工具自动化重构,结果搞出更大的坑
有一段 Python 代码特别乱,我就想着用 autopep8 一类的工具自动格式化一下,至少缩进整齐了看着不烦。本来以为是一次无损操作,结果格式化完了之后,发现有一行代码被折行,注释位置变了,实际效果倒没什么问题,但 diff 变得特别大,代码评审里根本看不出来哪些是自动格式化的改动、哪些是手动的逻辑改动。
从那以后我的原则就是:自动格式化工具要在重构之前单独跑一个提交,且这个提交只做格式化,不掺任何逻辑改动,方便后续过滤。
5.3 坑三:代码风格战争比技术问题难解得多
结构好的代码不一定让每个人都满意。有一次我在团队里提议给核心代码补类型标注,本意是提高可读性,结果由于这台机器的性能特别一把刀,有个老兄坚持说“加类型标注影响性能”,拒绝合并。争论本身倒好说,背后其实是团队里对代码拥有权的呼声:大家都觉得自己有权决定代码长什么样。
后来我把代码风格相关的议题全都收到了“团队共同约定”的框架里,统一一次投票,而不是每次在提测里拉锯。这里面的经验是:caveman 代码的重构不只是技术问题,也是协作问题。一个重要变量只要有人觉得动不得,那你就必须跟他讨论清楚再动。
5.4 排查与治理工具速查
最后整理一下我用到的排查工具和技巧:
- git log -p / git blame:理清演变历史,找 bug 定位的第一站。
- pytest 和快照测试:行为冻结期最好的帮手,推荐自建一个小型快照比对工具,运行时多出几百 ms 没关系,保住行为才是第一要务。
- xx`补类型标注/语法检查:在 CI 里开 pyright 或 mypy,能让大部分“临时变量”和“隐式转换”的优雅问题在合并前就被发现。
- code review 规则:要求“改动代码的同时必须有对应的测试改动”和“不允许无测试保护的大规模重构”,这两条规则逼着大家养成小步走的习惯。
写在最后,说一点我自己的想法
我自己写代码也会偶尔流露出 caveman 的影子,特别是赶需求的时候。诚实说,我不觉得这有什么好羞愧的——caveman 风格是面对真实压力的自然产物,很多优秀的软件最初都是从“能跑就行”的雏形里长出来的。
关键问题不是你曾经写过多少 caveman 代码,而是你有没有能力识别它、有没有意识控制它。我现在的习惯是:赶工写出来的代码,事后必须补一个优化记录,标记为“待整理”;等项目稳定了再排一个专门的时间来整理,而不是让它默默腐烂在仓库深处。
最后再分享一个很实用的小技巧。如果你马上要接手一段非常陌生的 caveman 代码,第一件事不是去读它,而是找人聊。找到曾经改过它的人,让他花十分钟给你讲一遍这段代码的真实业务场景和处理逻辑。哪怕他已经记得不全,这十分钟的信息量也远超你自己埋头读两小时。这种人对代码的“手感记忆”,实际上就是你破解 caveman 风格最快的钥匙。