-NET-Core3-设计模式教程-五-

108 阅读36分钟

.NET Core3 设计模式教程(五)

原文:Design Patterns in .NET Core 3

协议:CC BY-NC-SA 4.0

二十一、观察者

观察者

简单地说,观察者模式让一个组件通知其他组件发生了什么。这种模式到处都在使用:例如,当将数据绑定到 UI 时,我们可以对域对象进行编程,这样当它们发生变化时,它们会生成通知,UI 可以订阅并更新视觉效果。

Observer 模式是一种流行且必要的模式,所以 C# 的设计者决定通过使用event关键字将该模式大规模整合到语言中也就不足为奇了。C# 中事件的使用通常使用一个约定,该约定要求:

  • 事件可以是类的成员,并用 event 关键字修饰。

  • 事件处理程序——每当事件发生时调用的方法——用+=操作符附加到事件上,用-=操作符分离。

  • 事件处理程序通常有两个参数:

    • 关于到底是谁引发了这一事件的引用

    • 一个(通常)从EventArgs派生的对象,包含关于事件的任何必要信息

所使用的事件的确切类型通常是委托。就像 lambdas 的Action/Func包装器一样,事件的委托包装器被称为EventHandler,存在于非泛型(采用一个EventArgs)和泛型(采用一个从EventArgs派生的类型参数)的第二个参数中。第一个论点总是一个object

这里有一个微不足道的例子:假设,每当一个人生病,我们叫医生。首先,我们定义事件自变量;在我们的案例中,我们只需要发送医生的地址:

public class FallsIllEventArgs : EventArgs
{
  public string Address;
}

现在,我们可以实现一个Person类型,如下所示:

public class Person
{
  public void CatchACold()
  {
    FallsIll?.Invoke(this,
      new FallsIllEventArgs { Address = "123 London Road" });
  }

  public event EventHandler<FallsIllEventArgs> FallsIll;
}

如您所见,我们使用强类型的EventHandler委托来公开公共事件。使用CatchACold()方法引发事件,安全访问?。操作符用于确保如果事件没有任何订阅者,我们不会得到一个NullReferenceException

剩下的工作就是建立一个场景并提供一个事件处理程序:

static void Main()
{
  var person = new Person();
  person.FallsIll += CallDoctor;
  person.CatchACold();
}

private static void CallDoctor(object sender, FallsIllEventArgs eventArgs)
{
  Console.WriteLine($"A doctor has been called to {eventArgs.Address}");
}

事件处理程序可以是一个普通的(成员)方法、一个局部函数或者一个 lambda——由你选择。签名由原始代表授权;因为我们使用的是强类型的EventHandler变量,所以第二个参数是FallsIllEventArgs。一旦CatchACold()被调用,就会触发CallDoctor()方法。

任何给定的事件都可以有多个处理程序(毕竟 C# 委托是多播的)。事件处理程序的移除通常是通过-=操作符来完成的。当所有订阅者都取消订阅一个事件时,事件实例被设置为null

弱事件模式

你知道吗?NET 程序会有内存泄漏?当然,不是从 C++的角度来说,但是有可能保持一个对象超过必要的时间。具体来说,您可以创建一个对象,并将其引用设置为null,但它仍然是活动的。怎么做?让我展示给你看。

首先,让我们创建一个按钮类:

public class Button
{
  public event EventHandler Clicked;

  public void Fire()
  {
    Clicked?.Invoke(this, EventArgs.Empty);
  }
}

现在,让我们假设我们在一个窗口中有这个按钮。为了简单起见,我将把它放入一个Window构造函数中:

public class Window
{
  public Window(Button button)
  {
    button.Clicked += ButtonOnClicked;
  }

  private void ButtonOnClicked(object sender, EventArgs eventArgs)
  {
    WriteLine("Button clicked (Window handler)");
  }

  ~Window()
  {
    WriteLine("Window finalized");
  }
}

看起来很无辜,但事实并非如此。如果你做了一个按钮和一个窗口,那么把窗口设置为null;它还会活着!证据:

var btn = new Button();
var window = new Window(btn);
var windowRef = new WeakReference(window);
btn.Fire();

window = null;

FireGC();
WriteLine($"Is window alive after GC? {windowRef.IsAlive}"); // True

窗口引用仍然存在的原因是它订阅了按钮。当点击一个按钮时,预期会发生一些合理的事情:因为有对该事件的订阅,所以不能允许碰巧进行了该订阅的对象死亡,即使对该对象的唯一引用已经被设置为null。这是中的内存泄漏。网感。

我们如何解决这个问题?一种方法是使用来自System.WindowsWeakEventManager类。这个类是专门设计来允许侦听器的处理程序被垃圾收集的,即使源对象仍然存在。这个类使用起来非常简单:

public class Window2
{
  public Window2(Button button)
  {
    WeakEventManager<Button, EventArgs>
      .AddHandler(button, "Clicked", ButtonOnClicked);
  }
  // rest of class same as before
}

再次重复这个场景,这个Window2实现根据需要给出了FalsewindowRef.IsAlive结果。

事件流

通过对 Observer 的所有这些讨论,您可能有兴趣了解。NET Framework 自带两个接口:IObserver<T>IObservable<T>。这些接口与反应性扩展(Rx)的发布相一致,主要用于处理反应性流。虽然我无意讨论整个反应式扩展,但这两个接口值得一提。

先说IObservable<T>。这是一个通常类似于典型接口的接口。网络事件。唯一的区别是,这个接口要求您实现一个名为Subscribe()的方法,而不是使用+=操作符进行订阅。这个方法将一个IObserver<T>作为它唯一的参数。请记住,这是一个接口,与事件/委托的情况不同,没有规定的存储机制。你可以随意使用任何你想要的东西。

还有一些额外的锦上添花:接口中明确支持 un 订阅的概念。Subscribe()方法返回一个IDisposable,并理解返回令牌(Memento 模式在起作用!)有一个Dispose()方法,让观察者从可观察对象中退订。

拼图的第二块是IObserver<T>界面。它旨在通过三种特定方法提供基于推送的通知:

  • 每当新事件发生时被调用。

  • 当数据源没有更多的数据时被调用。

  • OnError()当观察者遇到错误情况时被调用。

再说一次,这只是一个接口,如何处理由你决定。例如,你可以完全忽略OnCompleted()OnError()

因此,有了这两个接口,我们琐碎的医生-病人示例的实现突然变得不那么琐碎了。首先,我们需要封装一个事件订阅的想法。之所以需要这样做,是因为我们需要一个实现IDisposable的备忘录,通过它可以取消订阅。

private class Subscription : IDisposable
{
  private Person person;
  public IObserver<Event> Observer;

  public Subscription(Person person, IObserver<Event> observer)
  {

    this.person = person;
    Observer = observer;
  }

  public void Dispose()
  {
    person.subscriptions.Remove(this);
  }
}

这个类是Person的内部类,它很好地暗示了任何想要支持事件流的对象日益增长的复杂性。现在,回到Person,我们希望它实现IObservable<T>接口。但是什么是T?不像传统的事件,没有指导方针要求我们从EventArgs继承——当然,我们可以继续使用那种类型, 1 或者我们可以构建我们自己的,完全任意的层次结构:

public class Event
{
  // anything could be here
}

public class FallsIllEvent : Event
{
  public string Address;
}

继续,我们现在有了一个基类Event,所以我们可以声明Person是这类事件的生成器。因此,我们的Person类型将实现IObservable<Event>,并在其Subscribe()方法中采用一个IObserver<Event>。下面是整个Person类,省略了Subscription内部类的主体:

public class Person : IObservable<Event>
{
  private readonly HashSet<Subscription> subscriptions
    = new HashSet<Subscription>();

  public IDisposable Subscribe(IObserver<Event> observer)
  {
    var subscription = new Subscription(this, observer);
    subscriptions.Add(subscription);
    return subscription;
  }

  public void CatchACold()
  {
    foreach (var sub in subscriptions)
     sub.Observer.OnNext(new FallsIllEvent {Address = "123 London Road"});
  }

  private class Subscription : IDisposable { ... }
}

我相信你会同意这比仅仅发布一个event供客户订阅要复杂得多!但是这样做也有好处:例如,您可以选择自己的重复订阅策略,也就是说,当订户试图再次订阅某个事件的情况。值得注意的一点是HashSet<Subscription>不是线程安全容器。这意味着如果你想让Subscribe()CatchACold()同时被调用,你需要使用线程安全的集合、锁定或者更好的东西,比如ImmutableList

问题并没有就此结束。记住,订户现在必须实现一个IObserver<Event>。这意味着,为了支持我们之前展示的场景,我们必须编写以下代码:

public class Demo : IObserver<Event>
{
  static void Main(string[] args)
  {
    new Demo();
  }

  public Demo()
  {
    var person = new Person();
    var sub = person.Subscribe(this);
  }

  public void OnNext(Event value)
  {
    if (value is FallsIllEvent args)
    WriteLine($"A doctor has been called to {args.Address}");
  }

  public void OnError(Exception error){}
  public void OnCompleted(){}

}

这又是一个相当复杂的问题。我们可以通过使用一个特殊的Observable.Subscribe()静态方法来简化订阅,但是Observable(没有I)是反应扩展的一部分,一个单独的库,你可以使用也可以不使用。

这就是你如何使用?NET 自己的接口,不使用event关键字。这种方法的主要优点是由一个IObservable生成的事件流可以直接输入到不同的 Rx 操作符中。例如,使用System.Reactive,前面展示的整个演示程序可以变成一条语句:

person
  .OfType<FallsIllEvent>()
  .Subscribe(args =>
    WriteLine($"A doctor has been called to {args.Address}"));

财产观察员

中最常见的观察者实现之一。NET 在属性更改时会得到通知。这是必要的,例如,当底层数据改变时更新 UI。这种机制使用普通事件以及一些在. NET 中已经成为标准的接口。

属性观察器可能会变得非常复杂,所以我们将逐步介绍它们,从基本的接口和操作开始,然后转到更复杂的场景。

基本变更通知

中更改通知的核心部分。NET 是一个名为INotifyPropertyChanged的接口:

public interface INotifyPropertyChanged
{
  /// <summary>Occurs when a property value changes.</summary>
  event PropertyChangedEventHandler PropertyChanged;
}

这个事件所做的只是公开一个你应该使用的事件。给定一个具有名为Age的属性的类Person,该接口的典型实现如下所示:

public class Person : INotifyPropertyChanged
{
  private int age;

  public int Age
  {
    get => age;
    set
    {
      if (value == age) return;
      age = value;
      OnPropertyChanged();
    }
  }

  public event PropertyChangedEventHandler PropertyChanged;
  [NotifyPropertyChangedInvocator]
  protected virtual void OnPropertyChanged(
    [CallerMemberName] string propertyName = null)
  {
    PropertyChanged?.Invoke(this,
      new PropertyChangedEventArgs(propertyName));
  }
}

这里有很多要讨论的。首先,属性获得一个支持字段。这是必需的,以便在分配属性之前查看其以前的值。注意,只有当属性发生变化时,才会调用OnPropertyChanged()方法。如果没有,就没有通知。

就 IDE 生成的OnPropertyChanged()方法而言,该方法被设计为通过[CallerMemberName]元数据接受受影响属性的名称,然后,如果PropertyChanged事件有订阅者,则通知那些订阅者具有该名称的属性实际上发生了更改。

当然,你可以构建你自己的变更通知机制,但是 WinForms 和 WPF 本质上都知道INotifyPropertyChanged,就像许多其他框架一样。因此,如果您需要更改通知,我会坚持使用这个接口。

需要添加一个关于INotifyPropertyChanging的特别说明,这是一个用于发送事件的接口,表明一个属性正在发生变化。这个接口很少被使用。如果能够使用这个属性来取消一个属性更改就好了,但是遗憾的是这个接口没有提供这个功能。事实上,取消属性更改可能是您想要实现自己的接口而不是这些接口的原因之一。

双向绑定

INotifyPropertyChanged对于通知用户界面某个标签所绑定属性的变化非常有用。但是,如果您有一个编辑框,并且该编辑框还需要在幕后更新代码元素,该怎么办呢?

这实际上是可行的,甚至不会导致无限递归!这个问题概括为:如何绑定两个属性,使得改变一个属性会改变另一个属性,换句话说,它们的值总是相同的?

让我们试试这个。假设我们有一个有 ?? 的 ??,我们也有一个有 ?? 的 ??。我们希望NameProductName绑定在一起。

var product = new Product{Name="Book"};
var window = new Window{ProductName = "Book"};

product.PropertyChanged += (sender, eventArgs) =>
{
  if (eventArgs.PropertyName == "Name")
  {
    Console.WriteLine("Name changed in Product");
    window.ProductName = product.Name;
  }
};

window.PropertyChanged += (sender, eventArgs) =>
{
  if (eventArgs.PropertyName == "ProductName")
  {
    Console.WriteLine("Name changed in Window");
    product.Name = window.ProductName;
  }
};

常识告诉我们,当这个代码被触发时,会导致一个StackOverflowException:窗口影响产品,产品影响窗口,等等。只是这不会发生。为什么呢?因为这两个属性中的 setter 都有一个安全措施来检查值是否真的发生了变化。如果没有,它会执行一个return操作,并且不会发生进一步的通知。所以我们在这里很安全。

前面的解决方案是可行的,但是像 WinForms 这样的框架试图将这样的情况收缩包装到单独的数据绑定对象中。在数据绑定中,您可以指定对象及其属性,以及它们如何联系在一起。例如,Windows 窗体使用属性名(作为字符串),但是现在我们可以更聪明一点,使用表达式树来代替。

因此,让我们构造一个BidirectionalBinding类,在其构造函数中将两个属性绑定在一起。为此,我们需要四条信息:

  • 第一笔财产的所有者

  • 访问第一个对象的属性的表达式树

  • 第二处房产的所有者

  • 访问第二个对象的属性的表达式树

遗憾的是,在这种情况下减少参数的数量是不可能的,但至少它们或多或少是人类可读的。我们还将避免在这里使用泛型,尽管从理论上讲,它们可以引入额外的类型安全。

这是整个班级:

public sealed class BidirectionalBinding : IDisposable
{
  private bool disposed;

  public BidirectionalBinding(

INotifyPropertyChanged first, Expression<Func<object>> firstProperty,
    INotifyPropertyChanged second, Expression<Func<object>> secondProperty)
  {
    if (firstProperty.Body is MemberExpression firstExpr
        && secondProperty.Body is MemberExpression secondExpr)
    {
      if (firstExpr.Member is PropertyInfo firstProp
          && secondExpr.Member is PropertyInfo secondProp)
      {
        first.PropertyChanged += (sender, args) =>
        {
          if (!disposed)
          {
            secondProp.SetValue(second, firstProp.GetValue(first));
          }
        };
        second.PropertyChanged += (sender, args) =>
        {
          if (!disposed)
          {
            firstProp.SetValue(first, secondProp.GetValue(second));
          }
        };
      }
    }
  }

  public void Dispose()
  {
    disposed = true;
  }
}

前面的代码依赖于关于表达式树的许多前提条件,特别是这些条件:

  • 每个表达式树都应该是一个MemberExpression

  • 每个成员表达式都应该访问一个属性(因此,PropertyInfo)。

如果满足这些条件,我们就为每个属性订阅对方的更改。这个类增加了一个额外的 dispose guard,允许用户在必要时停止处理订阅。

前面是一个简单的例子,说明了在本质上支持数据绑定的框架中,幕后可能发生的事情。

属性依赖关系

在 Microsoft Excel 中,您可以让单元格包含使用其他单元格的值进行的计算。这非常方便:每当特定单元格的值发生变化时,Excel 都会重新计算该单元格影响的每个单元格(包括其他工作表上的单元格)。然后那些单元格导致依赖于它们的每个单元格的重新计算。如此循环下去,直到遍历完整个依赖图,不管花多长时间。太美了。

属性的问题(通常也是 Observer 模式的问题)完全相同:有时类的一部分不仅生成通知,还会影响到类的其他部分,然后那些成员也会生成他们自己的事件通知。与 Excel 不同。NET 没有内置的处理方式,所以这种情况很快就会变得一团糟。

让我举例说明。16 岁或 16 岁以上的人(在你的国家可能不同)可以投票,所以假设我们希望在一个人的投票权发生变化时得到通知:

public class Person : PropertyNotificationSupport
{
  private int age;

  public int Age
  {
    get => age;
    set
    {
      if (value == age) return;
      age = value;
      OnPropertyChanged();
    }
  }

  public bool CanVote => Age <= 16;
}

在前面的法典中,一个人年龄的改变会影响他的投票能力。然而,我们也期望为CanVote生成适当的变更通知…但是在哪里呢?毕竟CanVote没有二传手!

你可以试着把它们放入年龄设置器,例如:

public int Age
{
  get => age;
  set
  {
    if (value == age) return;
    age = value;
    OnPropertyChanged();
    OnPropertyChanged(nameof(CanVote));
  }
}

这是可行的,但是考虑一个场景:如果年龄从 5 岁变成 6 岁呢?当然,年龄变了,但是CanVote没有变,那么我们为什么要无条件地在上面做通知呢?这是不正确的。功能上正确的实现应该如下所示:

set
{
  if (value == age) return;

  var oldCanVote = CanVote;

  age = value;
  OnPropertyChanged();

  if (oldCanVote != CanVote)
    OnPropertyChanged(nameof(CanVote));
}

如您所见,确定CanVote受到影响的唯一方法是缓存它的旧值,对age执行更改,然后获取它的新值并检查它是否被修改,然后才执行通知。

即使没有这个特殊的痛点,我们对属性依赖采取的方法也是不可伸缩的。在一个复杂的场景中,属性依赖于其他属性,我们如何跟踪所有的依赖关系并发出所有的通知呢?显然,需要某种集中机制来自动跟踪所有这些。

让我们建立这样一个机制。我们将构建一个名为PropertyNotificationSupport的基类,它将实现INotifyPropertyChanged并处理依赖关系。下面是它的实现:

public class PropertyNotificationSupport : INotifyPropertyChanged
{
  private readonly Dictionary<string, HashSet<string>> affectedBy
    = new Dictionary<string, HashSet<string>>();

  public event PropertyChangedEventHandler PropertyChanged;

  [NotifyPropertyChangedInvocator]
  protected virtual void OnPropertyChanged
    ([CallerMemberName] string propertyName = null)
  {
    PropertyChanged?.Invoke(this,
      new PropertyChangedEventArgs(propertyName));

    foreach (var affected in affectedBy.Keys)
      if (affectedBy[affected].Contains(propertyName))
        OnPropertyChanged(affected);
  }

  protected Func<T> property<T>(string name,
    Expression<Func<T>> expr) { ... }

  private class MemberAccessVisitor : ExpressionVisitor { ... }
}

这门课很复杂,所以我们慢慢来,弄清楚这是怎么回事。

首先,我们有affectedBy,这是一个字典,列出了每个属性和受其影响的属性的HashSet。例如,如果投票能力受年龄和你是否是公民的影响,这本字典将包含一个关键字"CanVote"和值{"Age", "Citizen"}

然后,我们修改默认的OnPropertyChanged()实现,以确保通知发生在属性本身和它影响的所有属性上。现在唯一的问题是——属性是如何被收入这本字典的?

要求开发人员手动填充这个字典太过分了。相反,我们通过使用表达式树来自动完成。只读属性的 getter 作为表达式树提供给基类,这完全改变了依赖属性的构造方式:

public class Person : PropertyNotificationSupport
{
  private readonly Func<bool> canVote;
  public bool CanVote => canVote();

  public Person()
  {
    canVote = property(nameof(CanVote),
      () => Citizen && Age >= 16);
  }

  // other members here
}

显然,一切都变了。现在,使用基类的“property()方法在构造函数中初始化该属性。该属性获取一个表达式树,解析它以找到依赖属性,然后将表达式编译成一个普通的Func<T>:

protected Func<T> property<T>(string name, Expression<Func<T>> expr)
{
  Console.WriteLine($"Creating computed property for expression {expr}");

  var visitor = new MemberAccessVisitor(GetType());
  visitor.Visit(expr);

  if (visitor.PropertyNames.Any())
  {
    if (!affectedBy.ContainsKey(name))
      affectedBy.Add(name, new HashSet<string>());
    foreach (var propName in visitor.PropertyNames)
      if (propName != name) affectedBy[name].Add(propName);
  }

  return expr.Compile();
}

表达式树的解析是通过使用我们创建的私有嵌套类MemberAccessVisitor来完成的。该类遍历表达式树寻找成员访问,并将所有属性名收集到一个简单的列表中:

private class MemberAccessVisitor : ExpressionVisitor
{
  private readonly Type declaringType;
  public readonly IList<string> PropertyNames = new List<string>();

  public MemberAccessVisitor(Type declaringType)
  {
    this.declaringType = declaringType;
  }

  public override Expression Visit(Expression expr)
  {
    if (expr != null && expr.NodeType ==  ExpressionType.MemberAccess)
    {
      var memberExpr = (MemberExpression)expr;
      if (memberExpr.Member.DeclaringType == declaringType)
      {
        PropertyNames.Add(memberExpr.Member.Name);
      }
    }

    return base.Visit(expr);
  }
}

请注意,我们将自己限制在所属类的声明类型上——处理类之间存在属性依赖的情况是可行的,但要复杂得多。

总之,将所有这些放在一起,我们现在可以编写如下内容:

var p = new Person();
p.PropertyChanged += (sender, eventArgs) =>
{
  Console.WriteLine($"{eventArgs.PropertyName} has changed");
};

p.Age = 16;
// Age has changed
// CanVote has changed
p.Citizen = true;
// Citizen has changed
// CanVote has changed

所以它是有效的。但是,我们的执行情况仍然很不理想。如果我在前面的代码中将年龄改为 10,CanVote仍然会收到通知,尽管它不应该收到通知!这是因为,目前,我们正在无条件地发送这些通知。如果我们想只在相关属性改变时才触发这些,我们将不得不求助于INotifyPropertyChanging(或类似的接口),在那里我们将不得不缓存每个受影响属性的旧值,直到INotifyPropertyChanged调用,然后检查那些属性实际上已经改变了。我将此作为读者的一个练习。

最后,一个小注意。你可以看到一些过度拥挤的现象发生在房地产设定者身上。三行代码已经很多了,但是如果考虑到额外的调用,比如使用INotifyPropertyChanging,那么将整个属性 setter 外部化是有意义的。将每个属性转换成一个Property<T>(参见代理模式的“属性代理”部分)有点矫枉过正,但是我们可以向基类注入类似

protected void setValue<T>(T value, ref T field,
  [CallerMemberName] string propertyName = null)
{
  if (value.Equals(field)) return;
  OnPropertyChanging(propertyName);
  field = value;
  OnPropertyChanged(propertyName);
}

属性现在简化为

public int Age
{
  get => age;
  set => setValue(value, ref age);
}

注意,在前面的代码中,我们必须进行propertyName传播,因为OnPropertyChanged()中的[CallerMemberName]属性在开箱即用时将不再为我们工作。

视图

财产观察者有一个很大、很大、很明显的问题:这种方法是侵入性的,并且明显违背了关注点分离的思想。变更通知是一个单独的问题,所以将它添加到您的域对象中可能不是一个好主意。

为什么不呢?好吧,假设你决定改变主意,从使用 INotifyPropertyChanged (INPC)转向使用IObservable界面。如果你要在你的域对象中分散 INPC 的使用,你必须仔细检查每一个,修改每一个属性以使用新的范例,更不用说你还必须修改那些类以停止使用旧的接口并开始使用新的接口。这是乏味且容易出错的,而这正是我们试图避免的事情。

那么,如果您希望在发生更改的对象之外处理更改通知,您应该将它们添加到哪里呢?这应该不难——毕竟,我们已经看到了像 Decorator 这样的模式就是为了这个目的而设计的。

一种方法是将另一个对象放在域对象的前面,它将处理变更通知和其他事情。这就是我们通常所说的视图——例如,就是这个东西将被绑定到 UI。

要使用视图,您应该保持对象简单,使用普通属性(甚至公共字段!)而不用任何额外的行为来修饰它们:

public class Person
{
  public string Name;
}

事实上,保持数据对象尽可能简单是值得的;在 Kotlin 等语言中,这就是所谓的数据类。现在你要做的是在对象的顶部构建一个视图。视图可以包含其他关注点,包括属性观察者:

public class PersonView : View
{
  protected Person person;
  public PersonView(Person person)
  {
    this.person = person;
  }

  public string Name
  {
    get => person.Name;
    set {
      setValue(value, () => person.Name);
    }
  }
}

当然,前面的代码是一个装饰器。它用执行通知的镜像 getter/setter 包装底层对象。如果你需要更多的复杂性,这里是你得到它的地方。例如,如果您希望在表达式树中跟踪属性依赖关系,那么您应该在这个构造函数中而不是在底层对象的构造函数中这样做。

您会注意到,在前面的清单中,我试图隐藏任何实现细节。我们只是从某个类View继承而来,我们并不关心它是如何处理通知的:也许它使用了INotifyPropertyChanged,也许它使用了IObservable,也许还有别的什么。我们不在乎。

唯一真正的问题是如何调用这个类的 setter,考虑到我们希望它具有关于我们正在分配的属性的名称(只是以防需要)和正在分配的值的信息。这个问题没有统一的解决方案,显然,你在这个临时的setValue()方法中包含的信息越多越好。如果person.Name是一个字段,事情将会大大简化,因为我们可以简单地传递一个对要分配的字段的引用,但是我们仍然需要传递一个nameof(),以便基类在必要时通过 INPC 进行通知。

可观察的集合

如果你在 WinForms 或 WPF 中将一个List<T>绑定到一个列表框,改变列表不会更新 UI。为什么不呢?因为List<T>不支持观察者模式——当然,它的单个成员可能支持,但是列表作为一个整体没有明确的方式通知它的内容已经改变。诚然,你可以做一个包装器,让Add()Remove()这样的方法生成通知。然而,WinForms 和 WPF 都有可观察集合——分别是BindingList<T>ObservableCollection<T>类。

这两种类型都表现为一个Collection<T>,但是这些操作会生成额外的通知,例如,当集合发生变化时,UI 组件可以使用这些通知来更新表示层。例如,ObservableCollection<T>实现了INotifyCollectionChanged接口,该接口又有一个CollectionChanged事件。该事件将告诉您对集合应用了什么操作,并将为您提供一个新旧项的列表,以及关于新旧起始索引的信息:换句话说,您获得了根据操作正确重绘列表框所需的一切。

需要注意的一点是BindingList<T>ObservableCollection<T>都不是线程安全的。因此,如果您计划从多个线程读取/写入这些集合,您需要构建一个线程代理(嘿,代理模式!).事实上,这里有两种选择:

  • 从一个可观察的集合中继承,只是将常见的集合操作如Add()放在锁后面。

  • 从并发集合(例如,ConcurrentBag<T>)继承并添加INotifyCollectionChanged功能。

您可以在 StackOverflow 和其他地方找到这两种方法的实现。我更喜欢第一种选择,因为它简单得多。

可观测的 LINQ

当我们讨论属性观察者时,我们也设法讨论了影响其他属性的属性的概念。但这并不是它们影响的全部。例如,一个属性可能包含在产生某些结果的 LINQ 查询中。那么,当某个特定查询所依赖的属性发生变化时,我们如何知道我们需要重新查询该查询中的数据呢?

随着时间的推移,出现了 CLINQ(连续 LINQ)和可绑定 LINQ 等框架,试图解决 LINQ 查询在其组成部分之一失败时生成必要事件(即CollectionChanged)的问题。还存在其他框架,我不能在这里推荐您使用哪一个。请记住,这些框架试图解决一个真正困难的问题。

Autofac 中的声明性订阅

到目前为止,我们的大部分讨论都集中在明确的概念上,即命令式订阅事件,不管是通过通常的方式。NET 机制、反应式扩展或其他。然而,这并不是事件订阅发生的唯一方式。

您还可以声明性地定义事件订阅*。这通常是因为应用使用了一个中央 IoC 容器,在这个容器中可以找到声明,并且可以在幕后进行事件连接。*

*声明性事件连接有两种流行的方法。第一种使用属性:您只需将某个方法标记为[Publishes("foo")],将某个其他类中的某个其他方法标记为[Subscribes("foo")],IoC 容器就会在幕后建立一个连接。

另一种方法是使用接口,这就是我们将要演示的,以及 Autofac 库的使用。首先,我们定义了事件的概念,并为事件的发送和处理充实了接口:

public interface IEvent {}

public interface ISend<TEvent> where TEvent : IEvent
{
  event EventHandler<TEvent> Sender;
}

public interface IHandle<TEvent> where TEvent : IEvent
{
  void Handle(object sender, TEvent args);
}

我们现在可以制造事件的具体实现。例如,假设我们正在处理点击事件,其中用户可以按下按钮一定次数(例如,双击它):

public class ButtonPressedEvent : IEvent
{
  public int NumberOfClicks;
}

我们现在可以创建一个生成此类事件的Button类。为了简单起见,我们将简单地添加一个触发事件的Fire()方法。采用声明式方法,我们用ISend<ButtonPressedEvent>接口来修饰Button:

public class Button : ISend<ButtonPressedEvent>
{
  public event EventHandler<ButtonPressedEvent> Sender;

  public void Fire(int clicks)
  {
    Sender?.Invoke(this, new ButtonPressedEvent
    {
      NumberOfClicks = clicks
    });
  }
}

现在,对于接收方,假设我们想要记录按钮按压。这意味着我们想要处理ButtonPressedEvent s,幸运的是,我们已经有了这样的接口:

public class Logging : IHandle<ButtonPressedEvent>
{
  public void Handle(object sender, ButtonPressedEvent args)
  {
    Console.WriteLine(
      $"Button clicked {args.NumberOfClicks} times");
  }
}

现在,我们想要的是,在幕后,我们的 IoC 容器在幕后自动订阅LoggingButton.Sender事件,而不需要我们手动操作。首先,让我向您展示这样做所需的一段代码:

var cb = new ContainerBuilder();
var ass = Assembly.GetExecutingAssembly();

// register publish interfaces
cb.RegisterAssemblyTypes(ass)
  .AsClosedTypesOf(typeof(ISend<>))
  .SingleInstance();

// register subscribers
cb.RegisterAssemblyTypes(ass)
  .Where(t =>
    t.GetInterfaces()
      .Any(i =>
        i.IsGenericType &&
        i.GetGenericTypeDefinition() == typeof(IHandle<>)))
  .OnActivated(act =>
  {
    var instanceType = act.Instance.GetType();
    var interfaces = instanceType.GetInterfaces();
    foreach (var i in interfaces)
    {
      if (i.IsGenericType
          && i.GetGenericTypeDefinition() == typeof(IHandle<>))
      {
        var arg0 = i.GetGenericArguments()[0];
        var senderType = typeof(ISend<>).MakeGenericType(arg0);
        var allSenderTypes =
          typeof(IEnumerable<>).MakeGenericType(senderType);
        var allServices = act.Context.Resolve(allSenderTypes);
        foreach (var service in (IEnumerable) allServices)
        {
          var eventInfo = service.GetType().GetEvent("Sender");
          var handleMethod = instanceType.GetMethod("Handle");
          var handler = Delegate.CreateDelegate(
            eventInfo.EventHandlerType, null, handleMethod);
          eventInfo.AddEventHandler(service, handler);
        }

      }
    }
})
.SingleInstance()
.AsSelf();

让我们一步一步地看看前面的代码中发生了什么。

  • 首先,我们注册所有实现ISend<>的程序集类型。那里不需要采取特殊的步骤,因为它们只需要存在于某个地方。为了简单起见,我们将它们注册为单例——如果不是这样,连接的情况会变得更加复杂,因为系统必须跟踪每个构造的实例。 2

  • 然后我们注册实现IHandle<>的类型。这就是事情变得疯狂的地方,因为我们指定了一个在返回对象之前必须执行的额外的OnActivated()步骤。

  • 然后,给定这个IHandle<Foo>类型,我们使用反射定位实现ISend<Foo>接口的所有类型。这是一个相当繁琐的过程。

  • 对于找到的每一种类型,我们都连接了订阅。同样,这是使用反射完成的,你也可以在这里和那里看到一些神奇的字符串。

有了这个设置,我们可以构建容器并解析一个Button和一个Logging组件,订阅将在后台完成:

var container = cb.Build();
var button = container.Resolve<Button>();
var logging = container.Resolve<Logging>();

button.Fire(1); // Button clicked 1 times
button.Fire(2); // Button clicked 2 times

以类似的方式,您可以使用属性来实现声明性订阅。如果您不使用 Autofac,不要担心:大多数流行的 IoC 容器都能够实现这种声明性事件连接。

摘要

一般来说,我们可以避免讨论 C# 中的观察者模式,因为该模式本身已经融入到语言中了。也就是说,我已经展示了观察器的一些实际用途(属性更改通知)以及与之相关的一些问题(依赖属性)。此外,我们还研究了观察者模式支持反应流的方式。

无论我们谈论的是单个事件还是整个集合,线程安全都是 Observer 的一个关注点。它出现的原因是因为一个组件上的几个观察者形成了一个列表(或类似的结构),然后问题立即出现了,即该列表是否是线程安全的,以及当它同时被修改和迭代(出于通知的目的)时到底发生了什么。

Footnotes 1

对了,System.EventArgs是空类型。它只有一个默认的构造函数(空的)和一个静态成员EventArgs.Empty,这是一个单独的空对象(双模式头像!)指示事件参数没有数据。

  2

从个人经验来看,在很多情况下,您确实需要您的 IoC 容器来跟踪所有已经构建的实例。一个例子是动态原型方法(参见“桥”一章的“动态原型桥”一节),在这种方法中,一个对象的进程中的变化要求立即替换在应用的生命周期中构建的所有这样的对象。

 

*

二十二、状态

我必须承认:我的行为受我的状态支配。如果我睡眠不足,我会有点累。如果我喝了酒,我就不会开车了。所有这些都是状态,它们支配着我的行为:我的感受,我能做什么和不能做什么。

当然,我可以从一种状态转换到另一种状态。我可以去喝杯咖啡,这会让我从困倦中清醒过来(我希望!).所以我们可以把咖啡想象成一个触发器,让你真正从困倦状态转变为清醒状态。在这里,我笨拙地为你说明一下:?? 1

        coffee
sleepy --------> alert

因此,状态设计模式是一个非常简单的想法:状态控制行为,状态可以改变,唯一没有定论的是谁触发从一个状态到另一个状态的改变。

有两种方法可以模拟状态:

  • 状态是具有行为的实际类,这些行为导致从一个状态到另一个状态的转换。换句话说,一个州的成员是我们从那个州走向何方的选项。

  • 状态和转换只是枚举。我们有一个叫做状态机的特殊组件来执行实际的转换。

这两种方法都是可行的,但第二种方法才是最常见的。我们将看一看这两个,但是我必须警告,我将浏览第一个,因为这不是人们通常做事情的方式。

状态驱动的状态转换

我们从最简单的例子开始:一个只能处于状态的灯开关。我们之所以选择这样一个简单的域,是因为我想强调一个经典的 State 实现所带来的疯狂(没有别的词可以形容),这个例子非常简单,不需要生成代码清单。

我们将构建一个模型,其中任何状态都能够切换到其他状态:这反映了状态设计模式的“经典”实现(根据 GoF 书)。首先,让我们建立电灯开关的模型。它只有一个状态和一些从一个状态切换到另一个状态的方法:

public class Switch
{
  public State State = new OffState();
}

这一切看起来完全合理;我们有一个处于某种状态的开关(或者是打开或者是关闭)。我们现在可以定义State,在这个特殊的例子中,它将是一个实际的类。

public abstract class State
{
  public virtual void On(Switch sw)
  {
    Console.WriteLine("Light is already on.");
  }

  public virtual void Off(Switch sw)
  {
    Console.WriteLine("Light is already off.");
  }
}

这个实现远非直观,以至于我们需要慢慢地、小心地讨论它,因为从一开始,关于State类的任何东西都没有意义。

虽然State是抽象的(意味着您不能实例化它),但它有非抽象成员,允许从一种状态切换到另一种状态。这……对一个通情达理的人来说,毫无意义。想象一下电灯开关:它是改变状态的开关。人们并不指望这个州本身会改变本身,然而它似乎确实在改变。

然而,也许最令人困惑的是,State.On() / Off()的默认行为声称我们已经处于这种状态!请注意,这些方法是虚拟的。当我们实现示例的其余部分时,这将在某种程度上结合在一起。

我们现在实现开和关状态:

public class OnState : State
{
  public OnState()
  {
    Console.WriteLine("Light turned on.");
  }
  public override void Off(Switch sw)
  {
    Console.WriteLine("Turning light off...");
    sw.State = new OffState();
  }
}
// similarly for OffState

每个状态的构造器简单地通知我们,我们已经完成了转换。但是过渡本身发生在OnState.Off()OffState.On()。这就是转变发生的地方。

我们现在可以完成Switch类,为它提供实际开关灯的方法:

public class Switch
{
  public State State = new OffState();
  public void On()  { State.On(this); }
  public void Off() { State.Off(this); }
}

因此,将所有这些放在一起,我们可以运行以下场景:

LightSwitch ls = new LightSwitch(); // Light turned off
ls.On();  // Switching light on...
          // Light turned on
ls.Off(); // Switching light off...
          // Light turned off
ls.Off(); // Light is already off

下面是一个从OffStateOnState的转换示意图:

         LightSwitch.On() -> OffState.On()
OffState------------------------------------>OnState

另一方面,从OnStateOnState的转换使用基本的State类,它告诉你你已经处于那个状态了:

         LightSwitch.On() -> State.On()
OnState --------------------------------> OnState

让我第一个说这里呈现的实现是可怕的。虽然这是 OOP 平衡的一个很好的演示,但它是一个不可读的、不直观的混乱,违背了我们对 OOP 的所有了解,特别是对设计模式的了解,具体来说:

  • 一个状态通常不会自行切换。

  • 可能的转换列表不应该到处出现;最好放在一个地方(SRP)。

  • 没有必要用实际的类来模拟状态,除非它们有特定于类的行为;这个例子可以简化得更简单。

也许我们一开始就应该使用enum s?

手工状态机

让我们试着为一个典型的电话对话定义一个状态机。

首先,我们将描述电话的状态:

public enum State
{
  OffHook,
  Connecting,
  Connected,
  OnHold
}

我们现在还可以定义状态之间的转换,也称为enum:

public enum Trigger
{
  CallDialed,
  HungUp,
  CallConnected,
  PlacedOnHold,
  TakenOffHold,
  LeftMessage
}

现在,这个状态机的确切的规则,也就是哪些变迁是可能的,需要存储在某个地方。下面是一个 UML 状态机图,显示了我们想要的转换类型:

img/476082_2_En_22_Figa_HTML.jpg

让我们使用状态到触发器/状态对的字典:

private static Dictionary<State, List<(Trigger, State)>> rules
    = new Dictionary<State, List<(Trigger, State)>>() { /* todo */ }

这有点笨拙,但本质上字典的键是我们从移动State,值是在这个状态和使用触发器时进入的状态中代表可能的触发器的Trigger-State对的列表。

让我们初始化这个数据结构:

private static Dictionary<State, List<(Trigger, State)>> rules
  = new Dictionary<State, List<(Trigger, State)>>
  {
    [State.OffHook] = new List<(Trigger, State)>
    {
      (Trigger.CallDialed, State.Connecting)
    },
    [State.Connecting] = new List<(Trigger, State)>
    {
      (Trigger.HungUp, State.OffHook),
      (Trigger.CallConnected,  State.Connected)
    },
    // more rules here
  };

我们还需要一个起始(当前)状态,如果我们希望状态机在到达该状态时停止执行,我们还可以添加一个退出(终止)状态:

State state = State.OffHook, exitState = State.OnHook;

所以在前面一行中,我们从OffHook状态开始(当您准备好打电话时),退出状态是当电话被放置OnHook并且呼叫结束时。

这样,我们就不必为实际运行(我们使用术语编排)状态机构建单独的组件。例如,如果我们想建立一个交互式的电话模型,我们可以这样做:

do
{
  Console.WriteLine($"The phone is currently {state}");
  Console.WriteLine("Select a trigger:");

  for (var i = 0; i < rules[state].Count; i++)
  {
    var (t, _) = rules[state][i];
    Console.WriteLine($"{i}. {t}");
  }

  int input = int.Parse(Console.ReadLine());

  var (_, s) = rules[state][input];
  state = s;
} while (state != exitState);
Console.WriteLine("We are done using the phone.");

该算法相当明显:我们让用户选择当前状态的一个可用触发器,如果该触发器有效,我们通过使用之前创建的rules字典转换到正确的状态。

如果我们到达的状态是退出状态,我们就跳出循环。这是一个与程序交互的例子:

The phone is currently OffHook
Select a trigger:
0\. CallDialed
0
The phone is currently Connecting Select a trigger:
0.HungUp
1.CallConnected
1
The phone is currently Connected
Select a trigger:
0.LeftMessage
1.HungUp
2.PlacedOnHold
2
The phone is currently OnHold
Select a trigger:
0.TakenOffHold
1.HungUp
1
We are done using the phone.

这种手工创建的状态机的主要好处是非常容易理解:状态和转换是普通的枚举,转换集在一个Dictionary中定义,开始和结束状态是简单的变量。我相信你会同意这比我们在本章开始时的例子更容易理解。

基于开关的状态机

在我们对状态机的探索中,我们已经从用类表示状态的不必要的复杂的经典例子发展到用枚举表示状态的手工例子,现在我们将经历退化的最后一步,因为我们完全停止使用状态的专用数据类型。

但是我们的简化不会就此结束:我们不会从一个方法调用跳到另一个方法调用,而是将自己限制在一个无限循环的switch语句中,在该语句中,状态将被检查,并且由于状态改变而发生转换。

我想让你考虑的场景是一个密码锁。锁有一个四位数的代码(如1234),您可以一次输入一个数字。当您输入代码时,如果您输入错误,您会得到“FAILED”输出,但是如果您输入的所有数字都正确,您会得到“UNLOCKED”,然后退出状态机。

整个场景可以放在一个清单中:

string code = "1234";
var state = State.Locked;
var entry = new StringBuilder();

while (true)
{
  switch (state)
  {
    case State.Locked:
      entry.Append(Console.ReadKey().KeyChar);

      if (entry.ToString() == code)
      {
        state = State.Unlocked;
        break;
      }

      if (!code.StartsWith(entry.ToString()))
      {
        // the code is blatantly wrong
        state = State.Failed;
      }
      break;

    case State.Failed:
      Console.CursorLeft = 0;
      Console.WriteLine("FAILED");
      entry.Clear();
      state = State.Locked;
      break;
    case State.Unlocked:
      Console.CursorLeft = 0;
      Console.WriteLine("UNLOCKED");
      return;
  }
}

如您所见,这在很大程度上是一个状态机,尽管它缺乏任何结构。你不能从顶层检查它,也不能说出所有可能的状态和转换是什么。除非你真的检查代码,否则不清楚转换是如何发生的——我们很幸运这里没有goto语句在案例之间跳转!

这种基于开关的状态机方法适用于状态和转换数量非常少的情况。它在结构、可读性和可维护性方面有所损失,但是如果您确实急需一个状态机并且懒得创建 enum 用例,它可以作为一个快速补丁。

总的来说,这种方法不可伸缩,并且难以管理,所以我不建议在生产代码中使用。唯一的例外是,这种机器是基于某种外部模型使用代码生成制造的。

用开关表达式编码转换

基于switch的状态机可能很笨拙,但这部分是由于关于状态和转换的信息的构造方式(因为事实并非如此)。但是有一种不同的switchswitch语句(相对于表达式)由于模式匹配,允许我们灵活地定义状态转换。

好了,是时候举个简单的例子了。你正在寻宝,发现了一个可以打开或关闭的宝箱…除非它是锁着的,在这种情况下,情况就有点复杂了(你需要有一把钥匙来打开或关闭宝箱)。因此,我们可以将状态和可能的转换编码如下:

enum Chest
{
  Open, Closed, Locked
}

enum Action
{
  Open, Close
}

有了这个定义,我们可以编写一个名为Manipulate的方法,将我们从一个状态带到另一个状态。胸部手术的一般规则如下:

  • 如果箱子是锁着的,只有有钥匙才能打开。

  • 如果箱子是开着的,你拿着钥匙关上它,你就锁上了它。

  • 如果箱子是开着的,而你没有钥匙,你就关上它。

  • 不管你有没有钥匙,一个封闭的(但没有上锁的)箱子都可以被打开。

可能转换的集合可以被编码在模式匹配表达式的结构中。事不宜迟,就是这样:

static Chest Manipulate(Chest chest,
  Action action, bool haveKey) =>
  (chest, action, haveKey) switch
  {
    (Chest.Closed, Action.Open, _) => Chest.Open,
    (Chest.Locked,  Action.Open, true) => Chest.Open,
    (Chest.Open, Action.Close, true) => Chest.Locked,
    (Chest.Open, Action.Close, false) => Chest.Closed,
  _ => chest
};

这种方法有许多优点和缺点。其优点是

  • 这个状态机很容易阅读

  • haveKey这样的保护条件很容易合并,并且非常适合模式匹配

也有不利之处:

  • 这个状态机的正式规则集是以一种无法提取的方式定义的。没有保存规则的数据存储,因此您不能生成报告或图表,也不能运行任何超出编译器所做的验证检查(它检查穷举性)。

  • 如果您需要任何行为,比如状态进入或退出行为,这在 switch 表达式中是不容易做到的——您需要定义一个包含 switch 语句的老式方法。

总而言之,这种方法非常适合简单的状态机,因为它会产生非常易读的代码。但这并不完全是一个“企业”解决方案。

无状态的状态机

虽然手工滚动状态机适用于最简单的情况,但是您可能希望利用工业级的状态机框架。通过这种方式,您可以获得一个经过测试的功能更多的库。这也是合适的,因为我们需要讨论额外的与状态机相关的概念,并且手工实现它们是相当乏味的。

在我们继续讨论我想讨论的概念之前,让我们首先使用无状态来重建我们之前的电话呼叫示例。 2 假设和以前一样存在相同的枚举StateTrigger,状态机的定义非常简单:

var call = new StateMachine<State, Trigger>(State.OffHook);

phoneCall.Configure(State.OffHook)
  .Permit(Trigger.CallDialed, State.CallConnected);

// and so on, then, to cause a transition, we do

call.Fire(Trigger.CallDialed); // call.State is now State.CallConnected

如你所见,Stateless' StateMachine class 是一个具有流畅接口的构建器。当我们讨论无状态的不同复杂性时,这个 API 设计背后的动机将变得显而易见。

类型、动作和忽略过渡

让我们来谈谈无状态和状态机的许多特性。

首先也是最重要的,无状态支持 any 的状态和触发器。NET 类型——它不局限于enums。你可以使用字符串,数字,任何你想要的。例如,一个灯开关可以用一个bool来表示状态(false =关,true =开);我们将继续使用enums作为触发器。下面是如何实现LightSwitch的例子:

enum Trigger { On, Off }
var light = new StateMachine<bool, Trigger>(false);

light.Configure(false)            // if the light is off...
  .Permit(Trigger.On, true)       // we can turn it on
  .Ignore(Trigger.Off);           // but if it's already off we do nothing

// same for when the light is on
light.Configure(true)
  .Permit(Trigger.Off, false)
  .Ignore(Trigger.On)
  .OnEntry(() => timer.Start())
  .OnExit(() => timer.Stop());   // calculate time spent in this state

light.Fire(Trigger.On);   // Turning light on
light.Fire(Trigger.Off);  // Turning light off
light.Fire(Trigger.Off);  // Light is already off!

这里有几件有趣的事情值得讨论。首先,这个状态机有动作——当我们进入特定状态时发生的事情。这些都是在OnEntry()中定义的,在这里你可以提供一个做某事的 lambda 类似地,您可以在使用OnExit()退出状态时调用一些东西。这种转换动作的一个用途是在进入一个转换时启动一个计时器,在退出一个转换时停止计时器,这可以用于跟踪在每个状态中花费的时间量。例如,您可能想要测量灯亮着的时间,以验证电费。

另一件值得注意的事情是Ignore()构建器方法的使用。这基本上是告诉状态机完全忽略这个转换:如果灯已经关了,我们试图关掉它(如前面清单的最后一行),我们指示状态机简单地忽略它,所以在这种情况下没有输出。

为什么这很重要?因为如果您忘记Ignore()这个转换或者没有明确指定它,无状态将抛出一个InvalidOperationException:

对于触发器“False ”,不允许从状态“False”进行有效的离开转换。考虑忽略触发器*。*

再次重入

“冗余交换”难题的另一个替代方案是无状态对可重入状态的支持。为了复制本章开始时的例子,我们可以配置状态机,以便在重新进入一个状态的情况下(意味着我们从false转换到false),调用一个动作。下面是如何配置它的:

var light = new StateMachine<bool, Trigger>(false);

light.Configure(false)        // if the light is off...
  .Permit(Trigger.On, true)   // we can turn it on
  .OnEntry(transition =>
  {
    if (transition.IsReentry)
      WriteLine("Light is already off!");
    else
      WriteLine("Turning light off");
  })
  .PermitReentry(Trigger.Off);

// same for when the light is on

light.Fire(Trigger.On);  // Turning light on
light.Fire(Trigger.Off); // Turning light off
light.Fire(Trigger.Off); // Light is already off!

在前面的清单中,PermitReentry()允许我们在一个Trigger.Off触发器上返回到false(关闭)状态。注意,为了向控制台输出相应的消息,我们使用了不同的 lambda:一个有Transition参数的 lambda。该参数具有完整描述转换的公共成员。这包括Source(我们正在转换的状态)、Destination(我们将要转换的状态)、Trigger(导致转换的原因),以及IsReentry,一个布尔标志,我们使用它来确定这是否是一个可重入的转换。

分级状态

在打电话的情况下,可以认为OnHold状态是Connected状态的子状态,这意味着当我们在等待时,我们也是连接的。无状态允许我们这样配置状态机:

phoneCall.Configure(State.OnHold)
    .SubstateOf(State.Connected)
    // etc.

现在,如果我们处于OnHold状态,phoneCall.State会给我们OnHold,但是还有一个a phoneCall.IsInState(State)方法,当用State.ConnectedState.OnHold调用时,它会返回true

更多功能

让我们再讨论几个与无状态实现的状态机相关的特性。

  • Guard 子句允许你通过调用PermitIf()和提供bool-返回 lambda 函数来随意启用和禁用转换,例如:

  • 参数化触发器是一个有趣的概念。本质上,您可以将参数附加到触发器上,这样,除了触发器本身之外,还有其他信息可以传递。例如,如果一个状态机需要通知一个特定的雇员,您可以指定一个用于通知的电子邮件:

phoneCall.Configure(State.OffHook)
  .PermitIf(Trigger.CallDialled, State.Connecting, () => IsValidNumber)
  .PermitIf(Trigger.CallDialled, State.Beeping, () =>!IsValidNumber);

  • 外部存储是无状态的一个特性,它允许你在外部存储一个状态机的内部状态(例如,在一个数据库中),而不是使用StateMachine类本身。要使用它,只需在StateMachine构造函数中定义 getter 和 setter 方法:
var notifyTrigger = workflow.SetTriggerParameters<string>(Trigger.Notify);
workflow.Configure(State.Notified)
  .onEntryFrom(assignTrigger, email => SendEmail(email));
workflow.Fire(notifyTrigger, "foo@bar.com");

  • 内省允许我们通过PermittedTriggers属性实际查看可以从当前状态触发的触发器表。
var stateMachine = new StateMachine<State, Trigger>(
  () => database.ReadState(),
  s => database.WriteState(s));

这远不是无状态提供的特性的详尽列表,但是它涵盖了所有重要的部分。

摘要

如您所见,状态机的整个业务远远超出了简单的转换:它允许大量的复杂性来处理最苛刻的业务案例。让我们回顾一下我们已经讨论过的一些状态机特性:

  • 状态机包括两个集合:状态和触发器。状态为系统的可能状态建模,并触发我们从一个状态到另一个状态的转换。不限于枚举:可以使用普通的数据类型。

  • 尝试未配置的转换将导致异常。

  • 可以为每个状态显式配置进入和退出操作。

  • API 中可以显式地允许重入,而且,您可以确定在进入/退出动作中是否发生了重入。

  • 转换可以通过保护条件打开或关闭。它们也可以被参数化。

  • 状态可以是分层的,也就是说,它们可以是其他状态的子状态。然后需要一个额外的方法来确定您是否处于特定的(父)状态。

虽然这些特性中的大部分看起来像是过度工程化,但是这些特性在定义真实世界的状态机时提供了很大的灵活性。

Footnotes 1

我撒谎了——两次。第一,我不开汽车,我更喜欢电动自行车。这并不会以任何方式影响饮酒——仍然是不允许的。第二,我不喝咖啡。

  2

无国籍可以在 https://github.com/dotnet-state-machine/stateless 找到。值得注意的是,电话示例实际上来自 SimpleStateMachine 的作者,这是一个无状态的基础项目。

 

二十三、策略

假设您决定接受一个包含几个字符串的数组或向量,并将它们作为一个列表输出

  • 仅仅

  • 喜欢

如果您考虑不同的输出格式,您可能知道您需要获取每个元素并使用一些附加标记输出它。但是对于 HTML 或 LaTeX 这样的语言,列表也需要开始和结束标签或标记。

我们可以制定一个呈现列表的策略:

  • 呈现开始标记/元素。

  • 对于每个列表项,呈现该项。

  • 呈现结束标记/元素。

可以为不同的输出格式制定不同的策略,然后可以将这些策略输入到一个通用的、不变的算法中来生成文本。

这是存在于动态(运行时可替换)和静态(基于泛型的、固定的)实例中的另一种模式。让我们来看看他们两个。

动态策略

因此,我们的目标是以下列格式打印一个简单的文本项列表:

public enum Output Format
{
  Markdown,
  Html
}

我们的策略框架将在下面的基类中定义:

public interface IListStrategy
{
  void Start(StringBuilder sb);
  void AddListItem(StringBuilder sb, string item);
  void End(StringBuilder sb);
}

现在,让我们跳到我们的文本处理组件。这个组件有一个特定于列表的方法,比如说,AppendList()

public class TextProcessor
{
  private StringBuilder sb = new StringBuilder();
  private IListStrategy listStrategy;

  public void AppendList(IEnumerable<string> items)
  {
    listStrategy.Start(sb);
    foreach (var item in items)
      listStrategy.AddListItem(sb, item);
    listStrategy.End(sb);
  }

  public override string ToString() => sb.ToString();
}

所以我们有一个名为sb的缓冲区,所有的输出都放在那里,我们使用的listStrategy用于呈现列表,当然还有AppendList(),它指定了使用给定策略来实际呈现列表所需的一组步骤。

现在,注意这里。如前所述,组合是两种可能的选择之一,可以用来实现骨架算法的具体实现。相反,我们可以添加像AddListItem()这样的函数作为抽象或虚拟成员,由派生类重写:这就是模板方法模式所做的。

不管怎样,回到我们的讨论,我们现在可以继续为列表实现不同的策略,比如一个HtmlListStrategy:

Public class HtmlListStrategy : ListStrategy
{
  public void Start(StringBuilder sb) => sb.AppendLine("<ul>");
  public void End(StringBuilder sb) => sb.AppendLine("</ul>");

  public void AddListItem(StringBuilder sb, string item)
  {
    sb.AppendLine($" <li>{item}</li>");
  }
}

通过实现覆盖,我们填补了指定如何处理列表的空白。我们以类似的方式实现了一个MarkdownListStrategy,但是因为 Markdown 不需要开始/结束标签,所以我们只在AddListItem()方法中工作:

public class MarkdownListStrategy : IListStrategy
{
  // markdown doesn't require list start/end tags
  public void Start(StringBuilder sb) {}
  public void End(StringBuilder sb) {}

  public void AddListItem(StringBuilder sb, string item)
  {
    sb.AppendLine($" * {item}");
  }
}

我们现在可以开始使用TextProcessor,给它输入不同的策略,得到不同的结果。例如:

var tp = new TextProcessor(); tp.SetOutputFormat(OutputFormat.Markdown);
tp.AppendList(new []{"foo", "bar", "baz"});
WriteLine(tp);

// Output:
// * foo
// * bar
// * baz

我们可以规定策略在运行时可切换——这就是为什么我们称这种实现为动态策略。这是在SetOutputFormat()方法中完成的,它的实现很简单:

public void SetOutputFormat(OutputFormat format)
{
  switch (format) {
    case OutputFormat.Markdown:
      listStrategy = new MarkdownListStrategy();
      break;
    case OutputFormat.Html:
      listStrategy = new HtmlListStrategy();
      break;
    default:
      throw new ArgumentOutOfRangeException(nameof(format), format, null);
  }
}

现在,从一种策略切换到另一种策略是很简单的,您可以立即看到结果:

tp.Clear(); // erases underlying buffer
tp.SetOutputFormat(OutputFormat.Html);

tp.AppendList(new[] { "foo", "bar", "baz" });
WriteLine(tp);

// Output:
// <ul>
//   <li>foo</li>
//   <li>bar</li>
//   <li>baz</li>
// </ul>

静态策略

多亏了泛型的魔力,你可以将任何策略嵌入到类型中。仅需要对TextStrategy类进行最小的修改:

public class TextProcessor<LS>
  where LS : IListStrategy, new()
{
  private StringBuilder sb = new StringBuilder();
  private IListStrategy listStrategy = new LS();

  public void AppendList(IEnumerable<string> items)
  {
    listStrategy.Start(sb);
    foreach (var item in items)
      listStrategy.AddListItem(sb, item);
    listStrategy.End(sb);
  }

  public override string ToString() => return sb.ToString();
}

动态实现中的变化如下:我们添加了LS泛型参数,用这个类型创建了一个listStrategy成员,并开始使用它来代替我们之前的引用。调用调整后的AppendList()的结果与我们之前的结果相同。

var tp = new TextProcessor<MarkdownListStrategy>();
tp.AppendList(new []{"foo", "bar", "baz"});
WriteLine(tp);

var tp2 = new TextProcessor<HtmlListStrategy>();
tp2.AppendList(new[] { "foo", "bar", "baz" });
WriteLine(tp2);

前面示例的输出与动态策略的输出相同。请注意,我们必须创建两个TextProcessor实例,每个都有不同的列表处理策略,因为不可能中途切换类型的策略:它已经被嵌入到类型中了。

平等和比较策略

里面最广为人知的使用策略模式。NET 当然是平等和比较策略的使用。

考虑一个简单的类,如下所示:

class Person
{
  public int Id;
  public string Name;
  public int Age;
}

目前,你可以在一个List中放几个Person实例,但是在这样的列表中调用Sort()是没有意义的。

var people = new List<Person>();
people.Sort(); // does not do what you want

使用==!=操作符的比较也是如此:目前,所有这些比较都是基于引用的比较。

我们需要明确区分两种类型的操作:

  • 相等根据您定义的规则检查一个对象的两个实例是否相等。这由IEquatable<T>接口(Equals()方法)以及通常在内部使用Equals()方法的操作符==!=覆盖。

  • 比较允许您比较两个对象,并找出哪个小于、等于或大于另一个。这包含在IComparable<T>接口中,并且是排序之类的事情所需要的。

通过实现IEquatable<T>IComparable<T>,每个对象都可以公开自己的比较和相等策略。例如,如果我们假设人们有唯一的Id,我们可以使用 ID 值进行比较:

public int CompareTo(Person other)
{
  if (ReferenceEquals(this, other)) return 0;
  if (ReferenceEquals(null, other)) return 1;
  return Id.CompareTo(other.Id);
}

所以现在,调用people.Sort()是有意义的——它将使用我们编写的内置CompareTo()方法。但是有一个问题:一个典型的类只能有一个默认的CompareTo()实现来比较这个类和它自己。平等也是如此。那么如果你的比较策略在运行时改变了呢?

幸运的是,BCL 设计者也想到了这一点。我们可以在调用点指定比较策略,只需传入一个 lambda:

people.Sort((x, y) => x.Name.CompareTo(y.Name));

这样,即使Person的默认比较行为是按 ID 进行比较,如果需要,我们也可以按名称进行比较。

但这还不是全部!有第三种方法可以定义比较策略。如果一些策略是通用的,并且您想在类本身中保留它们,那么这种方法是有用的。

想法是这样的:定义一个实现了IComparer<T>接口的嵌套类。然后将该类作为静态变量公开:

public class Person
{
  // ... other members here
  private sealed class NameRelationalComparer :
 IComparer<Person>
  {
    public int Compare(Person x, Person y)
    {
      if (ReferenceEquals(x, y)) return 0;
      if (ReferenceEquals(null, y)) return 1;
      if (ReferenceEquals(null, x)) return -1;
      return string.Compare(x.Name, y.Name,
        StringComparison.Ordinal);
    }
  }

  public static IComparer<Person> NameComparer { get; }
    = new NameRelationalComparer();
}

正如您所看到的,前面的类定义了一个独立的策略,使用名称来比较两个Person实例。我们现在可以简单地获取这个类的一个静态实例,并将其提供给Sort()方法:

people.Sort(Person.NameComparer);

正如您可能已经猜到的,相等比较的情况非常相似:您可以使用一个IEquatable<T>,传入一个 lambda,或者生成一个实现了IEqualityComparer<T>的类。你的选择!

职能策略

策略模式的功能变化很简单:所有 OOP 结构都简单地被函数所取代。首先,TextProcessor从一个类退化为一个函数。这实际上是惯用的(即正确的做法),因为TextProcessor只有一个操作。

let processList items startToken itemAction endToken =
  let mid = items |> (Seq.map itemAction) |> (String.concat "\n")
  [startToken; mid; endToken] |> String.concat "\n"

前面的函数有四个参数:一个项目序列、起始标记(注意:这是一个标记,不是函数)、处理每个元素的函数和结束标记。因为这是一个函数,所以这种方法假设processList是无状态的,也就是说,它不在内部保存任何状态。

正如您从前面的文本中看到的,我们的策略不仅仅是一个简单的、自包含的元素,而是三个不同项目的组合:开始和结束标记以及对序列中的每个元素进行操作的函数。我们现在可以专门化processList,以便像以前一样实现 HTML 和 Markdown 处理:

let processListHtml items =
  processList items "<ul>" (fun i -> " <li>" + i + "</li>") "</ul>"

let processListMarkdown items =
  processList items "" (fun i -> " * " + i) ""

这就是您如何使用这些专业化,并获得可预测的结果:

let items = ["hello"; "world"]
printfn "%s" (processListHtml items)
printfn "%s" (processListMarkdown items)

关于这个例子值得注意的有趣的事情是,processList的接口绝对没有给出任何关于客户端应该作为itemAction提供什么的提示。他们只知道这是一个'a -> string,所以我们依靠他们来猜测它实际上是做什么的。

摘要

策略设计模式允许您定义算法的框架,然后使用组合来提供与特定策略相关的缺失的实现细节。这种方法有两种表现形式:

  • 动态策略简单地保持对当前使用的策略的引用。想换个不同的策略吗?换个参照物就行了。放轻松!

  • 静态策略要求你在编译时选择策略并坚持下去——以后没有改变主意的余地。

应该使用动态策略还是静态策略?嗯,动态的允许你在对象被构造后重新配置它们。想象一个控制文本输出形式的 UI 设置:你更愿意拥有一个可切换的TextProcessor还是两个类型为TextProcessor<MarkdownStrategy>TextProcessor<HtmlStrategy>的变量?这真的取决于你。

二十四、模板方法

策略和模板方法设计模式非常相似,以至于就像工厂一样,我很想将这些模式合并成某种“框架方法”设计模式。我会忍住冲动。

策略和模板方法的区别在于,策略使用组合(不管是静态的还是动态的),而模板方法使用继承。但是在一个地方定义算法的框架,在其他地方定义其实现细节的核心原则仍然存在,再次观察 OCP(我们简单地用扩展系统)。

游戏模拟

大多数棋盘游戏都非常相似:游戏开始(发生某种设置),玩家轮流玩,直到决定一个赢家,然后可以宣布赢家。不管是什么游戏——国际象棋,跳棋,还是别的什么——我们可以将算法定义如下:

public abstract class Game
{
  public void Run()
  {
    Start();
    while (!HaveWinner)
      TakeTurn();
    WriteLine($"Player {WinningPlayer} wins.");
  }
}

如您所见,运行游戏的run()方法只是使用了一组其他方法和属性。这些方法是抽象的,并且具有protected可见性,因此它们不会被外部调用:

protected abstract void Start();
protected abstract bool HaveWinner { get; }
protected abstract void TakeTurn();
protected abstract int WinningPlayer { get; }

平心而论,前面的一些成员,尤其是void -returning 成员,不一定要抽象。例如,如果一些游戏没有明确的start()过程,将start()作为抽象就违反了 ISP,因为不需要它的成员仍然必须实现它。在“策略”一章我们特意做了一个界面,但是用模板的方法,情况就不那么一目了然了。

现在,除了上述内容之外,我们还可以拥有某些与所有游戏相关的受保护字段——玩家数量和当前玩家的索引:

public abstract class Game
{
  public Game(int numberOfPlayers)
  {
    this.numberOfPlayers = numberOfPlayers;
  }

  protected int currentPlayer;
  protected readonly int numberOfPlayers;
  // other members omitted
}

从现在开始,Game类可以被扩展来实现一个国际象棋游戏:

public class Chess : Game
{
  public Chess() : base(2) { /* 2 players */ }

  protected override void Start()
  {
    WriteLine($"Starting a game of chess with {numberOfPlayers} players.");
  }

  protected override bool HaveWinner => turn == maxTurns;

  protected override void TakeTurn()
  {
    WriteLine($"Turn {turn++} taken by player {currentPlayer}.");
    currentPlayer = (currentPlayer + 1) % numberOfPlayers;
  }

  protected override int WinningPlayer => currentPlayer;

  private int maxTurns = 10;
  private int turn = 1;
}

一局国际象棋涉及两个玩家,所以这是提供给基类的构造函数的值。然后,我们继续覆盖所有必要的方法,实现一些非常简单的模拟逻辑,以便在十回合后结束游戏。我们现在可以使用带有 new Chess().Run()的类了——下面是输出:

Starting a game of chess with 2 players
Turn 0 taken by player 0
Turn 1 taken by player 1
...
Turn 8 taken by player 0
Turn 9 taken by player 1
Player 0 wins.

这差不多就是全部了!

功能模板法

正如您可能已经猜到的,模板方法的函数方法是简单地定义一个独立的函数runGame(),它将模板化的部分作为参数。唯一的问题是游戏是一个固有可变的 ?? 场景,这意味着我们必须有某种容器来表示游戏的状态。我们可以尝试使用一种记录类型:

type GameState = {
  CurrentPlayer: int;
  NumberOfPlayers: int;
  HaveWinner: bool;
  WinningPlayer: int;
}

有了这个设置,我们最终不得不将一个GameState的实例传递给作为模板方法一部分的每个函数。请注意,方法本身相当简单:

let runGame initialState startAction takeTurnAction haveWinnerAction =
  let state = initialState
  startAction state
  while not (haveWinnerAction state) do
    takeTurnAction state
  printfn "Player %i wins." state.WinningPlayer

象棋游戏的实现也不是特别困难,唯一真正的问题是内部状态的初始化和修改:

let chess() =
  let mutable turn = 0
  let mutable maxTurns = 10
  let state = {
    NumberOfPlayers = 2;
    CurrentPlayer = 0;
    WinningPlayer = -1;
  }
  let start state =
    printfn "Starting a game of chess with %i players" state.NumberOfPlayers

  let takeTurn state =
    printfn "Turn %i taken by player %i." turn state.CurrentPlayer
    state.CurrentPlayer <- (state.CurrentPlayer+1) % state.NumberOfPlayers
    turn <- turn + 1
    state.WinningPlayer <- state.CurrentPlayer

  let haveWinner state =
    turn = maxTurns

  runGame state start takeTurn haveWinner

所以,简单重述一下,我们在这里做的是在外部函数中初始化方法/函数所需的所有函数(这在 C# 和 F# 中都是完全合法的),然后将每个函数传递给runGame。还要注意,我们有一些可变的状态,在整个子函数调用中使用。

总的来说,如果您准备在代码中引入记录类型和可变性,使用函数而不是对象实现模板方法是完全可能的。当然,从理论上来说,你可以重写这个例子,通过存储每个游戏状态的快照,并在递归设置中传递它来摆脱可变状态——这将有效地把模板方法变成一种模板化的状态模式。试试看!

摘要

与使用组合并因此分为静态和动态变化的策略不同,模板方法使用继承,因此,它只能是静态的,因为一旦对象被构造,就没有办法操纵它的继承特征。

模板方法中唯一的设计决策是你是否希望模板方法使用的方法是抽象的或者实际上有一个主体,即使主体是空的。如果你预见到有些方法对于所有的继承者来说都是不必要的,那就让它们为空/非抽象,以符合 ISP。

二十五、访问者

为了解释这个模式,我将首先跳到一个例子中,然后讨论这个模式本身。希望你不要介意!

假设您已经解析了一个数学表达式(当然,使用了解释器模式!)由double值和加法运算符组成,例如:

(1.0 + (2.0 + 3.0))

此表达式可以使用类似于下面的对象层次结构来表示:

public abstract class Expression { /* nothing here (yet) */ }

public class DoubleExpression : Expression
{
  private double value;

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

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

  public AdditionExpression(Expression left, Expression right)
  {
    this.left = left;
    this.right = right;
  }
}

根据这个设置,您对两件事感兴趣:

  • 将 OOP 表达式打印为文本

  • 评估表达式的值

您还希望尽可能统一和简洁地完成这两件事(以及对这些树的许多其他可能的操作)。你会怎么做?嗯,有很多方法,我们将从打印操作的实现开始,逐一查看。

不速之客

最简单的解决方案是获取基类Expression并向其添加一个抽象成员。

public abstract class Expression
{
  // adding a new operation
  public abstract void Print(StringBuilder sb);
}

除了破坏 OCP 之外,这种修改还依赖于这样一个假设,即您实际上可以访问该层次结构的源代码——这并不总是有保证的。但我们总得从某个地方开始,对吧?因此,随着这种变化,我们需要在DoubleExpression中实现Print()(这很简单,所以我在这里省略了)以及在AdditionExpression中实现Print():

public class AdditionExpression : Expression
{
  ...
  public override void Print(StringBuilder sb)
  {
    sb.Append(value: "(");
    left.Print(sb);
    sb.Append(value: "+");
    right.Print(sb);
    sb.Append(value: ")");
  }
}

哦,这太有趣了!我们在子表达式上多态地递归调用Print()。太好了,让我们来测试一下:

var e = new AdditionExpression(
  left: new DoubleExpression(1),
  right: new AdditionExpression(
    left: new DoubleExpression(2),
    right: new DoubleExpression(3)));
var sb = new StringBuilder();
e.Print(sb);
WriteLine(sb); // (1.0 + (2.0 + 3.0))

嗯,这很简单。但是现在假设您在层次结构中有 10 个继承者(顺便说一下,在现实世界的场景中并不少见),您需要添加一些新的Eval()操作。那是需要在十个不同的类中完成的十个修改。但是 OCP 不是真正的问题。

真正的问题是 SRP。你知道,像印刷这样的问题是需要特别关注的。与其说每个表达式都应该打印自己,为什么不引入一个知道如何打印表达式的ExpessionPrinter?并且,稍后,您可以引入一个知道如何执行实际计算的ExpressionEvaluator——所有这些都不会以任何方式影响Expression层次结构。

反射式打印机

既然我们已经决定制作一个独立的打印机组件,让我们去掉Print()成员函数(当然,要保留基类)。

abstract class Expression
{
  // nothing here!
};

现在让我们试着实现一个ExpressionPrinter。我的第一反应会是这样写:

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

  public static void Print(AdditionExpression ae, StringBuilder sb)
  {
    sb.Append("(");
    Print(ae.Left, sb);  // will not compile!!!
    sb.Append("+");
    Print(ae.Right, sb); // will not compile!!!
    sb.Append(")");
  }
}

前面编译的几率:零。C# 知道,ae.Left是一个Expression,但是由于它不在运行时检查类型(不像各种动态类型的语言),它不知道调用哪个重载。太糟糕了!

这里能做什么?嗯,只有一件事——移除重载并在运行时检查类型:

public static class ExpressionPrinter
{
  public static void Print(Expression e, StringBuilder sb)
  {
    if (e is DoubleExpression de)
    {
      sb.Append(de.Value);
    }
    else if (e is AdditionExpression ae)
    {
      sb.Append("(");
      Print(ae.Left, sb);
      sb.Append("+");
      Print(ae.Right, sb);
      sb.Append(")");
    }
  }
}

前面的代码实际上是一个可用的解决方案:

var e = new AdditionExpression(
  left: new DoubleExpression(1),
  right: new AdditionExpression(
    left: new DoubleExpression(2),
    right: new DoubleExpression(3)));
var sb = new StringBuilder();
ExpressionPrinter.Print(e, sb);
WriteLine(sb);

这种方法有一个相当大的缺点:没有编译器检查,你,事实上,为层次结构中的每个元素实现了打印。当添加新元素时,您可以继续使用ExpressionPrinter而无需修改,它将跳过任何新类型的元素。

但这是一个可行的解决方案。说真的,很有可能就此打住,不再进一步研究访问者模式:is操作符没有那么昂贵,我认为许多开发人员会记得在if语句中覆盖每一种类型的对象。

扩展方法?

如果你认为分离出一个ExpressionPrinter的问题可以在不使用类型检查的情况下得到解决,这是可以理解的。可悲的是,这种设置也转移到使用反射访问者。

当然,你可以同时获取DoubleExpressionAdditionExpression并给它们Print()扩展方法,这些方法可以在驻留在其他地方时直接在对象上调用。然而,您的AdditionExpression.Print()实现仍然会有几个问题:

public static void Print(this AdditionExpression ae, StringBuilder sb)
{
  sb.Append("(");
  ae.Left.Print(sb); // oops
  sb.Append("+");
  ae.Right.Print(sb);
  sb.Append(")");
}

第一个问题是,由于这是一个扩展方法,我们需要将LeftRight成员公开,以便扩展方法可以访问它们。

但这不是真正的问题。这里的主要问题是ae.Left.Print()不能被调用,因为ae.Left是一般的Expression。你会如何支持它?嗯,这就是你通过在层次结构的根元素上实现一个扩展方法并执行类型检查来转移到反射打印机的地方:

public static void Print(this Expression e, StringBuilder sb)
{
  switch (e)
  {
    case DoubleExpression de:
      de.Print(sb);
      break;
    case AdditionExpression ae:
      ae.Print(sb);
      break;
    // and so on
  }
}

这个解决方案遇到了与原始方案相同的问题,即没有验证来确保switch语句覆盖了Expression的每个继承者*。现在,不可否认的是,这是我们实际上可以强制的,因此通过使用…反射赋予了反射打印机它真正的名字!*

Extension method classes are static, and can have both static fields and constructors, so we can map out all the inheritors and attempt to find the methods that handle them:

public static class ExpressionPrinter
{
  private static Dictionary<Type, MethodInfo> methods
    = new Dictionary<Type, MethodInfo>();

  static ExpressionPrinter()
  {
    var a = typeof(Expression).Assembly;
    var classes = a.GetTypes()
      .Where(t => t.IsSubclassOf(typeof(Expression)));
    var printMethods = typeof(ExpressionPrinter).GetMethods();
    foreach (var c in classes)
    {
      // find extension method that takes this class
      var pm = printMethods.FirstOrDefault(m =>
        m.Name.Equals(nameof(Print)) &&
        m.GetParameters()?[0]?.ParameterType == c);

      methods.Add(c, pm);
    }
  }
}

有了这个设置,为基本类型Expression实现的扩展方法Print()现在转移到:

public static void Print(this Expression e, StringBuilder sb)
{
  methods[e.GetType()].Invoke(null, new object[] {e, sb});
}

当然,这种方法有很大的性能成本。有一些方法可以抵消这些成本,比如使用Delegate.CreateDelegate()来避免存储那些MethodInfo对象,而是在需要时使用随时可以调用的委托。

最后,总有一个“核心选项”:生成在运行时创建这些调用的代码。当然,这也带来了一系列问题:您要么基于反射生成代码(这意味着您几乎总是落后一步,因为您需要二进制文件来提取类型信息),要么使用 Roslyn、ReSharper、Rider 或其他类似机制提供的解析器框架来检查实际编写的代码。

功能反射访问者

值得注意的是,前面采用的方法是确切地说是在诸如 F# 之类的语言中你将采用的方法,唯一的区别当然是,你将主要处理函数,而不是继承层次。

如果您不是在层次结构中定义表达式类型,而是在有区别的联合中定义表达式类型,例如

type Expression =
  | Add of Expression * Expression
  | Mul of Expression * Expression
  ...

那么您将实现的任何访问者很可能具有类似于下面的结构:

let rec process expr =
  match expr with
  | And(lhs,rhs) -> ...
  | Mul(lhs,rhs) -> ...
  ...

这种方法完全等同于我们在 C# 实现中采用的方法。一个match表达式中的每一种情况都将被有效地转化为一个is检查。然而,还是有很大的不同。首先,F# 中具体的 case 过滤器和 guard 条件比 C# 中嵌套的if语句更容易阅读。整个过程可能的递归性更具表现力。

改进

虽然在前面的例子中不可能静态地强制每个必要类型检查的存在,但是如果缺少适当的实现,那么有可能生成异常。为此,只需创建一个字典,将支持的类型映射到处理这些类型的 lambda 函数,即:

private static DictType actions = new DictType
{
  [typeof(DoubleExpression)] = (e, sb) =>
  {
    var de = (DoubleExpression) e;
    sb.Append(de.Value);
  },
  [typeof(AdditionExpression)] = (e, sb) =>
  {
    var ae = (AdditionExpression) e;
    sb.Append("(");
    Print(ae.Left, sb);
    sb.Append("+");
    Print(ae.Right, sb);
    sb.Append(")");
  }
};

现在,您可以用更简单的方式实现顶级的Print()方法。事实上,为了加分,您可以使用 C# 扩展方法机制来添加Print()作为任何Expression的方法:

public static void Print(this Expression e, StringBuilder sb)
{
  actionse.GetType();
}
// sample use:
myExpression.Print(sb)

对于 SRP 的目的来说,您是否在一个Printer上使用扩展方法或普通的静态或实例方法是完全不相关的。一个普通的类和一个扩展方法类都用来将打印功能与数据结构本身隔离开来,唯一的区别是你是否考虑打印Expression's API 的一部分,我个人认为这是合理的:我喜欢expression.Print()expression.Eval()等等的想法。尽管如果你是一个 OOP 纯粹主义者,你可能会讨厌这种方法。

什么是调度?

每当人们谈到来访者,就会提到派遣这个词。这是什么?简而言之,“分派”是一个计算要调用哪些方法的问题——具体来说,需要多少条信息才能进行调用。

这里有一个简单的例子:

interface IStuff { }
class Foo : IStuff { }
class Bar : IStuff { }

public class Something
{
  static void func(Foo foo) { }
  static void func(Bar bar) { }
}

现在,如果我创建一个普通的Foo对象,我可以用它调用func():

Foo foo = new Foo();
func(foo); // this is fine

但是如果我决定将它强制转换为基类型(接口或类),编译器将不知道调用哪个重载:

Stuff stuff = new Foo;
func(stuff); // oops!

现在,让我们从多方面考虑这个问题:有没有什么方法可以让我们强迫系统调用正确的重载,而不需要任何运行时(isas和类似的)检查?原来是有的。

看,当你在一个IStuff上调用某个东西时,那个调用可以是多态的,它可以被直接分派给必要的组件,而组件又可以调用必要的重载。这被称为双调度是因为以下原因:

  1. 首先对实际对象进行多态调用。

  2. 在多态调用中,调用重载。因为在对象内部,this有一个精确的类型(例如,FooBar),所以正确的重载被触发。

我的意思是:

interface Stuff {
  void call();
}
class Foo : Stuff {
  void call() { func(this); }
}
class Bar : Stuff {
  void call() { func(this); }
}

void func(Foo foo) {}
void func(Bar bar) {}

你能看到这里发生了什么吗?我们不能只把一个通用的call()实现粘在Stuff中:不同的实现必须在它们各自的类中,这样this指针才能被正确地类型化。

该实现允许您编写以下内容:

Stuff foo = new Foo;
foo.call();

这是一个示意图,展示了正在发生的事情:

            this = Foo
foo.call() ------------> func(foo)

动态访问者

让我们回到我声称没有零成功机会的ExpressionPrinter例子:

public class ExpressionPrinter
{
  public void Print(AdditionExpression ae, StringBuilder sb)
  {
    sb.Append("(");
    Print(ae.Left, sb);
    sb.Append("+");
    Print(ae.Right, sb);
    sb.Append(")");
  }

  public void Print(DoubleExpression de, StringBuilder sb)
  {
    sb.Append(de.Value);
  }
}

如果我告诉你,我可以通过添加两个关键字并提高Print(ae,sb)方法的计算成本来实现它,会怎么样?我相信你已经猜到我在说什么了。是的,我说的是动态调度:

public void Print(AdditionExpression ae, StringBuilder sb)
{
  sb.Append("(");
  Print((dynamic)ae.Left, sb);  // <-- look closely here
  sb.Append("+");
  Print((dynamic)ae.Right, sb); // <-- and here
  sb.Append(")");
}

为了支持动态类型语言,dynamic的全部业务都被添加到了 C# 中。其中一些语言的一个方面是动态调度的能力,也就是说,在运行时而不是编译时做出调用决策。这正是我们在这里所做的!

你可以这样称呼它:

var e  = ...; // as before
var ep = new ExpressionPrinter();
var sb = new StringBuilder();
ep.Print((dynamic)e, sb); // <-- note the cast here
WriteLine(sb);

通过将一个变量赋给dynamic,我们将调度决策推迟到运行时。因此,我们得到了正确的调用;只有几个问题,即:

  • 这种类型的分派会带来相当大的性能损失。

  • 如果缺少一个需要的方法,您将得到一个运行时错误。

  • 你可能会在继承方面遇到严重的问题。

如果您希望访问的对象图很小,并且调用不频繁,那么动态访问者是一个很好的解决方案。否则,性能损失可能会使整个努力难以为继。

经典访客

访问者设计模式的“经典”实现使用了双重分派。访问者成员函数的调用有一些约定:

  • 访问者的方法通常被称为Visit()

  • 在整个层次结构中实现的方法通常被称为Accept().

所以现在,我们再一次将一些东西放入基类Expression中:函数Accept()

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

正如你所看到的,前面的代码引用了一个名为IExpressionVisitor的接口类型,它可以作为各种访问者的基本类型,比如ExpressionPrinterExpressionEvaluator等等。现在,Expression的每一个实现者现在都被要求以相同的方式实现Accept(),特别是:

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

从表面上看,这似乎违反了干(不要重复自己),另一个自我描述的原则。然而,如果你仔细想想,每个实现者都会有一个不同类型的this引用,所以这不是另一个静态分析工具如此喜欢抱怨的剪切粘贴编程的例子。

现在,在另一边,我们可以如下定义IExpressionVisitor接口:

public interface IExpressionVisitor
{
  void Visit(DoubleExpression de);
  void Visit(AdditionExpression ae);
}

注意,我们绝对必须为所有表达式对象定义重载;否则,在实现相应的Accept()时,我们会得到一个编译错误。我们现在可以实现这个接口来定义我们的ExpressionPrinter:

public class ExpressionPrinter : IExpressionVisitor
{
  StringBuilder sb = new StringBuilder();

  public void Visit(DoubleExpression de)
  {
    sb.Append(de.Value);
  }

  public void Visit(AdditionExpression ae)
  {
    // wait for it!
  }

  public override string ToString() => sb.ToString();
}

DoubleExpression的实现非常明显,但是下面是AdditionExpression的实现:

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

注意现在调用是如何发生在子表达式本身上的,再次利用了双重分派。至于新的双派遣访问者的用法,这里是:

var e = new AdditionExpression(
  new DoubleExpression(1),
  new AdditionExpression(
    new DoubleExpression(2),
    new DoubleExpression(3)));
var ep = new ExpressionPrinter();
ep.Visit(e);
WriteLine(ep.ToString()); // (1 + (2 + 3))

遗憾的是,不可能构造一个类似于前面实现的扩展方法,因为,你猜怎么着,静态类不能实现接口。如果您想将表达式 printer 隐藏在稍微好一点的 API 后面,您可以使用以下方法:

public static class ExtensionMethods
{
  public void Print(this DoubleExpression e, StringBuilder sb)
  {
    var ep = new ExpressionPrinter();
    ep.Print(e, sb);
  }
  // other overloads here
}

当然,实现所有正确的重载取决于您,所以这种方法实际上没有太大帮助,并且不提供安全检查来确保您已经为每个Expression继承者重载。

实现附加访问者

那么,双重调度方法的优势是什么呢?好处是你只需要通过层次结构实现一次成员。你再也不用碰任何一个成员了。例如,假设您现在想要有一种方法来评估表达式的结果。这很容易,但是需要记住的是,Visit()目前被声明为一个void方法,所以AdditionExpression的实现可能看起来有点奇怪:

public class ExpressionCalculator : IExpressionVisitor
{
  public double Result;

  public void Visit(DoubleExpression de)
  {
    Result = de.Value;
  }

  public void Visit(AdditionExpression ae)
  {
    // in a moment!
  }

  // maybe, what you really want is double Visit(...)
}

…但是需要记住的是,Visit()目前被声明为一个void方法,所以一个AdditionExpression的实现可能看起来有点奇怪:

public void Visit(AdditionExpression ae)
{
  ae.Left.Accept(this);
  var a = Result;
  ae.Right.Accept(this);
  var b = Result;
  Result = a + b;
}

前面的代码是无法从Accept()得到return的副产品,所以我们将结果缓存在变量ab中,然后返回它们的总和。它工作得很好:

var calc = new ExpressionCalculator();
calc.Visit(e);
WriteLine($"{ep} = {calc.Result}");
// prints "(1+(2+3)) = 6"

这种方法的有趣之处在于,现在您可以在单独的类中编写新的访问者,即使您无法访问层次结构本身的源代码。除了让你的代码更容易理解之外,这也让你忠于 SRP 和 OCP。

img/476082_2_En_25_Figa_HTML.jpg

非循环访问者

现在是一个很好的时机来提及访问者设计模式实际上有两种类型。它们如下:

  • 循环访客,基于函数重载。由于层次结构(必须知道访问者的类型)和访问者(必须知道层次结构中每个类的*)之间的循环依赖,该方法的使用仅限于不经常更新的稳定层次结构。*

  • 非循环 访问者,这也是基于类型转换的。这里的优点是对被访问的层次结构没有限制,但是正如您可能已经猜到的那样,这里存在性能问题。

非循环访问者实现的第一步是实际的访问者接口。我们没有为层次结构中的每一个类型定义一个Visit()重载,而是尽可能地使事情通用化:

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

我们需要领域模型中的每个元素都能够接受这样的访问者,但是由于每个专门化都是唯一的,我们所做的就是引入一个标记接口——一个空接口,里面什么也没有。

public interface IVisitor {} // marker interface

前面的接口没有成员,但是我们使用它作为我们想要实际访问的任何对象中的Accept()方法的参数。现在,我们能做的是重新定义我们之前的Expression类,如下所示:

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

下面是新的Accept()方法的工作原理:我们获取一个IVisitor,然后尝试将其转换为一个IVisitor<T>,其中T是我们当前所在的类型。如果转换成功,这个访问者知道如何访问我们的类型,所以我们调用它的Visit()方法。如果它失败了,那就没用了。关键是要理解为什么typed本身没有一个我们可以调用的Visit()。如果是这样的话,就需要为每一个有兴趣调用它的类型重载,这正是引入循环依赖的原因。

在我们模型的其他部分实现了Accept()(同样,每个Expression类中的实现是相同的)之后,我们可以通过再次定义一个ExpressionPrinter将所有东西放在一起,但是这一次,它看起来如下:

public class ExpressionPrinter : IVisitor, IVisitor<Expression>,

    IVisitor<DoubleExpression>,
    IVisitor<AdditionExpression>
{
  StringBuilder sb = new StringBuilder();

  public void Visit(DoubleExpression de) { ... }

  public void Visit(AdditionExpression ae) { ...  }

  public void Visit(Expression obj)
  {
    // default handler?
  }

  public override string ToString() => sb.ToString();
}

如你所见,我们实现了IVisitor标记接口以及一个Visitor<T>用于我们想要访问的每个T。如果我们省略了一个特定的类型T(例如,假设我注释掉了Visitor<DoubleExpression>),程序仍然会编译,并且相应的Accept()调用,如果它来了,将简单地作为空操作执行

在前面的文本中,Visit()方法的实现与我们在传统的 visitor 实现中的实现是相同的,结果也是如此。

然而,这个例子和传统的访问者之间有一个基本的区别。传统的访问者使用一个接口,而我们的非循环访问者使用一个抽象类作为层次结构的根。这是什么意思?嗯,一个抽象类可以有一个可以用作“后备”的实现,这就是为什么在ExpressionPrinter的定义中,我可以实现IVisitor``<Expression>并提供一个Visit(Expression obj)方法,该方法可以用来处理缺失的Visit()重载。例如,您可以在这里添加日志记录,或者抛出一个异常。

img/476082_2_En_25_Figb_HTML.jpg

功能访问者

当我们讨论解释器设计模式时,我们已经看到了实现访问者的函数方法,所以我在这里不再重复。一般的方法归结为使用类型的模式匹配和其他有用的特性(比如列表理解)遍历递归的有区别的联合(当然,假设您使用的是列表)。

函数范式中的访问者与面向对象的范式有着本质的不同。在面向对象编程中,访问者是一种机制,它“在侧面”为一组相关的类提供额外的功能,同时理想地能够

  • 将功能组合在一起

  • 避免类型检查,而是依靠接口

有区别的联合上的模式匹配相当于使用is C# 关键字(isinst IL 指令)来检查每种类型。然而,与 C# 不同,F# 会告诉您丢失的情况,因此它提供了更大的编译时安全性。

因此,与 OOP 实现相比,规范的 F# 访问者将实现一个反射访问者方法。

F# 中 Visitor 的实现有很多问题。首先,正如我们之前提到的,受歧视的工会本身打破了 OCP,因为除了改变他们的定义之外,没有其他方法来扩展他们。但是 Visitor 实现使问题变得更加复杂:因为我们的函数 Visitor 本质上是一个巨大的switch语句,所以增加对特定类型支持的唯一方法就是在 Visitor 中也违反 OCP!

摘要

访问者设计模式允许我们向对象层次结构中的每个元素添加一些独特的行为。我们看到的方法包括以下几种:

  • 介入式:向层次结构中的每个对象添加一个方法。有可能(假设你有源代码),但打破 OCP。

  • 反射:添加一个不需要改变对象的单独访客;每当需要运行时调度时,使用is / as

  • 动态:通过将层次对象强制转换为dynamic,强制通过 DLR 进行运行时调度。这以非常大的计算成本提供了最好的接口。

  • 经典(双重调度):整个等级被修改,但是只有一次,而且是以一种非常普通的方式。这个层级的每一个成员都学会了如何接待访客。然后,我们对 visitor 进行子类化,以在各个方向增强层次结构的功能。

** 非循环:就像反射类一样,为了正确调度而进行强制转换。然而,它打破了访问者和被访问者之间的循环依赖,允许访问者更加灵活的组合。*

访问者经常与解释器模式一起出现:在解释了一些文本输入并将其转换成面向对象的结构之后,我们需要,例如,以特定的方式呈现抽象语法树。Visitor 帮助在整个层次结构中传播一个StringBuilder(或类似的累加器对象)并将数据整理在一起。*