ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

代码重构实战:识别坏味道并小步改善代码设计

代码重构实战:识别坏味道并小步改善代码设计 我大概每隔两三年就会把《重构改善既有代码设计》翻出来再读一遍。最近一次读的是第2版Martin Fowler把示例代码从Java换成了JavaScript读起来更轻快但核心思想反而更清晰了。这本书讲的是同一件事在不改变代码外部行为的前提下通过一系列小步骤的调整把内部结构改得更容易理解、更容易修改。说白了就是教你如何给代码“做手术而不是截肢”。我会把它推荐给两类人一类正在接手老系统、打算做系统重构的程序员另一类是写了几年业务代码、隐约感觉代码设计出了问题但不知道从哪下手的开发者。第2版去掉了不少Java时代特有的重量级技法整个体系更贴近现代主流语言再加上新的示例和排版读起来不累。但要注意这是一本手册不是小说。手册的意思是你把它放在手边写代码时遇到可疑的味道就翻到对应的坏味道和手法章节查完了直接拿到自己项目里用。只要你有过一次“这段代码我不敢改怕改崩”的感受书里的方法就能帮上忙。1. 先想清楚一件事重构到底在解决什么问题1.1 为什么这本书每隔几年都值得重读《重构》这本书的核心价值不在于让你学会某个具体技巧而在于帮你建立一套判断标准什么样的代码值得改什么样的改动才是真正的重构。第2版在保留了原有20多个坏味道和几十个重构手法的基础上把示例迁移到了JavaScript环境读起来不像第1版那样隔着Java的三层包装而是更贴近现代开发者每天面对的真实代码。我会反复读它是因为每一轮重构的认知都会更新。最早接触这本书时我只记住了“提炼函数”“搬移函数”这些动作以为重构就是把大函数拆小后来自己带项目、接手老系统才慢慢理解“行为保持”这四个字的分量。一个系统跑得好好的你凭什么动它因为改不动了、加需求太痛了、新人学不会了。这时候重构的价值才会显现它不是炫技而是为了让系统继续活下去。这本书适合放在案头随时翻。遇到一个函数看得头疼翻到“过长函数”和“提炼函数”遇到改一个需求要动五个地方翻到“霰弹式修改”和“搬移语句”。它就是一本关于代码设计的实用手册而不是让你读完一遍就束之高阁的畅销书。1.2 重构不是重写也不是表演很多人听到重构第一反应是推翻重来。我在评审团队里各种“重构方案”时发现它们的本质往往是重写换个新框架、重新设计表结构、把代码推倒再来一遍。Fowler在书里反复强调重构是一种不动摇外部可观察行为的结构调整。每一步都很小改完立刻测试通过后再走下一步。这不是保守而是为了让你随时可以停下来随时可以安全地退回到上一个稳定状态。当然重写在一些场景下也有价值。一个系统如果病入膏肓维护成本高到难以接受重写可能才是正确选择。但那是另外一条路和这本书讨论的重构并不是一回事。混为一谈带来的问题是嘴上说“我在重构”手里却在重写结果行为变化和结构调整搅在一起出了Bug根本定位不到是哪一步引入的。我以前带过几个教学项目比如经典的“机房收费系统”三层架构练习经常听到同学说“我要重构一下”。追问下去才发现计划是换数据库访问方式、重写业务层甚至顺手改掉几个业务规则。这不是重构这是重写。重写没有错但是当它戴着“重构”的帽子出现时所有人的预期都错了最后往往被一堆行为和结构同时变化的Bug淹没。重构的前提是行为不变如果行为要变那已经不是重构这件事了。2. 代码里的坏味道怎么发现该重构的地方2.1 坏味道清单哪些信号说明代码生病了Fowler在书中给出了一份非常重要的坏味道清单。我把这份清单看成体检报告单气味是信号而非判决它提示你可能存在问题是否需要处理还要看具体场景。第2版里的坏味道包括神秘命名、重复代码、过长函数、过长参数列表、全局数据、可变数据、发散式变化、霰弹式修改、依恋情结、数据泥团、基本类型偏执、重复的switch、循环语句、惰性元素、内幕交易、过大的类、类继承体系过深、冗余元素、夸夸其谈。我挑几个平时遇到最多的做成表格方便直接对照坏味道典型信号常用处理手法神秘命名函数名/变量名含糊看不出意图重命名变量、重命名函数重复代码同一段逻辑在多个模块出现提炼函数、搬移函数过长函数一个函数几十上百行缩进很深提炼函数、以查询取代参数过长参数列表参数超过四五个调用像念咒语引入参数对象、以查询取代参数可变数据一个值在多处被改来改去以查询取代派生变量、封装变量依恋情结某个函数更爱访问别的对象的数据搬移函数霰弹式修改改一个需求要动好几个类将相关逻辑聚拢进同一模块坏味道不会让程序立刻崩溃但会一点一点抬高修改成本。就像家里的杂物不会突然压塌房子但会让你找东西的时间越来越长最后连客厅都不想进。每次改代码时都要多读几遍才能明白上下文这就是成本开始失控的信号。2.2 为什么代码会一步步腐化代码腐化几乎没有例外。我待过的团队代码库只要超过半年不主动维护结构复杂度就会持续上升。这不是某个人的水平问题而是系统熵增的自然结果。需求一直在变时间一直在压正确但麻烦的设计没人坚持复制粘贴比抽象省事于是腐化在一行行代码里悄悄发生。一个真实的场景订单状态字段最初只是一个简单枚举后来为了支持退款流程加了“退款中”又加了“退款失败”再后来是“退款成功”和“部分退款”。每个新状态进来调用方都习惯性地在switch里加一个case几个月之后那个switch已经有几十个分支没人敢动它因为它牵着大量隐式依赖。这段代码是坏味道的集合体重复的switch、可变数据、过长函数全占齐了。它告诉我们不要等系统烂到这一步才想着做系统重构应当在熵增刚开始时就定期清理。2.3 系统重构场景下的坏味道会被放大在小模块里坏味道只是局部别扭一旦面对一个运行多年、几十万行代码的系统坏味道会被放大成灾难。我接过的老系统里启动时要加载一批配置每个配置文件单独看都很短很清晰但联合起来才发现调用链绕了七八层。改一个字段要在六个模块里同步修改漏掉任何一个线上就出问题。这种“改一次需求动一堆地方”的感受就是书里的霰弹式修改——本应聚在一起的变化被拆散到了各个角落。做系统重构时我先用坏味道清单把目标模块过一遍再定一个简单标准某个模块如果一周内让你改了两次以上并且两次都涉及不同文件它就值得成为重构候选。量化信号比凭感觉重要用这样的标准挑出来的模块重构后效率提升几乎都能直接感受到。3. 常用重构手法从书里学到又能用上的3.1 提炼函数让每一段代码都有一个名字提炼函数Extract Function可能是最常用的重构手法。动机很简单一段代码你看了两遍还想再看第三遍才能明白它在干什么那就值得把它提炼成一个有名字的函数。这样读者只需看到函数名就能知道这段逻辑的意图。// 重构前 function report(orders, customer) { let total 0; for (const o of orders) { total o.amount; } // 这里打印订单明细大概二十行 // 这里再计算客户折扣大概十五行 // 最后输出汇总信息大概十行 } // 重构后 function report(orders, customer) { printOrders(orders); printCustomer(customer); }Fowler有个观点我一直很认同函数名就是注释。好的函数名把意图写在一行里读代码的人不必自己从五十行逻辑中拼出“哦这里是在算总价”。注意提炼函数不是为了把代码变短而是为了提高信息密度。那些“函数名叫process函数体八十行”的写法本质上等于注释写了等于没写。我的实操心得是别追求提炼到极致。我见过有人把两行代码也提成一个函数全文件布满三四个字母缩写的小函数读起来反而更费力。提炼函数的核心是“命名表达意图”不是“拆得越碎越好”。我在决定是否提炼时只问自己这段代码以后会不会单独被修改或者复用会就提炼不会就让它在原处待着别为了所谓的整洁制造更多无谓层级。3.2 以查询取代派生变量让数据流动变清晰书中花了不少篇幅谈可变数据的坏味道也给出了一批对应手法。以查询取代派生变量Replace Derived Variable with Query是我在重构业务系统时特别常用的一种。它解决的问题是某个值明明可以由其他数据推导出来却被保存成了变量然后在多个地方被反复修改。// 坏味道用变量维护派生状态 let basePrice 100; let discountedTotal 0; function applyDiscount(rate) { discountedTotal basePrice - rate * basePrice; } // 重构状态尽量用函数推出来 function getDiscountedTotal() { return basePrice * (1 - discountRate); }理由很简单当多处同时修改同一个派生变量很容易出现改到一半被别的代码读到中间态的问题。把派生值改成计算函数以后数据流向变成单向的原始数据不变派生值永远跟随变化。以后扩展新的折扣策略也只需要改一个计算函数不需要去所有给discountedTotal赋值的地方翻找。这个手法在报表模块尤其好用。报表本质上是拿一堆原始数据算出各种汇总值如果每个汇总值都是被反复赋值的变量整个函数就会变成一团乱麻。把汇总值改写成函数后调用顺序不再影响结果维护起来会清爽很多。我重构过的几个报表模块基本都靠这一招把复杂度压了下来。3.3 搬移函数代码要住在该住的地方搬移函数Move Function解决的是归属感问题。当一个函数主要依赖另一个对象的数据而跟自己的宿主类几乎没什么关系就应该搬过去。这个判断并不难难的只是下定决心动手。我在重构订单模块时遇到过这样的函数订单类里有个calculateShippingCost读的却全是发货单模块的数据。它在订单类里待了很久每次维护都要跨模块翻代码。表面上看订单需要知道运费实际上运费计算逻辑只和发货单相关。搬移之后发货单模块的数据和逻辑终于自洽了新人要改运费时只需要看发货单这个文件少绕一层弯路。判断归属地是否合适我有个朴素的方法问自己如果别人想修改这段逻辑他第一反应会去哪个文件找如果答案是另一个类那就说明它住错地方了。这个方法虽然带点主观但在团队实践中非常有效——代码组织最终服务的是人类读者不是编译器而读者的直觉是最值得参考的线索。3.4 以多态取代条件表达式让分支逻辑各归其位条件表达式本身没有错但如果代码里到处是按类型字段切换行为的switch或if-else每加一个新类型就要把所有地方都改一遍那就是需要重构的信号。书中提出的手法是以多态取代条件表达式Replace Conditional with Polymorphism把分支逻辑收敛到各自的类型实现里。// 重构前 function getSpeed(animal) { switch (animal.type) { case dog: return animal.legs * 2; case bird: return animal.wings * 3; } } // 重构后 class Dog { getSpeed() { return this.legs * 2; } } class Bird { getSpeed() { return this.wings * 3; } }回到第2章里的订单状态例子。与其继续在switch里增加case不如定义一组状态类让每个状态自己决定是否可退款、如何计算费用。新增一种状态时只需要增加一个新类而不必改动那个几十个分支的老switch。这个手法在业务系统重构里价值很高也正是“系统重构”区别于零散改代码的地方不是救火而是把变化点分布到合理的位置让未来新增功能时改动最小。4. 重构实操流程测试、小步、工具一个都不能少4.1 测试先行没有安全网就别走钢丝Fowler在书里反复强调重构第一步是确保有一套可靠的测试。这句话在我看来比所有具体做法加起来都重要。没有测试你就是蒙着眼睛改代码。很多人上手就改改完才发现一个隐藏依赖被破坏再花两倍时间去定位是哪一行行为发生了变化。我的建议是重构前先把目标模块的输入输出用测试固定下来。不追求覆盖率很高但必须覆盖要动过的路径。全部跑绿再动手。每完成一个小重构立刻再跑一遍测试。如果测试变红先判断是行为确实变了还是测试写得太死。行为变了就要停下来想一想刚才那一步是不是引入了计划外变化。在遗留系统上做重构补测试的难点往往不是态度而是不知道怎么补。这时候可以退一步写一个“特征测试”给定几个典型输入把当前系统输出记下来当成预期结果。特征测试不关心逻辑是否合理只关心重构前后行为是否一致。我曾经在一段几乎没人敢动的支付代码上先写了特征测试才敢开始重构。有了这张安全网哪怕中途走歪了也能快速退回。4.2 小步提交每一次改动都要能安全回退我见过不少团队“重构”一整天代码改到面目全非中间没有留下任何保存点。出问题以后只能对着git diff发呆或者干脆放弃一天的成果。正确做法是把大重构拆成一系列小重构每完成一步就编译、跑测试、提交。这和Fowler说的重构节奏完全一致一次只提炼一个函数、一次只搬移一个方法、一次只替换一个算法。每次提交信息都要写清楚比如“重构将订单计算逻辑提取到calculateTotal函数”。将来回溯时你能精确看到每一步改了哪些文件、动了哪些逻辑而不是面对一个名叫“refactor”的巨型commit不知所措。我自己定了一个“二十分钟规则”如果一个重构步骤超过二十分钟还没法验证说明切得太粗需要回头重新拆分。这条规则很简单坚持下来后重构效率反而提升明显——每一步都是确定的只有最后一步需要动脑。4.3 用好IDE与静态检查工具主流IDE的重命名和提炼函数功能已经非常成熟能大幅减少手工改写的错误。重命名函数时我基本不再用“全局替换”这类粗暴方式而是用IDE的Rename Symbol它会自动把所有调用点一起改掉并且允许预览改动列表。提炼函数也一样选中代码段IDE自动抽取函数并处理参数与局部变量省掉大量易错的机械操作。静态检查工具同样值得接入比如ESLint、SonarQube。书里的坏味道有些无法用工具自动识别但圈复杂度、函数长度、重复代码、未使用变量这些指标工具能给出明确提示。把静态检查放到CI里每次提交自动跑一遍相当于让机器盯住低级坏味道把人的精力留给真正需要判断力的重构决策。再提一个容易被忽略的小事格式化。不少人在重构时顺手把整个文件重新排版diff里塞满了空白和换行改动评审者根本看不出逻辑变化。我的做法是让重构和格式化完全分离要么先把格式化单独提交一次要么干脆不在重构提交里做格式化。确保每次diff只反映真正的结构变化对团队协作非常关键。5. 重构踩坑实录替你交过的学费5.1 把重构当成性能优化的借口重构的目标是改善代码结构而不是提升性能。一边重构一边调性能很容易在“行为保持不变”这条约束上翻车。原因有两个性能优化可能本身就改变行为而在结构不清晰的时候绝大部分性能直觉都是错的你优化了半天也许只是优化了一个根本不热的分支。正确的顺序我是从这本书里学到的先让结构变清晰再做性能优化。结构清晰以后真正的瓶颈往往自己浮出水面就算没有你也更容易写出准确的基准测试而不是靠猜。所以我的习惯是重构阶段完全忽略性能只追求结构正确等结构稳定后再单独做一轮性能分析和优化。这样做也能让重构的边界更干净谁也不会混淆“这次改动到底改了什么”。5.2 重构做着做着就偏航了重构中最容易犯的错就是顺手加需求、顺手改格式、顺手重命名一堆无关函数。这个“顺手”会让变更范围越滚越大最后无法收尾。我见过最典型的场景本来只想把一个长函数拆成三个小函数拆到一半觉得“既然都来了不如把模块接口也统一一下”。结果一下午过去函数拆了一半没有提交接口改了一半没有跑通。这种半成品状态最危险。要避免偏航动手前先立边界这次重构只处理哪个坏味道、涉及哪些文件、允许改到什么程度。一旦发现想做边界之外的事就记到TODO里留给下一次。书里的重构态度也是这样的只关心当前这一步要替换什么其他东西保持原样。范围控制住了重构才可能按期结束而不是变成一个永远做不完的“系统重构工程”。5.3 团队协作中的重构沟通问题重构代码必须跟团队沟通这点经常被忽略。你可能觉得代码自己写得很熟想怎么改就怎么改但如果别人正在同一棵文件树上工作你的重构很可能在他分支上留下合并地狱。所以提前说一声并不是多余。我的经验是重构前用issue或MR把改动范围描述清楚重构过程中尽量不做大范围格式重排把重构提交和功能变更分开让评审者能聚焦在逻辑变化上。和项目经理沟通收益时不要说“我要改善代码质量”这种空话而是找具体业务痛点。比如“目前修改一个支付规则要动四个模块大约需要两天重构后只动一个模块预期半天”。用业务语言讲收益别人才更容易支持你。还有一个很容易被忽略的点团队里如果有新人重构前先拉他一起看看这段代码听听他的疑问。你看得太熟觉得理所当然新人没有历史包袱他的提问往往能帮你发现被忽略的可变数据或隐含依赖。重构不只是一个人的技术活更是团队知识同步的过程。6. 写在最后一次只做一步的重构经验6.1 三遍阅读带来的三个层次这本书我读了三遍每一遍收获都不同。第一遍学手法记住了提炼函数、搬移函数这些名词第二遍开始理解“行为保持”的价值知道为什么每一步都要小、都要可验证第三遍才真正意识到重构不只是一堆代码层面的动作更是一种持续的工作方式。现在我会把“每次提交都顺手做一点小重构”变成习惯改一个有意义的名字、去掉一段重复代码、合并两个本应在一起的模块。单看每件事都微不足道但长期坚持系统的结构真的不会再像以前那样迅速变烂。6.2 给想开始重构的人一个小建议如果看这篇文章的你也准备开始我给的建议和书里一样简单找到一处你最看不下去的坏味道为它补上一个特征测试然后用一个手法把它改掉。一次只做一步做完立刻测试和提交。所有大型优化工程包括那些听起来吓人的“系统重构”本质上都是由无数个这样的小步骤组成的。不要急着证明自己一天能重构多少行代码先证明自己能稳定地、小步地、安全地把一段代码变好这个习惯比任何单一技巧都值钱。
返回列表