孜孜不倦的程序员

多模式 .NET,第 8 部分:动态编程

Ted Neward

Ted Neward
上个月的文章中,我们介绍了由 Microsoft .NET Framework 语言支持的三种元编程功能的第三种,即参数化多态性(泛型),并讨论了它如何在结构和行为方面提供可变性。就目前来说,参数化元编程提供了一些功能强大的解决方案。但是它不是每个设计问题的最终答案 — 任何编程模式都不是。

例如,考虑一下上次的示例中用作试验台的 Money<> 类(请参见图 1)。在上次的示例中,我们使用货币作为类型参数,主要是为了避免未经过官方汇率转换,编译器就意外允许将欧元转换为美元。

图 1 Money<>

class USD { }
class EUR { }
class Money<C> {
  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator 
    +(Money<C> lhs, Money<C> rhs) {
    return new Money<C>() { 
      Quantity = lhs.Quantity + rhs.Quantity, 
      Currency = lhs.Currency };
  }

  public Money<C2> Convert<C2>() where C2 : new() {
    return new Money<C2>() { Quantity = this.Quantity, 
      Currency = new C2() };
  }
}

正如上次指出的,能够在货币类型之间进行转换是非常重要的,而 Convert<> 例程的预期功能就是执行此类转换 — 这使我们能够将美元转换为欧元、比索、加拿大元或任何我们想转换的货币类型。

但这意味着需要某些货币转换代码,而图 1 中的实现中明显没有这种代码 — 现在,我们只需要进行一对一的转换,即只需要将 Currency 属性转换为新的 C2 货币类型,但这种转换却不成功。

我的钱,您的钱,都是合法的真钱

要解决这个问题,我们需要一些转换例程,以进行货币转换的计算,而有许多不同的解决方案可以实现这个目的。 一种方法是再次利用继承轴线,使用专门用于将 USD 和 EUR 转换为 ICurrency 类型的例程来进行此类转换。 使用这种方法时,首先要定义 ICurrency 类型,并将 USD 和 EUR 标记为该接口的实现者,如图 2 所示。

图 2 ICurrency

interface ICurrency { }
class USD : ICurrency { }
class EUR : ICurrency { }
class Money<C> where C : ICurrency {
  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator+(Money<C> lhs, 
    Money<C> rhs) {

    return new Money<C>() { 
      Quantity = lhs.Quantity + rhs.Quantity, 
      Currency = lhs.Currency };
  }

  public Money<C2> Convert<C2>() where C2 : new() {
    return new Money<C2>() { Quantity = this.Quantity, 
      Currency = new C2() };
  }
}

到目前为止,此策略很有效。 实际上,Money<> 中对类型参数增加类型约束是一个有用的改进技巧,可以确保我们不会有 Money<string> 或 Money<Button>。 它看起来有点奇怪,但此技巧(在 Java 中称为“标记接口”)很有趣且非常重要。

在 Java 中,在 Java 5 获得自定义属性的等效属性之前,我们使用此技巧对类型作出静态声明。 与 .NET Framework 使用 [Serializable] 来表明可以将某个类序列化成字节流一样,Java 类从 Serializable 接口(无成员)实现(继承)。

虽然我们愿意使用自定义属性将 USD 和 EUR 标记为 [Currency],但类型约束无法杜绝使用自定义属性,而且对 C 施加这种类型约束是一项重要改进,因此我们采用标记接口。 这有些奇怪,但如果您将接口看作是一种编写有关此类型是什么(而不仅仅是此类型能做什么)的声明语句的方式,这就说得通了。

(当我们解决这个问题时,我们将添加构造函数以便于实例化 Money<>。)

但尝试在 ICurrency 中声明货币转换会立即遇到障碍:ICurrency 无法识别任何子类型(具体的货币类型),因此我们在此无法声明一种方法来获取 Money<USD>,我们通过自动调整转换计算来将 Money<USD> 转换为 Money<EUR>。 (在这里,实际实施将是某种基于 Internet 的查找或 Web 服务,但现在,让我们假定汇率是静态的。)但即使我们可以做到,尝试编写上述方法也是非常棘手的,因为我们需要基于两个类型(转换的源货币和目标货币)和一个变量(要转换的金额)进行分派。

我们喜欢将货币作为一个类型,这意味着我们初次尝试编写此方法时可能会编写出如下结果:

interface ICurrency {
  float Convert<C1, C2>(float from);
}

那么,看上去我们可以编写类似于下面的内容来使 Convert 方法专用于派生类型:

class USD : ICurrency {
  public float Convert<USD, EUR>(float from) { 
    return (from * 1.2f); }
  public float Convert<EUR, USD>(float from) { 
    return (from / 1.2f); }
}

唉,这样做可就错了。 与 C1 和 C2 一样,编译器将 USD 和 EUR 解释为类型参数。

接下来,我们可能会尝试编写以下内容:

class USD : ICurrency {
  public float Convert<C1,C2>(float from) {
    if (C1 is USD && C2 is EUR) {
    }
  }
}

但编译器会再次发出警报:C1 是一个“类型参数”,但它却被用作“变量”。换句话说,我们无法像 C1 本身就是类型一样来使用它。 它只是一个占位符。 咳,这个办法行不通!

一个可能的解决方案是直接将类型作为基于 Reflection 的 Type 参数来传递,这将生成如图 3 所示的代码。

图 3 使用基于 Reflection 的 Type 参数

interface ICurrency {
  float Convert(Type src, Type dest, float from);
}

class USD : ICurrency {
  public float Convert(Type src, Type dest, float from) {
    if (src.Name == "USD" && dest.Name == "EUR")
      return from / 1.2f;
    else if (src.Name == "EUR" && dest.Name == "USD")
      return from * 1.2f;
    else
      throw new Exception("Illegal currency conversion");
  }
}

class EUR : ICurrency {
  public float Convert(Type src, Type dest, float from) {
    if (src.Name == "USD" && dest.Name == "EUR")
      return from / 1.2f;
    else if (src.Name == "EUR" && dest.Name == "USD")
      return from * 1.2f;
    else
      throw new Exception("Illegal currency conversion");
  }
}

class Money<C> where C : ICurrency, new() {
  public Money() { Currency = new C(); }
  public Money(float amt) : this() { Quantity = amt; }

  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator +(Money<C> lhs, Money<C> rhs) {
    return new Money<C>(lhs.Quantity + rhs.Quantity);
  }

  public Money<C2> Convert<C2>() where C2 : ICurrency, new() {
    return new Money<C2>(
      Currency.Convert(typeof(C), typeof(C2), this.Quantity));
  }
}

这种解决方案行得通,因为代码编译成功并且有效运转,但存在许多陷阱:必须在 USD 类和 EUR 类之间复制转换代码,当添加新货币(如英镑 (GBP))时,不仅需要新的 GBP 类(这是预计到的),还需要修改 USD 和 EUR 以包括 GBP。 很快这就会变得一团糟。

名称包含什么?

在传统的面向对象的编程 (OOP) 语言中,开发人员已经能够通过使用虚拟方法基于单一的类型进行分派。 根据调用方法时所依据的引用背后的实际类型,编译器将请求发送至适当的方法实现。 (例如,这是经典的 ToString 方案。)

然而,在这种情况下,我们想基于两个类型(C1 和 C2)来进行分派 — 有时被称为双重分派。 除了“访问者”设计模式外,传统的 OOP 没有更好的解决方案,但坦白地说,许多开发人员都认为“访问者”设计模式根本不是一个好的解决方案。 它需要创建一个包含类的单一用途的层次结构。 随着新类型的引入,整个层次结构内将涌现大量的方法以容纳各个新类型。

但后退一步,我们便有机会重新审视问题。 虽然需要类型安全来确保 Money<USD> 和 Money<EUR> 实例不会被混淆,但除了将它们用作类型参数之外,我们实际上需要类型 USD 和 EUR 的时候并不多。 换句话说,出于货币转换的目的,我们关心的只是它们的名称,而不是它们的类型。 它们的名称允许另一种形式的可变性,有时称为名称绑定或动态编程。

动态语言与 动态编程

乍一看,动态语言与动态编程之间好像存在内在联系 — 从一定程度上讲,它们之间的确有联系,但这种联系仅仅在于:动态语言将名称绑定执行这一概念用到了极致。 诸如 Ruby、Python 或 JavaScript 等动态语言不是在编译时确定目标方法或类是否存在,而是仅仅假定它们存在,并且尽可能在最后一刻对它们进行查询。

当然,结果是 .NET Framework 使内行的设计人员可以同样灵活地使用 Reflection 进行绑定。 您可以创建一个包含货币名称的静态类,然后使用 Reflection 调用该类,如图 4 所示。

图 4 使用 Reflection 进行动态绑定

static class Conversions {
  public static Money<EUR> USDToEUR(Money<USD> usd) { 
    return new Money<EUR>(usd.Quantity * 1.2f); 
  }

  public static Money<USD> EURToUSD(Money<EUR> eur) { 
    return new Money<USD>(eur.Quantity / 1.2f); 
  }
}

class Money<C> where C : ICurrency, new() {
  public Money() { Currency = new C(); }
  public Money(float amt) : this() { Quantity = amt; }

  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator +(Money<C> lhs, Money<C> rhs) {
    return new Money<C>(lhs.Quantity + rhs.Quantity);
  }

  public Money<C2> Convert<C2>() where C2 : ICurrency, new() {
    MethodBase converter = typeof(Conversions).GetMethod(
      typeof(C).Name + "To" + typeof(C2).Name);
    return (Money<C2>)converter.Invoke(null, new object[] { this });
  }
}

添加新货币(如英镑)意味着创建空的 GBP 类(实现 ICurrency),并向 Conversions 添加必要的转换例程即可。

当然,C# 4(以及之前的 Visual Basic 的几乎每一个版本)提供内置功能来方便这些操作,但前提是我们在编译时知道名称。 C# 提供动态类型,而 Visual Basic 数十年以来一直将 Option Strict 和 Option Explicit 都指定为 Off。

实际上,从 Apple Objective-C 可以看出,动态编程并不一定非要局限于解释性语言。 Objective-C 是一种编译语言,在其框架中普遍使用动态编程,尤其是用于事件处理绑定。 希望接收事件的客户端只需提供正确命名的事件处理方法。 当发送者想告知客户端一些有趣的事情时,它按名称查找方法并调用该方法(如果有)。 (记忆力好的人还能记得,这也正是 Smalltalk 的运作方式。)

当然,名称绑定解决方案也有自己的缺陷,其中大多数都是在进行错误处理时发现的。 如果您认为应该存在的方法或类并不存在,会发生什么情况呢? 对某些语言(如 Smalltalk 以及 Objective-C 的 Apple 实现)来说,不会发生任何情况。 但有的语言(如 Ruby)却建议抛出一个错误或异常。

正确答案在很大程度上取决于域本身。 在 Money<> 示例中,如果期望某些货币不能被转换是合理的,那么当缺失转换例程时,便会触发面向用户的某种消息。 如果系统中的所有货币均可转换,那么很明显,这是开发人员出错了,这种错误将在单元测试中被查出来。 实际上,这就很好地说明了以下一点:如果没有先成功地通过一系列重要的单元测试,那么就不应该将动态编程解决方案向毫无戒心的公众公开。

创建通用性

名称绑定可变性为通用性/可变性分析提供了一种有效的机制,而动态编程并不满足于此。 使用 CLR 中提供的全保真元数据功能,可以尝试通过名称之外的其他标准来创建通用性:方法返回类型、方法参数类型等。 实际上,也可以这么认为:属性元编程仅仅是动态编程基于自定义属性的一个分支。 此外,名称绑定可变性不一定非要与整个名称绑定在一起。 NUnit 单元测试框架的早期版本假定测试方法是以字符“test”开头的任何方法。

在我的下个专栏中,我们将探讨通用 .NET Framework 语言中的最后一个模式,即函数式编程的模式,并将探讨它是怎样提供了另一种用于查看通用性/可变性分析的方法 — 这与传统的忠于对象的人的观点几乎正好相反。   

Ted Neward是 Neward & Associates 的负责人,这是一家专门研究企业 .NET Framework 系统和 Java 平台系统的独立公司。他曾写过 100 多篇文章,是 C# 领域最优秀的专家之一;他是 INETA 发言人;著有并合著过十几本书,包括《Professional F# 2.0》(Wrox,2010 年)。他定期担任顾问和导师,请通过 ted@tedneward.com 与他联系,或通过 blogs.tedneward.com 访问其博客。

衷心感谢以下技术专家对本文的审阅:Mircea Trofin