ToB企服应用市场:ToB评测及商务社交产业平台

标题: [数据结构1.2-线性表] 动态数组ArrayList(.NET源码学习) [打印本页]

作者: 冬雨财经    时间: 2022-9-16 17:19
标题: [数据结构1.2-线性表] 动态数组ArrayList(.NET源码学习)
[数据结构1.2-线性表] 动态数组ArrayList(.NET源码学习)

在C#中,存在常见的九种集合类型:动态数组ArrayList、列表List、排序列表SortedList、哈希表HashTable、栈Stack、队列Queue、链表LinkedList、字典Dictionary、点列阵BitArray。本文将基于动态数组ArrayList,从源码的角度出发,分析其内部定义以及常用方法的实现。
【# 请先阅读注意事项】
【注:(1)以下提到的复杂度仅为算法本身,不计入算法之外的部分(如,待排序数组的空间占用)且时间复杂度为平均时间复杂度 。
(2)除特殊标识外,测试环境与代码均为.NET 6/C# 。
(3)默认情况下,所有解释与用例的目标数据均为升序。
(4)默认情况下,图片与文字的关系:图片下方,是该幅图片的解释。
(5)本文内容基本为本人理解所得,可能存在较多错误,欢迎指出并提出意见,请见谅。】
一、ArrayList的实现

(一) 构造函数

该数据结构有三种构造方法。即,三种初始化的方法:

创建一个(当前容量为0)空的Array对象。

创建一个有大小的空的Array对象。

将某个非空集合转换为ArrayList类型。
从这三个构造方法可以初步得出以下结论:
1. 字段_items储存了动态集合本身。
2. 动态集合对象的创建调用的是Array中的方法,所以其本质依旧属于数组。
3. 动态集合的容量必须大于等于0;传入的其他集合不能为空,但长度可为0。
4. 有关AddRange()方法:

根据上方推断出的_items,可知此处的_size指的是ArrayList的当前长度。可以发现,该方法的作用是在原动态数组的某一特定位置,插入新数据集。




注意,AddRange()方法和InsertRange()方法均可被调用,前者是在动态集合末尾加上新的数据集;后者可在任意合法位置插入新数据集。但AddRange()方法也调用的是InsertRange()方法,正是因为有Line 432行的if语句。
(1)当调用AddRange()方法时,传入的index就是_size,所以此时不执行if语句内的内容。直接创建一个新数组array,将新数据集存入其中,再把该数组拼接到原动态数组的末尾。
(2)当调用InsertRange()方法时,若待插入数据集长度大于等于_size,则为情况(1),不执行if内的语句;待插入数据长度小于ArrayList的长度时(index < _size),从索引index开始,将原本在ArrayList中的数据向后移动,保证从index开始足够插入新的数据。
(二) 字段与属性

1.  三个私有字段:

其中,_items表示动态数组本身;_size表示动态数组当前存储的元素数;_version表示当前动态集合的版本号,在执行某些操作后,会使版本号+1,所以,理论上对于同一个动态数组这些操作总共只能执行有限次,即int的上限次。这些操作包含:属性(索引器)this[int indxe]、方法Add()、AddRange()、Clear()、Insert()、InsertRange()、Remove()、RemoveAt()、RemoveRange()、Reapt()、Reverse()、SetRange()、Sort()。
2. 七个公共属性:

(1)Capacity,读/写,表示当前的容量上限,不是当前储存的元素个数




有关Capacity的运行流程参照下图,图片分析过程已逐行标号,若存在问题,欢迎在评论区提出。


(2)Count,只读,返回当前元素个数。区别于容量Capacity。

(3)IsFixedSize,只读,指示数组是否具有固定大小。此处默认为false且不可更改,因为要保证其动态性。
若要创建大小固定的动态数组,可以调用静态方法FixedSize()方法,由该方法创建的动态数组不能再添加或移除元素,但是允许修改现有元素


该属性包含在一个新的类FixedSize中,是一个内部类。

(4)IsReadOnly,只读,指示当前对象是否为只读对象。

(5)IsSynchronSized,只读,指示对动态数组的访问是否同步(线程安全)。

(6)   SyncRoot,只读,获取可用于同步访问动态数组的对象,即IsSynchronized值为true的动态数组对象。
【(5)、(6)的详细内容将在今后的有关线程的文章中论述】

(7)索引器:可以使用索引运算符来访问某个数据结构中的对象。
其中get属性为要返回的对象;set属性用于将对应的元素和索引一一对应,此处的value指代动态数组中的元素。
小结 一

1. 动态数组之所以称为“动态”,是因为其大小可以在程序运行时改变,而不是像Array一样,在初始化时就指明长度大小且不可更改。
2. 动态数组中的Capacity是可以保存的元素数,即容量;Count是实际的元素数。
3. 其内部元素可以为null可以重复可以为不同类型;但不能多维数组作为其元素。


据微软的说法,多维数组不能作为ArrayList的元素,但实测后似乎没有问题,有知道如何理解微软的那句话的学者可以评论交流。
二、ArrayList的常用方法

(一) Add()、AddRange()、Insert()和InsertRange()方法


Add(),公共虚方法,在原动态数组末尾插入单个值。





AddRange(),公共虚方法,在原动态数组末尾插入新的数据集,其调用的是内部的InsertRange()方法。

InsertRange(),公共虚方法,在某一特定位置插入某一数据集。





注意到,该方法内部并未显式更新索引器。原因可能是:此处创建了一个数组,数组本身就是通过索引访问的,将集合c复制给数组,使得集合c内部的元素可由索引访问;将array再复制给动态数组,将数组可由索引访问的特性保留了下来,因此此处无需显式更新索引器。

Insert(),公共虚方法,在某一特定位置插入单个数据。
形式和之前的Add()方法相差不大,只是增加了一个特定位置的参数。
(二) BinarySearch()方法

该方法为二分查找法,使用的前提是元素有序。其调用的是Array类中的BinarySearch()方法,用于查找某个元素所在的位置/索引,不存在则返回一个较为特殊的值,后文会提到。












之后,根据不同的数据类型,进入到不同到重载方法中进行查找(以下以int为例)

所调用的BinarySearch()方法位于类SpanHelpers中,在比较时运用到了指针,所以方法被标记为unsafe。
之后的部分就是熟悉的双指针/折半查找。
进行上述两个过程的前提分别是转换后的array != null和比较器对象为默认值,若都不符合,则进行以下面的方式进行查找。

该方法和第一种成功转换的方法基本一致,只是直接在原数组中进行查找,使用非默认比较器对象。
(三) Clear()方法

【注:对于该方法的源码分析,以下多数内容位推断得出,可能存在较多错误,还请各位大佬指正】
由于动态数组本质还是数组,所以调用的大部分方法均是类Array中的方法。


可以发现,一个感觉简单的清除元素的方法,执行起来却较为复杂。
【注:有关Int与IntPtr的区别会在后文提到,可先行转跳查看】











【注:关于类Unsafe中的内容会在今后的文章中解释】
如果运行正常,最终都会进入到SpanHelpers类中的ClearWithReferences()方法。从方法名称上看,可以推断出其不仅删除了集合中的元素,还删除了该集合用于存储元素的引用指向。即,删除了元素分配给集合存储元素的内存空间位置
下面是ClearWithReferences()方法
【注:关于ContainsGCPointer,其相关解释仅为推断内容,有待证实】
(1)对于不包含GCPointer的对象,需要自行定义新的清除方法,进入Line 341处的方法:
 



该方法获取待清除数组的首地址,之后执行一个扩展方法,需要在外部写一个清除方法,特殊处理;若没有超过上限则执行下面的方法。


startAddress表示引用要初始化的内存块开头的托管指针;value表示要初始化内存块的所有字节的值;byteCount表示要初始化的字节数。

据微软官方的说法,该方法不用于初始化可供使用的运行内存。那么推测其原理应该是通过重置内存块上的分配信息,以达到清除某数据结构中元素的目的。被重置的内存块将不再被任何结构占用,可再次自由分配。
(2)对于内部包含GCPointer的对象,收到GC的管控,可自行回收,进入Line 338处的方法:




通过简要分析可以得出,该方法的时间复杂度位O(n),因为其需要通过指针进行遍历并修改每一个值;空间复杂度为O(n),因为其创建了MethodTable,用于存储传入数组的相关信息。
虽然其较为复杂,但效率且不低,下面将通过对比Clear()方法、新建对象和单纯遍历删除这三种方式的耗时。数据量为10万,进行100万次,由于JIT的特性,第一次启动程序会耗时较高,于是不保留第一次的测试数据。



小结 二

(1)在数据量庞大时,Clear()方法能保持较高的效率,其次是手动清除,最后是创建新对象。
(2)在测试string类型前,也跑了一遍int类型,同样的到的类似的结果。不过横向对比可以得出,值类型的清除效率要比引用类型更高。
(3)理论上,从简单的分析可以知道手动清除只是遍历,而创建新对象是在堆与栈中反复读取与写入,所以创建新对象效率比较低。这样的结果或许也得益于CLR等相关架构的优化,具体有关CLR的相关内容,将会在今后专门写文章进行分析。
(4)以下是有关Clear()方法的执行流程简图:【字迹不清,还请见谅】

(四) Contains()方法

Contains()方法不论是在动态数组还是在其他线性数据结构中都经常被使用,其实现原理也很简单,就是单纯的遍历数组。时间复杂度O(n),空间复杂度O(1)。

(五) IndexOf()、LastIndexOf()方法

Contains()方法只是查找元素是否包含在集合中,而这两种方法在查找后还将返回元素所在的索引位置。时间复杂度O(n),空间复杂度O(n)。
首先是IndexOf()方法,其从索引0开始,返回第一个与目标值相等的索引。

两个if用于判断起始索引是否越界;查找长度是否小于0或以startIndex为起点,查找过程中是否会越界。之后调用类Array中的IndexOf()方法。







其中,等于运算符“==”和Equals()方法的区别,在比较值类型时,均比较在栈中的内容;比较引用类型时,运算符“==”比较的是存放在栈中引用地址,Equals()方法比较的是堆中的内容






如果不是基元类型,则取出每一个元素,逐一比较,相等返回索引;没有找到则返回-1。
接下来是LastIndexOf()方法,从方法名上可以推断出,其查找顺序是从后向前

只看关键部分代码


startIndex为起始索引,向前查找count位,直到num。除此之外,其余均与IndexOf()方法一致。
(六) Reverse()方法

时间复杂度O(n),空间复杂度O(1)。


可以发现,其原理是通过交换指针实现的。





小结 三(有关Array、ArrayList和List)

(1)Array是C#中最早的数据结构,其在内存中是连续存储的,且同索引进行访问及相关操作,有较高的效率。但连续的存储形式,使得其在插入和删除上效率低下,且在声明时需要指定长度,过短会溢出;过长会浪费。
(2)为了弥补数组的缺点,引入了动态数组ArrayList。其本质还是数组,不过是在数组的基础上增加了一定的灵活性,其多数内部方法依旧基于类Array。但继承了IList接口的它,具有灵活的长度,同时不受数据类型的限制,可在一个对象中存储任意类型。但正是因为不受类型的限制,频繁的装箱拆箱操作,导致了一定的性能损耗和不安全性。
(3)随着.NET(以前称为.NET Framework)的发展,又引入了集合List,每个集合具有固定的类型,只能存放相应类型的数据;在内存中不连续;且长度为动态的。可以说兼容了数组和动态集合的优点,因此也成为目前使用最为广泛的数据结构之一。该数据结构在之后的文章中会提到
[# 有关结构体MethodTable(方法表)]

【注:由于关于该结构体的相关资料较少,故以下内容多以推测为主,可能存在较多错误。】

该结构体在命名空间System.Runtime.CompilerServices下,据书籍《Pro .NET Performance》的说法:MethodTable是由一个类的所有方法的相关信息所组成。即,主要用于存储对象的基本信息,在必要时候进行提供。其是一个内部的(internal)结构体,结构体内部包含7个公共的字段和6个公共属性。
先来看7个字段







接下来是6个属性


这几个属性均为只读,都反映了对象的一些信息。其中sizeof(IntPtr)的值据程序的位而定,32位值就为4;64位值就为8。
[# 有关数据类型Int与IntPtr]

整两种数据类型均可以称为整型。
对于Int,我们都很熟悉,属于基元类型,带符号的32位整数,默认值位0,十进制运算,-2,147,483,648到2,147,483,647,即-2^31到2^31 – 1。若加上前缀‘u’,则表示无符号类型。
对于IntPtr,表示一个有符号整数,用于表示内存地址。其中位宽度与指针相同,即其所占字节大小与基于其派生出的原类型(Int)大小相同。需要注意的是,此类型的实例应在32位进程中为32位,在64位进程中为64位。该类型可由支持指针的语言使用,并作为引用执行和不支持指针的语言之间的数据的常见方法。基本信息如下:

更多详细信息可参考IntPtr 结构 (System) | Microsoft Docs
总结

(1)ArrayList相较于数组而言具有更高的灵活性空间利用性,但整体的性能不如泛型集合List。
(2)类ArrayList旨在保存对象的异类集合,因此其通常不能保证排序的有序性BinarySearch()方法的准确性,这也是整体性能不如泛型集合的主要原因。
(3)由于类ArrayList内部存在一个记录版本version的整型变量,因此其可能存在操作的上限次数。若的确存在,则不适用于当下的大量数据处理和交互的环境下。
(4)ArrayList里传入的实例不能访问到实例的属性,需要进行判断和转型,并赋值给中间变量才能被查看,如下图:

原因是,无论原本是声明类型,进入ArrayList后均被转换为object类型(装箱),要转回原本的类型(拆箱)才可以访问到原本属于自己的东西。
【感谢您可以抽出时间阅读到这里,因个人水平有限,可能存在错误,望各位大佬指正,留下宝贵意见,谢谢!】

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4