孜孜不倦的程序员

多模式 .NET,第 7 部分:参数化元编程

Ted Neward

image: Ted Neward
在我读大学时,在一节充斥了微积分计算的微观经济学课上,教授跟我们分享了一些至理名言,让我直到今天都会产生共鸣:

“假如,在我们为选定的题材苦苦寻找枯燥的详细资料时,你发现自己不明白为什么要这么做,那你应该打断我说,‘Anderson 教授,这么做有什么用?’,然后我们将花一点时间回顾过去并解释我们为什么要这么做。”

通读了本系列所有文章的读者可能也遭遇了这一问题,所以让我们花费一点时间回顾过去,看看我们是如何走到这一步的。

回顾

正如 James Coplien 在他的著作《Multi-Paradigm Design for C++》(Addison-Wesley,1998 年)(这部著作也为本系列文章的撰写提供了许多灵感)中所述,所有的编程活动就其本质而言,都是在捕捉通用性(编写能够代表“始终如此”的代码),然后使用语言中的变异结构,使代码的行为或结构在某些情况下表现得有所不同。

例如,面向对象的编程捕捉类的通用性,然后通过继承(即创建改变通用性的子类)来实现可变性。 通常情况下,这是通过使用方法或消息(具体取决于所用的语言)改变类的特定部分的行为来实现的。 但是项目对通用性和可变性的需要不会一直恰巧适合面向对象模式,也不会一直恰巧适合其他任何一种模式,而且面向对象编程起源于过程化编程,其初衷是试图提供过程化编程不能轻松获得的可变性。

对于本杂志的读者而言,幸运的是 Microsoft 在 Visual Studio 2010 中提供的语言是多模式语言,这意味着他们将几种不同的编程模式融入一种语言中。 Coplien 首先将 C++ 确认为多模式语言,它汇集了三种主要的模式:过程化、对象和元编程(有时候也更为准确地称为元对象)。 C++ 作为一种复杂的语言也广受批评,对于普通的开发人员来说它难以掌握,主要是因为很难判断什么时候需要使用语言的各种功能来解决特定的问题。

现代语言经常发展为高度多模式化的语言。 Visual Studio 2010 中的 F#、C# 和 Visual Basic 都直接支持五种模式:过程化、面向对象、元对象、动态和函数式。 所有这三种语言(如果算上 C++/CLI,就是四种)可能因此会落入与 C++ 相同的命运。

如果不能深入了解融入这些语言的每一种模式,开发人员很容易就会落入最常见的功能陷阱:开发人员过分依赖某一功能或模式,而排除了其他功能或模式,从而创建出过于复杂的代码,最终不得不将其放弃并重写。 多次出现这样的情况,该语言就首当其冲地成为开发人员抱怨的对象,导致最后需要一种新的语言或不再使用该语言。

过程化模式和面向对象模式

到目前为止,我们已经对过程化或结构化编程进行了通用性/可变性分析。在分析过程中,我们捕捉数据结构的通用性,然后将它们放入不同的过程调用以操作这些结构,同时通过创建新的过程调用来操作相同的数据结构,以此创建可变性。 我们还谈到对象的通用性/可变性,在这个过程中我们将共性捕捉到类中,然后为这些类创建子类并通过改写方法或属性来更改它们的组成部分,以此创建可变性。

还要记住,在这种继承中也产生了另一个问题(在大多数情况下),即只允许正可变性 - 我们无法从基类中删除内容,例如成员方法或字段。 在 CLR 中,我们可以隐藏派生访问成员的实现,方法是将其隐藏起来(例如使用 virtual 关键字而不是 C# 中的 override 关键字)。 然而,这意味着使用其他内容代替其行为而不是彻底将其删除。 无论如何字段依然存在。

这一结果导致令人不安的现实:对象不能满足我们所有的需求 - 至少纯粹的对象不能。 例如,对象不能使用继承沿结构行捕捉可变性:这是一个捕捉类似堆栈行为的集合类,但各种不同的数据类型(整数、双精度、字符串等)却不能捕捉这种结构差异。 当然,在 CLR 中我们可以使用统一类型系统。 我们可以存储 System.Object 的引用实例,根据需要向下转换,但这与能够创建只存储一种类型的类型不同。

这一现实引导我们走向元对象编程,因为我们要寻找方法来捕捉传统对象轴之外的内容。

在这样的元方法中,第一类是生成,即源代码是基于某种模板生成的。 此方法允许在各个不同轴上具有一些可变性,但仅限于(在大多数情况下)源代码级执行模型。 当可变性的数量增加时它会开始崩溃,因为源模板必须以某种方式在代码生成时更改生成的代码(通常会有决策语句隐藏在模板语言中),而这会给模板增加复杂性。

第二类元方法是反射或属性编程。 在运行时,代码使用平台的完全保真的元数据工具(反射)检查代码并根据其看到的内容而做出不同行为。

这让运行时决策获得了实现或行为上的灵活性,但是也引入了其自身的限制:反射/属性设计中不存在类型关系,这意味着与 XML 持久类型相比,没有办法以编程方式确保只有数据库持久类型能够传递到方法。 缺乏继承或其他关系意味着一定程度的类型安全(也是一项防止出错的重要功能)因此丢失。

这就将我们带到了 .NET 环境中的第三种元对象机制:参数化多态性。 这意味着能够定义以类型作为参数的类型。 或者,简单来说,就是 Microsoft .NET Framework 所指的泛型。

泛型

简而言之,泛型允许编译时创建的类型由客户端代码在编译过程中提供部分结构。 换句话说,堆栈行为集合的开发人员在他的库进行编译时不需要知道他的客户端要在不同实例中存储哪些类型,因为它们在创建集合的实例时会提供此信息。

例如,在之前的文章中,我们看到笛卡尔点的定义要求提前决定(由 Point 的开发人员做出)表示轴的值(X 和 Y)。 它们应该是整数值吗? 它们可以是负值吗?

应用于数学的笛卡尔点很可能需要浮点值和负值。 笛卡尔点用于表示计算机图形屏幕像素时则需要正值、整数,并且可能在一定的数值内,因为 40 亿乘以 40 亿的计算机显示器还未普及。

因此,从表面上看,一个设计良好的笛卡尔点库将需要几个不同的 Point 类型:一个使用无符号字节作为 X 和 Y 字段,一个使用双精度作为 X 和 Y 字段等等。 在大多数情况下,这种行为适用于这些所有类型,但这很明显与捕捉通用性的愿望相冲突(通俗地称为 DRY 原则:“切勿重复”)。

使用参数化多态性,我们可以巧妙地捕捉这种通用性:

class Point2D<T> {
  public Point2D(T x, T y) { this.X = x; this.Y = y; }

  public T X { get; private set; }
  public T Y { get; private set; }
  // Other methods left to the reader's imagination
}

现在开发人员能够明确指出他想要使用的笛卡尔点的范围和类型属性。 用于数学领域中时,他创建 Point2D<double> 值的实例;将这些值用在屏幕上时,他创建 Point2D<sbyte> 或 Point2D<ushort> 的实例。 每一个都是其自己的特有类型,所以在编译时尝试进行比较或将 Point2D<sbyte> 分配到 Point2D<double> 将惨遭失败,这也正是强类型化语言所偏好的。

但是正如文中所写,Point2D 类型还存在一些弊端。 当然,我们已经捕捉了笛卡尔点的通用性,我们实质上已可以将任何值用作 X 和 Y 值。 虽然这无疑会在某些方案中非常有用(“我们在此图中展示每个人对某部电影的评级”),但作为一般规则,尝试创建 Point2D<DateTime> 可能会使人困惑,而尝试创建 Point2D<System.Windows.Forms.Form> 则肯定使人困惑。 在此我们需要引入一些负可变性(或者如果您喜欢,限制正可变性的程度),限制能够成为 Point2D 中的轴值的类型。

很多 .NET 语言通过参数设定限制(有时也称为类型限制)来捕捉这种负可变性,明确描述类型参数应具有的条件:

class Point2D<T> where T : struct {
  public Point2D(T x, T y) { this.X = x; this.Y = y; }

  public T X { get; private set; }
  public T Y { get; private set; }
  // Other methods left to the reader's imagination
}

这意味着编译器不会为 T 接受任何除值类型以外的内容。

老实说,这本身并不完全是负可变性,但它的作用相当于尝试删除特定的功能,与真正的负可变性的作用差不多。

变化行为

参数化多态性通常用于在结构轴上提供可变性,但是 C++ Boost 库的开发人员已经证实,结构轴不是它唯一能够进行操作的轴。 通过明智地使用类型约束,我们也可以使用泛型来提供一种策略机制,其中的客户端可以为正在构造的对象指定一种行为机制。

请思考一下诊断日志的传统难题:我们想要诊断在服务器上(或者甚至在客户端计算机上)运行的代码的问题,需要通过代码库跟踪代码的执行。 这通常意味着将消息写入文件。 但有时我们希望消息显示在控制台上(至少是在某些情况下),有时我们想要丢弃消息。 这些年来,处理诊断日志消息一直是个复杂的问题,这方面的解决方案也层出不穷。 Boost 的教训也为我们提供了新的方法。

首先,我们定义一个接口:

interface ILoggerPolicy {
  void Log(string msg);
}

这是一个简单的接口,它的一种或多种方法能够定义我们想要更改的行为,而这通过接口的一系列子类型实现:

class ConsoleLogger : ILoggerPolicy {
  public void Log(string msg) { Console.WriteLine(msg); }
}

class NullLogger : ILoggerPolicy {
  public void Log(string msg) { }
}

在此我们有两种可能的实现,一种是将日志消息写入控制台,另一个则将消息丢弃。

采用这一方法需要客户端声明日志程序是类型化的参数,并为其创建一个实例来进行实际的日志记录:

class Person<A> where A : ILoggerPolicy, new() {
  public Person(string fn, string ln, int a) {
    this.FirstName = fn; this.LastName = ln; this.Age = a;
    logger.Log("Constructing Person instance");
  }

  public string FirstName { get; private set; }
  public string LastName { get; private set; }
  public int Age { get; private set; }

  private A logger = new A();
}

然后描述要使用哪个记录程序类型只需传递构造函数时间参数即可,如下所示:

Person<ConsoleLogger> ted = 
  new Person<ConsoleLogger>("Ted", "Neward", 40);
var anotherTed  = 
  new Person<NullLogger>("Ted", "Neward", 40);

这一机制允许开发人员创建自己的自定义日志记录实现,并将它们插入以供 Person<> 实例使用,而无需 Person<> 开发人员知道有关所用的日志记录实现的详细信息。 但是很多其他方法也可以如此,例如拥有从外部传入 Logger 实例的 Logger 字段或属性(或者通过 Dependency-Injection 方法获得)。 基于泛型的方法具有一个基于字段的方法所没有的优势,那就是编译时间的区别:Person<ConsoleLogger> 是一个与 Person<NullLogger> 截然不同的独立类型。

资金,资金,资金

另一个困扰开发人员的问题是如果单位未量化,数量就是无效的。 1,000 便士很显然与 1,000 匹马或 1,000 名员工或 1,000 块披萨是不同的。 但无疑 1,000 便士与 10 美元实际上是同等价值的。

这在需要捕捉单位(度/弧度、英尺/米、华氏温度/摄氏温度)的数学计算中变得格外重要,特别是当您为大型火箭编写控制软件时,尤其如此。 想想 Ariane 5,由于单位换算错误,首次飞行就不得不自毁。 或者探索火星的 NASA 探测器,其中一个也由于换算错误而导致全速撞上了火星上的景观。

最近,像 F# 等新兴语言已经决定将适应测量单位作为一种直接的语言功能,而因为泛型的缘故,甚至像 C# 和 Visual Basic 也能做类似的工作。

与我们内部的 Martin Fowler 沟通后,让我们从简单的 Money 类开始,假设已知某一特定货币量的金额(数量)和币种(类型):

class Money {
  public float Quantity { get; set; }
  public string Currency { get; set; }
}

从表面上看,这似乎是可行的,但是不久之后,我们就要打算利用它做一些与值相关的事情,例如将 Money 实例添加到一起(想想看,这其实是一件花钱时常做的事情):

class Money {
  public float Quantity { get; set; }
  public string Currency { get; set; }

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

当然,当我们试图加上美元时问题出现了。 美元 (USD) 和欧元 (EUR) 加在一起,就像我们出去吃午饭时(毕竟,所有人都知道欧洲人酿造最好的啤酒,而美国人做出最好的披萨):

var pizza = new Money() { 
  Quantity = 4.99f, Currency = "USD" };
var beer = new Money() { 
  Quantity = 3.5f, Currency = "EUR" };
var lunch = pizza + beer;

任何人只需快速浏览一下财务指示板就会明白某人正在挨宰:欧元以 1:1 的汇率兑换成美元。 为了防止意外的欺诈行为,我们可能想要确保编译器知道要经过批准的转换流程才能将美元转换成欧元,而这一流程将查找当前的汇率(请参见图 1)。

图 1 按顺序转换

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 };
  }
}
...
var pizza = new Money<USD>() { 
  Quantity = 4.99f, Currency = new USD() };
var beer = new Money<EUR>() { 
  Quantity = 3.5f, Currency = new EUR() };
var lunch = pizza + beer;    // ERROR

请注意如何使 USD 和 EUR 仅仅作为占位符,以便给编译器可供比较的东西。 如果两个 C 类型参数不同,它就是个问题。

当然,我们也没有办法将它们结合,并且如果我们真要这么做会有机会。 这一操作需要更多的参数语法(请参见图 2)。

图 2 故意组合类型

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() };
  }
}

这是泛型类中一个特殊的泛型方法,并且方法名称后的 <> 语法为方法的范围增添了更多的类型参数。在本示例中,就是要转换的第二种货币的类型。 所以,现在购买披萨和啤酒就变成类似于这样:

var pizza = new Money<USD>() { 
  Quantity = 4.99f, Currency = new USD() };
var beer = new Money<EUR>() { 
  Quantity = 3.5f, Currency = new EUR() };
var lunch = pizza + beer.Convert<USD>();

如果需要,我们甚至可以使用转换运算符(在 C# 中)自动进行转换,但这可能会让代码的读者变得更加困惑而不是对他们有所帮助,具体取决于您对外观的偏好。

总结

Money<> 示例中缺少的部分很明显:显然需要某种方式将美元转换成欧元和将欧元转换成美元。 但在这样的设计中,其目标有一部分是避免出现封闭的系统,也就是当需要新的币种时(卢布、卢比、英镑、里拉或者其他您要使用的币种),如果不必召唤我们(Money<> 类型的最初设计者)来添加它们就好了。 理想情况下,在开放的系统中,其他开放人员可以在需要时插入它们并且一切都“正常工作。”

先不要走开,当然也不要就这样开始编码。 我们仍然需要做些调整使 Money<> 类型更加强大、更安全并且可扩展。 接下来,我们将聊聊动态和函数式编程。

但现在,快乐编程!

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

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