数组是允许将多个数据项当作一个集合来处理的机制。CLR支持一维数组、多维数组和交错数据(即由数组构成的数组)。所有数组类型都隐式地从System.Array抽象类派生,后者又派生自System.Object。这意味着数组始终是引用类型,是在托管堆上分配的。在你应用程序的变量或字段中,包含的是对数组的引用,而不是包含数组本身的元素。下面的代码更清楚的说明了这一点:
Int32[] myIntegers; //声明一个数组引用 myIntegers = new int32[100] //创建含有100个Int32的数组
在第一行代码中,myIntegers变量能指向一个一维数组(由Int32值构成)。myIntegers刚开始被设为null,因为当时还没有分配数组。第二行代码分配了含有100个Int32值的一个数组,所有Int32都被初始化为0。由于数组是引 用类型,所有托管堆上还包含一个未装箱Int32所需要的内存块。实际上,除了数组元素,数字对象占据的内存块还包含一个类型对象指针、一个同步块索引和一些额外的成员(overhead)。该数组的内存块地址被返回并保存到myIntegers变量中。
C#也支持多维数组。下面演示了几个多维数组的例子:
// 创建一个二维数组,由Double值构成 Double[,] myDoubles = new Double[10,20]; // 创建一个三位数组,由String引用构成 String[,,] myStrings = new String[5,3,10];
CLR还支持交错数组,即由数组构成的数组。下面例子演示了如何创建一个多边形数组,其中每一个多边形都由一个Point实例数组构成。
// 创建一个含有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]); }
注意:CLR会验证数组索引的有效性。换句话说,不能创建一个含有100个元素的数组(索引编号为0到99),又试图访问索引为-5或100的元素。
一、始化数组元素
前面展示了如何创建一个数组对象,以及如何初始化数组中的元素。C#允许用一个语句来同时做两件事。例如:
String[] names = new String[] { "Aidan", "Grant" };
大括号中的以逗号分隔的数据成为数组初始化器。每个数据项都可以是一个任意复杂度的表达式;在多维数组的情况下,则可以是一个嵌套的数组初始化器。可利用C#的隐式类型的数组功能让编译器推断数组元素的类型。注意,下面这一行代码没有在new和[]之间指定类型:
var names = new[] { "Aidan", "Grant", null};
在上一行中,编译器检查数组中用于初始化数组元素的表达式的类型,并选择所有元素最接近的共同基类作为数组的类型。在本例中,编译器发现两个String和一个null。由于null可隐式转型成为任意引用类型(包括String),所以编译器推断应该创建和初始化一个由String引用构成的数组。
给定一下代码:
var names = new[] { "Aidan", "Grant", 123};
编译器是会报错的,虽然String类和Int32共同基类是Object,意味着编译器不得不创建Object引用了一个数组,然后对123进行装箱,并让最后一个数组元素引用已装箱的,值为123的一个Int32。但C#团队认为,隐式对数组 元素进行装箱是一个代价昂贵的操作,所以要做编译时报错。
在C#中还可以这样初始化数组:
String[] names = { "Aidan", "Grant" };
但是C#不允许在这种语法中使用隐式类型的局部变量:
var names = { "Aidan", "Grant" };
最后来看下"隐式类型的数组"如何与"匿名类型"和"隐式类型的局部变量"组合使用。
// 使用C#的隐式类型的局部变量、隐式类型的数组和匿名类型 var kids = new[] {new { Name="Aidan" }, new { Name="Grant" }}; // 示例用法 foreach (var kid in kids) Console.WriteLine(kid.Name);
输出结果:
Aidan
Grant
二、数组转型
对于元素为引用类型的数组,CLR允许将数组元素从一种类型隐式转型到另一种类型。为了成功转型,两个数组类型必须维数相等,而且从源类型到目标类型,必须存在一个隐式或显示转换。CLR不允许将值类型元素的数组转型为其他任何类型。(不过为了模拟实现这种效果,可利用Array.Copy方法创建一个新数组并在其中填充数据)。下面演示了数组转型过程:
private static void ArrayCasting() { // 创建一个二维FileStream数组 FileStream[,] fs2dim = new FileStream[5, 10]; // 隐式转型为一个二维Object数组 Object[,] o2dim = fs2dim; // 不能从二维数组转型为一维数组 //Stream[] s1dim = (Stream[]) o2dim; // 显式转型为二维Stream数组 Stream[,] s2dim = (Stream[,]) o2dim; // 显式转型为二维String数组 // 能通过编译,但在运行时会抛出异常 String[,] st2dim = (String[,]) o2dim; // 创建一个意味Int32数组(元素是值类型) Int32[] i1dim = new Int32[5]; // 不能将值类型的数组转型为其他任何类型 // Object[] o1dim = (Object[]) i1dim; // 创建一个新数组,使用Array.Copy将元数组中的每一个元素 // 转型为目标数组中的元素类型,并把它们复制过去 // 下面的代码创建一个元素为引用类型的数组, // 每个元素都是对已装箱的Int32的引用 Object[] o1dim = new Object[i1dim.Length]; Array.Copy(i1dim, o1dim, 0); }
Array.Copy方法的作用不仅仅是将元素从一个数组复制到另一个数组。Copy方法还能正确处理内存的重叠区域。
Copy方法还能在复制每一个数组元素时进行必要的类型转换。Copy方法能执行以下转换:
1)将值类型的元素装箱为引用类型的元素,比如将一个Int32[]复制到一个Object[]中。
2)将引用类型的元素拆箱为值类型的元素,比如将一个Object[]复制到Int32[]中。
3)加宽CLR基元值类型,比如将一个Int32[]的元素复制到一个Double[]中。
4)在两个数组之间复制时,如果仅从数组类型证明不了两者的兼容性。
在某些情况下,将数组从一种类型转换为另一种类型是非常有用的。这种功能称为数据协变性。利用数组协变性时,应该清楚由此带来的性能损失。
注意:如果只需要把数组中某些元素复制到另一个数组,可以选择System.Buffer的BlockCopy方法,它的执行速度比Array.Copy方法快。不过,Buffer的BlockCopy方法只支持基元类型,不提供像Array的Copy方法那样的转型能力。方法的Int32参数代表的是数组中的字节偏移量,而非元素索引。如果需要可靠的将一个数组中的元素复制到另一个数组,应该使用System.Array的ConstrainedCopy方法,该方法能保证不破坏目标数组中的数组的前提下完成复制,或者抛出异常。另外,它不执行任何装箱、拆箱或向下类型转换。
三、所有数组都隐式派生自System.Array
如果像下面这样声明一个数组变量:
FileStream[] fsArray;
CLR会为AppDomain自动创建一个FileStream[]类型。这个类型将隐式派生自System.Array类型;因此,System.Array类型定义的所有实例方法和属性都将有FileStream[]继承,使这些方法和属性能通过fsArray变量调用。
四、所有数组都隐式实现IEnumerable,ICollection和IList
许多方法都能操作各种集合对象,因为在声明它们时,使用了IEnumerable,ICollection和IList等参数。可以将数组传给这些方法,因为System.Array也实现了这三个接口。System.Array之所以实现这些非泛型接口,是因为这些接口将所有元素都视为Systm.Object。然而,最好让System.Array实现这个接口的泛型形式,提供更好的编译时类型安全性和更好的性能。
五、数组的传递和返回
数组作为实参传给一个方法时,实际传递的是对该数组的引用。因此,被调用的方法能修改数组中的元素。如果不想被修改,必须生成数组的一个拷贝,并将这个拷贝传给方法。注意,Array.Copy方法执行的是浅拷贝。
有的方法返回一个对数组的引用。如果方法构造并初始化数组,返回数组引用是没有问题的。但假如方法返回的是对一个字段维护的内部数组的引用,就必须决定是否向让该方法的调用者直接访问这个数组及其元素。如果是就可以返回数组引用。但是通常情况下,你并不希望方法的调用这获得这个访问权限。所以,方法应该构造一个新数组,并调用Array.Copy返回对新数组的一个引用。
如果定义一个返回数组引用的方法,而且该数组不包含元素,那么方法既可以返回null,又可以放回对包含另个元素的一个数组的引用。实现这种方法时,Microsoft强烈建议让它返回后者,因为这样做能简化调用该方法时需要的代码。
// 这段代码更容易写,更容易理解 Appointment[] app = GetAppointmentForToday(); for (Int32 a =0; a< app.Length; a++) { // 对app[a]执行操作 }
如果返回null的话:
// 写起来麻烦,不容易理解 Appointment[] app = GetAppointmentForToday(); if( app !=null ) { for (Int32 a =0; a< app.Length; a++) { // 对app[a]执行操作 } }
六、创建下限非零的数组
可以调用数组的静态CreateInstance方法来动态创建自己的数组。该方法有若干个重载版本,允许指定数组元素的类型、数组的维数、每一维的下限和每一维的元素数目。CreateInstance为数组分配内存,将参数信息保存到数组的内存块的额外开销(overhead)部分。然后返回对该数组的一个引用。
七、数组的访问性能
CLR内部实际支持两种不同的数组
1)下限为0的意味数组。这些数组有时称为SZ数组或向量。
2)下限未知的一维或多维数组。
可执行一下代码来实际地查看不同种类的输出
internal static class ArrayTypes {
public static void Go() {
Array a; // 创建一个一维数组的0基数组,其中不包含任何元素 a = new String[0]; Console.WriteLine(a.GetType()); // System.String[] // 创建一个一维数组的0基数组,其中不包含任何元素 a = Array.CreateInstance(typeof(String), new Int32[] { 0 }, new Int32[] { 0 }); Console.WriteLine(a.GetType()); // System.String[] // 创建一个一维数组的1基数组,其中不包含任何元素 a = Array.CreateInstance(typeof(String), new Int32[] { 0 }, new Int32[] { 1 }); Console.WriteLine(a.GetType()); // System.String[*] <-- 注意! Console.WriteLine(); // 创建一个二维数组的0基数组,其中不包含任何元素 a = new String[0, 0]; Console.WriteLine(a.GetType()); // System.String[,] // 创建一个二维数组的0基数组,其中不包含任何元素 a = Array.CreateInstance(typeof(String), new Int32[] { 0, 0 }, new Int32[] { 0, 0 }); Console.WriteLine(a.GetType()); // System.String[,] // 创建一个二维数组的1基数组,其中不包含任何元素 a = Array.CreateInstance(typeof(String), new Int32[] { 0, 0 }, new Int32[] { 1, 1 }); Console.WriteLine(a.GetType()); // System.String[,] } }
对于一维数组,0基数组显示的类型名称是System.String[],但1基数组显示的是System.String[*]。*符号表示CLR知道该数组不是0基的。注意,C#不允许声明String[*]类型的变量,因此不能使用C#语法来访问一维的非0基数组。尽管可以调用Array的GetValue和SetValue方法来访问数组的元素,但速度会比较慢,毕竟有方法调用的开销。
对于多维数组,0基和1基数组会显示同样的类型名称:System.String[,]。在运行时,CLR将对所有多维数组都视为非0基数组。这自然会人觉得应该显示为System.String[*,*]。但是,对于多维数组,CLR决定不用*符号,避免开发人员对*产生混淆。
访问一维0基数组的元素比访问非0基数组或多维数组的元素稍快一些。首先,有一些特殊的IL指令,比如newarr,ldelem,ldelema等用于处理一维0基数组,这些特殊IL指令会导致JIT编译器生成优化代码。其次,JIT编译器知道for循环要反问0到Length-1之间的数组元素。所以,JIT编译器生成的代码会在运行时测试所有数组元素的访问都在数组有效访问内。
如果很关系性能,请考虑由数组构成的数组(即交错数组)来替代矩形数组。
下面C#代码演示了访问二维数组的三种方式:
internal static class MultiDimArrayPerformance { private const Int32 c_numElements = 10000; public static void Go() { const Int32 testCount = 10; Stopwatch sw; // 声明一个二维数组 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: 用普通的安全技术访问数组中的所有元素 sw = Stopwatch.StartNew(); for (Int32 test = 0; test < testCount; test++) Safe2DimArrayAccess(a2Dim); Console.WriteLine("{0}: Safe2DimArrayAccess", sw.Elapsed); // 2: 用交错数组技术访问数组中的所有元素 sw = Stopwatch.StartNew(); for (Int32 test = 0; test < testCount; test++) SafeJaggedArrayAccess(aJagged); Console.WriteLine("{0}: SafeJaggedArrayAccess", sw.Elapsed); // 3: 用unsafe访问数组中的所有元素 sw = Stopwatch.StartNew(); for (Int32 test = 0; test < testCount; test++) Unsafe2DimArrayAccess(a2Dim); Console.WriteLine("{0}: Unsafe2DimArrayAccess", sw.Elapsed); Console.ReadLine(); } 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; } }
本机结果是:
可以看出,安全二维数组访问技术最慢。安全交错数组访问时间略少于安全二维数组。不过应该注意的是:创建交错数组所花的时间多于创建多维数组所花的时间,因为创建交错数组时,要求在堆上为每一维分配一个对象,造成垃圾回收器的周期性活动。所以你可以这样权衡:如果需要创建大量"多个维的数组",而不会频繁访问它的元素,那么创建多维数组就要快点。如果"多个维的数组"只需创建一次,而且要频繁访问它的元素,那么交错数组性能要好点。当然,大多数应用中,后一种情况更常见。
最后请注意,不安全和安全二维数组访问技术的速度大致相同。但是,考虑到它访问是单个二维数组(产生一次内存分配),二不像交错数组那样需要许多次内存分配。所以它的速度是所有技术中最快的。
八、不安全的数组访问和固定大小的数组
如果性能是首要目标,请避免在堆上分配托管的数组对象。相反,应该在线程栈上分配数组,这是通过C#的 stackalloc语句来完成的。stackalloc语句只能创建一维0基、由值类型元素构成的数组,而且值类型绝对不能包 含任何引用类型的字段。当然,在栈上分配的内存(数组)会在方法返回时自动释放。
以下代码显示如何使用C#的stackalloc语句:
internal static class StackallocAndInlineArrays { public static void Go() { StackallocDemo(); InlineArrayDemo(); } private static void StackallocDemo() { unsafe { const Int32 width = 20; Char* pc = stackalloc Char[width]; // 在栈上分配数组 String s = "Jeffrey Richter"; // 15 个字符 for (Int32 index = 0; index < width; index++) { pc[width - index - 1] = (index < s.Length) ? s[index] : '.'; } //显示".....rethciR yerffeJ" Console.WriteLine(new String(pc, 0, width)); } } private static void InlineArrayDemo() { unsafe { CharArray ca; // 在栈上分配数组 Int32 widthInBytes = sizeof(CharArray); Int32 width = widthInBytes / 2; String s = "Jeffrey Richter"; // 15 个字符 for (Int32 index = 0; index < width; index++) { ca.Characters[width - index - 1] = (index < s.Length) ? s[index] : '.'; } //显示".....rethciR yerffeJ" Console.WriteLine(new String(ca.Characters, 0, width)); } } private unsafe struct CharArray { // 这个数组以内联的方式嵌入结构 public fixed Char Characters[20]; } }
通常,因为数组是引用类型,所以在一个结构中定义的数组字段实际只是指向数组的一个指针;数组本身在结构的内存的外部。不过,也可以像上述代码中的CharArray结构那样,直接将数组嵌入结构中。要在结构中直接嵌入一个数组,需要满足以下几个要求:
1)类型必须是结构(值类型);不能在类(引用类型)中嵌入数组。
2)字段或其定义结构必须用unsafe关键字标记
3)数组字段必须使用fixed关键字标记
4)数组必须是一维0基数组。
5)数组的元素类型必须是一下类型之一:Boolean,Char,SByte,Byte,Int16,Int32,UInt16,UInt32,Int64,UInt64,Single或Double。
内联(内嵌)数组常用于和非托管代码进行互操作,而且非托管数据结构也有一个内联数组。不过,也可用于其他情况。