重构 聊聊代码的复杂性

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

回想起来,我的职业生涯有大半的时间都在有意无意的死磕这个问题。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

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

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