一、数组
数组是一组使用数字索引的对象,这些对象属于同一种类型。虽然C#为创建数组提供了直接的语言支持,但通用类型系统让您能够创建System.Array。因此,C#数组属于引用类型,对于指向数组的引用,将从托管堆给它分配内存。然而,对于数组元素(数组包含的各项数据),将根据其类型分配内存。
在最简单的情况下,数组变量的声明类似于:
type[] identifer;
ps:C#数组不同于C语言数组,因为它们实际上是System.Array对象。因此,C#数组通过属性和方法提供了只有类才有的灵活性和威力,但使用的语法与C语言数组一样简单。
type指定了数组将包含的每个元素的类型。由于只声明了类型一次,因此数组中所有元素的类型都必须相同。方括号为索引运算符(index operator),它告诉编译器您要声明一个指定类型的数组与常规变量声明相比,数组声明的唯一不同之处在于包含方括号。不同于其他语言(如C),数组的大小是在实例化而不是声明时指定的。
要创建一个可包含5个int值的数组,可编写类似于下面的代码:
int[] array = new int[5];
ps:数组大小
在 C#中,数组大小指的是包含在各维中的元素总数,而不是数组的最大索引(upper bound),这可以通过属性Length获悉。
在 C#中,数组索引从 0 开始,因此数组中第一个元素的位置为 0。下面的语句声明了一个包含5个元素的数组,索引为0~4:
int[] array = new int[5];
该数组的长度为5,但最大索引为4。
这种数组声明创建一个一维的矩形数组。在矩形数组中,每行的长度必须相同。这种限制导致数组的形状为矩形,矩形数组因此得名。要声明多维矩形数组,可在方括号内使用逗号指定维数(称之为秩[rank])。最常见的多维数组是二维数组,可将其视为由行和列组成。数组最多不能超过32维。
除矩形多维数组外,C#还支持交错数组。由于交错数组的每个元素又是一个数组,因此不同于矩形数组,交错数组每行的长度可以不同。
ps:交错矩形数组
在下面的代码中,创建了一个二维数组和一个一维数组(j)。其中前者包含6个元素(共3行,每行2个元素);而后者的每个元素都是一维数组,分别包含一个、两个和3个元素:
int[,] a = {
{10,20},
{30,40},
{50,60}
};
int[][] j = {
new[] {10},
new[] {20,30},
new[] {40,50,60}
};
int[,][] j2;
实际上是一个二维数组,而它的每个元素都是一个一维数组。要初始化这样的数组,可编写下面这样的代码:
j2 = new int[3,2][];
因此,使用泛型集合几乎总是更好的选择。List<int[,]>显然表示一个二维数组列表,而List<int>[,]显然是一个二维数组,其元素为List<int>。
类型系统要求所有变量在使用前都必须初始化,并为每种数据类型提供了默认值。数组也不例外。对于包含数值元素的数组,每个元素的默认初始值都为0;对于包含引用类型(包括string),每个元素的默认初始值都为null。由于交错数组的元素是数组,因此这些元素的默认初始值也为null。
1.1 数组索引
要让数组发挥作用,必须访问其元素,这是通过将所需元素的数字位置放在索引运算符中实现的。要访问多维数组或交错数组的元素,需要提供多个索引位置。
1.2 数组初始化
使用运算符new可创建一个数组,并将其元素初始化为默认值。在这种情况下,必须指定秩(rank),让编译器能够知道数组的大小。以这种方式声明数组时,必须编写额外的代码。
举个栗子
class Program
{
static void Main ()
{
int[] array = new int[5];
for (int i = 0; i < array.Length; i++)
{
array [i] = i * 2;
}
}
}
C#提供了一种简捷方法,让声明和初始化数组时无需重复指出数组的类型。这种简捷方法称为数组初始值设定项,可在声明数组型局部变量和字段时使用,还可放在数组构造函数调用的后面。数组初始值设定项是一系列变量初始值设定项,它们用逗号分开,并用大括号括起。每个变量初始值设定项都是表达式;如果初始化的是多维数组,则为嵌套的数组初始值设定项。
用于一维数组时,数组初始值设定项必须包含一系列表达式,其类型与数组元素的类型兼容。这些表达式从索引0开始初始化元素,而表达式数量决定创建的数组的长度。
改写如下
class Program
{
static void Main ()
{
int[] array = { 0, 2, 4, 6, 8 };
}
}
数组初始值设定项对一维数组来说很有用;对多维数组来说,其威力将更强大,且需要使用嵌套的数组初始值设定项。在这种情况下,数组初始值设定项的嵌套深度必须与数组的维数相等。其中,最左边的维数对应于最外面的嵌套级别,最右边的维数对应于最里面的嵌套级别。
如下两个声明是等价的
int[,] array = { {0, 1}, {2, 3}, {4, 5}, {6, 7}, {8, 9} };
int[,] array = new internal[5,2];
array[0,0] =0; array[0,1] = 1;
array[1,0] =2; array[1,1] = 3;
array[2,0] =4; array[2,1] = 5;
array[3,0] =6; array[3,1] = 7;
array[4,0] =8; array[4,1] = 9;
1.3 System.Array类
System.Array 类是数组的基类,但只有运行时和编译器可显式地从它派生出其他类。虽然存在这种限制,但它提供了25个不同的静态方法供大家使用。这些方法主要用于一维数组,但鉴于一维数组是最常见的,因此这种限制通常影响不大。
下表是一些较常用的方法
方法 | 描述 |
---|---|
BinarySearch | 使用二分法搜索算法在一维排序数组中搜索指定的值 |
Clear | 根据元素类型将指定范围内的元素设置为零、false或null |
Exists | 确定指定数组是否包含符合指定条件的元素 |
Find | 在整个数组中搜索符合指定条件的元素,并返回第一个符合条件的元素 |
FindAll | 返回符合指定条件的所有元素 |
ForEach | 对数组中的每个元素执行指定的操作 |
Resize | 将数组调整到指定大小。如果数组大小没变,那么什么也不会发生;如果数组变大了,就创建一个新数组,并将旧数组中的元素复制到新数组中 |
Sort | 对指定数组中的元素进行排序 |
TrueForAll | 确定数组中的每个元素是否都符合指定的条件 |
二、索引器
通过使用索引运算符,访问数组非常简单。虽然不可能重写索引运算符,但在自己创建的类中,可以提供一个索引器,这样就可以像访问数组那样访问对象的数据。
索引器的声明方法与属性类似,但存在一些重要的差别。其中,最重要的差别如下:
索引器是用签名而不是名称标识的。
索引器只能是实例成员
索引器的签名指的是其形参的数量和类型。由于索引器是用签名标识的,因此只要它们的签名在当前类中是唯一的,就可以重载索引器。
要声明索引器,可使用如下语法:
type this [type parameter]
{
get;
set;
}
对于索引器,可使用修饰符new、virtual、sealed、override、abstract以及4个访问修饰符的有效组合。请记住,由于索引器必须是实例成员,因此不能对其使用修饰符static。索引器的形参列表必须至少包含一个参数,但是也可包含多个用逗号分开的参数。这类似于方法的形参列表。索引器的类型决定了get访问器返回的对象类型。
索引器总是应该提供get访问器(虽然这并非必须的),但可以不提供set访问器。只提供了get访问器的索引器是只读的,因为它们不允许赋值。
三、泛型集合
在C#内置的数据结构中,只有数组支持一系列对象,但作为补充,基类库提供了大量集合以及与集合相关的类型,这些类型更灵活,能够根据要使用的数据类型派生出自定义集合。
这些类型分为类和接口,而它们又进一步分为非泛型集合和泛型集合。非泛型集合不是类型安全的,因为它们只能用于 object类型,这旨在与旧版本的.NET Framework兼容。泛型集合更好,因为它们是类型安全的,且性能优于非泛型集合。泛型集合的数量几乎是非泛型集合的两倍。
3.1 列表
在所有的集合类型中,List<T>与数组最接近,也是用得最多的集合。与数组一样,列表也是一系列使用数字索引的对象;不同的是,可在需要时动态调整List<T>的大小。
列表的默认容量为16个元素,当您添加第17个元素时,列表的大小将自动翻倍。如果预先知道列表大概包含多少个元素,可在添加第一个元素前使用构造函数之一或Capacity属性设置初始容量。
如下列出了List<T>的一些常用属性和方法。如果将其与System.Array的常用静态方法进行比较,将发现有很多相似之处
成员 | 描述 |
---|---|
Capacity | 获取或设置列表在不调整大小的情况下可包含的元素总数 |
Count | 获取列表实际包含的元素数 |
Add | 将一个对象添加到列表末尾 |
AddRange | 将一组对象添加到列表末尾 |
BinarySearch | 使用二分法在排序列表中搜索特定的值 |
Clear | 删除列表中所有的元素 |
Contains | 确定列表是否包含特定元素 |
Exists | 确定列表是否包含满足指定条件的元素 |
Find | 在整个列表中搜索满足指定条件的元素,并返回第一个满足条件的元素 |
FindAll | 获取满足指定条件的所有元素 |
ForEach | 对列表中的每个元素执行指定的操作 |
Sort | 对列表中的元素进行排序 |
TrimExcess | 将容量设置为列表实际包含的元素数 |
TrueForAll | 确定是否列表中的每个元素都满足指定的条件 |
一个与List<T>相关的泛型集合是LinkedList<T>,这是一种通用的双向链表,在通常将元素添加到列表的特定位置且总是顺序访问元素时,双向链表可能是更好的选择。
3.2 Collection<T>
虽然 List<T>功能强大,但是它没有虚成员,也不能禁止修改列表。由于没有虚成员,因此难以对其进行扩展,这使其在用作基类方面的用途有限。
要创建自己的集合,且能够像数组那样使用数字索引访问它,可从Collection<T>派生;也可直接使用Collection<T>类,即创建一个Collection<T>实例,并指定要包含的对象类型。
如下列出了常用的Collection<T>的成员
成员 | 描述 |
---|---|
Count | 获取集合中实际包含的元素数 |
Add | 将一个对象加入到集合末尾 |
Clear | 删除集合中所有的元素 |
Contains | 判断集合是否包含指定的元素 |
从Collection<T>派生时,可重写它提供的多个受保护的虚方法,以自定义新类的行为。
如下是Collection<T>受保护的虚方法
成员名 | 描述 |
---|---|
ClearItems | 删除集合中的所有元素。通过重写该方法,可改变Clear方法的行为 |
InsertItem | 将元素插入到集合的指定索引处 |
RemoveItem | 删除指定索引处的元素 |
SetItem | 替换指定索引处的元素 |
一个与 Collection<T>相关的集合是 ReadOnlyCollection<T>,可像 Collection<T>那样直接使用它,也可使用它来派生只读集合。另外,还可使用List<T>实例创建只读集合。
如下是常用的ReadOnlyCollection<T>成员
成员名 | 描述 |
---|---|
Count | 获取集合实际包含的元素数 |
Contains | 判断集合是否包含指定的元素 |
IndexOf | 搜索指定的元素,并返回找到的第一个元素的索引 |
ps:ReadOnlyCollection<T>
可将ReadOnlyCollection<T>视为现有可变集合的封装,如果您试图修改它,就将引发异常。底层集合仍是可变的。
3.3 字典
创建通用集合时,List<T>和Collection<T>很有用,但有时需要这样的集合,即将一组键映射到一组值,且不能有重复的键。
Dictionary<TKey, TValue>类提供了这样的映射,能够使用键而不是数字索引进行访问。要在Dictionary<TKey, TValue>实例中添加元素,必须提供键和值。其中,键必须是唯一的,且不为null,但如果TValue为引用类型,那么值可以为null。
如下列出了Dictionary<TKey, TValue>的常用成员
成员名 | 描述 |
---|---|
Count | 获取字典包含的键/值对数 |
Keys | 返回一个集合,其中包含字典中所有的键 |
Values | 返回一个集合,其中包含字典中所有的值 |
Add | 将指定的键和值加入到字典中 |
Clear | 删除字典中所有的键和值 |
ContainsKey | 确定字典是否包含所有的键 |
ContainsValue | 确定字典是否包含所有的值 |
Remove | 将指定键对应的元素从字典中删除 |
不同于 List<T>和 Collection<T>,遍历字典中的元素时,将返回 KeyValuePair<TKey, TValue>结构,它表示键和相关联的值。因此,在foreach语句中遍历字典的元素时,关键字var很有用。
通常,字典不对其包含的元素进行排序,遍历时将以随机顺序返回元素。List<T>提供了一个Sort方法,可用于对列表中的元素进行排序,但字典没有这样的方法。希望添加或删除元素时,字典对元素进行排序,可以有两种选择:使用 SortedList<TKey, TValue>或SortedDictionary<TKey, TValue>。这两个类相似,在检索元素方面的性能相同,但占用的内存不同,插入和删除元素的性能也不同。
- SortedList<TKey, TValue>使用的内存更少
- SortedDictionary<TKey,TValue>使用的内存更少
- 使用经过排序的数据一次性填充时,SortedDictionary<TKey,TValue>的速度更快
- 检索键和值时,SortedDictionary<TKey,TValue>的效率更高
如下所示列出了SortedList<TKey, TValue>和SortedDictionary<TKey, TValue>的常用成员
成员名 | 描述 |
---|---|
Capacity | 只有SortedList<TKey,TValue>支持。获取或设置列表可包含的元素数 |
Count | 获取列表包含的键/值对数 |
Add | 将指定的键和值加入到列表中 |
Clear | 删除列表中所有的键和值 |
ContainsKey | 确定列表是否包含指定的键 |
ContainsValue | 确定列表是否包含指定的值 |
Remove | 将指定键对应的元素从列表中删除 |
TrimExcess | 如果实际包含的元素小于当前容量的90%,将容量设置为实际包含的元素数 |
TryGetValue | 获取与指定键相关联的值 |
3.4 集
在数学中,集(set)是一个不包含重复元素的集合,以随机顺序存取。除标准的插入和删除操作外,集还支持超集、子集、交集和并集操作。
在.NET中,集是通过HashSet<T>和SortedSet<T>类实现的。HashSet<T>是数学意义上的集,而 SortedSet<T>在插入和删除元素时保持元素按特定顺序排列,并且不会影响性能。这两个类都不允许元素重复,它们的公有接口几乎相同
如下所示列出了HashSet<T>和SortedSet<T>的常用成员
成员名 | 描述 |
---|---|
Count | 获取集包含的元素数 |
Max | 获取集中最大的值(只适用于SortedSet<T>) |
Min | 获取集中最小的值(只适用于SortedSet<T>) |
Add | 将指定元素加入集中 |
Clear | 删除集中所有的元素 |
Contains | 判断集是否包含指定的元素 |
ExceptWith | 将指定集合中的所有元素从当前集中删除 |
IntersectWith | 修改当前集,使其只包含当前集和指定集合中都有的元素 |
IsProperSubsetOf | 判断当前集是否是指定集合的真子集 |
IsProperSupersetOf | 判断当前集是否是指定集合的真超集 |
Overlaps | 判断当前集与指定集合是否有相同的元素 |
Remove | 将指定元素从集中删除 |
RemoveWhere | 将符合指定条件的所有元素都从集中删除 |
Reverse | 返回一个枚举器,它以倒序遍历集(只适用于Sorted<T>) |
SetEquals | 判断当前集与指定集合包含的元素是否相同 |
SymmetricExceptWith | 修改当前集,使其只包含这样的元素,即要么出现在当前集中,要么出现在指定集合中,但不同时出现在这两者中 |
TrimExcess | 设置当前集的容量,使其等于实际包含的元素数(向上舍入到最接近的值) |
UnionWith | 修改当前集,使其包含出现在当前集或指定集合中的所有元素 |
ps:为何是HashSet<T>?
不同于大多数其他的泛型集合,HashSet<T>的名称基于其实现细节,而不是其用途。这样做的原因是,Set是Visual Basic保留字,要使用它,只能进行转义:
Dim s as [Set] of Int
为避免使用这种语法,设计.NET Framework的人员选择了一个不与任何保留字发生冲突的名称。
3.5 堆栈和队列
堆栈和队列相对简单,它们分别表示后进先出(LIFO)和先进先出(FIFO)集合。虽然它们是简单集合,但也很有用。对存储按收到的顺序依次处理的数据来说,队列很有用;而对诸如语句分析等操作来说,堆栈很有用。一般而言,当操作应限于列表开头或末尾时,可使用队列和堆栈。
Stack<T>类以数组的方式实现了堆栈,其操作总是发送在数组末尾,且可包含重复的元素和null元素。Stack<T>提供了简单的公有接口,如下所示
成员名 | 描述 |
---|---|
Count | 获取堆栈包含的元素数 |
Clear | 删除堆栈中所有的元素 |
Contains | 判断堆栈是否包含指定的元素 |
Peek | 返回栈顶元素,但不将其删除 |
Pop | 返回栈顶元素并将其删除 |
Push | 将一个元素插入栈顶 |
TrimExcess | 如果堆栈包含的元素数小于当前容量的90%,就将容量设置为堆栈包含的元素数 |
Queue<T>以数组方式实现了队列,其插入操作总是在数组的一端进行,而删除操作总是在另一端进行。Queue<T>可包含重复的元素和 null 元素。Queue<T>提供了一个简单的公有接口,其常用成员如下所示
成员名 | 描述 |
---|---|
Count | 获取队列包含的元素数 |
Clear | 删除队列中所有的元素 |
Contains | 判断队列是否包含指定的元素 |
Dequeue | 返回队列开头的元素并将其删除 |
Enqueue | 将一个元素加入队列末尾 |
Peek | 返回队列开头的元素,但不删除它 |
TrimExcess | 如果队列实际包含的元素数小于当前容量的90%,就将容量设置为队列实际包含的元素数 |
四、集合初始值设定项
数组提供了数组初始化语法,对象提供了对象初始化语法,同样集合提供了集合初始化语法:集合初始值设定项。
集合初始值设定项能够指定一个或多个元素,以初始化实现了IEnumerable的集合。通过使用集合初始值设定项,可以省略多次调用Add方法的代码,而让编译器为您添加这些调用代码。元素初始值设定项可以是值、表达式或对象初始值设定项。
ps:集合初始值设定项只能用于有Add方法的集合
集合初始值设定项只能用于有 Add 方法的集合,这意味着不能将其用于Stack<T>和Queue<T>等集合。
集合初始值设定项的语法与数组初始值设定项类似。您仍必须调用new运算符,但接着可使用集合初始化语法来填充集合。
如下示例使用List<int>和集合初始值设定项
class Program
{
static void Main()
{
List<int> list = new List<int> (){0, 2, 4, 6, 8};
}
}
集合初始值设定项可以更复杂,您可以将对象初始值设定项用作元素初始值设定项,举个栗子
class Program
{
static void Main()
{
List<Contact> list = new List<Contact> ()
{
new Contanct () { FirstName = "Oliver", LastName = "Kahn" },
new Contanct () { FirstName = "Roy", LastName = "Makaay" },
new Contanct () { FirstName = "Bastian", LastName = "Schweinsteiger" }
};
foreach (Contact c in list)
{
Console.WriteLine (c);
}
}
}
由于可以在集合初始值设定项中使用对象初始值设定项,因此集合初始值设定项可用于任何类型的集合,包括Add方法接受多个参数的字典。
五、集合接口
集合接口分两类,一类提供了具体的集合实现协定,另一类提供支持实现,如比较和枚举功能。在第一类(即提供具体集合行为的)接口只有4个。
ICollection<T>:定义了用于操作泛型集合的方法和属性。
IList<T>:它扩展了 ICollection<T>,以提供用于操作这样的泛型集合的方法和属性,即其元素可通过索引进行访问。
IDictionary<TKey, TValue>:它扩展了 ICollection<T>,以提供用于操作这样的泛型集合的方法和属性,即其元素为键/值对,且每个元素的的键都必须是唯一的。
ISet<T>:它扩展了 ICollection<T>,以提供用于操作这样的泛型集合的方法和属性,即其元素都是唯一的,并支持集运算。
第二类也只包含4个接口,具体如下
IComparer<T>:定义了一个对两个对象进行比较的方法。这个接口用于支持方法List<T>.Sort和List<T>.BinarySearch,提供了一种自定义排序的方式。Compare<T>类提供了这个接口的默认实现,通常足以满足大部分需求。
IEnumberable<T>:它扩展了 IEnumerable,通过暴露一个枚举器提供了对集合进行简单迭代的支持。提供这个接口旨在与非泛型集合兼容:只要泛型集合实现了这个接口,就可将其传递给需要IEnumerable对象的方法。
IEnumerator<T>:也提供了对集合进行简单迭代的支持。
IEqualityComparer<T>:让您能够给类型 T提供相等的定义。EqualityComparer<T>类提供了这个接口的默认实现,通常足以满足大部分需求。
六、可枚举的对象和迭代器
如果查看IEnumerable<T>和IEnumerator<T>的定义,就会发现它们很像。这些接口(及其对应的非泛型接口)遵循了迭代器模式(iterator pattern)。
这种模式让您能够要求实现了IEnumerable<T>的对象(可枚举的对象)提供一个枚举器(实现了IEnumerator<T>的对象)。有了IEnumerator<T>后,就可以每次一个元素的方式枚举(迭代)数据。
如下示例是一条典型的foreach语句,它迭代一个列表,并显示每个值
List<int> list = new List<int>() { 0, 2, 4, 6, 8 };
foreach(int if in list)
{
Console.WriteLine(i);
}
实际上,编译器将把上述代码转换为类似于如下的代码:
List<int> list = new List<int>() { 0, 2, 4, 6, 8 };
IEnumerator<int> iterator = ((IEnumerable<int>)list).GetEnumerator();
while (iterator.MoveNext())
{
Console.WriteLine(iterator.Current);
}
GetEnumerator方法是在接口IEnumberable<T>中定义的(实际上,这是该接口定义的唯一一个成员),它只是提供了一种方式,让可枚举的成员能够暴露一个枚举器。正是这个枚举器通过方法MoveNext和属性Current(它们是在接口IEnumerator<T>和IEnumerator中定义的),让你能够遍历可枚举的对象包含的数据。
ps:为何提供两个接口
通过将可枚举的对象( IEnumberable<T>)同用于该对象的枚举器(IEnumerator<T>)分开,可同时对同一个可枚举源进行多次枚举。显然,都不希望这些迭代操作在数据中移动时相互干扰,因此需要两个独立的枚举器向您提供当前的元素以及移到下一个元素的机制。
所幸的是,所有泛型集合(以及非泛型集合)都实现了IEnumerable<T>、IEnumerable、IEnumerator<T>和IEnumerator,因此您无需编写实现迭代器机制的代码就可以使用它们。
如果希望自己创建的类也支持这种行为,可以让它们实现IEnumerable<T>,并编写自己的IEnumerator<T>派生类,但如果使用迭代器,就不需要这样做。
迭代器是一个方法、属性 get 访问器或运算符,它返回同一种类型的值的有序序列。关键字 yield告诉编译器,其所属代码块是一个迭代器块。yield return语句返回指定的值,而yield break语句终止迭代。对于这种迭代器方法、访问器或运算符,存在如下限制。
不能包含不安全的代码块
不能接受ref或out参数
yield return语句不能位于try-catch块内,但可位于后面跟finally块的try块内
yield break语句可位于try块或catch块内,但不能位于finally块内
yield语句不能位于匿名方法内
如下所示为一个简单的迭代器
class Program
{
static IEnumerable<int> GetEvenNumbers()
{
yield return 0;
yield return 2;
yield return 4;
yield return 6;
yield return 8;
}
static void Main()
{
foreach (int i in GetEvenNumbers())
{
Console.WriteLine(i);
}
}
}
除返回值外,迭代器还有很多功能。只要遵守前面指出的限制,迭代器在决定返回什么值时,其逻辑可以非常复杂。如下代码输出与上边的相同,但使用的迭代器更复杂。
class Program
{
static IEnumerable<int> GetEvenNumbers()
{
for (int i = 0; i <= 9; i++)
{
if (i % 2 == 0)
{
yield return i;
}
}
}
static void Main()
{
foreach (int i in GetEvenNumbers())
{
Console.WriteLine(i);
}
}
}