从“能用”到“好改”:一套新手也能执行的代码进化路径

0 阅读35分钟

引言

“能用”和“好改”,这两个词道出了代码质量的两个层次。能用,意味着代码能够完成预定的功能,在测试环境中跑通,在理想情况下产生正确的输出。好改,意味着代码在面对需求变化、边界调整、功能扩展时,能够以较低的成本、较小的风险进行修改,而不至于牵一发而动全身,引发连锁反应。能用是起点,好改是目标;从能用进化到好改,是每一位开发者成长的必经之路。

在软件行业有一个著名的说法:代码的阅读次数远多于编写次数。一段代码在被写出来之后,可能会被阅读几十次、上百次,被修改十几次,被调试无数次。如果代码只追求“能用”,那么每次阅读都是一次煎熬——变量命名不知所云,函数逻辑绕来绕去,注释要么缺失要么过时,边界条件藏在层层嵌套的 if-else 中。如果代码追求“好改”,那么维护工作就会轻松得多——命名清晰自解释,逻辑简洁扁平,注释与代码同步,边界条件一目了然。

然而,“好改”不是一蹴而就的。代码的进化是一个渐进的过程,需要在日常的开发中持续投入。对于新手来说,最大的困难不是不知道什么是好的代码,而是不知道从何下手,不知道哪些改进是值得的,不知道改进的优先级是什么。本文的目的,就是为这个困惑提供一套可操作的答案。我们将围绕代码进化的几个核心维度——可读性、可测试性、可扩展性、模块化——展开讨论,每个维度都给出清晰的改进原则和具体的执行步骤。这套方法不要求天才般的洞见,不需要大规模的重写,只需要一点一滴的积累和坚持。

一、可读性:让代码自己说话

1.1 可读性的价值:被忽视的工程红利

代码可读性(Code Readability)是一个被广泛讨论却又容易被忽视的话题。说它被广泛讨论,是因为几乎所有编程书籍和风格指南都会提到它;说它容易被忽视,是因为在实际开发中,迫于交付压力,很多开发者会选择先让代码跑起来,可读性之类的事情“以后再说”。问题是,这个“以后”往往永远不会来——代码一旦上线,就被新的需求覆盖,被新的功能叠加,原来的“以后再说”变成了永久的技术债务。

可读性的价值在于它是一种杠杆效应极强的投资。花十分钟写一段清晰易懂的代码,可能为自己和同事节省数小时的理解时间;花一小时建立一个良好的命名规范和注释习惯,可能让整个团队的协作效率提升一个量级。更重要的是,高可读性的代码更容易被发现问题、更容易被测试覆盖、更容易被安全审计——这些都是在代码生命周期的后期才能体现出来的红利。

1.2 命名艺术:从模糊到精确

命名是可读性的基石。一个好的名字应该能够在不查看文档和注释的情况下,让读者明白这个变量、函数、类代表什么。糟糕的命名则会让代码变成猜谜游戏,读者不得不反复上下翻阅,试图从上下文推断某个名字的真实含义。

变量命名应该描述它代表什么,而不是它是什么类型。比如,用 totalPrice 比用 double value 更好,因为前者直接说明了金额的语义,后者只是一个技术类型;用 userIsActive 比用 flag 更好,因为前者清晰表达了布尔值的意义,后者需要读者猜测这个标志位是做什么的。对于布尔变量,推荐使用 is、has、can、should 等前缀来明确其布尔性质。对于集合变量,推荐使用复数名词或 Collection 后缀来明确其集合属性,比如 users、orderList、roleMap。

函数命名应该描述它做什么,而不是它怎么做。比如,用 calculateTotalPrice() 比用 compute() 更好,因为前者说明了函数的职责,后者过于模糊;用 sendEmail() 比用 process() 更好,因为前者明确表示了这是一个发送邮件的操作。对于返回布尔值的函数,应该用 isX、hasX、canX 等形式命名,比如 isEmpty()、hasPermission()、canAccess()。

类命名应该描述它是什么,以及它的主要职责。一个好的类名应该包含足够的信息,让读者知道这个类的用途。比如,用 UserService 比用 UserHelper 更明确,因为 Service 暗示了它提供用户相关的业务服务;用 PaymentProcessor 比用 Payment 更明确,因为 Processor 暗示了它负责处理支付流程。

避免的命名陷阱包括:过于通用的名字(如 data、info、temp)、过于简短的名字(如 buf、ctx、val)、中英混用的名字(如 订单列表 orderList,应该统一语言)、使用缩写而未经说明的名字(如用 RMB 代替人民币金额,应该明确是 currency)。对于是否使用缩写,原则是:如果这个缩写是团队内所有人都熟知的标准缩写(如 API、URL、HTTP),可以使用;否则应该使用完整单词。

1.3 注释策略:少而精,准而新

关于代码注释,有一种极端的观点认为“好的代码不需要注释”,这种观点过于绝对。注释是代码的补充说明,在某些场景下是必要的——比如解释为什么这样做而不是那样做,记录业务规则的来源,标记待处理的 TODO 等。关键是,注释应该少而精,准而新——数量要少但必要,内容要准确且与代码同步更新。

什么值得注释:复杂的业务逻辑可以用注释来解释其背景和意图;边界条件可以用注释来说明为什么需要这样的判断;非显而易见的优化可以用注释来解释优化前的方案和原因;第三方调用可以用注释来说明依赖的外部接口和行为;对于可能会引起疑惑的魔法数字(magic number),应该用命名常量或注释来说明其含义。

什么不值得注释:显而易见的操作不需要注释,比如 i++ 表示计数加一这类;过时的注释比没有注释更糟糕,它们会误导读者;如果代码足够清晰,注释只是重复代码已经表达的信息。

注释的同步问题:注释最大的敌人是代码变更后忘记更新注释。一旦注释与代码脱节,它就不再是帮助而是伤害。因此,应该把注释当作代码的一部分来维护,在修改代码的同时修改对应的注释。对于确实需要注释的复杂逻辑,如果发现注释太长、太多,可能提示这段代码本身需要重构——长注释有时是复杂实现的一个信号。

1.3 控制结构扁平化:减少认知负荷

嵌套过深的控制结构是代码可读性的杀手。当一个函数包含四五层甚至更多的 if-else 嵌套时,读者需要不断在脑中“压栈”和“出栈”,跟踪当前执行到了哪一层、哪些分支已经执行过、哪些条件正在判断。这种认知负荷不仅让代码难以理解,也容易引入 bug——在复杂的嵌套条件下,开发者很容易漏掉某个边界情况。

**卫语句(Guard Clause)**是简化嵌套的有效手段。卫语句的核心思想是:对于不满足前置条件的情况,尽早返回或抛出异常,而不是继续执行后续逻辑。传统的写法可能是这样的:在一个函数开头检查参数是否合法,合法才继续执行,否则执行一些操作后再返回。这种写法会导致正常逻辑被缩进在一个大的 if 块内。使用卫语句后,参数不合法的情况直接 return,正常的逻辑从函数开头就在主流程上,不需要额外的缩进层级。

合并条件表达式可以简化复杂的判断逻辑。如果多个条件最终都导向相同的处理逻辑,可以将它们合并为一个复合条件表达式。比如,多个独立的 null 检查可以合并为一个 isInvalid() 方法调用;多个返回 false 的条件可以合并为一个逻辑或表达式。

提取方法是另一种简化嵌套的手段。当一段逻辑在函数中重复出现,或者某个内联的表达式过于复杂时,应该将它提取为一个独立的方法。提取后的方法应该有清晰的名字,准确描述这段逻辑的作用。这样做不仅减少了嵌套层级,也让代码的复用性更好。

1.4 代码布局:形式美与结构清晰

代码的物理布局虽然不影响程序的运行,却极大地影响阅读体验。良好的布局可以让代码结构一目了然,糟糕的布局则让读者在字里行间迷失方向。

空行的使用:空行是代码的“段落标记”,用来分隔不同的逻辑块。在同一个函数内,不同的操作序列之间应该用空行分隔;在声明相关变量的语句之间可以用空行,隔开无关的变量组;在函数定义之间应该有空行;在注释之前可以有空行,提示接下来要解释的内容。但是,空行也不能滥用——过多的空行会打断阅读的连续性,让代码显得零散。

对齐与缩进:一致的缩进是代码可读性的基本要求。不同的团队和语言有不同的缩进风格(空格还是 Tab、两个空格还是四个空格),关键是要在整个代码库中保持一致。对于包含多个变量的声明,可以考虑对齐赋值语句的等号,让相似的结构看起来更整齐。

分组与区块:对于文件较长的类,可以考虑用注释将代码分成逻辑区块,比如“构造器区”、“公开方法区”、“私有方法区”、“常量区”等。这种分区标记可以帮助读者快速定位到需要的代码段。

二、可测试性:代码质量的镜子

2.1 可测试性的本质:为什么难以测试的代码是问题

可测试性(Testability)是指一段代码被测试的难易程度。高可测试性的代码可以通过自动化测试快速验证其正确性,低可测试性的代码则很难被测试覆盖,往往依赖于手动的、耗时的验证方式。可测试性不仅是测试效率的问题,它本身就是代码质量的一面镜子——难以测试的代码通常意味着设计上的问题。

为什么难以测试?因为测试需要控制被测代码的输入,观察被测代码的输出,隔离被测代码的外部依赖。如果一个函数依赖了太多的全局状态、太复杂的对象创建过程、太深层的外部调用,那么要写一个有效的测试用例,就需要付出极大的努力来搭建测试环境。这种困难本身就是代码设计问题的信号:过多的全局状态意味着隐式的依赖链;复杂的对象创建意味着类的职责不清;深层外部调用意味着模块边界混乱。

2.2 依赖注入:让测试可以控制依赖

依赖注入(Dependency Injection,DI)是提高可测试性的核心手段。它的基本思想是:一个类不应该自己创建它所依赖的对象,而应该由外部在构造时或通过方法参数传入。这样做的好处是,测试时可以用 Mock 对象替换真实的依赖,从而隔离被测代码,专注于验证其自身逻辑。

构造器注入是最推荐的依赖注入方式。通过类的构造器传入所有必需的依赖,这些依赖会被保存为类的成员变量。这种方式清晰地表明了类的职责边界——它需要什么才能正常工作。同时,构造器注入让测试代码在创建被测对象时就知道需要准备哪些依赖。

方法注入适用于依赖在每次调用时都可能不同的情况。比如,一个处理订单的方法可能需要一个审计日志记录器,但不同的调用场景可能需要不同的记录器。这种情况下,可以将审计日志记录器作为方法的参数传入。

Setter 注入适用于可选的依赖。某些依赖可能有默认值,如果调用者没有特别指定,就使用默认值。这种情况下,可以使用 Setter 方法来设置依赖,但需要确保在使用前已经设置了必要的依赖。

2.3 单例与静态方法的替代方案

单例模式(Singleton)和静态方法(Static Method)是可测试性的两个大敌。它们的共同特点是:无法在运行时替换其行为。一旦代码中使用了单例或静态方法,测试就无法轻易地用 Mock 对象来替换它们,导致测试用例难以隔离外部影响。

单例的问题:单例的本质是一个全局可见的实例。它的状态在整个程序生命周期中都是共享的,这导致多个测试用例之间可能产生状态污染。当一个测试用例修改了单例的状态,后续的测试用例可能会受到影响。更糟糕的是,单例的创建逻辑通常隐藏在 getInstance() 方法中,测试代码很难控制单例的创建过程。

静态方法的问题:静态方法虽然不持有状态,但它们往往会调用其他静态方法或访问静态状态,形成难以拆分的静态调用链。测试时,我们可能只想测试某一层的逻辑,但静态方法之间的紧密耦合让这种隔离变得困难。另外,某些静态方法的行为可能依赖于运行环境(如系统属性、JVM 配置),这使得测试结果变得不可靠。

替代方案:对于单例,可以考虑将其替换为依赖注入的普通类,由一个工厂类或容器来负责创建和管理实例。这样,测试时可以创建真实的或 Mock 的实例,替换起来非常灵活。对于静态方法,可以考虑将它们封装在一个接口背后,通过依赖注入传入接口实现。这样做虽然增加了一点复杂度,但极大地提升了可测试性和灵活性。

2.4 公有方法与私有方法的测试策略

一个常见的问题是:私有方法需要测试吗?这个问题的答案取决于私有方法的复杂度和独立性。

直接测试 vs 间接测试:如果一个私有方法是复杂的独立逻辑,它应该被直接测试;如果它只是公有方法的一个步骤,可以只通过公有方法来间接测试它。直接测试私有方法通常意味着它应该被提取为独立的类或函数,因为私有方法之所以难以直接测试,正是因为它与当前类耦合太紧。

使用包级访问或反射:在 Java 等语言中,可以通过包级访问(默认访问修饰符)或反射来访问私有方法进行测试。但这些方式都有各自的代价:包级访问破坏了封装性,反射破坏了类型的静态安全性。更好的做法是重新审视代码设计,看看是否有必要将这些私有方法暴露出来或者提取为独立的组件。

2.5 可测试性的实践检查清单

在编写代码时,可以用以下清单来评估代码的可测试性:

依赖是否明确:类的所有依赖是否都通过构造器或方法参数传入?是否有隐式的全局状态或静态调用?依赖的接口是否稳定且易于 Mock?

是否容易创建:创建类的实例是否需要大量的配置和依赖准备?是否有不必要的外部依赖导致实例创建困难?

是否容易断言:函数的返回值是否明确?副作用是否可控?是否有难以观察的内部状态变化?

是否容易隔离:测试一个函数是否需要同时启动数据库、Web 服务器、消息队列等外部服务?如果是,是否有替代方案(如内存数据库、Mock 服务器)?

三、可扩展性:为变化预留空间

3.1 可扩展性的思考:软件唯一不变的是变化

软件系统的一个根本特性是它需要不断变化。业务需求在变,技术环境在变,用户期望在变,团队构成在变。一段代码如果设计时只考虑当前的固定需求,当需求发生变化时,就可能需要大幅修改甚至重写。可扩展性(Scalability/Extensibility)是指代码能够以较小的成本适应这些变化的能力。

可扩展性不同于性能意义上的“扩展”(Scaling),而是特指代码结构对功能扩展的友好程度。高可扩展性的代码通常具有清晰的结构边界、稳定的抽象接口、合理的职责划分。当需要添加新功能时,开发者可以在不修改现有代码的情况下,通过扩展点插入新的实现;当需要修改现有功能时,影响范围被控制在局部,不会引发连锁反应。

3.2 开闭原则:扩展优于修改

开闭原则(Open-Closed Principle,OCP)是面向对象设计的核心原则之一,其表述是:软件实体应该对扩展开放,对修改关闭。这意味着,一个良好的设计应该允许在不修改现有代码的情况下引入新功能。

为什么这个原则如此重要?因为修改现有代码是有风险的。即使开发者小心翼翼,仍然可能在修改时引入新的 bug,破坏已有的功能。对于一个被广泛调用的模块,修改它意味着要重新测试所有依赖方,影响范围可能很大。因此,一个好的设计应该尽量减少对已有代码的修改,将变化隔离在扩展点。

**策略模式(Strategy Pattern)**是实现开闭原则的经典手段。它将算法封装为独立的策略类,客户端代码依赖抽象的策略接口而非具体实现。当需要新的算法时,只需要添加新的策略实现类,无需修改客户端代码。比如,一个订单价格计算逻辑可能有多种策略(普通用户原价、会员用户打折、促销期间满减等),使用策略模式可以将这些算法分离,每个算法独立变化和测试。

**模板方法模式(Template Method Pattern)**是另一种常用手段。它定义了一个算法的骨架,将某些步骤的实现延迟到子类。在不改变算法结构的前提下,子类可以重新定义算法的某些特定步骤。比如,一个数据处理流程可能包含读取、清洗、转换、保存四个步骤,其中读取和保存可能是固定的,清洗和转换可能因场景而异,使用模板方法可以将清洗和转换定义为可覆盖的方法。

插件机制是更高级的扩展方式。它提供一个插件接口,允许第三方在运行时加载和注册插件,实现功能的动态扩展。很多框架(如 Spring、VSCode)都使用插件机制来支持高度的可扩展性。

3.3 里氏替换原则:正确使用继承

里氏替换原则(Liskov Substitution Principle,LSP)指出:子类型必须能够替换其基类型而不改变程序的正确性。违反这个原则是导致扩展性问题的重要原因——当子类无法正确替换父类时,使用继承来扩展功能就会带来意想不到的问题。

子类不应加强前置条件。如果父类的方法接受任意整数作为参数,子类的方法不应该要求参数必须为正数——这会破坏父类契约,使调用方无法以同样的方式使用子类。

子类不应弱化后置条件。如果父类的方法保证返回一个非空列表,子类的方法不应该返回空列表或 null——这会破坏调用方对返回值的预期。

子类不应引入新的异常。如果父类的方法声明抛出 IOException,子类可以抛出 IOException 的子类,但不能抛出新的父类未声明的异常——这会破坏调用方的异常处理逻辑。

遵循里氏替换原则,意味着继承层次的设计要谨慎。继承是一种强耦合关系,子类与父类之间存在紧密的依赖。在考虑使用继承来扩展功能之前,应该先评估是否可以通过组合(Composition)来实现——组合通常比继承更加灵活,也更符合“针对接口编程”的原则。

3.4 接口的稳定性:契约即边界

接口是模块之间的边界,接口的稳定性直接影响整个系统的可扩展性。一个经常变化的接口会让所有依赖方疲于应对,稍有不慎就会引发兼容性问题。

接口应该小而专注。一个接口如果定义了过多的方法,就很难保持稳定——任何一个方法的变化都会影响所有实现者。因此,接口应该代表一个小的、专注的抽象。当需要不同的行为时,应该定义多个接口,而不是一个大的接口。

接口应该向内依赖。这是依赖倒置原则(Dependency Inversion Principle)的核心:高层模块不应依赖低层模块,两者都应该依赖抽象。抽象不应依赖细节,细节应该依赖抽象。这意味着,当设计模块间的依赖关系时,应该让细节依赖抽象,而不是让抽象依赖细节。

避免暴露内部细节的接口。接口应该定义稳定的行为契约,而不应暴露实现细节。如果接口依赖于特定的实现(如特定的返回类型),当实现需要变化时,接口也可能需要变化。

3.5 扩展性评估:设计时问自己这些问题

在设计代码结构时,可以问自己以下问题来评估可扩展性:

**如果需要添加新的功能,是否需要修改现有代码?**如果答案是肯定的,说明存在改进空间。理想情况下,添加新功能应该只需要添加新代码,而不是修改旧代码。

**如果需求变化,现有代码是否容易调整?**比如,如果价格计算的规则变了,是否只需要修改配置或添加新的策略类?还是需要深入业务逻辑层进行大幅修改?

**模块之间的边界是否清晰?**如果修改一个模块会意外影响另一个模块,说明模块边界存在问题。

**依赖关系是否合理?**如果高层业务模块依赖了底层的工具细节,说明依赖方向可能需要调整。

四、模块化:分而治之的艺术

4.1 模块化的意义:从混沌到秩序

模块化(Modularization)是将一个大型系统分解为若干相对独立、可组合的模块的软件设计技术。模块化不仅是一种代码组织手段,更是一种思维方式——它要求开发者在面对复杂问题时,学会分解问题、定义边界、建立连接。

模块化的价值体现在多个方面。首先是可理解性——一个复杂的系统如果被分解为若干小模块,每个模块可以独立理解和分析,降低了认知负荷。其次是可维护性——问题可以被隔离在单个模块内部,修改一个模块不会波及其他模块。再次是可复用性——一个设计良好的模块可以在不同场景下被复用,甚至被不同的项目复用。最后是可测试性——每个模块可以独立测试,不需要启动整个系统。

4.2 模块划分原则:内聚与耦合

内聚(Cohesion)和耦合(Coupling)是模块化设计的两个核心概念。

内聚衡量的是一个模块内部各元素之间的关联程度。高内聚意味着模块内部的元素紧密相关,共同完成一个明确的职责;低内聚意味着模块内部元素关系松散,可能包含多种不相关的功能。高内聚是好的设计的标志——每个模块应该只做一件事,并且把这件事做好。

耦合衡量的是不同模块之间的依赖程度。低耦合意味着模块之间的依赖关系简单、清晰、稳定;高耦合意味着模块之间存在复杂的、紧密的依赖,一个模块的变化会牵连很多其他模块。低耦合是好的设计的标志——模块之间应该通过稳定的接口交互,而不是直接依赖彼此的实现细节。

如何实现高内聚、低耦合

单一职责原则是实现高内聚的基础。如果一个类或模块承担了多个职责,当其中一个职责需要变化时,整个类都需要修改。将不同的职责分离到不同的类中,可以让每个类更加专注,也更容易维护。

接口隔离原则要求模块之间通过接口而不是具体类进行交互。调用方不应该依赖它不使用的方法。一个包含过多方法的“胖接口”会让实现者被迫实现不需要的功能,也会在接口变化时影响所有实现者。

依赖倒置原则要求高层模块不依赖低层模块,两者都依赖抽象。抽象的接口是稳定的,而具体实现可能经常变化。通过依赖抽象而不是依赖实现,即使底层实现发生了变化,上层模块也不需要修改。

4.3 包与命名空间:代码的物理组织

在 Java、C# 等语言中,包(Package)或命名空间(Namespace)提供了代码的物理组织手段。良好的包结构可以帮助开发者快速定位代码,也可以作为模块化的边界。

按功能分包 vs 按层级分包:两种分包方式各有优劣。按功能分包将相关的类和接口放在同一个包里,比如把所有与用户相关的类放在 user 包下,这种方式便于理解业务领域;按层级分包将不同层次的代码放在不同的包里,比如所有 Controller 放在 controller 包、所有 Service 放在 service 包,这种方式便于理解技术架构。很多项目会结合两种方式,先按功能分包,再在功能包内按层级分包。

包的命名规范:包名应该使用小写字母,避免使用数字和特殊字符(除了下划线);对于跨项目的共享库,应该使用反转的域名作为前缀(比如 com.example.utils),以避免命名冲突;包名应该反映其功能,不应该过于通用或模糊。

包的依赖管理:包与包之间也会形成依赖关系,这些依赖应该遵循一定的规则。比如,业务包不应该依赖基础设施包的具体实现;核心模块不应该依赖外围模块。可以通过静态分析工具(如 Java 的 JDepend)来检查包之间的依赖关系是否符合预期。

4.4 模块化架构:分层与组件化

在更宏观的层面,模块化体现为系统的分层架构和组件化设计。

分层架构将系统划分为若干层,每层有明确的职责和定位。经典的三层架构包括表现层(UI)、业务逻辑层(Service)、数据访问层(DAO)。每层只能调用它下面一层的服务,不能跨层调用。这种分层方式让系统的结构清晰,便于开发和维护。

组件化是在分层的基础上进一步解耦的手段。组件是一个独立的、可部署的单元,拥有自己的代码、配置和依赖。一个复杂的系统可以被分解为多个组件,每个组件负责特定的功能领域。组件之间通过明确定义的接口进行通信,内部实现对外部不可见。组件化让系统可以被独立开发、独立测试、独立部署,也为微服务架构提供了基础。

4.5 模块化的实践:从哪里开始

对于一个现有的、尚未模块化的项目,从哪里开始模块化是一个现实的问题。

识别领域边界是第一步。分析现有的代码,找出那些紧密相关、共同变化的元素,它们可能就是潜在的模块。比如,在一个电商系统中,商品、库存、订单、支付可能是四个相对独立的领域,每个领域都可以成为一个模块。

从新代码开始是降低风险的好方法。不要试图一次性重构所有代码,而是从新开发的功能开始,按照模块化的原则设计。当新代码稳定后,可以逐步将老代码中的相关部分迁移到新模块。

建立稳定的核心是模块化成功的关键。系统的核心业务逻辑应该是最稳定的,不依赖频繁变化的外部因素。以核心业务为基础,逐步向外扩展模块,每个模块都建立在更稳定的模块之上。

持续重构是保持模块化成果的手段。模块化不是一劳永逸的事情,需要在日常开发中持续维护。当发现模块之间的边界变得模糊、依赖关系变得混乱时,应该及时进行重构,恢复清晰的结构。

五、渐进式演进:从一点一点开始

5.1 进化的心态:接受不完美,持续改进

代码的进化不是一次性的重构活动,而是一个持续的过程。没有任何代码天生就是完美的,也没有开发者能够一步到位写出完美的代码。重要的是保持进化的心态:承认当前的代码有改进空间,但不是推到重来,而是在日常工作中一点一点地改善。

不追求一步到位是这种心态的核心。试图一次性将代码改造成理想状态,既不现实也有风险——改动范围太大,引入 bug 的可能性也很大;而且,改动的收益很难被立即看到,投入产出比不高。相比之下,每次只改进一小部分,集中精力把这个改进做好、做稳,再进行下一个改进,效率要高得多。

让改进成为日常工作的一部分。将代码改进融入到日常的开发流程中,而不是将它视为额外的工作。当修复一个 bug 时,顺便改善一下相关代码的可读性;当开发一个新功能时,顺手重构一下与之相关的陈旧代码;当进行代码审查时,主动提出改进建议。通过这种持续的积累,代码质量会在不知不觉中稳步提升。

5.2 改进优先级:哪些先做,哪些后做

在资源有限的情况下,需要确定改进的优先级。以下是一些判断优先级的参考原则。

越频繁使用的代码越值得改进。核心的业务逻辑、广泛调用的公共方法、被多个模块依赖的基础组件——这些代码每天被阅读和执行无数次,即使只是微小的改进,累积的收益也会很大。

问题越多的地方越需要改进。经常出 bug 的模块、维护成本高的模块、新人难以理解的模块——这些地方的问题已经被实际地暴露出来,改进的收益是明确的。

改进风险低的地方可以先做。当不确定一个改进是否正确时,可以先从不重要的、独立的代码开始尝试。如果改进效果良好,再将经验应用到更重要的地方。

5.3 Boy Scout Rule:每次离开时都比来时干净

Boy Scout Rule(童子军规则)是一条简洁而有力的改进原则:每次离开代码时,都应该让它比来时更干净。这条规则的好处是:不需要大块的时间,不需要专门的计划,不需要管理层的批准——只需要在日常工作中养成习惯,每次遇到可以改进的地方就顺手改进。

什么是“更干净” :可以是修复一个发现的 bug,可以是改善一个变量的命名,可以是删除一段不再使用的死代码,可以是添加一个缺失的注释,可以是将一个嵌套的 if 块改写为卫语句,可以是为一段逻辑补充一个单元测试。这些改进可能看起来很小,但累积起来就是显著的质量提升。

保持改进的粒度小是Boy Scout Rule的关键。如果一个改进需要花费数小时甚至数天,它就不是“顺手”的改进,而是一个正式的重构任务。遇到大的改进机会时,可以记录下来,安排专门的时间处理,而不是在当前的任务中强行完成它。

5.4 渐进式重构:如何在不破坏系统的前提下改进

渐进式重构(Incremental Refactoring)是在不改变系统外部行为的前提下,持续改善代码内部结构的过程。它的核心思想是:将大的重构分解为一系列小的、可以独立验证的步骤,每一步都不破坏系统的功能。

三步循环:重构的基本模式是“测试-修改-测试”。第一步是确保现有的功能被测试覆盖,如果已经有测试用例,要确保它们能够通过;如果没有,需要先补充测试用例。第二步是进行一个小的改进,这个改进不应该超过几分钟。第三步是运行测试,确保功能没有被破坏。重复这个循环,直到代码达到满意的程度。

重构的前置条件:不是所有代码都值得或应该立即重构。在进行重构之前,需要确保有足够的测试覆盖来验证外部行为没有被改变;需要与团队成员沟通,确保他们了解你的改进计划;需要在项目时间表中预留出足够的缓冲来应对可能的意外。

重构的反面:重写。有时,重构的成本会超过重写的成本。如果一个系统已经腐化到无法通过渐进式重构来改善,或者它的架构已经完全无法支撑新的需求,重写可能是更好的选择。但是,重写是风险极高的决策,需要谨慎评估。重写的风险包括:新系统的功能可能不完整;重写过程中业务需求可能继续变化;历史积累的隐性知识可能丢失。业界有句老话:“只有傻子才会重写,聪明人会重写然后死去”——这句话虽然夸张,但提醒我们重写决策需要非常谨慎。

5.5 改进清单:具体可执行的检查点

以下是一些具体可执行的代码改进检查点,新手可以按照这个清单逐一实践。

可读性方面:所有变量名是否清晰描述了其含义?是否有难以理解的魔法数字需要用命名常量替代?函数是否过长需要拆分?嵌套层级是否过深需要简化?注释是否与代码同步更新?

可测试性方面:新写的函数是否容易写测试?如果很难测试,原因是什么?是依赖太多还是副作用太多?是否可以通过依赖注入或提取接口来改善?是否有足够的边界测试覆盖?

可扩展性方面:如果要添加新功能,需要修改哪些现有代码?这些修改是否可以被避免或减少?模块之间的边界是否清晰?依赖方向是否合理?

模块化方面:一个类是否承担了多个职责?类与类之间的依赖是否过于紧密?是否存在难以理解的跨模块调用?包的结构是否反映了模块的划分?

每次开发完成后,选择一两个点进行改进。坚持一段时间后,会发现代码质量有了显著的提升,而这种提升是渐进的、可持续的。

六、总结

从“能用”到“好改”,是代码质量提升的两个阶段,也是开发者成长的两条路径。能用的代码解决的是当下的任务,好改的代码解决的是未来的变化。能够写出能用的代码是基础,能够写出好改的代码是进阶。

可读性是好改的基础。一段难以阅读的代码,几乎不可能被安全地修改。良好的命名、适度的注释、扁平的逻辑结构、整洁的代码布局——这些看似简单的 Practices,实际上是代码可维护性的根本保障。它们不需要高深的技术,只需要认真的态度和长期的坚持。

可测试性是质量的镜子。难以测试的代码往往在设计上存在问题,而测试覆盖则是这些问题的检测器。通过依赖注入、接口隔离、减少全局状态等手段,我们可以让代码更容易被测试,也更容易被理解、被改进。

可扩展性是应对变化的武器。开闭原则、里氏替换、接口稳定性——这些原则不是教条,而是经验教训的总结。遵循它们不能保证代码永远不需要修改,但可以确保修改的范围被控制在最小。

模块化是复杂系统的解药。通过分解问题、定义边界、控制依赖,我们可以将一个复杂的系统变成一组相对简单的模块,每个模块都可以被独立理解、独立开发、独立测试、独立部署。

最后,渐进式演进是实际的路径。没有人能够一步到位写出完美的代码,重要的是保持改进的心态和行动。Boy Scout Rule、渐进式重构、持续改进——这些方法将代码进化从一次性的豪赌变成日常的习惯,让我们在每一天的工作中都能为代码库贡献一点积极的变化。

代码的进化是一场马拉松,不是百米冲刺。重要的不是速度,而是方向和坚持。每一个好的命名、每一次小的重构、每一行新增的测试,都是在正确的方向上前行。假以时日,你的代码库会变得完全不同——不是一夜之间的巨变,而是日积月累的蜕变。这就是从“能用”到“好改”的真正路径:不是颠覆式的重写,而是点点滴滴的进化。