分享

最佳实践集合(转 MSDN)

 快乐学习 2007-11-22
部分计算机科学和编程的入门课本中都会有一章介绍集合。它们可能被称为数组或数据结构,但概念是相同的。将正式数据对象中的一组元素与另一组元素联系在一起的能力对现代编程技术来说是必需的。

 

在 Microsoft® .NET Framework 中,付出的很多努力都是为了创建强大的集合类,以满足各种需求及样式。这些集合使用方便、直观,并有足够的性能,这些都是非常重要的特征。在本月的这一期内容中,我将着眼于 .NET 中的集合、它们的工作原理、使用它们的时机和一些最佳实践。


使用集合的时机

开发人员面对的第一个决定好像相当简单:我是否该使用集合?有时答案会显而易见。有时候就是个人喜好问题。集合的基本用途是将某些对象联系起来构成一个组,因为它们彼此间有着某种逻辑关系。例如,您可以有一个代表书籍的 Book 类,也可以有一个代表图书馆或图书馆中一书架书的 BookCollection 类。在这中情况下使用集合,可以在书架中快速添加或删除书籍、移动书籍、根据标题或 ISBN 或某些其他描述性数据查找书籍、统计书籍数量、遍历书籍等。随后我将就此详细介绍,但在这种情况下使用集合是显而易见的选择。

现在,让我们看一看您几乎不会选择使用集合的情形££有理数的情况。有理数是能表示为两个整数之比的数。在集合描述中,我提出集合应该与一组元素结合使用,这样您就可以使用有两个元素的集合,一个用做分子,另一个用作分母。但是,分子和分母的用途完全不同(与所有元素都用于相似目的的书籍示例不同)。在集合中存储项目的一个主要原因是可以轻松遍历所有元素,因此可以对每个元素进行操作,而对组成有理数的整数来说这项任务几乎没有意义。另外,有理数的元素数量固定,而且非常少。因此,用集合存储有理数的元素没有任何意义;这里您应该使用两个单独的整数字段。

现在,请设想一个可能有必要讨论使用集合的情形。假设一个用于描述某个体育赛事(如棒球比赛)参赛队的数据结构。在比赛中需要对每个参赛队执行操作,而且每个队都有相似的目标(与有理数中的分子和分母不同)。但是,在类似棒球这样的比赛中,只有两个队参赛,因此虽然循环支持可能有用,但要枚举的项目数量却非常少(原型实现中数量是固定的)。有些人可能选择使用 TeamCollection 或 Team 实例数组描述参赛双方,另外一些人可能选择使用两个单独的 Team 字段。两种实现各有其优缺点。

然而,考虑使用集合时应牢记一些简单的指导方针。当以下陈述至少有一个为真时,则应该使用集合:

  • 各个元素具有相似的用途和同样的重要性。
  • 元素的数量未知,或者没有在编译时固定。
  • 需要支持对所有元素的遍历。
  • 需要支持对元素的排序。
  • 需要公开某个库的元素,并且预期用户在此使用集合类型。

 

注意,出于本栏目之目的,我认为数组 (System.Array) 将成为一种特殊类型的集合。


创建自定义集合的时机

.NET Framework 包含多种集合类型,其中最常见的类型在 System.Collections 命名空间和子命名空间下(System.Array 除外)。我强烈建议在了解可用的集合后再编写自定义集合,因为现有的类型可以满足您对集合的绝大部分需求。

您可能需要创建自定义集合的情况是您在尝试实现 Framework 中尚不存在的特殊数据结构。脑海中出现的图形、树和其他特殊数据结构。许多情况下它们会非常有用。如果需要现有集合类型的一个变体,则必须创建一个自定义集合。您可能需要集合只承载某些类型,并且此集合的特定实现取决于这些类型。您可能出于性能考虑而需要自定义集合;如果通用集合无法满足您的特殊性能需求,编写自定义集合可能是您的唯一选择。例如,您可能需要一个能在其中快速插入项目的列表,同时也可以快速查找项目而不必遍历该列表。


现有的集合

如果您使用现有的集合,则仍需要选择合适的集合。根据构建的内容不同,您可以从各种不同的集合中作出选择。首先,您需要确定集合中要存储什么,然后决定要如何使用集合。是否会一次性添加所有元素?是否会动态添加和删除元素?是否只需要遍历整个集合,或者还需要查找和使用特定元素?是否通过某个公共 API 公开集合?应用程序的性能特征和要求是什么?询问这些问题有助于您决定使用哪个集合,如果这些选项无法满足您的需求,可以考虑扩展集合而不是从头开始。


泛型或非泛型

.NET Framework 2.0 引入了泛型概念。在 .NET Framework 1.x 中,大多数集合类都存储 System.Object 引用。这意味着每次从集合中提取项目时都必须将其转换回原来的类型(如果您建了库,那么库的使用者需要知道这些都是什么类型)。这个转换对性能有影响,尤其在存储值类型时,因为将值添加到集合时首先需要将值封装到 System.Object 中,以后在从集合中取出值并转换回原来的类型时需要取消封装。

通过使用泛型集合类型(可在 System.Collections.Generic 命名空间下找到),可以避免这些问题。例如,如果您的 C# 应用程序中需要一个字符串列表,则可以使用 System.Collections.Generic.List<String>(Visual Basic® 中为 List(Of String),C++ 中为 <String^>)。这样可以确保提取元素时,它们只进入 System.String 类型(意味着您不需要从 Object 转换到 String)。如果尝试在列表中存储其他内容,也可以通过提供编译时错误使编译器强制该行为。另外,如果您正在开发供其他人使用的库,并且不知道他们要使用什么内含类型,则可以继续使用库用户随后将定义的泛型 T。您也可以限制该 T 类型,以便对即将使用的类型有些了解(它们从一组特定的接口派生而来,它们实现无参数的构造函数,等等)。

如果您现在或将来使用 .NET Framework 2.0,非常不鼓励使用旧的非泛型集合。需要保存它们的唯一原因可能是作为遗产,或者您的代码需要与旧版的 Framework 配合使用。使用旧的非泛型集合会降低代码速度,也会降低它的可读性。即使需要在一个集合中存储多个类型,仍可使用泛型集合。您只需要为要存储的对象找到一个常用的基本类型;最不济也可以将 System.Object 用作泛型参数以便存储任何内容(如 List<Object>),因为每个类型都是从 System.Object 派生而来。

如果有使用非泛型集合的旧代码,强烈建议您考虑更改代码以便使用泛型集合。图 1 中的表显示了建议的类型和应使用的接口。此外,.NET Framework 2.0 还包含了在 .NET Framework 1.x 中没有直接对应项的新泛型集合,例如,LinkedList<T> 和 SortedDictionary<TKey, TValue>。.NET Framework 3.5 还包含一个 HashSet<T> 类,提供新的标准集功能(先前的版本中,通常可利用 Hashtable 或 Dictionary<TKey,TValue> 来满足对集的需求)。


性能方面的含义

需要集合的设计通常是因为需要使用大量的项目,而您使用的项目数量会影响应用程序的性能。要确保选择了正确的集合类型,重要的是了解预期的集合使用模式。例如,您可能只在初始化应用程序时在集合中插入项目,或者在程序的整个生命周期中您都可能插入项目。您与集合的唯一交互可能是遍历所有元素,或者您可能需要随机访问功能。了解使用配置文件有助于找到符合需求的最佳集合。

如果需要非常快地添加、删除和查找项目,而且不关心集合中项目的顺序,那么首先应该考虑使用 System.Collections.Generic.Dictionary<TKey, TValue>(或者您正在使用 .NET Framework 1.x,可以考虑 Hashtable)。三个基本操作(添加、删除和包含)都可快速操作,即使集合包含上百万的项目。另一方面,利用 List<T>(或 .NET Framework 1.x 中的 ArrayList)插入和删除项目所需的时间都可能有所不同。(List<T> 和 ArrayList 都在基础数组中存储项目,并保持顺序。添加项目可能需要移动基础数组中现有的项目以腾出空间。在末尾添加项目不需要进行任何移动,而且速度非常快)。

如果您的使用模式很少需要删除和大量添加,而重要的是保持集合的顺序,那么您仍然可以选择 List<T>。虽然查找速度可能比较慢(因为在搜索目标项目时需要遍历基础数组),但可以保证集合会保持特定的顺序。另外,您可以选择 Queue<T> 实现先进先出 (FIFO) 顺序或 Stack<T> 实现后进先出 (LIFO) 顺序。虽然 Queue<T> 和 Stack<T> 都支持枚举集合中的所有项目,但前者只支持在末尾插入和从开头删除,而后者只支持从开头插入和删除。

如果需要在实现快速插入的同时保持顺序,那么使用新的 LinkedList<T> 集合可帮助您提高性能。与 List<T> 不同,LinkedList<T> 是作为动态分配的对象链实现。与 List<T> 相比,在集合中间插入对象只需要更新两个连接和添加新项目。从性能的角度来看,链接列表的缺点是垃圾收集器会增加其活动,因为它必须遍历整个列表以确保没有对象没有被释放。另外,由于每个节点相关的开销以及每个节点在内存中的位置等原因,大的链接列表可能会出现性能问题。虽然将项目插入到 LinkedList<T> 的实际操作比在 List<T> 中插入要快得多,但是找到要插入新值的特定位置仍需遍历列表并找到正确的位置。

如前所述,Dictionary<TKey, TValue> 可能最适用于快速插入和查找项目。但是,较快的查找速度只是针对普通情况而言,某些数据集可能导致性能急剧下降。SortedDictionary<TKey,TValue> 是一个不同的实现,它用平衡树实现作为基础数据存储;这相对提高了查找速度并保持项目的排列顺序,但插入很有可能会慢一些(随集合中的项目数量不同而有所差异)。或者也可以用 SortedList<Tkey,TValue>,它使用两个独立的数组分别保存键和值,并保持两者的顺序(最坏的情况是必须移动所有键和值)。


使用 List<T> 的天气年鉴

以下示例是一个简单的应用程序,计算各城市天气预报列表的平均温度和湿度(请参见图 2)。WeatherReport 类用于存储所选城市的单个天气事件。List<WeatherReport> 集合用于存储求平均值的信息。我在这个案例中选择了 List<T> 集合,因为我的使用模式是只添加项目,而不是删除项目。另外,我不关心排序或在中间插入项目,也不关心在列表中查找单个项目。我对列表做的唯一其他操作是遍历项目。您可以选择将此程序扩展为找到最热或最冷的城市,以及增加许多天气年鉴类型操作。


自定义集合

某些情况下,您可能发现 Framework 中任何现有的集合类型都无法满足您的全部要求。可能您有独特的要求,或者仍在使用经过很少改动的现有集合。如果决定要编写自定义集合,首先要考虑扩展现有的集合类型。

通过查看 System.Collections.ObjectModel 命名空间开始。这些集合已经实现了最需要的接口,并为您提供了需要的基本功能。您可以轻易地为继承的类添加方法,从而满足您的特定需求。

例如,假设您需要实现一个简单的 System.Double 值集合,但还希望支持使用 Double.TryParse 的 Add(string) 方法,只在将值成功解析为 Double 后才添加结果。System.Collections.ObjectModel.Collection<T> 类实现了所有的标准集合接口,为了提供附加的 Add 重载,您的自定义集合类型从 Collection<Double> 派生即可。

class DoubleCollection : Collection<double>
{
public void Add(string st)
{
Double d;
if (Double.TryParse(st, out d)) base.Add(d);
else throw new ArgumentException(
“Cannot parse string to a double. “ +
“Item was not added to collection.”);
}
}

 

随着扩展方法的引入,.NET Framework 3.5 可以提供额外的机制来创建扩展现有类型的方法。例如,您可以在 C# 3.0 中重新编写此方法作为扩展方法,如下所示:

class CollectionExtensions
{
public static void Add(this Collection<Double> c, string st)
{
Double d;
if (Double.TryParse(st, out d)) c.Add(d);
else throw new ArgumentException(
“Cannot parse string to a double. “ +
“Item was not added to collection.”);
}
}

 

如果一个 Collection<Double> 有命名的值,则此静态方法可通过传统语法将新值添加到集合中。

CollectionExtensions.Add(values, “3.14”);

 

但是,因为这是一个扩展方法(显然“this”关键字将第一个参数归为静态 Add 方法),您可以按如下所示重新编写:

values.Add(“3.14”);

 

如果用新类型扩展了 Collection<Double>,那么您会编写完全相同的方法调用,但您现在可以将此新 Add 方法与任何 Collection<Double> 实例(或其派生的任何类型)配合使用,而不只是与自定义类型一起使用。或者您可以开始构建自己的集合,不需要继承任何现有的集合。这种情况下,您可以利用 .NET Framework 中提供的适当接口。


实现集合接口

如果您真的决定从头开始编写自己的自定义集合,则应实现 .NET Framework 提供的所有适当接口。这些接口公开了与集合交互的常见方式,大多数人都认为非常直观和易于使用。下面是可供选择的各种接口的简短概述。

IEnumerable  该接口允许通过返回集合枚举器来枚举集合。该接口公开可返回 IEnumerator 对象的单一方法 GetEnumerator。IEnumerator 对象用于快速遍历整个集合。实现了此接口的任何集合都可以通过 C# 中的 foreach 语言结构(在 Visual Basic 中是 For Each)进行访问。

IEnumerable<T>  此接口通过附加的 GetEnumerator 方法扩展了非泛型 IEnumerable,该方法返回 IEnumerator<T> 而不是 IEnumerator。这意味着如果计划实现 IEnumerable<T>,则还必须实现 IEnumerable。不同之处是此附加的 GetEnumerator 返回泛型版本的 IEnumerator (IEnumerator<T>) 而不是非泛型版本。这是一个常见的做法,首先在 IEnumerable<T> 中提供集合的枚举逻辑,然后使非泛型 GetEnumerator 方法委托到泛型 GetEnumerator 方法以确保两者都返回相同的枚举器(从而不需要再一次实现同一逻辑)。

IEnumerator  此接口通常不由集合自身实现。我在此提到它是因为它是一个有用的接口,在构建自定义集合时应牢记。此接口用于枚举集合中的项目。它公开三个方法:Reset、Current 和 MoveNext。Reset 方法用于初始化枚举。Current 方法用于获取当前的项目,MoveNext 用于移至下一个项目。如果能用 GetEnumerator 为集合获取一个枚举器,则可以实现 foreach 循环。例如,如果 Words 是一个字符串 ArrayList,那么以下代码将得到相同结果:

foreach (string word in Words)  Console.WriteLine (word);

 

IEnumerator enumerator = Words.GetEnumerator();
while(enumerator.MoveNext())
Console.WriteLine(enumerator.Current);

 

IEnumerator <T>  泛型版本的 IEnumerator 接口通过添加返回 T 的 Current 属性重载扩展了非泛型版本。此重载无需进行转换,从而使枚举集合更简单。

ICollection  此接口是 IEnumerable 的扩展。它还公开三个附加的属性和一个附加的方法:Count 是一个只读属性,可返回集合中的元素数量;IsSynchronized 指出该集合对线程安全,因此可以在多线程应用程序中使用它;SyncRoot 使实现人员能够实现对线程安全的集合(通过提供用于该目的的对象);CopyTo 用于将集合的项目按给定索引复制到数组。大体来说,我建议避免使用非泛型 ICollection 接口,因为泛型接口要有用得多。

ICollection<T>  此接口是 IEnumerable<T> 的扩展(因此也是 IEnumerable 的扩展接口)。它还公开五个新方法和两个新属性。方法包括 Add(用于将新元素添加到集合中)、Remove(用于删除元素)、Clear(用于删除所有元素)、Contains(用于确定某个项目是否在集合中)和 CopyTo(用于将项目复制到数组)。两个只读属性是 Count(与非泛型 ICollection 中的相同)和 IsReadOnly,后者告诉我们集合是否为只读,或者说无法删除或添加项目(即使集合为只读,集合中的可变项目仍然可以更改)。

此接口作为任何自定义集合的起点都非常有用。如果不知道要在集合中存储什么类型的项目(或要存储多种类型的项目),用 ICollection<Object> 即可,实际上得到的是非泛型的扩展 ICollection 接口。

IList 和 IList<T>  IList 是 ICollection 的扩展。除了 ICollection(泛型或非泛型版本)中的所有内容外,它还有 IndexOf、Insert 和 RemoveAt 方法以及一个索引器,用于获取和设置集合中的任何项目,就如同使用数组一样。非泛型 IList 也包含存在于泛型 ICollection 中和不存在于非泛型 ICollection 中的方法(Add、Remove、Clear 和 Contains)。创建自定义集合时 IList 接口非常有用,可用于自行添加和删除项目,获取或设置任何项目的值。RemoveAt 方法可根据索引删除项目(而常规 Remove 只能根据值删除项目)。Insert 方法用于在列表的任何位置添加项目,而常规 Add 只能在最后添加。

IDictionary 和 IDictionary<TKey, TValue>  IDictionary 是 ICollection 的扩展。它支持用一个键在集合中存储和查找项目的概念。除了 ICollection 支持的所有方法外,它还公开了将键与添加的项目相关联的另一个 Add 重载。ContainsKey 方法将检查是否存在特殊键。Keys 和 Values 属性可返回 IDictionary 中所有键或值的 ICollection、基于键而不是值的删除重载,以及使用该键的索引器。该接口的泛型版本要复杂一些,因为它允许分别指定值类型和键类型。


再次强调泛型或非泛型

创建自定义集合时,可能不必使之成为泛型。如果知道集合要存储的对象类型,而且知道这些类型将来不会改变,则可使用固定类型的集合。您仍可以扩展泛型框架集合或使用泛型接口,但类总会存储特定类型的对象。

如果您要构建库或更通用的集合(如树或图),那么创建泛型可能是一个不错的主意。这样此类型的使用者就能够将它与各种类型一起使用,而且没有使用 Object 和来回转换的缺点。通用有向图类可能如下所示:

class DirectionalGraph<T> :
IEnumerable<T>, ICollection<T>, IList<T>
{
public void Clear() { }
public void Add(T item, IEnumerable<T> inNodes,
IEnumerable<T> outNodes) { }
public void Remove(T item) { }
public bool Contains(T item) { }
static public DirectionalGraph<T> EmptyGraph { get{}}
// implement the interfaces...
}

 


家族树

下一个示例中,我将实现一个自定义的集合来存储用于家谱目的的家族树(请参见图 3)。为简化起见,我将做如下假设:每个人都有性别和年龄,每个人都有生父母。让我们称他们为母亲和父亲。

树是由节点构成的特殊集合类型。每个节点(如图 4 中的 Person 类)都存储着有关树中其他节点的信息。有一个特殊的节点被称为“根”,它是树的基础。所有其他节点都可以从树的根节点开始遍历访问。我还实现了 IEnumerable<T>,通过 foreach 循环枚举 FamilyTree 的所有成员。所有人员都输入到树中后,您应该能够回答如下问题:A 和 B 两个人之间是什么关系?

注意,此示例中并没有实现所有可能的关系,但您可以自己向 FindRelationship 方法添加更多的关系。另外还需要注意,为了示例的清晰起见,并没有对所有方法都进行充分的参数检验。建议您在实现之前确保每个方法只接受有效的参数值。

在运行图 5 中的示例时,应该注意图 6 中显示的输出。

图 6 家族树输出示例
图 6 家族树输出示例  (单击该图像获得较大视图)

此刻您应该对 .NET Framework 中的集合、它们是什么,以及使用它们的时机和原因有了很好的理解。集合可以是编程工具箱中的强大工具,因此了解它何时有用以及如何聪明地使用它都非常重要。如果您需要更多的信息,请参阅“更多参考资料”侧栏中的资源。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多