1. 这标题是个陷阱——屎山代码背后的真实警示
先讲个我自己的经历。有一次接手一个老项目,打开核心服务文件,密密麻麻两千多行,变量名从 a、b、data 到 handler、manager、utils 什么都有。我一边皱眉一边翻,在心里骂了十几次“这是哪个天才写的”,直到某天同事提醒我用 git blame 查一下,结果那几段最恶心的代码底下,赫然挂着的提交作者是我自己,时间是两年前。那一刻我沉默了,也突然就理解了“屎山代码”这四个字为什么能在技术圈引起这么大的共鸣——因为它太真实了,真实到每个人多多少少都亲手堆过几层。
屎山代码,圈内更正式的说法叫“大泥球”(Big Ball of Mud),1999年就有人专门写了文章讨论这种没有清晰结构、靠无数补丁和临时修改维持运转的系统。它从来不是一天建成的,而是无数个“先这样吧,后面再改”“这个需求明天就上线,先硬写出来”堆积起来的。但你可能也注意到了,今年“屎山代码”这个词突然又火了起来,甚至有人开始用自嘲的口吻说自己是“屎山工程师”。当自嘲变成一种集体文化时,说明大家是真的被搞痛了。
这篇文章的标题用了反话正说,说“写出屎山代码的12个技巧”,实际上是把日常代码评审、项目维护里最常见的反面操作集中起来示众。每一个技巧背后,都对应一个真实存在的坏习惯。我强烈建议你不要只看热闹,而是拿这些反面教材当镜子,照一照自己的代码。新手可以拿它建立代码品味的边界;被老项目折磨过的维护者能从中看到熟悉的痛;带团队的负责人甚至可以直接把清单变成评审红线。下面这12条,我先按“代码本身的毒化”和“工程协作的腐化”分成两批来讲,你会发现它们各自的杀伤路径很不一样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前六招:在代码里埋下“可读性”地雷
先看代码本身层面的六个问题。这六招的共同点在于,它们都发生在“写代码的那一瞬间”,瞄准的是未来所有要读这份代码的人。代码写出来是给机器执行的,这一点没错,但它首先是给人读的。人读不懂的代码,机器也没法长期安全地运行。前面这六招,招招都针对“可读性”下手。
2.1 技巧一:给变量起名,以别人看不懂为荣
怎么写?变量名只用 a、b、c、data、info、tmp、res、flag,需要区分多个数据时就在后面加数字,list1、list2、list3。更有甚者中英文混写,拼音和英文单词交替出现,比如 getMaxHeigh、copyDanHao、totalJine,缩写更是完全看心情。
为什么这是颗雷?因为命名是代码的第一层文档。读代码的时候,每遇到一个无意义的名字,就必须回溯到它的赋值处,搞清楚数据从哪来、往哪去。读这种代码像做侦探,每次都要在脑子里面建一张“解码表”。我印象最深的一次维护经历,是看到一个变量叫 xx,上下文完全看不出来它是什么,最后追踪了十多分钟才发现它存的是“订单流水号”。那一刻我差点把键盘捏碎,因为如果它直接叫 orderSerialNo,十秒钟就能读懂。
正确做法其实一点都不复杂:变量名要表达业务语义,布尔值用 is、has、can 开头,方法名用动词开头,类名用名词,功能是什么就写什么。把 int a = 3; 改成 int timeoutSeconds = 3;,阅读速度提升是立竿见影的。别担心名字长,长名字的代价远小于猜名字的代价。
2.2 技巧二:一个函数塞满整个业务流程
这是“万能函数”流派。一个方法几百行甚至上千行,循环、判断、数据库操作、日志、埋点、缓存刷新全塞在同一坨里,局部变量到处都是,业务规则和流程控制混在一起。你问它负责什么?它负责“一切”。类也一样,一个 Service 类上万行,打开文件光是滚动条就拖半天。
这种写法的致命之处在于心智负担。函数越长,局部变量越多,状态分支的组合就越复杂,人脑根本无法同时记住所有分支。改一个边界条件,必须把整个函数通读一遍才能确认有没有影响到别的地方。最现实的结果是:作者本人两周后再看,也要从头开始“考古”。
正确姿势是让函数短小并且单一职责。一个函数只做一件事,把每一步业务动作抽成命名清晰的私有方法,主流程只保留一串调用序列。看主函数就像看目录,想看细节再跳进去。这里我给你一个直觉上的对比:
python复制# 反面:一个函数解决所有问题
def process_order(order_id):
order = db.query("select * from orders where id=...")
if order["status"] == 1:
...
if order["amount"] > 100:
...
inventory = db.query("select ...")
# 继续往下写两百行
python复制# 正面:主流程像目录一样清晰
def process_order(order_id):
order = fetch_order(order_id)
if not order.is_payable():
raise OrderStateError(...)
deduct_inventory(order)
send_notification(order)
2.3 技巧三:把魔法数字和神秘字符串撒满全场
魔法数字和硬编码字符串,是很多老项目里最常见的“传承”。if (status == 3)、sleep(5000)、url = "http://..." 直接写在业务代码里,“success”“failed”这些字符串散落在各个文件,拼错一个还不报错,只有运行到那个分支才炸。
为什么这类代码让人头疼?因为数字和字符串本身没有语义,它们代表的是代码作者和读者之间的一种“隐藏约定”。你对 3 背后的含义心知肚明,但读代码的人只能靠猜。一旦需求变更,你要全局搜索所有“3”出现的地方,人工确认哪个是订单状态、哪个是过期天数、哪个是别的什么。漏掉一个,线上事故就来了。
正确做法是用常量、枚举和配置代替裸值。订单状态用枚举 OrderStatus.PAID 而不是裸的 3,URL 和阈值放到配置中心,字符串类型标记用枚举约束起来。改起来只动一处,读起来一目了然。
2.4 技巧四:用Ctrl+C/V构建分布式代码副本
复制粘贴是屎山最大的加速器。同一段逻辑,在这个类里粘贴一次,在那个类里再粘贴一次,换了点变量名就当新代码用。最典型的场景是:某个校验逻辑在 A、B、C 三个模块里各有一份,看起来一模一样,实际已经因为历次需求变动产生了细微差异。改需求时你得全局搜索相似片段,一个个比对,漏掉任何一个都是隐患。
为什么说重复是软件腐化的头号来源?因为每多一份副本,维护成本就成倍增加。你修好了 A 处,B 处和 C 处还是坏的;更坑的是 B 处和 A 处长得几乎一样但行为不同,排查的人根本分不清哪个是“正确版本”。
正确做法是“三次法则”(Rule of Three):同一段逻辑出现第一次,先忍住;第二次,停下来观察;第三次,就应该提取公共函数、策略模式或者模板方法,把公共逻辑收拢到一处。当然也别走向另一个极端——就出现一两次就强行抽象,过度设计本身就是一种屎山。
2.5 技巧五:参数列表越长,显得你的函数越重要
把函数参数当作展示架,十几个参数一字排开:
java复制public void processUser(String name, int age, String email, String phone,
String address, String city, String country,
String idCard, String memberLevel, int points, ...)
或者干脆反过来,用一个 Map 或者 JSONObject 当入参,键名靠调用方记忆,取值靠猜测。你问为什么这么设计?答:灵活,以后加字段不用改方法签名。
长参数列表的麻烦在于调用方记不住顺序,漏传一个参数、传错一个类型,编译期可能都发现不了;而大 Map 入参等于把类型安全彻底扔掉了,IDE 的自动补全和静态检查全部失效。代码看似灵活,其实脆得像纸。
正确做法是参数对象模式,把内聚的参数封装成一个对象;对外接口用 DTO 限定输入输出结构。同样是处理用户信息,processUser(UserProfile profile) 一眼就知道传入的是什么,编译器也能帮你拦住大部分错误。
2.6 技巧六:让缩进成为代码的精神状态
嵌套地狱的典型形态是:for 里面套 if,if 里面套 try,try 的 catch 里再套一层 while,缩进层层叠叠,代码往右飘出去老远,右侧竖着一排右括号。更致命的是完全不使用提前返回,所有逻辑都往 else 分支里叠,一个条件分支下来,主路径早就淹没在括号海洋里了。
为什么要避开深层嵌套?因为嵌套深度每增加一层,读代码的人就要多记住一个条件上下文。大脑的“内存”是很有限的,第三层嵌套还能跟上,第六层嵌套基本就只能靠数括号了。这种代码的后遗症很典型:没人敢动,改一行可能整个花括号就错位了;也几乎没法测试,因为每个分支都包裹在多重条件里。
正确姿势是用卫语句(guard clause)和提前返回把异常情况拍平:
javascript复制// 反面:嵌套到怀疑人生
function handleRequest(req) {
if (req) {
if (req.isLogin) {
if (req.hasPermission) {
if (req.body) {
// 继续往下写
} else {
return error("body is missing");
}
} else {
return error("no permission");
}
} else {
return error("not login");
}
} else {
return error("req is null");
}
}
// 正面:一串卫语句,逻辑立刻平铺
function handleRequest(req) {
if (!req) return error("req is null");
if (!req.isLogin) return error("not login");
if (!req.hasPermission) return error("no permission");
if (!req.body) return error("body is missing");
// 正常处理逻辑放最后
}
深嵌套和副作用还不太一样,它是纯结构性的问题,几乎每个项目里都能找到几处。我的建议是:宁可多写几行卫语句,也不要为了“看起来简短”而叠条件,简约和隐晦是两回事。
3. 后六招:在工程协作里埋下“维护性”地雷
前六个技巧是“代码写出来就自带毒性”,后六个则更像是团队协作、项目长期演进过程中慢慢渗出来的毒。它们不直接体现在某一行代码上,而是决定了一个项目半年后还能不能继续维护、一年后还要不要重写。如果说前六招是“自爆”,后六招就是“让整个团队一起陪葬”。
3.1 技巧七:注释要少写“为什么”,多写“是什么”
这招的精髓是让注释成为一种复读机。// 加1,然后下面写 i++;// 设置用户名,然后下面写 user.setName(name)。还有一种更高级的操作:注释堂而皇之地描述逻辑,代码已经改了三版,注释还停在第一版。至于“为什么这里要这么写”这种关键信息,一个字都不写。
为什么这会毒化项目?因为无信息量的注释是噪音,而不准确的注释是谎言。代码至少还在运行,注释一旦和代码不一致,就会把后来者带进沟里。你看着注释理解了一版错误逻辑,代码实际跑的又是另一套,排查问题的成本直接翻倍。更可怕的是,没有注释解释关键分支的来龙去脉,后来的人看到“为什么这里要重试三次”“为什么这里要等五秒”完全摸不着头脑,只敢原样保留,屎山就越垒越高。
正确做法很简单:注释聚焦“为什么”,而不是“是什么”。为什么这里用异步而不是同步?为什么这个分支要特殊处理?这个参数为什么默认是 3?代码的“是什么”尽量让命名和结构自己说话。而且每次改代码,一定要同步更新注释,不一致的注释比没有注释糟糕得多。
3.2 技巧八:全局状态越多,系统越“灵活”
全局状态和隐式耦合是工程层面最隐蔽的杀手。写法上表现为:static 变量贯穿全局,一个共享的单例对象被七八个模块读写,多个服务共享同一个可变配置缓存;模块之间不通过函数参数和返回值通讯,而是靠“大家都去改同一个全局对象”来传递信息。
这种代码的可怕之处在于时序依赖。A 模块先把某个值写进全局变量,B 模块在另一个线程里去读它,两者之间没有任何显式约定,只要执行的先后顺序一变化,系统行为就完全不可预测。排查并发问题的时候,你得把所有可能改动这个全局变量的地方全部列出来,逐个排除,那种绝望感没有经历过很难描述。而且可变全局状态还会污染测试——测试 A 和测试 B 共享同一个状态,跑的顺序不同,结果就不同,最后只能靠 sleep 和大面积重置来续命。
正确做法是尽量消灭可变全局状态,让数据流从入口到出口单向流动,函数之间通过参数传值、通过返回值收结果;必须共享的上下文保持只读;真需要跨模块共享,用依赖注入把依赖关系显式写出来。这个转变初期会觉得麻烦,但长期收益非常大。
3.3 技巧九:catch住异常,然后若无其事地吞掉
这是我最恨的一种写法,没有之一。catch (Exception e) { } 空块,捕获了异常什么都不做;或者 catch 之后返回一个 null 或者默认值,让上层继续跑;再或者,日志里只打一行堆栈,然后当作无事发生。表面上看,程序“健壮”了,再怎么出错都不崩了,问题是它会带着脏状态继续跑,错误被传递给下游,最终在一个八竿子打不着的地方爆出诡异现象。
经典的悲剧场景是:一个远程调用失败了,代码把异常吞掉并返回空列表,上层业务拿着空列表继续计算,最后用户看到的是一个完全不符合预期的结果,报错信息里却找不到任何和远程调用相关的线索。排查这种问题,你得从最终症状一路反推,翻遍所有中间层,最后才发现最底层三年前的异常被吞了。
正确做法是分清楚哪些异常可以恢复、哪些不可以。在合适的边界捕获异常,打日志要带上下文(不光是堆栈,还有是哪个订单、哪个用户、哪个环节),保留原始异常链;拿不准的时候不要吞,让它抛出去,让有处理能力的地方去处理。
3.4 技巧十:测试只测快乐路径,失败交给命运
我给这条起了个名字:温室测试。测试数据全部是合法输入,参数永远齐全,权限永远足够,接口永不超时,数据库永不宕机。只测“一切顺利时正常工作”,从来不测超时、降级、校验失败、重复提交、权限不足这些场景。更极端的是完全不写自动化测试,交付质量全指望手工点一点,运气好了就上线。
问题在于,生产环境从来不是温室。一个系统最危险的时候不是正常运行时,而是依赖挂了、流量突增、数据异常时候的失败路径。快乐路径的测试只能证明“顺利时能用”,不能证明“不顺利时不出大乱子”。而且没有测试保护的代码,改起来像走钢丝,每次重构都心惊胆战,最后所有人都不敢动,项目自然走向僵化。
正确做法是给关键业务补三组用例:正常路径、边界值、异常分支。用依赖注入把外部依赖换掉,测试里可以模拟失败,不需要依赖真实网络和数据库。跑得快、跑得稳定、能上 CI 的自动化测试,比手工点一百次有用得多。
3.5 技巧十一:拒绝重构,用补丁创造历史沉淀
这种代码风格在久经沙场的项目里特别常见:每次需求变更都在原有逻辑上叠一个 if,遇到老代码跑不动的场景就加一个参数开关;新旧逻辑并存,越堆越多;面对明显的坏味道,用“没时间”“怕改炸”“先这样吧”来安慰自己。日积月累,真实逻辑被一层又一层补丁裹成洋葱,核心路径彻底看不清了。
技术债这东西就跟信用卡一样,利息越滚越快。你欠下的不是某一笔代码,而是整个团队的交付速度。每加一个功能,评估时间从半天变两天,从两天变一周;每一个改动都可能触发几十个隐形分支的连锁反应。到最后,这个项目会变成没人敢碰的“文化遗产”,所有人都想逃离,没人愿意接手。
正确做法是反着来:坚持童子军规则——离开营地前,让它比你到来时更干净一点。每次修改顺手清掉一小片:把这次涉及到的变量名改清楚,把一个嵌套拆平,把一个魔法值换成常量。小步走,配合测试做回归。大范围的重构单独排期,最好在需求间歇期专门处理。别怕慢,慢是在为过去的债务还利息。
3.6 技巧十二:只要跑通,就别问为什么
最后这条不是技术操作,而是一种心态。报错信息不读完就复制到搜索引擎;网上找一段代码能跑就贴进去,完全不关心它为什么这么写;做需求只问“怎么实现”,不关心“这个需求解决什么问题”;对老代码、框架代码、底层依赖全都是“知其然不知其所以然”。
“跑通”和“做对”是两回事。一段代码能跑,只说明在当前输入下它碰巧给出了预期的输出,不代表它面对异常输入、边界情况或者未来变化时依旧正确。不理解就写出来的代码,连可演进性都没有——表面上项目一直在上线功能,但每次加功能都像是在雷区里小心翼翼地走,时刻准备踩响不知道哪一年埋下的雷。
正确做法是写代码前先把业务逻辑的输入输出理清楚,想明白“这段代码在业务上到底要达成什么”;遇到报错,把完整信息读一遍再动手搜索;拿到一个老项目,先花时间弄清楚核心模块为什么这样设计,最好能画出一个粗略的数据流图;改代码时多问自己一句:“这个字段为什么存在?删了会怎样?”
4. 从反面清单里提炼的“屎山气味检查表”
12个技巧摊开之后,我发现它们反过来用,就是一套特别实用的代码走查标准。与其记着一堆“不要这样写”的模糊感觉,不如把相反的正向要求直接固化下来。我把它们整理成一张检查表,做代码评审的时候逐项对照,中三项以上就说明这部分代码已经需要返工了;新项目可以直接把这张表贴到团队文档里,从第一天就按红线约束。
4.1 一张可以贴在工位上的评审红线表
| 检查维度 | 屎山警讯 | 正向要求 |
|---|---|---|
| 命名 | a、data、tmp、拼音缩写到处都是 |
名字表达业务语义,布尔用 is/has/can |
| 长度 | 一个函数上百行,一个类上千行 | 函数短小单一职责,主流程像目录 |
| 魔法值 | 裸数字、硬编码字符串遍布各处 | 常量、枚举、配置中心统一管理 |
| 重复 | 同一段逻辑复制多份 | 三次重复时提取公共逻辑 |
| 参数 | 函数参数超过五个,或拿 Map 当入参 | 参数对象/DTO 封装,类型明确 |
| 嵌套 | 缩进层级超过四层 | 卫语句/提前返回,逻辑平铺 |
| 注释 | 注释复读代码,或与代码不一致 | 注释写“为什么”,改代码同步改注释 |
| 状态 | 可变全局变量被多处读写 | 显式传参,数据流单向,共享上下文只读 |
| 异常 | 空 catch 或吞掉后返回默认值 | 合适边界捕获,日志带上下文,保留异常链 |
| 测试 | 只测快乐路径,无失败分支 | 正常、边界、异常三组用例齐备 |
| 修改 | 每次都在旧逻辑上叠补丁 | 顺手清理周边坏味道,定期处理技术债 |
| 理解 | 写代码但不追问为什么 | 先理清业务目标,再谈实现方案 |
这张表的用法我建议是“逐项打钩”,而不是“凭感觉评价”。只要有一项出现明确警讯,就应该在评审里指出来,而不是觉得“反正能跑就算了”。尤其是命名、魔法值、重复这三项,它们是最容易改的,也是最容易忽略的,一旦放行,后面代码会迅速往坏的方向滑。
4.2 三个警告信号:你的项目正在屎山化
检查表是静态的,还有一些动态信号能提前告诉你“屎山正在形成”。我总结了自己带项目时最敏感的三个信号。
第一个信号是“每改一个小需求都要翻遍五个文件”。一个简单的文案变更,改完还得检查有没有别的地方也写了同样的字符串;加一个字段,从接口、实体、DAO、Service 到前端页面全要动一遍,这种“改动放大效应”是项目复杂化失控的明确标志。
第二个信号是“读代码时要不断画地图”。你发现自己每看一个方法,都要往笔记里记一笔“这个东西是从哪传进来的”“这个值在哪里被修改的”,地图越画越复杂,说明代码里充斥着隐式流转和跨层依赖,已经没有人能从全局想清楚一条请求的完整路径了。
第三个信号是“git blame 之后发现最臭的代码进库超过一年,并且没人敢动”。坏味道不可怕,可怕的是它长年存在且成为“禁区”。一旦某个文件或某段逻辑变成“碰了就出事的祖传代码”,你的项目实际上已经失去了持续演进的能力。
这三个信号任何一个亮红灯,都说明问题已经超出了个人代码风格层面,是项目结构与团队协作节奏的问题,必须尽早介入,越拖代价越大。
5. 已经身处屎山,往外爬的四步法
上面说了那么多反面教材,如果你发现自己的项目已经中了好几条,先别慌。写过屎山不可耻,可耻的是明知是屎山还继续往里面堆。下面是我自己实践下来比较有效的四步出逃路线,适合那种“已经烂了但还得继续迭代”的现实项目。
5.1 第一步:先给核心路径补上“行为锁定”测试
重写一个新系统永远比改造老系统听着爽,但直接重写大概率会掉进更大的坑——因为老系统虽然烂,里面却积累了大量通过线上验证过的业务细节,你重写时很难在一开始就把这些细节全部考虑周全。所以第一步不是重写,而是先锁定当前行为。
具体做法是给核心入口、最容易出错的模块写特征测试(characterization test):准备好输入,记录下当前系统的输出,把“现状”固化成断言。这些测试的意义不是证明“系统是对的”,而是证明“系统没变”。有了它们兜底,后面的重构哪怕出错了,测试也会立刻告诉你哪条路径的行为发生了不该有的变化。这一步是在给外科手术铺无菌台面。
5.2 第二步:每次修改都带走一点垃圾
面对一座大山,最有效的策略不是一次把它搬空,而是“每次经过都带走一袋垃圾”。我习惯把自己的改动范围当成责任区:这次要改这个函数,就顺手把它周边的变量名改清楚;下次要加一个新分支,就趁这个机会把这十几行嵌套拆平;每次保持行为不变,做一个小步骤,然后跑一遍测试验证。
一年下来,你会发现很多频繁改动的文件“气味”明显变了。这不是什么玄学,而是复利效应——越核心的文件被访问得越多,你顺手清理它的机会也越多,而核心文件的健康度对整个项目的影响是最大的。
5.3 第三步:用机器和工具替你盯住复杂度
靠自觉对抗代码腐化很难持续,必须让工具当哨兵。静态分析工具比如 SonarQube、ESLint、Checkstyle,以及各类圈复杂度检查插件,可以自动报告重复代码、魔法数字、高复杂度函数等大部分坏味道。IDE 的重构快捷键——rename、extract method、extract variable——也能让绝大多数重构操作变成机械性的安全操作。规则配好以后,机器比人记仇多了,它不会因为“当时赶进度”就对坏味道心软。
5.4 第四步:在代码评审阶段设置拦截红线
如果你的项目有代码评审流程,把前面那张“评审红线表”直接作为 PR/MR 的检视清单。评审不发散,不靠个人审美吵来吵去,只对着清单逐项打钩,不符合就打回。新代码不再污染,老代码再一点点清理,整个过程就进入正循环。等团队习惯了这套红线,你会明显感觉到新引入的坏味道变少了,大家的重心终于可以从“救火”转向真正的功能设计。
坦白讲,我自己产出过不少“当时的自己觉得挺合理”的屎山代码,git blame 照妖镜没少照到自己。但被照过几次之后,我心里评价代码的标准就彻底变了:衡量一段代码好不好,不是看我写的时候顺不顺手,而是看三个月后的我或者我的同事要花多大力气才能读懂它、修改它、并且不炸。这个标准一旦立住,你就再也回不到“能跑就行”的状态了。
