Dr.GUI 最近有机会帮助一位朋友解决了他在初级 C 语言课程中所遇到的编程问题。
请不要介意,距离这位好心的博士使用 C 语言来编程,几乎已经隔了十年之久。(C 并不是“完全”不同于 C++,但还是不太一样。)
那么,Dr. GUI 究竟遇到了什么问题? 所有问题都出在二维结构数组。很早以前,他在 C++ 中使用过二维结构数组,但现在他已经忘记了 C 和 C++ 将它们视为恰好连续存储的数组的数组。这位好心的博士在过去处世不够和蔼、平和的日子里,每当面试未来的合作者时总要问到这类问题。
于是,他建议编写 ArrayName[i, j].MemberName 来访问数组中结构的成员。结果:出现错误消息,建议他应该使用箭头 (->) 运算符,因为位于圆点左侧的是指针。
不幸的是,Dr.GUI 忘记了一条最重要的编程规则:“除非您了解哪里出了错误,否则不要进行更改”。与此相反,他认为,在指针、数组、下标、结构、箭头和圆点等方面必定有某种神奇的规则,只是他早就忘了而已。于是,他决定采纳编译器的建议,将表达式写为 ArrayName[i, j]->MemberName。
那当然是一个极大的错误。代码编译后并不能正常运行。
有一件事这位好心的博士确实是忘记了:C 和 C++ 中设置二维数组索引的语法要求彼此分离的下标,比如 ArrayName[i][j].MemberName。他还忘记了 C 和 C++ 中麻烦的逗号运算符:因此,上述有关 [i, j] 的代码从语义上来讲与
ArrayName[j]->MemberName 是相同的,因为在 C 和 C++ 中表达式“i, j”的结果是 i 被求值,但结果被丢弃,然后 j 被求值并成为表达式的值。如果 Dr. GUI 记得这一点,并记得 ArrayName[j] 是指向数组第 j 行开头处的指针,就会知道上述表达式还等价于 (*ArrayName[j]).MemberName (因为这是箭头运算符的定义)。它等价于 ArrayName[j][0].MemberName (因为这是下标运算符的定义,而且在 C 和 C++ 中在许多环境下指针和一维数组是可以互换的),此一神秘问题也因此得以解决。
那么,是什么导致 Dr. GUI 犯了最初的错误?实际上,他刚刚完成了最近一期专栏的编写,在其中他指出,C# 有二维实数数组(C 和 C++ 是这样的,但 Java 不然)。但在 C# 中,您使用这位好心的博士尝试的第一个符号:ArrayName[i, j].MemberName 对 C# 中的二维结构数组或对象而言是正确的。
为复合这一问题,C# 和 Java 都使用 ArrayName[i][j] 符号,但意思与在 C 和 C++ 中不同:在 C# 和 Java 中,第一个下标在动态分配的多个一维数组中作为一维数组的索引 ;第二个下标在该数组中查找特定的元素。有关图表,请参阅前一期专栏。(另请注意,在 C 和 C++ 中,[i][j] 符号可以表达两种含义中的任意一种意思,到底是哪种含义要取决于 ArrayName 的声明方式和初始化方式。)
是否从中获得了教益?当您在相似的语言之间进行切换时,其中的微小差异都可能导致您出错。请小心谨慎。
您可能记得类库的方框图,如 Microsoft® Visual Studio® 包装盒中随带的 MFC 类库框图以及 Visual J++® 类框图。事实上,如果您与大多数面向对象的开发人员一样,您的墙上就可能张贴了一张或多张这样的图表。
其实, Dr. GUI 早在等待这样的 .NET 框架图表(但因含有数千个类,它将是一张“大”图表!)。最近,他看到 Microsoft 的其他人发来的邮件,询问是否有类的图表或 Visio® 文件。
“Visio 文件……嗯……”这位好心的博士在想。Visio 公开了 COM 自动化模型,以便您在另一个应用程序中创建和编辑绘图。而且,我们已经看到,.NET 框架有一点点“反射”能力,即读取一种类型甚至整个集合的元数据的能力。最后,为 .NET 框架编写的程序可以与 COM 对象(如 Visio 自动化模型公开的对象)进行互操作。
因此,有了反射、Visio 自动化模型以及 COM 互操作的组合,就应该能够编写一个 .NET 框架程序,来考虑每个系统部件,找出它们包含的类、值类型、枚举、授权和接口 — 以及它们之间的关系(谁继承谁)。然后,该程序通过其自动化模型来驱动 Visio 绘制图表。(为什么不直接在位图上绘制呢?因为 Visio 具有一个很酷的功能,即在您移动某个绘图时能自动重画连接线 — 这样,Visio 就允许您以后进行手动绘制,使图表更美观。)
在它出现后,其他一些人就已经考虑到这一点(Microsoft 有很多人要比 Dr. GUI 更有经验),并早已付诸行动。如果顺利的话,我们很快就会得到一些图表 — 它们根据系统组件的实际元数据绘制,因此不会出现缺少线条、贴错标签,以及在手工绘制这种巨大而单调的图表中其他难以避免的错误。
Dr.GUI 很想知道在其他平台上的做法。
接着,他想得到一种引用某个数据结构,并以递归方式绘制其方框图的方法。换言之,如果您传递一个对字符串数组的引用,它将绘制一个与我们上次获得的图表类似的图表。
将这个功能集成到 Visual Studio 调试程序中会更酷,因此当您用鼠标右键单击某个表达式时,上下文菜单选项会包括“绘制数据”。
Dr.GUI 这次不打算谈论格式和集合以外的内容,而是希望传授一些有关规则表达式的经验。
实际上,Dr. GUI 在规则表达式方面还没有多少经验,他只能为您提供一些“.NET 开发人员指南”中有关规则表达式的有用文档(英文)。在那里有许多可供阅读的内容,甚至还有一些很好的使用规则表达式匹配字符串模式的示例。
有一点很有趣:当您创建规则表达式以便将实现匹配的代码编译成 IL 时,有一个选项,随后该选项进行 Just-In-Time (JIT) 编译。换言之,匹配指定规则表达式所需的运行极快的精确代码,规则表达式对象可以创建、优化、JIT 编译和执行。
上次我们谈到 .NET 框架中的数组和字符串。这次我们将讨论格式和集合。
基本内容是:可以在 Console.WriteLine(以及 String.Format,它被 Console.WriteLine 调用)中的格式字符串内的括号中放入非索引数字的内容。格式规范的完整形式如下:
{index [, width][:formatstring]}
其中,index 是此格式程序引用的格式字符串之后的参数,从零开始计数;width(如果有的话)是要设置格式的字段的宽度(以空格计)。width 取正数表示结果右对齐,取负数则意味着数字在字段中左对齐。(请参阅下面的前两个示例。)
formatstring 是可选项,其中包含有关设置类型格式的格式说明。如果对象实现 IFormattable,formatstring 就会传递给对象的 Format 方法(在 Beta 2 和后续版本中,该方法的签名变为 ToString(string, IFormatProvider),但功能不变)。如果对象不实现 IFormattable,就会调用 Object.ToString(),而忽略 formatstring。
另请注意,在 Beta 1 中不区分当前语言的 ToString 在 Beta 2 和后续版本中“将”区分语言。例如,对于用“.”分隔千位,用“,”分隔小数的国家,1,234.56 将会格式化成 1.234,56。如果您需要结果无论在什么语言下都是一样的,就请使用 CultureInfo.InvariantCulture 作为语言。
若要获取有关格式的完整信息,请查阅“.NET 框架开发人员指南”中的格式概述(英文)。
请注意,数字的格式是区分语言的:分隔符以及分隔符之间的空格,还有货币符号,都是由语言决定的 — 默认情况下,是您计算机上的默认语言。默认语言与执行线程相关,可以通过 Thread.CurrentThread.CurrentCulture 了解和设置语言。有几种方法,可以不必仅为一种给定的格式操作就立即更改语言。
有一种格式命令以单个字母开头,表示下列设置:
G—常规,E 或 F 中较短的
F—浮点数,常规表示法
E—用 E 表示法表示的浮点数(其中,E 代表 10 的次幂)
N—带有分隔符的浮点数(在美国为逗号)
C—货币,带有货币符号和分隔符(在美国为逗号)
D—十进制数,仅用于整型
X—十六进制数,仅用于整型
字母可以后跟一个数字,根据字母的不同,该数字可以表示总位数、有效位数或小数点后面的位数。
下面是字母格式的一些示例:
double pi = Math.PI;
double p0 = pi * 10000;
int i = 123;
Console.WriteLine("浮点格式,无分隔符(逗号)");
Console.WriteLine("pi, Left {0, -25}", pi); // 3.1415926535897931
Console.WriteLine("p0, Rt. {0, 25}", p0); // 3.1415926535897931
Console.WriteLine("pi, E {0, 25:E}", pi); // 3.1416E+000
Console.WriteLine("使用 E 和 F 格式,小数点后保留 n(此处为 4)位");
Console.WriteLine("pi, E4 {0, 25:E4}", pi); // 3.1416E+000
Console.WriteLine("pi, F4 {0, 25:F4}", pi); // 3.1416
Console.WriteLine("使用 G 格式,保留 4 位有效数字——如果需要请使用 E 格式");
Console.WriteLine("pi, G4 {0, 25:G4}", pi); // 3.142
Console.WriteLine("p0, G4 {0, 25:G4}", p0); // 3.142E4
Console.WriteLine("N 和 C 格式带有逗号(分隔符)," +
"默认小数点后保留两位,四舍五入。");
Console.WriteLine("p0, N {0, 25:N}", p0); // 31,415.93
Console.WriteLine("p0, N4 {0, 25:N4}", p0); // 31,415.9265
Console.WriteLine("p0, C {0,25:C}", pi); // $3.14
Console.WriteLine("D 和 X 格式仅用于整型," +
"非整型将产生格式异常——X 指十六进制");
Console.WriteLine("i, D {0, 25:D}", i ); // 123
Console.WriteLine("i, D7 {0, 25:D7}", i ); // 0000123
Console.WriteLine("i, X {0, 25:X}", i ); // 7B
Console.WriteLine("i, X8 {0, 25:X8}", i ); // 0000007B
与字母格式不同,formatstring 可以包含“图片格式”。下面是从代码中摘录的几个实例。(这类似于 Basic 中的“Print Using”语句。)图片格式功能甚至包括以不同方式设置负数、正数和零的格式的能力。还有几个图片格式功能,下面的示例中未包括在内。有关详细信息,请参阅“.NET 框架开发人员指南”或文档中的主题图片格式数字串(英文)。
在下例中您将注意到,好心的博士既使用了“#”字符,又使用了“0”字符。如果相应的数字是前导零或尾随零,“#”字符就会替换为空值。无论相应数字的值如何,“0”字符都会被替换为零字符 — 因此,数字将会被零填补。句号(如果有的话)表示小数分隔符的位置。
那么,为什么要同时使用这些字母,比如“###0.##”? 如果要设置格式的值恰好为零,“#” 图片字符就被替换为“无”(连零字符也不是)。您可能“总是”希望在小数点的左边至少有一个“0”,否则,如果值为零,字段就没有输出。换言之,仅包含“#”字符,一个“0”也没有的格式常被认为是一个编程错误。
逗号有两种用法:如果一个逗号或一组逗号紧跟在句号的左边(或者没有句号时在结尾),它们就会告诉格式化程序分隔 10 ** (3 * n) 所显示的数字,其中,n 是逗号的个数。换言之,数字按千位、百万位、十亿位等分隔。
如果逗号的右侧至少有一个“0”或“#”占位符,它就会告诉格式化程序在各数位组之间放置适当的组分隔符字符(在美国为逗号。)(在美国,每三个数位算一组。)
可以设置百分比的格式,方法是在图片中放入“%”。“%”将在指定的位置显示,在显示前数字将被乘以 100(这样,0.28 就变成了 28%)。
如果希望将图片格式用于指数表示法,可以指定“e”或“E”后跟加号或减号,再后跟任意个零,比如“E+00”或“e-000”。如果使用“e”,则显示小写“e” 。如果使用“E”,则显示大写“E” 。如果使用加号,则指数的符号总是出现。如果使用减号,则符号只有在指数为负数时才会显示。(Beta 1 版在处理“-”时有问题,该符号会导致负号总是出现。)
根据要设置格式的数字的符号,还有一个条件格式。在格式字符串中仅包含两个或三个独立的格式,它们由分号分隔。如果有两个格式,则第一个将用于非负数,第二个用于负数。如果有三个格式,则第一个将用于正数,第二个用于负数,第三个用于零。
可以在格式字符串中包含文字字符。如果所需的字符具有特殊意义,请在其前面使用反斜杠符号,使其“转义”。例如,如果希望在不乘以 100 的情况下显示百分比符号,就可以在数字前面使用反斜杠(在 C++ 和 C# 中必须使用两个反斜杠),比如“#0.##//%”
。(如果正在使用 C#,就可以使用极酷的逐字字符串文字,比如@"#0.##/%"。
)或者,也可以将字符串放入单引号或双引号中,以避免将其字符解释为格式命令。在 Beta 2 及更高版本中,可以通过使用双括号,从而在格式字符串中包含文字括号。
下面是有关图片格式的一些示例:
long m34 = 34000000; // 34,000,000
Console.WriteLine("几种图片格式");
Console.WriteLine("如果没有数位,0 将打印 0;" +
"诸如 i: 的文字总是打印");
Console.WriteLine("/t句点代表小数分隔符的位置");
Console.WriteLine("i, i: 0000.0 {0, 10:i: 0000.0}", i); //
i:0123.0
Console.WriteLine("如果没有有效数字 # 将不显示," +
"逗号意味着放入分隔符");
Console.WriteLine("请确保在数字图片中至少使用一个 0。");
Console.WriteLine("p0, ##,##0.# {0, 10:##,##0.#}",-p0); // -31,415.9
Console.WriteLine("m34, 0,, {0, 10:0,, 百万}", m34); // 34 百万
Console.WriteLine("p0, #0.#E+00 {0, 10:#0.#E+00}", p0); // 31.4E+03
Console.WriteLine("% 乘以 100 并打印百分号");
Console.WriteLine("pi, ###0.##% {0, 10:###0.##%}", pi); // 314.16%
Console.WriteLine("因为 // 而没有进行乘法运算" +
"(注意:两个反斜线!)");
Console.WriteLine("pi, ###0.##% {0, 10:###0.##//%}", pi); // 3.14%
Console.WriteLine("与 C# 的逐字字符串相同");
Console.WriteLine(@"pi, ###0.##//% {0, 10:###0.##/%}", pi); // 3.14%
Console.WriteLine("10, '#'#0 {0, 10:'#'#0}", 10); // #10
Console.WriteLine("基于符号的条件格式");
Console.WriteLine("如果是 0 或正数打印 #,如果是负数打印 (#)");
Console.WriteLine("-5 0;(0) {0, 10:0;(0)}", -5); // (5)
Console.WriteLine("如果是正数打印 #,如果是负数打印 -#,如果是 0 打印 zip");
Console.WriteLine(" 0 0;-0;zip {0, 10:0;-0;zip}", 0); // zip
如您所见,格式功能非常强大。
文档中的示例对所传递的对象类型的变量调用 Format 方法。对这些 Format 方法仅传递格式规范的 formatstring 部分,而不传递 index 和 width。(在 Beta 2 中,对 Format 的调用将改为对 ToString 的调用。)
index 和 width 由 String.Format(它被 Console.Write 和 Console.WriteLine 调用)使用,以获得调用 Format 的正确对象以及将该调用的结果左或右对齐。(顺便说一下,如果要设置格式的对象不实现 IFormattable(并因此调用 Format 方法),String.Format 将调用对象的 ToString() 方法,而忽略 formatstring。)
换言之,Console.WriteLine 调用 String.Format,传递向它传递的所有参数。String.Format 分析字符串,查找“{”字符。找到该字符后,它将分析子字符串直到第一个“}”为止,以确定 index 数、width 和 formatstring。然后,它按照 index 访问相应的参数,并调用其 Format 方法,传递“{}”段中的 formatstring 部分。(如果参数对象不实现 IFormattable,则被调用的是 ToString。)
无论是实现还是不实现,都会返回一个字符串,并且 String.Format 在继续分析格式字符串之前会将其与结果字符串连接。之后,String.Format 将生成的带格式字符串返回给 Console.WriteLine,由 Console.WriteLine 进行显示。
对于 Beta 2 及更高版本,对象的 Format 方法(它是 IFormattable 中的 Format 方法)被 ToString 所替代,ToString 获取一个格式字符串和一个 IFormatProvider(或 null)。但 String.Format 仍存在,因此这些调用将不改变。
您自己也可以编写格式化程序,用于自己的类型或作为内置类型的自定义格式化程序,如“.NET 框架开发人员指南”中的自定义 Format 方法所说明的那样。如果编写内置类型的自定义格式化程序,就不能从 Console.WriteLine 中使用它,但可以通过调用 String.Format 的重载而使用它,String.Format 的重载将采用 IServiceObjectProvider(在 beta 2 及更高版本中称为 IFormatProvider)作为参数。
您将记起,有一个叫做 DateTime 的类,用于保存日期和时间。像您所猜想的那样,有大量方法可供设置 DateTime 对象的格式:仅日期、仅时间、世界时或本地时、若干种日/月/年顺序,甚至可分类。日期和时间格式是区分语言的。
还可以使用自定义格式字符串来设置 DateTime 对象的格式。这种字符串将包含由某些字母组成的区分大小写的子字符串,以表示日期和时间的各个不同部分,如星期几、几号、月份、年份、纪元、小时、分钟、秒或时区。这些部分中有许多具有多种格式,例如,M 是没有前导零的数字月份,MM 是有前导零的数字月份,MMM 是三个字母的月份缩写,MMMM 是所在国家语言对应的完整月份名称的拼写。在“.NET 框架参考”中可以找到自定义和标准格式字符的完整列表。
下面是有关日期和时间格式的一个示例:
Console.WriteLine("标准格式");
// 后面的“分析”中会有更多信息
DateTime dt = DateTime.Parse("2001 年 1 月 1 日,12:01:00am");
Console.WriteLine("d: {0:d}", dt); // 1/1/2001
Console.WriteLine("D: {0:D}", dt); // 2001 年 1 月 1 日,星期一
Console.WriteLine("f: {0:f}", dt); // 2001 年 1 月 1 日,星期一 12:01 AM
Console.Write("F: {0:F}", dt); // 2001 年 1 月 1 日,星期一 12:01:00 AM
Console.WriteLine();
Console.WriteLine("g: {0:g}", dt); // 1/1/2001 12:01 AM
Console.WriteLine("G: {0:G}", dt); // 1/1/2001 12:01:00 AM
Console.WriteLine("M/m: {0:M}", dt); // 2001 年 1 月
Console.WriteLine("R/r: {0:R}", dt); // 2001 年 1 月 1 日,星期一 08:01:00 GMT
Console.WriteLine("s: {0:s}", dt); // 2001-01-01T00:01:00
Console.WriteLine("t: {0:t}", dt); // 12:01 AM
Console.WriteLine("T: {0:T}", dt); // 12:01:00 AM
Console.WriteLine("u: {0:u}", dt); // 2001-01-01 08:01:00Z
Console.Write("U: {0:U}", dt); // 2001 年 1 月 1 日,星期一 8:01:00 AM
Console.WriteLine();
Console.WriteLine("Y/y: {0:Y}", dt); // 2001 年 1 月
Console.WriteLine("自定义格式");
// 对作为格式使用的字符必须“转义”—此处为 t 和 z
// 同时使用引号(在文字字符串中)和反斜杠
Console.WriteLine(@"dddd, dd MMMM yyyy"" at ""HH:mm:ss in /zone zzz:");
Console.WriteLine(@"{0:dddd, dd MMMM yyyy"" at ""HH:mm:ss in /zone zzz}",
dt);
// 2001 年 1 月 1 日,星期一 00:01:00 于时区 -08:00
每个数字类和 DateTime 类都有一个或多个静态的 Parse 方法。最简单的方法就是采用字符串。数字类有一个重载,该重载也会采用 NumberStyles 枚举,其中包含描述在分析时允许使用的非数字字符的标记。(在“.NET 框架参考”中提供有关 NumberStyles 枚举的详细信息 。)
另外,还可以使用非默认的语言进行分析以及格式设置。
例如,若要将一个字符串分析成双精度数,可以编写:
String s1 = "123.450"; // 从控制台或文件获得字符串
Double d1 = Double.Parse(s1);
Console.WriteLine("“123.450”的分析结果是 {0}", d1);
String s2 = "$1,234.56";
Console.WriteLine("如果没有传递正确的 NumberStyles,将获得" +
"例外。");
Double d2 = Double.Parse(s2, NumberStyles.Currency);
Console.WriteLine("使用 NumberStyles.Currency 分析“$1,234.56”的结果" +
"是 {0}", d2);
请注意,Beta 1 中的许多类都有 FromString 方法。在 Beta 2 及更高版本中,该方法将从大多数类中删除。代之以 Parse,因而省去自行更新代码的麻烦。
在编程中,所谓的“集合”其最简单的形式是数组。集合正象它的意思所表达的那样,是由对象组成的组(或集合)。不同类型的集合将有不同的存储和访问方法 — 各种情况下的不同性能特征。
例如,一个数组在内存中是连续存储的。这意味着,对其编制索引(访问第 i 个元素)是很快的。但在数组中间插入元素却很慢,原因是要移动数组中插入元素后面的所有元素。(最坏的情况是,数组中没有空间,必须新建一个空间,然后将所有元素都复制到其中。)如果数组的内容是排序了的,在数组中进行搜索就可以很快(可以使用对分搜索),但如果没有排序,搜索就会很慢(因为必须依次查看每个元素)。
散列表中存储对值的引用,每个引用都与一个键相关。从逻辑观点看,可以将散列表中的键看作数组的下标:在向散列表提供键后,就会返回与该键相关的对象。(很明显,键必须是唯一的,就象数组下标一样。)实际上,许多语言,包括 C# 在内,都使用数组下标语法进行查找,比如 hashTab["key value"]。键可以是任何可以正确实现 Object.GetHashCode 和 Object.Equals 的类型,而不仅是 String 或 Int32。
值不是连续存储的,不能用一个整数索引值在散列表中进行搜索(除非键是连续的整数)。但是,可以使用 foreach 枚举所有值。添加和删除元素都很快,因为是根据键进行元素查找的。
排序的列表类型将键数组和对对象引用的数组结合起来,提供了一个已排序数组,这样就可以按键索引和访问。它不象散列表那样快,但它在既需要索引又需要进行查找时却不失为好的选择。
可以组合多个集合,以便实现所需的性能特征。例如,可以使用指向列表元素或数组元素的散列表,以便在列表或数组中快速进行键查找。但必须确保散列表与列表或数组同步。最好是编写一个包含并能正确维护这两种容器的集合。
如果希望按照添加到集合时的顺序访问项目,请使用队列,队列是按先进先出的访问顺序进行优化的。如果希望按相反的顺序访问项目(后进先出),请使用堆栈。
许多系统还有链接的列表类型,但 .NET 框架没有。在经常需要添加和删除项目的情况下,链接的列表很有用,因为一旦找到正确的位置,就可以通过切换几个指针来添加项目。按顺序显示项目是很快的,但编制索引和进行搜索却很慢。在大多数情况下,一个基于数组的列表类型与链接的列表效果相同或更好一些, 但有时会经常需要在列表中添加或删除项目,这时链接的列表可能更适合。
公用的树结构在 .NET 框架也未包括。在大多数操作(添加、删除和搜索)中,它都是相当快的,但却无法有效地对其编制索引。不过,可以按顺序有效地穿越树的结构。大多数情况下,散列表或排序列表的效果都与树一样或更好一些,但散列表不能保持项目的顺序,排序列表在添加和删除项目时要慢一些。
为了解 .NET 框架集合,先了解一些几乎所有集合都实现的接口是很有用的。
所有集合都实现至少两种接口:ICollection 和 IEnumerable。
IEnumerable 只有一种方法,称为 GetEnumerator。它返回一个计数器对象,该对象实现 IEnumerator 接口。
C# 和 Visual Basic.NET 中的 foreach 语句依赖于实现 IEnumerable 的集合。
IEnumerator 接口允许您枚举集合,换言之,即访问每个元素。如果集合是有序集合(如数组或列表),该访问将是按顺序进行的;而在无序的集合(如散列表)中,访问没有特定的顺序。IEnumerator 有一个只读属性,称为 Current,该属性返回一个对当前对象的引用。它还有两个方法:MoveNext,用于移至集合中的下一个对象,设置 Current 指向该对象,并且如果没有越出结尾就返回 True;Reset,用于在集合的开头部分之前将 Current 指针重新设置为不确定的值。创建计数器或调用 Reset 后首次访问 Current 之前,“必须”先调用 MoveNext,因为 Current 引用最初是不确定的。
请注意,集合并“不”自行实现 IEnumerator;相反,每次调用 GetEnumerator 时集合都返回一个独立的计数器对象。
下面有一个有关枚举集合的示例,既使用了 foreach,又直接使用了枚举接口。
ICollection 是对 IEnumerable 的扩展,并新增三个只读属性和一个方法。三个属性为:Count,用于返回集合中的对象数;IsSynchronized,如果对于多线程访问,对集合的访问是同步的,该属性就返回 True;SyncRoot,该属性返回的对象可用于对跨线程的集合访问进行同步(如果还没有进行同步)。一个方法为:CopyTo,用于将集合中的元素复制到数组,并在指定的数组位置开始。
每个集合都可以告诉您集合中含有多少元素,都可以被枚举和复制,并且能够被同步。因此,可以将“任何”集合传递给下列方法:
public static void PrintCollection(String info, ICollection c) {
Console.Write("{0}:集合有 {1} 个元素:", info, c.Count);
foreach (Object o in c) { // 编写循环的最简便方法
Console.Write("{0} ", o);
}
Console.WriteLine();
}
public static void PrintCollectionSync(ICollection c) {
Console.WriteLine("同步访问(此处不需要)");
lock (c.SyncRoot) { // 锁定 SyncRoot
//--仅用于演示,仅在多线程访问时才需要
// 以下循环与上面的 foreach 等价
IEnumerator e = c.GetEnumerator();
while (e.MoveNext()) { // 首次移动
Console.Write("{0} ", e.Current);
}
Console.WriteLine();
}
}
public static Object [] CopyToArray(ICollection c) {
Object [] retVal = new Object[c.Count];
c.CopyTo(retVal, 0);
return retVal;
}
请注意,只要编程语言支持,foreach 就是首选的访问集合的方法,C# 和 Visual Basic.NET 都具有这一特征。除非有合理的理由,否则就应该使用 foreach,因为使用它可以允许编译器生成更适合的代码供特定的集合访问使用。无论您是否使用同步(在上述的第二个方法中,我们这样做了,但除非多个线程可以同时访问同一集合,否则没有必要同步),都应该如此。
在下面的示例中,我们要传入两个不同的集合:一个 Int32 数组和一个 Object 数组。可以自行对其他集合试用这些方法调用:
Int32 [] intArr = {0, 1, 2, 3, 4};
PrintCollection("intArr", intArr);
Object [] oArray = CollectionExample.CopyToArray(intArr);
PrintCollection("oArray(从 intArr 复制得到)", oArray);
PrintCollectionSync(oArray);
许多集合(特别是那些排了序因而可编制索引的集合)还实现 IList,IList 针对可编制索引的有序列表扩展了 ICollection(可能您还记得,ICollection 是对 IEnumerable 的扩展)。IList 新增了一个属性(在 Beta 2 及更高版本中增加三个属性)和七个方法。
一个属性为 Item,用于获取索引并返回相应的列表元素。(Item 是 C# 的索引函数,因此对集合 Foo 而言,在 C# 中访问 Item 属性所用的语法是 Foo[indexer]。) 在 Beta 2 及更高版本中,还有两个属性:IsFixedSize 和 IsReadOnly。它们在列表大小固定或为只读的情况下分别返回 True。
Ilist 中有许多方法:Clear 可清除列表;Add 可在列表中添加项目,通常添加到结尾(取决于实际实现);Insert 可在列表中指定的索引处插入项目;Remove 可删除某一对象的第一个实例;RemoveAt 可删除指定索引处的对象;Contains 可表明列表是否包含某个值;IndexOf 返回列表中值所在位置的索引。请注意,在大小固定的集合(如数组)上不能使用 Add、Insert、Remove 或 RemoveAt 方法。如果这样做,就会导致异常错误。
但我们可以轻松地从数组创建 ArrayList — 我们可以通过它演示 IList 方法。若要创建 ArrayList 并进行调用,要编写的代码很简单:
ArrayList al = new ArrayList(intArr);
ListTest(al);
ListTest 方法也相当简单:
public static void ListTest(IList l) {
PrintCollection("原列表", l);
l.Add(5);
PrintCollection("Add(5) 之后", l);
l.RemoveAt(2);
PrintCollection("RemoveAt(2) 之后", l);
l.Remove(3);
PrintCollection("Remove(3) 之后", l);
l.Insert(1, 7);
PrintCollection("Insert(1, 7) 之后", l);
Console.WriteLine("包含 55?{0}", l.Contains(55));
Console.WriteLine("IndexOf(4) 是 {0}", l.IndexOf(4));
l.Clear();
PrintCollection("清除之后", l);
}
这种情况下,输出为:
原列表:集合有 5 个元素:0 1 2 3 4
Add(5) 之后:集合有 6 个元素:0 1 2 3 4 5
RemoveAt(2) 之后:集合有 5 个元素:0 1 3 4 5
Remove(3) 之后:集合有 4 个元素:0 1 4 5
Insert(1, 7) 之后:集合有 5 个元素:0 7 1 4 5
包含 55?False
IndexOf(4) 是 3
清除之后:集合有 0 个元素:
至此,我们已经看到了几个用于集合的接口,那么不妨再看一下在 .NET 框架中可用的集合。
与某些系统不同,.NET 框架中的数组类型是一个很丰富的集合:它有许多可供处理数组的方法。首先,数组实现 IList,因此在将数组传递给获得 IList 的方法或者将数组转换到 IList 时可以将其视为列表。(不过请注意,数组是固定大小的,因此 Add、Insert、Remove 和 RemoveAt 不可用。)由于 IList 是对 ICollection 和 IEnumerable 的扩展,因此也可以使用这两个接口处理数组。数组实现这些接口意味着,可以将它们用于 Windows 窗体和 ASP.NET 中的数据绑定控件。
不过要注意,虽然可以用 Array.CreateInstance 创建任何支持类型的数组,但不能从数组中衍生新的类,也不能直接创建自己的数组类。“.NET 框架参考”中有一个使用 Array.CreateInstance 的示例(英文)。
还记得上次讨论时,System.Array 实现大量处理数组的方法,包括对数组进行搜索、排序和转置操作。
ArrayList 类实现大小动态变化的数组,这样的数组非常类似于内置数组,唯一不同的是初始化稍微怪异一些,数组大小可以根据需要变大。还可以通过设置数组的 Capacity 属性明确地增大数组的大小,或者通过设置 Capacity 或调用 TrimToSize 将其缩小到仅包括当前数量的元素。容量是在不扩大 ArrayList 的情况下可以容纳的元素数,与 ArrayList 中当前具有的元素数不同。Capacity 总是大于或等于 Count。
可以使用现有的 ICollection、用于指定容量的 Int32,或者根本不使用参数来构造 ArrayList。还可以通过使用 Repeat 静态方法,创建使用某一重复值进行初始化的 ArrayList。
ArrayList 的功能在其他方面与数组和其他实现 IList 的类相似。不过,有些方法,如 BinarySearch、Sort 和 Reverse,在 ArrayList 中是实例方法,但在 Array 中它们是静态方法,因此 Array 和 ArrayList 的编码略微不同。
ArrayList 类还有一些方法,可处理同时对整个集合进行的添加/插入/删除/替换/检索操作。这些方法都以“Range”结尾:AddRange 获得一个集合并将其添加到 ArrayList 的结尾;GetRange 将 ArrayList 的部分复制到新的 ArrayList 中;SetRange 将作为其参数传递的集合复制到 ArrayList 中的现有元素;InsertRange 将作为其参数传递的集合插入 ArrayList 中的指定位置;RemoveRange 从 ArrayList 中删除一组元素;ToArray 返回新的数组,该数组由指定的对象或类型组成,包含 ArrayList 的内容。
最后,有一些静态方法可为现有的数据结构提供特定目的的包装:Adapter 返回引用 Ilist 中项目的 ArrayList,如果希望使用 ArrayList 方法对与 IList 兼容的集合进行排序、转置或搜索操作,这一包装就很方便;FixedSize 为大小无法更改的 ArrayList 返回包装;ReadOnly 为无法更改的 ArrayList 返回包装;Synchronize 为线程安全的 ArrayList 返回包装。
请注意,如果不需要 ArrayList 的特殊功能,就应考虑使用常规数组,从而略微提高性能 — 运行时对数组提供了一些内置优化,而对其他类型则没有。
.NET 框架中主要的非数组集合是 Hashtable 类。在散列表中存储着成对的键和对象引用。若要检索一个对象,只要给散列表提供一个键,它就会返回对该键所对应的对象的引用。对于添加和删除对象/键对,散列表速度很快。但由于它们是无序的,因此无法用整数对其编制索引(除非键是连续的整数,但这种情况下应使用数组),也无法将其排序或按顺序打印。可以对其进行枚举,但项目的顺序是不确定的。(请注意,可以让 ArrayList 引用的对象与 Hashtable 引用的对象相同,这样您就既可以编制索引/排序,也可以快速搜索数据结构 — 它在这两方面表现最好 — 只要同时更新这两种数据结构即可。)
散列表的工作方式是,将键和对象引用放入存储桶数组中。对于给定键而言,存储桶的索引通常是通过调用键对象的 GetHashCode 方法计算的。(是否记得对象讨论中的 GetHashCode? 现在到了使用它的时候。)多个键与同一个存储桶对应是可以的。在这种希望渺茫的情况下,要访问对象,就需要对整个存储桶的内容进行搜索。
如果要在散列表中用作键的对象没有可用的 GetHashCode 实现,或者如果需要对特定数据使用自定义散列函数,可以创建实现 IHashCodeProvider 接口(其中仅包含 GetHashCode 方法)的对象。然后,要创建这些对象中的一个,并将其传递给适当的 Hashtable 构造函数,这样在计算散列代码时就会调用您的方法。有一个内置的比较对象,称为 CaseInsensitiveHashCodeProvider.Default,需要时可以使用它。
散列表还需要将要查找的键与所存储的键进行比较。为此,它们要依赖于您是否正确实现 Object.Equals。这就是为什么如果覆盖 Object.Equals,也要覆盖 GetHashCode 的原因 — 这两个方法都是在散列表中进行键查找所必需的。
还可以通过创建实现 IComparer(其中包含一个方法:Compare)的对象,并在构造散列表时将其传递给适当的构造函数,从而对键比较进行自定义。有两个内置的比较函数可供使用:Comparer.Default 和 CaseInsensitiveComparer.Default。
Hashtable 实现的主要接口是 IDictionary,它是对 ICollection(及其基本 IEnumerable)的扩展。除基本接口方法的成员外,Hashtable 还实现 IDictionary 成员 — 在具有键和值的集合中使用的若干方法和属性。属性有 Keys 和 Values,它们返回集合的键和值的 ICollections;还有 Item,键要传递给它,而它返回对相应值的引用。(这是 C# 的索引函数,使用数组下标可以访问它。)
IDictionary 方法有:Add(添加键/值对)、Remove(删除键/值对)、Clear(清除词典)、Contains(如果词典中含有特定的键,它就返回 True)和 GetEnumerator(返回 IDictionaryEnumerator 而不是 IEnumerator)。
IDictionaryEnumerator 是对 IEnumerator 的扩展,并新增三个属性:Entry,返回 DictionaryEntry 结构,其中包含对当前键及值的引用;Key,返回当前键;Value,返回对当前值的引用。(仍使用 MoveNext 移至集合中的下一个项目。)
除 IDictionary 中的 Contains 方法外,Hashtable 还提供 ContainsKey(与 Contains 相同)和 ContainsValue 方法。Contains/ContainsKey 进行散列表查找,速度很快。ContainsValue 对整个散列表进行线性搜索,因此速度很慢。
虽然对集合进行了大量说明,但实际上它非常易用。我们将要使用 CaseInsensitiveHashtable(它是从 Hashtable 中衍生的),而不是普通的 Hashtable,这样即使将字符串用作键也不必进行匹配。请注意,CaseInsensitiveHashtable 在 system.dll 中,而不在 mscorlib.dll 中。我们必须在 csc 命令行添加 /r 开关,才可以进行编译:
csc /r:system.dll file.cs
// IDictionary 集合的新重载
public static void PrintCollection(String info, IDictionary c) {
Console.Write("{0}:集合有 {1} 个元素:", info, c.Count);
foreach (DictionaryEntry e in c) { // 编写循环的最简便方法
Console.Write("{0}/{1} ", e.Key, e.Value);
}
Console.WriteLine();
}
public static void HashTest() {
Hashtable h = new CaseInsensitiveHashtable(5);
String [] sList = {"Zero", "One", "Two", "Three", "Four"};
Int32 count = sList.Length;
for (Int32 i = 0; i < count; i++) {
h.Add(sList[i], i);
}
h.Add(5, "FIVE"); // 注意键和值类型不同
Console.WriteLine("顺序未定义");
PrintCollection("散列表", h);
Console.WriteLine("键‘fOuR’的值是 {0}", h["fOuR"]);
// 输出:键‘fOuR’的值是 4
Console.WriteLine("键 5 的值是 {0}", h[5]);
// 输出:键 5 的值是五
}
请注意,可以使用散列表表示任何类型的对象对之间的相关性。键可以是任意类型(甚至可以在同一散列表中混合多种类型),值也可以是任意类型。唯一的要求是键类型必须正确实现 GetHashCode 和 Equals(或者您必须提供实现 IHashCodeProvider 和 IComparer 的对象),键在被散列表引用时必须是不变的。如果键发生改变,就找不到它了。因此,最好使用不变类型的键。(请注意,String 是不变的。)
散列表实际上很易用,而且性能良好 — 如果散列函数完备,键就会均衡地分布到存储桶中。编写完备的散列代码生成器颇具艺术性,很难掌握。但要说明的是,如果没有完备的散列函数,则还可以使用其他集合(包括下述的一个集合)代替散列表。
SortedList 类介于散列表和数组之间:可以使用对分搜索快速查找键(但不如散列表快)。此外,还可以对 SortedList 编制索引。换言之,SortedList 既实现 IDictionary 又实现 IList(而且具有二者的所有成员) 。从内部来说,SortedList 使用数组对进行存储:一个用于键,一个用于值。
有几种以前不曾见过的方法:GetKeyList 和 GetValueList 与 Keys 和 Values 属性相似,唯一不同的是前者返回有序的、可编制索引的 IList,而不是 ICollection;GetByIndex 和 SetByIndex 允许您按照索引而不是按照键访问值;IndexOfKey 和 IndexOfValue 返回某一键或值的索引 — 请注意,IndexOfValue 很慢,原因在于它是线性搜索。(不过,请注意,按照索引访问 SortedList 要比访问散列表快。)
SortedList 的代码与散列表相似:
public static void SortedListTest() {
SortedList sl = new CaseInsensitiveSortedList();
String [] s = {"Zero", "One", "Two", "Three", "Four"};
Int32 count = s.Length;
for (Int32 i = 0; i < count; i++) {
sl.Add(s[i], i);
}
Console.WriteLine("注意:按字符串排序,不是按值!");
PrintCollection("SortedList", sl);
// 输出:Four/4 One/1 Three/3 Two/2 Zero/0
Console.WriteLine("键‘fOuR’的值是 {0}", sl["fOuR"]);
// 输出:键‘fOuR’的值是 4
Console.WriteLine("索引 4 的键/值是 {0}/{1}",
sl.GetKey(4), sl.GetByIndex(4));
// 输出:索引 3 的键/值是零/0
}
Queue 和 Stack 类实现传统的 FIFO(先进先出)队列和 LIFO (后进先出)堆栈。二者都是使用数组在内部实现的。对二者而言,都要确保所用的数组足够长。如果在向集合中添加对象的过程中,数组长度不够,则会分配新的数组,并将原有数组复制到其中 — 成本很高的操作。
这两个集合都实现 ICollection(并因此实现 IEnumerable)。此外,它们还有一些有趣的方法。二者都有 Peek 方法,可允许您查看集合头中的对象而不删除它。将项目添加到堆栈中的方法称为 Push,添加到队列中的方法称为 Enqueue。删除堆栈头的方法称为 Pop,删除队列头的方法称为 Dequeue。
下面是一个使用队列和堆栈的简单示例:
public static void QueueStackTest() {
Queue q = new Queue();
Stack s = new Stack();
for (Int32 i = 0; i < 5; i++) {
q.Enqueue(i);
s.Push(i);
}
Object o;
PrintCollection("Queue", q);
Console.Write("从队列删除:");
while (q.Count > 0) {
o = q.Dequeue();
Console.Write("{0} ", o);
}
// 输出:0 1 2 3 4
Console.WriteLine();
PrintCollection("Stack", s);
Console.Write("从堆栈删除:");
while (s.Count > 0) {
o = s.Pop();
Console.Write("{0} ", o);
}
// 输出:4 3 2 1 0
Console.WriteLine();
}
如果希望处理压缩的位数组,BitArray 类很适合。它允许您将 Boolean、Byte 或 Int32 的数组一位一位地复制到可以处理的位字符串中。它还支持位的设置和清除,以及 And、Or、Xor 和 Not 方法。
“.NET 框架参考”中有大量示例,因此 Dr. GUI 不在此重复了。
StringCollection 类是极为简单的、基于 IList 的 String 集合。它与集合(如 ArrayList)相比,主要优点在于,它的类型很稳定 — 当您从集合中取得一个值时,它是 String,而不是 Object,而 Object 需要转换为 String。请到 .NET 框架中的查阅文档(英文),以获得详细信息。
其余的集合大都处于 Beta 1 和 Beta 2 之间的过渡状态,因此我们现在跳过它们。可以通过以下方法查阅它们:在“.NET 框架参考”中查看 System.Collections 名称空间及其子名称空间 System.Collections.*。子名称空间的名称在 Beta 2 中会有更改。
如果想编写一个自己的集合,则可能的话,最好基于现有的集合创建 — 查看结尾为“Base”的类,也可以只是继承另一个集合或者包含另一个集合。
即使无法使用一个基类,也最好是实现适当的标准接口,如 IList、ICollection、IDictionary 和 IEnumerable。这样做可允许您的集合被其他 .NET 框架代码(例如,数据绑定控件)无缝地使用。
在使用 .NET 框架集合时您将会注意到,在从集合中获取项目时,要进行大量的类型转换,原因是它们在内部通常存储成 Object。可以不经转换而将它们放入,但若要取出它们,就需要在将它们指派给类型固定的引用时进行转换。
使用 Object 引用的事实还表明,放入集合中的值类型将被包装。如果正在创建包含值类型的集合,则更有效的做法是创建要使用的引用类型,以避免大量包装和解开包装操作。
问题在于,由于可以在集合中放入“任何”类型,因此很容易放入类型有误的对象。是否记得,当您进行类型转换时,运行时会检查类型 — 如果不能进行转换,就会出现异常错误。
如果可以有一个 Add 方法,只在集合中放入所需的类型,则集合的使用将更安全,也更容易,因为这样就不会在集合中放入任何“错误”类型。
C++ 程序员知道,高明的解决方案是:模板(也称为通用类或用参数表示的类型)。如果您不是 C++ 程序员,使用模板可以创建类或函数,其中某些参数实际上是类型,而不是变量。换言之,可以创建能处理任何通用(而不是类型安全)的类。
.NET 框架现在没有通用类,但以后的版本中会有。
如果确实需要类型安全,可以进行一些工作来实现。新建一个类并对其中适当的容器进行声明。然后,提供用户将使用的方法,让他们处理所包含的容器。下面是一个特别简化的示例:
class StringQueue {
private Queue q = new Queue();
public void Enqueue(String s) {
q.Enqueue(s);
}
public String Peek() {
return (String)q.Peek();
}
public String Dequeue() {
return (String)q.Dequeue();
}
}
// 测试
StringQueue sq = new StringQueue();
sq.Enqueue("Hello");
// sq.Enqueue(1); // 不编译
String s = sq.Dequeue(); // 不转换!
Console.WriteLine("StingQueue: {0}", s);
问题在于,必须实现希望用户能访问的每个方法,而您无法实现允许添加错误类型的接口,因此这不是明智的解决方案。不过,Dr. GUI 已经听说 Beta 2 中将有一个工具,能够生成正确的类型安全的代码 — 这会很有用。
您知道沥青吗?如果不亲自用手试一试,把手弄脏,就不能了解这种材料。这次,要狠试一把了!
如果与别人合作,就会更有意思,您学到的东西也更多 — 从而发现与他人合作您可以进一步探求 .NET 的奥秘。
下面这些看法可强化您的学习: