refactor-clean

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Clean Refactoring

干净重构

Replace the old shape with the simpler shape the codebase would want if it were designed today. Refactoring is not adding a compatibility layer beside the problem; it is moving ownership until every concept has exactly one clear home. That cuts both ways: merging N duplicated owners into one, and splitting one over-loaded module into the several owners it was hiding.
用代码库如果在当下设计会采用的更简洁形态,替换掉旧的形态。重构不是在问题旁边加一个兼容层;而是调整职责归属,直到每个概念都有且仅有一个明确的归属地。这体现在两个方面:将N个重复的职责所有者合并为一个,以及将一个过载的模块拆分为它所隐含的多个职责所有者。

Workflow

工作流程

  1. Name the concept that lacks one clear owner — duplicated across several owners, or several concepts fused into one over-loaded module. Identify the thing(s) that should each have one owner: environment, pricing rule, geometry source, state machine, data contract, renderer phase, API shape, UI state, or test oracle.
  2. Find every current owner and consumer. Treat wrappers, aliases, pass-local constants, copied structs, and "temporary" branches as sediment until proven otherwise.
  3. Promote the concept to its natural home. Pick the module that would own it from scratch, then make old call sites consume that owner directly.
  4. Delete or collapse the stale path in the same pass when feasible. If a bridge must remain, make it tiny, named as compatibility, and give it a removal condition.
  5. Verify behavior through consumers, not just the new module. A clean refactor is only proven when the surfaces that used to diverge now report or exercise the same source of truth.
  1. 找出缺乏明确唯一归属的概念——可能是在多个所有者中重复存在,也可能是多个概念被融合进一个过载的模块。确定那些本应各自拥有一个所有者的事物:环境、定价规则、几何数据源、状态机、数据契约、渲染阶段、API形态、UI状态,或是测试预言。
  2. 找到所有当前的所有者和消费方。将包装器、别名、本地传递的常量、复制的结构体以及「临时」分支都视为代码沉积物,除非能证明它们不是。
  3. 将该概念迁移到它的天然归属地。选择从零开始设计时本应负责它的模块,然后让旧的调用点直接使用这个所有者。
  4. 可行的话,在同一次变更中删除或折叠过时的路径。如果必须保留一个桥接层,要让它尽可能小,明确命名为兼容层,并为它设定移除条件。
  5. 通过消费方而非仅仅是新模块来验证行为。只有当过去存在分歧的接口表面现在都使用或验证同一个可信源时,干净的重构才算是被证明有效。

Rules

规则

  • Every line is debt — the best mechanism is the one you don't write. Before building any guard, validator, convergence protocol, or cleanup path, two checks gate it:
    1. Does the platform already do this? Read the component/framework you are working around before coding around it — its crons, retries, defaults, and lifecycles. A hand-built janitor beside a component that already vacuums itself is pure debt, and it will be debugged by someone who doesn't know the component made it unnecessary.
    2. Does the failure it guards change any real outcome? Trace the guarded condition to its consumer. If the consumer is indifferent — an ordering check feeding a consumer that doesn't care about order, a validation on data only our own code produces — the guard tests nothing and must not be written. A common shape: an invariant re-checked at read time that the single write path already establishes, so the branch can only fire on hand-corrupted rows. Ask which caller could produce the bad state; if the honest answer is "none, short of someone editing the database", delete the branch. "It could be inconsistent" is not a reason; "the consumer would then do the wrong thing" is. The bar is not "is this correct?" — defensive code is usually correct. The bar is "what breaks, for whom, if this line doesn't exist?" No concrete answer → no line.
  • Measure the change, and treat a bad size-to-behaviour ratio as a shape defect. Before accepting a change, count what it actually cost — production code apart from tests and docs, and comments apart from logic, because a diff dominated by explanation is not the same as one dominated by machinery:
    git diff --numstat <base> -- ':!specs' ':!*.test.*' ':!**/test-harness'
    Then read the added lines, not just the total. A small behavioural change that costs a large number of lines is a red flag — it is the most reliable signal that the fix is fighting the existing shape rather than fitting it: a special case layered where the general case belongs, a second owner introduced beside the real one, or a wrapper bridging two things that should have been merged. The correct response is to rework it from the shape the code would want, not to commit it with a paragraph explaining why it had to be big; that explanation is the smell, not the mitigation.
    Two honesty rules, or the number means nothing: formatter churn in files the change did not otherwise touch is not part of the change and must stay out of the commit; and a net count near zero can still hide real weight — a new cron, table column, index, endpoint, dependency, or config flag is a surface someone now owns and maintains, so name those separately from the count.
  • Do not over-weigh the sunk cost of the existing architecture. "It already exists and works" is not an argument for keeping a shape — coding agents make large architecture switches cheap, so size a refactor by the quality of the end state, not by the volume of code it replaces. When behavior must survive, pin it with tests at the consumer surface and swap the architecture underneath — though most of the time even the old seams shouldn't survive verbatim: a big refactor is the chance to redraw them into the shape the codebase would want today, not to faithfully rebuild the old interfaces on a new foundation.
  • Prefer one shared primitive over N adapters. An adapter is acceptable only at an external boundary or as a short-lived migration seam.
  • Split a module that owns too many concepts — decomposition is refactoring too. The dual of merging duplicates: when one file or function has accreted unrelated responsibilities, divide it along ownership seams so each piece owns a coherent slice a reader can name and the original thins to composition. Past ~1000 lines a file is a smell, not a verdict — some modules earn their size (a cohesive state machine, a generated table, one algorithm with tight internal coupling), but a god-file that registers dozens of routes or handles many domains inline is accreted responsibility, not cohesion. The split is right only when each new module owns a nameable responsibility and import pressure drops because dependencies moved with it; it's wrong when it's line-count relief that scatters one concept across files a reader must reassemble.
  • Know what already exists before you build something new. Before writing a new mechanism — a shape, computation, asset, state machine, data contract — search the codebase for one that already does this, or something close enough to share. Only once you know what's there can you make the real decision: reuse it, consolidate two near-duplicates, or extract the shared core into an independent module both call — and that decision belongs before you start, not bolted on after. Reuse is not automatically the answer; the existing thing may be wrong, or genuinely different, and then you build new deliberately. The failure this prevents is building in ignorance of what's already there: two implementations that must agree then silently drift, each re-deriving details the other already settled (orientation, units, edge cases, ordering).
  • Do not preserve dev-only compatibility by default. Unshipped scaffolding should move to the clean contract immediately.
  • Zero lineage signaling: name things for what they are, never for where they came from. A name that encodes history — a slice file prefixed with the mega-file it was split from,
    foo-v2
    /
    foo-new
    /
    foo-legacy
    , a module named after the experiment that produced it, a wrapper named after the API it replaced — carries no information to a reader who wasn't there, and actively misleads the one who was once the old thing is gone. The test: would someone who joined today, knowing nothing of the history, choose this name? If the name only makes sense with the backstory, rename it; history lives in git, not in identifiers. The same rule kills lineage comments ("previously this was...", "moved from X") — they describe the diff, not the code, and rot the moment the referent disappears.
  • Prefer the idempotent contract over the refusal. When an operation can be asked for twice — a retry after a lost response, a user clicking the same button again, a replayed webhook — reaching the requested end state should succeed, not error. "Install X" where X is already installed at the place it belongs is a success: return the thing. Refusal is correct only when the second request means something genuinely different from the first — a DIFFERENT thing already occupies the name, so honoring the request would destroy or shadow it. The tell that you have it backwards: a caller has to special-case your error code to recover normal behavior, or a retry path needs a pre-flight "does it already exist?" read that races. Applies beyond writes: deletes of absent things, unsubscribes, and "mark complete" toggles are all idempotent by nature, and making them 404 or 409 pushes bookkeeping onto every caller.
  • Constrain the model to what production actually writes. A schema that permits more than any writer produces — a list where every flow stores one element, a state nothing reaches — taxes every consumer with the general case. The tell: a consumer asking "but which one?" when the data can only contain one. Enforce the constraint at the write path; widen when a real use arrives.
  • Make ownership visible in stats, tests, or debug output when divergence was the bug class.
  • A check that RE-DERIVES a value the code already computes will drift from it. A test or second consumer that recomputes geometry, state, or a derived quantity independently can disagree with the code over a difference invisible on paper — an operation-order or rounding subtlety — and fire false verdicts. Export the owner's computed value and have the check consume that, so it enforces exactly what the code produced: one owner for the computation, not two that happen to mostly agree.
  • A symmetric or featureless placeholder can hide an orientation or coordinate bug in the thing it stands in for. A symmetric stand-in renders the same whether or not the coordinate frame is flipped, so the defect stays invisible until a real asymmetric asset exposes it. When you swap a placeholder for the real asset, re-verify orientation and framing, not just that it renders.
  • Update the spec or handoff with the new invariant, not the mechanical file list.
  • If the refactor starts widening into unrelated behavior, slice it: land the shared contract first, then port consumers in reviewable passes.
  • 每一行代码都是债务——最好的机制是你根本不需要写的那种。 在构建任何防护、验证器、收敛协议或清理路径之前,先通过两个检查把关:
    1. 平台是否已经提供了这个功能? 在编写代码绕过你正在使用的组件/框架之前,先了解它本身的能力——它的定时任务、重试机制、默认配置和生命周期。在一个已经具备自清理能力的组件旁边手动构建一个清理程序,纯粹是债务,而且将来会由某个不知道该组件已经自带该功能的人来调试。
    2. 它所防护的故障会改变任何实际结果吗? 追踪被防护的条件到它的消费方。如果消费方并不关心——比如一个顺序检查的消费方根本不在乎顺序,或是对仅由我们自己的代码产生的数据做校验——那么这个防护什么都测试不到,绝对不能写。一种常见的形态:在读取时重新检查某个不变量,但唯一的写入路径已经保证了这个不变量,所以这个分支只有在人为篡改数据行时才会触发。问问自己哪个调用方会产生坏状态;如果诚实的答案是「没有,除非有人直接编辑数据库」,那就删除这个分支。「它可能会不一致」不是理由;「消费方会因此做出错误行为」才是。 衡量的标准不是「这行代码对不对」——防御性代码通常都是正确的。标准是「如果这行代码不存在,会出什么问题?影响谁?」 没有具体的答案 → 就不要写这行代码。
  • 衡量变更的规模,将代码规模与行为变更的不良比率视为一种形态缺陷。 在接受一个变更之前,计算它的实际成本——生产代码要排除测试和文档,逻辑要排除注释,因为一个以解释说明为主的diff和一个以功能逻辑为主的diff完全不是一回事:
    git diff --numstat <base> -- ':!specs' ':!*.test.*' ':!**/test-harness'
    然后要阅读新增的代码行,而不仅仅是看总数。少量的行为变更却需要大量代码,这是一个危险信号——这是最可靠的信号,说明修复方案是在对抗现有代码形态,而非适配它:在本该是通用逻辑的地方堆叠了特殊情况,在真正的所有者旁边又引入了第二个所有者,或者用一个包装器桥接两个本应合并的东西。正确的做法是按照代码本应有的形态重新实现,而不是提交代码时附上一大段解释为什么它必须这么大;这种解释本身就是坏味道,而非缓解措施。
    两个诚实原则,否则统计数字毫无意义:如果变更只是格式化了那些原本没有改动的文件,这些格式化改动不属于变更的一部分,必须从提交中剥离;另外,净增代码行数接近零也可能隐藏了实际的重量——一个新的定时任务、表字段、索引、接口端点、依赖项或者配置开关,都是现在需要有人负责和维护的对外表面,所以要把这些单独列出来,不计入行数统计。
  • 不要过度高估现有架构的沉没成本。 「它已经存在且能正常运行」并不是保留现有形态的理由——编码Agent让大规模架构切换的成本变得很低,所以重构的规模应该由最终状态的质量来决定,而不是由它替换的代码量来决定。当必须保留原有行为时,在消费方接口层面用测试锁定行为,然后替换底层的架构——不过大多数情况下,就连旧的接缝也不应该原封不动地保留:大规模重构是一个机会,可以把它们重新设计成代码库在当下本应有的形态,而不是在新的基础上忠实地重建旧接口。
  • 优先使用一个共享的原语,而非N个适配器。适配器只有在外部边界或者作为短期迁移接缝时才是可接受的。
  • 拆分承担了过多概念的模块——分解也是重构。 这是合并重复项的另一面:当一个文件或函数积累了太多不相关的职责时,沿着职责归属的接缝进行拆分,让每个部分都承担一个读者能明确命名的内聚职责,而原来的模块则变薄为组合层。文件超过约1000行是一种坏味道,但不是定论——有些模块的规模是合理的(比如一个内聚的状态机、一个生成的表格、一个内部耦合紧密的算法),但一个注册了几十条路由或者内联处理多个领域的上帝文件,是职责堆积的结果,而非内聚。只有当每个新模块都承担一个可命名的职责,并且因为依赖随模块一起迁移,导入压力降低时,拆分才是正确的;如果拆分只是为了减少行数,把一个概念分散到多个文件里,读者必须拼凑起来才能理解,那就是错误的。
  • 在构建新东西之前,先了解已有的内容。 在编写新的机制——无论是一种形态、计算逻辑、资源、状态机还是数据契约——之前,先在代码库中搜索是否已经有实现了相同功能的东西,或者足够接近可以共享的东西。只有当你了解了已有的内容之后,才能做出真正的决策:复用它、合并两个近乎重复的实现,或者把共享的核心提取到一个独立的模块中供双方调用——而且这个决策应该在你开始编码之前做出,而不是之后再补上去。复用并不总是正确答案;现有的东西可能是错的,或者确实有本质不同,这时候你可以刻意地构建新的。这一原则能避免的问题是:在不了解已有内容的情况下盲目构建——两个本应保持一致的实现会悄悄产生偏差,各自重新推导对方已经确定的细节(比如方向、单位、边界情况、顺序)。
  • 默认情况下不要保留仅用于开发的兼容性。未上线的脚手架代码应该立即迁移到干净的契约上。
  • 零血统标识:按事物的本质命名,永远不要按它们的来源命名。 编码了历史信息的名称——比如以拆分前的巨型文件为前缀的切片文件、
    foo-v2
    /
    foo-new
    /
    foo-legacy
    、以产生它的实验命名的模块、以它所替换的API命名的包装器——对不了解这段历史的读者来说毫无意义,而且一旦旧的东西被移除,还会对了解历史的人产生误导。检验标准是:一个今天刚入职、完全不了解历史的人,会选择这个名字吗?如果这个名字只有知道背景故事才有意义,就重命名它;历史存在于git中,而不是标识符里。同样的规则也适用于血统注释(「之前这是……」、「从X迁移而来」)——它们描述的是diff,而不是代码本身,而且一旦被引用的东西消失,这些注释就立刻失效了。
  • 优先使用幂等契约,而非拒绝式契约。 当一个操作可能被请求两次时——比如响应丢失后的重试、用户再次点击同一个按钮、重放的webhook——达到请求的最终状态应该是成功的,而不是报错。如果「安装X」时X已经安装在正确的位置,那这就是成功:直接返回该对象即可。只有当第二次请求与第一次请求的含义确实不同时,拒绝才是正确的——比如已经有一个完全不同的东西占用了这个名称,满足请求会破坏或遮蔽它。判断你搞反了的迹象:调用方必须对你的错误码做特殊处理才能恢复正常行为,或者重试路径需要先做一次「它是否已经存在?」的读取,而这会产生竞态条件。这一原则不仅适用于写操作:删除不存在的东西、取消订阅、「标记完成」的切换操作,本质上都是幂等的,让它们返回404或409会把记账工作推给每一个调用方。
  • 将模型约束为生产环境实际写入的内容。 如果一个schema允许的范围超过了所有写入方实际产生的内容——比如每个流程都只存一个元素的列表,一个永远不会到达的状态——就会让所有消费方都要处理通用情况,增加负担。判断迹象:当数据只能包含一个元素时,消费方却在问「但到底是哪一个?」。在写入路径上强制执行约束;当有真实的使用场景时再放宽。
  • 当分歧是一类bug时,让职责归属在统计数据、测试或调试输出中可见。
  • 重新推导代码已经计算过的值的检查,会与原代码产生偏差。 如果一个测试或者第二个消费方独立地重新计算几何形状、状态或某个派生量,它们可能会因为一些纸面上看不见的差异——比如运算顺序或者舍入的细微差别——与原代码产生不一致,从而给出错误的判定。应该导出所有者计算出的值,让检查直接使用这个值,这样检查就能准确验证代码产生的结果:计算只有一个所有者,而不是两个碰巧大部分时候一致的所有者。
  • 对称的或无特征的占位符,可能会隐藏它所替代的东西的方向或坐标bug。 对称的替代物无论坐标系是否翻转,渲染出来的效果都是一样的,所以缺陷会一直隐藏,直到一个真实的非对称资源暴露出来。当你把占位符换成真实资源时,要重新验证方向和帧对齐,而不仅仅是验证它能渲染出来。
  • 用新的不变量更新规格说明或交接文档,而不是机械地列出文件清单。
  • 如果重构开始扩大到不相关的行为,就把它拆分:先落地共享契约,然后以可评审的批次逐步迁移消费方。