CodeSnippet.Cn
代码片段
Csharp
架构设计
.NetCore
西班牙语
kubernetes
MySql
Redis
Algorithm
Other
Ubuntu
Linux
.NetMvc
VisualStudio
Python
Git
pm
WPF
java
Plug-In
分布式
CSS
微服务架构
JavaScript
DataStructure
Shared
浅谈 .NET 中的对象引用、非托管指针和托管指针
0
Csharp
小笨蛋
发布于:2022年04月26日
更新于:2022年04月26日
90
#custom-toc-container
### 前言 本文主要是以 C# 为例介绍 .NET 中的三种指针类型(本文不包含对于函数指针的介绍):对象引用、非托管指针 、托管指针。 开始话题之前,我们先对一些概念作出定义。 变量:给存储单元指定名称、即定义内存单元的名称或者说是标识。 指针:一种特殊的变量、其存储的是值的地址而不是值本身。 ### 一、对象引用 对于对象引用,大家都不会陌生。 与值类型变量直接包含值不同,引用类型变量存储的是数据的存储位置(托管堆内存地址)。 对象引用是在托管堆上分配的对象的开始位置指针。访问数据时,运行时要先从变量中读取内存位置(隐式间接寻址),再跳转到包含数据的内存位置,这一切都是隐藏在CLR背后发生的事情,我们在使用引用类型的时候,并不需要关心其背后的实现。 ### 二、值传递和引用传递 很多朋友,包括我,在初期学习的时候,可能都会有这么一个认知误区:"对象在C#中是按引用传递的"。 对于引用传递,借鉴《深入理解C#》中话,我们需要记住这一点: **假如以引用传递的方式来传送一个变量,那么调用的方法可以通过更改其参数值,来改变调用者的变量值。** 例如下面这么一个例子: ```csharp static void Main(string[] args) { Foo foo = new Foo { Name = "A" }; Test(foo); Console.WriteLine(foo.Name); // 输出B } static void Test(Foo obj) { obj.Name = "B"; obj = new Foo { Name = "C" }; } ``` 按照引用传递的定义,上述代码的结果应该是 C,但实际输出的是 B。 因为 C# 默认是按值传递的,在将`Main`函数中的 `foo` 变量传入`Test`函数时,会将它所包含的值(对象引用)复制给变量`obj`。所以可以通过`obj`变量修改原来的实例成员,这仅仅是由于引用类型的特性导致的,并不是所谓的引用传递。因为如果将`obj`变量指向一个新的实例,并不会影响到`foo`变量,它们两者是完全独立的。  只要对上述代码做一个小修改,就能顺利地打印出 C,也就是 `ref` 关键词。 ```csharp static void Main(string[] args) { Foo foo = new Foo { Name = "A" }; Test(ref foo); Console.WriteLine(foo.Name); // 输出C } static void Test(ref Foo obj) { obj.Name = "B"; obj = new Foo { Name = "C" }; } ```  ### 三、初识托管指针和非托管指针 在C#中,如果我们想要定义一个引用传递的方法,我们需要通过给方法参数加上 `ref` 或者 `out` 关键词。 同时C#也允许我们通过 `unsafe` 关键词编写不安全的代码。那么这两者到底有什么区别呢。 以以下C#代码为例: ```csharp static unsafe void Main(string[] args) { int a, b; Method1(&a); // 使用非托管指针 Method2(out b); // 使用out关键词 Console.WriteLine($"a:{a},b:{b}"); // a:1,b:2 } static unsafe void Method1(int* num) { *num = 1; } static void Method2(out int b) { b = 2; } ``` 接下来,我们通过查看生成的IL的代码来分析一下这两者之间的区别。 ```csharp .assembly extern mscorlib {} .assembly 'App' {} .class private auto ansi beforefieldinit PointerDemo.Program extends [mscorlib]System.Object { .method private hidebysig static void Main( string[] args ) cil managed { .entrypoint .maxstack 3 .locals init ( [0] int32 a, [1] int32 b ) // [8 9 - 8 10] IL_0000: nop // [10 13 - 10 25] IL_0001: ldloca.s a IL_0003: conv.u IL_0004: call void PointerDemo.Program::Method1(int32*) IL_0009: nop // [11 13 - 11 28] IL_000a: ldloca.s b IL_000c: call void PointerDemo.Program::Method2(int32&) IL_0011: nop // [13 13 - 13 47] IL_0012: ldstr "a:{0},b:{1}" IL_0017: ldloc.0 // a IL_0018: box [mscorlib]System.Int32 IL_001d: ldloc.1 // b IL_001e: box [mscorlib]System.Int32 IL_0023: call string [mscorlib]System.String::Format(string, object, object) IL_0028: call void [mscorlib]System.Console::WriteLine(string) IL_002d: nop // [14 9 - 14 10] IL_002e: ret } // end of method Program::Main .method private hidebysig static void Method1( int32* num ) cil managed { .maxstack 8 // [17 9 - 17 10] IL_0000: nop // [18 13 - 18 22] IL_0001: ldarg.0 // num IL_0002: ldc.i4.1 IL_0003: stind.i4 // [19 9 - 19 10] IL_0004: ret } // end of method Program::Method1 .method private hidebysig static void Method2( [out] int32& b ) cil managed { .maxstack 8 // [22 9 - 22 10] IL_0000: nop // [23 13 - 23 19] IL_0001: ldarg.0 // b IL_0002: ldc.i4.2 IL_0003: stind.i4 // [24 9 - 24 10] IL_0004: ret } // end of method Program::Method2 .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { .maxstack 8 IL_0000: ldarg.0 // this IL_0001: call instance void [mscorlib]System.Object::.ctor() IL_0006: nop IL_0007: ret } // end of method Program::.ctor } // end of class PointerDemo.Program ``` 可以看到 静态方法`Method1`中的参数对应的IL代码 `int32* num`。 静态方法Method2中的参数对应的IL代码是 `[out] int32& b`,其中[out]即使去除也不影响代码的运行,上述代码是可通过ilasm编译的完整代码,有兴趣的朋友可以自己做尝试。 通过学习《.NET探秘:MSIL权威指南》这本书,我们可以了解到很多相关的知识。 在CLR中可以定义两种类型的指针: | ILAsm符号 | 说明 | | ------------ | ------------ | | type* | 指向type的非托管指针 | | type& | 指向type的托管指针 | 也就是说用`out/ref`定义的指针类型其实对应的就是CLR中的托管指针。 ### 四、非托管指针 非托管指针的使用主要包括 寻址运算符 `&` 间接寻址运算符 `*` 用于结构指针的成员访问运算符 `->` 非托管指针的用法和C/C++基本一致,这边不一一列出,下面主要列出几个.net 中非托管指针的注意点。 #### 1、非托管指针不能指向对象引用 我们知道一个引用类型的变量,它所存储的是托管堆上的实例的内存地址。这个内存地址记录本身也是保存在内存的某个位置。类似于我们用记事本记下了某人的联系方式,同时这条联系方式记录本身也占据了我们记事本上一定的空间,被我们写在了记事本的某个位置。 我们可以创建指向值类型变量的**非托管指针**,也可以创建多级**非托管指针**,但是不能创建指向引用类型变量(对象引用)的非托管指针。 ```csharp static unsafe void Main(string[] args) { int num = 2; object obj = new object(); int* pNum = # // 指向值类型变量的非托管指针,编译通过 int** ppNum = &pNum; // 二级指针,编译通过 object* pObj = &obj; // 指向引用类型变量的非托管指针,编译不通过 } ``` #### 2、类成员指针 如果我们想要创建一个对象的值类型成员变量的指针,按下方的代码是无法编译通过的。 ```csharp class Foo { public int Bar; } static unsafe void Main(string[] args) { Foo foo = new Foo(); int* p = &foo.Bar; // 编译不通过 } ``` 因为对于生存在托管堆上的引用类型的实例而言,**在一次 GC 之后,其内存位置可能会发生变动(GC的compact压缩阶段),包含在实例内的成员变量也就随之发生了位置的移动**。对于标识内存位置的指针而言,显然这样的情况是不能够被允许的。 但是我们可以通过 `fixed` 关键词避免 GC 时实例内存位置的移动来实现这种类型的指针的创建,如下面代码所示。 ```csharp static unsafe void Main(string[] args) { Foo foo = new Foo(); fixed (int* p = &foo.Bar) // 编译通过 { Console.WriteLine((int)p); // 打印内存地址 Console.WriteLine(*p); // 打印值 } } ``` 同理,我们也可以利用 `fixed` 关键词创建指向值类型数组的指针(数组是引用类型,这里指数组的元素是值类型)。 ```csharp static unsafe void Main(string[] args) { int[] arr = { 1, 2 }; // 除去 fixed 关键词外,指向数组的非托管指针声明方式与 C/C++ 类似 fixed (int* p = arr) { // 指针保存的是第一个元素的内存地址 Console.WriteLine(*p); // 输出1 // 通过 +1 可以获取到第二个元素的内存地址 Console.WriteLine(*(p + 1)); // 输出2 } } ``` ### 五、托管指针 在上文我们已经提到,我们在使用引用传递的时候使用的 **ref/out 关键词其实就是创建了托管指针**。 在 C#7 之前,我们只能在方法参数上见到托管指针的身影,C#7 进一步开放了托管指针的功能,使得我们能够在更多的场景下使用它们。例如和非托管指针一样,用于方法的返回值, 托管指针完全受 CLR 管理,与非托管指针相比,在 C# 中(IL对于托管指针的限制会更少)托管指针存在以下几个特点: - 只能引用已经存在的项,例如字段、局部变量或者方法参数,并不支持和非托管指针一样的单独声明。 - 不支持多级托管指针,但是托管指针能够指向对象引用。 - 不能够打印内存地址的值。 - 不能够执行指针算法。 - 不需要显示的间接寻址(生成的IL代码中执行了间接寻址 通过 ldind.i4、ldind.ref 等指令 )。 ```csharp static void Main(string[] args) { var foo = new Foo{Bar = 1}; // 创建指向引用类型变量(对象引用)的托管指针 ref Foo p = ref foo; // IL代码中通过 ldind.ref 指令间接寻址找到对象引用 Console.WriteLine(p.Bar); // 输出1 } ```
这里⇓感觉得写点什么,要不显得有点空,但还没想好写什么...
返回顶部
About
京ICP备13038605号
© 代码片段 2025