孜孜不倦的程序员

多模式 .NET,第 10 部分:选择方法

Ted Neward

Ted Neward
上一个专栏中(本系列文章的第 9 部分),我曾说过,如果系列文章的组成部分达到两位数,要么是作者相当自信,认为读者真的对文章主题感兴趣,愿意连篇累牍地读下去;要么就是作者江郎才尽无力开拓新的主题。读者恐怕就是因为这两个原因而选择离开的。

但是,现实告诉我们,即使文章数量达到两位数存在风险,但还是需要一篇文章来介绍多模式化设计,尝试将各种分散的内容综合起来,以展示如何使用这些不同的模式,以及如何从中选择以解决实际问题。这里所说的“实际”表示其重要程度足以说明我们选择的方法对于解决问题的重要性,而这些问题也不像杂志文章中常用的那些问题一样简单。

实际上要找出这样一个问题可没有想象中容易,要么是想法过于复杂,要制定相应的解决方案需要处理太多的因素;要么是想法过于简单,实施时变化的余地太小,无法说明不同模式对于不同问题的作用。幸运的是,开始时我们可以借用一些现成的成果,就像 Dave Thomas 的“Code Katas”。

Code Katas

在 Thomas 的网站 (http://codekata.pragprog.com) 上,他写道,他送儿子 Zachary 去练习空手道,却发现训练馆的家长观摩室已经没有空位了。这让他有了 45 分钟的独处时间,开始琢磨一些他曾不经意地考虑过性能意义的代码。他写道:

我想处理一些代码,使用我从未用过的技术来做实验。我在一个简单、有序的环境中进行实验,尝试了不同的变化(不止是我在这里列出的)。而且还有其他一些工作要做…

如何实现这个实践过程呢?嗯,我有一些时间可以独处而不被打扰。我要尝试一件简单的事,而且尝试了很多次。每次我都想得到反馈,这样才可以做出改进。没有压力:代码可以弃之不用。充满乐趣:我一点一点的推进,这样激励我继续下去。最后,我的成果超出预期。

最终而言,需要有空闲时间让我进行练习。如果有压力,即交付博客的搜索功能存在时限,那么现有的性能也是可以接受的,也就不会发生练习这回事儿了。但是,那些毫无压力的 45 分钟时光让我可以尽情练习。

因此我这一天中面临的挑战就是:看看能否拿出 45 到 60分钟的时间琢磨一小段代码。你不必去关注性能,而可以去处理结构、内存使用情况或者界面。而到最后,这都没关系。我所做的就是实验、衡量、改进。

换句话说,Code Kata 是一个(相对)简单的问题,不难从概念上进行把握,但它提供了一个可供探索的框架。在我们的示例中,目标是进行设计。我们的研究探索基于 Thomas 的“Kata Four:Data Munging”(数据再加工)。

Kata Four:Data Munging(数据再加工)

这个示例中的 Code Kata 包含三个部分,我们会分步进行研究,在推进的同时进行设计并考虑在 C# 中可用的每个基准(尽管用 Visual Basic 编写解决方案也是同样可行的)。

第一步

第一步如下所示:

在 weather.dat (http://bit.ly/ksbVPs) 中包含的是 2002 年 6 月美国新泽西州莫里斯镇每天的天气数据。下载此文本文件,然后编写程序输出温差最小(最高温度在第二列,最低温度在第三列)的日期(第一列)。

weather.dat 文件很像图 1 所示(区别是图 1 中并未列出所有 30 天的数据)。

图 1 Weather.dat 文本文件

MMU June 2002                                
Dy MxT MnT AvT HDDay AvDP 1HrP TPcpn WxType PDir AvSp Dir MxS SkyC MxR MnR AvSLP
1 88 59 74   53.8   0.00 F 280 9.6 270 17 1.6 93 23 1004.5
2 79 63 71   46.5   0.00   330 8.7 340 23 3.3 70 28 1004.5
3 77 55 66   39.6   0.00   350 5.0 350 9 2.8 59 24 1016.8
4 77 59 68   51.1   0.00   110 9.1 130 12 8.6 62 40 1021.1
. ..                              
28 84 68 76   65.6   0.00 RTFH 280 7.6 340 16 7.0 100 51 1011.0
29 88 66 77   59.7   0.00   040 5.4 020 9 5.3 84 33 1020.6
30 90 45 68   63.6   0.00 H 240 6.0 220 17 4.8 200 41 1022.7
mo 82.9 60.5 71.7 16 58.8   0.00     6.9     5.3      

 

很明显,这个文件不是逗号分隔格式的文件,但采用了位置分隔:“MxT”(最高温)列始终从同一位置开始,“MnT”(最低温)则从另一个固定位置开始。在 Visual Studio 中浏览这个文件会发现每行的长度都是 90 个字符;按行、按字符串解析这个文件将轻而易举,因为我们可以按换行符或 90 个字符的长度进行分割。

但是在那之后,事情变得复杂起来。尽管练习要求我们通过对位置值进行硬编码并对每行执行 String.Subset 来输出一个月中温差最小的日期,但离我们的目标稍有差距。因此让我们深入一步,提炼这个问题,以便还要求能够检查这个月中任意一天的任何数据元素。

请记住,多模式设计的核心是找出通用性/可变性基准。到目前为止,对于这个问题,通用性/可变性基准相当不明显,尽管很容易明白我们需要解析一个文本文件并查看结果。那些数据本质上是表格式的,但根据我们研究的模式,可以按若干不同的方式看待它们。

从过程角度看,数据结构就是每行的定义;而面向对象的解决方案并不能给予我们很多。如果只是采用过程数据结构,并尝试用各种方法通过类来解析数据,则是相当简单的事。就本质而言,我们又回到了“智能数据”(尽管它并不非常对象化)的路子上。至少目前是这样的。

这里还看不出元编程策略(或动态编程)的实用价值,除了这种数据结构可能允许基于列名进行某些查询,因此 day1["MxT"] 可能返回“88”。函数式方法可能会很有趣,因为解析过程可以看作是一组函数,一个接一个运行,获取输入并返回字符串(或其他解析后的数据),而更多函数则解析文件的其余部分。这样的技术称为“解析器组合”,但这个概念并不在本文的讨论范围内。

这些方法看起来用处都不大,仅就目前而言,最好的解决方案可能是过程方法,就如图 2 所示。

图 2 采用过程方法

namespace DataMunger
{
  public struct WeatherData
  {
    public int Day;
    public float MxT;
    public float MnT;
    // More columns go here
  }
  class Program
  {
    static void Main(string[] args)
    {
      TextReader reader = new StreamReader("weather.dat");
           
      // Read past first four lines
      reader.ReadLine();
      reader.ReadLine();
      reader.ReadLine();
      reader.ReadLine();
 
      // Start reading data
      List<WeatherData> weatherInfo = new List<WeatherData>();
      while (reader.Peek() > 0)
      {
        string line = reader.ReadLine();
 
        // Guard against "mo" summation line
        if (line.Substring(0, 4) == "  mo")
          continue;
 
        WeatherData wd = new WeatherData();
        wd.Day = Int32.Parse(line.Substring(0, 4).Replace("*", " "));
        wd.MxT = Single.Parse(line.Substring(5, 6).Replace("*", " "));
        wd.MnT = Single.Parse(line.Substring(12, 6).Replace("*", " "));
        // More parsing goes here
 
        weatherInfo.Add(wd);
      }
 
      Console.WriteLine("Max spread: " +
        weatherInfo.Select((wd) => wd.MxT - wd.MnT).Max());
    }
  }
}

请注意,这段代码已经提出一个问题:当解析到第 9 天时,MnT 列显示一个星号,表明这是这个月的“低温”,就像第 26 天是这个月的“高温”。 这可以通过将“*”放在字符串之外来进行解决,但问题随之产生:过程基准关注的是建立数据结构并对数据结构进行操作,在本例中就是解析数据结构。

另外,请注意这里用到了 List<>(我们使用过程方法来解析文件,并不意味着就不能利用 Base Class Library 中的有用类), 这使得找出最小温差变得不怎么重要,一个 LINQ-to-Objects 查询就可以获得所需的结果。 (当然,我们应该获得出现这个温差的日期才算是解决问题,但这不过是返回并对除原始浮动值之外的某些内容执行 Max() 罢了;我将此留给读者作为练习。)

第二步

我们可能要花很多时间来设想如何写出“可重用”的代码,但这就像预测未来;这段代码可以使用,所以我们进入 Kata 中的下一步:

文件 football.dat ( http://bit.ly/lyNLya) 包含 2001/2002 赛季的英超联赛 战绩。 “F”和“A”列分别包含该赛季每支队伍的总进球数和总失球数,例如 Arsenal(阿森纳)的进球数是 79 个,失球数是 36 个。 编写程序找出进球数和失球数差距最小的球队。

要进行更多的文本解析, 很有意思。 football.dat 文件如图 3 所示,它很大程度上与 weather.dat(图 1)类似,但两者的区别也不少,足够采用不同的解析代码。

图 3 Football.dat 文本文件

  球队 P W L D F   A Pts
1. Arsenal 38 26 9 3 79 - 36 87
2. Liverpool 38 24 8 6 67 - 30 80
3. Manchester_U 38 24 5 9 87 - 45 77
4. Newcastle 38 21 8 9 74 - 52 71
5. Leeds 38 18 12 8 53 - 37 66
6. Chelsea 38 17 13 8 66 - 38 64
7. West_Ham 38 15 8 15 48 - 57 53
8. Aston_Villa 38 12 14 12 46 - 47 50
9. Tottenham 38 14 8 16 49 - 53 50
10. Blackburn 38 12 10 16 55 - 51 46
11. Southampton 38 12 9 17 46 - 54 45
12. Middlesbrough 38 12 9 17 35 - 47 45
13. Fulham 38 10 24 14 36 - 44 44
14. Charlton 38 10 14 14 38 - 49 44
15. Everton 38 11 10 17 45 - 57 43
16. Bolton 38 9 13 16 44 - 62 40
17. Sunderland 38 10 10 18 29 - 51 40
18. Ipswich 38 9 9 20 41 - 64 36
19. Derby 38 8 6 24 33 - 63 30
20. Leicester 38 5 13 20 30 - 64 28

 

如果我们分别考虑这两个程序,很容易看出每个程序可以用几乎相同的方式解决,重复讨论这个足球练习没有多大意义。

第三步

但是,Kata 的最后一步让我们看到了练习的意义所在:

拿出以前编写的两个程序,尽可能剔除共用的代码,您就会得到两个较小的程序,以及一些共同的功能。

这是非常有趣的,也是通用性/可变性分析中所需的因素。

通用性/可变性

因为我们现在要考虑的问题不止一个(也就是说,是一系列的顾虑),所以要梳理出两个问题之间的通用性和可变性就变得容易起来。 从对文本文件的分析开始,分析过程揭示出练习其实可以归纳为两个步骤:将文件解析为原始数据的列表,然后用某种类型的计算或分析来检查这些数据。

这里是解析文本文件时需要考虑的一些问题:

  • 两个文件的格式都包含需要忽略的“标题”。
  • 两个文件的格式都是基于位置的。
  • 两个文件的格式都包含需要忽略的行(weather.dat 文件中的“mo”总结行,football.dat 文件中的“------”标记)。
  • Weather.dat 包含一些空列,football.dat 则没有。
  • 两个文件的格式原则上都支持数字和字符串列(此外,weather.dat 还包含“*”值,需要用某种方法捕获)。

计算结果主要取决于解析后的数据是什么样的,因此从那里入手是合理的。 根据每种模式,我们有几种方法可以开始:

过程方法 这种基准主要捕获数据结构中的通用性。 但是,对于两种不同的文件格式,无疑我们需要捕获可变性,因此过程方法看起来并不合适。 创建某种捕获两种文件格式的数据结构是可能的,但是与其他模式相比,效果并不好。

面向对象方法 两个文件之间的通用性表明可以使用抽象基类“TextParser”来提供基本的解析功能,包括跳行功能。 可变性则在解析每行时产生,这意味着子类必须重写某种“ParseLine”方法才能执行实际的逐行解析。 但是,如何从 TextParser 子类型检索得到解析后的值(以便进行最大/最小值比较)可能会有难度,因为列的类型也会改变。 以前,Microsoft .NET Framework 曾通过返回对象来解决这个问题(使用 SQL 数据集),如有必要我们可以使用这种方法。 但是这种方法可能引入类型安全错误,因为对象需要向下转换才能使用,而这可能很危险。

元方法 几个不同的解决方案都涉及到元对象/元编程。 属性方法表明 TextParser 类会接受“Record”类型,其中描述的每个列都具有起点/长度自定义属性,用来描述如何解析各行,如下所示:

public struct WeatherData
{
  [Parse(0, 4)]
  public int Day;
  [Parse(5, 6)]
  public float MxT;
  [Parse(12, 6)]
  public float MnT;
}

然后,TextParser 可以进行参数化以接受 Record 类型 (TextParser<RecordT>),并在运行时使用自定义属性来发现如何解析各行。 这之后会返回 Records 列表 (List<RecordT>),我们可以用这个列表进行上文所述的计算。

另外,生成式编程表明某种类型的源格式可以描述文本文件,针对每种文件的解析器是根据该源格式生成的。

动态方法 采用动态方法稍有些奇怪,因为它是 .NET 领域中较新的方法。但是在这里,我们可以假设一个接受字符串的 TextParser 类,这个类描述如何解析每个文件。 就其自身而言,它几乎是一种无足轻重的编程语言,可能融入了正则表达式,或(在我们的示例中)只使用位置数据,因为这就是这个问题所需的全部:

string parseCommands =
  "Ignore; Ignore; Ignore; Ignore; Repeat(Day:0-4, MxT:5-6, MnT:12-6)";
TextParser parser = new TextParser(parseCommands);
List<string> MxTData = parser["MxT"];
List<string> MnTData = parser["MnT"];

这与元方法有着显著的区别,因为它缺乏元方法能够提供的类型安全。 但是,如果文件格式发生改变,元方法就需要在编译时更改,而动态方法由于处理的是基于名称的可变性,因此可以应付这种改变。 动态方法提供了更高的灵活性,代价是其内部的复杂性有所增加。

由生成式元方法使用的源格式可以在运行时传递,从而在运行时而不是在源代码时构建解析器。

函数式方法 从某些方面来说,函数式方法是最奇怪的,因为这种方法表明算法(解析)就是可变性之所在,所以 TextParser 类应该接受一个或多个描述如何解析文本的函数,而无论是逐行解析,还是逐列解析,或者两者兼具。 例如,TextParser 需要了解的两点是如何忽略不可解析的行,以及如何将一行分解为各个组成部分。 这可以通过建立 TextParser 本身的函数实例来实现,或者通过构造函数传递,或者通过属性进行设置,如图 4 所示。

图 4 函数式方法

TextParser<WeatherData> parser = new TextParser<WeatherData>();
parser.LineParseVerifier =
  (line) =>
  {
    if ( (line.Trim().Length == 0) || // Empty line
         (line.Contains("MMU")) ||    // First header line
         (line.Contains("Dy")) ||     // Second header line
         (line.Contains("mo")))
        return false;
    else
      return true;
  };
parser.ColumnExtracter =
  (line) =>
  {
    WeatherData wd = new WeatherData();
    wd.Day = line.Substring(0, 4);
    wd.MxT = line.Substring(5, 6);
    wd.MnT = line.Substring(12, 6);
    return wd;
  };
List<WeatherData> results = parser.Parse("weather.dat");

TextParser 单独使用参数化的类型来捕获 WeatherData 类型,以获得更好的类型安全。如果需要或者更容易进行,可以让它返回泛型 System.Object。

总结

很明显,并没有一种“万能方法”能够真正完全解决问题,随着其他要求逐渐清晰,我们可以更好地理解可变性在哪里,也就能更好地理解需要捕获的通用性。 顺便说一句,这突显了重构的真正力量:因为我们无法预测未来,重构代码意味着我们的通用性/可变性解析可能是错的,但当这些问题暴露时,我们无需“推倒重来”。

多模式设计并不是容易掌握的课题,不可能毕其功于一役。 我写了 10 篇文章进行论述,如果加入您自己的思考,无疑这一系列的内容会更长。 但随着时间的推移,对此的研究会让我们获得更灵活、更舒适的设计,甚至可以为一些棘手的问题提供解决方案,让其他开发人员对您拨开疑云看清本质的能力刮目相看。 最后请注意,多模式方法开始于通用性和可变性,也结束于通用性和可变性 — 记住这一点,很多问题就能迎刃而解。

为此,我强烈建议读者看看“数据再加工”Kata,分别用五种不同的模式试着解决那两个文件解析练习,然后想办法将不同模式组合起来写出强大、可重用的代码。 当面向对象模式与函数式或动态模式结合在一起时会产生什么结果? 解决方案是更简单了,还是更复杂了? 然后运用您得到的方法处理其他文件格式,例如对 CSV 文件格式的效果如何? 更重要的是,您可以在项目会议或工作事务的间隙做这些事情,由此获得的经验会在您面临实际的工作压力时给予回报,帮助您处理好此类设计。

尽管我已经说过,但有必要重复:多模式语言已然存在,很常用,而且看起来它们会有更多进展。 各种 Visual Studio 2010 语言都或多或少地呈现以上某种模式。例如 C++ 就有一些参数化的元编程机制,这些机制在托管代码中不可能实现,而这要归功于 C++ 编译器的运行方式;而且就在最近(在最新的 C++0x 标准中),它又增加了 lambda 表达式。 即使口碑不佳的 ECMAScript/JavaScript/JScript 语言也可以处理对象、过程、元编程、动态和函数式模式,实际上 JQuery 在很大程度上就是构建在这些理念之上的。 这些理念越早在您的头脑中扎根,您就越早能够写出处理最棘手情况的代码。

祝您工作愉快!

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

衷心感谢以下技术专家对本文进行了审阅:Anthony D. Green