C#:委托你个事哈,帮我拆装箱 | 我:???

301 阅读8分钟

一、前言 Introduction

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第4天,点击查看活动详情委托事件拆装箱C# 中的 核心概念 ,了解它们对于巩固我们开发基础、提升开发能力和水平有很大的意义,让我用最通俗易懂的文字带你们走近并吃透它们吧!

二、委托 Delegate

委托可以视为一种更为高级的函数指针,他不仅能把地址地址指向另一个函数,而且还能传递参数、获得返回值等多个信息。

2.1 函数指针

谈委托之前,就不得不说一说指针了,使用过 C/C++ 的朋友对于指针应该再清楚不过了,很多人学到指针开始 “头痛”,学习 C/C++ 可能就此到头了hhh。指针难学、难懂是一方面,使用的同时更需谨慎对待,它不仅可以指向变量的地址,本质上,它是指向内存的地址。

但是在 C# 中万物皆是类,我们使用 C# 编程语言开发的过程中几乎没有指针的身影,最多也只是引用,那么,难道指针就不存在嘛?答案是否定的,它只是被封装在内部函数当中,其回调函数仍然是存在的,于是 C# 中就多了一个委托(delegate)的概念,所有函数指针功能都是以委托的方式来完成的。

2.2 声明

    // 形如:delegate <返回值类型> <委托名>(<参数列表>)
    delegate int DRandomBuilder(int min, int max);

定义了一个随机数产生委托类型(委托类型是一种引用类型,都默认继承于 System.Delegate 类)

注意这里的 DRandomBuilder 不是方法名 而是 委托名,千万不要把它当作一个函数去使用!

2.3 测试

2.3.1 随机数产生类

    /// <summary>
    /// 随机数产生类
    /// </summary>
    class CRandomBuilder
    {
        private Random rand;

        // 无参构造函数
        public CRandomBuilder()
        {
            // 初始化随机类
            rand = new Random();
        }

        // 产生随机数方法一
        public int GetRandomNumber1(int min, int max)
        {
            return rand.Next(min, max + 1);
        }

        // 产生随机数方法二
        public static int GetRandomNumber2(int min, int max)
        {
            Random r = new Random(DateTime.Now.Millisecond);
            return r.Next(min, max + 1);
        }
    }

自定义的随机数产生类在初始化创建时就将内部私有成员变量 rand 初始化,其中有两个产生随机数的方法,一个是 假随机,另一个是根据系统毫秒数播散随机种子的 真 . 随机,产生 minmax 区间的随机数。

2.3.2 调用

        public static void Test()
        {
            CRandomBuilder rndBuilder = new CRandomBuilder();

            DRandomBuilder rb1 =
                new DRandomBuilder(rndBuilder.GetRandomNumber1);

            DRandomBuilder rb2 =
                new DRandomBuilder(CRandomBuilder.GetRandomNumber2);

            // 执行委托(直接使用委托对象)
            Console.WriteLine("产生一位随机数为{0}", rb1(1, 9)); // 第一种调用方法
            Console.WriteLine("产生三位随机数为{0}", rb2.Invoke(100, 999)); // 第二种调用方法
        }

首先,创建委托对象,创建委托对象时的参数是方法名(方法名可以是实例方法,也可以是类的静态方法)。

  • 第一种调用执行方法:直接使用 委托对象名(委托方法形参列表)

  • 第二种调用执行方法:在第一种调用方法的基础上,使用类实例中的 Invoke() 函数直接调用

注意:这二种方法等价,但要注意传入参数和返回值要保持一致。

当委托被调用时,委托实例会把所有链表里的函数依次用传进来的参数调用一遍,但如果在执行列表中遇到一个错误就会立即抛出异常并停止调用。

三、事件 Event

那什么又是事件(Event)呢?它和委托又有什么关系?

事件其实就是在 委托上又做了一次封装。

可能你会问:

既生瑜何生亮,又何必再做一次封装呢?

其实啊,这次封装的意义在于限制用户直接操作委托对象中的实例成员变量,使之操作加限。

封装后,用户不能再直接通过赋值 = 操作来改变委托的变量,只能通过注册或者注销委托的方法来增减委托函数的数量。

也就是说,被 Event 声明的委托不再提供 = 操作符,但是仍有 +=-= 操作符可供操作。

  • += : Delegate 委托类重写的操作符之一,对应的是 MulticastDelegate 类中的 Combine() 方法,相当于把函数地址推入链表尾部。
  • -= : Delegate 委托类重写的操作符之一,对应的是 MulticastDelegate 类中的 Remove() 方法,相当于把函数地址移出链表。

3.1 限制原因

因为在平时项目开发中,项目过于庞大,开发人员多且编写代码区域存在交叉,这就导致我们能无法得知其他人编写的代码是什么,以及有什么意图,这样公开的委托直接暴露在外,就很容易被篡改或者清空,委托的操作权限权限范围太大了,很容易出现较大问题,维护起来就很困难了。

这样一来,就达到了 谁注册就必须谁负责销毁 的目的,极大维护了委托的秩序了。

四、装箱 Pack

装箱,即把 值类型实例转换成引用类型实例

那问题来了,什么是 值类型引用类型 呢?

4.1 值类型

值类型变量可以直接存储数据,例如:

byteshort
intlong
floatdouble
decimalchar
boolstruct

4.2 引用类型

引用类型的变量存储的是数据的引用,即 地址,其真实数据存储在数据堆中,如所有的 class 实例的变量、string,上面讲到的 Delegate 声明的委托变量,这些统称为引用类型。

当声明一个类时,只在堆栈(堆或栈)中分配一小片内存用于容纳一个地址,而此时并没有为其分配堆上的空间,因此它是空的,也就是大家熟知的 null

直到使用 new 关键字创建一个类的实例,分配一个堆上的空间,并把堆上的空间的地址保存给这个引用变量,这时这个引用变量才真正指向内存空间。

4.3 举例

    int a = 1; // (1)
    object obj = a; // (2)

(1)(2) 的过程就是 装箱 操作了!

  • a 为值类型,是直接有数据的变量
  • obj 为引用类型,指针与内存拆分开来
  • a 赋值给 obj ,实际上就是 obj 为自己创建一个指针,并指向了 a 的数据空间

五、拆箱 Unpack

抢答环节来了,那什么是拆箱呢?

引用类型实例转化为值类型实例

聪明!

5.1 举例

    int a = 1; // (1)
    object obj = a; // (2)
    
    int b = (int)obj; // (3)

(1)(2)装箱 操作,(3) 就是 拆箱 操作了!

相当于把 obj 指向的内存空间复制了一份交给 b ,但是 bint 值类型的,并不允许指向某个内存空间,只能靠 复制数据传递数据

六、为何需要拆、装箱 Why & Inspiration

6.1 意义

  1. 通用性 :当程序、逻辑或接口需要更加通用的时候,比如调用一个含类型为 object 的参数方法,该 object 可支持任意类型,以便通用。

  2. 适用性 :值类型是在声明时就初始化了,有自己的数据空间;而引用数据类型在分配内存后,只是一副空壳,可以认为是 野指针 ,不指向任何空间,因此默认为 null

    • 例如你如何把 Struct 声明的结构体变量当作引用类型,与 class 类同等看待,那你就会在复制操作后会产生改变一方数据却并没有同步到另一方数据上的疑问了。

6.2 影响

由于拆装箱时生成的是全新的对象,不断地 分配和销毁内存

所以,会产生如下消极影响:

  1. 大量消耗 CPU
  2. 增加内存碎片
  3. 降低性能。

6.3 启发

为了避免拆装箱带来的负面影响,那我们该如何做呢?

主要靠的是加强代码规范、减少拆装箱的情况来提高性能。Struct 不一样,它既是值类型,又可以像类那样集成,用途多且转换的途径也多,但稍不留神,玩出的花样可能变成了麻烦了,这里主要讲讲 Struct 变化后的优化方法:

  • 重载函数
    • 比如常用的 ToString()GetType() 方法,如果 Struct 没有写重载 ToString()GetType() 的方法,就会在 Struct 实例调用它们时先装箱再调用,导致内存块重新分配,性能损耗
    • 所以对于那些需要调用的引用方法,必须重载
  • 泛型
    • 不要忘了 Struct 也是可以继承的,在不同的、相似的、父子关系的 Struct 之间可以使用泛型来传递参数,这样就不用在装箱后再传递了
    • 比如 BC 继承 A,就有这个泛型方法 void Test(T t) where T:A ,以避免使用 object 引用类型形式来传递参数
  • 继承统一的接口
    • 提前进行拆、装箱,以此来避免多次重复拆、装箱操作导致的性能损耗

七、结尾 Ending

撰文不易,欢迎大家点赞、评论,你的关注、点赞是我坚持的不懈动力,感谢大家能够看到这里!Peace & Love。