Visitor 是面向对象设计模式中一个很重要的设计模式,这个模式是一种将数据操作和数据结构分离的一种方法,它能够在不修改结构的情况下向现有对象结构添加新操作,是遵循开放/封闭原则的一种方法。上述这个定义很枯燥抽象,那就以例子来说明吧。

    假设我们要打印四则运算表达式,比如(1.0+(2.0+3))的字符串表示,或者对其求值,这是两个需求,一个是打印,一个是求值。为了简化,这里仅对加号括号进行处理,其他的可以类推。在写任何代码之前,一定要考虑一下是否面向对象,针对上面的字符串,可以将数字和操作符抽象为两个对象,并实现同一抽象类Expression。

public abstract class Expression
{

}

    目前上述Expression抽象类没有任何成员或方法,后续我们会添加。接着添加表示doule类型数字的具体类DoubleValueExpression,和表示加号的AddOperationExpression两个具体类。

public class DoubleValueExpression : Expression
{
    private double value;
    public DoubleValueExpression(double d)
    {
        value = d;
    }
}

public class AddOperationExpression : Expression
{
    private Expression left, right;

    public AddOperationExpression(Expression left, Expression right)
    {
        this.left = left ?? throw new ArgumentNullException(nameof(left));
        this.right = right ?? throw new ArgumentNullException(nameof(right));
    }
}

    针对以上抽象,现在需要实现两个功能:

  • 打印表达式字符串
  • 表达式字符串求值

    这里只是展示了简单的一个double类型的,只带有加号的表达式树,后续当然可以扩充更多符号和类型。在上面的这个简单抽象基础上,如何实现这两个目的,下面一步一步来看。

不好的访问器实现


    首先,最简单和直接的实现就是在抽象类中添加需要的操作。这里以打印表达式为例子,求值类似。在Expression抽象类中,添加抽象方法Print,如下

public abstract class Expression
{
    public abstract void Print(StringBuilder sb);
}

    因为抽象类Expression中添加了Print抽象方法,所以,所有的子类必须实现Print抽象方法。

    DoubleValueExpression的实现如下,很简单,就是把当前的值添加到StringBuilder中。

public class DoubleValueExpression : Expression
{
    private double value;
    public DoubleValueExpression(double d)
    {
        value = d;
    }

    public override void Print(StringBuilder sb)
    {
        sb.Append(value);
    }
}

    AddOperationExpression的实现如下,简单来说,就是把左右表达式的值,使用字符串“(左边值+右边值)” 拼接起来,也比较简单:

public class AddOperationExpression : Expression
{
    private Expression left, right;

    public AddOperationExpression(Expression left, Expression right)
    {
        this.left = left ?? throw new ArgumentNullException(nameof(left));
        this.right = right ?? throw new ArgumentNullException(nameof(right));
    }

    public override void Print(StringBuilder sb)
    {
        sb.Append("(");
        left.Print(sb);
        sb.Append("+");
        right.Print(sb);
        sb.Append(")");
    }
}

  现在,对于“(1.0+(2.0+3.0))”这个表达式,我们可以写为:

var expression = new AddOperationExpression(
left: new DoubleValueExpression(1.0),
right: new AddOperationExpression(
    left: new DoubleValueExpression(2.0),
    right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
expression.Print(sb);
Console.WriteLine(sb.ToString());

    输出结果为:

(1+(2+3))

    非常简单,但是假设我们有十几种表达式,加减乘除,或者十几种数值类型,再或者需要增加求值方法Eval(),这个改动就非常大了,需要在顶层的抽象类Expression中添加新的抽象方法,然后修改所有的各种实现子类,这明显违背了开放封闭原则OCP,但这还不是主要的。

    最主要的问题是,上面的代码违反了单一职责原则SRP,因为诸如打印字符串Print,字符串求职Eval这些都是我们额外的其他需求,不应该把这些功能放到表达式的抽象中。要打印字符串,为什么不新增一个ExpressionPrint类,来专门负责字符串打印;要对字符串求值,为什么不单独新增一个ExpressionEvaluator类来专门负责求值,这显然是更好的方法。

重构一


    因为我们需要将Print功能放到单独的类中,所以需要将Print方法充Expression抽象类中移除,但仍将Expression作为基类。

public abstract class Expression
{

}

public class AddOperationExpression : Expression
{
    public Expression left, right;

    public AddOperationExpression(Expression left, Expression right)
    {
        this.left = left ?? throw new ArgumentNullException(nameof(left));
        this.right = right ?? throw new ArgumentNullException(nameof(right));
    }
}

public class DoubleValueExpression : Expression
{
    private double value;
    public DoubleValueExpression(double d)
    {
        value = d;
    }
}

    然后先建ExpressionPrinter类:

public static class ExpressionPrinter
{
    public static void Print(DoubleValueExpression e, StringBuilder sb)
    {
        sb.Append(e);
    }

    public static void Print(AddOperationExpression e, StringBuilder sb)
    {
        sb.Append("(");
        Print(e.left, sb);//编译不过
        sb.Append("+");
        Print(e.right, sb);//编译不过
        sb.Append(")");
    }
}

    可以看到,这样就可以在不修改原先的逻辑,通过新类的方式来实现新的功能,完美的满足了开闭原则和单一职责原则。在上述的第二个Print重载方法中,试图调用第一个重载方法,但遗憾的是,第一个Print的第一个参数是DoubleValueExpression,而e.Left和e.Right的类型却是Expression,所以这里是编译不通过的。所以这里只能将参数改为Expression抽象类,然后在运行时动态判断并转换为具体类型,再具体的操作:

public static void Print(Expression e, StringBuilder sb)
{
    if (e is DoubleValueExpression de)
    {
        sb.Append(de.value);
    }
    else if (e is AddOperationExpression ae)
    {
        sb.Append("(");
        Print(ae.left, sb);
        sb.Append("+");
        Print(ae.right, sb);
        sb.Append(")");
    }
}

    现在使用方法如下:

var expression = new AddOperationExpression(
    left: new DoubleValueExpression(1.0),
    right: new AddOperationExpression(
        left: new DoubleValueExpression(2.0),
        right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
ExpressionPrinter.Print(expression, sb);
Console.WriteLine(sb.ToString());

    输出结果跟之前一样。

    这种方式的缺点也很明显,因为是在运行时动态检查类型,所以缺少编译时检查。另外当有新的表达式加入时,需要增加判断逻辑,需要增加额外的if else,否则这些新加的表达式类型就会被忽略。如果不太讲究,到此为止就可以了。但这里面仍然不完美,问题显而易见,就是if else,这个地方就是典型的“bad smell”。针对这一问题,可以做一些改进。

重构二


    我们可以事先将表达式的类型,及其对应的处理先明确下来,比如,可以定义如下结构。

private static Dictionary<Type, Action<Expression, StringBuilder>> DictType =
    new Dictionary<Type, Action<Expression, StringBuilder>>
    {
        [typeof(DoubleValueExpression)] = (e, sb) =>
        {
            DoubleValueExpression de = (DoubleValueExpression)e;
            sb.Append(de.value);
        },
        [typeof(AddOperationExpression)] = (e, sb) =>
        {
            AddOperationExpression aoe = (AddOperationExpression)e;
            sb.Append("(");
            Print(aoe.left, sb);
            sb.Append("+");
            Print(aoe.right, sb);
            sb.Append(")");
        }
    };

    这是个静态字典,key为表达式类型,value为一个Action,Action参数为Expression表达式和传进来的StringBuilder,有了这个类型->行为定义表,则Print方法可以简化为如下,我们将其命名为Print2:

public static void Print2(Expression e, StringBuilder sb)
{
    DictType[e.GetType()](e, sb);
}

     非常的简洁明了,也移除了if else的“bed smell”。在这里,甚至还可以将Print2改为扩展方法,将第一个参数用this标明,如下:

public static void Print2(this Expression e, StringBuilder sb)
{
    DictType[e.GetType()](e, sb);
}

    修改之后,用法为:

var expression = new AddOperationExpression(
    left: new DoubleValueExpression(1.0),
    right: new AddOperationExpression(
        left: new DoubleValueExpression(2.0),
        right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
expression.Print2(sb);

    非常简洁优雅。如果后面要实现一个求值的方法,那么添加一个ExpressionEvaluator类,并实现Eval扩展方法,用起来就像expression.Print2(sb)这样,直接使用expression.Eval()即可。

动态分发


    动态分发(Dynamic dispatch)的含义就是,将方法具体调用那个类型放到运行时,而不是在编译时调用,回到我们上次的那个编译不通过的例子中:

public static class ExpressionPrinter
{
    public static void Print(DoubleValueExpression e, StringBuilder sb)
    {
        sb.Append(e);
    }

    public static void Print(AddOperationExpression e, StringBuilder sb)
    {
        sb.Append("(");
        Print(e.left, sb);//编译不过
        sb.Append("+");
        Print(e.right, sb);//编译不过
        sb.Append(")");
    }
}

    这里有两个地方是编译不通过的,但是,如果我们将其强制转换为动态类型dynamic,则就可以。

public class ExpressionPrinter
{
    public void Print(DoubleValueExpression e, StringBuilder sb)
    {
        sb.Append(e.value);
    }

    public void Print(AddOperationExpression e, StringBuilder sb)
    {
        sb.Append("(");
        Print((dynamic)e.left, sb);
        sb.Append("+");
        Print((dynamic)e.right, sb);
        sb.Append(")");
    }
}

    这个就是动态分发,现在可以直接编译通过,使用方法如下:

var expression = new AddOperationExpression(
    left: new DoubleValueExpression(1.0),
    right: new AddOperationExpression(
        left: new DoubleValueExpression(2.0),
        right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
ExpressionPrinter ep = new ExpressionPrinter();
ep.Print(expression, sb);
Console.WriteLine(sb.ToString());

    输出结果也想同。但是这种方法有严重的性能问题,另外,如果缺少一些重载方法,运行时会报错,这种出错在编译时发现不了。

经典的访问器实现


    访问设计模式最经典的实现为“双分发”(Double dispatch),实现访问模式的时候通常会遵循一定的命名规则:

  • 访问者的方法通常命名为Visit().
  • 具体的实现这方法命名为Accept().

    现在,我们对上述的例子进行改造,首先在Expression抽象类中,增加Accept方法:

public abstract class Expression
{
    public abstract void Accept(IExpressionVisitor visitor);
}

    这里的IExpressionVisitor接口,可以用来让特殊的访问器,比如ExpressionPrinter,ExpressionEvaluator来实现。

    每个实现该抽象类Expression的子类中,重写Accept的方法的实现如下,这里以AddOperationExpression为例,DoubleValueExpression的实现跟这一模一样:

public class AddOperationExpression : Expression
{
    public Expression left, right;

    public AddOperationExpression(Expression left, Expression right)
    {
        this.left = left ?? throw new ArgumentNullException(nameof(left));
        this.right = right ?? throw new ArgumentNullException(nameof(right));
    }

    public override void Accept(IExpressionVisitor visitor)
    {
        visitor.Visit(this);
    }
}

     特别需要注意Accept方法里的实现visitor.Visit(this)里面的this,因为这个this代表的是每一个具体实现了Expression抽象类型的具体子类,他在运行时是动态判断的。

     IExpressionVisior的接口可以定义如下,接口里面定义了Visit方法的所有类型重载:

public interface IExpressionVisitor
{
    void Visit(DoubleValueExpression de);
    void Visit(AddOperationExpression ae);
}

     现在,具体的功能访问器只需要实现IExpressionVisitor。

public class ExpressionPrinter : IExpressionVisitor
{
    StringBuilder sb = new StringBuilder();
    public void Visit(DoubleValueExpression de)
    {
        sb.Append(de.value);
    }

    public void Visit(AddOperationExpression ae)
    {
       //TODO
    }

    public override string ToString()
    {
        return sb.ToString();
    }
}

    这里,需要特别注意的是Visit(AddOperationExpression ae)这里的实现,其实现如下:

public void Visit(AddOperationExpression ae)
{
    sb.Append("(");
    ae.left.Accept(this);
    sb.Append("+");
    ae.right.Accept(this);
    sb.Append(")");
}

    可以看到,这里调用了ae.left.Accept(this)和ae.right.Accept(this),方法,非常巧妙。ae.left和ae.right定义的时候都是Expression类型,当调用Accept方法时,实际上是调用的本身visit.Vistor(this)方法,这个this,就是运行时实际的类型DoubleValueExpression的Visit方法,也就是第一个重载方法。简要来说,就是将DoubleValueExpression.Accept(this),转换为了ExpressionPrinter.Visit(DoubleValueExpression de)重载方法,非常巧妙的做法。

   现在,整个用法如下:

var expression = new AddOperationExpression(
    left: new DoubleValueExpression(1.0),
    right: new AddOperationExpression(
        left: new DoubleValueExpression(2.0),
        right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
ExpressionPrinter ep = new ExpressionPrinter();
ep.Visit(expression);
Console.WriteLine(ep.ToString());

    这里,直接调用ExpressionPrinter的Visit方法,输出结果跟之前类似。这种实现无法直接使用扩展方法,且对外暴露了Visit方法。但可以使用下面的扩展方法将ExpressionPrinter包装一下:

public static class Extionsion
{
    public static void Print(this DoubleValueExpression e, StringBuilder sb)
    {
        var ep = new ExpressionPrinter();
        ep.Visit(e);
        sb.Append(ep.ToString());
    }

    public static void Print(this AddOperationExpression e, StringBuilder sb)
    {
        var ep = new ExpressionPrinter();
        ep.Visit(e);
        sb.Append(ep.ToString());
    }
}

     现在上述用法可以简化为:

var expression = new AddOperationExpression(
    left: new DoubleValueExpression(1.0),
    right: new AddOperationExpression(
        left: new DoubleValueExpression(2.0),
        right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
expression.Print(sb);
Console.WriteLine(sb);

     输出结果相同。

扩展访问器


    上述这种双分发的设计,非常容易扩展,我们只需要实现IExpressionVisitor接口就可以扩展,而不需要去访问每一个层级类型。比如假如现在实现一个表达式求值的功能:ExpressionCalculator,就很简单。

public class ExpressionCalculator : IExpressionVisitor
{
    public double Result { get; private set; }

    public void Visit(DoubleValueExpression de)
    {
        Result = de.value;
    }

    public void Visit(AddOperationExpression ae)
    {
         //TODO
    }
}

     因为Visit返回的是Void,所以这里要特别注意下具体的实现。

public void Visit(AddOperationExpression ae)
{
    ae.left.Accept(this);
    var a = Result;
    ae.right.Accept(this);
    var b = Result;
    Result = a + b;
}

    因为在第一个重载里面,是直接将de.Value赋值给Result的,所以第二个重载里面,每次都需要保存上一次的值,略微有些繁琐,这里可以改进一下,将第一个重载里面的值改为累加,改进后的实现如下:

public class ExpressionCalculator : IExpressionVisitor
{
    public double Result { get; private set; }

    public void Visit(DoubleValueExpression de)
    {
        Result += de.value;
    }

    public void Visit(AddOperationExpression ae)
    {
        ae.left.Accept(this);
        ae.right.Accept(this);
    }
}

    现在,计算表达式功能的用法如下:

var expression = new AddOperationExpression(
left: new DoubleValueExpression(1.0),
right: new AddOperationExpression(
left: new DoubleValueExpression(2.0),
right: new DoubleValueExpression(3.0)));
 
ExpressionCalculator ec = new ExpressionCalculator();
ec.Visit(expression);
Console.WriteLine(ec.Result);

    输出结果为6。

    双分发的这种访问模式实现的优点在于能够非常容易扩展新功能,即使我们没有原始类型的层次结构的源代码,只需要新增代码实现定义好的接口,即可扩展新的功能,完美的满足了OOP里面的单一职责原则和开放封闭原则,另外,如果理解了方法里的this,代码也非常容易理解。

Acyclic 访问器


    前面提到的这种是经典的访问器模式,访问器模式的实现分为两大类:

  • Cyclic Visitor,就是前面提到的这种经典的访问器模式,他是基于函数的重载。这种模式的实现,必须要知道整个访问模式中的所有子类型,就像IExpressionVisitor接口里所述的,必须定义所有类型重载的Visitor方法。所以对于稳定的结构以及不怎么变动频繁的,可以使用这种模式。
  • Acyclic  Visitor,这种实现基于类型强制转换,他的优点是不需要实现了解访问器模式中的所有类型层级,但问题是,因为存在类型转换,所以可能有性能损失。

    这里还是以前面的例子来说明如何实现Acyclic访问器模式。首先需要定义Visitor接口,与Cyclic里面定义的IExpressionVisor接口,并且列出所有的类型重载方法不同,这里用泛型来表示。

public interface IVisitor<TVisitable>
{
    void Visit(TVisitable obj);
}

    这使得,在访问器模型中,定义的所有类型都能够实现这一接口。同时,我们还需要定义一个非泛型的IVisitor接口,里面不包含任何实现。

public interface IVisitor
{
}

    现在,我们定义一个抽象类Expression,其抽象方法Accept以IVisitor非抽象类型作为参数。

public abstract class Expression
{
    public virtual void Accept(IVisitor visitor)
    {
        if (visitor is IVisitor<Expression> typed)
        {
            typed.Visit(this);
        }
    }
}

     在Accept方法实现里,我们试图将IVisitor转换为具体的泛型IVisitor<Expression>类型,就是只要实现了Expression泛型类型的IVisitor接口,就能强制转换到具体泛型类型上,然后再调用自己本身的Visit方法。如果转换失败,则什么都不做。这里需要特别理解的是IVisitor接口为什么没有任何Visit方法,因为如果有的话,就需要列出所有的类型重载方法。这就跟前一节的Cyclic访问器一样了。

     定义好了抽象的Expression之后,对于我们的访问器,比如ExpressionPrint,只需要实现一系列泛型类型接口即可。

public class ExpressionPrinter : IVisitor, 
                                 IVisitor<Expression>, 
                                 IVisitor<DoubleValueExpression>,
                                 IVisitor<AddOperationExpression>
{
    StringBuilder sb = new StringBuilder();
    public void Visit(Expression obj)
    {
        //默认实现
    }

    public void Visit(DoubleValueExpression obj)
    {
        sb.Append(obj.value);
    }

    public void Visit(AddOperationExpression obj)
    {
        sb.Append("(");
        obj.Left.Accept(this);
        sb.Append("+");
        obj.Right.Accept(this);
        sb.Append(")");
    }

    public override string ToString()
    {
        return sb.ToString();
    }
}

     可以看到,通过实现具体的泛型接口IVisitor<Expression>,达到了Cyclic模式里,实现单个Visitor接口里,列出所有的重载类型方法的效果。如果这里漏掉了实现某个泛型接口比如  IVisitor<DoubleValueExpression>,程序也能编译通过,只是遇到该类型表达式时,找不到对应的接口实现,就什么也不做,就不会调用对应的Visit方法。这里的实现主体跟Cyclic的实现基本一样。运行结果也是一样。

    另外一个ExpressionCalculator的实现类似,如下:

public class ExpressionCalculator : IVisitor, 
                                    IVisitor<Expression>, 
                                    IVisitor<DoubleValueExpression>,
                                    IVisitor<AddOperationExpression>
{
    public double result = 0;
    public void Visit(Expression obj)
    {
        //默认实现
    }

    public void Visit(DoubleValueExpression obj)
    {
        result += obj.value;
    }

    public void Visit(AddOperationExpression obj)
    {
        obj.Left.Accept(this);
        obj.Right.Accept(this);
    }

    public override string ToString()
    {
        return result.ToString();
    }
}

       但是可以看到,这里的Acyclic访问器实现,跟之前的经典的Cyclic访问器模式是有本质区别的。经典的访问器模式使用接口,而这里的Acyclic访问器模式使用抽象类作为这个结构的根节点。抽象类可以有自己的默认实现,在上述的ExpressionPrint和ExpressionCalculator中,除了实现具体的泛型类型IVisitor<Expression>之外,还可以实现Visit(Expression obj)方法。这个方法可以用来处理没有匹配到重载方法的Visitor,比如可以在这里记录日志,抛出异常等。在上面的例子中,我们可以不实现  IVisitor<DoubleValueExpression>,那么就会跳到Visit(Expression obj)方法里,只需要做一点修改,修改如下:

public class DoubleValueExpression : Expression
{
    public double value;

    public DoubleValueExpression(double value)
    {
        this.value = value;
    }

    public override void Accept(IVisitor visitor)
    {
        if (visitor is IVisitor<DoubleValueExpression> v)
        {
            v.Visit(this);
        }
        else
        {
            base.Accept(visitor);
        }
    }
}

    这里,如果Accept的visitor参数无法转换为IVisitor<DoubleValueExpression>类型,表示具体的类型比如ExpressionPrinter并没有实现该泛型类型,那么我们调用基类Expression里面的Accept方法。ExpressionPrinter的修改如下,这里假设忘记实现了IVisitor<DoubleValueExpression>泛型接口,并实现了基础的IVisitor<Expression>接口:

public class ExpressionPrinter : IVisitor,
    IVisitor<Expression>,
    IVisitor<AddOperationExpression>
{
    StringBuilder sb = new StringBuilder();
    public void Visit(Expression obj)
    {
         sb.Append("<missing???>");
    }

    public void Visit(AddOperationExpression obj)
    {
        sb.Append("(");
        obj.Left.Accept(this);
        sb.Append("+");
        obj.Right.Accept(this);
        sb.Append(")");
    }

    public override string ToString()
    {
        return sb.ToString();
    }
}

     现在运行一下程序:

var expression = new AddOperationExpression(
    left: new DoubleValueExpression(1.0),
    right: new AddOperationExpression(
        left: new DoubleValueExpression(2.0),
        right: new DoubleValueExpression(3.0)));

var sb = new StringBuilder();
ExpressionPrinter ep = new ExpressionPrinter();
ep.Visit(expression);
Console.WriteLine(ep.ToString());

    输出结果如下:

(<missing???>+(<missing???>+<missing???>))

    因为我们这里没有实现对DoubleValue类型的解释,所以调用了默认的方法,在这里填充了特殊字符,所以输出结果如上所示,非常意思。

总结


    Visitor 访问器模式是一种将数据操作和数据结构分离的一种方法,它能够在不修改结构的情况下向现有对象结构添加新操作。正如其名字所说,当我们要访问系统中存在等级关系的一系列对象时,能够添加新的访问功能,而不需要修改原来的数据结构。在前面的几部分中,讲述了访问器模式的几种实现。

  • 最开始介绍了不好的访问器实现,它将所有的功能,比如打印Print、求值Eval统统放在了基类中,所有的子类必须要实现。在能访问到源代码系统的情况下,这种实现可以,但是违背了开放封闭和单一职责原则。要添加新的功能,必须修改基类,修改基类会导致所有的子类必须修改;新加的功能应该放到单独的类中,而不应该放在原有的类中。
  • 在之前的基础上重构,将基类中的所有实现都移除,只保留了一个空的基类,然后在运行时使用is或者as动态的判断类型,再调用其具体实现,这种运行时动态分发的方式缺少编译时保护。另外,这其中需要用到if else这种语句判断不同的子类型,繁琐且不够优雅,是典型的的“bed smell”。在接下来对其进行了重构,通过将类型及其对应的操作方法预先定义到字典里,然后在运行时动态根据当前类型找到对应的执行方法,这种虽然移除了if else这种判断,但仍然是在运行时动态分发。
  • Dynamic实现,在之前的基础上,移除了is as这种运行时判断和强制转换,使用了C#里面的动态语言特征,强迫程序在运行时动态获取实际类型。实现起来简单,只需要将类型强制转换为dynamic即可。但是这种方式对性能存在影响。
  • 经典的Cyclic访问器模式,也就是双分发的模式。整个结构重新进行了组织,定义了一个抽象基类,只包含约定俗成的Accept抽象方法,该抽象方法只有一个参数,参数类型为一个接口。这个接口里定义了所有的子类型的名为Visit的重载方法。所有的子类型实现这一接口,在内部调用自己Visit(this),所有的功能访问器,只需要实现接口即可。这种完美的解决了之前存在的破坏单一职责和开闭原则的问题。
  • Acyclic访问器模式,这一模式使用泛型接口的方式来在运行时动态分发。比经典的访问器模式灵活,但是破坏了访问器和访问者之间的环形联系。

    访问器模式在对所有元素进行访问,比如在文本解析时用的很多。比如我们要以特定的方式来解析抽象的语法树,使用访问者模式就很方便,我们只需要添加新的功能点和实现就行,不需要破坏原有的数据结构和表示。

 

参考


  1. https://www.jianshu.com/p/1f1049d0a0f4
  2. https://github.com/Apress/design-patterns-in-.net/tree/master/Behavioral/Visitor