孜孜不倦的程序员

使用 C# 的乐趣(第 2 部分)

Ted Neward

Ted Neward欢迎回来。在我上一期专栏《使用 C# 的乐趣》(msdn.microsoft.com/magazine/dn754595) 中,我简要提到过,如果您对其他编程语言也很熟悉的话,那么在出现看起来非常棘手的设计问题时,将会非常有助于您理清思路。我引用了几年前在一次提供咨询服务时遇到的一个问题,客户要求我将在本地存储的事务列表与可能相当的远程存储的事务列表进行匹配调整。即使是完全相同的事务也无法保证匹配,所以,我不得不按事务数量将其进行匹配,或者在结果列表中标记不匹配的项。

对于该任务,我选择了使用 F#,因为这是我熟悉的语言。坦率地说,它很容易成为类似于 Scala、Clojure 或 Haskell 的其他语言。所有函数式语言都是以相似的方式进行工作的。这里的关键不在于语言本身或其运行的平台,而在于函数式语言中所涉及的概念。这就是一个很好的函数式友好问题。

F# 解决方案

为了巩固概念,请先看一下图 1 中的 F# 解决方案,然后了解如何将其转换为 C#。

尤其当您不熟悉 F# 时,请参考以前的专栏来查看图 1 中所使用的 F# 语法的简要概括。在我将 F# 转换为 C# 时也打算重温一遍,这样您也可以一起进行深入学习。

图 1 用于解析不同事务的 F# 解决方案

type Transaction =
  {
    amount : float32;
    date : DateTime;
    comment : string
  }
type Register =
  | RegEntry of Transaction * Transaction
  | MissingRemote of Transaction
  | MissingLocal of Transaction
let reconcile (local : Transaction list) 
  (remote : Transaction list) : Register list =
  let rec reconcileInternal outputSoFar local remote =
    match (local, remote) with
    | [], _
    | _, [] -> outputSoFar
    | loc :: locTail, rem :: remTail ->
      match (loc.amount, rem.amount) with
      | (locAmt, remAmt) when locAmt = remAmt ->
        reconcileInternal (RegEntry(loc, rem) :: 
          outputSoFar) locTail remTail
      | (locAmt, remAmt) when locAmt < remAmt ->
        reconcileInternal (MissingRemote(loc) :: 
          outputSoFar) locTail remote
      | (locAmt, remAmt) when locAmt > remAmt ->
        reconcileInternal (MissingLocal(rem) :: 
          outputSoFar) local remTail
      | _ ->
        failwith "How is this possible?"
  reconcileInternal [] local remote

C# 解决方案

首先,您需要事务类型和注册类型。事务类型比较容易获得。它是具有三个命名元素的简单结构类型,因此易于将其建模为 C# 类:

class Transaction
{
  public float Amount { get; set; }
  public DateTime Date { get; set; }
  public String Comment { get; set; }
}

这些自动属性使此类几乎与其同类的 F# 一样短。坦率地说,如果我真的打算将其转换成 F# 版本的一一对应,则应该介绍重写的 Equals 和 GetHashCode 方法。不过这对于本专栏的初衷而言确实有效。

针对可辨识联合注册类型,问题显得有些棘手。类似于 C# 中的枚举,注册类型的实例只能是三个可能的值(RegEntry、MissingLocal 或 Missing­Remote)中的一个。与 C# 枚举不同,这三个值中的每个值反过来又可以包含数据(两个事务与 RegEntry 相匹配,或者缺失与 MissingLocal 或 Missing­Remote 相匹配的事务)。虽然在 C# 中创建三个不同的类很容易,但这三个类必须以某种方式相关。我们需要一个可包含任意三个类的列表,但只有这三个用于返回的输出,如图 2 中所示。你好,继承。

图 2 使用继承包含三个不同的类

class Register { }
  class RegEntry : Register
  {
    public Transaction Local { get; set; }
    public Transaction Remote { get; set; }
  }
  class MissingLocal : Register
  {
    public Transaction Transaction { get; set; }
  }
  class MissingRemote : Register
  {
    public Transaction Transaction { get; set; }
  }

这不是特别复杂,只是比较冗长。如果这是面向生产的代码,我应该添加几种方法 - Equals、GetHashCode,以及几乎明确的 ToString。虽然有几种方法可以使其更习惯于 C#,不过我还是要编写相当接近其 F# 灵感的 Reconcile 方法。稍后,我将寻找惯用的优化方法。

F# 版本具有一个可公开访问的“外部”函数,该函数以递归方式调用到一个内部封装函数。然而,C# 没有嵌套方法的概念。我估计最可能使用的两种方法是:声明为公共的方法和声明为私有的方法。尽管如此,这也不太一样。在 F# 版本中,嵌套函数将被封装起来,即使相同模块中的其他函数也会如此,不让所有人知悉。但是我们最多只能了解这么多了,如您在图 3 中所见。

图 3 嵌套函数将在此处封装

class Program
{
  static List<Register> ReconcileInternal(List<Register> Output,
             List<Transaction> local,
             List<Transaction> remote)
  {
    // . . .
  }
  static List<Register> Reconcile(List<Transaction> local,
             List<Transaction> remote)
  {
    return ReconcileInternal(new List<Register>(), local, remote);
  }
}

另外,我现在可以通过将整个内部函数作为一个 lambda 表达式来编写,以 Reconcile 方法中的本地变量进行引用,从而达到“不让所有人看到”的递归方法。这样的话,可能有点太盲目坚持原来的语言,并不完全适合 C#。

大多数 C# 开发员都不会这么做,虽然它与 F# 版本几乎具有相同的功效。在内部调整中,我必须显式提取使用的数据元素。然后,我用 if/else-if 树对其进行显式编写,而非使用更加简洁的 F# 模式匹配。不过,这的确是完全相同的代码。如果本地或远程列表任意一个为空,那我就完成了递归。仅返回输出并且结束操作,如下所示:

static List<Register> ReconcileInternal(List<Register> Output,
              List<Transaction> local,
              List<Transaction> remote)
{
  if (local.Count == 0)
    return Output;
  if (remote.Count == 0)
    return Output;

然后,我需要提取每个列表的“头部”。我还需要保持对每个列表其余部分(“尾巴”)的引用:

Transaction loc = local.First();
List<Transaction> locTail = local.GetRange(1, local.Count - 1);
Transaction rem = remote.First();
List<Transaction> remTail = remote.GetRange(1, remote.Count - 1);

如果我不小心,就很容易在这里引入非常大的性能问题。F# 中列表是固定不变的,因此,采用列表的尾巴即是引用列表中的第二项。不生成副本。

然而 C# 没有这样的保证。这意味着我可能终止每次为列表创建完整副本。GetRange 方法说它进行“浅复制”,意味着它将新建一个列表。然而,它将指向原始事务元素。这可能是我可期望得到的不过于奇异的最好方式。尽管如此,如果代码成为了瓶颈,也可以根据情况另辟蹊径。

再看一下 F# 版本,我在第二个模式匹配中实际上检查的内容就是本地和远程事务的数量,如图 4 中所示。因此,我还提取了这些值,并开始对其进行对比。

图 4 F# 版本检查本地和远程事务的数量

 

float locAmt = loc.Amount;
  float remAmt = rem.Amount;
  if (locAmt == remAmt)
  {
    Output.Add(new RegEntry() { Local = loc, Remote = rem });
    return ReconcileInternal(Output, locTail, remTail);
  }
  else if (locAmt < remAmt)
  {
    Output.Add(new MissingRemote() { Transaction = loc });
    return ReconcileInternal(Output, locTail, remote);
  }
  else if (locAmt > remAmt)
  {
    Output.Add(new MissingLocal() { Transaction = rem });
    return ReconcileInternal(Output, local, remTail);
  }
  else
    throw new Exception("How is this possible?");
}

此时,树的每个分支很容易了解了。我将新元素添加到输出列表,然后对本地和远程列表中的未处理元素进行递归。

总结

如果 C# 解决方案真的如此完善,为什么还要麻烦首先通过 F# 进行停止呢?这很难解释清楚,除非您经历了同样的过程。实质上,我需要首先通过 F# 执行停止,以填充算法。我对此的第一次尝试是个绝对的灾难。我首先使用双“foreach”循环来对两个列表进行迭代。我曾尝试在此过程中跟踪状态,但最终得到的是一个我永远无法进行调试的庞大、令人精疲力尽的混乱。

学习如何通过“换种方式进行思考”(借用几十年前一个著名计算机公司的市场营销系)来得到结果,而不是通过对语言本身的选择。我通过 Scala、Haskell 或 Clojure 能比较容易地讲述这个事情。关键并不在于语言功能集,而在于函数式语言(尤其是递归)背后的概念。这有助于打破心理的僵局。

这就是开发人员每年都应该学习新编程语言的部分原因,这一建议最早由 Ruby fame 的实用程序员 Dave Thomas 提出。这样,您的思维就会接触到新的概念并进而产生新的选择。当一位程序员花些时间在 Scheme、Lisp 上,或基于栈的语言上如 Forth,或基于原型的语言上如 lo,也会出现类似的想法。

如果你想了解一些脱离于 Microsoft .NET Framework 平台的其他语言的精简介绍,我强烈推荐 Bruce Tate 的书《七周七语言》(出版社:Pragmatic Bookshelf,2010年)。其中一些语言您无法直接在 .NET 平台上使用。但我想再说一遍,有时成功在于我们思考问题的方式以及制定解决方案,而不一定在于可重用的代码。祝您工作愉快!


Ted Neward 是 iTrellis(一家咨询服务公司)的 CTO。他写过 100 多篇文章,独自撰写并与人合著过十几本书,包括《Professional F# 2.0》(Wrox,2010 年)。他是 C# 领域最优秀的专家之一,在全球各种会议上演讲。他定期担任顾问和导师,如果您有兴趣请他参与您的团队工作,请通过 ted@tedneward.comted@itrellis.com 与他联系,或通过 blogs.tedneward.com 访问其博客。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Lincoln Atkinson