重构 聊聊代码的复杂性

qiumaoyuan · 2025年01月06日 · 最后由 bobby_chen 回复于 2025年01月13日 · 898 次阅读
本帖已被管理员设置为精华贴

回想起来,我的职业生涯有大半的时间都在有意无意的死磕这个问题。2016 年左右,开始觉得有必要写下来,因为总觉得不写下来的话,到该说的时候脑子里有许许多多的想法,临时却又说不出来什么。本文写于 2016-08-06,写得挺早,一直没有发在社区,放着好多年。现在看起来,来来回回也没改动多少,发出来大家随缘看看。

复杂从哪来

代码当中包含的复杂性,我分为两部分来看:业务逻辑本身的复杂性,和代码结构的复杂性。

对有点规模的系统程序来说,程序员本应该做的工作除了完成功能之外,就是消除代码结构额外的复杂性,让系统代码整体的结构趋向于跟业务逻辑在各个抽象层面上一致——即人们常说的:你所写的代码就是你想要表达的业务。

业务逻辑变得复杂,很多时候是业务发展的需要,程序员只有接受,并为业务提供支持。当然如果你身兼数职,可以以产品经理的身份在产品的角度优化一下,但这不是本文讨论的范围。抛开业务本身的复杂度,剩下的就是代码结构的复杂度了。

那么代码结构为什么总是变得越来越复杂呢?主要是跟程序员控制代码结构复杂度的能力有关。能力不足的程序员很容易经常犯些小毛病——例如在一个方法内部出现了临时变量,或者代码中出现了一个带了 2 个参数的方法(注 [^1])——但他本身并没有意识到这是个问题。代码随着业务的变化和增加,这种小毛病就会越积累越多。

到了某一时刻,程序员会发现以前某些代码很难修改或者重用,多数情况下,能力不足的程序员这时候意识不到那些小毛病的存在,会很本能地选择对以前的代码做一种打补丁式的修改(比如多传个参数、多加个条件分支来处理一下特殊情况),或者干脆把代码大片复制出来做些小修改,而不是重新组织它——也就是我们常说的“重构”。

程序员们经常把“重构”这个词挂在嘴边,但遇到问题他们的选择要么是打补丁,要么干脆全部重写,真正做重构的真的很少。来复习一下“重构”一词的定义:在不改变代码外在行为的前提下,对代码进行修改,以改进程序的内部结构。这里的关键,是“改变内部结构”、“不改变外在行为”。理解了这一点之后,“重构把代码改出 bug”这样的认知错误自然就不存在了。

跑题了,言归正传。打个比方。想像一下,假设上面我所提到的程序员,在一开始经常犯的那些小错误,是在一根绳子上打了的一个小结。这种小结打多了之后,一整根绳子上就都是绳结。程序员对之前的问题代码打补丁的动作可以看成在之前的小结上打了一个更大的结,结上再打结……如此发展下去,最终系统必然是一团错综复杂的结。这时候,问题本身终于显而易见了——代码无比复杂,修改十分困难,但产生这问题的根本原因还是像迷一样难以被发现。

因为“解小结”的能力不足,所以才最终造成了这么一团大结。同样的因为“解小结”的能力一直不足,他们不知道应该从最外面的结开始解,解开一个结之后,才能再解这个结下面更多的小结(重构的过程真的是这样,你会发现首先要把代码用一种重构手法整理完之后,才能够再用另一种重构手法对解开的代码进行进一步的整理)。他们要么直接把这一团东西扔了重写一份——但是没有训练出“解小结”的能力的话,未来继续重新开始打结大概率是免不了的;要么继续痛苦地在这玩意上面继续打结。

代码之所以不容易写好,是因为许多错误的小决策往往要很久以后才能让人看到显而易见的问题。 没有即时的负面反馈,不够细心的人们就发现不了问题,“问题”对他们来说不存在,“改进”自然就无从谈起。 复杂难用的系统都是由小毛小病慢慢积累而来,最终才表现成为显而易见的大问题,但是每个小毛小病多数人看不见,临时用打补丁的方式不讲究地处理一下在他们看来似乎也没什么坏的影响。他们没有意识到 防微杜渐才是防止大病形成的唯一办法。 问题最终很明显的表现出来之后他们也往往不知道根源到底在哪里——其实根源都在之前做决定的每一步的细节里,没有一个单独存在的、明显的大问题(因为如果有,那早就被发现和解决了)——而正是因为这样,最终的问题往往都很难解决,你总是,总是会发现牵一发要动全身。

所以代码的版本越高越难以被读懂,除了业务变得复杂了以外,我觉得主要的原因在于此。

如何解决

提出问题,找到了原因,然后就得解决。

复杂是由简单组合而成的,巨复杂的代码结构问题本质上也都是些简单的问题组合成的。最终的那个复杂无比的问题不可能只用一种简单的手段一次性解决,只能针对组成整个大问题的那些小问题一个一个来解决。

那么解决这些不起眼的小问题的手段是什么?就是许多程序员不屑于练习的,被他们声称解决的问题过于简单,被他们认为无法用于解决实际项目当中超级复杂的系统问题的,同样不起眼的一个个重构手法。这些重构手法全在《重构》这本书后面的每一个示例里,我认为这本书的正确读法是把每个重构手法以及每一句示例代码都认真的阅读和理解一遍,搞清楚重构前的代码问题在哪,为什么那算得上是个问题,那个问题应该如何解决,解决这种问题本质上所运用的该编程语言的特性和原理是什么。

可能有人读了前五章之后,认为剩下的部分可以先放着,等遇到问题了再来读。但矛盾的地方在于,从第六章起之后的这部分容,很重要的一个作用就是训练程序员对各种细微 bad smell 的嗅觉,在大脑里对各种 bad smell 设置 trigger。未经训练的程序员在真遇到问题的时候,并不认为自己遇到了问题,因为那些问题就像这些重构手法一样,是如此的不起眼,最后自然也就不会再次打开这本《重构》。

当程序员控制代码结构复杂性的能力训练得足够好了,你会发现他写的代码模块职责清晰,大多数方法的代码行数真的不超过 10 行(这个数字用不同的 OOP 语言会有所不同)。系统里全是如此短小不互相纠缠的代码,还会有人觉得难读吗?

这里可能又可以引发出另一个讨论:到底什么样的代码才叫“直观”?什么是代码的可读性?似乎有不少程序员觉得层层调用的代码很不“直观”,把逻辑完全展开,平铺直叙的代码才叫“直观”。这背后隐含的问题是“到底什么是‘复杂’”,这又是另一个话题了。我知道其实很多人不习惯跟类似“什么是‘复杂’”这样看似简单、每个人好像都知道,但认真解释起来还是需要一些思考的概念死磕。

上面是我最近在思考的东西。

脚注

注 [^1]:并不是所有的临时变量都是不好的,也并不是所有参数多于 1 个的方法就是有问题的。这里是故意把“大量临时变量”中的“大量”给省去了,把“多个参数”写成了“2 个参数”。因为不在具体的上下文当中,“大量”和“多个”根本没个标准。至于如何判断,可以仔细阅读《重构》。

这里想要表达的是,所有的 bad smell 都是一种反模式,所谓“模式”就是很容易被人发现的一种有规律的定式,一旦遇到就要动脑子分析,和具体的规模(无论是“2 个”还是“很多个”,“5 行”还是“很多行”)无关。但很多程序员倾向于总结出非此即彼的固定标准,因为具体问题具体分析太费脑子。所以:临时变量既然不可能完全消除,就完全不需要消除;方法参数既然不可能永远不超过 1 个那就可以是 5, 6, 7, 8 甚至 10 多个;方法行数既然不能全部减少到 5 行那就说明 100 来行也是没问题的。最终失去了对 bad smell 的警觉。

而训练有素的程序员会把这些模式当作“触发器”,时时刻刻警觉着分析这些现象在当前的情况下到底算是个问题还是算正常情况。训练有素的程序员知道很多东西不是非此即彼,不可以一概而论,编程本身是一项脑力劳动很大程度上也是因此吧。

但是多数情况下我们似乎都倾向于追求和谐的统一,把编程变为体力劳动,只是因为那看起来更不需要思考。或者用有些人的话来说,“可以更容易招聘到不会思考的下等编码机器”,并称之为“软件工程”。只可惜采用这种“不需要思考”的方式写代码造成结果却往往是需要更多的思考,因为混乱的代码结构维护起来一定比条理清晰的代码费脑得多。

知易行难,大家都希望自己别遇到屎山代码,但真到赶需求的时候是否能遵循原则😂 。 推荐 B 站好视频

BiliBili

我覺得「複雜」在每個人看來確實是不同的,應該和思維習慣有關係,我覺得最簡單的代碼結構是不用參數,除了 initialize 的時候。

例如對一個有參數的方法,

class A
  def m b
  end
end

我會考慮寫成這樣:

class AxB
  def initialize a, b
    @a, @b = a, b
  end

  def m
  end
end

一般來說AxB是會有一個更適合的名字,可以反哺給產品用,這樣溝通起來比較方便。但總之是,給定一個 type 我們可以知道它一定有哪些 property,這些 property 的有效性是 unconditional 的。如果大部分代碼都這樣組織,那麼加東西的時候就找找要加的東西依賴哪些,加到合適的層級就好了,改動應該會比較小。

Rei 将本帖设为了精华贴。 01月07日 14:18
mizuhashi 回复

这个我会看情况,如果方法行数短,放在 A 类里也无所谓。

class MemberOrder
  def apply_festival_discount(discount)
    member_price = @amount * @base_discount

    if @amount >= discount.threshold
      member_price -= discount.bonus_amount
    end

    return member_price.round(2)
  end
end

如果行数长,我会考虑拆分。而拆分又分 3 种情况:在当前类中拆分,在参数所在的类中拆分,在新类中拆分。

需要新建类来拆分的,就是我上面这个例子:即有当前类属性和方法的深度参与,又有参数所在类的方法的深度参与。我换一个长一点的代码:

# 请忽略各种硬编码等等与讨论主题不相关的 bad smell,例子是我让 AI 按我的要求临时生成的
class MarketingCampaign
 def remaining_budget_ratio
   (@total_budget - @used_budget) / @total_budget.to_f
 end

 def calculate_success_rate
   case @campaign_type 
   when "seasonal" then 0.8
   when "flash" then 0.6
   when "normal" then 0.4
   end
 end

 def budget_adjustment_factor
   if remaining_budget_ratio > 0.7
     1.2 
   elsif remaining_budget_ratio > 0.3
     1.0  
   else
     0.8  
   end
 end

 def calculate_advertisement_cost(platform_info)
   # 基础广告费计算
   base_cost = platform_info.base_price

   # 根据平台受众规模调整费用
   audience_factor = case platform_info.daily_active_users
     when 0..10000 then 0.8
     when 10001..100000 then 1.0
     when 100001..1000000 then 1.2
     else 1.5
   end
   adjusted_cost = base_cost * audience_factor

   # 根据活动成功率调整费用
   success_factor = 1 + calculate_success_rate
   adjusted_cost *= success_factor

   # 根据预算情况调整费用
   adjusted_cost *= budget_adjustment_factor

   # 根据平台评级调整费用
   rating_factor = case platform_info.platform_rating
     when "S" then 1.3
     when "A" then 1.2
     when "B" then 1.1
     when "C" then 1.0
   end
   adjusted_cost *= rating_factor

   # 根据广告位置调整费用
   position_factor = case platform_info.ad_position
     when "homepage" then 2.0
     when "search_result" then 1.5
     when "recommendation" then 1.3
     when "category_page" then 1.2
   end
   adjusted_cost *= position_factor

   return adjusted_cost.round(2)
 end
end

代码没必要读完,主要看的是:calculate_advertisement_cost 中即有参数 platform_infobase_pricedaily_active_usersplatform_rating 等方法,也有本类的 calculate_success_rate, budget_adjustment_factor 等方法。这时候你说的 AxB 就很好用了:

MarketingCampaign 的实例和参数 platform_info 传入新类 CalculateAdvertisementCost 的构造方法,当作新类的成员变量 @marketing_campaign@platform_info。然后把 calculate_advertisement_cost 里所有的内容全移过去,再在新类里对其进行拆解。

class CalculateAdvertisementCost
  def initialize(marketing_campaign, platform_info)
    @marketing_campaign = marketing_campaign
    @platform_info = platform_info
  end

  def calculate
    # ...
  end
end

这样的话,@marketing_campaign@platform_info 作为新类的成员,作用域是整个类,在整个 CalculateAdvertisementCost 里都可见,拆分就容易得多了。我想你说的“這些 property 的有效性是 unconditional 的”也是这个意思?

但再回到前面短一点的那个例子,你说非要把它移到 AxB 里行不行呢?我觉得也是可以的,也很赞同。但同时我觉得不移也行。在这种比较边缘的情况下,我自己会有一种选择,要是同时别人用了另一种选择,我也没意见,因为我自己有时候也会选择另一种。我觉得写代码很多时候存在这种情况,你即可以进一步,也可以退一步。

不过如果你的意思是方法一个参数都不能有,不分情况,那在我看来又似乎有点过头了:

def apply_festival_discount(discount)
  member_price = @amount * discount
  if @amount >= 1000
    member_price -= 50
  end
  return member_price.round(2)
end

像这种只有 1 个简单数值(非对象,和前面的例子不一样)参数的方法,代码还是挺正常的,我不会去重构它。

但如果有 2 个参数或以上,我会开始注意它:如果这个方法的计算步骤多,那这里的参数带来的坏处跟临时变量差不多——会把方法越拉越长,长方法总是会让人觉得复杂,所以在这个例子里,需要考虑把它变成另一个类的成员变量,然后再进行拆分。

这里的关键,是“改变内部结构”、“不改变外在行为”。理解了这一点之后,“重构把代码改出 bug”这样的认知错误自然就不存在了。

有测试才行

w7938940 回复

那要是没有测试怎么办呢?

可以以产品经理的身份在产品的角度优化一下

这个国内有叫需求让步的,英文里是 trade-off,软件/系统设计常用手段。

设计的话,个人喜欢这本 Principles of Computer System Design: An Introduction

前司更喜欢聊架构,代码方面的没人花心思说。技术负责人,有不少开源项目,代码质量没啥问题。不过他不会强调这些,也没人聊这个。

qiumaoyuan 回复

先補測試再重構

qiumaoyuan 回复

我說的 unconditional 就是,例如在數學證明裏我們會用到各種數學對象,在使用他們的時候我們會遵循 1.先構造對象 2.再使用對象的屬性。例如給定一個群,我們知道它肯定有 幺元,逆元,結合性三個屬性,這是由「這個對象是一個群」決定的。寫成代碼的話可以是:

class Group
  def identity
  end
  def inverse
  end
  def associativity
  end
end

這三個屬性是無條件成立的,但如果我們考慮一種特化的群,AbelianGroup,也就是滿足交換律的群,

class Group
  def commutativity proof_of_commutativity
    if proof_of_commutativity
      # 代表交換律的對象
    else
      nil
    end
  end
end

我們可以理解為,這在表達,這個群的交換律依賴於一個證明存在,我們只有傳進一個證明才可以用這個屬性,如果沒有證明,就不能用。這時候這個屬性就變成有條件了(conditional)。

但這樣對大腦的負擔比較大,每次用屬性我都要考慮兩種情況,我們更傾向於想「只要我知道這個對象是 AbelianGroup,我就知道它一定有交換律」。這時候我們就會想,把考慮的負擔提前到構造階段:

class AbelianGroup
  def initialize group:, proof_of_commutativity:
  end
  def commutativity
  end
  #... AbelianGroup的其他各種定理,定理也可以考慮成屬性,也是unconditional的
end

這樣只要我們能成功 new 出一個 AbelianGroup,它就必定擁有我們想要的屬性。我們需要關心的點只剩「x 是不是 AbelianGroup」這個問題。

上面的構造過程可以表示成 tuple,AbelianGroup = (Group, commutativity) 這樣我們一眼就能看到構造 AbelianGroup 需要的兩個成分。如果我要用 AbelianGroup 的定理,我要考慮的就只剩下如何得到這兩個部分,之後的都是確定的了。

我們可以隨便寫一個現實裏的 tuple,如 Purchase = (Customer, Product),我們也一眼就能看出,一個 Purchase 就是一個顧客 + 一個產品的組合,然後我們可以想象 Purchase 會有 quantity 之類的屬性,只要我們能構造出一個 Purchase 對象,這些屬性是 unconditional 的。

回到軟件,如果我們的類只有 initialize 有參數,那我們只需要看 initialize 就能知道它的功能的全部依賴,考慮某個東西能不能用,現在只需要考慮一個對象的類型是什麼,我們是不是成功構造出了這麼一個對象,如果是後面就順理成章了。

以上模式在前端 vue 等響應式 UI 裏用得很多,因為它們需要的主要是從一些基礎數據衍生出複雜的 UI 數據。後端要考慮的除了衍生,還有修改狀態,也就是保存 records,操作數據庫之類。不過我這絕大部分的操作也都是通過 service class 實現的,order.save_discount(discount) 會寫成 SaveDiscount.new(order:, discount:).run這樣。這麼幹有一個重要的好處是,搜索代碼很容易,搜類名就行,而在 model 上用方法名搜索,遇到重名就很麻煩。

mizuhashi 回复

哦,你说的是在调用方法的时候有记忆负担?如果是指这种负担,我的第一个反应是靠给这个方法和参数重新起个更合适的名字来解决。

我在前面的回复中更关注的是进入这个方法之后,对方法本身实现的理解,这个过程中上下文的记忆负担。这可以靠缩短代码行数,把代码分散到不同的抽象概念中解决(比如把其中的五六行代码单独提取到另一个方法,这可能是最常见的抽象)。而参数的数量对方法带来的复杂性,在我看来主要也是在这个方面。

但是我通常不会把方法调用时的记忆负担,用重新组织类的结构这样的方式来解决。

franklinyu 回复

我比较想知道有没有人真的这么干过。

重构可能分为两种情况,一种是边写边重构(随时保持代码整洁),这样自然积累不起来多少腐烂的代码。

另一种是写的时候不管不顾,后期实在受不了了再重构,这属于重构遗留代码。

如果是后一种,这时候代码似乎往往比较难测试。写的时候如果都不花时间重构,并对更干净的代码编写更容易写的测试,我比较怀疑在更难测试的时候,这个人会愿意花更多精力和时间来先补测试。

但实际上后一种情况还是有救的。比如 3 楼的这段代码:

 def calculate_advertisement_cost(platform_info)

  # ... 前面省略

   audience_factor = case platform_info.daily_active_users
     when 0..10000 then 0.8
     when 10001..100000 then 1.0
     when 100001..1000000 then 1.2
     else 1.5
   end
  adjusted_cost = base_cost * audience_factor
  # ... 后面省略
end

我把它放入 PlatformInfo


def calculate_advertisement_cost(platform_info)
  # ... 前面省略

  adjusted_cost = base_cost * platform_info.audience_factor

  # ... 后面省略
end

class PlatformInfo

  def audience_factor
    case daily_active_users
     when 0..10000 then 0.8
     when 10001..100000 then 1.0
     when 100001..1000000 then 1.2
     else 1.5
   end
  end

end

这就是一次重构了,这样的改动,你觉得需要测试吗?这就是“只改变了 calculate_advertisement_cost 结构,而不改变它的外在行为(逻辑)”。这次重构的效果是缩短了一部分 calculate_advertisement_cost 方法,这整个方法我可以继续这样一小步一小步改下去。如主帖所述,所有的重构都是这么不起眼的小步骤。重命名一个方法或属性,也都算一次重构。把三五行代码提取到一个方法里,也是一次重构。这就是所谓的“针对组成整个大问题的那些小问题一个一个来解决”,最终的效果都是积少成多而来的。所以主帖中一直在提“不起眼”,“小毛病”,“解小结”,“小决策”,“防微杜渐”。

不过在我重构遗留代码的经历中,确实有过把代码改出 bug 的时候,但那全都是因为我不小心改变了代码逻辑。比如我曾经很自信的在重构代码时顺手把 a.nil? 改成了 a.blank?,多数情况下这是没问题的。但在个别时候,用 blank? 会排除掉一些本该考虑到的情况,比如空字符串,这里用 nil? 是最合适的,因此这个改动就导致运行时出 bug 了。

但这已经不是重构了,这是改逻辑了。翻遍重构手法你会发现,重构是用各种方法把代码结构整理清晰,完全不涉及修改逻辑。只要你在重构代码的过程中不像我一样手贱去修改逻辑,是改不出 bug 的。

出 bug 还有一种情况,就是代码没有改全。比如对某个 public 方法重命名,调用它的地方有好几处,不小心漏改了一处。这属于重构步骤本身没执行完整。这种情况我一般是靠全局搜索代码来一个一个修改,多搜几次,多确认几遍。这一点,像 Java 这样的语言有 IDE 辅助会方便得多。

qiumaoyuan 回复

是的,其實每個人感覺的複雜不太一樣,我現在比以前懶了不少,不會記和推過於細緻的細節,腦裏只會有一些大致的模塊,誰依賴誰能推出什麼信息。然後前端就靠 typescript,只要思路正確能過類型檢查就很難錯,ruby 就靠 rspec,手擼無測試的代碼基本已經超出大腦的處理能力。。

有的需求就是混乱的。导致用代码描述的时候,会自相矛盾。 PM 的角色,本质上是一个前期逻辑过滤器。

代码的复杂性,来自于 需求衍生出来的 状态,以及状态转换。还有就是信息熵。 重构就是在不断地 逆熵的过程。

彻底失控的熵增,处理熵增的成本 > 重写 的时候,就可以认为 这项目 死了。

Mark24 回复

非常赞同,在需求逻辑不成型的时候,重构弊大于利。

oatw 回复

重构不是在某个节点才进行的,理论上是随时随地都需要做。 举个小例子就是,当你加功能的时候,你是选择在原来的上面堆,还是选择对原来的代码进行重新整理再加功能。 当然,还是看整个公司文化,对重构是尊重还是不尊重。

bobby_chen 回复

是的,好像蛮多人对这词存在误解,可能多数情况下,大家都觉得重构只是针对大量遗留代码的。

还有“非得有测试才能重构”的认识似乎也在不小的程度上影响着重构的实施,给重构加了不小门槛。我是觉得有测试肯定更好,但没测试也不是不行。

bobby_chen 回复

所谓“持续重构”是吧,能有这样的环境自然是好的,能沉淀下去东西。

可能是我的就业环境太低端了,主要做些边缘化的 gov 项目,不是没等上线就黄摊子了,就是上线了就晾在那没人在意了,说需求可能有点埋汰需求这个词了,全凭领导一句话,问题是 gov 内部的人也不知道什么需求不需求的,反正就是“我有个想法”。也不知道这算不算一种幸运,没有维护烦恼,因为领导一换,想法也换了,新摊子就要支起来,还真没有重构这一说……

自己的项目当然是另说了。

qiumaoyuan 回复

重构的原则是保持原来的代码外部行为不变,调整优化内部代码。如果没有测试很多人不敢改~测试是为了一方面是检测外部行为有没有改变,另一方面是为了让重构的人员更有信心。如果你有足够的自信,你可以不用测试用例。就像站在火车头,给开着的火车清除障碍,god bless you.

需要 登录 后方可回复, 如果你还没有账号请 注册新账号