欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

浅谈 .NET 中的对象引用、非托管指针和托管指针

程序员文章站 2022-05-17 16:09:06
[TOC] 前言 本文主要是以 C 为例介绍 .NET 中的三种指针类型(本文不包含对于函数指针的介绍): 对象引用 、 非托管指针 、 托管指针 。 学习是一个不断深化理解的过程,借此博客,把自己关于 .NET 中指针相关的理解和大家一起讨论一下,若有表述不清楚,理解不正确之处,还请大家批评指正。 ......

前言

本文主要是以 c# 为例介绍 .net 中的三种指针类型(本文不包含对于函数指针的介绍):对象引用非托管指针托管指针

学习是一个不断深化理解的过程,借此博客,把自己关于 .net 中指针相关的理解和大家一起讨论一下,若有表述不清楚,理解不正确之处,还请大家批评指正。

开始话题之前,我们不妨先对一些概念作出定义。

变量:给存储单元指定名称、即定义内存单元的名称或者说是标识。

指针:一种特殊的变量、其存储的是值的地址而不是值本身。

一、对象引用

对于对象引用,大家都不会陌生。

与值类型变量直接包含值不同,引用类型变量存储的是数据的存储位置(托管堆内存地址)。

对象引用是在托管堆上分配的对象的开始位置指针。访问数据时,运行时要先从变量中读取内存位置(隐式间接寻址),再跳转到包含数据的内存位置,这一切都是隐藏在clr背后发生的事情,我们在使用引用类型的时候,并不需要关心其背后的实现。

二、值传递和引用传递

很多朋友,包括我,在初期学习的时候,可能都会有这么一个认知误区:"对象在c#中是按引用传递的"。

对于引用传递,借鉴《深入理解c#》中话,我们需要记住这一点:

假如以引用传递的方式来传送一个变量,那么调用的方法可以通过更改其参数值,来改变调用者的变量值。

例如下面这么一个例子:

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变量,它们两者是完全独立的。

浅谈 .NET 中的对象引用、非托管指针和托管指针

只要对上述代码做一个小修改,就能顺利地打印出 c,也就是通过大家习惯的 ref 关键词。

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"
    };
}

浅谈 .NET 中的对象引用、非托管指针和托管指针

三、初识托管指针和非托管指针

在c#中,如果我们想要定义一个引用传递的方法,我们需要通过给方法参数加上 ref 或者 out 关键词。

同时c#也允许我们通过 unsafe 关键词编写不安全的代码。那么这两者到底有什么区别呢。

以以下c#代码为例:

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的代码来分析一下这两者之间的区别。

.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、非托管指针不能指向对象引用

我们知道一个引用类型的变量,它所存储的是托管堆上的实例的内存地址。这个内存地址记录本身也是保存在内存的某个位置。类似于我们用记事本记下了某人的联系方式,同时这条联系方式记录本身也占据了我们记事本上一定的空间,被我们写在了记事本的某个位置。

我们可以创建指向值类型变量的非托管指针,也可以创建多级非托管指针,但是不能创建指向引用类型变量(对象引用)的非托管指针

static unsafe void main(string[] args)
{
    int num = 2;
    object obj = new object();
    int* pnum = # // 指向值类型变量的非托管指针,编译通过
    int** ppnum = &pnum; // 二级指针,编译通过
    object* pobj = &obj; // 指向引用类型变量的非托管指针,编译不通过
}

2、类成员指针

如果我们想要创建一个对象的值类型成员变量的指针,按下方的代码是无法编译通过的。

class foo
{
    public int bar;
}

static unsafe void main(string[] args)
{
    foo foo = new foo();

    int* p = &foo.bar; // 编译不通过
}

因为对于生存在托管堆上的引用类型的实例而言,在一次 gc 之后,其内存位置可能会发生变动(gc的compact阶段),包含在实例内的成员变量也就随之发生了位置的移动。对于标识内存位置的指针而言,显然这样的情况是不能够被允许的。

但是我们可以通过 fixed 关键词避免 gc 时实例内存位置的移动来实现这种类型的指针的创建,如下面代码所示。

static unsafe void main(string[] args)
{
    foo foo = new foo();

    fixed (int* p = &foo.bar) // 编译通过
    {
        console.writeline((int)p); // 打印内存地址
        console.writeline(*p); // 打印值
    }
}

同理,我们也可以利用 fixed 关键词创建指向值类型数组的指针(数组是引用类型,这里指数组的元素是值类型)。

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 等指令 )。
static void main(string[] args)
{
    var foo = new foo{bar = 1};

    // 创建指向引用类型变量(对象引用)的托管指针
    ref foo p = ref foo;

    // il代码中通过 ldind.ref 指令间接寻址找到对象引用
    console.writeline(p.bar); // 输出1
}