代码能跑就行?初学者应该懂得“可维护&易扩展的重要性“
写了几年代码,带过几轮新人,我发现绝大多数初学者都有一个共同心态:管他三七二十一,能出结果就行。数据结构乱一点没关系,函数长一点也没关系,变量名随手敲个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到底是订单金额还是用户ID?tmp存的是计算结果还是中间状态?
我知道有人会反驳:很多开源项目的变量名也不长,比如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 命名、函数边界、依赖方向:无声的文档
回到这整篇文章的核心观点:最好的文档不是写在注释里的,而是体现在代码结构里的。
一个命名清晰的函数、一个职责单一的函数、一个依赖方向明确的模块……这些东西不需要单独花时间“记录”,因为代码本身就是记录。而当代码结构和真实意图一致的时候,后人读代码的过程就是读文档的过程。
我在代码评审时最常说的口头禅是:“如果这段代码明天交给另一个人维护,他能不能在两小时内看懂你做的事?”如果你能做到,你的代码就是好代码;如果你需要靠一堆注释去解释,那说明结构还不够好。不要用注释的勤奋,掩盖结构的懒惰,这是我这些年最深刻的体会。
聊了这么多,其实核心就一句话:写代码不是写给机器的一次性交差,而是写给未来的自己和队友的一份长期契约。我自己现在写每一段代码之前,都会习惯性地问一句:“三个月后的我看到这段代码,能不能秒懂?”这个习惯帮我避开过太多坑。如果你看完这篇文章只能记住一件事,我希望是这句。