第 16 章 数组

第 16 章 数组

本章内容:

数组是允许将多个数据项作为集合来处理的机制。CLR 支持一维、多维和交错数组(即数组构成的数组)。所有数组类型都隐式地从 System.Array 抽象类派生,后者又派生自 System.Object。这意味着数组始终是引用类型,是在托管堆上分配的。在应用程序的变量或字段中,包含的是对数组的引用,而不是包含数组本身的元素。下面的代码更清楚地说明了这一点:

1
2
Int32[] myIntegers;                 // 声明一个数组引用
myIntegers = new Int32[100]; // 创建含有 100 个 Int32 的数组

第一行代码声明 myIntegers 变量,它能指向包含 Int32 值的一维数组。myIntegers 刚开始设为 null,因为当时还没有分配数组。第二行代码分配了含有 100 个 Int32 值的数组,所有 Int32 都被初始化为 0。由于数组是引用类型,所以会在托管堆上分配容纳 100 个未装箱Int32所需的内存块。实际上,除了数组元素,数组对象占据的内存块还包含一个类型对象指针、一个同步块索引和一些额外的成员。该数组的内存块地址被返回并保存到myIntegers变量中。

CLS要求,所有数组都必须是 0 基数组(即最小索引为 0)。

每个数组都关联了一些额外的开销信息:

  1. 括数组的秩,即数组的维数
  2. 数组每一维的下限(几乎总是 0)
  3. 每一维的长度
  4. 数组的元素类型
多维数组
1
2
3
4
5
// 创建一个二维数组,由 Double 值构成
Double[,] myDoubles = new Double[10, 20];

// 创建一个三维数组,由 String 引用构成
String[,,] myStrings = new String[5, 3, 10];
交错数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建由多个 Point 数组构成的一维数组
Point[][] myPolygons = new Point[3][];

// myPolygons[0] 引用一个含有 10 个 Point 实例的数组
myPolygons[0] = new Point[10];

// myPolygons[1] 引用一个含有 20 个 Point 实例的数组
myPolygons[1] = new Point[20];

// myPolygons[2] 引用一个含有 30 个 Point 实例的数组
myPolygons[2] = new Point[30];

// 显示第一个多边形中的 Point
for (Int32 x = 0; x < myPolygons[0].Length; x++)
Console.WriteLine(myPolygons[0][x]);

16.1 初始化数组元素

前面展示了如何创建数组对象,如何初始化数组中的元素。C# 允许用一个语句做这两件事情。例如:

String[] names = new String[] { "Aidan", "Grant" };

大括号中的以逗号分隔的数据的数据项称为数组初始化器(array initializer)。每个数据项都可以是一个任意复杂度的表达式;在多维数组的情况下,则可以是一个嵌套的数组初始化器。
可利用 C# 的“隐式类型的局部变量”功能来简化一下代码:

1
2
// 利用 C# 的隐式类型的局部变量功能:
var names = new String[] { "Aidan", "Grant" };
1
2
// 利用 C#的隐式类型的局部变量和隐式类型的数组功能:
var names = new[] { "Aidan", "Grant", null };

作为初始化数组时的一个额外的语法奖励,还可以像下面这样写:

String[] names = { "Aidan", "Grant" };

C#编译器不允许在这种语法中使用隐式类型的局部变量:

1
2
// 试图使用隐式类型的局部变量(错误)
var names = { "Aidan", "Grant" };
1
2
3
4
5
6
// 使用 C# 的隐式类型的局部变量、隐式类型的数组和匿名类型功能:
var kids = new[] { new { Name="Aidan" }, new { Name="Grant" }};

// 示例用法(用了另一个隐式类型的局部变量):
foreach (var kid in kids)
Console.WriteLine(kid.Name);

16.2 数组转型

对于元素为引用类型的数组,CLR 允许将数组元素从一种类型转型另一种。成功转型要求数组维数相同,而且必须存在从元素源类型到目标类型的隐式或显式转换。CLR 不允许将值类型元素的数组转型为其他任何类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 创建二维 FileStream 数组
FileStream[,] fs2dim = new FileStream[5, 10];

// 隐式转型为二维 Object 数组
Object[,] o2dim = fs2dim;

// 二维数组不能转型为一维数组,编译器报错:
// error CS00303: 无法将类型“object[*,*]”转换为“System.IO.Stream[]”
Stream[] sldim = (Stream[]) o2dim;

// 显示转型为二维 Stream 数组
Stream[,] s2dim = (Stream[,]) o2dim;

// 显式转型为二维 String 数组
// 能通过编译,但在运行时抛出 InvalidCastException 异常
String[,] st2dim = (String[,]) o2dim;

// 创建一维 Int32 数组(元素是值类型)
Int32[] ildim = new Int32[5];

// 不能将值类型的数组转型为其他任何类型,编译器报错:
// error CS0030:无法将类型 "int[]" 转换为 "Object[]"
Object[] oldim = (Object[]) ildim;

// 创建一个新数组,使用 Array.Copy 将源数组中的每个元素
// 转型为目标数组中的元素类型,并把它们复制过去。
// 下面的代码创建元素为引用类型的数组,
// 每个元素都是对已装箱 Int32 的引用
Object[] obldim = new Object[ildim.Length];
Array.Copy(ildim, obldim, ildim.Length);

Array.Copy 的作用不仅仅是将元素从一个数组复制到另一个。Copy方法还能正确处理内存的重叠区域,就像 C 的 memmove 函数一样。有趣的是, C 的 memcpy 函数反而不能正确处理处理重叠的内存区域。Copy方法还能在复制每个数组元素时进行必要的类型转换,具体如下所述:

  • 将值类型的元素装箱为引用类型的元素,比如将一个 Int32[] 复制到一个 Object[] 中。

  • 将引用类型的元素拆箱为值类型的元素,比如将一个 Object[] 复制到一个 Int32[] 中。

  • 加宽 CLR 基元值类型,比如将一个 Int32[] 的元素复制到一个 Double[] 中。

  • 在两个数组之间复制时,如果仅从数组类型证明不了两者的兼容性,比如从 Object[] 转型为 IFormattable[],就根据需要对元素进行向下类型转换。如果Object[]中的每个对象都实现了IFormattableCopy方法就能成功执行。
    下面演示了 Copy 方法的另一种用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义实现了一个接口的值类型
internal struct MyValueType : IComparable {
public Int32 CompareTo(Object obj){
...
}
}

public static class Program {
public static void Main() {
// 创建含有 100 个值类型的数组
MyValueType[] src = new MyValueType[100];

// 创建 IComparable 引用数组
IComparable[] dest = new IComparable[src.Length];

// 初始化 IComparable 数组中的元素,
// 使它们引用源数组元素的已装箱版本
Array.Copy(src, dest, src.Length);
}
}

16.3 所有数组都隐式派生自 System.Array

System.Array定义了许多有用的实例方法和属性,比如 CloneCopyToGetLengthGetLongLengthGetLowerBoundGetUpperBoundLengthRank等。

System.Array类型还公开了很多有用的、用于数组处理的静态方法。这些方法均获取一个数组引用作用作为参数。一些有用的静态方法包括:AsReadOnlyBinarySearchClearConstrainedCopyConvertAllCopyExistsFindFindAllFindIndexFindLastFindLastIndexForeachIndexOfLastIndexOfResizeSortTrueForAll。这些方法中,每个都有多个重载版本,能保障编译时的类型安全性和良好的性能。

16.4 所有数组都隐式实现 IEnumerableICollectionIList

创建一维 0 基数组类型时,CLR 自动使数组类型实现IEnumerable<T>ICollection<T>IList<T>(T是数组元素的类型)。同时,还为数组类型实现这三个接口,只要它们是引用类型。

所以,如果执行以下代码:

FileStream[] fsArray;

那么当 CLR 创建FileStream[]类型时,会自动为这个类型实现 IEnumerable<FileStream>ICollection<FileStream>IList<FileStream>接口。此外,FileStream[]类型还会为基类型实现接口:IEnumerable<Stream>IEnumerable<Object>ICollection<Stream>ICollection<Object>IList<Stream>IList<Object>。由于所有这些接口都由 CLR 自动实现,所以在存在这些接口任何地方都可以使用 fsArray 变量。例如,可将 fsArray 变量传给具有以下任何一种原型的方法:

1
2
3
void M1(IList<FileStream> fsList) { ... }
void M2(ICollection<Stream> sCollection) { ... }
void M3(IEnumerable<Object> oEnumerable) { ... }

注意,如果数组包含值类型的元素,数组类型不会为元素的基类型实现接口。例如,如果执行以下代码:

DateTime[] dtArray; // 一个值类型的数组

那么 DateTime[] 类型只会实现 IEnumerable<DateTime>ICollection<DateTime>IList<DateTime>接口,不会为DateTime的基类型(包括System.ValueTypeSystem.Object)实现这些泛型接口。这意味着dtArray变量不能作为实参传给前面的M3方法。这是因为值类型的数组在内存中的布局与引用类型的数组不同。数组内存的布局请参见本章前面的描述。

16.5 数组的传递和返回

当将数组作为实参传递给方法时,实际传递的是对该数组的引用。这意味着被调用的方法可以修改数组中的元素。如果不想被修改,需要生成数组的拷贝并将拷贝传递给方法。

另外,如果方法返回对数组的引用,可以选择返回原始数组的引用或构造一个新数组并返回该新数组的引用。如果返回一个新数组的引用,应该使用Array.Copy方法来进行浅拷贝。

如果定义返回数组引用的方法且数组中没有元素,建议返回对一个包含零个元素的数组的引用,因为这样可以简化代码。

16.7 数组的内部工作原理

CLR 内部支持两种不同的数组:

  1. 下限为0的一维数组,也称为SZ数组或向量。
  2. 下限未知的一维或多维数组。

访问一维0基数组的元素比访问非0基一维或多维数组的元素稍快。原因有两个:一是一维0基数组有特殊的IL指令,可以优化JIT编译器生成的代码;二是JIT编译器能将索引范围检查代码从循环中拿出,只执行一次。而访问非0基一维数组或多维数组的速度相对较慢,因为需要进行索引检查和减去数组下限。为了提高性能,可以考虑使用交错数组代替矩形数组。

C# 和 CLR 还允许使用 unsafe(不可验证)代码访问数组。这种技术实际能在访问数组时关闭索引上下限检查。这种不安全的数组访问技术适合以下元素类型的数组:SByteByteInt16UInt16Int32UInt32Int64UInt64CharSingleDoubleDecimalBoolean、枚举类型或者字段为上述任何类型的值类型结构。

这个功能很强大,但使用须谨慎,因为它允许直接内存访问。访问越界(超出数组上下限)不会抛出异常,但会损失内存中的数据,破坏类型安全性,并可能造成安全漏洞!

以下 C# 代码演示了访问二维数组的三种方式(安全、交错和不安全):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;
using System.Diagnostics;

public static class Program {
private const Int32 c_numElements = 10000;

public static void Main() {
// 声明二维数组
Int32[,] a2Dim = new Int32[c_numElements, c_numElements];

// 将二维数组声明为交错数组(向量构成的向量)
Int32[][] aJagged = new Int32[c_numElements][];
for (Int32 x = 0; x < c_numElements; x++)
aJagged[x] = new Int32[c_numElements];

// 1:用普通的安全技术访问数组中的所有元素
Safe2DimArrayAccess(a2Dim);

// 2:用交错数组技术访问数组中的所有元素
SafeJaggedArrayAccess(aJagged);

// 3:用 unsafe 技术访问数值中的所有元素
Unsafe2DimArrayAccess(a2Dim);
}

private static Int32 Safe2DimArrayAccess(Int32[,] a) {
Int32 sum = 0;
for (Int32 x = 0; x < c_numElements; x++) {
for (Int32 y = 0; y < c_numElements; y++) {
sum += a[x, y];
}
}
return sum;
}

private static Int32 SafeJaggedArrayAccess(Int32[][] a) {
Int32 sum = 0;
for (Int32 x = 0; x < c_numElements; x++) {
for (Int32 y = 0; y < c_numElements; y++) {
sum += a[x][y];
}
}
return sum;
}

private static unsafe Int32 Unsafe2DimArrayAccess(Int32[,] a) {
Int32 sum = 0;
fixed (Int32* pi = a) {
for (Int32 x = 0; x < c_numElements; x++) {
Int32 baseOfDim = x * c_numElements;
for (Int32 y = 0; y < c_numElements; y++) {
sum += pi[baseOfDim + y];
}
}
}
return sum;
}
}

16.8 不安全的数组访问和固定大小的数组

不安全的数组访问非常强大,因为它允许访问以下元素。

  • 堆上的托管数组对象中的元素(上一节对此进行了演示)。
  • 非托管堆上的数组中的元素。
  • 线程栈上的数组中的元素。

如果性能是首要目标,请避免在堆上分配托管的数组对象。相反,应该使用C#的stackalloc语句在线程栈上分配数组。

stackalloc语句只能创建一维0基、由值类型元素构成的数组,并且不能包含引用类型的字段。它实际上分配了一个内存块,可以使用不安全的指针来操作。

栈上分配的内存(数组)会在方法返回时自动释放,对性能有一定的提升。使用这个功能需要为C#编译器指定/unsafe开关。

在结构中嵌入数组需满足以下几个条件:
通常,由于数组是引用类型,所以结构中定义的数组字段实际只是指向数组的指针或引用;数组本身在结构的外部。不过,也可像上述代码中的CharArray结构那样,直接将数组嵌入结构。在结构中嵌入数组需满足以下几个条件。

  • 类型必须是结构(值类型);不能在类(引用类型)中嵌入数组。

  • 字段或其定义结构必须用unsafe关键字标记。

  • 数组字段必须用 fixed 关键字标记。

  • 数组必须是一维 0 基数组。

  • 数组的元素类型必须是以下类型之一:BooleanCharSByteByteInt16Int32UInt16UInt32Int64SingleDouble