需求变更导致的代码腐化

代码腐化的原因

Code is read far more times than it's written 软件开发的成本也大都发生在第一次交付之后。分析曾经重构过的几个项目发现,在项目的最初,代码也都还算是眉清目秀,但随着时间的推移,由于需求的不断变更,代码逐渐演化成了一个逻辑迷宫,一个维护的焦油坑。

需求变更

杀死一个程序员不用枪,改三次需求就可以了。每一次需求的变更都使代码更复杂一点,假以时日,为了应对不断变化的需求,代码变得越来越复杂。下文通过一个咖啡制作的例子来演示代码腐化的过程。

冲泡速溶咖啡

原始需求

为客户泡制速溶咖啡,制作过程分为三步:

  • 倒入咖啡粉
  • 加入沸水
  • 搅拌
void makeCoffee() {
    pourCoffeePowder();
    pourBoilingWater();
    stir();
}
新需求,制作奶咖
void makeCoffee(bool isMilkCoffee) {
    pourCoffeePowder();
    pourBoilingWater();
    if(isMilkCoffee) {
        pourMilk();
    }
    stir();
}
新需求,加糖
void makeCoffee(bool isMilkCoffee, bool isSweetTooth,CoffeeType type) {
    pourCoffeePowder();
    pourBoilingWater(); 
    if(isMilkCoffee) {
        pourMilk();
    }   
    if(isSweetTooth) {
        addSugar();
    }
    stir();
}
新需求,可以选择咖啡口味
const int MAX_WATER_MACHINE_COUNT = 10;
void makeCoffee(bool isMilkCoffee, bool isSweetTooth, CoffeeType type) {
    if (type == CAPPUCCINO) {
        pourCappuccinoPowder();
    }
    else if (type == BLACK) {
        pourBlackPowder();
    }
    else if (type == MOCHA) {
        pourMochaPowder();
    }
    else if (type == LATTE) {
        pourLattePowder();
    }
    else if (type == ESPRESSO) {
        pourEspressoPowder();
    }
    pourBoilingWater();

    if (isMilkCoffee) {
        pourMilk();
    }
    if (isSweetTooth) {
        addSugar();
    }
    stir();
}

应对没有开水的异常
const int MAX_WATER_MACHINE_COUNT = 10;
void makeCoffee(bool isMilkCoffee, bool isSweetTooth, CoffeeType type) {
    if (type == CAPPUCCINO) {
        pourCappuccinoPowder();
    }
    else if (type == BLACK) {
        pourBlackPowder();
    }
    else if (type == MOCHA) {
        pourMochaPowder();
    }
    else if (type == LATTE) {
        pourLattePowder();
    }
    else if (type == ESPRESSO) {
        pourEspressoPowder();
    }
    bool hasBoilingWater = false;
    while (!hasBoilingWater) {
        for (int i = 0; i < MAX_WATER_MACHINE_COUNT; i++) {
            if (isBoiling(i) {
                hasBoilingWater = true;
                break;
            }
        }
        dawdling();
    }
    pourBoilingWater();

    if (isMilkCoffee) {
        pourMilk();
    }
    if (isSweetTooth) {
        addSugar();
    }
    stir();
}

随着时间的推移,会有更多的需求增加进来,譬如:

  • 需要支持售卖现磨咖啡
  • 需要提供更多的咖啡类型
  • 需要提供给用户更多的口味选择
  • 需要制作冰咖啡
  • 需要拉花

另外,在现实的工程中我们不得不不考虑更多的异常

  • 各种材料都存在售空的可能,每一个函数都有失败的可能,都需要返回状态码来返回执行结果,而函数的调用方则需要检查函数的返回值,根据返回值的不同决定具体的逻辑分支。
  • 更合理的机制是,我们在需要在用户准备下单的那一刻起就判断出制作咖啡所需要的各种材料是不是都有现货,而不是在冲泡过程中发现原材料缺失。
  • 如果有多个收银员,还要考虑多线程资源共享的问题。

可以预见,随着需求的变更,我们的函数会越来越长,越来越复杂,它最终将会从三段式的优雅表达,演化成一个if else交织的逻辑谜团。

问题分析

分析前面的代码,我们发现,比较最初的版本,这些代码中加入了更多的细节已满足不断增加的需求。但是从整体上看,它只完成了四件事:

  • 倒咖啡粉,会有不同的选择
  • 倒开水,要应对没有开水的异常
  • 调味,根据需求加糖或者加奶
  • 搅拌

重构

代码是程序员用来沟通的工具,代码应该反应程序员的意图。既然我们想要通过四个步骤完成咖啡的冲泡,代码就应该清晰地体现这种逻辑。按照这个思路,我们把这四件事分别封装到四个子函数里,得到代码如下:

void makeCoffee(bool isMilkCoffee, bool isSweetTooth, CoffeeType type) {
    pourCoffeePowder(type);
    pourWater();
    flavor(isMilkCoffee, isSweetTooth);
    stir();
}

void pourCoffeePowder(CoffeeType type) {
    if (type == CAPPUCCINO) {
        pourCappuccinoPowder();
    }
    else if (type == BLACK) {
        pourBlackPowder();
    }
    else if (type == MOCHA) {
        pourMochaPowder();
    }
    else if (type == LATTE) {
        pourLattePowder();
    }
    else if (type == ESPRESSO) {
        pourEspressoPowder();
    }
}

const int MAX_WATER_MACHINE_COUNT = 10;
void pourWater() {
    bool hasBoilingWater = false;
    while (!hasBoilingWater) {
        for (int i = 0; i < MAX_WATER_MACHINE_COUNT; i++) {
            if (isBoiling(i) {
                hasBoilingWater = true;
                break;
            }
        }
        dawdling();
    }
    pourBoilingWater();
}

void flavor(bool isMilkCoffee, bool isSweetTooth) {
    if (isMilkCoffee) {
        pourMilk();
    }
    if (isSweetTooth) {
        addSugar();
    }
}

按这个思路重构完成后,我们的代码又变得干净简洁了。

拆分带来的好处

更清晰的表达

拆分后的版本像文章给出章节目录一样列出了程序的框架,每个子函数给出了具体的实现细节。这种分层次的表达方式,使阅读者可以从梗概到细节的了解函数的意图,为阅读者有选择地忽略某些具体的实现细节提供了可能。而面对拆分前的版本,阅读者只能在一堆逻辑细节中反向推导出程序的梗概。

更好的应对变化

衡量一个设计好坏的标准是,当变化来临的时候,设计被破坏的程度。假定有个新的需求,需要支持新的Coffee类型。
在拆分前的版本里,我们需要在makeCoffee这个大函数中找到根据类型选择咖啡粉的那几行代码,在此基础上加入新的逻辑。由于这个新需求的引入,makeCoffee发生了修改,我们需要对整个整个函数做回归测试,以保证我们的修改没有破坏原有的功能。
而拆分后的版本中,我们只需要修改pourCoffeePowder数,也只需要针对这个函数做回归测试。而其他的逻辑可以被完全重用。
通过函数拆分,缩小了变化发生时软件修改的范围,使尽量多的软件得到重用,降低了开发的成本。

更好的复用

越是独立的功能越是容易被复用。譬如如果我们有一个makeTea的需求,pourWater的逻辑就可以被复用。
除非要提取的函数本身是一个独立的概念,能封装实现细节,更清晰的表达意图,否则不要仅仅为了可能的复用而提取函数。因为我们永远无法预测下一个需求,因而无法预测哪一段逻辑有可能被复用。

背后的原则

单一职责(Single Responsibility Principle)

所谓单一职责,是指一个实体应该有且仅有一个职责。
首先请大家思考一个问题,拆分前makeCoffee函数是不是单一职责的?答案看上去是肯定的,毕竟它只完成了一个功能:制作咖啡。
在回答这个问题之前,我们需要先明确一个概念,什么是职责。在这里,援引Uncle BobPPP里的定义:所谓职责,就是变化的原因。
软件设计的学问就是对复杂度管理的学问,管理问题域的本质复杂度,尽量降低因为设计与实现而带来的偶发复杂度。而衡量设计优劣的标准就是应对变化的能力。当变化来临时,我们希望能尽可能地重用原来的代码。换句话说,是希望尽量少的代码受到变化的影响。我们通过类、函数等方式来封装变化,努力让一个变化的影响尽量局部。如果当变化发生时,所有相关的改动都发生在一处,那么这个职责就是高内聚的。譬如,用户要求提供更多的咖啡类型,只需要扩充pourCoffeePowder即可。pourCoffeePowder封装了咖啡类型这一变化方向,因为咖啡类型的增删而导致的修改全部会发生在这个函数中。

读者可能会有疑问,如果我们把所有的实现都放在一个函数里,需求变化引起的所有改动也都在这一个函数内,这样的函数是不是也是高内聚的?必须指出高内聚有两层含义:

  • 关联紧密的事物应该被放在一起
  • 只有关联紧密的事物才应该被放在一起

从需求的角度分析,用户要求支持更多的咖啡粉的选择,必须要做的修改应该仅仅是与咖啡粉选择相关的逻辑。除此之外其他的更改,都是在应对因设计、实现而带来的偶发复杂度。也正是因为一个简单的需求的变化,需要在庞大的code base中到处做修改才导致了系统的僵化性(Rigidity),导致系统的维护及扩充变得困难。

拆分前的makeCoffee函数里所包含的职责:
  • 根据类型选咖啡粉。变化方向:咖啡类型的增删
  • 取水。变化方向:面临缺水,需找可用的热水。这里边有可以延伸出其他的变化,譬如水桶空了,需要换水,甚至需要打电话订水;可能需要冰水
  • 根据用户选择,加糖或牛奶。变化方向,其他口味需求,可能需要加肉桂粉、拉花、花形等选择
  • 搅拌。变化方向:搅拌的力度
  • 制作咖啡的四步流程。变化方向:流程上的变化,可能需要先搅拌,后加糖

因此,拆分前的makeCoffee是单一功能的,却不是单一职责的。它涵盖了整个咖啡制作过程的所有细节,它的稳定性依赖于制作咖啡的所有细节。任何一个细节的变化都会引起这个函数的变化。

SLAP(Single Layer of Abstraction Principle)

函数应该拆的多小,哪些逻辑应该平铺,哪些逻辑应该用子函数封装?答案就在于SLAP,即单一抽象层次原则。这个原则要求在一个函数里平铺的语句应该在同一个抽象层次、同一个概念层级。
仍然是makeCoffee的例子,倒热水和搅拌处于同一个概念层级。但拿起杯子,小心地按下饮水机出水按钮,等待热水慢慢盛满四分之三水杯就处于更低的概念层级,它是怎样倒热水的具体描述,与搅拌不在同一个逻辑层次。
代码要表达的是程序员的意图,函数拆分的有效方法是,用自然语言来描述函数的功能。要实现这个功能需要哪些步骤,或者分为哪些情况。函数中的每一行代码都是直接表达这些步骤或者分类吗?如果不是,它们是不是某个步骤或情况的具体实现?能否作为单独的问题提取成函数?这个简单的技巧可以让我们理顺函数的逻辑,最终写出符合SLAP原则的代码。

可读性是一种主观的评判

在实际的工作中,经常会被问到提取函数到底是提高了还是降低了代码的可读性。可读性的衡量标准是阅读、理解代码时所花费的时间。提取函数,并用一个合适的名字来命名,为阅读者忽略实现细节,站在更高的抽象层次上理解问题提供了可能。
但如果代码中充斥着大量的词不达意的命名、甚至误导性的命名,譬如:

void checkForContinue(bool shouldContinue) {
  if (shouldContinue) {
    abort();
  }
}

在这种情况下,阅读者就不敢忽略任何实现细节,不得不去查看任何一个函数的具体实现。拆分成小函数,尤其是函数长度仅为几行的函数反而降低了可读性。

编程是一种匠艺

编程是一门匠艺,只有不断的打磨,不断地把事情做完美,才有可能获得技艺上的提升。在实际工作中,由于种种约束,我们不得不采用不那么优雅的方法来快速交付,以更低的成本交付。(这是不得不为之的妥协,很多时候也是非常正确的选择,因为软件的开发的最终目标是在软件的整个生命周期内,以最低的成本及时交付最大的价值,技术卓越是达到这一目标的手段而不是目标本身。当然,这个期限很重要,考量的是软件的整个生命周期)
一个优秀的程序员,必须有能力给出合理的优雅的实现,想清楚两中方案之间的优劣,审时度势的做出合理的选择。让重要的技术决策是深思熟虑之后的理性选择,而不是因技能缺失而不得不为的拙劣拼凑。

代码腐化的应对策略

要达到高内聚低耦合的目标,就需要分离那些变的与不变的部分,把受某个变化影响的相关数据与逻辑封装起来,让变化对现有的设计影响最小。
随着需求的增加,新的变化方向不断地被引入,原先高内聚的模块的逻辑有一部分受这个变化的影响,有一部分不受这个变化的影响,变得不再高内聚,这时候需要把原有的模块做相应的拆分。
另外一种情况是,随着需求的增加,发现原来分布在不同模块的部分逻辑都受相同的变化的影响,它们之间有更强的内在关联。这时候也需要把模型及逻辑做相应地调整。而这一过程,也就是领域模型发现与完善的过程。
总之,要想防止代码的腐化,需要根据新的需求,不断地调整我们的架构与实现,使他始终保持在一个合理的状态,而不是迁就原有的架构与实现,为了实现新的功能不断地做艰难地适配,让架构和实现变得越来越晦涩难懂。

推荐阅读更多精彩内容