[UE5 设计模式] 访问者模式Visitor Pattern


参考材料
1. 【UE4 设计模式】策略模式 Strategy Pattern
2. 访问者模式
3. 游戏开发:访问者模式实战解析

1. 概述

1.1 描述

$\cdot$ 在访问者模式(Visitor Pattern)中, 我们使用了一个访问者类, 它改变了元素类的执行算法. 通过这种方式, 元素的执行算法可以随着访问者改变而改变. 这种类型的设计模式属于行为型模式. 根据模式, 元素对象已接受访问者对象, 这样访问者对象就可以处理元素对象上的操作.

1.2 套路

$\cdot$ 实现方式:
$\\$ 1) 定义访问者接口: 声明一系列访问方法, 一个访问方法对应数据结构中的一个元素类.
$\\$ 2) 创建具体访问者: 实现访问者接口, 为每个访问方法提供具体实现.
$\\$ 3) 定义元素接口: 声明一个接受访问者的方法.
$\\$ 4) 创建具体元素: 实现元素接口, 每个具体元素类对应数据结构中的一个具体对象.

$\cdot$ 关键代码:
$\\$ 1) 访问者接口: 包含访问不同元素的方法.
$\\$ 2) 具体访问者: 实现了访问者接口, 包含对每个元素类的访问逻辑.
$\\$ 3) 元素接口: 包含一个接受访问者的方法.
$\\$ 4) 具体元素: 实现了元素接口, 提供给访问者访问的入口.

$\cdot$ 结构:
$\\$ 包含的几个主要角色
$\\$ 1) 访问者(Visitor): 定义了访问元素的接口.
$\\$ 2) 具体访问者(Concrete Visitor): 实现访问者接口, 提供对每个具体元素类的访问和相应操作.
$\\$ 3) 元素(Element): 定义了一个接受访问者的方法.
$\\$ 4) 具体元素(Concrete Element): 实现元素接口, 提供一个Accept方法, 允许访问者访问并操作.
$\\$ 5) 对象结构(Object Structure)(可选): 定义了如何组装具体元素, 如一个组合类.
$\\$ 6) 客户端(Client)(可选): 使用访问者模式对对象结构进行操作.

1.3 使用场景

$\cdot$ 当需要对一个对象结构中的对象执行多种不同的且不相关的操作时, 尤其是这些操作需要避免”污染” 对象类本身.

1.4 优缺点

$\cdot$ 优点:
$\\$ 1) 单一职责原则: 访问者模式符合单一职责原则, 每个类只负责一项职责.
$\\$ 2) 扩展性: 容易为数据结构添加新的操作.
$\\$ 3) 灵活性: 访问者可以独立于数据结构变化.

$\cdot$ 缺点:
$\\$ 1) 违反迪米特原则: 元素需要向访问者公开其内部信息.
$\\$ 2) 元素类难以变更: 元素类需要维持与访问者的兼容.
$\\$ 3) 依赖具体类: 访问者模式依赖于具体类而不是接口, 违反了依赖倒置原则.

2. 访问者模式原理—— 生动形象解释

2.1 生活中的例子

$\cdot$ 比喻: 医生巡诊不同病人
$\\$ 想象医院里有很多不同类型的病人: 小孩, 成年人, 老人.
$\\$ 每个病人有自己的身体状况, 但医院会有不同的医生来巡诊, 比如内科医生, 外科医生, 心理医生.
$\\$ 1) 病人类(Element): 小孩, 成年人, 老人.
$\\$ 2) 医生类(Visitor): 内科医生, 外科医生, 心理医生.
$\\$ 每个医生都能”访问” 每个病人, 并根据病人类型做不同的检查和处理.

$\cdot$ 关键点: 你不用在病人类里写所有医生的处理逻辑, 而是让医生自己带着”处理方法” 来访问病人.

$\cdot$ 本质: 访问者模式就是把对一组对象的操作从对象本身分离出来, 新增操作时不用修改对象类, 只需增加访问者类.

2.2 游戏中的实际应用

$\cdot$ Buff/状态系统: 不同Buff(加血, 减速, 眩晕) 访问不同类型的角色(玩家, 怪物, NPC), 实现不同效果.
$\\$ $\cdot$ 数据导出: 导出游戏中各种对象(角色, 道具, 关卡) 到不同格式(JSON, XML, CSV).
$\\$ $\cdot$ 技能系统: 技能访问不同目标, 产生不同效果.

3. UE5实践(以Buff系统为例)

3.1 元素接口和具体元素


#pragma once
#include "CoreMinimal.h"


class IBuffVisitor;

// 元素接口
class ICharacter
{
public:
	virtual void Accept(IBuffVisitor* Visitor) = 0;
};

// 具体元素: 玩家
class FPlayer : public ICharacter
{
public:
	virtual void Accept(IBuffVisitor* Visitor) override;

public:
	int32 HP = 100;
};

// 具体元素: 玩家
class FMonster : public ICharacter
{
public:
	virtual void Accept(IBuffVisitor* Visitor) override;

public:
	int32 HP = 50;
};


#include "Element.h"


void FPlayer::Accept(IBuffVisitor* Visitor)
{
	Visitor.Visit(this);
}

void FMonster::Accept(IBuffVisitor* Visitor)
{
	Visitor.Visit(this);
}

3.2 访问者接口和具体访问者


#pragma once


class FPlayer;
class FMonster;

// 访问者接口
class IBuffVisitor
{
	virtual void Visit(FPlayer* Player) = 0;
	virtual void Visit(FMonster* Monster) = 0;
};

// 具体访问者: 加血Buff
class FHealBuff : public IBuffVisitor
{
public:
	virtual void Visit(FPlayer* Player) override;
	virtual void Visit(FMonster* Monster) override;
};

// 具体访问者: 中毒Buff
class FPoisonBuff : public IBuffVisitor
{
public:
	virtual void Visit(FPlayer* Player) override;
	virtual void Visit(FMonster* Monster) override;
};


#include "Visitor.h"
#include "CoreMinimal.h"


void FHealBuff::Visit(FPlayer* Player)
{
	Player->HP += 20;
	UE_LOG(LogTemp, Log, TEXT("玩家加血,当前HP:%d"), Player->HP);
}

void FHealBuff::Visit(FMonster* Monster)
{
	Monster->HP += 10;
	UE_LOG(LogTemp, Log, TEXT("怪物加血,当前HP:%d"), Monster->HP);
}

void FPoisonBuff::Visit(FPlayer* Player)
{
	Player->HP -= 15;
	UE_LOG(LogTemp, Log, TEXT("玩家中毒,当前HP:%d"), Player->HP);
}

void FPoisonBuff::Visit(FMonster* Monster)
{
	Monster->HP -= 6;
	UE_LOG(LogTemp, Log, TEXT("怪物中毒,当前HP:%d"), Monster->HP);
}

3.3 使用示例


#include "Element.h"
#include "Visitor.h"


int main()
{
	ICharacter* Player = new FPlayer();
	ICharacter* Monster = new FMonster();

	IBuffVisitor* Heal = new FHealBuff();
	IBuffVisitor* Poison = new FPoisonBuff();

	// 给玩家和怪物加血
	Player->Accept(Heal); // 玩家加血, 当前HP: 120
	Monster->Accept(Heal); // 怪物加血, 当前HP: 60

	// 给玩家和怪物中毒
	Player->Accept(Poison); // 玩家中毒,当前HP:105
	Monster->Accept(Poison); // 怪物中毒,当前HP:55

	delete Player;
	Player = nullptr;

	delete Monster;
	Monster = nullptr;

	delete Heal;
	Heal = nullptr;

	delete Poison;
	Poison = nullptr;

	return 0;
}

3.4 口诀总结

访问者像医生,
元素是病人;
新增操作加医生,
病人代码不用变!

比如你有一组场景对象(NPC, 怪物, 宝箱), 你想实现”导出数据到JSON” 和”导出到XML” 两种操作.
$\\$ 用访问者模式, 每种导出方式写一个访问者, 后续要加”导出到CSV” 只需加一个访问者类, 原有对象不用动.

4. 深入理解

下面我们将深入理解:
$\\$ 1) 双分派原理(访问者模式的核心).
$\\$ 2) 访问者链(Visitor Chain / Chain of Visitors).
$\\$ 3) 复杂案例(如技能系统, 导出系统等).

4.1 双分派原理(Double Dispatch)

1) 单分派 vs 双分派
$\\$ 单分派: C#, Java等语言默认是”单分派”, 即方法调用只根据接收者对象的实际类型决定调用哪个方法.
$\\$ 双分派: 访问者模式实现了”根据访问者类型和被访问者类型共同决定调用哪个方法”.

2) 形象解释
$\\$ 比喻: 你(访问者) 去医院(元素), 你是内科医生, 病人是小孩.
$\\$ $\cdot$ 医院先看你是哪个医生(访问者类型),
$\\$ $\cdot$ 再看病人是哪个类型(元素类型),
$\\$ $\cdot$ 最终决定调用”内科医生给小孩看病” 的方法.

3) 代码演示


#pragma once
#include "CoreMinimal.h"


// 元素接口
class IElement
{
public:
	virtual void Accept(class IVisitor* Visitor) = 0;
};

// 访问者接口
class IVisitor
{
public:
	virtual void Visit(class FElementA* A) = 0;
	virtual void Visit(class FElementB* B) = 0;
}

// 具体元素
class FElementA : public IElement
{
public:
	virtual void Accept(class IVisitor* Visitor) override
	{
		Visitor->Visit(this); // 这里的this类型是FElementA
	}
};

class FElementB : public IElement
{
public:
	virtual void Accept(class IVisitor* Visitor) override
	{
		Visitor->Visit(this); // 这里的this类型是FElementB
	}
};

// 具体访问者
class FVisitor1 : pulic IVisitor
{
public:
	virtual void Visit(FElementA* A) override
	{
		UE_LOG(LogTemp, Log, TEXT("Visitor1访问ElementA"));
	}

	virtual void Visit(FElementB* B) override
	{
		UE_LOG(LogTemp, Log, TEXT("Visitor1访问ElementB"));
	}
};

双分派过程:
$\\$ $\cdot$ Element.Accept(Visitor): 先根据Element的实际类型(ElementA / ElementB) 调用对应的Accept.
$\\$ $\cdot$ Visitor.Visit(this): 再根据Visitor的实际类型和this的类型, 调用正确的Visit重载.

4.2 访问者链(Visitor Chain)

4.2.1 概念

访问者链是指多个访问者依次作用于同一组元素, 类似责任链模式, 但每个访问者都能处理元素.

4.2.2 形象解释

比如你有一组角色, 先让”加血Buff” 访问一遍, 再让”中毒Buff” 访问一遍, 角色会依次受到多种效果.

4.2.3 代码实现


#include "Element.h"
#include "Visitor.h"


int main()
{
	TArray Visitors = {new FHealBuff(), new FPoisonBuff()};
	TArray Elements = { new FPlayer(), new FMonster() };

	for (auto& Visitor : Visitors)
	{
		for (auto& Element : Elements)
		{
			Element->Accept(Visitor);
		}
	}

	for (auto& Visitor : Visitors)
	{
		delete Visitor;
		Visitor = nullptr;
	}
	Visitors.Empty();

	for (auto& Element : Elements)
	{
		delete Element;
		Element = nullptr;
	}
	Elements.Empty();

	return 0;
}

这样每个角色会被每个Buff访问一次.

4.3 UE复杂案例

4.3.1 技能系统中的访问者模式

场景: 你有多种技能(火球, 治疗, 冰冻), 每种技能对不同目标(玩家, 怪物, 建筑) 有不同效果.

代码结构


#pragma once
#include "CoreMinimal.h"


class ISkillVisitor;

// 目标接口
class ITarget
{
public:
	virtual void Accept(ISkillVisitor* Visitor) = 0;
};

// 具体目标
class FPlayer : public ITarget
{
public:
	virtual void Accept(ISkillVisitor* Visitor) override
	{
		Visitor->Visit(this);
	}

public:
	int32 HP = 100;
};

class FMonster : public ITarget
{
public:
	virtual void Accept(ISkillVisitor* Visitor) override
	{
		Visitor->Visit(this);
	}

public:
	int32 HP = 50;
};

class FBuilding : public ITarget
{
public:
	virtual void Accept(ISkillVisitor* Visitor) override
	{
		Visitor->Visit(this);
	}

public:
	int32 Durability = 200;
};

// 技能访问者接口
class ISkillVisitor
{
public:
	virtual void Visit(FPlayer* Player) = 0;
	virtual void Visit(FMonster* Monster) = 0;
	virtual void Visit(FBuilding* Building) = 0;
};

// 具体技能
class FFireballSkill : public ISkillVisitor
{
public:
	virtual void Visit(FPlayer* Player) override
	{
		Player->HP -= 10;
		UE_LOG(LogTemp, Log, TEXT("玩家被火球击中,HP: %d"), Player->HP);
	}

	virtual void Visit(FMonster* Monster) override
	{
		Monster->HP -= 30;
		UE_LOG(LogTemp, Log, TEXT("怪物被火球击中,HP: %d"), Monster->HP);
	}

	virtual void Visit(FBuilding* Building) override
	{
		Building->Durability -= 50;
		UE_LOG(LogTemp, Log, TEXT("建筑被火球击中,耐久: %d"), Building->Durability);
	}
};

class FHealSkill : public ISkillVisitor
{
public:
	virtual void Visit(FPlayer* Player) override
	{
		Player->HP += 20;
		UE_LOG(LogTemp, Log, TEXT("玩家被治疗,HP: %d"), Player->HP);
	}

	virtual void Visit(FMonster* Monster) override
	{
		/*怪物不能被治疗*/
	}

	virtual void Visit(FBuilding* Building) override
	{
		/*建筑不能被治疗*/
	}
};

使用方式


#include "TargetAndSkillVisitor.h"


int main()
{
	TArray Targets = { new FPlayer(), new FMonster(), new FBuilding() };
	ISkillVisitor* Fireball = new FFireballSkill();
	ISkillVisitor* Heal = new FHealSkill();

	for (auto& Target : Targets)
	{
		Target->Accept(Fireball); // 所有目标都被火球访问
		Target->Accept(Heal); // 所有目标都被治疗访问
	}

	for (auto& Target : Targets)
	{
		delete Target;
		Target = nullptr;
	}
	Targets.Empty();

	delete Fireball;
	Fireball = nullptr;

	delete Heal;
	Heal = nullptr;

	return 0;
}

4.3.2 游戏对象导出系统

场景: 你有一堆游戏对象(角色, 道具, 关卡), 需要导出为不同格式(JSON, XML, CSV).

代码结构


#pragma once
#include "CoreMinimal.h"


class IExportVisitor;

class IGameElement
{
public:
	virtual void Accept(IExportVisitor* Visitor) = 0;
};

class FCharacter : public ITarget
{
public:
	virtual void Accept(ISkillVisitor* Visitor) override
	{
		Visitor->Visit(this);
	}

public:
	FString Name = TEXT("Hero");
};

class FItem : public ITarget
{
public:
	virtual void Accept(ISkillVisitor* Visitor) override
	{
		Visitor->Visit(this);
	}

public:
	FString ItemName = TEXT("Sword");
};

class IExportVisitor
{
public:
	virtual void Visit(FCharacter* Character) = 0;
	virtual void Visit(FItem* Item) = 0;
};

class FJsonExportVisitor : public IExportVisitor
{
public:
	virtual void Visit(FCharacter* Character) override
	{
		UE_LOG(LogTemp, Log, TEXT("导出角色为JSON: %s"), *(Character->Name));
	}

	virtual void Visit(FItem* Item) override
	{
		UE_LOG(LogTemp, Log, TEXT("导出道具为JSON: %s"), *(Monster->ItemName));
	}
};

class FXMLExportVisitor : public IExportVisitor
{
public:
	virtual void Visit(FCharacter* Character) override
	{
		UE_LOG(LogTemp, Log, TEXT("导出角色为XML: %s"), *(Character->Name));
	}

	virtual void Visit(FItem* Item) override
	{
		UE_LOG(LogTemp, Log, TEXT("导出道具为XML: %s"), *(Monster->ItemName));
	}
};

使用方式


#include "GameElementAndExportVisitor.h"


int main()
{
	TArray Elements = { new FCharacter(), new FItem() };
	IExportVisitor* JsonVisitor = new FJsonExportVisitor();
	IExportVisitor* XMLVisitor = new FXMLExportVisitor();

	for (auto& Element : Elements)
	{
		Element->Accept(JsonVisitor);
		Element->Accept(XMLVisitor);
	}

	for (auto& Element : Elements)
	{
		delete Element;
		Element = nullptr;
	}
	Elements.Empty();

	delete JsonVisitor;
	JsonVisitor = nullptr;

	delete XMLVisitor;
	XMLVisitor = nullptr;

	return 0;
}

4.3.3 总结口诀

双分派, 类型双重判,
访问者链, 批量处理忙.
UE技能, 导出皆可用,
新增操作不动原对象!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注