草庐IT

让泛型的思维扎根在脑海——深刻理解泛型

坚持 2023-03-28 原文

1.前言

往往一些刚接触C#编程的初学者,对于泛型的认识就是直接跳到对泛型集合的使用上,虽然微软为我们提供了很多内置的泛型类型,但是如果我们只是片面的了解调用方式,这会导致我们对泛型盲目的使用。至于为什么要使用泛型,什么情况下定义属于自己的泛型,定义泛型又能为程序带来哪些好处。要理清这些问题,我们就必须深刻理解泛型的本质,形成泛型编程的思维方式。

接下来我将基于一个基础示例,然后通过需求不断的演化示例,从而让泛型在关键时刻脱颖而出,以便让我们能够深刻体会泛型的作用。假设.NET没有为我们提供用于存储数据的集合,而我们需要一个能够用于存储string元素的集合,基于这个情况我们自定义了一个用于存储字符串的集合类:

    class ArraryStr
    {
        public ArraryStr()
        {
            _items = new string[100]; //初始化存储元素的容量,只是为了演示故将容量定义为固定值
        }

        private string[] _items; //存储元素的数组
        private int _count;   //元素总数
        public int Count
        {
            get { return _count; }
        }

        public void Add(string item) //新增元素
        {
            _items[_count] = item;
            _count++;
        }

        public string this[int index] //索引
        {
            get { return _items[index]; }
            set { _items[index] = value;  }
        }

    } // END ArraryStr

为了验证自定义string集合的可行性,我们对其进行了如下的应用:

1             ArraryStr arraryStr = new ArraryStr();
2             arraryStr.Add("张三");
3             Console.WriteLine(arraryStr[0]);

2.重复

目前对于创建string类型的集合已经大功告成,而此刻我们又接到了一个新的需求,即我们需要一个集合存储int类型的元素。基于自定义string集合的经验来看,我们可以发现,string集合类型和我们即将要创建的int集合类型的结构和内容几乎是一样的。这就意味着我们可以使用江湖盛行的“复制大法”,将之前的代码复制一遍,然后轻微修改下即可。下面是两个集合类型代码的对比图。

在早年有款热门的游戏叫做“大家来找茬”,该游戏主要玩法就是在两个大致相同的图片中,查找两者之间的细微差异之处。我们使用的“复制大法”,促使我们编写的代码形成了可以用于这个游戏游玩的场景。“对于上面的两个代码截图,你能找出图中不同的地方吗?”

对于软件开发者而言,面对的最主要的敌人就是“变化”,假设后面还会出现N个类型的元素需要我们定义集合来存储,那我们是不是要将相同的代码无穷尽的复制下去?DRY(Don't Repeat Yourself,不要重复自己,请记住这是作为一名软件开发者编码的原则,“复制大法”很明显的违背了这个原则。


3.安全和性能

通过“复制,粘贴”的手段可以很明显的感受到我们在做重复的事情,在重复中我们可以发现:集合存储的类型在增加,但是集合的结构和添加元素的方法都是相同的逻辑。简单来说就是,不同类型的处理,其处理逻辑都是类似的。基于这个特点,为了满足自定义集合能够应对所有类型的存储,我们必须使用一个通用类型来作为代表,此时此刻我们脑海中就能浮现出一句话:object是一切类型的基类。这就意味着我们添加的所有类型,都可以隐式的转换为object类型,从而使得自定义集合可以添加任何类型的元素。让我们来运用这个object类型来试试:

   class ArraryList
    {
        public ArraryList() { _items = new object[100]; }

        private object[] _items;
        private int _count;
        public int Count
        {
            get { return _count; }
        }

        public void Add(object item)
        {
            _items[_count] = item;
            _count++;
        }

        public object this[int index]
        {
            get { return _items[index]; }
            set { _items[index] = value; }
        }
    } // END ArraryStr
    internal class Program
    {
        static void Main(string[] args)
        {
            ArraryList arraryList = new ArraryList();
            arraryList.Add("张三");
            arraryList.Add(18);

            string name = (string)arraryList[0];
            int age = (int)arraryList[1];

        } // END Main()

    }

在上面的代码中,我们结合了object是一切类型基类的特点,对集合类型进行改造,并成功的使用该方式的集合添加了不同类型的元素。虽然在使用的角度来看已经完美无缺(可以添加任何类型),但是获取集合元素进行赋值的时候,还使用了类型强制转换的手段。这是因为这种方式存在很严重的问题,主要包括以下两个方面:

  1. 类型安全方面,如果集合的第一个元素是sting类型,但是你客观认为是int类型,于是你在获取时进行了int类型的强制转换,这个时候代码不会提示错误且可以正常编译,那么这就意味着程序在运行时会产生一个你无法预料的类型无效转换的异常。
  2. 性能方面,值类型元素添加到集合时,必然会存在装箱操作;而在获取元素并赋值给一个值类型变量时,又会发生相应的拆箱操作。这种拆箱和装箱的操作,在操作大量元素时会大幅度的损失程序的性能。

到目前位置,我们还是没有能创建一个能够存储任何类型的集合,但是我们可以对于上述的示例演变的过程进行一个总结:对于不同类型有相同处理逻辑的情况,如果一味的复制会导致我们出现重复代码,如果使用object来作为解决重复的方案,会存在类型安全和性能的问题。至于如何让彻底解决这些问题,这就要说到了本文讲解的主题——泛型。


4.代码模板

C#中有两种不同的机制来编写跨类型(一个类型代替多个类型)可复用的代码:继承和泛型。继承的复用性来自于基类,而泛型的复用性是通过带有“占位符”的代码模板类型实现的。继承实现复用是站在面向对象的角度思考的,而泛型的复用是站在实现特定功能上思考的。相比于继承,泛型不用遵循里氏替换原则,并且能够提高类型的安全性,减少类型转换带来的拆箱和装箱。

怎么样理解泛型?泛型本质上相当于一种“代码模板”,可以用一套代码,为不同类型的同一逻辑使用统一的方式实现。其中“模板”一词的概念需要进行深刻的体会。例如,公司在招聘时会与用人方签订劳动合同,而这个劳动合同的主要内容对于所有人来说几乎都是一样的,只是在极个别的地方有所差异,如薪资、姓名等。所以公司不会为某个人(张三或李四)去特意的制定合同,而是会统一制定一份劳动合同作为模板,将其中针对个人存在差异的部分通过“下划线”进行占位预留,“下划线”的值将在签订合同时由具体的聘用者根据自身情况填写。

对于这种模板方式的使用,公司在制定合同时则不用考虑签订合同的人具体是谁,因为劳动合同(模板)和使用者是分开的,所以公司只用专注于合同的主要内容即可。而我们在实际的编程运用中,使用泛型的目的,其实和公司制定通用的劳动合同模板是一个道理。假设你的公司需要雇佣100名员工时,你不希望为每一个人都制定一个专属的合同吧?假设你的代码中,如果遇到10个类型,它们的操作处理逻辑都一样时,你不希望为这个10个类型写10个处理方式吧?

通过上面的介绍和例子,接下来我们将泛型运用到我们的示例中来,代码如下:

 1     class ArraryList<T>
 2     {
 3         public ArraryList() { _items = new T[100]; }
 4 
 5         private T[] _items;
 6         private int _count;
 7         public int Count
 8         {
 9             get { return _count; }
10         }
11 
12         public void Add(T item)
13         {
14             _items[_count] = item;
15             _count++;
16         }
17 
18         public T this[int index]
19         {
20             get { return _items[index]; }
21             set { _items[index] = value; }
22         }
23     } // END ArraryStr
24     internal class Program
25     {
26         static void Main(string[] args)
27         {
28             ArraryList<string> arraryStr = new ArraryList<string>();
29             arraryStr.Add("张三");
30             Console.WriteLine(arraryStr[0]);
31 
32             ArraryList<int> arraryInt = new ArraryList<int>();
33             arraryInt.Add(18);
34             Console.WriteLine(arraryInt[0]);
35 
36         } // END Main()
37 
38     }

5.类型参数

在上面的代码中,我们将集合类型定义为了泛型类,该类型中出现的T属于泛型中的类型参数(Type Parameter)。泛型为了达到通用处理的目的,所以不能将某个具体类型作为处理的目标类型,故而将要处理的类型用“T”作为一个类型占位符。

“T”并不是真正的数据类型,它更像是泛型使用的类型蓝图,所以在使用时,泛型类型的消费者必须将一个具体类型作为“类型参数”传递到尖括号内,以此构造一个有明确处理类型的泛型实例。所以我们在外部使用泛型时不能以:“ArraryList<T>list =new ArraryList<T>()”、“T t=new T()”这种方式去实例化泛型类型。另外,“T”本身仅仅是类型参数的名称,它只是代表了类型参数的标识而已,这意味着我们可以使用其他字符来为类型参数命名。


6.替换

通过类型参数的使用我们可以得知,泛型类型代码在静态阶段没有明确的类型,那么在程序运行的时候,它又是如何和使用时指定的“类型参数”进行对接的呢?为了搞清楚这个问题,下面我们来了解下泛型运行时的本质。

我们编写的C#程序在编译后生成的代码,并不是计算机可以直接执行的代码,而是会生成CIL(通用中间语言)代码并包含在程序集中,如果想要生成计算机可执行的代码,则还需要JIT(即时编译器)对CIL代码进行二次编译。然而泛型类型确认其具体类型的时机,就在JIT进行二次编译时,JIT编译的代码如果包含了泛型的内容,那么它会根据泛型类型的消费者指定的类型参数,将CIL中泛型代码中的占位符T替换为一个具体的类型,从而明确当前执行的泛型代码是针对哪个类型来使用的,其中替换的过程是由CLR在运行时进行主导,JIT来实际操作完成的。这个在运行时确认了类型的泛型又被称之为“封闭类型”,反之在运行时确认之前的泛型称为“开放类型”。

泛型使用占位符在运行时替换具体类型的机制,其实和本文中例举劳动合同模板使用“下划线”的方式有同样的思想。在指定劳动合同模板时,对于聘用者的姓名并不能写一个具体的名字,因为模板的目的是为了通用化,所以对于名字采用了“下划线”的方式。当公司与某个具体的人签订合同的时候,劳动合同模板中的下划线将由聘用者根据自身情况填写。回到泛型中其使用思想也是如此,我们使用泛型的目的是为了让多个类型的处理通用化,所以在定义泛型代码的时候并不能指定一个具体类型,故使用类型参数T进行代替,这个类型参数T就相当于劳动合同模板中的“下划线”,当泛型在实际运行的时候,JIT会根据泛型消费者指定的具体类型与占位符T进行替换。


7.总结

本文并不是专门适用于介绍泛型的使用细节的文章,而是通过一个实例根据需求不断演化的过程,对泛型一步步深入,从而更加深刻的理解泛型的使用初衷,相比了解泛型“只言片语”而言,形成泛型的编程概念和思维显得尤为重要。在泛型的机制中,我们可以将不同类型存在相同处理逻辑的情况,形成一个通用的方案,从而不在为特定的类型进行编码,用一套通用的代码模板会服务于更多的类型,并且在使用上能保证类型安全和提供良好的性能。
 

有关让泛型的思维扎根在脑海——深刻理解泛型的更多相关文章

  1. CAN协议的学习与理解 - 2

    最近在学习CAN,记录一下,也供大家参考交流。推荐几个我觉得很好的CAN学习,本文也是在看了他们的好文之后做的笔记首先是瑞萨的CAN入门,真的通透;秀!靠这篇我竟然2天理解了CAN协议!实战STM32F4CAN!原文链接:https://blog.csdn.net/XiaoXiaoPengBo/article/details/116206252CAN详解(小白教程)原文链接:https://blog.csdn.net/xwwwj/article/details/105372234一篇易懂的CAN通讯协议指南1一篇易懂的CAN通讯协议指南1-知乎(zhihu.com)视频推荐CAN总线个人知识总

  2. TimeSformer:抛弃CNN的Transformer视频理解框架 - 2

    Transformers开始在视频识别领域的“猪突猛进”,各种改进和魔改层出不穷。由此作者将开启VideoTransformer系列的讲解,本篇主要介绍了FBAI团队的TimeSformer,这也是第一篇使用纯Transformer结构在视频识别上的文章。如果觉得有用,就请点赞、收藏、关注!paper:https://arxiv.org/abs/2102.05095code(offical):https://github.com/facebookresearch/TimeSformeraccept:ICML2021author:FacebookAI一、前言Transformers(VIT)在图

  3. ruby - 易于初学者理解的 Ruby 库 - 2

    关闭。这个问题不符合StackOverflowguidelines.它目前不接受答案。我们不允许提问寻求书籍、工具、软件库等的推荐。您可以编辑问题,以便用事实和引用来回答。关闭3年前。Improvethisquestion我正处于学习Ruby的阶段,我想查看一些小型库的源代码以了解它们是如何构建的。我不知道什么是小型图书馆,但希望SO能推荐一些易于理解的图书馆来学习。因此,如果有人知道一两个非常小的库,这是新手Rubyists学习的好例子,请推荐!我想使用Manveru'sInnatelib,因为它试图保持在2000LOC以下,但我还不熟悉其中经常使用的Ruby速记。也许大约100-5

  4. ruby - 无法理解 `puts{}.class` 和 `puts({}.class)` 之间的区别 - 2

    由于匿名block和散列block看起来大致相同。我正在玩它。我做了一些严肃的观察,如下所示:{}.class#=>Hash好的,这很酷。空block被视为Hash。print{}.class#=>NilClassputs{}.class#=>NilClass为什么上面的代码和NilClass一样,下面的代码又显示了Hash?puts({}.class)#Hash#=>nilprint({}.class)#Hash=>nil谁能帮我理解上面发生了什么?我完全不同意@Lindydancer的观点你如何解释下面几行:print{}.class#NilClassprint[].class#A

  5. ruby - 如何理解 Ruby 中的发送者和接收者? - 2

    我很难理解Ruby中sender和receiver的实际含义。它们一般是什么意思?到目前为止,我只是将它们理解为方法调用和获取其返回值的调用。但是,我知道我的理解还远远不够。谁能给我一个Ruby中发送者和接收者的具体解释? 最佳答案 面向对象中的一个核心概念是消息传递和早期概念化,这在很大程度上借鉴了计算的Actor模型。艾伦·凯(AlanKay)创造了面向对象一词并发明了最早的OO语言之一SmallTalk,他拥有voicedregretatusingatermwhichputthefocusonobjectsinsteadofo

  6. ruby-on-rails - Rails - 理解 application.js 和 application.css - 2

    rails新手。只是想了解\assests目录中的这两个文件。例如,application.js文件有如下行://=requirejquery//=requirejquery_ujs//=require_tree.我理解require_tree。只是将所有JS文件添加到当前目录中。根据上下文,我可以看出requirejquery添加了jQuery库。但是它从哪里得到这些jQuery库呢?我没有在我的Assets文件夹中看到任何jquery.js文件——或者直接在我的整个应用程序中没有看到任何jquery.js文件?同样,我正在按照一些说明安装TwitterBootstrap(http:

  7. 常见网络安全产品汇总(私信发送思维导图) - 2

    安全产品安全网关类防火墙Firewall防火墙防火墙主要用于边界安全防护的权限控制和安全域的划分。防火墙•信息安全的防护系统,依照特定的规则,允许或是限制传输的数据通过。防火墙是一个由软件和硬件设备组合而成,在内外网之间、专网与公网之间的界面上构成的保护屏障。下一代防火墙•下一代防火墙,NextGenerationFirewall,简称NGFirewall,是一款可以全面应对应用层威胁的高性能防火墙,提供网络层应用层一体化安全防护。生产厂家•联想网御、CheckPoint、深信服、网康、天融信、华为、H3C等防火墙部署部署于内、外网编辑额,用于权限访问控制和安全域划分。UTM统一威胁管理(Un

  8. ruby - 你如何理解 Ruby 中的这个三元条件? - 2

    我在某些代码中遇到了三元组,但我无法理解条件:str.split(/',\s*'/).mapdo|match|match[0]==?,?match:"somestring"end.join我确实理解我是在某些点上拆分字符串并将总结果转换为数组,然后依次处理数组的每个元素。除此之外,我不知道发生了什么。 最佳答案 一种(稍微)不那么令人困惑的写法是:str.split(/',\s*'/).mapdo|match|ifmatch[0]==?,matchelse"somestring"endend.join我认为多行三元语句很糟糕,尤其是

  9. ruby - 您如何将 S3 理解为 Ruby 中的分层目录结构? - 2

    有没有人成功地将S3存储桶读取为子文件夹?文件夹1--子文件夹2----文件3----文件4--文件1--文件2文件夹2--子文件夹3--文件5--文件6我的任务是读取文件夹1。我希望看到子文件夹2、文件1和文件2,但看不到文件3或文件4。现在,因为我将存储桶键限制为prefix=>'folder1/',你仍然会得到file3和4,因为它们在技术上具有folder1前缀。似乎真正做到这一点的唯一方法是吸收folder1下的所有键,然后使用字符串搜索从结果数组中实际排除file3和file4。有没有人有过这方面的经验?我知道像Transmit和Cyber​​duck这样的FTP风格的S3

  10. 关于yolov5训练时参数workers和batch-size的理解 - 2

    关于yolov5训练时参数workers和batch-size的理解yolov5训练命令workers和batch-size参数的理解两个参数的调优总结yolov5训练命令python.\train.py--datamy.yaml--workers8--batch-size32--epochs100yolov5的训练很简单,下载好仓库,装好依赖后,只需自定义一下data目录中的yaml文件就可以了。这里我使用自定义的my.yaml文件,里面就是定义数据集位置和训练种类数和名字。workers和batch-size参数的理解一般训练主要需要调整的参数是这两个:workers指数据装载时cpu所使

随机推荐