天天看点

虾皮一面:手写一个Strategy模式(策略模式)

作者:架构师尼恩

▌说在前面:

在40岁老架构师 尼恩的读者交流区(50+)中,最近有指导一个小伙伴面试架构师,面试的公司包括虾皮、希音、美团等大厂,目标薪酬50K以上,遇到了一个比较初级的问题:

  • 请手写一个Strategy模式(策略模式)
  • 或者请手写一个template模式(模板模式)
  • 或者请手写一个proxy模式(代理模式)

小伙伴却忘了, 其他都准备的好好的,结果在这个基础知识的地方,摔了大跟头。

这里尼恩给大家做一下系统化、体系化的梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”。

也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典》V85版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

最新《尼恩 架构笔记》《尼恩高并发三部曲》、《尼恩Java面试宝典》 的PDF文件,请到公号【技术自由圈】取。
- 说在前面
- 策略模式(Strategy Pattern)的定义
- 策略模式的场景分析
- 策略模式的主要角色
- 策略模式的Java实现
  - step1:创建抽象策略类
  - step2:创建具体策略
  - step3:创建环境类
  - step4:创建客户类
- 策略模式的GO代码实现
- 策略模式的优缺
  - 优点
  - 缺点
- 关于策略模式的讨论
- 策略模式的应用场景
- 策略模式和工厂模式的区别
- 说在最后
- 相关面试题 PDF           

▌策略模式(Strategy Pattern)的定义:

策略模式是属于设计模式中的行为模式中的一种,策略模式主要解决选项过多的问题,避免大量的if else 和 switch下有太多的case。

策略模式的重心不是如何实现算法,而是如何组织、调用这些算法,从而让程序结构更灵活,具有更好的维护性和扩展性

策略(Strategy)模式:

  • 该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换
  • 策略模式让算法独立于使用它的客户而变化。

策略是个形象的表述,所谓策略就是方案,日常生活中,要实现目标,有很多方案,每一个方案都被称之为一个策略。

打个比方说,我们出门的时候会选择不同的出行方式,比如骑自行车、坐公交、坐火车、坐飞机、坐火箭等等,这些出行方式,每一种都是一个策略。

再比如我们去逛商场,商场现在正在搞活动,有打折的、有满减的、有返利的等等,其实不管商场如何进行促销,说到底都是一些算法,这些算法本身只是一种策略,并且这些算法是随时都可能互相替换的,比如针对同一件商品,今天打八折、明天满100减30,这些策略间是可以互换的。

在软件开发中也常常遇到类似的情况,实现某一个功能有多个途径,此时可以使用一种设计模式来使得系统可以灵活地选择解决途径,也能够方便地增加新的解决途径,这就是策略模式。

▌策略模式的场景分析:

这个模式使得我们可以在根据环境或者条件的不同选择不同的策略来完成该任务。

先看下面的图片,我们去旅游选择出行模式有很多种,可以骑自行车、可以坐汽车、可以坐火车、可以坐飞机。

虾皮一面:手写一个Strategy模式(策略模式)

作为一个程序猿,开发需要选择一款开发工具,当然可以进行代码开发的工具有很多,可以选择Idea进行开发,也可以使用eclipse进行开发,也可以使用其他的一些开发工具。

虾皮一面:手写一个Strategy模式(策略模式)

在软件开发中,我们也常常会遇到类似的情况,实现某一个功能有多条途径,每一条途径对应一种算法,此时我们可以使用一种设计模式来实现灵活地选择解决途径,也能够方便地增加新的解决途径。

策略模式的的最大优势: 方便代码的功能横向扩展,策略模式将解决途径进行封装有利于我们对解决方式的增加或删除。

同时,策略模式(Strategy Pattern) 也符合开闭原则。

▌策略模式的主要角色:

在策略模式中,我们可以定义一些独立的类来封装不同的算法,每一个类封装一种具体的算法,在这里,每一个封装算法的类我们都可以称之为一种策略(Strategy),为了保证这些策略在使用时具有一致性,一般会提供一个抽象的策略类来做规则的定义,而每种算法则对应于一个具体策略类。

策略模式涉及到三个角色,具体如下:

  • 抽象策略(Strategy)类:这是一个抽象角色,通常由一个接口或抽象类实现。此角色给出所有的具体策略类所需的接口,所有具体的策略类都要实现这个接口。环境(上下文)类Context 使用这个接口调用具体的策略类。
  • 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现或行为。
  • 环境(Context)类:用于配置一个具体的算法策略对象 ,维持一个策略接口类型的引用( Reference ),并且可以定义一个让接口 Strategy 的具体对象访问的接口。在简单情况下,Context 类可以省略。
虾皮一面:手写一个Strategy模式(策略模式)

策略模式是一个比较容易理解和使用的设计模式,策略模式是对算法的封装,它把算法的责任和算法本身分割开,委派给不同的对象管理。

策略模式通常把一个系列的算法封装到一系列具体策略类里面,作为抽象策略类的子类。

在策略模式中,对环境类和抽象策略类的理解非常重要,环境类是需要使用算法的类。在一个系统中可以存在多个环境类,它们可能需要重用一些相同的算法。

在客户端代码中只需注入一个具体策略对象,可以将具体策略类类名存储在配置文件中,通过反射来动态创建具体策略对象,从而使得用户可以灵活地更换具体策略类,增加新的具体策略类也很方便。

策略模式提供了一种可插入式(Pluggable)算法的实现方案。

策略模式的主要目的是将算法的定义与使用分开,也就是将算法的行为和环境分开,将算法的定义放在专门的策略类中,每一个策略类封装了一种实现算法,使用算法的环境类针对抽象策略类进行编程,符合“依赖倒转原则”。在出现新的算法时,只需要增加一个新的实现了抽象策略类的具体策略类即可。

▌策略模式的Java实现:

1.创建抽象策略类

2.创建具体策略

(1)具体策略A

(2)具体策略B

(3)具体策略N....

3.创建环境类

虾皮一面:手写一个Strategy模式(策略模式)

▌step1:创建抽象策略类

  • 定义所有持的算法的公共接口。
  • 所有具体的策略类都要实现这个接口。
  • context使用这个接口来调用某concreteStrategy定义的算法;

定义百货公司所有促销活动的共同接口

package com.crazymakercircle.designpattern.strategy;

//抽象策略类
public interface Strategy {
    void show();
}           

▌step2:创建具体策略

进一步拆分策略类 ,将每个促销算法都单独封装在一个类中,也就是将一个类拆分成几个类,每个类都单独封装一个促销策略算法。

这样一来,修改一个算法只需重新编译算法所涉及的那个类,而不需要重新编译其他类。如果想要添加一个新的算法 只需在子类的集合中再添加一个新的封装该算法的类即可。

定义具体策略角色(Concrete Strategy):每个节日具体的促销活动

//为春节准备的促销活动A
public class StrategyA implements Strategy {

    public void show() {
        System.out.println("买一送一");
    }
}

//为中秋准备的促销活动B
public class StrategyB implements Strategy {

    public void show() {
        System.out.println("满200元减50元");
    }
}

//为圣诞准备的促销活动C
public class StrategyC implements Strategy {

    public void show() {
        System.out.println("满1000元加一元换购任意200元以下商品");
    }
}           

▌step3:创建环境类

Context 通常根据配置, 加载和初始化具体的 ConcreteStrategy, 配置的方式是多样化的:

  • 系统环境变量
  • xml文件
  • 数据库
  • 等等

Context将它的客户的请求转发给它的Strategy。

client 仅与Context交互。客户通常从 context 获取 ConcreteStrategy,这样, Context 通常有一系列的ConcreteStrategy类可供 客户从中选择。

package com.crazymakercircle.designpattern.strategy;

import lombok.Data;

@Data
public class Context {

    String type = System.getenv("strategy");
    Strategy strategy = null;

    public Context() {
        switch (type) {
            case "1":
                strategy = new StrategyA();
                break;
            case "2":
                strategy = new StrategyB();
                break;
            case "3":
                strategy = new StrategyC();
                break;
            default:
                strategy = new StrategyA();
        }
    }
}           

运行的时候,设置好环境变量

虾皮一面:手写一个Strategy模式(策略模式)

▌step4:创建客户类

虾皮一面:手写一个Strategy模式(策略模式)

▌策略模式的GO代码实现:

下面我们就开始以GO代码的形式来展示一下策略模式吧,代码很简单,我们用一个加减乘除法来模拟。

首先,我们看到的将会是策略接口和一系列的策略,这些策略不要依赖高层模块的实现。

package strategy
/**
 * 策略接口
 */
type Strategier interface {
    Compute(num1, num2 int) int
}           

很简单的一个接口,定义了一个方法Compute,接受两个参数,返回一个int类型的值,很容易理解,我们要实现的策略将会将两个参数的计算值返回。

接下来,我们来看一个我们实现的策略,

package strategy
import "fmt"

type Division struct {}

func (p Division) Compute(num1, num2 int) int {
    defer func() {
        if f := recover(); f != nil {
            fmt.Println(f)
            return
        }
    }()

    if num2 == 0 {
        panic("num2 must not be 0!")
    }

    return num1 / num2
}           

为什么要拿除法作为代表呢?因为除法特殊嘛,被除数不能为0,其他的加减乘基本都是一行代码搞定,除法我们需要判断被除数是否为0,如果是0则直接抛出异常。

ok,基本的策略定义好了,我们还需要一个工厂方法,根据不用的type来返回不同的策略,这个type我们准备从命令好输入。

func NewStrategy(t string) (res Strategier) {
    switch t {
        case "s": // 减法
            res = Subtraction{}
        case "m": // 乘法
            res = Multiplication{}
        case "d": // 除法
            res = Division{}
        case "a": // 加法
            fallthrough
        default:
            res = Addition{}
    }

    return
}           

这个工厂方法会根据不用的类型来返回不同的策略实现,当然,哪天我们需要新增新的策略,我们只需要在这个函数中增加对应的类型判断就ok。

现在策略貌似已经完成了,接下来我们来看看主流程代码,一个Computer,

package compute

import (
    "fmt"
    s "../strategy"
)

type Computer struct {
    Num1, Num2 int
    strate s.Strategier
}

func (p *Computer) SetStrategy(strate s.Strategier) {
    p.strate = strate
}

func (p Computer) Do() int {
    defer func() {
        if f := recover(); f != nil {
            fmt.Println(f)
        }
    }()

    if p.strate == nil {
        panic("Strategier is null")
    }

    return p.strate.Compute(p.Num1, p.Num2)
}           

这个Computer中有三个参数,Num1和Num2当然是我们要操作的数了,strate是我们要设置的策略,可能是上面介绍的Division,也有可能是其他的,在main函数中我们会调用SetStrategy方法来设置要使用的策略,Do方法会执行运算,最后返回运算的结果,可以看到在Do中我们将计算的功能委托给了Strategier。

貌似一切准备就绪,我们就来编写main的代码吧。

package main

import (
    "fmt"
    "flag"
    c "./computer"
    s "./strategy"
)

var stra *string = flag.String("type", "a", "input the strategy")
var num1 *int = flag.Int("num1", 1, "input num1")
var num2 *int = flag.Int("num2", 1, "input num2")

func init() {
    flag.Parse()
}

func main() {
    com := c.Computer{Num1: *num1, Num2: *num2}
    strate := s.NewStrategy(*stra)

    com.SetStrategy(strate)
    fmt.Println(com.Do())
}           

首先我们要从命令行读取要使用的策略类型和两个操作数,在main函数中,我们初始化Computer这个结构体,并将输入的操作数赋值给Computer的Num1和Num2,接下来我们根据策略类型通过调用NewStrategy函数来获取一个策略,并调用Computer的SetStrategy方法给Computer设置上面获取到的策略,最后执行Do方法计算结果,最后打印。

就是这么简单,现在我们在命令行定位到main.go所在的目录,并执行一下命令来编译文件

go build main.go

继续执行命令

main -type d -num1 4 -num2 2

来尝试一下使用加法策略操作4和2这两个数,来看看结果如何,

虾皮一面:手写一个Strategy模式(策略模式)

结果很正确,换一个策略试试,来个乘法吧,执行命令

main -type m -num1 4 -num2 2
虾皮一面:手写一个Strategy模式(策略模式)

结果也是正确的。

▌策略模式的优缺:

虾皮一面:手写一个Strategy模式(策略模式)

▌优点

  1. 策略模式的关注点不是如何实现算法,而是如何组织、调用这些算法,从而让程序结构更灵活,具有更好的维护性和扩展性。
  2. 策略模式中各个策略算法是平等的。对于一系列具体的策略算法,地位是完全一样的。正因为这个平等性,才能实现算法之间可以相互替换。所有的策略算法在实现上也是相互独立的,相互之间是没有依赖的。所以可以这样描述这一系列策略算法:策略算法是相同行为的不同实现。
  3. 运行期间,策略模式在每一个时刻只能使用一个具体的策略实现对象,虽然可以动态地在不同的策略实现中切换,但是同时只能使用一个。

如果所有的具体策略类都有一些公有的行为。这时候,就应当把这些公有的行为放到共同的抽象策略角色 Strategy 类里面。当然这时候抽象策略角色必须要用 Java 抽象类实现,而不能使用接口。但是,编程中没有银弹,策略模式也不例外,也有一些缺点,先来回顾总结下优点:

  1. 策略模式提供了对“开闭原则”的完美支持,用户可以在不修改原有系统的基础上选择算法或行为,也可以灵活地增加新的算法或行为。
  2. 策略模式提供了管理相关的算法族的办法。策略类的等级结构定义了一个算法或行为族。恰当使用继承可以把公共的代码移到父类里面,从而避免代码重复。
  3. 使用策略模式可以避免使用多重条件(if-else)语句。多重条件语句不易维护,它把采取哪一种算法或采取哪一种行为的逻辑与算法或行为的逻辑混合在一起,统统列在一个多重条件语句里面,比使用继承的办法还要原始和落后。

▌缺点

  1. 客户端必须知道所有的策略类,并自行决定使用哪一个策略类。这就意味着客户端必须理解这些算法的区别,以便适时选择恰当的算法类。这种策略类的创建及选择其实也可以通过工厂模式来辅助进行。
  2. 由于策略模式把每个具体的策略实现都单独封装成为类,如果备选的策略很多的话,那么对象的数目就会很可观。可以通过使用享元模式在一定程度上减少对象的数量。

▌关于策略模式的讨论:

策略模式还是算比较容易理解的,策略模式的核心就是将容易变动的代码从主逻辑中分离出来,通过一个接口来规范它们的形式,在主逻辑中将任务委托给策略。这样做既减少了我们对主逻辑代码修改的可能性,也增加了系统的可扩展性。

设计的核心原则:对扩展开发,对修改关闭

使用策略模式主要有两个出发点:

(1) 将一组相关的算法封装为各个策略分支,从而将策略分支相关的代码隐藏起来。

(2) 希望可以提升程序的可扩展性。

下面我们就策略模式的可扩展性进行简单的讨论,实际上 策略模式的初衷是要减少与各个分支下的行为相关的条件语句。这已经通过将一个具有条件相关的多种行为的类拆分成一个策略超类与若干个策略子类得到了解决。也就是说,将原来的一个单独的但是包含多个条件语句的类改变为一个没有条件语句的策略层次类。

这里虽然看似条件语句消失了,但是在客户程序与 Context 类中是否也不存在与策略子类相关的条件语句了呢?答案当然不是。

实际上一般在策略模式的设计中 客户类根据不同的条件负责创建不同的策略子类的对象,然后再将该对象传递给 Context 环境类,Context 类的作用可以理解为:为被调用策略子类的一些方法提供一些参数,以及使用该由 Client 类传入的对象去调用 Strategy 类的某些方法。

这说明 在客户类 Client 中 存在许多与策略分支子类相关的条件语句,而在 Context 类中,没有这样的语句。

那么 是否可以将创建策略子类的对象的责任交给 Context 类,而客户类 Client 只为 Context 类提供一些代表客户请求的参数呢?

(1) 客户类负责创建策略子类的对象的情况

客户类根据用户提供的不同的请求,负责创建不同的策略子类的对象 ,然后再将该对象传递给 Context 类。在这种情况下,客户类中通常包含与策略相关的条件语句,而在 Context 类中不必使用任何与策略有关的条件语句,因此,修改或者添加一个策略子类都不必修改 Context 类。但是,在添加一个新的策略子类的情况下,如果客户类需要使用该子类,往往需要在客户类中添加一个新的条件语句,即客户类需要修改。

(2) Context 类负责创建策略子类的对象的情况

将创建策略子类的对象的责任交给 Context 类, 而客户类 Client 只为 Context 类提供一些代表客户请求的参数 ;在此情况下,Context 类在创建策略子类的对象时,必然会使用与策略子类有关的条件语句。此时,修改一个策略子类不需要修改客户类与 Context 类。而在添加一个新的策略子类时,如果此时客户类暂时不使用该新的子类,则新子类的添加不会影响客户类与 Context 类的源代码。但是,如果客户类要使用新的策略子类,则必须同时在客户类与 Con-text 类中添加新的条件分支,也就是说,需要同时修改客户类与 Context 类。

在以上两种情况下,当只是需要修改策略子类的代码时,客户类与 Context 类都不需要进行修改。

综上所述 由客户类创建对象的设计可扩展性好一些。这样,可以做到在 Context 类中出现与策略子类相关的条件语句,从而可扩展性也得到了提高。

▌策略模式的应用场景:

虾皮一面:手写一个Strategy模式(策略模式)
  • 多个类只有算法或行为上稍有不同的场景
  • 算法需要自由切换的场景
  • 需要屏蔽算法规则的场景
  • 出行方式,自行车、汽车等,每一种出行方式都是一个策略
  • 商场促销方式,打折、满减等

▌策略模式和工厂模式的区别:

▌工厂模式

  1. 目的是创建不同且相关的对象
  2. 侧重于"创建对象"
  3. 实现方式上可以通过父类或者接口
  4. 一般创建对象应该是现实世界中某种事物的映射,有它自己的属性与方法

▌策略模式

  1. 目的实现方便地替换不同的算法类
  2. 侧重于算法(行为)实现
  3. 实现主要通过接口
  4. 创建对象对行为的抽象而非对对象的抽象,很可能没有属于自己的属性

▌说在最后:

手写一些基础的设计模式,是非常常见的面试题。

以上2大方案,如果大家能对答如流,如数家珍,基本上 面试官会被你 震惊到、吸引到。

最终,让面试官爱到 “不能自已、口水直流”, 同时 offer, 也就来了。

学习过程中,如果有啥问题,大家可以来 找 40岁老架构师尼恩交流。

▌相关面试题 PDF:

4000页《尼恩Java面试宝典》PDF 41个专题

  • 专题01:JVM面试题
  • 专题02:Java算法面试题
  • 专题03:Java基础面试题
  • 专题04:架构设计面试题
  • 专题05:Spring面试题
  • 专题06:SpringMVC
  • 专题07:Tomcat面试题
  • 专题08:SpringBoot面试题
  • 专题09:网络协议面试题
  • 专题10:TCP-IP协议
  • 专题11:JUC并发包与容器类
  • 专题12:设计模式面试题
  • 专题13:死锁面试题
  • 专题14:Redis 面试题
  • 专题15:分布式锁 面试题
  • 专题16:Zookeeper 面试题
  • 专题17:分布式事务面试题
  • 专题18:一致性协议
  • 专题19:Zab协议
  • 专题20:Paxos 协议
  • 专题21:raft 协议
  • 专题22:Linux面试题
  • 专题23:Mysql 面试题
  • 专题24:SpringCloud 面试题
  • 专题25:Netty 面试题
  • 专题26:消息队列面试题:RabbitMQ、Kafka、RocketMQ
  • 专题27:内存泄漏 内存溢出
  • 专题28:JVM 内存溢出 实战
  • 专题29:多线程面试题
  • 专题30:HR面试题:过五关斩六将后,小心阴沟翻船!
  • 专题31:Hash连环炮面试题
  • 专题32:大厂面试的基本流程和面试准备
  • 专题33:BST、AVL、RBT红黑树、三大核心数据结构
  • 专题34:Elasticsearch面试题
  • 专题35:Mybatis面试题
  • 专题36:Dubbo面试题
  • 专题37:Docker面试题
  • 专题38:K8S面试题
  • 专题39:Nginx面试题
  • 专题40:操作系统面试题
  • 专题41:大厂面试真题
注:尼恩 架构笔记、面试题 的PDF,请到公号《技术自由圈》取

继续阅读