5.5 不安全的代码
如前所述,C#非常擅长于对开发人员隐藏大部分基本内存管理,因为它使用了垃圾回收器和引用。但是,有时需要直接访问内存。例如,由于性能问题,要在外部(非.NET环境)的DLL中访问一个函数,该函数需要把一个指针当作参数来传递(许多Windows API函数就是这样)。本节将论述C#直接访问内存的内容的功能。
5.5.1 用指针直接访问内存
下面把指针当作一个新论题来介绍,而实际上,指针并不是新东西。因为在代码中可以自由使用引用,而引用就是一个类型安全的指针。前面已经介绍了表示对象和数组的变量实际上存储相应数据(被引用者)的内存地址。指针只是一个以与引用相同的方式存储地址的变量。其区别是C#不允许直接访问在引用变量中包含的地址。有了引用后,从语法上看,变量就可以存储引用的实际内容。
C#引用主要用于使C#语言易于使用,防止用户无意中执行某些破坏内存中内容的操作。另一方面,使用指针,就可以访问实际内存地址,执行新类型的操作。例如,给地址加上4个字节,就可以查看甚至修改存储在新地址中的数据。
下面是使用指针的两个主要原因:
● 向后兼容性——尽管.NET运行库提供了许多工具,但仍可以调用本地的Windows API函数。对于某些操作这可能是完成任务的唯一方式。这些API函数都是用C++或C#语言编写的,通常要求把指针作为其参数。但在许多情况下,还可以使用DllImport声明,以避免使用指针,例如,使用System.IntPtr类。
● 性能——在一些情况下,速度是最重要的,而指针可以提供最优性能。假定用户知道自己在做什么,就可以确保以最高效的方式访问或处理数据。但是,注意在代码的其他区域中,不使用指针,也可以对性能进行必要的改进。请使用代码配置文件,查找代码中的瓶颈,Visual Studio中就包含一个代码配置文件。
但是,这种低级的内存访问也是有代价的。使用指针的语法比引用类型的语法更复杂。而且,指针使用起来比较困难,需要非常高的编程技巧和很强的能力,仔细考虑代码所完成的逻辑操作,才能成功地使用指针。如果不仔细,使用指针就很容易在程序中引入细微的、难以查找的错误。例如,很容易重写其他变量,导致栈溢出,访问某些没有存储变量的内存区域,甚至重写.NET运行库所需要的代码信息,因而使程序崩溃。
另外,如果使用指针,就必须授予代码运行库的代码访问安全机制的高级别信任,否则就不能执行它。在默认的代码访问安全策略中,只有代码运行在本地计算机上,这才是可能的。如果代码必须运行在远程地点,如Internet,用户就必须给代码授予额外的许可,代码才能工作。除非用户信任你和你的代码,否则他们不会授予这些许可,第24章将讨论代码访问安全性。
尽管有这些问题,但指针在编写高效的代码时是一种非常强大和灵活的工具。
注意:这里强烈建议不要轻易使用指针,否则代码不仅难以编写和调试,而且无法通过CLR施加的内存类型安全检查。
1.用unsafe关键字编写不安全的代码
因为使用指针会带来相关的风险,所以C#只允许在特别标记的代码块中使用指针。标记代码所用的关键字是unsafe。下面的代码把一个方法标记为unsafe:
unsafe int GetSomeNumber() { // code that can use pointers }
任何方法都可以标记为unsafe——无论该方法是否应用了其他修饰符(例如,静态方法、虚方法等)。在这种方法中,unsafe修饰符还会应用到方法的参数上,允许把指针用作参数。还可以把整个类或结构标记为unsafe,这表示假设所有的成员都是不安全的:
unsafe class MyClass { // any method in this class can now use pointers }
同样,可以把成员标记为unsafe:
class MyClass { unsafe int* pX; // declaration of a pointer field in a class }
也可以把方法中的一块代码标记为unsafe:
void MyMethod() { // code that doesn't use pointers unsafe { // unsafe code that uses pointers here } // more 'safe' code that doesn't use pointers }
但要注意,不能把局部变量本身标记为unsafe:
int MyMethod() { unsafe int *pX; // WRONG }
如果要使用不安全的局部变量,就需要在不安全的方法或语句块中声明和使用它。在使用指针前还有一步要完成。C#编译器会拒绝不安全的代码,除非告诉编译器代码包含不安全的代码块。通过DNX,可以在project.json文件的compilationOptions中把allowUnsafe设置为true(代码文件PointerPlayground/project.json ):
"compilationOptions": {"allowUnsafe": true},
在传统的csc编译器中,可以设置/unsafe选项,或者使用Visual Studio 2015在Project设置中把Build配置指定为Allow Unsafe Code:
csc /unsafe MySource.cs
2.指针的语法
把代码块标记为unsafe后,就可以使用下面的语法声明指针:
int* pWidth, pHeight; double* pResult; byte*[] pFlags;
这段代码声明了4个变量,pWidth和pHeight是整数指针,pResult是double型指针,pFlags是字节型的数组指针。我们常常在指针变量名的前面使用前缀p来表示这些变量是指针。在变量声明中,符号*表示声明一个指针,换言之,就是存储特定类型的变量的地址。
声明了指针类型的变量后,就可以用与一般变量相同的方式使用它们,但首先需要学习另外两个运算符:
● &表示“取地址”,并把一个值数据类型转换为指针,例如,int转换为*int。这个运算符称为寻址运算符。
● *表示“获取地址的内容”,把一个指针转换为值数据类型(例如,*float转换为float)。这个运算符称为“间接寻址运算符”(有时称为“取消引用运算符”)。
从这些定义中可以看出,&和*的作用是相反的。
注意:符号&和*也表示按位AND(&)和乘法(*)运算符,为什么还可以以这种方式使用它们?答案是在实际使用时它们是不会混淆的,用户和编译器总是知道在什么情况下这两个符号有什么含义,因为按照指针的定义,这些符号总是以一元运算符的形式出现——它们只作用于一个变量,并出现在代码中该变量的前面。另一方面,按位AND和乘法运算符是二元运算符,它们需要两个操作数。
下面的代码说明了如何使用这些运算符:
int x = 10; int* pX, pY; pX = &x; pY = pX; *pY = 20;
首先声明一个整数x,其值是10。接着声明两个整数指针pX和pY。然后把pX设置为指向x(换言之,把pX的内容设置为x的地址)。然后把pX的值赋予pY,所以pY也指向x。最后,在语句*pY = 20中,把值20赋予pY指向的地址包含的内容。实际上是把x的内容改为20,因为pY指向x。注意在这里,变量pY和x之间没有任何关系。只是此时pY碰巧指向存储x的存储单元而已。
要进一步理解这个过程,假定x存储在栈的存储单元0x12F8C4~0x12F8C7中(十进制就是1243332~1243335,即有4个存储单元,因为一个int占用4个字节)。因为栈向下分配内存,所以变量pX存储在0x12F8C0~0x12F8C3的位置上,pY存储在0x12F8BC~0x12F8BF的位置上。注意,pX和pY也分别占用4个字节。这不是因为一个int占用4个字节,而是因为在32位处理器上,需要用4个字节存储一个地址。利用这些地址,在执行完上述代码后,栈应如图5-6所示。
图5-6
注意:这个示例使用int来说明该过程,其中int存储在32位处理器中栈的连续空间上,但并不是所有的数据类型都会存储在连续的空间中。原因是32位处理器最擅长于在4个字节的内存块中检索数据。这种计算机上的内存会分解为4个字节的块,在Windows上,每个块有时称为DWORD,因为这是32位无符号int数在.NET出现之前的名字。这是从内存中获取DWORD的最高效的方式——跨越DWORD边界存储数据通常会降低硬件的性能。因此,.NET运行库通常会给某些数据类型填充一些空间,使它们占用的内存是4的倍数。例如,short数据占用两个字节,但如果把一个short放在栈中,栈指针仍会向下移动4个字节,而不是两个字节,这样,下一个存储在栈中的变量就仍从DWORD的边界开始存储。
可以把指针声明为任意一种值类型——即任何预定义的类型uint、int和byte等,也可以声明为一个结构。但是不能把指针声明为一个类或数组,因为这么做会使垃圾回收器出现问题。为了正常工作,垃圾回收器需要知道在堆上创建了什么类的实例,它们在什么地方。但如果代码开始使用指针处理类,就很容易破坏堆中.NET运行库为垃圾回收器维护的与类相关的信息。在这里,垃圾回收器可以访问的任何数据类型称为托管类型,而指针只能声明为非托管类型,因为垃圾回收器不能处理它们。
3.将指针强制转换为整数类型
由于指针实际上存储了一个表示地址的整数,因此任何指针中的地址都可以和任何整数类型之间相互转换。指针到整数类型的转换必须是显式指定的,隐式的转换是不允许的。例如,编写下面的代码是合法的:
int x = 10; int* pX, pY; pX = &x; pY = pX; *pY = 20; ulong y = (ulong)pX; int* pD = (int*)y;
把指针pX中包含的地址强制转换为一个uint,存储在变量y中。接着把y强制转换回一个int*,存储在新变量pD中。因此pD也指向x的值。
把指针的值强制转换为整数类型的主要目的是显示它。虽然插入字符串和Console.Write()方法没有带指针的重载方法,但是必须把指针的值强制转换为整数类型,这两个方法才能接受和显示它们:
WriteLine($"Address is {pX}"); // wrong -- will give a compilation error WriteLine($"Address is {(ulong)pX}"); // OK
可以把一个指针强制转换为任何整数类型,但是,因为在32位系统上,一个地址占用4个字节,把指针强制转换为除了uint、long或ulong之外的数据类型,肯定会导致溢出错误(int也可能导致这个问题,因为它的取值范围是-20亿~20亿,而地址的取值范围是0~40亿)。如果创建64位应用程序,就需要把指针强制转换为ulong类型。
还要注意,checked关键字不能用于涉及指针的转换。对于这种转换,即使在设置checked的情况下,发生溢出时也不会抛出异常。.NET运行库假定,如果使用指针,就知道自己要做什么,不必担心可能出现的溢出。
4.指针类型之间的强制转换
也可以在指向不同类型的指针之间进行显式的转换。例如:
byte aByte = 8; byte* pByte= &aByte; double* pDouble = (double*)pByte;
这是一段合法的代码,但如果要执行这段代码,就要小心了。在上面的示例中,如果要查找指针pDouble指向的double值,就会查找包含1个byte(aByte)的内存,和一些其他内存,并把它当作包含一个double值的内存区域来对待——这不会得到一个有意义的值。但是,可以在类型之间转换,实现C union类型的等价形式,或者把指针强制转换为其他类型,例如,把指针转换为sbyte,来检查内存的单个字节。
5. void指针
如果要维护一个指针,但不希望指定它指向的数据类型,就可以把指针声明为void:
int* pointerToInt; void* pointerToVoid; pointerToVoid = (void*)pointerToInt;
void指针的主要用途是调用需要void*参数的API函数。在C#语言中,使用void指针的情况并不是很多。特殊情况下,如果试图使用*运算符取消引用void指针,编译器就会标记一个错误。
6.指针算术的运算
可以给指针加减整数。但是,编译器很智能,知道如何执行这个操作。例如,假定有一个int指针,要在其值上加1。编译器会假定我们要查找int后面的存储单元,因此会给该值加上4个字节,即加上一个int占用的字节数。如果这是一个double指针,加1就表示在指针的值上加8个字节,即一个double占用的字节数。只有指针指向byte或sbyte(都是1个字节)时,才会给该指针的值加上1。
可以对指针使用运算符+、-、+=、-=、++和--,这些运算符右边的变量必须是long或ulong类型。
注意:不允许对void指针执行算术运算。
例如,假定有如下定义:
uint u = 3; byte b = 8; double d = 10.0; uint* pUint= &u; // size of a uint is 4 byte* pByte = &b; // size of a byte is 1 double* pDouble = &d; // size of a double is 8
下面假定这些指针指向的地址是:
● pUint: 1243332
● pByte: 1243328
● pDouble: 1243320
执行这段代码后:
++pUint; // adds (1*4) = 4 bytes to pUint pByte -= 3; // subtracts (3*1) = 3 bytes from pByte double* pDouble2 = pDouble + 4; // pDouble2 = pDouble + 32 bytes (4*8 bytes)
指针应包含的内容是:
● pUint: 1243336
● pByte: 1243325
● pDouble2: 1243352
注意:一般规则是,给类型为T的指针加上数值X,其中指针的值为P,则得到的结果是P+ X*(sizeof(T))。使用这条规则时要小心。如果给定类型的连续值存储在连续的存储单元中,指针加法就允许在存储单元之间移动指针。但如果类型是byte或char,其总字节数不是4的倍数,连续值就不是默认地存储在连续的存储单元中。
如果两个指针都指向相同的数据类型,则也可以把一个指针从另一个指针中减去。此时,结果是一个long,其值是指针值的差被该数据类型所占用的字节数整除的结果:
double* pD1 = (double*)1243324; // note that it is perfectly valid to // initialize a pointer like this. double* pD2 = (double*)1243300; long L = pD1-pD2; // gives the result 3 (=24/sizeof(double))
7. sizeof运算符
这一节将介绍如何确定各种数据类型的大小。如果需要在代码中使用某种类型的大小,就可以使用sizeof运算符,它的参数是数据类型的名称,返回该类型占用的字节数。例如:
int x = sizeof(double);
这将设置x的值为8。
使用sizeof的优点是不必在代码中硬编码数据类型的大小,使代码的移植性更强。对于预定义的数据类型,sizeof返回下面的值。
sizeof(sbyte) = 1; sizeof(byte) = 1; sizeof(short) = 2; sizeof(ushort) = 2; sizeof(int) = 4; sizeof(uint) = 4; sizeof(long) = 8; sizeof(ulong) = 8; sizeof(char) = 2; sizeof(float) = 4; sizeof(double) = 8; sizeof(bool) = 1;
也可以对自己定义的结构使用sizeof,但此时得到的结果取决于结构中的字段类型。不能对类使用sizeof。
8.结构指针:指针成员访问运算符
结构指针的工作方式与预定义值类型的指针的工作方式完全相同。但是这有一个条件:结构不能包含任何引用类型,这是因为前面介绍的一个限制——指针不能指向任何引用类型。为了避免这种情况,如果创建一个指针,它指向包含任何引用类型的任何结构,编译器就会标记一个错误。
假定定义了如下结构:
struct MyStruct { public long X; public float F; }
就可以给它定义一个指针:
MyStruct* pStruct;
然后对其进行初始化:
var myStruct = new MyStruct(); pStruct = &myStruct;
也可以通过指针访问结构的成员值:
(*pStruct).X = 4; (*pStruct).F = 3.4f;
但是,这个语法有点复杂。因此,C#定义了另一个运算符,用一种比较简单的语法,通过指针访问结构的成员,它称为指针成员访问运算符,其符号是一个短划线,后跟一个大于号,它看起来像一个箭头:->。
注意:C++开发人员能识别指针成员访问运算符。因为C++使用这个符号完成相同的任务。
使用这个指针成员访问运算符,上述代码可以重写为:
pStruct->X = 4; pStruct->F = 3.4f;
也可以直接把合适类型的指针设置为指向结构中的一个字段:
long* pL = &(Struct.X); float* pF = &(Struct.F);
或者
long* pL = &(pStruct->X); float* pF = &(pStruct->F);
9.类成员指针
前面说过,不能创建指向类的指针,这是因为垃圾回收器不维护关于指针的任何信息,只维护关于引用的信息,因此创建指向类的指针会使垃圾回收器不能正常工作。
但是,大多数类都包含值类型的成员,可以为这些值类型成员创建指针,但这需要一种特殊的语法。例如,假定把上面示例中的结构重写为类:
class MyClass { public long X; public float F; }
然后就可以为它的字段X和F创建指针了,方法与前面一样。但这么做会产生一个编译错误:
var myObject = new MyClass(); long* pL = &(myObject.X); // wrong -- compilation error float* pF = &(myObject.F); // wrong -- compilation error
尽管X和F都是非托管类型,但它们嵌入在一个对象中,这个对象存储在堆上。在垃圾回收的过程中,垃圾回收器会把MyObject移动到内存的一个新单元上,这样,pL和pF就会指向错误的存储地址。由于存在这个问题,因此编译器不允许以这种方式把托管类型的成员的地址分配给指针。
解决这个问题的方法是使用fixed关键字,它会告诉垃圾回收器,可能有引用某些对象的成员的指针,所以这些对象不能移动。如果要声明一个指针,则使用fixed的语法,如下所示:
var myObject = new MyClass(); fixed (long* pObject = &(myObject.X)) { // do something }
在关键字fixed后面的圆括号中,定义和初始化指针变量。这个指针变量(在本例中是pObject)的作用域是花括号标识的fixed块。这样,垃圾回收器就知道,在执行fixed块中的代码时,不能移动myObject对象。
如果要声明多个这样的指针,就可以在同一个代码块前放置多条fixed语句:
var myObject = new MyClass(); fixed (long* pX = &(myObject.X)) fixed (float* pF = &(myObject.F)) { // do something }
如果要在不同的阶段固定几个指针,就可以嵌套整个fixed块:
var myObject = new MyClass(); fixed (long* pX = &(myObject.X)) { // do something with pX fixed (float* pF = &(myObject.F)) { // do something else with pF } }
如果这些变量的类型相同,就可以在同一个fixed块中初始化多个变量:
var myObject = new MyClass(); var myObject2 = new MyClass(); fixed (long* pX = &(myObject.X), pX2 = &(myObject2.X)) { // etc. }
在上述情况中,是否声明不同的指针,让它们指向相同或不同对象中的字段,或者指向与类实例无关的静态字段,这一点并不重要。
5.5.2 指针示例:PointerPlayground
为了理解指针,最好编写一个使用指针的程序,再使用调试器。下面给出一个使用指针的示例:PointerPlayground。它执行一些简单的指针操作,显示结果,还允许查看内存中发生的情况,并确定变量存储在什么地方(代码文件PointerPlayground/Program.cs):
using System; using static System.Console; namespace PointerPlayground { public class Program { unsafe public static void Main() { int x=10; short y = -1; byte y2 = 4; double z = 1.5; int* pX = &x; short* pY = &y; double* pZ = &z; WriteLine($"Address of x is 0x{(ulong)&x:X}, " + $"size is {sizeof(int)}, value is {x}"); WriteLine($"Address of y is 0x{(ulong)&y2:X}, " + $"size is {sizeof(short)}, value is {y}"); WriteLine($"Address of y2 is 0x{(ulong)&y2:X}, " + $"size is {sizeof(byte)}, value is {y2}"); WriteLine($"Address of z is 0x{(ulong)&z:X}, " + $"size is {sizeof(double)}, value is {z}"); WriteLine($"Address of pX=&x is 0x{(ulong)&pX:X}, " + $"size is {sizeof(int*)}, value is 0x{(ulong)pX:X}"); WriteLine($"Address of pY=&y is 0x{(ulong)&pY:X}, " + $"size is {sizeof(short*)}, value is 0x{(ulong)pY:X}"); WriteLine($"Address of pZ=&z is 0x{(ulong)&pZ:X}, " + $"size is {sizeof(double*)}, value is 0x{(ulong)pZ:X}"); *pX = 20; WriteLine($"After setting *pX, x = {x}"); WriteLine($"*pX = {*pX}"); pZ = (double*)pX; WriteLine($"x treated as a double = {*pZ}"); ReadLine(); } } }
这段代码声明了4个值变量:
● int x
● short y
● byte y2
● double z
它还声明了指向其中3个值的指针:pX、pY和pZ。
然后显示这3个变量的值,以及它们的大小和地址。注意在获取pX、pY和pZ的地址时,我们查看的是指针的指针,即值的地址的地址!还要注意,与显示地址的常见方式一致,在WriteLine()命令中使用{0:X}格式说明符,确保该内存地址以十六进制格式显示。
最后,使用指针pX把x的值改为20,执行一些指针类型强制转换,如果把x的内容当作double类型,就会得到无意义的结果。
编译并运行这段代码,得到下面的结果:
Address of x is 0x376943D5A8, size is 4, value is 10 Address of y is 0x376943D5A0, size is 2, value is -1 Address of y2 is 0x376943D598, size is 1, value is 4 Address of z is 0x376943D590, size is 8, value is 1.5 Address of pX=&x is 0x376943D588, size is 8, value is 0x376943D5A8 Address of pY=&y is 0x376943D580, size is 8, value is 0x376943D5A0 Address of pZ=&z is 0x376943D578, size is 8, value is 0x376943D590 After setting *pX, x = 20 *pX = 20 x treated as a double = 9.88131291682493E-323
注意:用CoreCLR运行应用程序时,每次运行应用程序都会显示不同的地址。
检查这些结果,可以证实“后台内存管理”一节描述的栈操作,即栈向下给变量分配内存。注意,这还证实了栈中的内存块总是按照4个字节的倍数进行分配。例如,y是一个short数(其大小为2字节),其地址是0xD4E710(十六进制),表示为该变量分配的存储单元是0xD4E710~0xD4E713。如果.NET运行库严格地逐个排列变量,则y应只占用两个存储单元,即0xD4E712和0xD4E713。
下一个示例PointerPlayground2介绍指针的算术,以及结构指针和类成员。开始时,定义一个结构CurrencyStruct,它把货币值表示为美元和美分,再定义一个等价的类CurrencyClass(代码文件PointerPlayground2/Currency.cs ):
internal struct CurrencyStruct { public long Dollars; public byte Cents; public override string ToString() => $"$ {Dollars}.{Cents}"; } internal class CurrencyClass { public long Dollars = 0; public byte Cents = 0; public override string ToString() => $"$ {Dollars}.{Cents}"; }
定义好结构和类后,就可以对它们应用指针了。下面的代码是一个新的示例。这段代码比较长,我们对此将做详细讲解。首先显示CurrencyStruct结构的字节数,创建它的两个实例和一些指针,然后使用pAmount指针初始化一个CurrencyStruct结构amount1的成员,显示变量的地址(代码文件PointerPlayground2/Program.cs ):
unsafe public static void Main() { WriteLine($"Size of CurrencyStruct struct is {sizeof(CurrencyStruct)}"); CurrencyStruct amount1, amount2; CurrencyStruct* pAmount = &amount1; long* pDollars = &(pAmount->Dollars); byte* pCents = &(pAmount->Cents); WriteLine("Address of amount1 is 0x{(ulong)&amount1:X}"); WriteLine("Address of amount2 is 0x{(ulong)&amount2:X}"); WriteLine("Address of pAmount is 0x{(ulong)&pAmount:X}"); WriteLine("Address of pDollars is 0x{(ulong)&pDollars:X}"); WriteLine("Address of pCents is 0x{(ulong)&pCents:X}"); pAmount->Dollars = 20; *pCents = 50; WriteLine($"amount1 contains {amount1}");
现在根据栈的工作方式,执行一些指针操作。因为变量是按顺序声明的,所以amount2存储在amount1后面的地址中。sizeof(CurrencyStruct)运算符返回16(见后面的屏幕输出),所以CurrencyStruct结构占用的字节数是4的倍数。在递减了Currency指针后,它就指向amount2:
--pAmount; // this should get it to point to amount2 WriteLine($"amount2 has address 0x{(ulong)pAmount:X} " + $"and contains {*pAmount}");
在调用WriteLine()语句时,它显示了amount2的内容,但还没有对它进行初始化。显示出来的东西就是随机的垃圾——在执行该示例前内存中存储在该单元中的内容。但这有一个要点:一般情况下,C#编译器会禁止使用未初始化的变量,但在开始使用指针时,就很容易绕过许多通常的编译检查。此时我们这么做,是因为编译器无法知道我们实际上要显示的是amount2的内容。因为知道了栈的工作方式,所以可以说出递减pAmount的结果是什么。使用指针算术,可以访问编译器通常禁止访问的各种变量和存储单元,因此指针算术是不安全的。
接下来在pCents指针上进行指针运算。pCents指针目前指向amount1.Cents,但此处的目的是使用指针算术让它指向amount2.Cents,而不是直接告诉编译器我们要做什么。为此,需要从pCents指针所包含的地址中减去sizeof(Currency):
// do some clever casting to get pCents to point to cents // inside amount2 CurrencyStruct* pTempCurrency = (CurrencyStruct*)pCents; pCents = (byte*) ( -pTempCurrency ); WriteLine("Address of pCents is now 0x{0:X}", (ulong)&pCents);
最后,使用fixed关键字创建一些指向类实例中字段的指针,使用这些指针设置这个实例的值。注意,这也是我们第一次查看存储在堆中(而不是栈)的项的地址:
WriteLine("\nNow with classes"); // now try it out with classes var amount3 = new CurrencyClass(); fixed(long* pDollars2 = &(amount3.Dollars)) fixed(byte* pCents2 = &(amount3.Cents)) { WriteLine($"amount3.Dollars has address 0x{(ulong)pDollars2:X}"); WriteLine($"amount3.Cents has address 0x{(ulong)pCents2:X}"); *pDollars2 = -100; WriteLine($"amount3 contains {amount3}"); }
编译并运行这段代码,得到如下所示的结果:
Size of CurrencyStruct struct is 16 Address of amount1 is 0xD290DCD7C0 Address of amount2 is 0xD290DCD7B0 Address of pAmount is 0xD290DCD7A8 Address of pDollars is 0xD290DCD7A0 Address of pCents is 0xD290DCD798 amount1 contains $ 20.50 amount2 has address 0xD290DCD7B0 and contains $ 0.0 Address of pCents is now 0xD290DCD798 Now with classes amount3.Dollars has address 0xD292C91A70 amount3.Cents has address 0xD292C91A78 amount3 contains $ -100.0
注意,在这个结果中,显示了未初始化的amount2的值,CurrencyStruct结构的字节数是16,大于其字段的字节数(一个long数占用8个字节,加上1个字节等于9个字节)。
5.5.3 使用指针优化性能
前面用许多篇幅介绍了使用指针可以完成的各种任务,但在前面的示例中,仅是处理内存,让有兴趣的人们了解实际上发生了什么事,并没有帮助人们编写出更好的代码!本节将应用我们对指针的理解,用一个示例来说明使用指针可以大大提高性能。
1.创建基于栈的数组
本节将探讨指针的一个主要应用领域:在栈中创建高性能、低系统开销的数组。第2章介绍了C#如何支持数组的处理。第7章详细介绍了数组。C#很容易使用一维数组和矩形或锯齿形多维数组,但有一个缺点:这些数组实际上都是对象,它们是System.Array的实例。因此数组存储在堆上,这会增加系统开销。有时,我们希望创建一个使用时间比较短的高性能数组,不希望有引用对象的系统开销。而使用指针就可以做到,但指针只对于一维数组比较简单。
为了创建一个高性能的数组,需要使用另一个关键字:stackalloc。stackalloc命令指示.NET运行库在栈上分配一定量的内存。在调用stackalloc命令时,需要为它提供两条信息:
● 要存储的数据类型
● 需要存储的数据项数
例如,要分配足够的内存,以存储10个decimal数据项,可以编写下面的代码:
decimal* pDecimals = stackalloc decimal[10];
注意,这条命令只分配栈内存。它不会试图把内存初始化为任何默认值,这正好符合我们的目的。因为要创建一个高性能的数组,给它不必要地初始化相应值会降低性能。
同样,要存储20个double数据项,可以编写下面的代码:
double* pDoubles = stackalloc double[20];
虽然这行代码指定把变量的个数存储为一个常数,但它等于在运行时计算的一个数字。所以可以把上面的示例写为:
int size; size = 20; // or some other value calculated at runtime double* pDoubles = stackalloc double[size];
从这些代码段中可以看出,stackalloc的语法有点不寻常。它的后面紧跟要存储的数据类型名(该数据类型必须是一个值类型),之后把需要的项数放在方括号中。分配的字节数是项数乘以sizeof(数据类型)。在这里,使用方括号表示这是一个数组。如果给20个double数分配存储单元,就得到了一个有20个元素的double数组,最简单的数组类型是逐个存储元素的内存块,如图5-7所示。
图5-7
在图5-7中,显示了stackalloc返回的指针,stackalloc总是返回分配数据类型的指针,它指向新分配内存块的顶部。要使用这个内存块,可以取消对已返回指针的引用。例如,给20个double数分配内存后,把第一个元素(数组的元素0)设置为3.0,可以编写下面的代码:
double* pDoubles = stackalloc double[20]; *pDoubles = 3.0;
要访问数组的下一个元素,可以使用指针算术。如前所述,如果给一个指针加1,它的值就会增加它指向的数据类型的字节数。在本例中,就会把指针指向已分配的内存块中的下一个空闲存储单元。因此可以把数组的第二个元素(元素编号为1)设置为8.4:
double* pDoubles = stackalloc double[20]; *pDoubles = 3.0; *(pDoubles + 1) = 8.4;
同样,可以用表达式*(pDoubles+X)访问数组中下标为X的元素。
这样,就得到一种访问数组中元素的方式,但对于一般目的,使用这种语法过于复杂。C#为此定义了另一种语法。对指针应用方括号时,C#为方括号提供了一种非常精确的含义。如果变量p是任意指针类型,X是一个整数,表达式p[X]就被编译器解释为*(p+X),这适用于所有的指针,不仅仅是用stackalloc初始化的指针。利用这个简洁的表示法,就可以用一种非常方便的语法访问数组。实际上,访问基于栈的一维数组所使用的语法与访问由System.Array类表示的基于堆的数组完全相同:
double* pDoubles = stackalloc double [20]; pDoubles[0] = 3.0; // pDoubles[0] is the same as *pDoubles pDoubles[1] = 8.4; // pDoubles[1] is the same as *(pDoubles+1)
注意:把数组的语法应用于指针并不是新东西。自从开发出C和C++语言以来,它就是这两种语言的基础部分。实际上,C++开发人员会把这里用stackalloc获得的、基于栈的数组完全等同于传统的基于栈的C和C++数组。这种语法和指针与数组的链接方式是C语言在20世纪70年代后期流行起来的原因之一,也是指针的使用成为C和C++中一种流行的编程技巧的主要原因。
尽管高性能的数组可以用与一般C#数组相同的方式访问,但需要注意:在C#中,下面的代码会抛出一个异常:
double[] myDoubleArray = new double [20]; myDoubleArray[50] = 3.0;
抛出异常的原因是:使用越界的下标来访问数组:下标是50,而允许的最大下标是19。但是,如果使用stackalloc声明了一个等价的数组,对数组进行边界检查时,这个数组中就没有封装任何对象,因此下面的代码不会抛出异常:
double* pDoubles = stackalloc double [20]; pDoubles[50] = 3.0;
在这段代码中,我们分配了足够的内存来存储20个double类型的数。接着把sizeof(double)存储单元的起始位置设置为该存储单元的起始位置加上50*sizeof(double)个存储单元,来保存双精度值3.0。但这个存储单元超出了刚才为double数分配的内存区域。谁也不知道这个地址存储了什么数据。最好是只使用某个当前未使用的内存,但所重写的存储单元也有可能是在栈上用于存储其他变量,或者是某个正在执行的方法的返回地址。因此,使用指针获得高性能的同时,也会付出一些代价:需要确保自己知道在做什么,否则就会抛出非常古怪的运行错误。
2. QuickArray示例
下面用一个stackalloc示例QuickArray来结束关于指针的讨论。在这个示例中,程序仅要求用户提供为数组分配的元素数。然后代码使用stackalloc给long型数组分配一定的存储单元。这个数组的元素是从0开始的整数的平方,结果显示在控制台上(代码文件QuickArray/Program.cs):
using static System.Console; namespace QuickArray { public class Program { unsafe public static void Main() { Write("How big an array do you want? \n> "); string userInput = ReadLine(); uint size = uint.Parse(userInput); long* pArray = stackalloc long[(int) size]; for (int i = 0; i < size; i++) { pArray[i] = i*i; } for (int i = 0; i < size; i++) { WriteLine($"Element {i} = {*(pArray + i)}"); } ReadLine(); } } }
运行这个示例,得到如下所示的结果:
How big an array do you want? > 15 Element 0 = 0 Element 1 = 1 Element 2 = 4 Element 3 = 9 Element 4 = 16 Element 5 = 25 Element 6 = 36 Element 7 = 49 Element 8 = 64 Element 9 = 81 Element 10 = 100 Element 11 = 121 Element 12 = 144 Element 13 = 169 Element 14 = 196 _