配套视频课程已更新完毕,大家可通过以下两种方式观看视频讲解:

关注公众号:爱编程的大丙,或者进入大丙课堂学习。


1. 遭遇香波地

在海贼世界中,香波地群岛位于伟大航路中间的红土大陆前方。岛屿是由多颗大树构成,地面就是树根,会从地面冒出气泡。在这里,路飞的好朋友人鱼凯米被人贩子拐卖并卖给了天龙人,路飞为了救凯米狠狠地揍了天龙人。最后,草帽团被巴索罗缪·大熊的果实能力拍到了世界各地,草帽团就地解散。

img

对于当时的草帽团来说一共有九个人,面对这个打不过的敌人大熊,九个人都做了最后的反抗和挣扎,在这个过程中这些人的状态和行为或许相同相互不同,归纳总结一下就是无助、恐惧、愤怒

xiangbodi

如果我们想要通过程序复刻上面动态图片中的这个场景是有很多种处理方式的,最简单的一种就是定义一个人的基类,然后让草帽团的各成员作为这个类的子类,在各个子类中来具体描述他们面对大熊的攻击时的反应和状态。

  1. 索隆很愤怒决定要砍了大熊
  2. 山治很愤怒决定要踢死大熊
  3. 乌索普很恐惧在心里画圈圈诅咒大熊
  4. 路飞很愤怒想锤死大熊
  5. 乔巴很愤怒想拍死大熊
  6. 布鲁克很愤怒用已死的身体阻挡大熊
  7. 弗兰奇很愤怒思考怎么弄死大熊
  8. 娜美很恐惧,请求伙伴的帮助
  9. 罗宾很恐惧,请求伙伴的帮助

可以看到这么写有一个弊端,就是比较啰嗦,其实草帽团面对大熊的突如其来的攻击就两个状态:愤怒和恐惧,在这两种状态下的反应就是战斗和求助。所以我们可以把上面的这个场景重构一下:

如果草帽团的某些成员在面对大熊攻击时的状态反应是一样的,那么在这些子类中就会出现很多相同的冗余代码。有一种更好的处理思路就是将状态和人分开,其中草帽团的各个成员我们可以看做是对象,草帽团成员的反应和状态我们可以将其看做是算法,这种将算法与其所作用的对象隔离开来的设计模式就叫做访问者模式,其实就是通过被分离出的算法来访问对应的对象。

关于访问者模式,在日常生活中对应的场景也有很多,比如:

  • 旅游:去安徽可以爬黄山,去山东可以爬泰山,去陕西可以爬华山

    • 不同的地点,人爬的山是不一样的
  • 卖保险:如果是老百姓推销医疗保险,如果是银行推销失窃保险,如果是商铺推销火灾和水灾保险。

    • 不同的受众,保险推销员推销的产品是不一样的
  • 小鬼子奇葩的盖章文化:Boss的章是正的,其他下属职位越低盖章的时候就得越倾斜(真他妈的虚伪),如果不这样就表示对上司有意见。

    • 不同等级的职员,盖章的方式是不一样的。

    image-20220928000017668

以上三个例子中前者是对象,后者就是算法,如果用访问者模式处理上边列举的场景就需要使用后者来访问前者。

2. 再见, 草帽团

2.1 草帽团成员

在香波地群岛的时候,草帽团的成员一共有9人,如果使用访问者模式来处理他们在遭遇大熊之后的状态,那么就需要将状态(算法)和人(对象)分离开来,关于这九个成员我们可以按照性别进行划分:男人和女人。不论是哪类成员最终都需要接受对应的被分离出的那个行为状态的访问,我们只需要在这个成员类中提供一个接受访问的函数就可以了,所以这个抽象的成员类定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 抽象的成员类
class AbstractMember
{
public:
AbstractMember(string name) : m_name(name){}
string getName()
{
return m_name;
}
// 接受状态对象的访问
virtual void accept(行为/动作类* action) = 0;
virtual ~AbstractMember() {}
protected:
string m_name;
};
  • 这个抽象的基类的构造函数提供了一个参数,用于指定当前成员的名字。
  • 调用getName() 函数可以得到当前成员的名字
  • 调用accept()函数表示当前成员对象接受了行为/动作类的访问
    • 关于行为/动作也可以对应很多种情况,所以此处的类也应该是一个基类
    • 目前行为/动作类还没有被定义,所以先用中文注释代替。

有了上面的抽象基类,按照里面的分类就可以把成员子类定义出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 男性成员
class MaleMember : public AbstractMember
{
public:
AbstractMember::AbstractMember;
void accept(行为/动作* action) override
{
// do something
}
};

// 女性成员
class FemaleMember : public AbstractMember
{
public:
AbstractMember::AbstractMember;
void accept(行为/动作* action) override
{
// do something
}
};

关于草帽团成员类,他们的行为全部被分离出去了,所以在当前成员类中剩下的就是这个成员自身的一些属性信息,比如:性别、姓名、在船上的职务、被悬赏的金额等。

如果在成员类中调用了accept()方法,就可以通过参数传入的行为/动作对象调用它的成员函数,这个动作就是当前草帽团某个成员对象被分离出去的动作或者行为,通过这种方式成员类和行为/动作类就可以关联到一起了。

2.2 最后的挣扎

草帽一伙在被大熊拍飞之前都做出了最后的挣扎,他们对应的就是一些状态和行为,假设状态就两种:愤怒和恐惧。这两个状态的所有者就是上面定义的两个类男性成员类和女性成员类。我们先把这两种行为状态对应的基类定义出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Visitor.h
// 类声明
class MaleMember;
class FemaleMember;
// 抽象的动作类
class AbstractAction
{
public:
// 访问男人
virtual void maleDoing(MaleMember* male) = 0;
// 访问女人
virtual void femalDoing(FemaleMember* female) = 0;
virtual ~AbstractAction() {}
};

这个类提供了两个虚函数:

  • maleDoing():男性成员的行为函数,所以需要访问一个男性成员的对象,故参数的类型为MaleMember*
  • femalDoing:女性成员的行为函数,所以需要访问一个女性成员的对象,故参数的类型为FemaleMember*
  • 这两个Doing()函数之所以参数是成员类的子类类型是因为男性成员和女性成员对象所拥有的函数可以是不一样的(除了从父类继承,可能在子类内部也会定义一些只属于这个子类的成员函数),这样才更方便进行函数的调用。
  • 在这个类的头文件中本别对MaleMember 类FemaleMember 类进行了声明,但并没有包含他们对应的头文件,目的是防止头文件重复包含。

有了行为类的基类,下面把它对应的两个子类定义出来:

头文件 Visitor.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 愤怒
class Anger : public AbstractAction
{
public:
void maleDoing(MaleMember* male) override;
void femalDoing(FemaleMember* female) override;
void warning();
void fight();
};

// 恐惧
class Horror : public AbstractAction
{
public:
void maleDoing(MaleMember* male) override;
void femalDoing(FemaleMember* female) override;
void help();
void thinking();
};

源文件 Visitor.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <iostream>
#include "Visitor.h"
#include "Member.h"
#include <list>
#include <vector>
using namespace std;

void Anger::maleDoing(MaleMember* male)
{
cout << "我是草帽海贼团的" << male->getName() << endl;
fight();
}

void Anger::femalDoing(FemaleMember* female)
{
cout << "我是草帽海贼团的" << female->getName() << endl;
warning();
}

void Anger::warning()
{
cout << "大家块逃,我快顶不住了, 不要管我!!!" << endl;
}

void Anger::fight()
{
cout << "只要还活着就得跟这家伙血战到底!!!" << endl;
}

void Horror::maleDoing(MaleMember* male)
{
cout << "我是草帽海贼团的" << male->getName() << endl;
thinking();
}

void Horror::femalDoing(FemaleMember* female)
{
cout << "我是草帽海贼团的" << female->getName() << endl;
help();
}

void Horror::help()
{
cout << "这个大熊太厉害, 太可怕了, 快救救我。。。" << endl;
}

void Horror::thinking()
{
cout << "得辅助同伴们一块攻击这个家伙, 不然根本打不过呀!!!" << endl;
}

在这个两个字行为类中分别通过maleDoing()femalDoing函数完成了对成员类的访问,通过参数传递进来的成员对象得到了成员属性,然后在行为类中赋予这个成员对象一系列的行为,这样成员对象和成员对象的行为就又被整合到一起了。

行为类被定义出来之后,我们就可以把前面的成员类补充完整了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Member.h
#pragma once
#include <iostream>
#include "Visitor.h"
using namespace std;
// 抽象的成员类
class AbstractMember
{
public:
AbstractMember(string name) :m_name(name){}
string getName()
{
return m_name;
}
// 接受状态对象的访问
virtual void accept(AbstractAction* action) = 0;
virtual ~AbstractMember() {}
protected:
string m_name;
};

// 男性成员
class MaleMember : public AbstractMember
{
public:
AbstractMember::AbstractMember;
void accept(AbstractAction* action) override
{
action->maleDoing(this);
}
};

// 女性成员
class FemaleMember : public AbstractMember
{
public:
AbstractMember::AbstractMember;
void accept(AbstractAction* action) override
{
action->femalDoing(this);
}
};

在上面的代码中用到了一种双分派技术

  1. 在调用成员类的accept() 函数的时候,将具体地行为状态通过参数传递给了男性成员或者女性成员。
  2. accept() 函数中通过行为状态对象调用行为函数的时候,将当前成员对象传递给了状态对象。

accept() 函数是一个双分派操作,它得到执行的操作不仅取决于传入的状态类的具体状态,还取决于它访问的人的类别。

2.3 集合

草帽一伙要被拍飞了,我们可以把这九个人先聚到一起,所以可以定义一个草帽团类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Visitor.cpp
// 草帽团
class CaoMaoTeam
{
public:
CaoMaoTeam()
{
m_actions.push_back(new Anger);
m_actions.push_back(new Horror);
}
void add(AbstractMember* member)
{
m_members.push_back(member);
}
void remove(AbstractMember* member)
{
m_members.remove(member);
}
void display()
{
for (const auto& item : m_members)
{
int index = rand() % 2;
item->accept(m_actions[index]);
}
}
~CaoMaoTeam()
{
for (const auto& item : m_members)
{
delete item;
}
}
private:
list<AbstractMember*> m_members;
vector<AbstractAction*> m_actions;
};

在这个类中使用了两个容器:

  • list 容器:用于存储草帽团成员
  • vector 容器:用于存储草帽团成员的两种行为状态。

通过这个类的display()函数我们就可以了解到草帽团成员在被大熊拍飞之前的行为状态了。

2.3 解散,再见

草帽团成员已集合完毕,大熊可以下手了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main()
{
srand(time(NULL));
vector<string> names{
"路飞", "索隆","山治", "乔巴", "弗兰奇", "乌索普", "布鲁克"
};
CaoMaoTeam* caomao = new CaoMaoTeam;
for (const auto& item : names)
{
caomao->add(new MaleMember(item));
}
caomao->add(new FemaleMember("娜美"));
caomao->add(new FemaleMember("罗宾"));
caomao->display();
delete caomao;
return 0;
}

最后看一下,草帽团各成员的反应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
我是草帽海贼团的路飞
只要还活着就得跟这家伙血战到底!!!
我是草帽海贼团的索隆
得辅助同伴们一块攻击这个家伙, 不然根本打不过呀!!!
我是草帽海贼团的山治
只要还活着就得跟这家伙血战到底!!!
我是草帽海贼团的乔巴
只要还活着就得跟这家伙血战到底!!!
我是草帽海贼团的弗兰奇
得辅助同伴们一块攻击这个家伙, 不然根本打不过呀!!!
我是草帽海贼团的乌索普
只要还活着就得跟这家伙血战到底!!!
我是草帽海贼团的布鲁克
只要还活着就得跟这家伙血战到底!!!
我是草帽海贼团的娜美
这个大熊太厉害, 太可怕了, 快救救我。。。
我是草帽海贼团的罗宾
这个大熊太厉害, 太可怕了, 快救救我。。。

3. 结构图

最后将上面的例子对应的UML类图画一下(学会了访问者模式之后,需要先画UML类图,再写程序。

image-20220929001027923

访问者模式适用于数据结构比较稳定的系统,对于上面的例子而言就是指草帽团成员:只有男性和女性(不会再出现其它性别)。在剥离出的行为状态类中针对男性和女性提供了相对应的 doing 方法。这种模式的优势就是可以方便的给对象添加新的状态和处理动作,也就是添加新的 AbstractAction 子类(算法类),在需要的时候让这个子类去访问某个成员对象,访问者模式的最大优势就是使算法的增加变得更加容易维护。

如果不按照性别进行划分,草帽团一共9个成员就需要在行为状态类中给每个成员提供一个 doing 方法,当草帽团又添加了新的成员,状态类中也需要给新成员再添加一个对应的 doing 方法,这就破坏了设计模式的开放 – 封闭原则。