C# 中的访问者模式 - 5 个版本
描述 C# 中访问者模式的教程文章。
- 下载 ClassicVisitor 源代码 - 26.2 KB
- 下载 DynamicVisitor 源代码 - 18.5 KB
- 下载 ReflectiveVisitor 源代码 - 19.5 KB
- 下载 ReflectiveExtensionVisitor 源代码 - 20 KB
- 下载 GenericsVisitor 源代码 - 19 KB
引言
这是一篇介绍 C# 中访问者模式的教程文章。目标读者是中级及以上水平的 C# 程序员。
访问者模式是 23 种 GoF 模式中最复杂的模式之一。在 C# 中,它有几个版本。这里,我们将以五种版本来描述它
- C# 中的访问者模式 - 版本 1 - 经典访问者
- C# 中的访问者模式 - 版本 2 - 动态访问者
- C# 中的访问者模式 - 版本 3 - 反射访问者
- C# 中的访问者模式 - 版本 4 - 反射扩展访问者
- C# 中的访问者模式 - 版本 5 - 泛型访问者
我们要解决的问题
首先,让我们尝试理解我们试图用这种模式解决什么问题,以及经典面向对象设计的局限性。让我们看一下图 1-1 和代码 1-1 中的经典面向对象设计。
public abstract class Element
{
public int Attribute1 = 0;
public int Attribute2 = 0;
abstract public void V1();
abstract public void V2();
abstract public void V3();
}
public class ElementA : Element
{
public ElementA()
{
}
public int Attribute3 = 0;
public override void V1()
{
}
public override void V2()
{
}
public override void V3()
{
}
}
public class ElementB : Element
{
public ElementB()
{
}
public int Attribute3 = 0;
public override void V1()
{
}
public override void V2()
{
}
public override void V3()
{
}
}
我们在此解决方案中看到的问题,或者更确切地说,局限性是:
- 数据和算法(方法
V1
、V2
等)在此方法中是耦合的。有时尝试将它们分离可能会很有用 - 添加新操作(例如
V4
)并不容易,除非更改现有的类结构。这与开闭原则相悖。如果能够不更改类结构就添加新操作(方法),那将是可取的。 - 在同一位置有不同的方法(例如
V1
和V2
),它们可以处理完全不同且不相关的功能/关注点。例如,V1
可能与生成 .pdf 有关,而V2
可能与生成 html 有关。这与关注点分离原则相悖。
访问者模式
访问者模式通过将数据和操作分离到不同的类中来解决上述问题/限制。数据保存在 Element
/Elements
类中,而操作保存在 Visitor
/Visitors
类中,其中每个特定的 Visitor
可以处理不同的关注点。通过创建新的 Visitor
类,可以轻松实现对 Elements
操作的扩展。
此模式的关键部分是设计解决方案,该解决方案使 Visitor
对象能够对 Element
执行操作。我们说“Visitor
访问 Element
”以对 Element
执行操作。
如果我们查看类图 Picture1-1,我们看到对于对象 ElementA
,我们有方法 V1
,所以操作调用将看起来像这样
ElementA elementa=new ElementA();
elementa.V1();
在访问者模式中,通过方法 V1()
执行的操作将被封装在对象 Visitor1
中,通过方法 V2()
执行的操作将被封装在 对象 Visitor2
中,等等。相同的操作调用现在将看起来像这样
ElementA elementa=new ElementA();
Visitor1 visitor1=new Visitor1();
visitor1.visit(elementa);
情况并未就此结束。问题是我们会有多个 Element
和 Visitor
对象,我们通常通过基类/接口来访问它们。然后,会出现分派适当方法的问题。“分派
”是找出要调用哪个具体方法的问题。
C#,像大多数面向对象语言一样,以虚函数调用的形式支持“单一分派”。这就是所谓的“动态绑定”。根据所讨论的对象的类型,C# 将在运行时动态地从虚方法表中调用适当的虚函数。
但有时,这还不够,需要“多重分派”。多重分派是一个根据多个对象的运行时类型找出要调用哪个具体方法的问题。
C# 中的访问者模式 - 版本 1 - 经典访问者
访问者模式的经典访问者版本在文献中最常见。在经典版本的访问者模式中,该模式基于 C# 的“双重分派”机制。此解决方案中使用的双重分派机制基于 C# 的两个特性
- 根据对象类型动态绑定具体虚方法的能力
- 根据参数类型将重载方法解析为具体方法的能力
这是示例代码的类图
这是它的代码
public abstract class Element
{
public abstract void Accept(IVisitor visitor);
}
public class ElementA : Element
{
public int Id = 0;
public ElementA(int Id)
{
this.Id = Id;
}
public override void Accept(IVisitor visitor) //(2)
{
visitor.Visit(this);
}
}
public class ElementB : Element
{
public int Id = 0;
public ElementB(int Id)
{
this.Id = Id;
}
public override void Accept(IVisitor visitor)
{
visitor.Visit(this);
}
}
public interface IVisitor
{
void Visit(ElementA ElemA);
void Visit(ElementB ElemB);
}
public class Visitor1 : IVisitor //(3)
{
public virtual void Visit(ElementA ElemA) //(4)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public virtual void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
public class Visitor2 : IVisitor
{
public virtual void Visit(ElementA ElemA)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public virtual void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
class Client
{
static void Main(string[] args)
{
//--single element, explicit call-------------------------
ElementA element0 = new ElementA(0);
Visitor1 vis0 = new Visitor1();
vis0.Visit(element0); //(0) works
//--single element, base class call-----------------------
Element element = new ElementA(1);
IVisitor visitor = new Visitor1();
//visitor.Visit(element); //(5) will not compile
element.Accept(visitor); //(1)
//--collection of elements-----------------
List<IVisitor> listVis = new List<IVisitor>();
listVis.Add(new Visitor1());
listVis.Add(new Visitor2());
List<Element> list = new List<Element>();
list.Add(new ElementA(2));
list.Add(new ElementB(3));
list.Add(new ElementA(4));
list.Add(new ElementB(5));
foreach (IVisitor vis in listVis)
foreach (Element elem in list)
{
elem.Accept(vis);
}
Console.ReadLine();
}
}
这是样本执行的结果
请注意,在 (0) 中,当 Visitor
通过显式类调用时,一切正常。我们说“Visitor
访问 Element
”以对 Element
执行操作。
但是,在 (5) 中,当我们尝试通过基类/接口调用 visitor
时,我们无法编译。编译器无法解析要调用哪个方法。这就是为什么我们需要所有这些“双重分派”的魔术来正确解析要调用哪个具体方法。
在 (1) 中,我们有正确的调用。正在发生的是
- 在 (1) 中,我们动态绑定到 (2)
- 在 (2) 中,我们动态绑定到 (3)
- 在 (2) 中,我们重载解析到 (4)
因为在 (2) 中,我们有双重解析,所以它被称为“双重分派”。
此解决方案的局限性。像任何解决方案一样,这也会有一些局限性/不良副作用
- 类层次结构
Elements
和Visitor
之间存在强烈的循环依赖关系。如果需要频繁更新层次结构,这可能是一个问题。 - 请注意,在 (4) 中,为了让
Visitor
访问Element
的数据属性Id
,该属性需要是public
。这在一定程度上违反了封装原则。例如,在我们的第一个解决方案(类图 Picture1-1)中,方法V1()
可以操作类Element
的private
成员。在 C++ 中,这可以通过使用“friend
class”范式来解决,但在 C# 中并非如此。
C# 中的访问者模式 - 版本 2 - 动态访问者
访问者模式的动态访问者版本基于 C# 对动态分派的支持。这是语言动态分派的能力,即在运行时做出具体的调用决策。我们将变量转换为“dynamic
”,这样,将分派决策推迟到运行时。我们再次拥有“双重分派”,因为我们根据两个对象的类型分派到具体方法,只是使用的语言机制不同。
这是示例代码的类图
这是它的代码
public abstract class AElement
{
}
public class ElementA : AElement
{
public int Id = 0;
public ElementA(int Id)
{
this.Id = Id;
}
}
public class ElementB : AElement
{
public int Id = 0;
public ElementB(int Id)
{
this.Id = Id;
}
}
public interface IVisitor
{
void Visit(ElementA ElemA);
void Visit(ElementB ElemB);
}
public class Visitor1 : IVisitor //(2)
{
public virtual void Visit(ElementA ElemA) //(3)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public virtual void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
public class Visitor2 : IVisitor
{
public virtual void Visit(ElementA ElemA)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public virtual void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
class Client
{
static void Main(string[] args)
{
//--single element-------------------------
AElement element = new ElementA(1);
IVisitor visitor = new Visitor1();
visitor.Visit((dynamic)element); //(1)
//--collection of elements-----------------
List<IVisitor> listVis = new List<IVisitor>();
listVis.Add(new Visitor1());
listVis.Add(new Visitor2());
List<AElement> list = new List<AElement>();
list.Add(new ElementA(2));
list.Add(new ElementB(3));
list.Add(new ElementA(4));
list.Add(new ElementB(5));
foreach (IVisitor vis in listVis)
foreach (AElement elem in list)
{
vis.Visit((dynamic)elem);
}
Console.ReadLine();
}
}
这是样本执行的结果
在 (1) 中,我们有一个新的调用。由于动态对象的工作方式,解析被推迟到运行时。然后,我们根据 Visitor
的类型进行第一次动态绑定到 (2),然后根据在运行时动态发现的 Element
类型进行动态解析到 (3)。
此解决方案的局限性
像任何解决方案一样,这也有一些局限性/不良副作用
- 类层次结构
Elements
和Visitor
之间存在强烈的循环依赖关系。如果需要频繁更新层次结构,这可能是一个问题。 - 请注意,为了让
Visitor
访问Element
的数据属性 Id,该属性需要是public
。这在一定程度上违反了封装原则。例如,在我们的第一个解决方案(类图 Picture1-1)中,方法V1()
可以操作类Element
的private
成员。 - 使用“
dynamic
”对象会带来性能影响。
C# 中的访问者模式 - 版本 3 - 反射访问者
访问者模式的反射访问者版本基于 C# 反射技术的使用,以在运行时发现对象类型并根据发现的类型执行显式方法分派。我们再次拥有“双重分派”,因为我们根据两个对象的类型分派到具体方法,只是使用的语言机制不同。
这是示例代码的类图
这是它的代码
public abstract class AElement
{
}
public class ElementA : AElement
{
public int Id = 0;
public ElementA(int Id)
{
this.Id = Id;
}
}
public class ElementB : AElement
{
public int Id = 0;
public ElementB(int Id)
{
this.Id = Id;
}
}
public abstract class AVisitor
{
public void Visit(AElement Elem) //(2)
{
if (Elem is ElementA)
{
Visit((ElementA)Elem);
};
if (Elem is ElementB)
{
Visit((ElementB)Elem);
};
}
public abstract void Visit(ElementA ElemA);
public abstract void Visit(ElementB ElemB);
}
public class Visitor1 : AVisitor
{
public override void Visit(ElementA ElemA) //(3)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public override void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
public class Visitor2 : AVisitor
{
public override void Visit(ElementA ElemA)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public override void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
class Client
{
static void Main(string[] args)
{
//--single element-------------------------
AElement element = new ElementA(1);
AVisitor visitor = new Visitor1();
visitor.Visit(element); //(1)
//--collection of elements-----------------
List<AVisitor> listVis = new List<AVisitor>();
listVis.Add(new Visitor1());
listVis.Add(new Visitor2());
List<AElement> list = new List<AElement>();
list.Add(new ElementA(2));
list.Add(new ElementB(3));
list.Add(new ElementA(4));
list.Add(new ElementB(5));
foreach (AVisitor vis in listVis)
foreach (AElement elem in list)
{
vis.Visit(elem);
}
Console.ReadLine();
}
}
这是样本执行的结果
在 (1) 中,我们有一个新的调用。即使在编译时,它也被解析为方法 (2)。然后在运行时,使用反射,解析参数类型并将调用传递给 (3)。
此解决方案的局限性
像任何解决方案一样,这也有一些局限性/不良副作用
- 类层次结构
Elements
和Visitor
之间存在强烈的循环依赖关系。如果需要频繁更新层次结构,这可能是一个问题。 - 请注意,为了让
Visitor
访问Element
的数据属性Id
,该属性需要是public
。这在一定程度上违反了封装原则。例如,在我们的第一个解决方案(类图 Picture1-1)中,方法V1()
可以操作类Element
的private
成员。 - 请注意,在 (2) 中,每个继承自
AElement
的类都被明确提及并检查类型。缺少某些类型可能会给实现带来问题。一个可能的解决方案是使用反射发现程序集中的所有类型,并自动分派到所有继承自AElement
的类。但是,我们不打算在这里这样做。
C# 中的访问者模式 - 版本 4 - 反射扩展访问者
访问者模式的反射扩展访问者版本基于:1) 使用 C# 反射技术在运行时发现对象类型并根据发现的类型执行显式方法分派;2) 使用扩展方法。此版本与“反射访问者”版本非常相似,但由于在其他文献中提到过,我们也将其作为单独的变体在此列出。我们再次拥有“双重分派”,因为我们根据两个对象的类型分派到具体方法,只是使用的语言机制不同。
这是示例代码的类图
这是它的代码
public abstract class AElement
{
}
public class ElementA : AElement
{
public int Id = 0;
public ElementA(int Id)
{
this.Id = Id;
}
}
public class ElementB : AElement
{
public int Id = 0;
public ElementB(int Id)
{
this.Id = Id;
}
}
public abstract class AVisitor
{
public abstract void Visit(ElementA ElemA);
public abstract void Visit(ElementB ElemB);
}
public static class AVisitorExtensions
{
public static void Visit<T>(this T vis, AElement Elem)
where T : AVisitor //(2)
{
if (Elem is ElementA)
{
vis.Visit((ElementA)Elem); //(3)
};
if (Elem is ElementB)
{
vis.Visit((ElementB)Elem);
};
}
}
public class Visitor1 : AVisitor
{
public override void Visit(ElementA ElemA) //(4)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public override void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
public class Visitor2 : AVisitor
{
public override void Visit(ElementA ElemA)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public override void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
class Client
{
static void Main(string[] args)
{
//--single element-------------------------
AElement element = new ElementA(1);
AVisitor visitor = new Visitor1();
visitor.Visit(element); //(1)
//--collection of elements-----------------
List<AVisitor> listVis = new List<AVisitor>();
listVis.Add(new Visitor1());
listVis.Add(new Visitor2());
List<AElement> list = new List<AElement>();
list.Add(new ElementA(2));
list.Add(new ElementB(3));
list.Add(new ElementA(4));
list.Add(new ElementB(5));
foreach (AVisitor vis in listVis)
foreach (AElement elem in list)
{
vis.Visit(elem);
}
Console.ReadLine();
}
}
这是样本执行的结果
在 (1) 中,我们有一个新的调用。即使在编译时,它也被解析为方法 (2)。然后在运行时,使用反射,在 (3) 中解析参数类型并将调用传递给 (4)。
此解决方案的局限性
像任何解决方案一样,这也有一些局限性/不良副作用
- 类层次结构
Elements
和Visitor
之间存在强烈的循环依赖关系。如果需要频繁更新层次结构,这可能是一个问题。 - 请注意,为了让
Visitor
访问Element
的数据属性Id
,该属性需要是public
。这在一定程度上违反了封装原则。例如,在我们的第一个解决方案(类图 Picture1-1)中,方法V1()
可以操作类Element
的private
成员。 - 请注意,在 (2) 中,每个继承自
AElement
的类都被明确提及并检查类型。缺少某些类型可能会给实现带来问题。一个可能的解决方案是使用反射发现程序集中的所有类型,并自动分派到所有继承自AElement
的类。但是,我们不打算在这里这样做。
C# 中的访问者模式 - 版本 5 - 泛型访问者
访问者模式的泛型访问者版本类似于反射访问者模式,因为它依赖于 1) 反射来在运行时动态发现类型;2) C# 泛型来指定 Visitor
实现的接口。我们再次拥有“双重分派”,因为我们根据两个对象的类型分派到具体方法,只是使用的语言机制不同。
这是示例代码的类图
这是它的代码
public abstract class Element
{
public abstract void Accept(IVisitor visitor);
}
public class ElementA : Element
{
public int Id = 0;
public ElementA(int Id)
{
this.Id = Id;
}
public override void Accept(IVisitor visitor) //(2)
{
if (visitor is IVisitor<ElementA>)
{
((IVisitor<ElementA>)visitor).Visit(this);
}
}
}
public class ElementB : Element
{
public int Id = 0;
public ElementB(int Id)
{
this.Id = Id;
}
public override void Accept(IVisitor visitor)
{
if (visitor is IVisitor<ElementB>)
{
((IVisitor<ElementB>)visitor).Visit(this);
}
}
}
public interface IVisitor { }; // marker interface
public interface IVisitor<TVisitable>
{
void Visit(TVisitable obj);
}
public class Visitor1 : IVisitor,
IVisitor<ElementA>, IVisitor<ElementB>
{
public void Visit(ElementA ElemA) //(3)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
public class Visitor2 : IVisitor,
IVisitor<ElementA>, IVisitor<ElementB>
{
public void Visit(ElementA ElemA)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemA.GetType().Name, ElemA.Id, this.GetType().Name);
}
public void Visit(ElementB ElemB)
{
Console.WriteLine("{0} with Id={1} visited by {2}",
ElemB.GetType().Name, ElemB.Id, this.GetType().Name);
}
}
class Client
{
static void Main(string[] args)
{
//--single element, base class call-----------------------
Element element = new ElementA(1);
IVisitor visitor = new Visitor1();
element.Accept(visitor); //(1)
//--collection of elements-----------------
List<IVisitor> listVis = new List<IVisitor>();
listVis.Add(new Visitor1());
listVis.Add(new Visitor2());
List<Element> list = new List<Element>();
list.Add(new ElementA(2));
list.Add(new ElementB(3));
list.Add(new ElementA(4));
list.Add(new ElementB(5));
foreach (IVisitor vis in listVis)
foreach (Element elem in list)
{
elem.Accept(vis);
}
Console.ReadLine();
}
}
这是样本执行的结果
在 (1) 中,我们有一个新的调用。在运行时,它动态绑定到 (2)。然后在 (2) 中,我们使用反射将其显式解析为 (3)。
此解决方案的局限性
像任何解决方案一样,这也有一些局限性/不良副作用
- 请注意,为了让
Visitor
访问Element
的数据属性Id
,该属性需要是public
。这在一定程度上违反了封装原则。例如,在我们的第一个解决方案(类图 Picture1-1)中,方法V1()
可以操作类Element
的private
成员。
结论
首先,我们讨论了我们的动机和我们试图解决的问题。我们试图实现什么很重要,因为解决问题可能不止一种方法。
然后我们展示了“经典访问者”,这是 GoF 提出的版本,经常在文献中提及。我认为,由于当时创建它的语言(C++、Smalltalk)的局限性,它被认为是唯一和最终的解决方案。
现代面向对象语言,如 C#,具有“动态对象”和“反射”等新功能,使我们能够以不同的方式实现相同的目标。这在访问者模式的其他四个版本中有所展示。如果您愿意,可以将它们视为“启用现代 C#”的访问者模式的替代版本。
历史
- 2022 年 2 月 28 日:初始版本