访问者模式 - 行为型设计模式

访问者模式 - 行为型设计模式

引言

本文旨在通过模拟业务场景,逐步衍生出该设计模式。
关于访问者模式本身的概念、优缺点、与其他模式对比等介绍较少,可参考最后一小节的其他文章列表进行详细了解。

一、背景引入

1.我们有很多种形状

我们有很多种形状。形状有一些功能,例如获取类型。

type Shape interface {
	GetType() string // 获取形状的类型
	// ...
}

现在有三种类型的形状:

  • 正方形
  • 圆形
  • 矩形

它们的实现分别为:

// Square 正方形
type Square struct {
	Side float64 // 边长
}
func (s *Square) GetType() string {
	return "正方形"
}

// Circle 圆形
type Circle struct {
	Radius float64 // 半径
}
func (c *Circle) GetType() string {
	return "圆形"
}

// Rectangle 矩形
type Rectangle struct {
	Length float64 // 长
	Width  float64 // 宽
}
func (r *Rectangle) GetType() string {
	return "矩形"
}

2.业务方需要形状提供一些新的功能

a) 提供面积的计算能力

我们首先能想到的实现方式就是,在形状中增加方法,让每种类型都提供这种能力:

type Shape interface {
	GetType() string
	CalcArea() // 新增方法 计算面积
}

// Square.CalcArea()
func (s *Square) CalcArea() {
	fmt.Printf("%s的面积为:%v.\n", s.GetType(), s.Side*s.Side)
}

// Circle.CalcArea()
func (c *Circle) CalcArea() {
	fmt.Printf("%s的面积为:%v.\n", c.GetType(), math.Pi*c.Radius*c.Radius)
}

// Rectangle.CalcArea()
func (r *Rectangle) CalcArea() {
	fmt.Printf("%s的面积为:%v.\n", r.GetType(), r.Length*r.Width)
}
b) 提供面积的切割能力(要求面积折半,即为原面积的1/2)

继续添加方法吗?先这么做试试:

type Shape interface {
	GetType() string
	CalcArea()
	CutArea() // 新增方法 切割面积
}

// Square.CutArea()
func (s *Square) CutArea() {
	s.Side /= math.Sqrt(2) // 边长除以根号2
}

// Circle.CutArea()
func (c *Circle) CutArea() {
	c.Radius /= math.Sqrt(2) // 半径除以根号2
}

// Rectangle.CutArea()
func (r *Rectangle) CutArea() {
	r.Width /= 2 // 宽度除以2
}
c) 这么做显然不是长久之计

我们能够感觉到,随着业务的不断扩增,Shape所提供的能力将会越来越多,每一种形状的代码也会越来越长。
存在两个问题:

  • Shape提供的新功能,是它本身职责所在嘛?
  • 这些已实现的类,是否允许我们去修改?

3.我们思考新的实现方式,来解决出现的问题

a) 让业务方自己实现Shape新的行为

大致步骤如下:

  1. 首先要有一个行为的类
  2. 由于Shape每种实现下,行为的具体操作可能不同,需要对每种Shape实现当前行为的操作逻辑
  3. 行为是基于具体形状的,那么需要将具体形状作为参数传入行为执行的方法

首先针对面积计算行为,尝试实现:

// BehaviorCalcArea 计算面积的行为
type BehaviorCalcArea struct{}

// VisitSquare 访问正方形计算面积
func (a *BehaviorCalcArea) VisitSquare(s *Square) {
	fmt.Printf("%s的面积为:%v.\n", s.GetType(), s.Side*s.Side)
}

// VisitCircle 访问圆计算面积
func (a *BehaviorCalcArea) VisitCircle(c *Circle) {
	fmt.Printf("%s的面积为:%v.\n", c.GetType(), math.Pi*c.Radius*c.Radius)
}

// VisitRectangle 访问矩形计算面积
func (a *BehaviorCalcArea) VisitRectangle(r *Rectangle) {
	fmt.Printf("%s的面积为:%v.\n", r.GetType(), r.Length*r.Width)
}

这看起来没有什么问题,那我们继续实现面积切割行为:

// BehaviorCutArea 裁剪面积的行为
type BehaviorCutArea struct{}

// VisitSquare 访问正方形裁剪面积
func (*BehaviorCutArea) VisitSquare(s *Square) {
	s.Side /= math.Sqrt(2) // 边长除以根号2
}

// VisitCircle 访问圆裁剪面积
func (*BehaviorCutArea) VisitCircle(c *Circle) {
	c.Radius /= math.Sqrt(2) // 半径除以根号2
}

// VisitRectangle 访问矩形裁剪面积
func (*BehaviorCutArea) VisitRectangle(r *Rectangle) {
	r.Width /= 2 // 宽度除以2
}
b) 这些行为的类 像极了访问者

观察上述两种行为,他们通过访问具体形状的属性,来实现自己的操作。就好像,我要去拜访你,然后跟你聊一下这周我写的bug,或者去你家蹭饭吃,存在一个访问你的前置条件。
那么根据上述两种行为的实现,我们抽象出访问者:

type Visitor interface {
	VisitSquare(s *Square)       // 访问正方形
	VisitCircle(c *Circle)       // 访问圆形
	VisitRectangle(r *Rectangle) // 访问矩形
}

通过这种抽象,我们可以理解,每种新增的行为,实现自己的访问者,即可实现所有形状对应的操作。
这成功的解决了前面抛出的问题:

  • Shape提供的新功能,是它本身职责所在嘛?

    不一定是。
    而我们现在已将其从Shape的职责中剥离出来单独实现。

  • 这些已实现的类,是否允许我们去修改?

    不一定允许,而且频繁修改已有的类在实际工程中也是比较危险的操作。
    现在的实现不会再更改那些类,它们的作者一定很开心。

c) 其实出现了新的问题

这里其实出现了新的问题,每种类对应的行为是不同的方法,那使用者就需要去确认Shape的具体类型,来确定调用哪种方法,这不太友好,甚至是非常影响我们的抽象。
既然是抽象的问题,我们就继续抽象的思考。
在前面提到过,所有行为都存在一个访问你的前置条件,那么有一个隐含的逻辑是:你是否接受我的访问?
核心点就在于,被访问者要提供接受访问的能力。把它实现出来(Talk is cheap. Show me the code!):

type Shape interface {
	GetType() string
	// CalcArea() // 弃用的方法
	// CutArea()  // 弃用的方法
	Accept(v Visitor)
}

// Square.Accept()
func (s *Square) Accept(v Visitor) {
	v.VisitSquare(s)
}

// Circle.Accept()
func (c *Circle) Accept(v Visitor) {
	v.VisitCircle(c)
}

// Rectangle.Accept()
func (r *Rectangle) Accept(v Visitor) {
	v.VisitRectangle(r)
}

当被访问者提供接受访问能力的时候,它明确知道自己是谁,应该接受的是哪种访问方式,这就解决了我们的问题。

4.回顾一下整个背景

我们有很多种形状,随着业务的不断扩增,要求这些形状能支持一些新的功能。
由于新的功能多种多样,但是不愿意让形状所承担的职责越来越庞杂,也为了避免频繁修改这些形状的实现导致一些问题,我们让业务方自己去实现这些行为。
但是这些行为都是依赖于具体形状的一些信息的,所以新增的行为,要有专门的方法对接每个具体的类。通过访问这个类的相关属性,极其类型特质,达到当前行为的目的。
访问者要访问这些类,这些类同时要提供接受访问的功能,从而调用访问者对应的访问方法。(这里的思想可以参考文章双分派设计模式

至此,我们实现了访问者模式。

二、访问者模式

1.简介

类型:行为型设计模式
意图:将数据结构对数据的操作分离,对操作进行抽象
角色:访问者 & 被访问者
使用场景

  • 结构很少改变,而要经常定义新的操作,不希望原数据结构由于操作的增加越来越庞杂
  • 希望增加新操作时,所有被访问者不用更改,降低代码风险

2.设计类图

如图所示。

被访问者有三种实现。
访问者的方法有三个,分别对应三种具体的形状。新增行为需要实现访问者的所有方法,即要实现三种形状下行为的具体操作。

设计模式类图-flj原创

3.代码实现

a) 形状
type Shape interface {
	GetType() string
	Accept(v Visitor)
}


// Square 正方形
type Square struct {
	Side float64 // 边长
}
func (s *Square) GetType() string {
	return "正方形"
}
func (s *Square) Accept(v Visitor) {
	v.VisitSquare(s)
}


// Circle 圆形
type Circle struct {
	Radius float64 // 半径
}
func (c *Circle) GetType() string {
	return "圆形"
}
func (c *Circle) Accept(v Visitor) {
	v.VisitCircle(c)
}


// Rectangle 矩形
type Rectangle struct {
	Length float64 // 长
	Width  float64 // 宽
}
func (r *Rectangle) GetType() string {
	return "矩形"
}
func (r *Rectangle) Accept(v Visitor) {
	v.VisitRectangle(r)
}
b) 访问者
type Visitor interface {
	VisitSquare(s *Square)       // 访问正方形
	VisitCircle(c *Circle)       // 访问圆形
	VisitRectangle(r *Rectangle) // 访问矩形
}


// BehaviorCutArea 裁剪面积的行为
type BehaviorCutArea struct{}

// VisitSquare 访问正方形裁剪面积
func (*BehaviorCutArea) VisitSquare(s *Square) {
	s.Side /= math.Sqrt(2) // 边长除以根号2
}

// VisitCircle 访问圆裁剪面积
func (*BehaviorCutArea) VisitCircle(c *Circle) {
	c.Radius /= math.Sqrt(2) // 半径除以根号2
}

// VisitRectangle 访问矩形裁剪面积
func (*BehaviorCutArea) VisitRectangle(r *Rectangle) {
	r.Width /= 2 // 宽度除以2
}


// BehaviorCalcArea 计算面积的行为
type BehaviorCalcArea struct{}

// VisitSquare 访问正方形计算面积
func (a *BehaviorCalcArea) VisitSquare(s *Square) {
	fmt.Printf("%s的面积为:%v.\n", s.GetType(), s.Side*s.Side)
}

// VisitCircle 访问圆计算面积
func (a *BehaviorCalcArea) VisitCircle(c *Circle) {
	fmt.Printf("%s的面积为:%v.\n", c.GetType(), math.Pi*c.Radius*c.Radius)
}

// VisitRectangle 访问矩形计算面积
func (a *BehaviorCalcArea) VisitRectangle(r *Rectangle) {
	fmt.Printf("%s的面积为:%v.\n", r.GetType(), r.Length*r.Width)
}
c) 使用
func main() {
	shapeList := []Shape{
		&Square{Side: 16},
		&Circle{Radius: 32},
		&Rectangle{Length: 10, Width: 8},
	}

	var visitor Visitor
	fmt.Println("\n对形状列表批量执行【计算面积】行为")
	visitor = &BehaviorCalcArea{}
	for _, shape := range shapeList {
		shape.Accept(visitor)
	}

	fmt.Println("\n对形状列表批量执行【切割面积】行为")
	visitor = &BehaviorCutArea{}
	for _, shape := range shapeList {
		shape.Accept(visitor)
	}

	fmt.Println("\n对形状列表批量执行【计算面积】行为")
	visitor = &BehaviorCalcArea{}
	for _, shape := range shapeList {
		shape.Accept(visitor)
	}
}
d) 运行

对形状列表批量执行【计算面积】行为
正方形的面积为:256.
圆形的面积为:3216.990877275948.
矩形的面积为:80.

对形状列表批量执行【切割面积】行为

对形状列表批量执行【计算面积】行为
正方形的面积为:127.99999999999997.
圆形的面积为:1608.4954386379738.
矩形的面积为:40.

Process finished with exit code 0

三、优缺点

1.优点

  • 开闭原则。引入在不同类对象上执行的新行为时,无需对类做出修改。
  • 单一职责原则。可将同一行为的不同版本移到一个类中。
  • 优秀的扩展性和灵活性。访问者对象可以在与各种对象交互时,收集所需要的的信息,而不必关注对象的复杂数据结构

2.缺点

  • 每次在元素层次结构中添加或移除一个类时,你都要更新所有的访问者
  • 具体元素对访问者公布细节,违反了迪米特原则
  • 违反了依赖倒置原则,依赖了具体类,没有依赖抽象

五、与其他设计模式

  • 可以使用访问者模式对整个组合模式树进行操作
  • 可以结合使用访问者模式迭代器模式来遍历复杂数据结构,并对其中的元素执行所需操作,即使这些元素所属的类各不相同

课后作业:关于以上两点,大家可以自己尝试实现下哈

六、其他

1.关于其他编程语言中的重载功能

如果语言支持重载特性,我们Visitor接口的实现可以修改为(假设Go语言支持):

type Visitor interface {
	Visit(s *Square)       // 访问正方形
	Visit(c *Circle)       // 访问圆形
	Visit(r *Rectangle) // 访问矩形
}

但是由于需要传入具体的对象,而不是接口。所以还是需要被访问者提供Accept支持访问能力。
实现大致为:

// Square.Accept()
func (s *Square) Accept(v Visitor) {
	v.Visit(s) // 重载 判定为Square参数对应的方法
}

// Circle.Accept()
func (c *Circle) Accept(v Visitor) {
	v.Visit(c) // 重载 判定为Circle参数对应的方法
}

// Rectangle.Accept()
func (r *Rectangle) Accept(v Visitor) {
	v.Visit(r) // 重载 判定为Rectangle参数对应的方法
}

2.etcd中的访问者思想

学习etcd时,看到里面有这样一个方法:

// etcd/raft/tracker/tracker.go
func (p *ProgressTracker) Visit(f func(id uint64, pr *Progress)) {
	// ... // 此处省略部分代码
	for _, id := range ids {
		f(id, p.Progress[id])
	}
}

这是ProgressTracker的Visit方法(我们暂且忽略这个类是做什么的),该方法需要一个传入参数——函数指针f。
在方法内部(即第4-6行),通过遍历ids(一个数据列表),分别调用定义的行为(即传入的f)。
我们能够感觉出来,这种访问形式,与访问者设计模式的核心需求是相同的:

有一批数据,需要执行同一行为

下面是两处使用,通过传参指定了两种行为,通过Visit方法让所有元素执行对应行为:

// etcd/raft/raft.go:526
// bcastAppend sends RPC, with entries to all peers that are not up-to-date
// according to the progress recorded in r.prs.
func (r *raft) bcastAppend() {
	r.prs.Visit(func(id uint64, _ *tracker.Progress) { // 调用Visit
		if id == r.id {
			return
		}
		r.sendAppend(id)
	})
}

// etcd/raft/status.go:44
func getProgressCopy(r *raft) map[uint64]tracker.Progress {
	m := make(map[uint64]tracker.Progress)
	r.prs.Visit(func(id uint64, pr *tracker.Progress) { // 调用Visit
		var p tracker.Progress
		p = *pr
		p.Inflights = pr.Inflights.Clone()
		pr = nil

		m[id] = p
	})
	return m
}

但是为什么说这里面只是有访问者思想,而不是设计模式呢?

  1. 它的数据列表是具体的对象,而不是接口;
  2. 它的行为是具体的过程式方法,而不是定义了行为的接口。

3.笔者思考

每个设计模式都有它自己的适用场景。全局可通用一个对象,避免重复创建&销毁时,我们往往使用单例模式。但是如果遇到了上述的情况,我们会愿意使用访问者模式嘛?
如果shape的维护方是很核心的业务,对外暴露accept这样的接口真的合适嘛?
我认为更好的方式,是自己再封装一层wrapShape,以支持各种新增功能。
这里的思想参考我们正在使用的golang组件中的rpcxutil实现。

package rpcxutil

import (
	"context"

	"github.com/smallnest/rpcx/client"
)

type WrapClient struct {
	xclient client.XClient
	wrap    RpcxWrap
}

4.参考资料

文章链接
访问者设计模式
双分派设计模式
访问者模式 菜鸟教程
访问者模式 简书
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值