前言
变量可以理解成是一块内存位置的别名,访问变量也就是访问对应内存中的数据。
指针是一种特殊的变量,它存储了一个内存地址,这个内存地址代表了另一块内存的位置。
指针指向的可以是一个变量、一个数组元素、一个对象实例、一块非托管内存、一个函数等。
截止到发文为止,.NET 最新正式版本为 .NET 9,C# 最新正式版本为 C# 13。文中提及的 IL
代码可能会随编译器版本的不同而有所差异,仅供参考。
本文将介绍到发文为止 C# 中的各类指针,并对比差异:
本文旨在为读者建立对各类指针的概念认知,不会每个细节都展开,读者可以参考 C# 的官方文档,了解更多用法。
涉及的知识点较多,如果存在纰漏和错误,还请谅解。
对象引用(Object Reference)
对象引用,也就是我们常说的引用类型变量,是一个类型安全的指针,指向引用类型实例的 MethodTable 指针,通过偏移和计算可以访问对象头和字段。

对象实例被分配在托管堆上,引用类型变量存储了一个指向该对象实例的引用。对象引用可以被赋值为 null,表示没有指向任何对象实例。通过 null 的对象引用访问不存在的对象会导致 NullReferenceException
。
对象引用可以存在栈或者堆上,作为局部变量时,存储在栈上;作为值类型字段时,跟随值类型的位置存储;作为引用类型字段时,存储在堆上。
指针(Pointer)
指针的声明和使用#
指针允许用户直接操作内存地址,提供了更高的性能和灵活性,但也带来了更高的风险。因此,C# 只允许在用 unsafe
关键字标记的代码块中使用指针,并且需要在项目中启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
。
unsafe
关键字可以用于方法、代码块、字段、类、结构体等。
一些资料中将这边的指针(Pointer)称为非托管指针(Unmanaged Pointer),因为它们不受 GC
的管理。
我们需要使用 <type>* ptr
的语法来声明指针类型的变量。
通过 &
运算符获取变量的地址,通过 *
运算符访问指针指向的数据。
&
通常被称为寻址运算符,*
通常被称为解引用运算符或间接寻址运算符。
unsafe class Program
{
static void Main()
{
int* p = null;
int a = 10;
p = &a;
Console.WriteLine(*p);
}
}
指针可以指向的位置#
指针可以指向以下几种位置:
值类型变量:也就是指向值类型的数据本体。
引用类型变量:因为引用类型变量存储的是对象实例的引用,所以这边相当于一个二级指针。
值类型或者引用类型的实例字段:readonly 也可以修改。
值类型或者引用类型的静态字段:readonly 也可以修改。
数组元素:数组在内存中是连续存储的,所以可以通过指针和指针算法来访问数组元素。
非托管内存:使用 Marshal
分配非托管内存。
另一个指针(Pointer):可以实现多级指针。
null:表示没有指向任何有效的内存地址,通过 null 指针访问不存在的数据会导致 NullReferenceException
。
注意:在声明指向实例字段,静态字段以及数组元素的指针时,需要使用 fixed
关键字。
可以声明指针的位置#
指针可以在以下位置声明:
指向值类型变量的指针#
指针可以指向值类型变量,直接访问值类型的数据本体,并且可以修改值类型变量的值。

unsafe class Program
{
static void Main()
{
int a = 10;
int* p = &a;
Console.WriteLine(*p);
*p = 20;
Console.WriteLine(a);
}
}
指向对象引用的指针#
指针可以指向对象引用,相当于一个二级指针。

在下面的示例代码中,关键的部分标注了编译后的 IL 代码。
class Program
{
static void Main()
{
var foo = new Foo
{
Bar = 1
};
unsafe
{
Foo* fooPtr = &foo;
Console.WriteLine(fooPtr->Bar);
*fooPtr = new Foo
{
Bar = 2
};
Console.WriteLine(foo.Bar);
fooPtr->Bar = 3;
Console.WriteLine(foo.Bar);
}
}
}
class Foo
{
public int Bar { get; set; }
}
关键的三个IL 指令:
conv.u
:将对象引用(foo)的地址转换为 unsigned native int,并存储到指针(fooPtr)中。
ldind.ref
:将指针(fooPtr)指向的对象引用(foo)加载到栈上。
stind.ref
:将栈上的对象引用(新的foo实例的引用)存储到指针指向的地址(foo)上。

指向 GC Heap 的指针#
如果指针指向 GC Heap 上的数据,例如指向数组元素或者引用类型实例字段,指针需要通过 fixed
关键字固定对象的地址,防止 GC
移动对象的位置。
class Program
{
static void Main()
{
Foo foo = new Foo
{
Bar = 1
};
unsafe
{
fixed (int* p = &foo.Bar)
{
Console.WriteLine(*p);
*p = 2;
}
}
Console.WriteLine(foo.Bar);
}
}
class Foo
{
public int Bar;
}
注意:不应在 fixed
语句块结束后,继续使用指针变量,因为 GC
可能会移动对象的位置,导致指针指向无效的内存地址。
class Program
{
static void Main()
{
Foo foo = new Foo
{
Bar = 1
};
var weakReference = new WeakReference(foo);
unsafe
{
int* p2;
fixed (int* p1 = &foo.Bar)
{
Console.WriteLine(*p1);
p2 = p1;
*p1 = 2;
}
Console.WriteLine(*p2);
for (int i = 0; i < 1_000_000; i++)
{
var arr = new int[1000];
}
GC.Collect();
Console.WriteLine(weakReference.IsAlive);
Console.WriteLine(*p2);
}
}
}
class Foo
{
public int Bar;
}
指向数组元素的指针#
当指针指向数组元素时,可以通过指针算法遍历数组元素,指针的单次偏移量为元素类型的大小。

指针算法支持的操作有:
对指针进行加法和减法运算时,p + n 是将指针 p 向后移动 n 个元素的大小,p - n 是将指针 p 向前移动 n 个元素的大小。
本文会讨论三种数组类型:
- 在栈上分配的数组
- 在托管堆上分配的数组
- 在非托管堆上分配的数组
本小节先讨论前两种,指向非托管堆上分配的数组的指针会在后面讨论。
栈上和非托管堆上分配的数组时,指针可以直接访问数组元素。在托管堆上分配的数组时,指针需要通过 fixed
关键字固定数组元素的地址,防止 GC
移动数组元素的位置。
在栈上分配的数组的示例代码:
unsafe class Program
{
static void Main()
{
int* arr = stackalloc int[5] { 0, 1, 2, 3, 4 };
for (int i = 0; i < 5; i++)
{
Console.WriteLine(*(arr + i));
}
for (int i = 0; i < 5; i++)
{
Console.WriteLine(arr[i]);
}
}
}
在托管堆上分配的数组的示例代码:
unsafe class Program
{
static void Main()
{
int[] arr = new int[5] { 0, 1, 2, 3, 4 };
fixed (int* p = arr)
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine(*(p + i));
}
}
fixed (int* p = &arr[0])
{
for (int i = 0; i < 5; i++)
{
*(p + i) = i * 10;
}
}
foreach (var item in arr)
{
Console.WriteLine(item);
}
}
}
在 fixed
语句块结束后,数组元素的地址会被释放,指针变量将不再有效。
在 fixed
语句块中,指针变量可以直接访问数组元素的地址,并且可以修改数组元素的值。
int* p = arr
和 int* p = &arr[0]
是等效的,都是获取数组第一个元素的地址。
注意: int[]* p = &arr
是创建一个指向数组变量的指针,并不是指向数组元素的指针。
指向静态字段的指针#
静态字段位于托管堆上,但非 GC
管理的内存区域,理论上内存地址应该是固定的,但不排除某些平台实现或某些情况下会被移动。
在.NET的规范以及C#语言规范中,编译器并不能完全确定某个字段是否可移动,必须通过 fixed
修饰保证安全。
统一使用 fixed
也可以避免特例导致的复杂性或bug。如果静态保存的是值类型还好。但如果静态字段保存的是一个对象引用,那就和方法的局部变量一样,指针必定需要通过 fixed
关键字固定对象的地址,防止 GC
移动对象的位置。静态字段如果存的是数组的引用,也是必须使用 fixed
关键字固定对象的地址才能访问数组元素。
unsafe class Program
{
static void Main()
{
Foo.ValueTypeField = 1;
fixed (int* valueTypeFieldPtr = &Foo.ValueTypeField)
{
*valueTypeFieldPtr = 2;
}
Console.WriteLine(Foo.ValueTypeField);
Foo.ReferenceTypeField = new Bar { Baz = 1 };
fixed (Bar* referenceTypeFieldPtr = &Foo.ReferenceTypeField)
{
*referenceTypeFieldPtr = new Bar { Baz = 2 };
}
Console.WriteLine(Foo.ReferenceTypeField.Baz);
Foo.ArrayField = [1, 2, 3];
fixed (int* arrayFieldPtr = Foo.ArrayField)
{
arrayFieldPtr[0] = 4;
}
Console.WriteLine(Foo.ArrayField[0]);
}
}
class Foo
{
public static int ValueTypeField;
public static Bar ReferenceTypeField;
public static int[] ArrayField;
}
class Bar
{
public int Baz;
}
指向非托管内存的指针#
使用 Marshal.AllocHGlobal
分配非托管内存,返回一个指向非托管内存的指针,最后使用 Marshal.FreeHGlobal
释放非托管内存。
Marshal
提供的方法的参数和返回值都是 IntPtr
类型,但可以和指针互换转换。
public static class Marshal
{
public static IntPtr AllocHGlobal(int cb);
public static void FreeHGlobal(IntPtr hglobal);
}
using System.Runtime.InteropServices;
unsafe class Program
{
static void Main()
{
int size = 10;
var ptr = (int*)Marshal.AllocHGlobal(size * sizeof(int));
for (int i = 0; i < size; i++)
{
ptr[i] = i;
}
for (int i = 0; i < size; i++)
{
Console.WriteLine(ptr[i]);
}
Marshal.FreeHGlobal((IntPtr)ptr);
}
}
作为方法参数的指针#
指针可以作为方法参数传递,允许在方法中修改指针指向的数据,但指针本身的传递是值传递,无法在传入的方法中修改指针的值,也就是无法修改指针指向的地址。
unsafe class Program
{
static void Main()
{
int a = 10;
int b = 20;
int* p1 = &a;
int* p2 = &b;
Console.WriteLine(*p1);
Console.WriteLine(*p2);
ModifyPointer(p1, p2);
Console.WriteLine(*p1);
}
static void ModifyPointer(int* p1, int* p2)
{
*p1 = 11;
p1 = p2;
}
}
作为方法返回值的指针#
当指针作为方法的返回值时,需要注意不能返回局部变量的指针,因为局部变量在方法结束后会被销毁,指针将指向无效的内存地址。
unsafe class Program
{
static void Main()
{
Foo* p = GetPointer();
Console.WriteLine(p->Bar);
Console.WriteLine(p->Bar);
}
static Foo* GetPointer()
{
Foo a = new Foo
{
Bar = 10
};
return &a;
}
}
struct Foo
{
public int Bar;
}
上述代码中,GetPointer
方法返回了一个指向局部变量 a
的指针,但 a
在方法结束后会被销毁,所以返回的指针将指向无效的内存地址。
之所以第一次输出 10,是因为 a
的内存数据没有被覆盖,第二次输出随机值是因为 a
的内存数据已经被覆盖。
在打印 p->Bar
之前,将一些别的数据载入到栈上,就会覆盖 a
的内存数据。下面的代码只打印了一次 p->Bar
,但在打印之前,已经将 20 到过栈上(被 Console.WriteLine
消费了),所以 a
的内存数据被覆盖了。
unsafe class Program
{
static void Main()
{
Foo* p = GetPointer();
Console.WriteLine(20);
Console.WriteLine(p->Bar);
}
static Foo* GetPointer()
{
Foo a = new Foo
{
Bar = 10
};
return &a;
}
}
struct Foo
{
public int Bar;
}
改为返回字段的指针也是一样的结果
unsafe class Program
{
static void Main()
{
int* p = GetPointer();
Console.WriteLine(*p);
Console.WriteLine(*p);
}
static int* GetPointer()
{
Foo a = new Foo
{
Bar = 10
};
return &a.Bar;
}
}
struct Foo
{
public int Bar;
}
多级指针#
下面是一个三级指针的例子
{
int x = 1;
int* p1 = &x;
int** p2 = &p1;
int*** p3 = &p2;
***p3 = 2;
Console.WriteLine(x);
}
进一步理解 fixed 关键字#
fixed
关键字用于固定对象的地址,防止 GC
移动对象的位置。
查看下面代码编译成的 IL 代码。
unsafe class Program
{
static void Main()
{
Foo.ReferenceTypeField = new Bar { Baz = 1 };
fixed (Bar* referenceTypeFieldPtr = &Foo.ReferenceTypeField)
{
*referenceTypeFieldPtr = new Bar { Baz = 2 };
}
Console.WriteLine(Foo.ReferenceTypeField.Baz);
Foo.ArrayField = [1, 2, 3];
fixed (int* arrayFieldPtr = Foo.ArrayField)
{
arrayFieldPtr[0] = 4;
}
Console.WriteLine(Foo.ArrayField[0]);
}
}
class Foo
{
public static Bar ReferenceTypeField;
public static int[] ArrayField;
}
class Bar
{
public int Baz;
}
.class private auto ansi beforefieldinit
Program
extends [System.Runtime]System.Object
{
.method private hidebysig static void
Main() cil managed
{
.entrypoint
.maxstack 4
.locals init (
[0] class Bar* referenceTypeFieldPtr,
[1] class Bar& pinned V_1,
[2] int32* arrayFieldPtr,
[3] int32[] pinned V_3
)
// ... 省略方法体
}
}
在 IL 代码中,Bar& pinned V_1
和 int32[] pinned V_3
表示固定的指向对象引用的托管指针和固定的数组的对象引用。
pinned
表示这个对象引用是固定的,GC
会识别到这个标记,并不会移动其指向的对象的位置。
在 fixed
语句块内,对 Bar* referenceTypeFieldPtr
的读写将转换为 Bar& pinned V_1
的读写。对 int32* arrayFieldPtr
的读写将转换为 int32[] pinned V_3
的读写。
IntPtr
基本概念#
IntPtr
是一个结构体,表示指针或句柄的值,用于管理非托管资源或非托管代码交互。
在部分场景,可以和指针互换使用,但 IntPtr
不能直接进行指针运算。
IntPtr
是一个平台相关的类型,在 32 位平台上是 4 字节,在 64 位平台上是 8 字节。
在使用 IntPtr
时,不需要使用 unsafe
关键字,也不需要启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
(如果使用 P/Invoke 调用非托管函数时,仍然需要启用)。
指向非托管内存的 IntPtr#
在使用 IntPtr
管理非托管内存时,不能直接读取和写入内存,需要使用 Marshal
提供的ReadXXX
和 WriteXXX
方法。
using System.Runtime.InteropServices;
class Program
{
static void Main()
{
int size = 10;
IntPtr ptr = Marshal.AllocHGlobal(size * sizeof(int));
for (int i = 0; i < size; i++)
{
Marshal.WriteInt32(ptr + i * sizeof(int), i);
}
for (int i = 0; i < size; i++)
{
Console.WriteLine(Marshal.ReadInt32(ptr + i * sizeof(int)));
}
Marshal.FreeHGlobal(ptr);
}
}
保存句柄的 IntPtr#
IntPtr
也可以用于存储句柄,例如文件句柄、窗口句柄等。
句柄可以理解为一个指向资源的引用,通常是一个整数值,用于唯一标识和访问由操作系统管理的资源。本质上它是一个资源标识符,而不是资源在内存中的实际地址。
下面是一个 windows 平台的例子
using System.Runtime.InteropServices;
public static partial class Program
{
private delegate bool EnumWC(IntPtr hwnd, IntPtr lParam);
[LibraryImport("user32.dll")]
private static partial int EnumWindows(EnumWC lpEnumFunc, IntPtr lParam);
private static bool OutputWindow(IntPtr hwnd, IntPtr lParam)
{
Console.WriteLine(hwnd.ToInt64());
return true;
}
public static void Main(string[] args)
{
EnumWindows(OutputWindow, IntPtr.Zero);
}
}
上面的代码使用了 LibraryImport
特性来导入 user32.dll
中的 EnumWindows
函数,并定义了一个委托 EnumWC
来对应这个函数的回调函数。EnumWindows
函数会枚举所有顶级窗口,并调用 OutputWindow
函数来输出每个窗口的句柄。
OutputWindow
函数的参数 hwnd
是一个 IntPtr
类型的句柄,表示窗口的句柄。可以使用 hwnd.ToInt64()
将其转换为长整型值进行输出。
函数指针(Function Pointer)
基本概念#
函数指针是一个指向函数的指针,分为托管函数指针和非托管函数指针。
这是一个 C# 9 新增的特性,建议读者阅读官方文档地址加深理解:
https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/proposals/csharp-9.0/function-pointers
在 IL 层面,调用方法的指令分为三种:
早期 C# 为我们提供了委托(Delegate)来封装方法的引用,委托可以看作是一个类型安全的函数指针。所有的委托类型都继承自 System.Delegate
类。我们在调用委托时,实际上是调用了委托的 Invoke
这个虚方法,IL 指令是 callvirt
。
在后期新增的函数指针语法中,编译器使用 calli
指令来调用函数,而不是实例化委托对象并调用 Invoke
方法。
函数指针的声明和使用#
和指针一样,函数指针也需要在 unsafe
代码块中使用,并且需要启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
。
声明函数指针的语法如下:
delegate*<[parameter type list], return type> variableName
delegate*
是一个关键字,表示函数指针类型。
<parameter type list>
是参数类型列表,可以是空的,也可以是一个或多个参数类型,用逗号分隔。
return type
是返回值类型,可以是 void
或者其他类型。
下面是几个例子:
delegate*<void> ptr
:表示一个不带参数和返回值的函数指针。
delegate*<int> ptr
:表示一个不带参数,返回值为 int
的函数指针。
delegate*<int, int, int> ptr
:表示一个带两个 int
参数,返回值为 int
的函数指针。
delegate*<int, int, void> ptr
:表示一个带两个 int
参数,无返回值的函数指针。
函数指针的声明和使用示例:
unsafe class Program
{
static void Main()
{
delegate*<int, int, int> addPtr = &Add;
int result = addPtr(1, 2);
Console.WriteLine(result);
}
static int Add(int a, int b)
{
return a + b;
}
}
使用 &
运算符获取函数的地址,并赋值给函数指针变量。
函数指针只能指向静态方法,不能指向实例方法或者委托。
可以指向静态的本地函数(local function),也就是说这个本地函数不是闭包。
下面对比函数指针和委托,用 BenchmarkDotNet
做个简单的性能测试
public class Program
{
public static void Main(string[] args)
{
BenchmarkRunner.Run<Benchmark>();
}
}
[MemoryDiagnoser]
public class Benchmark
{
private delegate int AddDelegate(int a, int b);
private static AddDelegate addDelegate = Add;
private unsafe delegate*<int, int, int> addPtr = &Add;
[Benchmark]
public void Delegate()
{
for (int i = 0; i < 1000000; i++)
{
var result = addDelegate(1, 2);
}
}
[Benchmark]
public unsafe void FunctionPointer()
{
for (int i = 0; i < 1000000; i++)
{
var result = addPtr(1, 2);
}
}
private static int Add(int a, int b)
{
return a + b;
}
}
运行结果如下:
| Method | Mean | Error | StdDev | Allocated |
|---------------- |---------:|----------:|----------:|----------:|
| Delegate | 1.530 ms | 0.0054 ms | 0.0048 ms | 1 B |
| FunctionPointer | 1.409 ms | 0.0042 ms | 0.0039 ms | 1 B |
虽然此处例子差距不是很明显,但还是能看到函数指针的性能更好一些。
托管函数指针和非托管函数指针#
在声明函数指针时,可以在 delegate*
后面加上 managed
或 unmanaged
关键字,表示托管函数指针或非托管函数指针。
不加关键字时,默认是托管函数指针。
下面是一个可以在 macOS 上运行的例子:
unsafe class Program
{
private delegate* unmanaged[Cdecl]<int> GetPidDelegate;
static void Main()
{
var prog = new Program();
prog.Run();
}
public void Run()
{
IntPtr lib = NativeLibrary.Load("/usr/lib/libc.dylib");
IntPtr pidFuncPtr = NativeLibrary.GetExport(lib, "getpid");
GetPidDelegate = (delegate* unmanaged[Cdecl]<int>)pidFuncPtr;
int pid = GetPidDelegate();
Console.WriteLine($"Current PID from libc.getpid(): {pid}");
NativeLibrary.Free(lib);
}
}
上面的代码中,delegate* unmanaged[Cdecl]<int>
声明了一个非托管函数指针类型,指向一个返回 int
的函数。
Cdecl
是调用约定,表示使用 C 语言的调用约定。
通过获取 getpid
函数的地址,并将其转换为函数指针类型,最后调用该函数获取当前进程的 PID。
NativeLibrary
是一个用于加载和调用非托管库的类,提供了 Load
和 GetExport
方法来加载库和获取函数地址。
使用完后,使用 NativeLibrary.Free
方法释放库。
托管指针(Managed Pointer)
托管指针的声明和使用#
托管指针并非一个新的特性,在早期的 C# 版本中,我们在方法参数上使用的 ref
和 out
就是声明了托管指针。
在 IL 中,用 <type>*
来表示前面说的指针(pointer,有些资料中称为 非托管指针)。
而 ref
和 out
在 IL 中对应的是 <type>&
,也就是托管指针(managed pointer)。
out
相当于 ref
的一种特殊情况,表示参数是一个输出参数,方法内部必须对其赋值。
另外还有一个 in
可以把方法参数声明为只读的托管指针,方法内部不能对其赋值。
使用托管指针时,我们不需要使用 unsafe
关键字,也不需要启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
。
注意:托管指针相关的语法会在几个位置用到 ref
关键字,但作用和意义是不同的。
我们使用 ref <type> ptr
来声明一个托管指针。
同时也用 ref
关键字来获取变量的地址,ref <type> ptr = ref a
。
访问托管指针指向的数据时,语法上只需直接访问不带 ref
的指针变量名 ptr
即可。
复制托管指针的值时,需要在指针变量前面加上 ref
关键字。ref <type> ptr2 = ref ptr
。
修改托管指针指向的数据时,语法上只需直接访问不带 ref
的指针变量名 ptr
即可,ptr = ref b
。
class Program
{
static void Main()
{
int a = 10;
ref int p1 = ref a;
Console.WriteLine(p1);
p1 = 20;
Console.WriteLine(a);
ref int p2 = ref p1;
p2 = 30;
Console.WriteLine(a);
int b = 40;
p1 = ref b;
Console.WriteLine(p1);
p1 = 50;
Console.WriteLine(b);
Console.WriteLine(p2);
}
}
托管指针可以指向的位置#
可以声明托管指针的位置#
托管指针的限制#
出于安全的设计目的,相较于指针(Pointer),托管指针只允许存在于栈上,不允许在存在于堆上。主要的限制如下:
不能作为类或者非 ref struct
的结构体的字段。
不能作为静态字段,因为静态字段在保存在托管堆上(非 GC Heap)。
不能作为 async方法 或 迭代器方法 的参数,因为参数会被状态机捕获,并保存在堆上。
不能在 await 和 yield 语句中使用,因为相关的变量会被状态机捕获,并保存在堆上。
不能被闭包捕获,因为编译器会将闭包转换为一个类,并将捕获的变量作为类的字段。
作为能保存托管指针的的 ref struct
,也只允许在栈上分配内存。C# 对 ref struct
的限制主要如下:
不能作为类或者非 ref struct
的结构体的字段。
不能作为静态字段。
不能装箱。无法将 ref struct
装箱为 object
或者接口类型。也无法将 ref struct
作为数组元素。
不能作为 async方法 的参数,因为参数会被状态机捕获,并保存在堆上。但可以作为迭代器方法的参数。
不能在 await 和 yield 语句中使用,因为相关的变量会被状态机捕获,并保存在堆上。
不能被闭包捕获,因为编译器会将闭包转换为一个类,并将捕获的变量作为类的字段。
指向对象引用的托管指针#
托管指针指向对象引用时,和指针(Pointer)一样,都类似于一个二级指针。
下面是一个简单的例子,演示了如何使用托管指针指向对象引用:
class Program
{
static void Main()
{
Foo foo = new Foo
{
Bar = 1
};
ref Foo fooPtr = ref foo;
Console.WriteLine(fooPtr.Bar);
fooPtr = new Foo
{
Bar = 2
};
Console.WriteLine(foo.Bar);
fooPtr.Bar = 3;
Console.WriteLine(foo.Bar);
}
}
public struct Foo
{
public int Bar { get; set; }
}
上面的代码中,ref Foo fooPtr = ref foo;
声明了一个托管指针 fooPtr
,指向 foo
的地址。
fooPtr
是一个托管指针,指向 foo
的地址,虽然语法可以直接访问 fooPtr.Bar
的属性,但其过程是先将 fooPtr
指向的对象引用加载到栈上,然后调用 get_Bar()
方法获取属性值。
fooPtr = new Foo { Bar = 2 };
修改了 fooPtr
指向的对象引用,也就是修改了 foo
的值。
和指针(Pointer)那一章节生成的 IL 代码进行对比,你会发现,唯一的区别是将变量地址保存到指针时,指针比托管指针多了一个 conv.u
指令。
class Program
{
static unsafe void Main()
{
Foo foo = new Foo
{
Bar = 1
};
Foo* fooPtr1 = &foo;
ref Foo fooPtr2 = ref foo;
}
}
public struct Foo
{
public int Bar { get; set; }
}
可以看出唯一的区别就是 指针(Pointer)和托管指针(Managed Pointer)在保存变量地址时,指针(Pointer)需要转换为 unsigned native int,而托管指针(Managed Pointer)不需要转换。
在获取对象引用时 ldind.ref
同时支持两种指针格式。
指向 GC Heap 的托管指针#
托管指针受 GC 管理,不用关注指向的数据是否在 GC 过程中被移动。在 GC 过程中,托管指针会被自动更新为新的地址。
下面是一个简单的例子,演示了如何使用托管指针指向引用类型的实例字段:
class Program
{
static void Main()
{
Foo foo = new Foo
{
Bar = 1
};
ref int p = ref foo.Bar;
Console.WriteLine(p);
p = 2;
Console.WriteLine(foo.Bar);
}
}
public class Foo
{
public int Bar;
}
指向数组元素的托管指针#
托管指针可以指向数组元素,但不支持指针算法。
class Program
{
static void Main()
{
int[] arr = new int[5] { 0, 1, 2, 3, 4 };
ref int p = ref arr[0];
Console.WriteLine(p);
p = 10;
Console.WriteLine(arr[0]);
}
}
指向静态字段的托管指针#
class Program
{
static void Main()
{
ref int p = ref Foo.StaticField;
Console.WriteLine(p);
p = 20;
Console.WriteLine(Foo.StaticField);
}
}
public class Foo
{
public static int StaticField;
}
作为方法参数的托管指针#
目前,我们有下面几种方法可以声明托管指针作为方法参数:
注意:托管指针本身是值传递,无法在方法内修改外部的托管指针的指向
ref
关键字:表示参数是一个引用类型的托管指针,方法内部可以修改托管指针指向的外部变量。
class Program
{
static void Main()
{
int a = 10;
int b = 20;
ref int p1 = ref a;
ref int p2 = ref b;
Modify(ref p1, ref p2);
Console.WriteLine(a);
Console.WriteLine(b);
}
static void Modify(ref int p1, ref int p2)
{
p1 = 11;
p1 = ref p2;
p1 = 22;
}
}
in
关键字:表示参数是一个只读的托管指针,方法内部不能修改托管指针指向的外部变量。
class Program
{
static void Main()
{
int a = 10;
int b = 20;
ref int p1 = ref a;
ref int p2 = ref b;
Modify(ref p1, ref p2);
Console.WriteLine(a);
Console.WriteLine(b);
}
static void Modify(in int p1, in int p2)
{
p1 = ref p2;
}
}
out
关键字:表示参数是一个输出参数,方法内部必须通过托管指针对其指向的外部变量赋值。
class Program
{
static void Main()
{
int a = 10;
int b = 20;
Modify(out a, out b);
Console.WriteLine(a);
Console.WriteLine(b);
}
static void Modify(out int p1, out int p2)
{
p1 = 11;
p2 = 22;
p1 = ref p2;
}
}
readonly ref
关键字:按目前的标准,作为参数时和 in
关键字的效果是一样的。
class Program
{
static void Main()
{
int a = 10;
ref int p = ref a;
ModifyRef(ref p);
ModifyRefReadonly(ref p);
ModifyInt(in p);
}
static void ModifyRef(ref int p)
{
Console.WriteLine(p);
p = 11;
}
static void ModifyInt(in int p)
{
Console.WriteLine(p);
p = 11;
}
static void ModifyRefReadonly(ref readonly int p)
{
Console.WriteLine(p);
p = 11;
}
}
ref readonly 托管指针#
在声明作为局部变量的托管指针时,可以使用 ref readonly
关键字,表示无法通过这个托管指针修改其指向的数据,但是可以修改托管指针的指向。
class Program
{
static void Main()
{
int a = 10;
ref readonly int p1 = ref a;
int b = 20;
p1 = ref b;
Console.WriteLine(p1);
Console.WriteLine(a);
}
}
作为 ref struct 的字段的托管指针#
ref struct
ref struct 并非表示引用传递的结构体,而是表示具有类似于托管指针的限制,依然和普通的结构体一样是值传递。
在 ref struct
可以声明托管指针作为字段。
注意:只能在 ref struct
的构造函数中对 ref 字段
进行初始化,不支持初始化器初始化或者实例化完成之后的初始化,否则将触发 NullReferenceException
。
using System.Runtime.CompilerServices;
var foo = new Foo();
Console.WriteLine(Unsafe.IsNullRef(foo.Value));
int value = 1;
var bar = new Bar(ref value);
Console.WriteLine(bar.Value);
ref struct Foo
{
public ref int Value;
}
ref struct Bar
{
public Bar(ref int value)
{
Value = ref value;
}
public ref int Value;
}
有几种方式可以声明 ref struct
的字段:
ref
关键字:表示字段是一个引用类型的托管指针,可以修改指针指向的数据以及修改指针的指向。
var a = 1;
var foo = new Foo(ref a);
Console.WriteLine(foo.Value);
foo.Value = 11;
Console.WriteLine(a);
var b = 2;
foo.Value = ref b;
Console.WriteLine(foo.Value);
ref struct Foo
{
public ref int Value;
public Foo(ref int value)
{
Value = ref value;
}
}
ref readonly
关键字:表示字段是一个指向只读数据的托管指针,不能修改指针指向的数据,但可以修改指针的指向。
var a = 1;
var foo = new Foo(ref a);
Console.WriteLine(foo.Value);
var b = 2;
foo.Value = ref b;
Console.WriteLine(foo.Value);
ref struct Foo
{
public ref readonly int Value;
public Foo(ref int value)
{
Value = ref value;
}
}
readonly ref
关键字:表示字段是一个只读的托管指针,不能修改指针的指向,但可以修改指针指向的数据。
var a = 1;
var foo = new Foo(ref a);
Console.WriteLine(foo.Value);
foo.Value = 11;
Console.WriteLine(a);
var b = 2;
ref struct Foo
{
public readonly ref int Value;
public Foo(ref int value)
{
Value = ref value;
}
}
readonly ref readonly
关键字:表示字段是一个指向只读数据的只读托管指针,不能修改指针的指向,也不能修改指针指向的数据。
var a = 1;
var foo = new Foo(ref a);
Console.WriteLine(foo.Value);
int b = 2;
ref struct Foo
{
public readonly ref readonly int Value;
public Foo(ref int value)
{
Value = ref value;
}
}
托管指针受 GC 管理#
托管指针受 GC 管理,不用关注指向的数据是否在 GC 过程中被移动。在 GC 过程中,托管指针会被自动更新为新的地址。
下面的例子中演示了用 指针(Pointer)和 托管指针(Managed Pointer)分别指向数组元素的情况。
GetArrayElementPointer
方法中的数组对象在方法结束后失去了根引用,GC 会在下一次回收时将其回收。
GetArrayElementManagedPointer
方法中的数组对象在方法结束后仍然有托管指针作为根引用,GC 不会回收它。
unsafe class Program
{
static void Main()
{
Console.WriteLine("before GC");
int* p1 = GetArrayElementPointer(out var wr1);
Console.WriteLine($"wr1.IsAlive: {wr1.IsAlive}");
Console.WriteLine($"*p1: {*p1}");
ref int p2 = ref GetArrayElementManagedPointer(out var wr2);
Console.WriteLine($"wr2.IsAlive: {wr2.IsAlive}");
Console.WriteLine($"p2: {p2}");
GC.Collect();
Console.WriteLine();
Console.WriteLine("after GC");
Console.WriteLine($"wr1.IsAlive: {wr1.IsAlive}");
Console.WriteLine($"*p1: {*p1}");
Console.WriteLine($"wr2.IsAlive: {wr2.IsAlive}");
Console.WriteLine($"p2: {p2}");
}
static int* GetArrayElementPointer(out WeakReference wr)
{
int[] arr = [1];
wr = new WeakReference(arr);
fixed (int* p = &arr[0])
{
return p;
}
}
static ref int GetArrayElementManagedPointer(out WeakReference wr)
{
int[] arr = [2];
wr = new WeakReference(arr);
return ref arr[0];
}
}
Unsafe.AsRef 方法#
Unsafe.AsRef<T>
有两个重载:
AsRef<T>(Void*)
: 将非托管指针转换为指向 类型的 T值的托管指针。
using System.Runtime.CompilerServices;
unsafe class Program
{
static void Main()
{
int a = 10;
int* p = &a;
ref int p1 = ref Unsafe.AsRef<int>(p);
Console.WriteLine(p1);
p1 = 20;
Console.WriteLine(a);
}
}
AsRef<T>(T)
: 将给定的 ref readonly
托管指针重新解释为可以修改指向的值的托管指针。
可以修改 ref readonly
托管指针指向的值。
using System.Runtime.CompilerServices;
class Program
{
static void Main()
{
int a = 10;
ref readonly int p1 = ref a;
ref int p2 = ref Unsafe.AsRef<int>(p1);
Console.WriteLine(p2);
p2 = 20;
Console.WriteLine(a);
Console.WriteLine(p1);
}
}
也可以修改 ref struct
的 ref readonly
或 readonly ref readonly
字段的值。
using System.Runtime.CompilerServices;
var a = 1;
var foo = new Foo(ref a);
Console.WriteLine(foo.Value);
ref int p = ref Unsafe.AsRef(foo.Value);
p = 11;
Console.WriteLine(a);
ref struct Foo
{
public readonly ref readonly int Value;
public Foo(ref int value)
{
Value = ref value;
}
}
转自https://www.cnblogs.com/eventhorizon/p/18873400
该文章在 2025/5/13 9:37:25 编辑过