集合(动态数组)的原理及自己写个简单的集合类
上篇博文跟大家一起讨论了索引器,这次就想自己写个集合并剖析其中的原理!
为了知道集合的原理,首先,我们打开反编译工具“Reflector”,找到集合类ArrayList,看到以下字段
主要注意画红色下划线的几个字段。其他不涉及到今天要讲的核心可以跳过不看。
_defaultCapacit:看字段名似乎是默认容量的意思,推测其意思为集合的初始默认容量为4
Items:object数组items,根据此字段名和类型,我大胆推测集合的本质就是个object数组,所以才可以存任意类型的数据
Size:看字段名的意思似乎与这个数组的长度有关,可是不是已经有所谓的默认容量了么?那size到底是什么呢?
emptyArray:是用来干什么的呢?看字段名似乎是说空数组的意思,那具体起什么作用呢?
为了验证我的推断以及带着种种疑问,我们继续往下看,先看构造函数
首先是静态构造函数,静态构造函数是当程序一访问到此类时即调用执行的,我们看看里面有什么
点开后,我们发现,其实就是给之前声明的字段emptyArray声明为长度为0的objec数组,现在看起来似乎还是没什么用,我们继续看下一个构造函数
这个是参数为空的构造函数,也即声明时不添加任何参数,里面做的事是将object数组_items的引用变为长度为0的object数组的堆空间地址。这下我们似乎就知道了,微软这么做的意思是为了防止当用户没给集合赋初始值然后用到集合进行某操作时会报引用为null的错误(私以为,这步有点多余,不如直接在构造函数里直接给_items赋值为一个长度为0的object数组即可,那样就没必要专门写个构造函数以及多一个字段了)
好了,搞清楚前两个构造函数的关系跟用处后,我们的疑惑还是没得到解答,所以我们继续看下一个重载的构造函数
看访问修饰符我们可以看到,只在它当前的程序集里有效,也就是说,我们是调用不到的,而且里面没任何内容,具体我也不清楚是想用来干嘛,但是看trash这个变量名,我猜测是不是为了给.net内部的GC(垃圾回收机制使用的),不管它,我们继续看下一个
看到这,我们似乎感觉看到点眉目了,这个构造函数是传入任意实现了ICollection接口类型的对象c进来,也即系统中的任意数组、集合都可以传进来,然后,我们首先判断传入的这个数组或集合c是否为空引用(null),如果为null则直接抛出异常,否则将传入的对象c的Count属性(长度)赋值给一个count变量,然后判断count是否等于0,如果等于0,则将object数组_items赋值为长度为0的数组,如果count不为0,则声明一个长度为count的object的数组,并把引用交给_items,然后调用这个集合类中定义的AddRange方法,把传入的对象作为参数传进去。
我们现在看看这个方法里面又干了什么
从上图看出,AddRange方法内又会调用InsertRange方法,传入之前定义的字段size,与传入的对象c。
这时,我们就不由得想问,size是什么了,为什么要传size进去呢?为了解决这个问题,我们回到ArrayList看看有没有对size的封装属性。找到属性那一栏
每个都点一下后,发现就Count这个属性对size的进行了封装
根据我们之前用集合的经验可以知道Count属性就是集合里面实际存了多少个有效元素。到此,我们推测到,有可能是数组里存了多少个有效元素,那么size的值就相应的变多少。
好了,size的问题解决了,我们接着点进去看InsertRange。
先跳过最上面的两个if不看(因为不涉及到我们这次要用的,这个方法主要是为集合中的AddRange方法服务的,所以会有那么些判断,有兴趣的可以自行查看代码,翻看AddRange方法就会明白)。
我们看到首先调用了一个EnsureCapacity(this._size + count);方法,把原数组items实际存的容量与传进来的数组或集合的实际长度相加传进去,点开进去我们看到:
局部变量min就是刚传入的this.size+count,也就是这个变量是用来表示添加完元素后的数组_items的长度,然后先判断当前数组可容纳的总容量(this.items.Length)是否小于min,如果小于,那证明这个数组装不下,我们需要做扩容(即改变数组的长度),那么紧接着是一个三元表达式,用num代表要扩容后的数组长度,然后判断object数组_items的长度是否等于0,如果等于0则把num赋值成前面我们讲过的_defaultCapacity(因为这个字段是常量,常量在C#编译器编译的时候会将使用常量的地方直接替换为常量的值,不会保留声明的常量名,所以我们此处看到的是4,而不是_defaultCapacity),如果不是0则给其扩容为它此时长度的2倍再赋值给num,然后经历两次判断,一、如果扩容后下标超过int的最大值0x7fefffff即(2146435071),那么给num就赋值为int的最大值,或者如果扩容后的数还是小于要传入的数组或集合的长度,那么把num赋值为要传入的数组或集合的长度。最后,把num赋值给这个集合的Capacity属性,此时,我们再点开看Capacity属性里面又有什么
首先看这个属性取值是取的这个集合对象中object数组_items的长度,也即这个数组实际能存多少个元素(与Count(size)不同,Count(size)是表示有效的元素个数)
然后再看赋值,首先判断传入的值value(EnsureCapacity方法里的num),是不是小于实际容量size,如果小则抛出异常(下面的代码也不会再执行),否则,此时set传入的value一定是大于或者等于items数组此时的长度的,然后再判断要传入的value是否不等于现在的数组长度(如果是相等就没必要再改,所以也没做任何操作),如果不等的话,那证明value一定是大于_items的长度,并且value也一定大于0(根据上面讲的传进来的num推断)那么在里面首先声明一个新的object数组,长度是传进来的value(也即上面的num),然后再判断这个集合类中的size字段是不是大于0,因为此时我们并未给size赋值,所以不成立,那么不会走括号里的那段,而是直接把刚刚创建的以value为长度的数组的引用赋值给当前集合对象中object数组_items,到此,EnsureCapacity(this._size + count);方法执行完了,我们接下来回到InsertRange方法继续往下面看
首先判断index(之前传进来的size)是否小于size(看到这时肯定很多读者疑问为什么有这么个判断,那是因为这个方法并不仅仅在构造函数里被调用,在其他诸如AddRange方法里也有被调用,所以才会有这么多判断,用来区分是构造函数还是AddRange等逻辑)
判断完后无论结果如何,都会创建一个新的长度为count的object数组array,然后调用传进来的集合或者数组c的CopyTo方法,把元素全部添加到array里面,然后再调用array的CopyTo方法,从_items数组的下表为index处位置开始添加,添加到_items数组,此时,这个集合类被实例化时通过构造函数,就已经有元素在里面了。然后_size属性也相应的加上刚添加的元素长度,得到现有的实际长度,这再一次证明_size字段就是用来表示这个动态数组(集合)里实际存了多少个有效元素的。
至此,我们可以总结出,集合的本质其实也就只是一个object数组_items,初始长度为0,如果是第一次为此集合添加元素时,如果要添加的元素不超过4个,则创建一个长度为4的新object数组,否则创建一个长度等于要添加的元素的个数的数组,最终都是把此引用赋值给之前的object数组_items,然后再把添加进来的元素加入到此时最新长度的object数组_items中。
第二次及第二次以后的为此集合添加数据的大体原理与此差不多,只是当此集合的当时最大容量小于要添加的元素后最终容量时,是创建一个原数组的最大容量2倍的数组!
再再精简一下总结:集合的本质就是object数组,初始长度为0,第一次为此数组赋值会变为长度为4的数组,当以后每次此数组长度无法满足要新添加数据后的容量时,都会变成长度为其原来长度的2倍的数组!
最后一个构造函数只是在声明时规定好这个集合内的数据初始长度而已,不多解释,看源码
构造函数我们已经看完了,此时有个疑问,如果我们声明集合时没有给构造函数赋值时又是怎样的机制呢?其实跟上面差不多,第一次赋值时集合内是4个长度的数组,当此集合内的数组无法装下新的要添加的数据时会乘以此集合数组长度的2倍再接收,不信?我们看源码。
这是add方法,跟上面的构造函数里的insertRange方法差不多,其中EnsureCapacity就是上边构造函数里用到过的,这里就不再详细解释。
至此,我们把集合(动态数组)的原理本质算是大体弄清楚了,接下来,我们结合上一篇博文讲过的索引器,自己也写个简单的集合,实现它一些基本功能
public class MyArrayList { //实际存储元素的数组 object[] _items = new object[4]; //这个集合的有效元素数 int _size = 0; //有效元素的封装属性,供外界调用,只读 public int Count { get { return _size; } } //索引器,通过下标访问 public object this[int index] { get { return this._items[index]; } set { this._items[index] = value; } } //无参的构造函数 public MyArrayList() { } //为了简易与便于理解,就用object数组代替 public MyArrayList(object[] array) { _items = array; } //我们所写的集合的Add方法 public void Add(object value) { //如果数组的有效元素个数等于这个数组的总长度时代表这个集合内的数组已经无法再装新数据,所以需要扩容 if (this._items.Length == this._size) { //创建个临时数组,长度为集合数组长度的2倍 object[] arrNew = new object[this._items.Length * 2]; //把原数据全部添加到临时数组 this._items.CopyTo(arrNew, 0); //再把临时数组的引用给集合内的数组,此时就完成扩容目的 this._items = arrNew; } //把传进来的值加进集合内的数组 this._items[this._size] = value; //相应地,有效元素数也得自增一 this._size++; } }
我们再在主程序里实例化并调用
static void Main(string[] args) { //声明此集合 MyArrayList myarr = new MyArrayList(); //调用添加方法 myarr.Add(1); myarr.Add(2); myarr.Add(3); myarr.Add(4); myarr.Add(5); //遍历此集合,看能否打印以及存值进去 for (int i = 0; i < myarr.Count; i++) { Console.WriteLine(myarr[i]); } }
根据代码,我们可以看到集合的基本功能是可以实现了,但是有个问题,它能不能被foreach遍历呢?答案是不同的,如果用foreach来遍历,此时编译器会报错,这是为什么呢?等下篇博文,我会继续解析foreach背后的原理,让我们写的集合类也能被foreach遍历