代码能跑就行?初学者必懂的可维护与易扩展之道
代码能跑就行初学者应该懂得“可维护易扩展的重要性“写了几年代码带过几轮新人我发现绝大多数初学者都有一个共同心态管他三七二十一能出结果就行。数据结构乱一点没关系函数长一点也没关系变量名随手敲个a、b、c更无所谓——反正程序跑起来了需求完成了leader也没说什么。这个心态我曾经也有过而且持续了相当长一段时间。直到有一次我自己写的代码过了三个月再看完全看不懂自己在干嘛改一个bug花了三个小时最后发现改完上一个功能又坏掉了。那次之后我才真正明白代码能跑只是及格线能不能维护、能不能扩展才是区分业余和专业的分水岭。这篇文章我想结合自己做过的项目把“可维护”“易扩展”这两个词拆开揉碎讲清楚。不扯太虚的理论全是我踩过的坑和总结出来的经验。适合刚入行的开发、正在自学编程的朋友也适合那些感觉“代码能跑但总觉得哪里不对”的困惑者。读完你会发现好代码和烂代码的差距往往不在技术难度而在于你有没有把“未来”考虑进去。1. 为什么“能跑就行”是个陷阱先聊清楚一个问题为什么“能跑就行”这五个字听着很爽实际却很坑1.1 “能跑”和“好用”之间隔着一条鸿沟先定义一下什么叫“能跑”。按照初学者的标准能跑程序没有报错结果看着对。这个标准本身没什么错毕竟你第一次写出一个能运行的程序那种成就感是很真实的。但在真实项目里“能跑”仅仅意味着程序执行了一遍离“能用”还有距离离“好用”更是差得远。我见过最典型的一个例子是同事写的报表导出功能。功能本身很简单从数据库读数据生成Excel文件。代码写得很“直率”def export(): rows db.query(SELECT * FROM orders) f open(report.xlsx, w) for r in rows: f.write(str(r[0]) , str(r[1]) , str(r[2]) \n) f.close()这段代码能跑吗能。能导出数据吗能。但你细看全是问题SQL写死了字段位置写死了文件名写死了连分隔符都写死了。下周业务方说“我要加一列”你得改函数内部再下周说“换成分号分隔”你又得改函数内部下个月说“要按日期生成多个文件”你还得改函数内部。一个小改动引发一堆连锁变化这就是不可维护代码的典型症状。问题不在于代码“能不能跑”而在于它把每一个可能变化的地方都焊死在了代码里。1.2 代码是写给电脑看的也是写给未来的自己看的很多初学者有个误解觉得代码是写给计算机执行的所以只要机器能跑通就算完事。这个认知只对了一半。机器确实只关心最终产物——一段可以被解释或编译的程序但写代码这个动作本质上是在和“未来的读者”交流。这个未来读者可能是你的同事更可能是三个月后的你。我们做个简单的算术假设一个功能你写了一个小时其中写代码用了20分钟另外40分钟花在查文档、调参数、处理边界情况上。三个月后你需要修改它如果你看不懂自己写的东西你得重新花30到40分钟去“考古”。如果这个东西只有你自己维护还好如果是团队项目别人还得先花半小时问你“这个变量是什么意思”“这里为什么这么写”。我自己的经验数据是一段代码的平均生命周期里编写时间只占20%左右剩下80%的时间都在阅读、理解、修改、排错。也就是说代码的首要读者永远是人而不是机器。如果你把全部精力都花在“让机器看懂”上那代码的其他读者——包括未来的你——就得为你的草率买单。1.3 可维护和易扩展一对双生兄弟再说清楚这两个词的定义因为很多人会把它们混为一谈。可维护性解决的是“改得动”的问题。需求变了你能在合理时间内把代码改对而且不引发新的bug。它关注的是修改的成本和风险。易扩展性解决的是“加得进”的问题。新功能来了你能在不推翻现有结构的前提下把新东西加进去。它关注的是新增的成本和风险。两者关系很紧密可维护性差扩展自然困难——你连现有代码都看不懂怎么在上面加东西而设计扩展性时如果你把接口预留得很合理通常也会让代码的维护体验更好。所以这俩不是两个独立维度而是一件事的两个面代码对变化的适应能力。从反面看不可维护的代码通常长这样函数动辄几百行一个函数干了七八件事全局变量到处都是你改A处B处莫名其妙跟着变重复代码极多同一个逻辑复制了五六份硬编码散落各处改个配置要全局搜索替换命名随心所欲a、b、c、temp、data2看名字完全猜不出用途。这些症状的本质都是同一个问题代码把“稳定部分”和“易变部分”搅在一起了。而可维护和易扩展的核心思路恰恰是把这两类东西分开。怎么分用什么姿势分这是本文后续要详细展开的内容。2. 判断代码好坏的五个实操维度不谈虚的直接从实际操作出发分享五个我判断代码质量时最常用的检查维度。你拿这份清单去检查自己写的代码基本一查一个准。2.1 维度一命名不是在给变量起名是在给代码写注释很多时候我们评价一段代码“看不看得懂”80%取决于命名。我收到过的烂代码里最常见的命名是a、b、c、tmp、data、data2、ddd、obj1、obj2这种。写代码的时候你觉得无所谓反正你脑子里清楚它是什么。但两周后再看你会面临灵魂拷问data2到底是订单金额还是用户IDtmp存的是计算结果还是中间状态我知道有人会反驳很多开源项目的变量名也不长比如i、j、k作为循环变量这在业界完全没问题。对循环变量用短名字本身就是惯例因为它的作用域通常只有三四行。但如果你写一个类、一个函数、一个模块级别的变量名字就不能再省了。我的经验标准是变量名要能回答“是什么”order_amount就比money好pending_orders_dict就比d好布尔变量名要能回答“是不是”is_valid、has_permission就比flag好函数名要能回答“做什么”send_verification_email就比do_email好类名要能回答“代表什么”OrderProcessor就比handler好。有人觉得这样写代码很啰嗦但实际算一笔账一个长变量名多敲八九个字符对打字速度的影响忽略不计但一个清晰的名字在阅读时节省的理解时间是几何级别的。写代码是给未来的读者省时间而不是给自己省打字时间这一步想通了命名基本就不会太差。2.2 维度二单一职责——一个函数只做好一件事“单一职责”听起来像教科书的术语换成大白话就是一个函数别又算数据又写文件又发邮件又改全局状态。一个函数只干一件事这件事的输入输出都是清晰的那它天生就好测试、好排查、好复用。我见过最夸张的一个函数叫process_data八百多行。里面有数据清洗有格式转换有数据库读写有Excel生成还有日志记录。整个函数像一条流水线把每个环节全部焊在一条主线上。后来有个新需求数据清洗逻辑要换一套规则。你没法独立替换清洗逻辑只能在那个八百行的函数里找到清洗那几十行小心翼翼地改——还不能保证改完不会影响后面的格式转换和数据库写入。那么该怎么拆思路很简单识别一个函数里的不同“动作”每个动作独立成一个函数。还是上面那个场景def load_raw_data(source): # 只负责读取数据 ... def clean_data(raw_data, rules): # 只负责清洗接收清洗规则 ... def transform_to_report(clean_data): # 只负责格式转换 ... def save_report(report, target): # 只负责落盘 ... def process_data(source, rules, target): # 组装上面四个步骤 raw load_raw_data(source) cleaned clean_data(raw, rules) report transform_to_report(cleaned) save_report(report, target)改一下对比原来的逻辑全部耦合在一起现在要替换清洗规则你只需要动clean_data的调用参数要换保存格式只动save_report。每个小函数都可以独立测试——给它一个输入看输出是否符合预期。这就是单一职责带来的实际收益每个函数的改动范围可控。2.3 维度三依赖方向——箭头朝外等着被插进来初学者最容易忽略的是“依赖关系”这个维度。什么叫做依赖关系举个例子A函数内部直接调用了B函数A就依赖于B。B如果改了签名A就得跟着改。依赖越深代码的改动就越容易引发连锁反应。可维护代码的一个核心特征是依赖有明确的方向稳定的东西不该依赖易变的东西实现细节不该反向污染核心逻辑。拿支付功能举例。一个订单系统需要支持微信支付、支付宝支付、银行支付。很多新手会这么写def pay(order, method): if method wechat: wechat_api.pay(order.amount) elif method alipay: alipay_api.pay(order.amount) elif method bank: bank_api.pay(order.amount)这个写法在只有三种支付方式的时候完全没问题。但业务方下个月说要接第四种支付、第五种支付你得不停往pay函数里加elif。每加一个分支改一次这个函数越改越长越改越乱。更糟糕的是测试的时候你得把所有支付方式都测一遍因为改一个分支可能会影响其他分支。更好的姿势是定义一套统一的“支付接口”让每种支付方式自己实现这套接口class Payment: def pay(self, amount): raise NotImplementedError class WechatPay(Payment): def pay(self, amount): wechat_api.pay(amount) class AlipayPay(Payment): def pay(self, amount): alipay_api.pay(amount) def pay(order, payment: Payment): payment.pay(order.amount)这样写核心的pay逻辑只依赖抽象的Payment接口不再关心具体的支付渠道。以后要接新支付方式只需要新增一个类核心代码一行不用改。这就是“核心依赖稳定接口实现细节可以扩展”的思路。这个特点专业上叫依赖倒置——听起来很高端实操起来就是一件事别让容易变的细节决定你核心代码的形态。2.4 维度四状态管理——别让全局变量四处飞初学者特别喜欢用全局变量因为省事。但全局变量是“隐蔽的耦合器”一个函数改了全局变量另一个函数读这个全局变量两个函数之间就产生了你无法直接从调用关系看到的数据依赖。举个经典翻车案例。我写过一个数据统计脚本定义了一个全局变量result []。一个函数往里append数据另一个函数遍历result生成统计表。刚开始没觉得有问题直到有一天我要在线程池里并发执行这两段逻辑。因为result是共享的多个线程同时写它数据直接乱了。排查了半天最后发现罪魁祸首就是那个“省事”的全局列表。也不是说全局变量绝对不能用但你在用它之前要想清楚一个问题这个数据该由谁持有、谁修改、谁来读。如果多个函数都要访问同一份数据更合理的做法是把它作为参数传递或者装进一个类里面作为实例属性。这样数据的流向在代码里是清晰的你读代码的时候一眼能看出来“这个数据是从哪来的”。这里插一个经验状态越少代码越好调试。所谓好代码往往不是因为它逻辑多精妙而是因为它在任意时刻需要跟踪的变量都很少。每个函数的输入输出都摆在明面上出了问题定位就快。2.5 维度五测试牵引力——能测试的代码才是好代码最后一个维度不太直观但非常关键代码做完了你能不能用一段测试代码去验证它。如果一个函数是纯粹的——给它相同的输入永远得到相同的输出——那测试就很容易写。如果一个函数依赖数据库、依赖文件系统、依赖全局状态、依赖当前时间那你写测试的时候光是做环境准备就想摔键盘。所以你在写函数的时候就要考虑“这玩意儿我怎么测”一旦你开始这么想你会自然而然地把外部依赖数据库、网络、时间和核心计算逻辑拆开。比如把“读数据”和“算结果”分开这样你就可以在不碰数据库的情况下用一组固定数据测试“算结果”的逻辑。这个思维模式一旦形成你的代码会自动变得可维护。因为测试就像一张安全网不管你以后怎么重构只要测试还通过你就知道改动没有破坏核心功能。这也是我敢放心重构老代码的底气所在——测试没过重构别动。3. 从能跑到能维护一次完整的重构实操理论讲再多不如跟着走一遍。下面我用一个真实的业务场景完整演示一下怎么把一段“能跑”的烂代码一步步变成“好维护、易扩展”的代码。3.1 场景设定生成订单统计报告业务需求很简单给定一个订单列表文件统计每个商品类别的销售总金额和订单总数输出一个汇总报告。订单文件长这样日期,商品类别,商品名称,单价,数量 2024-05-01,数码,手机,3000,2 2024-05-01,数码,耳机,500,5 2024-05-02,图书,Python入门,80,3 2024-05-02,数码,充电器,99,10 2024-05-03,图书,算法导论,120,13.2 第一阶段一个“能跑”的初版很多初学者拿到这个需求第一反应就是写一个脚本读文件、按类别统计、打印结果。代码如下with open(orders.txt) as f: lines f.readlines()[1:] categories {} for line in lines: parts line.strip().split(,) cat parts[1] price float(parts[3]) count int(parts[4]) if cat not in categories: categories[cat] [0, 0] categories[cat][0] price * count categories[cat][1] count for cat in categories: print(f{cat}: 总金额 {categories[cat][0]}, 订单数 {categories[cat][1]})这段代码能跑吗能跑。结果正确吗正确。但问题也是一眼就能看出来的第一格式解析逻辑和统计逻辑全在一个代码块里揉成团了。第二字段靠位置索引parts[1]、parts[3]谁知道第三列是“单价”还是“数量”第三数据格式全写死今天用CSV明天用JSON这段代码就报废。第四没有任何函数边界没法测试没法复用连注释都没法写——因为代码自己要表达的东西就不清晰。3.3 第二阶段先梳理职责再动代码拿到这种代码我第一件事不是急着改而是先把“这段程序到底做了几件事”列出来从文件读取原始数据解析一行文本变成有意义的数据结构按类别聚合统计输出结果。每一步都是一个独立的职责。理论上从“读取”到“解析”到“聚合”到“输出”每一步都可以单独替换。如果业务方以后说“数据源换成接口”我只用换第1步说“要统计季度趋势”我只需要动第3步和第4步。这就是前面说的“依赖清晰、职责单一”的落地方式。干完这一步重构的大纲其实已经出来了不需要什么高深的设计能力——把过程拆开让每步只干一件事。3.4 第三阶段重构到可维护版本基于上面的划分重构后的代码大概是这样的def load_orders(file_path): 加载订单文件返回原始行列表。 with open(file_path) as f: return f.readlines()[1:] def parse_order_line(line): 把一行文本解析成订单对象用dict表示。 fields line.strip().split(,) return { date: fields[0], category: fields[1], name: fields[2], price: float(fields[3]), quantity: int(fields[4]), } def parse_orders(lines): 批量解析多行。 return [parse_order_line(line) for line in lines] def aggregate_by_category(orders): 按商品类别聚合销售金额和订单数。 stats {} for order in orders: cat order[category] amount order[price] * order[quantity] if cat not in stats: stats[cat] {total_amount: 0, order_count: 0} stats[cat][total_amount] amount stats[cat][order_count] order[quantity] return stats def format_report(stats): 把统计结果格式化成可读文本。 lines [] for cat, stat in sorted(stats.items()): lines.append(f{cat}: 总金额 {stat[total_amount]}, 订单数 {stat[order_count]}) return \n.join(lines) def generate_report(file_path): 主流程加载、解析、聚合、格式化。 lines load_orders(file_path) orders parse_orders(lines) stats aggregate_by_category(orders) return format_report(stats)对比初版变化非常明显每个函数都有清晰的名字读代码的人不用猜测它在干嘛每个函数都有一个明确的输入和一个明确的输出解析、聚合、格式化彼此独立互不依赖主流程只是一个简单的流水线组装。现在如果业务方说“我要在报告里加上订单数占总单量的比例”我只需要改format_report这一个函数。说“以后订单数据从数据库读”我只改load_orders其他全不用动。说“要给金额最低的类别标红”再加一个函数插到合适的位置就行。这就是易扩展的实际感受。3.5 第四阶段新需求来了验证一下扩展性空口说扩展性没意思来一个真实的新需求验证一下。假设业务方说「报告里要同时展示每个类别下的具体商品列表按销量排序。」初版代码怎么加这个功能得在一个混沌的代码块里到处塞逻辑。而重构后的代码只需要增加一个函数aggregate_by_product按商品聚合销量在format_report里调用它把商品列表格式化进去。核心流程几乎不变甚至你都不需要动已有的函数只需要新增一个函数再在输出环节接一小段逻辑。新增一个功能改动的代码量小于原来总量的10%这就是一个好的扩展设计给你带来的红利。把这个体验换成另一个角度理解代码设计得好不好不要看第一次写功能时写了多少行要看第二次加需求时改了多少行。改得越少说明你第一次写的时候把结构的稳定部分和易变部分分得越清楚。3.6 重构的边界别为了“优雅”过度设计最后一定要泼一盆冷水别走向另一个极端——为了“可维护”而过度设计。我有段时间就走过这个弯路写一个排序脚本非要用模板模式策略模式配置文件驱动结果脚本本身50行框架搭了300行。后来发现那个脚本这辈子就我一个人用根本不会扩展。所以判断“要不要重构”“要不要加抽象层”的标准很简单这个代码会不会被多次修改会不会有多个调用方会不会有不同的实现三个问题全答“否”那就别搞什么抽象了写清楚、写简单就是最好的设计。判断是否过度设计就看你的每一个抽象层是否真的在未来发挥了作用——没有用到的抽象不是设计是负债。4. 真实项目里的常见坑与排查思路前面讲的是怎么把代码写好这一章聊聊实际项目中那些“代码能跑但总觉得不对劲”的场景以及我是怎么排查和处理的。4.1 接到一堆能跑但看不懂的旧代码怎么办很多初学者入行的第一份工作就是接手别人的老项目。代码能跑但毫无可维护性可言。这种时候别急着推翻重写——你还不完全了解业务逻辑贸然重写往往会把一些“看似奇怪但实际必要”的细节丢掉结果就是新代码比你想象的更烂。我的建议是三步走第一步先跑起来摸清行为。不管代码多烂先把它完整跑一遍记录输入输出搞清楚它到底干了什么。这一步不写代码只做观察。第二步挑选“测试点”建立安全网。给关键路径写几个简单的“测试”脚本输入固定数据断言输出结果。哪怕没有正规的测试框架你拿Python脚本手动断言也行。这些测试负责守住行为边界你在重构时只要这些断言不挂就可以放心改内部实现。第三步小步重构一次只拆一个大函数。先把最长的那颗大函数按职责拆成几个小函数跑一遍测试再拆下一个。不要指望一天搞定每天拆一点两周后你就会发现整个项目变得人体工学多了。4.2 “能跑就行”在什么场景下真的是对的前面说了那么多“能跑不行”但我也必须诚实在有些场景下能跑真的就够。什么场景呢一次性脚本、实验性代码、临时数据处理。比如你写一个脚本把某个文件里的数据清洗一下导出来下次可能半年以后才会用或者你在做数据分析随手写一段代码验证一个思路验证完就扔——这种代码追求可维护性就是浪费。我自己的判断标准是代码会被复用超过两次吗会被别人读吗会长期运行吗三个问题只要有一个“是”就值得花时间让代码变得可维护。全答“否”那就放心让代码保持朴素甚至用完即弃。关键在于你得知道自己正在写的是什么性质的代码。给一次性脚本加三层抽象和给核心业务不写函数声明一样都是没搞清代码的定位。4.3 团队协作中的可维护个人习惯如何变成团队规范如果是团队项目光靠个人自觉是不够的还得有约束机制。我之前在团队里推过三件小事效果很好第一Code Review 必须有。你自己看自己的代码永远觉得没问题别人一句话问“这个参数是干嘛的”你就知道这块写得不清楚。评审的核心不是挑错而是逼着每个写代码的人站在“读者”视角再审视一遍自己的作品。第二命名规范与代码风格统一。不用搞多复杂的规范先统一代码格式工具比如Python的black、JavaScript的Prettier、C的clang-format再定一套命名约定。风格统一之后代码的可读性会有立竿见影的提升因为你不再需要花时间去解析别人的排版习惯。第三模块内的API即接口谁调用谁负责理解。在团队里函数命名和参数设计不是个人的事情。你写一个公共函数别人会调用如果你的函数名有歧义、参数含义不明确坑的是整个团队。所以公共函数尤其要花心思不仅写清楚它是干什么的还得在docstring里说明参数的范围、返回值的含义、异常情况是怎么处理的。你的代码如果只有你自己能看懂那在团队里它的价值就要打五折。可维护性不是一个人善不善于写代码的问题而是一个团队能不能持续交付的问题。5. 代码即文档注释到底该写什么评论是代码维护里最容易被误解的东西。我见过两种极端一种人从来不写注释觉得代码本身就是文档另一种人每行代码都写注释把i写成// i加1。这两种都不可取。5.1 注释该记录“为什么”而不是“是什么”代码本身能表达“做了什么”你看total_amount price * quantity不用注释也能看出是在累加金额。注释真正要记录的是那些代码表达不了的信息为什么选这个方案而不是另一个方案比如“这里用列表不用集合因为需要保持插入顺序”为什么有这个看似奇怪的判断比如“这个字段在旧数据里可能为空必须做兼容处理”业务规则的特殊约定比如“金额单位是分展示时除以100历史数据里曾经有单位不统一的脏数据”。这就像你在工位上贴便利贴不是为了让别人知道“我在调接口”而是为了提醒自己“下次别再做这个反向兼容”或者“这个接口有坑记得处理超时”。5.2 “代码即文档”的另一面让代码好到不太需要注释我也见过只靠注释撑起来的“可读”代码# 遍历列表 for i in range(len(items)): # 检查是否是手机 if items[i].type phone: # 把手机加入列表 phone_list.append(items[i])这种注释确实能帮你翻译代码但问题在于如果有一天逻辑变了人们往往会改代码忘记改注释。你盯着代码和注释不一致的地方只会更迷茫。更好的做法是把代码本身写清楚让注释成为补充而不是替代。比如把上面那段改成phone_items [item for item in items if item.type phone]这行代码不需要注释因为变量名和判断条件自己就能说明一切。写了注释反而画蛇添足。5.3 命名、函数边界、依赖方向无声的文档回到这整篇文章的核心观点最好的文档不是写在注释里的而是体现在代码结构里的。一个命名清晰的函数、一个职责单一的函数、一个依赖方向明确的模块……这些东西不需要单独花时间“记录”因为代码本身就是记录。而当代码结构和真实意图一致的时候后人读代码的过程就是读文档的过程。我在代码评审时最常说的口头禅是“如果这段代码明天交给另一个人维护他能不能在两小时内看懂你做的事”如果你能做到你的代码就是好代码如果你需要靠一堆注释去解释那说明结构还不够好。不要用注释的勤奋掩盖结构的懒惰这是我这些年最深刻的体会。聊了这么多其实核心就一句话写代码不是写给机器的一次性交差而是写给未来的自己和队友的一份长期契约。我自己现在写每一段代码之前都会习惯性地问一句“三个月后的我看到这段代码能不能秒懂”这个习惯帮我避开过太多坑。如果你看完这篇文章只能记住一件事我希望是这句。