小酌重构系列[15]——策略模式代替分支

前言

在一些较为复杂的业务中,客户端需要依据条件,执行相应的行为或算法。在实现这些业务时,我们可能会使用较多的分支语句(switch case或if else语句)。使用分支语句,意味着“变化”和“重复”,每个分支条件都代表一个变化,每个分支逻辑都是相似行为或算法的重复。
当追加新的条件时,我们需要追加分支语句,并追加相应的行为或算法。

上一篇文章“使用多态代替条件判断”中,我们讲到它可以处理这些“变化”和“重复”,今天我将介绍一种新的方式——使用策略模式代替分支,它也能处理这些“变化”和“重复”。在讲这个策略之前,我们先来看一则小故事。

小商城的运营

某小型在线商城,有3位核心成员,他们分别是CTO、COO和CEO。
CTO:小A,负责撸代码,以及维护商城系统。
COO:小B,负责吹牛忽悠,以及市场推广和运营。
CEO:小C,负责拉皮条,以及看着你俩干活。

在这个故事中,假定你就是小A,头衔CTO(谁让你既不会拉皮条,也不会吹牛忽悠呢)。

第一幕

某一天,小B策划了一个促销活动,免费给用户发放一些优惠券,用户在消费满一定金额后,可以使用这些优惠券抵扣。
假定现在有两个优惠活动——“满99减20,满199减50”。

每个用户要买的东西和花费的金额是不同的,根据不同的消费金额,系统需要判定使用什么优惠券。
面对这样一个场景,你说这不是忒简单了嘛,然后唰唰唰2分钟就撸完了这串代码。

public decimal CalculateAmount(decimal amount)
{
    if (amount < 99)
        return amount;
    else if (amount < 200)
        return amount - 20;
    else
        return amount - 50;
}

小B看了后,说道:“哇,这么快就弄完了,不愧是咱们公司的CTO,赶紧上线吧!”。

第二幕

第一天,小B根据交易数据分析得知,自从上了优惠券后(我是优惠券,谁要上我?),商城的交易额增长了很多,而且有较多用户的订单金额竟然超过了200。
为了回馈这部分“高端”用户的热情和贡献,商城决定加大优惠力度,于是小B追加了两项优惠活动:满299减80,满399减120。(好吧,这和街边卖场的大叔吆喝是一样样的,原价500多的真皮皮鞋、钱包,现在只要50元,全场50元,通通50元…!)

看到这新出现的场景,你想这不是分分钟搞定的事儿?于是你修改了CalculateAmount()方法。

public decimal CalculateAmount(decimal amount)
{
    if (amount < 99)
        return amount;
    else if (amount < 200)
        return amount - 20;
    else if(amount < 300)
        return amount - 50;
    else if (amount < 400)
        return amount - 80;
    else
        return amount - 120;
}

第三幕

第二天,小B又提了一个要求:“有些用户的会员等级比较高,为了给用户一种“老子是上帝”的感觉,可以为这些高级会员打一些折扣。”

铜牌会员无折扣,银牌会员打98折,金牌会员打95折,砖石会员打9折。

这时,你心里嘀咕了一声,干嘛不早说? 改吧,反正也不是啥难事儿。

public decimal CalculateAmount(Customer customer, decimal amount)
{
    // 优惠券减免
    if (amount < 99)
    {
    }
    else if (amount <200)
        amount -= 20;
    else if(amount <300)
        amount -= 50;
    else if (amount < 400)
        amount -= 80;
    else
        amount -= 120;

    // 会员等级减免
    switch (customer.MemberLevel)
    {
        case MemberLevel.Silver:
            amount *= 0.98m;
            break;
        case MemberLevel.Gold:
            amount *= 0.95m;
            break;
        case MemberLevel.Diamond:
            amount *= 0.90m;
            break;
    }
    return amount;
}

小B拍了拍你的肩膀,意味深长地说:“网站的维护就全靠你了,咱们会好起来的,赚了钱大家一起分!”。

第四幕

三天之后,小B说这几天商城销量非常不错,咱们应该赚了不少钱。但是用户现在的激情也降下去了,咱们可以撤回这些优惠了,商品都按原价来卖吧,麻烦你把优惠政策给撤销吧。

你幽幽地叹了一口气:“好吧,现在改(说好的赚钱大家一起分的呢,这茬子事儿你咋不提?一万匹草泥马疯狂地踏过)。”

于是你删除了调用这个方法的代码。

第五幕

一个月后,小B又来找你了:“现在又到了购物的旺季,淘宝京东开始做活动了,优惠力度还挺大,咱们也在这股购物潮里凑个热闹吧。这一次,我们有以下几项业务规则,和上次的不同,也比上次的复杂一些,你听我向你娓娓道来啊。”

1. aaa规则
2. bbb规则
3. ccc规则
4. ddd规则

10. xxx规则

听完这些后,你崩溃了,你这个小B(一语双关),怎么一下子提出这么多业务,还不带重样的,我从何改起啊?

设计模式简介

听完这个故事后,你能了解到什么呢?我们用两个词来概括,也就是本文开头提到的“变化”和“重复”。
大多数的“变化”都会伴随着“重复”,这些“重复”的表现形式可能不一样,但它们的本质是类似的。

无论是生活还是工作,变化是和重复都是无处不在的。
在工作中,我们处于某一个岗位,我们每天的工作任务都会有变化,我们使用近乎相同的方式处理这些工作任务。

代码中也会出现很多“变化”和“重复”,我们该如何应对呢?。
你可以借用一些设计模式,怎么用设计模式咱先不说,我们先粗略地解一下设计模式。

设计模式是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。

设计模式我们把它拆分成两个来看,“设计”和“模式”。
“设计”就是设计,对于软件系统来说,即分析问题并解决问题的过程。
“模式”是指事物的标准。在软件领域,每个人面临的问题是不同的,虽然不同的问题有不同的标准,但很多问题本质上是类似的。

设计模式更多的是软件层面的,而非业务层面的。
即使你用了设计模式,你也不一定能解决业务上的问题。
即使你不用设计模式,业务上的问题你也许能通过其他途径解决。

设计模式怎么用,在这里我也无法给一个确定的答案。
我个人的看法是,尽量做到“心中无模式”。最关键的是,你应该直面问题的本质,寻找问题最有效的解决方式,不要一遇到问题,就夸夸其谈地使用某某设计模式。真切地从用户角度去出发,去剖析问题的本质,并提出合理的设计和解决方案。当问题得以解决,你回顾这个过程时,你会发现很多模式你是自然而然地用到了。不要特别在意设计模式,这可能会让你忽视问题的本质,即使你把GOF的23种设计模式倒背如流,你解决问题的能力也不会有所提升。

PS:为了描述“变化”和“重复”,我使用了小商城运营这个故事。这个故事里面有些不恰当的地方,搞在线购物的是不会这么去设计优惠券和折扣的。

策略模式

正式进入今天的主题吧,这篇文章要提到的设计模式是“策略模式”。

定义

策略模式是设计模式里面较为简单的一种,它的定义如下:

The Strategy Pattern defines a family of algorithms,encapsulates each one,and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

策略模式定义了一系列的算法,并将每一个算法封装起来,而且使它们还可以相互替换。
在策略模式中,算法是其中的“变化点”,策略模式让算法独立于使用它的客户而独立变化。

组成部分

策略模式有4个部分组成:

1. 客户端:指定使用哪种策略,它依赖于环境

2. 环境:依赖于算法接口,并为客户端提供算法接口的实例

3. 抽象策略:定义公共的算法接口

4. 策略实现:算法接口的具体实现

下面这幅图诠释了这4个组成部分:

注意:在策略模式中,策略是由用户选择的,这意味着具体策略可能都要暴露给客户端,但是我们可以通过“分解依赖”来隐藏策略细节。

示例

重构前

该示例是一家物流公司根据State计算物流运费的场景,ShippingInfo类的CalculateShippingAmount()方法,会按照不同的State计算出运输费用。物流公司最开始只处理3个State的运输业务,分别是Alaska, NewYork和Florida。

隐藏代码

public class ClientCode
{
    public decimal CalculateShipping()
    {
        ShippingInfo shippingInfo = new ShippingInfo();
        return shippingInfo.CalculateShippingAmount(State.Alaska);
    }
}

public enum State
{
    Alaska,
    NewYork,
    Florida
}

public class ShippingInfo
{
    public decimal CalculateShippingAmount(State shipToState)
    {
        switch (shipToState)
        {
            case State.Alaska:
                return GetAlaskaShippingAmount();
            case State.NewYork:
                return GetNewYorkShippingAmount();
            case State.Florida:
                return GetFloridaShippingAmount();
            default:
                return 0m;
        }
    }

    private decimal GetAlaskaShippingAmount()
    {
        return 15m;
    }

    private decimal GetNewYorkShippingAmount()
    {
        return 10m;
    }

    private decimal GetFloridaShippingAmount()
    {
        return 3m;
    }
}

这段代码使用了switch case分支语句,每个State都有相应的运费算法。当物流公司业务扩大,追加新的State时,我们不得不追加switch case分支,并提供新的State的运费算法。

在不远的将来,ShippingInfo类将变成这样:

  • CalculateShippingAmount()方法中包含了大量的switch case分支

  • 大量的运费算法使得ShippingInfo变得非常臃肿

从职责角度看,运费算法是另外一个层面的职责,我们也理应将运费算法从ShippingInfo中剥离出来。

重构后

为了演示策略模式的各个组成部分,我将重构后的代码拆分为4个部分,下图是重构后的UML图示。

抽象策略

计算运费的策略接口,在接口中定义了State属性和Calculate()计算方法。

public interface IShippingCalculation
{
    State State { get; }
    decimal Calculate();
}

策略实现

计算运费的策略实现,分别实现了Alask、NewYork和Florida三个州的运算策略。

public class AlaskShippingCalculation : IShippingCalculation
{
    public State State { get { return State.Alaska; } }

    public decimal Calculate()
    {
        return 15m;
    }
}

public class NewYorkShippingCalculation : IShippingCalculation
{
    public State State { get { return State.NewYork; } }

    public decimal Calculate()
    {
        return 10m;
    }
}

public class FloridaShippingCalculation : IShippingCalculation
{
    public State State { get { return State.Florida; } }

    public decimal Calculate()
    {
        return 3m;
    }
}

环境

IShippingInfo接口相当于环境接口,ShippingInfo相当于环境具体实现,ShippingInfo知道所有的运算策略。

public interface IShippingInfo
{
    decimal CalculateShippingAmount(State state);
}

public class ShippingInfo : IShippingInfo
{
    private IDictionary<State, IShippingCalculation> ShippingCalculations { get; set; }

    public ShippingInfo(IEnumerable<IShippingCalculation> shippingCalculations)
    {
        ShippingCalculations = shippingCalculations.ToDictionary(calc => calc.State);
    }

    public decimal CalculateShippingAmount(State state)
    {
        return ShippingCalculations[state].Calculate();
    }
}

客户端

ClientCode表示客户端,由客户端指定运输目的地,它通过IShippingInfo获取运费计算结果。

客户端依赖于IShippingInfo接口,这使运费计算策略得以隐藏,并解除了客户端对具体环境的依赖性。

public class ClientCode
{
    public IShippingInfo ShippingInfo { get; set; }

    public decimal CalculateShipping()
    {
        return ShippingInfo.CalculateShippingAmount(State.Alaska);
    }
}

使用分支还是策略模式?

通过上面这个示例,大家可以清晰地看到,重构后的代码比重构前复杂的多。出现新的State时,虽然我们可以方便地扩展新的策略,但是会导致策略类越来越多,这意味着我们可能需要维护大量的策略类。

有些人会觉得重构前的代码会比较实用,虽然耦合性高,无扩展性,但代码也比较好改——想使用哪种方式完全取决于你。
(在实际的应用中,运费计算远比示例中的代码要复杂的多。比如:需要依据当前的油价、运输路线、运输工具、运输时间等各种条件来计算。)

另外,我们不应该一遇到分支语句,就想着把它改造成策略模式,这是设计模式的滥用。
如果分支条件是比较固定的,而且每个分支处理逻辑较为简单,我们就没必要使用设计模式。

总的来说,使用分支判断还是策略模式?答案是:It depends on you.

【关注】keepfool

时间: 2024-10-26 04:31:40

小酌重构系列[15]——策略模式代替分支的相关文章

小酌重构系列[11]&mdash;&mdash;提取基类、提取子类、合并子类

概述 继承是面向对象中的一个概念,在小酌重构系列[7]--使用委派代替继承这篇文章中,我"父子关系"描述了继承,这是一种比较片面的说法.后来我又在UML类图的6大关系,描述了继承是一种"is a kind of"关系,它更偏向于概念层次,这种解释更契合继承的本质.本篇要讲的3个重构策略提取基类.提取子类.合并子类都是和继承相关的,如果大家对继承的理解已经足够深刻了,这3个策略用起来应该会得心应手. 提取基类 定义:如果有超过一个类有相似的功能,应该提取出一个基类,并

小酌重构系列目录汇总

为了方便大家阅读这个系列的文章,我弄了个目录汇总. 方法.字段重构 移动方法 (2016-04-24) 提取方法.提取方法对象 (2016-04-26) 方法.字段的提升和降低 (2016-05-01) 分解方法 (2016-05-02) 为布尔方法命名 (2016-05-03) 引入对象参数 (2016-05-04) 类.接口重构 使用委派代替继承 (2016-05-07) 提取接口 (2016-05-08) 解除依赖 (2016-05-09) 分离职责 (2016-05-11) 提取基类.提

小酌重构系列[8]&mdash;&mdash;提取接口

前言 世间唯一"不变"的是"变化"本身,这句话同样适用于软件设计和开发.在软件系统中,模块(类.方法)应该依赖于抽象,而不应该依赖于实现. 当需求发生"变化"时,如果模块(类.方法)依赖于具体实现,具体实现也需要修改:如果模块(类.方法)依赖于接口,则无需修改现有实现,而是基于接口扩展新的实现. 面向实现?面向接口? 接口可以被复用,但接口的实现却不一定能被复用. 面向实现编程,意味着软件的模块(类.方法)之间的耦合性非常高,每次遭遇"

小酌重构系列[17]&mdash;&mdash;提取工厂类

概述 在程序中创建对象,并设置对象的属性,是我们长干的事儿.当创建对象需要大量的重复代码时,代码看起来就不那么优雅了.从类的职责角度出发,业务类既要实现一定的逻辑,还要负责对象的创建,业务类干的事儿也忒多了点.对象创建也是"一件事",我们可以将"这件事"从业务代码中提取出来,让专门的类去做"这件事",这个专门的类一般是"工厂类",这样使得业务类和工厂类各司其职,代码整洁性得以提高.这就是本文要讲的主题--提取工厂类. 工厂举例

小酌重构系列[2]&mdash;&mdash;提取方法、提取方法对象

前言 "艺术源于生活"--代码也源于生活,你在生活中的一些行为习惯,可能会恰如其分地体现在代码中.当实现较为复杂的功能时,由于它包含一系列的逻辑,我们倾向于编写一个"大方法"来实现.为了使项目便于维护,以及增强代码的可读性,我们有必要对"大方法"的逻辑进行整理,并提取出分散的"小方法".这就是本文要讲的两种重构策略:提取方法.提取方法对象. 如何快速地找到想读的书? 在生活中,我是一个比较随意的人,平时也买了不少书去看.我的书

小酌重构系列[18]&mdash;&mdash;重命名

概述 代码是从命名开始的,我们给类.方法.变量和参数命名,我们也给解决方案.工程.目录命名.在编码时,除了应该遵守编程语言本身的命名规范外,我们应该提供好的命名.好的命名意味着良好的可读性,读你代码的人无需太多的注释,就能通过名称知道它是什么,它能做什么事儿,以及它应该怎么用. 我们命名.命名,不断地命名.既然有这么多命名要做,我们不妨做好他. 关于命名 取名字的成本 取个名字很简单,取个好的名字就不那么容易了.快速随意地取个名字,还不如花点时间取个好名字,因为好名字省下来的时间要比花掉的多.

小酌重构系列[14]——使用多态代替条件判断

概述 有时候你可能会在条件判断中,根据不同的对象类型(通常是基类的一系列子类,或接口的一系列实现),提供相应的逻辑和算法.当出现大量类型检查和判断时,if else(或switch)语句的体积会比较臃肿,这无疑降低了代码的可读性.另外,if else(或switch)本身就是一个“变化点”,当需要扩展新的对象类型时,我们不得不追加if else(或switch)语句块,以及相应的逻辑,这无疑降低了程序的可扩展性,也违反了面向对象的OCP原则. 基于这种场景,我们可以考虑使用“多态”来代替冗长的条

小酌重构系列[12]&mdash;&mdash;去除上帝类

关于上帝类 神说:"要有光",就有了光.--<圣经>.上帝要是会写程序,他写的类一定是"上帝类".程序员不是上帝,不要妄想成为上帝,但程序员可以写出"上帝类".上帝是唯一的,上帝的光芒照耀人间,上帝是很爱面子的,他知道程序员写了"上帝类",抢了他的风头,于是他降下神罚要惩戒程序员.--既然你写了"上帝类",那么就将你流放到艰难地修改和痛苦的维护的炼狱中,在地狱之火中永久地熬炼. 你看,上帝也是有

小酌重构系列[13]&mdash;&mdash;移除中间类

我们有时候在应用程序中可能编写了一些"幽灵"类,"幽灵"类也叫中间类.这些中间类可能什么事儿都没做,而只是简单地调用了其他的组件.这些中间类没有发挥实际的作用,它们增加了应用程序的层级(layer),并且增加了应用程序的复杂性.这时,我们应将这样的中间类删除,甚至删除中间类所处的"中间层"--这就是本文要讲的重构策略"移除中间类". 移除中间类 图说 这个重构策略比较容易理解,下面这幅图演示了它的重构过程. 例外 通常情况下,