C++/CLI 实战——使用内部指针和固定指针






4.93/5 (38投票s)
摘自关于内部指针和固定指针的第 4 章。
![]() |
|
这是 Nishant Sivakumar 撰写并由 Manning Publications 出版的《C++/CLI 实战》一书的章节摘录。内容已为 CodeProject 重新排版,可能与印刷版和电子书的布局有所不同。
4.1 使用内部指针和固定指针
你不能在托管堆上将原生指针与 CLI 对象一起使用。这就像试图用英文字母书写印地语文本一样——它们是两种完全不同的语言,使用完全不同的字母表。原生指针本质上是保存内存地址位置的变量。它们指向内存位置而不是特定对象。当我们说一个指针指向一个对象时,我们本质上是指一个特定对象位于该特定内存位置。
这种方法不适用于 CLI 对象,因为 CLR 堆中的托管对象在其整个生命周期内不会停留在同一位置。图 4.1 显示了此问题的示意图。垃圾回收器 (GC) 在垃圾回收和堆压缩周期期间会移动对象。一旦对象被重新定位,指向 CLI 对象的原生指针就变成了垃圾。届时,它将指向随机内存。如果尝试写入该内存,并且该内存现在被其他对象使用,你最终会损坏堆并可能导致应用程序崩溃。
C++/CLI 提供了两种指针来解决此问题。第一种称为*内部指针*,它由运行时更新,以反映每次对象重新定位时所指向对象的新位置。内部指针指向的物理地址从不保持不变,但它始终指向同一个对象。另一种称为*固定指针*,它阻止 GC 重新定位对象;换句话说,它将对象固定在 CLR 堆中的特定物理位置。在某些限制下,内部指针、固定指针和原生指针之间可以进行转换。
指针本质上是不安全的,因为它们允许你直接操作内存。因此,使用指针会影响代码的类型安全性和可验证性。我强烈建议你在纯托管应用程序(使用 /clr:safe
或 /clr:pure
编译的应用程序)中避免使用 CLI 指针,并严格将它们用于使互操作调用更方便。
4.1.1 内部指针
*内部指针*是指向托管对象或托管对象成员的指针,它会自动更新以适应可能导致所指向对象在 CLR 堆上重新定位的垃圾回收周期。你可能想知道这与托管句柄或跟踪引用有什么不同;区别在于内部指针表现出指针语义,并且你可以在其上执行指针操作,例如指针算术。尽管这不是一个完全准确的类比,但可以把它想象成一部手机。人们可以在任何地方给你打电话(这类似于内部指针),因为你的号码会随你移动——移动网络会不断更新,以便你的位置始终已知。他们无法用固定电话做到这一点(这类似于原生指针),因为固定电话的物理位置是固定的。
内部指针声明使用与 CLI 数组相同的模板式语法,如下所示
interior_ptr< type > var = [address];
清单 4.1 显示了当内部指针指向的对象被重新定位时,内部指针如何更新。
ref struct CData
{
int age;
};
int main()
{
for(int i=0; i<100000; i++) // ((1))
gcnew CData();
CData^ d = gcnew CData();
d->age = 100;
interior_ptr<int> pint = &d->age; // ((2))
printf("%p %d\r\n",pint,*pint);
for(int i=0; i<100000; i++) // ((3))
gcnew CData();
printf("%p %d\r\n",pint,*pint); // ((4))
return 0;
}
清单 4.1 显示 CLR 如何更新内部指针的代码
在示例代码中,你创建了 100,000 个孤立的 CData
对象 ** ((1))**,以便填充 CLR 堆的大部分。然后,你创建一个存储在变量中的 CData
对象,并 **((2))** 创建一个指向该 CData
对象的 int
成员 age
的内部指针。然后,你打印出指针地址以及所指向的 int
值。现在,**((3))** 你又创建了 100,000 个孤立的 CData
对象;在此过程中,会发生垃圾回收周期(之前创建的孤立对象 **((1))** 会因为没有任何引用而被回收)。请注意,你没有使用 GC::Collect
调用,因为这不能保证强制执行垃圾回收周期。正如你在上一章关于垃圾回收算法的讨论中已经看到的那样,GC 通过移除孤立对象来释放空间,以便进行进一步的分配。在代码的末尾(此时已经发生垃圾回收),你再次 **((4))** 打印出指针地址和 age
的值。这是我在我的机器上得到的输出(请注意,地址会因机器而异,因此你的输出值将不相同)
012CB4C8 100
012A13D0 100
如你所见,内部指针指向的地址已更改。如果这是原生指针,它将继续指向旧地址,该地址现在可能属于其他数据变量或可能包含随机数据。因此,使用原生指针指向托管对象是一种灾难性的尝试。编译器不允许你这样做:你不能将 CLI 对象的地址赋给原生指针,也不能从内部指针转换为原生指针。
按引用传递
假设你需要编写一个函数,该函数接受一个整数(按引用),并使用一些预定义规则更改该整数。以下是当你使用内部指针作为按引用参数时,这样的函数的样子
void ChangeNumber(interior_ptr<int> num, int constant)
{
*num += constant * *num;
}
以下是你如何调用该函数
CData^ d = gcnew CData();
d->age = 7;
interior_ptr<int> pint = &d->age;
ChangeNumber(pint, 3);
Console::WriteLine(d->age); // outputs 28
因为你传递了一个内部指针,所以原始变量(CData
对象的 age 成员)被更改了。当然,对于这种特定情况,你也可以使用跟踪引用作为 ChangeNumber
函数的第一个参数;但使用内部指针的一个优点是,你也可以将原生指针传递给该函数,因为原生指针隐式转换为内部指针(尽管不允许反向转换)。以下代码有效
int number = 8;
ChangeNumber(&number, 3); // ((1)) Pass native pointer to function
Console::WriteLine(number); // outputs 32
务必记住这一点。你可以将原生指针传递给期望内部指针的函数,如你在此处所做 **((1))**,因为存在从内部指针到原生指针的隐式转换。但是你不能将内部指针传递给原生指针;如果你尝试这样做,你将收到编译器错误。因为原生指针会转换为内部指针,所以你应该意识到内部指针不一定总是指向 CLR 堆:如果它包含转换后的原生指针,那么它就指向原生 C++ 堆。接下来,你将看到内部指针如何在指针算术中使用(这是跟踪引用无法做到的)。
指针算术
内部指针(与原生指针一样)支持指针算术;因此,你可能希望通过对某些数据使用直接指针算术来优化一段对性能敏感的代码。以下是一个函数示例,该函数使用内部指针上的指针算术来快速求和 int
数组的内容
int SumArray(array<int>^% intarr)
{
int sum = 0;
interior_ptr<int> p = &intarr[0]; // ((1)) Get interior pointer to array
while(p != &intarr[0]+ intarr->Length) // ((2)) Iterate through array
sum += *p++;
return sum;
}
在此代码中,p
是数组的内部指针 **((1))**(数组第一个元素的地址也是数组的地址)。你无需担心 GC 在 CLR 堆中重新定位数组。你通过对内部指针使用 ++ operator
来遍历数组 **((2))**,并将每个元素添加到变量 sum
中。这样,你就可以避免通过 System::Array
接口访问每个数组元素的开销。
不仅数组可以使用内部指针进行操作。以下是另一个使用内部指针操作 System::String
对象内容的示例
StString^ str = "Nish wrote this book for Manning Publishing";
interior_ptr<Char> ptxt = const_cast< interior_ptr<Char> >(
PtrToStringChars(str)); // ((1))
interior_ptr<Char> ptxtorig = ptxt; // ((2))
while((*ptxt++)++); // ((3))
Console::WriteLine(str); // ((4))
while((*ptxtorig++)--); // ((5))
Console::WriteLine(str); // ((6))
你使用 PtrToStringChars
辅助函数 **((1))** 获取指向 System::String
对象底层字符串缓冲区的内部指针。PtrToStringChars
函数是 <vcclr.h> 中声明的辅助函数,它返回一个指向 System::String
第一个字符的 const
内部指针。因为它返回一个 const
内部指针,所以你必须使用 const_cast
将其转换为非 const
指针。你使用 while
循环 **((3))** 遍历字符串,该循环递增指针和每个字符,直到遇到 nullptr
,因为 String
对象的底层缓冲区始终以 nullptr
终止。接下来,当你对 String
对象使用 Console::WriteLine
时 **((4))**,你可以看到字符串已更改为
Ojti!xspuf!uijt!cppl!gps!Nboojoh!Qvcmjtijoh
你已经实现了加密!(开个玩笑。) 因为你将原始指针保存在 ptxtorig
中 **((2))**,所以你可以使用它通过另一个 while
循环将字符串转换回其原始形式。第二个 while
循环 **((5))** 递增指针但递减每个字符,直到它到达字符串的末尾(由 nullptr
确定)。现在,** ((6))** 当你执行 Console::WriteLine
时,你得到原始字符串
Nish wrote this book for Manning Publishing
使用内部指针操纵的危险副作用
|
无论何时使用内部指针,它在生成的 MSIL 中都表示为托管指针。为了将其与引用(在 IL 中也表示为托管指针)区分开来,编译器会发出类型为 IsExplicitlyDereferenced
的 modopt
。modopt
是一个可选修饰符,可以应用于类型的签名。与内部指针相关的另一个有趣点是,value
类型实例的 this
指针是指向该类型的非 const
内部指针。请看此处所示的 value
类,它通过将其分配给 this
指针来获取指向该类的内部指针
value class V
{
void Func()
{
interior_ptr<V> pV1 = this;
//V* pV2 = this; <-- this won't compile
}
};
显然,在 value
类中,如果你需要获取指向 this
的指针,你应该使用内部指针,因为编译器不允许你使用原生指针。如果你特别需要指向托管堆上的 value
对象的原生指针,你必须使用固定指针固定该对象,然后将其赋给原生指针。我们尚未讨论固定指针,但这是我们将在下一节中讨论的内容。
4.1.2 固定指针
正如我们在上一节中讨论的,GC 在垃圾回收周期和堆压缩操作期间会移动 CLR 堆中的 CLI 对象。原生指针不适用于 CLI 对象,原因前面已提及。这就是为什么我们有内部指针,它们是自调整指针,会自行更新以始终引用同一对象,无论该对象位于 CLR 堆中的何处。虽然当你需要对 CLI 对象进行指针访问时这很方便,但它只适用于托管代码。如果你需要将指向 CLI 对象的指针传递给原生函数(在 CLR 外部运行),你不能传递内部指针,因为原生函数不知道内部指针是什么,并且内部指针无法转换为原生指针。这就是固定指针发挥作用的地方。
固定指针将 CLI 对象固定在 CLR 堆上;只要固定指针存活(意味着它没有超出范围),对象就保持固定。GC 知道固定对象,并且不会重新定位固定对象。继续手机的类比,想象一下固定指针类似于你被迫保持静止(类似于被固定)。尽管你有一部手机,但你的位置是固定的;这几乎就像你有一部固定电话。
因为固定对象不会移动,所以将固定指针转换为可以传递给在 CLR 控制之外运行的原生调用者的原生指针是合法的。固定或固定这个词是一个不错的选择;尝试想象一个被固定到内存地址的对象,就像你将一张便签贴到你的小隔间的侧板上一样。
固定指针的语法类似于内部指针的语法
pin_ptr< type > var = [address];
固定的持续时间是固定指针的生命周期。只要固定指针在范围内并指向一个对象,该对象就保持固定。如果固定指针设置为 nullptr
,则该对象不再固定;或者如果固定指针设置为另一个对象,则新对象变为固定,而前一个对象不再固定。
清单 4.2 演示了内部指针和固定指针之间的区别。为了在短代码片段中模拟真实场景,我使用 for
循环创建了大量对象,以使 GC 发挥作用。
for(int i=0; i<100000; i++)
gcnew CData(); // Fill portion of CLR Heap
CData^ d1 = gcnew CData(); // ((1))
for(int i=0; i<1000; i++)
gcnew CData();
CData^ d2 = gcnew CData();
interior_ptr<int> intptr = &d1->age; // ((2))
pin_ptr<int> pinptr = &d2->age; // ((3))
printf("intptr=%p pinptr=%p\r\n", // Display pointer addresses before GC
intptr, pinptr);
for(int i=0; i<100000; i++) // ((4))
gcnew CData();
printf("intptr=%p pinptr=%p\r\n",
intptr, pinptr); // Display pointer addresses after GC
清单 4.2 比较内部指针和固定指针的代码
在代码中,你创建了两个 CData
对象,它们之间有一个间隔 **((1))**,并将其中一个与指向第一个对象的 age
成员的内部指针关联 **((2))**。另一个与指向第二个对象的 age
成员的固定指针关联 **((3))**。通过创建大量孤立对象,你强制执行垃圾回收周期 **((4))**(再次注意,调用 GC::Collect
可能不总是强制执行垃圾回收周期;你需要填满一个代,然后才会发生垃圾回收周期)。我得到的输出是
intptr=012CB4C8 pinptr=012CE3B4
intptr=012A13D0 pinptr=012CE3B4
你的指针地址会有所不同,但在垃圾回收周期之后,你会发现固定指针 (pinptr
) 持有的地址没有改变,尽管内部指针 (intptr
) 已经改变。这是因为 CLR 和 GC 看到对象被固定并将其保留(这意味着它不会在 CLR 堆上重新定位)。这就是为什么你可以将固定指针传递给原生代码(因为你知道它不会被移动)。
传递给原生代码
固定指针总是指向同一个对象(因为对象处于固定状态)这一事实允许编译器提供从固定指针到原生指针的隐式转换。因此,你可以将固定指针传递给任何期望原生指针的原生函数,前提是这些指针类型相同。显然,你不能将指向 float
的固定指针传递给期望指向 char
的原生指针的函数。请看以下原生函数,它接受 wchar_t*
并返回 wchar_t*
指向的字符串中的元音数量
#pragma unmanaged
int NativeCountVowels(wchar_t* pString)
{
int count = 0;
const wchar_t* vowarr = L"aeiouAEIOU";
while(*pString)
if(wcschr(vowarr,*pString++))
count++;
return count;
}
#pragma managed
#pragma managed/unmanaged这些是 |
以下是你如何将指向 CLI 对象的指针(先固定它)传递给刚刚定义的原生函数
String^ s = "Most people don't know that the CLR is written in C++";
pin_ptr<Char> p = const_cast< interior_ptr<Char> >(
PtrToStringChars(s));
Console::WriteLine(NativeCountVowels(p));
PtrToStringChars
返回一个 const
内部指针,你将其强制转换为非 const
内部指针;这会隐式转换为固定指针。你将此固定指针(它隐式转换为原生指针)传递给 NativeCountVowels
函数。将固定指针传递给期望原生指针的函数的能力在混合模式编程中非常方便,因为它为你提供了一种将指向 CLR 堆上对象的指针传递给原生函数的简便机制。图 4.2 说明了可用的各种指针转换。
如图中所示,唯一非法的指针转换是从内部指针到原生指针;所有其他转换都允许并隐式完成。你已经了解了固定指针如何方便你将指向 CLI 对象的指针传递给非托管代码。现在我必须警告你,只有在必要时才应使用固定指针,因为不加思考地使用固定指针会导致所谓的*堆碎片问题*。
堆碎片问题
对象始终在 CLR 堆中按顺序分配。每当发生垃圾回收时,孤立对象将被移除,并且堆将被压缩,使其不会保持碎片化状态。(我们在上一章讨论 CLR 使用的多代垃圾回收算法时介绍了这一点。)让我们假设内存是从一个简单的堆中分配的,该堆看起来像图 4.3 到 4.6。当然,这是 CLR 基于 GC 的内存模型的简化表示,它涉及更复杂的算法。但堆碎片问题的基本原理保持不变,因此这个更简单的模型足以用于当前的讨论。图 4.3 描绘了垃圾回收周期发生之前堆的状态。
堆中目前有三个对象。假设 Obj2
(带有灰色阴影背景)是一个孤立对象,这意味着它将在下一次垃圾回收周期中被清除。图 4.4 显示了垃圾回收周期后堆的样子。
孤立对象已被移除,并且已执行堆压缩,因此 Obj1
和 Obj3
现在彼此相邻。这样做的目的是最大限度地利用堆中可用的空闲空间,并将该空闲空间置于一个连续的内存块中。图 4.5 显示了在垃圾回收周期中存在固定对象时堆的样子。
假设 Obj3
是一个固定对象(圆圈表示固定)。由于 GC 不会移动固定对象,Obj3
仍在其原位。这导致碎片化,因为 Obj1
和 Obj2
之间的空间不能添加到大的连续空闲内存块中。在这种特定情况下,这只是一个小的间隙,只会包含一个对象,因此不是一个主要问题。现在,假设在垃圾回收周期发生时 CLR 堆上存在多个固定对象。图 4.6 显示了在这种情况下发生的情况。
这些固定对象都无法重新定位。这意味着压缩过程无法有效实施。当有几个这样的固定对象时,堆会严重碎片化,导致新对象的内存分配速度变慢且效率低下。这是因为 GC 必须更加努力地寻找足够大的块来容纳请求的对象。有时,尽管总空闲空间大于请求的内存,但没有足够大的单个连续内存块来容纳该对象的事实会导致不必要的垃圾回收周期或内存异常。显然,这不是一种高效的场景,这也是为什么在使用固定指针时必须格外小心。
使用固定指针的建议
现在你已经了解了固定指针何时有用,何时又有些棘手,我将给你一些有效使用固定指针的一般提示。
除非绝对必要,否则不要使用固定指针!每当你认为需要使用固定指针时,请查看内部指针或跟踪引用是否是更好的选择。如果内部指针可以作为替代方案,那么很可能这不是使用固定指针的正确位置。
如果你需要固定多个对象,请尝试将这些对象一起分配,以便它们在 CLR 堆中位于相邻区域。这样,当你固定它们时,这些固定对象将位于堆的连续区域中。与它们分散在堆中相比,这减少了碎片化。
在调用本机代码时,请检查 CLR 封送层(或目标本机代码)是否为你执行了任何固定。如果执行了,你无需在传递对象之前固定它,因为你将通过向固定对象添加额外的固定指针来编写不必要的(尽管无害的)代码(这不会对对象的固定状态做任何事情)。
新分配的对象被放置在 CLR 堆的第 0 代。你知道垃圾回收周期在第 0 代堆中最频繁发生。因此,你应该尽量避免固定最近分配的对象;很可能在对象仍然固定时发生垃圾回收周期。
减少固定指针的生命周期。它在作用域内停留的时间越长,它所指向的对象保持固定的时间就越长,堆碎片化的可能性就越大。例如,如果你需要在
if
块中放置一个固定指针,请在if
块内声明它,以便在if
块退出时停止固定。每当你将固定指针传递给原生指针时,你都必须确保只有在固定指针仍然存活时才使用原生指针。如果固定指针超出作用域,对象就会取消固定。现在它可以被 GC 移动。一旦发生这种情况,原生指针就会指向 CLR 堆上的某个随机位置。我听说过“GC 漏洞”一词来指代这种情况,这可能是一个棘手的调试问题。虽然这听起来不太可能,但请考虑如果一个接受原生指针的原生函数存储此指针供以后使用会发生什么。调用者代码可能已将固定指针传递给此函数。一旦函数返回,固定将很快停止,因为原始固定指针不会存活太久。然而,保存的指针可能稍后会被本机代码中的其他函数使用,这可能导致一些灾难性情况(因为指针指向的位置现在可能包含其他对象,甚至可能是空闲空间)。你能做的最好的事情是了解本机代码在使用指针之前会做什么,然后再将固定指针传递给它。这样,如果你看到存在 GC 漏洞的风险,你就可以避免调用该函数并尝试寻找替代解决方案。
请注意,这些是一般准则,而不是必须始终盲目遵守的硬性规则。掌握一些基本策略并理解不当使用固定指针时会发生的具体后果是好的。最终,你必须评估你的编码场景并运用你的判断力来决定最佳方案。