孜孜不倦的程序员
多模式 .NET,第 10 部分:选择方法
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