最近接手了一个JIT编译器的项目,代码质量堪忧:命名混乱、函数超长、耦合严重、没有注释、测试缺失。我花了一个月时间对它进行重构,把烂代码变成了优雅代码。本文分享这次重构的过程和经验,包括代码坏味道识别、重构策略、具体重构手法、性能保持、测试保障等,希望对需要做代码重构的同学有参考价值。

一、项目背景

这个JIT编译器,是公司内部一个脚本语言引擎的核心组件。它的作用是把解释执行的字节码,在运行时动态编译成本地机器码,以提升执行性能。

项目已经存在了三年多,前前后后有五六个开发者参与过。因为人员流动大、时间紧、任务重,代码质量一路下滑。到我接手的时候,已经是典型的"祖传代码"了:能跑,但没人敢改。

我接手的任务,是在这个JIT编译器上增加一个新的优化特性。但看了代码之后,我发现,在现有代码上增加新特性,风险太大了,很容易引入bug。而且,以后维护也会越来越困难。

于是,我向领导申请,先做一次代码重构,把代码质量提上来,再增加新特性。领导同意了,给了我一个月的时间。

下面,我就分享这次重构的全过程。

二、代码坏味道诊断

重构之前,先做诊断。我花了三天时间,通读代码,识别代码中的坏味道。

坏味道1:命名混乱

这是最直观的问题。变量名、函数名、类名,各种命名风格混杂:

  • 有的用拼音,有的用英文,有的中英文混合
  • 有的用缩写,而且是只有原作者才知道的缩写
  • 有的变量名叫a、b、c、tmp、data,完全看不出是什么意思
  • 有的函数名叫doIt、process、handle,不知道具体做什么
  • 命名风格不统一,有的用驼峰,有的用下划线,有的全小写

比如,有一个函数叫jitcompilebb,我看了半天才明白,bb是"basic block"(基本块)的缩写。还有一个变量叫ir_buf,是"intermediate representation buffer"(中间表示缓冲区)的缩写。这些缩写,对于不熟悉项目的人来说,就是天书。

坏味道2:函数超长

项目里有很多超长函数,动辄几百行,甚至上千行。最长的一个函数,有1200多行,从寄存器分配到指令选择到代码生成,全在一个函数里。

超长函数的问题:

  • 难以理解,看了后面忘了前面
  • 难以测试,无法对单个逻辑单元做单元测试
  • 难以复用,里面的逻辑无法被其他地方调用
  • 难以修改,改一个地方可能影响其他地方
  • 容易出bug,逻辑复杂,边界条件多

那个1200行的函数,我看了整整一天,才勉强搞懂它在做什么。

坏味道3:耦合严重

模块之间、函数之间,耦合非常严重。一个函数里,直接操作另一个模块的内部数据结构;一个类的成员变量,被其他类直接读写;全局变量满天飞,到处都在读写。

比如,JIT编译器的核心数据结构JITContext,里面有几十个字段,几乎每个函数都在直接读写它的字段。改一个字段的名字,要改几十个地方;加一个字段,要检查所有函数有没有正确初始化和清理。

还有很多全局变量,比如gjitenabledgoptimizationlevelgcodecache,到处都在用,根本不知道谁在什么时候改了它们。

耦合严重的后果是:牵一发而动全身,改一个地方,可能影响很多地方,很容易引入bug。

坏味道4:没有注释和文档

代码里几乎没有注释。复杂的算法,没有注释说明原理;关键的逻辑,没有注释说明为什么这么做;特殊的边界条件,没有注释说明是为了解决什么问题。

有一段代码,我看了半天看不懂,后来问了原作者(已经离职了,好不容易联系上),才知道那是为了解决一个特定CPU架构上的bug。如果有注释,我几分钟就能看懂,结果花了半天。

文档就更没有了。没有架构设计文档,没有接口文档,没有开发指南。新人接手,只能靠读代码来理解,效率极低。

坏味道5:测试缺失

整个项目,单元测试覆盖率不到5%。只有几个最基础的函数有测试,核心的编译逻辑完全没有测试。

没有测试的后果:

  • 重构没有安全网,改了代码不知道有没有引入bug
  • 无法做回归测试,每次修改都要手动测试
  • 新人不敢改代码,怕改出问题
  • bug容易流入生产环境,因为没有测试来发现

我接手的时候,项目里有几个已知的bug,但因为没有测试,一直没人敢修,怕修了一个bug,引入更多bug。

坏味道6:重复代码

项目里有大量的重复代码。同样的逻辑,在不同的函数里复制粘贴,改了一个地方,另一个地方忘了改,导致行为不一致。

比如,指令编码的逻辑,在三个地方有几乎相同的代码,只是处理的指令类型不同。后来加了一种新的指令类型,只改了两个地方,第三个地方忘了改,导致那个路径上有bug,找了很久才找到。

坏味道7:魔法数字和魔法字符串

代码里有很多魔法数字和魔法字符串,没有定义成常量。比如:

  • if (opt_level >= 2),2是什么意思?
  • buf[0] = 0x90,0x90是什么指令?
  • if (strcmp(arch, "x8664") == 0),"x8664"散落在代码各处

魔法数字和字符串的问题:可读性差,不知道是什么意思;改的时候容易漏,改了一个地方,另一个地方忘了改。

坏味道8:错误处理混乱

错误处理非常混乱。有的地方返回错误码,有的地方返回NULL,有的地方直接assert,有的地方直接exit,有的地方打印一条错误信息然后继续执行。没有统一的错误处理机制。

而且,很多地方不检查错误返回值,函数调用之后,不管成功失败,直接继续执行,导致错误被掩盖,后面出了更严重的问题,才发现前面早就出错了。

三、重构策略

诊断完代码坏味道,我制定了重构策略。重构不能盲目进行,要有计划、有步骤。

策略1:先建立测试,再重构

这是最重要的一条。没有测试的重构,就是在裸奔。改了代码,不知道有没有引入bug,心里没底。

所以,重构的第一步,不是改代码,而是补测试。先给核心逻辑写测试,建立一个安全网,然后再在安全网的保护下进行重构。

当然,给烂代码补测试也不容易,因为烂代码耦合严重、函数超长,很难单元测试。我的做法是:先写高层的集成测试(端到端测试),给JIT编译器的整体行为做测试,确保重构前后行为一致。然后,在重构的过程中,随着代码被拆分成小函数、小模块,再逐步补充单元测试。

策略2:小步快跑,持续验证

重构不要想着一步到位,要小步快跑。每次只做一个小的重构(比如提取一个函数、重命名一个变量、提取一个常量),改完之后立刻运行测试,确保没有问题,然后再做下一个。

小步快跑的好处:

  • 每次改动小,出了问题容易定位
  • 每次改动都有测试验证,心里有底
  • 可以随时停下来,不会留下半成品
  • 进度可见,容易获得成就感

如果一次改太多,出了问题,不知道是哪次改动引起的,调试起来很痛苦。

策略3:保持行为一致

重构的定义是:在不改变代码外部行为的前提下,改善代码的内部结构。所以,重构过程中,最重要的原则是:保持行为一致。

每次重构,都要确保代码的外部行为没有变化。如果在重构的同时,又改了逻辑、加了功能、修了bug,那就不是纯粹的重构了,出了问题不知道是重构引起的还是逻辑修改引起的。

我的做法是:重构阶段,只做结构改善,不做逻辑修改。所有的逻辑修改、bug修复、新功能,都等重构完成之后再做。这样,重构过程中出了问题,一定是结构改坏了,容易定位。

策略4:先易后难,循序渐进

重构从容易的地方开始,先做简单的、风险小的重构(比如重命名、提取常量、消除魔法数字),建立信心和节奏,然后再做复杂的、风险大的重构(比如拆分超长函数、解耦模块、重新设计架构)。

先易后难的好处:

  • 简单的重构风险小,不容易出问题
  • 简单的重构见效快,能快速获得成就感
  • 简单的重构完成后,代码质量提升了,复杂的重构也更容易做
  • 循序渐进,不会因为一开始就遇到难题而放弃

策略5:定期提交,保留回滚点

重构过程中,要定期提交代码到版本控制系统,每个小的重构完成后,测试通过了,就提交一次。这样,每个提交都是一个回滚点,如果后面出了问题,可以回滚到上一个正常的状态。

提交信息要写清楚,这次提交做了什么重构,比如"重命名JITContext的字段"、"提取指令编码函数"、"拆分compilebasicblock函数"。这样,以后看提交历史,就能知道重构的过程。

策略6:文档同步更新

重构过程中,文档要同步更新。改了接口,要更新接口文档;改了架构,要更新架构设计文档;改了命名,要更新相关的注释和文档。

不要等重构完了再补文档,那时候很多细节可能已经忘了。边重构边更新文档,文档才能和代码保持一致。

四、具体重构手法

下面,我分享一些具体的重构手法,以及在这个项目中的应用。

手法1:重命名(Rename)

这是最简单、最基础的重构,但也是效果最明显的。把混乱的命名,改成清晰、准确、一致的命名。

具体做法:

  • 变量名:用有意义的名字,不要用a、b、tmp、data。比如,把buf改成codebuffer,把cnt改成instructioncount
  • 函数名:用动词+名词的形式,准确描述函数的功能。比如,把doIt改成compilebasicblock,把process改成allocate_registers
  • 类名:用名词,准确描述类的职责。比如,把JIT改成JITCompiler,把Mgr改成CodeCacheManager
  • 缩写:要么不用缩写,要么用大家都知道的缩写(如JIT、IR、CPU)。把只有原作者才知道的缩写,改成完整的单词
  • 命名风格:统一用一种风格,项目里用驼峰就都用驼峰,用下划线就都用下划线

重命名的时候,要用IDE的重命名功能,不要手动全局替换,因为手动替换容易替换错(比如替换短名字的时候,可能替换到其他单词的一部分)。

在这个项目里,我花了一周时间,把所有的变量、函数、类都重命名了一遍。重命名之后,代码的可读性提升了一个档次,很多以前看不懂的代码,现在看名字就知道在做什么了。

手法2:提取函数(Extract Method)

把超长函数中的一段逻辑,提取成一个独立的函数,给它起一个能准确描述其功能的名字。

提取函数的好处:

  • 函数变短了,更容易理解
  • 提取出来的函数可以被复用
  • 可以对提取出来的函数做单元测试
  • 函数名本身就是注释,看到函数名就知道在做什么

那个1200行的compilebasicblock函数,我拆成了十几个小函数:

  • parse_bytecode:解析字节码
  • buildintermediaterepresentation:构建中间表示
  • optimizeintermediaterepresentation:优化中间表示
  • allocate_registers:分配寄存器
  • select_instructions:选择指令
  • encode_instructions:编码指令
  • relocate_code:重定位代码
  • patch_branches:修补分支指令

每个函数都只有几十行,职责单一,一看就懂。拆完之后,compilebasicblock函数本身只剩下几十行,就是按顺序调用这些小函数,整个流程一目了然。

提取函数的时候,要注意:

  • 每个函数只做一件事(单一职责原则)
  • 函数名要准确描述函数的功能
  • 函数的参数和返回值要清晰
  • 函数内部不要有副作用(或者副作用要明确)

手法3:提取常量(Extract Constant)

把魔法数字和魔法字符串,提取成有意义的常量。

比如:

  • if (optlevel >= 2)if (optlevel >= OPTIMIZATIONLEVELSTANDARD)
  • buf[0] = 0x90buf[0] = X86OPCODENOP
  • strcmp(arch, "x8664")strcmp(arch, ARCHX86_64)

提取常量的好处:

  • 可读性好,看到常量名就知道是什么意思
  • 改的时候只需要改一个地方,不会漏
  • 避免拼写错误(字符串常量拼错了编译器不会报错,但用常量的话,拼错了编译器会报错)

在这个项目里,我把所有的魔法数字和字符串都提取成了常量,定义在一个统一的头文件里。比如,CPU架构、优化级别、指令操作码、寄存器编号等,都定义成了常量。

手法4:用枚举代替整数常量

对于一组相关的整数常量,用枚举来代替,比用#define定义的常量更好。

比如,优化级别:

// 以前
#define OPT_LEVEL_NONE 0
#define OPT_LEVEL_BASIC 1
#define OPT_LEVEL_STANDARD 2
#define OPT_LEVEL_AGGRESSIVE 3

// 现在
typedef enum {
    OPT_LEVEL_NONE = 0,
    OPT_LEVEL_BASIC,
    OPT_LEVEL_STANDARD,
    OPT_LEVEL_AGGRESSIVE,
} OptimizationLevel;

用枚举的好处:

  • 类型安全,编译器会检查类型
  • 一组相关的常量组织在一起,更清晰
  • 调试的时候,调试器可以显示枚举的名字,而不是数字
  • 可以用枚举类型作为函数参数和返回值,更清晰

在这个项目里,我把优化级别、CPU架构、指令类型、寄存器类型等,都改成了枚举。

手法5:封装数据结构(Encapsulate Field)

把直接暴露的数据结构,封装起来,只通过接口访问,不让外部直接读写内部字段。

比如,JITContext这个核心数据结构,以前所有字段都是公开的,到处都在直接读写。我把它的字段都改成了私有的(在C里用不透明指针,在C++里用private),然后提供了一组访问函数:

  • jitcontextgetcodecache(ctx)
  • jitcontextgetoptimizationlevel(ctx)
  • jitcontextsetoptimizationlevel(ctx, level)
  • jitcontextget_statistics(ctx)

封装的好处:

  • 内部实现可以自由修改,不影响外部调用者
  • 可以在访问函数里加校验、日志、统计等逻辑
  • 接口清晰,调用者不需要知道内部细节
  • 减少耦合,外部只依赖接口,不依赖内部实现

封装之后,改JITContext的字段,就不需要改几十个地方了,只需要改访问函数的实现。

手法6:消除全局变量

把全局变量消除掉,改成通过参数传递,或者封装在数据结构里。

全局变量的问题:

  • 隐式依赖,函数的依赖不明显,看函数签名不知道它依赖了全局变量
  • 难以测试,测试的时候要设置全局变量的状态,测试之间容易互相影响
  • 难以复用,用了全局变量的函数,很难在其他环境下复用
  • 线程不安全,多线程环境下,全局变量需要加锁,容易出问题

在这个项目里,我把所有的全局变量都消除了:

  • gjitenabled → 作为参数传给需要的函数,或者封装在JITContext
  • goptimizationlevel → 封装在JITContext
  • gcodecache → 封装在JITContext里,通过访问函数访问

消除全局变量之后,函数的依赖变得清晰了,看函数签名就知道它需要什么。测试也变得容易了,可以创建不同的JITContext来测试不同的配置。

手法7:消除重复代码(Extract Method + 统一调用)

找到重复的代码,提取成公共函数,然后把所有重复的地方,都改成调用这个公共函数。

比如,前面提到的指令编码逻辑,在三个地方有几乎相同的代码。我把它提取成了一个公共函数encode_instruction,然后把三个地方都改成调用这个函数。

消除重复代码的好处:

  • 改逻辑只需要改一个地方,不会漏
  • 代码量减少了
  • 逻辑统一了,不会出现不同地方行为不一致的情况
  • 公共函数可以单独测试

在这个项目里,我消除了十几处重复代码,代码量减少了几百行,而且逻辑更统一了。

手法8:统一错误处理

建立统一的错误处理机制,所有的错误都用同样的方式处理。

在这个项目里,我定义了统一的错误码类型JITError,所有可能失败的函数,都返回JITError。调用者必须检查返回值,如果出错了,要做相应的处理(清理资源、向上传播错误等)。

同时,我定义了一些辅助宏,简化错误处理:

#define JIT_TRY(expr) \
    do { \
        JITError err = (expr); \
        if (err != JIT_OK) { \
            return err; \
        } \
    } while (0)

用这个宏,调用可能失败的函数,只需要写JITTRY(compilebasic_block(ctx, bb));,如果出错了,会自动返回错误码。

统一错误处理的好处:

  • 错误处理方式一致,代码更清晰
  • 不会遗漏错误检查,因为编译器会警告未使用的返回值
  • 错误可以向上传播,不会被掩盖
  • 资源清理更规范,不会出现资源泄漏

手法9:拆分模块(Split Module)

把一个大的、职责不单一的模块,拆分成多个小的、职责单一的模块。

在这个项目里,原来所有的代码都在几个大文件里:

  • jit.c:2000多行,什么都有
  • jit.h:500多行,所有的接口都在这里

我把它们拆分成了多个模块:

  • jit_compiler.c/h:JIT编译器的主接口
  • bytecode_parser.c/h:字节码解析
  • ir_builder.c/h:中间表示构建
  • ir_optimizer.c/h:中间表示优化
  • register_allocator.c/h:寄存器分配
  • instruction_selector.c/h:指令选择
  • code_encoder.c/h:代码编码
  • code_cache.c/h:代码缓存管理
  • jit_context.c/h:JIT上下文管理

每个模块都只有几百行,职责单一,接口清晰。找代码的时候,直接去对应的模块找,不用在一个2000行的大文件里翻。

拆分模块的时候,要注意:

  • 每个模块只做一件事(单一职责原则)
  • 模块之间的依赖要清晰,不要循环依赖
  • 模块的接口要稳定,内部实现可以自由修改
  • 模块的命名要清晰,一看就知道是做什么的

手法10:补充注释和文档

给复杂的算法、关键的逻辑、特殊的边界条件,补充清晰的注释。同时,编写架构设计文档、接口文档、开发指南。

注释要写"为什么",而不是"做什么"。因为"做什么"可以从代码看出来,而"为什么"是代码看不出来的。

比如:

// 不好的注释:把buf的第一个字节设为0x90
buf[0] = 0x90;

// 好的注释:插入NOP指令,用于对齐代码缓存行,提升执行性能
buf[0] = X86_OPCODE_NOP;

好的注释,能让读者快速理解代码的意图和背景,不需要自己去猜。

在这个项目里,我给所有的公共接口写了文档注释(Doxygen格式),给复杂的算法写了原理说明,给特殊的边界条件写了原因。同时,写了一份架构设计文档,描述整个JIT编译器的架构、模块划分、核心流程;写了一份开发指南,描述如何添加新的优化、如何调试、如何测试。

五、性能保持

JIT编译器是性能敏感的组件,重构不能影响性能。重构过程中,我采取了一些措施来保持性能。

措施1:建立性能基准测试

重构之前,先建立性能基准测试。选一组有代表性的测试用例(不同类型的脚本、不同大小的函数、不同的优化级别),测量重构前的编译时间和生成代码的执行时间,作为基准。

重构过程中,每次大的重构之后,都跑一遍性能基准测试,和基准对比,确保性能没有下降。如果性能下降了,要分析原因,优化之后再继续。

措施2:避免引入额外的抽象开销

重构的时候,要注意不要引入额外的抽象开销。比如:

  • 函数调用开销:提取函数的时候,不要把频繁调用的小函数拆得太碎,函数调用有开销(虽然编译器可能会内联,但不保证)
  • 指针间接访问开销:封装数据结构的时候,访问函数如果太频繁,会有指针间接访问的开销。对于性能关键路径上的访问,可以考虑用inline函数,或者直接访问(但要控制范围)
  • 内存分配开销:重构的时候,不要引入不必要的内存分配。内存分配开销很大,性能关键路径上要避免

在这个项目里,对于性能关键路径上的代码(比如指令编码的内层循环),我保持了比较高的内聚,没有过度拆分。对于访问频繁的字段,我用了inline访问函数,编译器会内联掉,没有额外开销。

措施3:用编译器优化

重构之后,代码结构更清晰了,编译器更容易做优化。比如,小函数更容易被内联,const-correct的代码更容易被优化,没有别名的指针更容易被优化。

所以,重构之后,性能不仅没有下降,有些地方反而提升了。因为代码结构更好了,编译器能做更多的优化。

措施4:性能分析和优化

重构完成之后,用性能分析工具(如perf、VTune)分析JIT编译器的性能,找到热点,做针对性的优化。

在这个项目里,重构之后,我发现寄存器分配是一个热点,占了编译时间的30%。我对寄存器分配算法做了优化,用了更高效的数据结构,编译时间减少了15%。

这是重构带来的额外好处:代码清晰了,更容易找到性能瓶颈,也更容易优化。

六、测试保障

测试是重构的安全网。重构过程中,我建立了完善的测试体系。

1. 端到端测试

首先建立了端到端测试。写了几十个测试脚本,覆盖各种语言特性(循环、条件、函数、递归、异常、闭包等)和各种边界条件。每个测试脚本,用解释器执行一遍,用JIT编译器执行一遍,对比结果是否一致。

端到端测试的好处是:不需要了解内部实现,只需要对比外部行为。重构过程中,只要端到端测试通过,就说明外部行为没有变化。

我把端到端测试接入了CI,每次提交代码都会自动跑一遍,确保没有引入回归。

2. 单元测试

随着重构的进行,代码被拆分成了小函数、小模块,我逐步补充了单元测试。每个模块、每个核心函数,都有对应的单元测试,覆盖正常情况和边界条件。

单元测试的好处是:可以测试单个逻辑单元,定位问题更精确。某个函数出了问题,单元测试能直接发现,不需要通过端到端测试来间接推断。

到重构完成的时候,单元测试覆盖率达到了70%以上,核心逻辑的覆盖率达到了90%以上。

3. 模糊测试

对于JIT编译器来说,模糊测试(Fuzzing)是非常有效的测试方法。用模糊测试工具,随机生成各种字节码,喂给JIT编译器,看会不会崩溃、会不会生成错误的代码。

模糊测试能发现很多人工测试发现不了的边界条件和异常情况。在这个项目里,模糊测试发现了十几个隐藏的bug,都是在非常特殊的输入下才会触发的。

4. 性能测试

前面提到的性能基准测试,也是测试的一部分。每次重构之后,都要跑性能测试,确保性能没有下降。

5. 测试驱动重构

我的重构流程是:

  1. 给要重构的代码写测试(如果还没有的话)
  2. 运行测试,确保测试通过
  3. 进行重构
  4. 运行测试,确保重构后测试仍然通过
  5. 如果测试失败,回滚或者修复
  6. 提交代码

这个流程,确保了每次重构都是安全的,不会引入bug。

七、重构的成果

一个月之后,重构完成了。来看看成果。

代码质量方面:

  • 命名全部规范化,清晰、准确、一致
  • 最长的函数从1200行降到了100行以内,大部分函数只有几十行
  • 模块从3个拆分成了10个,每个模块职责单一
  • 全局变量全部消除,耦合大幅降低
  • 魔法数字和字符串全部提取成常量和枚举
  • 错误处理统一,所有错误都有检查
  • 公共接口全部有文档注释,复杂算法有原理说明
  • 单元测试覆盖率从5%提升到70%以上

代码量方面:

  • 因为消除了重复代码,代码量减少了约20%
  • 虽然增加了注释和文档,但净代码量还是减少了

性能方面:

  • 编译时间没有下降,部分场景还有提升(因为代码结构更好,编译器优化更充分)
  • 生成代码的执行性能没有变化
  • 寄存器分配优化后,编译时间减少了15%

可维护性方面:

  • 新人接手,从以前的一个月才能看懂,降到了一周就能上手
  • 改代码不再提心吊胆,有测试保护
  • 加新特性的效率提升了,因为代码结构清晰,知道在哪里加
  • bug修复速度提升了,因为代码清晰,容易定位问题

重构完成之后,我在重构后的代码上,增加了当初要加的新优化特性,只花了一周时间。如果是在重构前的代码上加,估计要花一个月,而且很容易引入bug。

八、重构中的教训

这次重构,也让我吸取了一些教训。

教训1:不要在重构的同时改逻辑

有一次,我在重构一个函数的时候,顺手改了里面的一个逻辑(觉得那个逻辑写得不好)。结果,测试失败了,我花了半天时间才找到问题,就是因为那个逻辑改动引起的。

从那以后,我严格遵守:重构阶段只改结构,不改逻辑。逻辑修改留到重构完成之后再做。

教训2:不要过度设计

重构的时候,容易犯过度设计的错误。比如,为了"可能的复用",提前抽象出很多接口和基类;为了"可能的扩展",提前加很多钩子和配置。

但这些"可能的"需求,很多时候根本不会来。过度设计的结果是:代码结构复杂,抽象层次多,理解起来困难,而且很多抽象根本用不上。

我的教训是:重构要基于实际需求,不要基于想象的需求。只抽象当前确实需要的东西,不要为了"以后可能会用到"而提前设计。简单、清晰,比复杂、灵活更重要。

教训3:重构要和团队沟通

重构不是一个人的事,会影响整个团队。重构之前,要和团队沟通,让大家知道为什么要重构、重构的计划、重构的影响。重构过程中,要及时同步进度,让大家知道当前的状态。重构完成之后,要组织培训,让大家熟悉新的代码结构和规范。

如果不和团队沟通,自己闷头重构,可能会和其他人的工作冲突(比如别人也在改同一份代码),或者重构完成后,其他人不适应新的代码结构,反而降低了效率。

在这个项目里,我每周和团队同步一次重构进度,重构完成后组织了一次代码走查,让大家熟悉新的结构。团队的反馈很好,大家都觉得重构之后的代码更好用了。

教训4:重构是持续的,不是一次性的

重构不是一次完成之后就一劳永逸了。代码会不断变化,新的代码会引入新的坏味道。所以,重构是持续的,要把重构融入日常开发中。

我的做法是:

  • 每次改代码的时候,顺便把周围的坏味道也改了("童子军规则":离开营地的时候,比你来的时候更干净)
  • 每个迭代留一点时间做重构
  • 定期做代码审查,发现坏味道及时处理
  • 建立代码规范,用工具(如lint、静态分析)自动检查坏味道

重构是一个持续的过程,不是一次性的运动。只有持续重构,才能保持代码质量。

九、给需要做代码重构的同学的建议

如果你也面临烂代码,需要做重构,给你几点建议。

1. 先想清楚为什么重构

重构之前,先想清楚:为什么要重构?要解决什么问题?重构的目标是什么?

不要为了重构而重构,不要觉得"代码不够优雅"就重构。重构是有成本的,要确保重构的收益大于成本。

常见的重构理由:

  • 代码太难懂,维护效率低
  • 改代码容易引入bug,没有测试保护
  • 加新特性很困难,代码结构不支持
  • 性能有问题,需要优化
  • 团队新人上手慢,学习成本高

想清楚了为什么重构,才能制定合理的重构计划,也才能说服领导和团队支持重构。

2. 先建立测试,再重构

这是最重要的一条。没有测试的重构,就是在裸奔。重构之前,一定要先建立测试,哪怕只是端到端测试,也要有。

如果代码太烂,无法写单元测试,就先写端到端测试,确保外部行为一致。然后在重构的过程中,随着代码结构的改善,逐步补充单元测试。

3. 小步快跑,不要贪多

不要想着一次就把所有问题都解决了。小步快跑,每次只做一个小的重构,改完立刻测试,确保没有问题,再继续下一个。

小步快跑,风险小,进度可见,容易坚持。一次改太多,出了问题很难定位,容易放弃。

4. 保持行为一致

重构阶段,只改结构,不改逻辑。确保重构前后,代码的外部行为一致。

如果在重构的同时改逻辑,出了问题不知道是重构引起的还是逻辑修改引起的,调试起来很痛苦。

5. 定期提交,保留回滚点

每个小的重构完成后,测试通过了,就提交一次。每个提交都是一个回滚点,出了问题可以回滚。

提交信息要写清楚,做了什么重构,方便以后追溯。

6. 不要追求完美

重构不是要把代码改得完美无缺,而是要让代码比现在更好。够用就好,不要过度设计,不要为了追求"优雅"而把代码搞得复杂难懂。

简单、清晰、可维护,比"优雅"更重要。

7. 和团队沟通

重构不是一个人的事,要和团队沟通,让大家知道重构的计划和进度,争取大家的支持。重构完成后,组织培训,让大家熟悉新的代码结构。

8. 持续重构

重构是持续的,不是一次性的。把重构融入日常开发中,每次改代码的时候顺便改善周围的代码,定期做代码审查,保持代码质量。

十、写在最后

这次JIT编译器的重构,是我做过的最大规模的一次重构。从最开始的恐惧(面对祖传代码,不知道从哪里下手),到后来的渐入佳境(小步快跑,逐步改善),到最后的成就感(烂代码变成了优雅代码),整个过程让我收获很多。

我最大的体会是:烂代码不可怕,可怕的是不敢面对烂代码,任由它继续烂下去。只要有决心、有方法、有耐心,烂代码也能变成优雅代码。

重构的过程,也是一个学习的过程。在重构的过程中,你会更深入地理解代码的逻辑,会学到更好的设计方法,会提升自己的工程能力。这些,比重构本身更有价值。

代码是写给人看的,顺便给机器执行。好的代码,让人读起来愉悦,改起来放心;烂的代码,让人读起来痛苦,改起来提心吊胆。作为程序员,我们有责任写出好的代码,也有责任把烂代码改好。

最后,用一句话结束本文:"任何傻瓜都能写出计算机能理解的代码,优秀的程序员写出人类能理解的代码。"愿每一个程序员,都能写出优雅的代码,也能有勇气和能力去重构烂代码。