访问者模式 - 行为型设计模式
引言
本文旨在通过模拟业务场景,逐步衍生出该设计模式。
关于访问者模式本身的概念、优缺点、与其他模式对比等介绍较少,可参考最后一小节的其他文章列表进行详细了解。
一、背景引入
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新的行为
大致步骤如下:
- 首先要有一个行为的类
- 由于Shape每种实现下,行为的具体操作可能不同,需要对每种Shape实现当前行为的操作逻辑
- 行为是基于具体形状的,那么需要将具体形状作为参数传入行为执行的方法
首先针对面积计算行为,尝试实现:
// 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.设计类图
如图所示。
被访问者有三种实现。
访问者的方法有三个,分别对应三种具体的形状。新增行为需要实现访问者的所有方法,即要实现三种形状下行为的具体操作。
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
}
但是为什么说这里面只是有访问者思想,而不是设计模式呢?
- 它的数据列表是具体的对象,而不是接口;
- 它的行为是具体的过程式方法,而不是定义了行为的接口。
3.笔者思考
每个设计模式都有它自己的适用场景。全局可通用一个对象,避免重复创建&销毁时,我们往往使用单例模式。但是如果遇到了上述的情况,我们会愿意使用访问者模式嘛?
如果shape的维护方是很核心的业务,对外暴露accept这样的接口真的合适嘛?
我认为更好的方式,是自己再封装一层wrapShape,以支持各种新增功能。
这里的思想参考我们正在使用的golang组件中的rpcxutil实现。
package rpcxutil
import (
"context"
"github.com/smallnest/rpcx/client"
)
type WrapClient struct {
xclient client.XClient
wrap RpcxWrap
}
4.参考资料
文章链接 |
---|
访问者设计模式 |
双分派设计模式 |
访问者模式 菜鸟教程 |
访问者模式 简书 |