在.NET编程中,经常需要在List数组T[]之间做出选择。List提供了许多便利的操作,比如动态扩容、添加和删除元素等,但这些便利性是以一定的性能开销为代价的。如果追求极致的性能,可能会考虑使用数组T[]。但是,数组的固定大小又限制了其灵活性。本文将探讨List和数组的性能差异,并介绍一种优化方案InternalList

List内存开销

List需要至少两次堆分配:一次用于List对象本身,另一次用于支持List的数组。每个.NET对象都有一个两字的头部,而List有四个字段:

private T[] _items; private int _size; private object _syncRoot; // 不调用SyncRoot则不使用 private int _version;

因此,List比数组多需要六个额外的字(在32位进程中是24个字节,在64位进程中可能是40个字节)。虽然这看起来不多,但在使用成千上万个小列表的应用中,这种开销会累积起来。

List的性能问题

List还必须执行额外的范围检查。例如,索引器看起来像这样:

public T this[int index] { get { if((uint)index >= (uint)_size) ThrowHelper.ThrowArgumentOutOfRangeException(); return _items[index]; } set { if((uint)index >= (uint)_size) ThrowHelper.ThrowArgumentOutOfRangeException(); _items[index] = value; _version++; } }

注意,_items[index]操作隐含了一个范围检查:CLR在实际读取或写入数组之前执行了一个等效于(uint)index < (uint)_items.Length的检查。因此,这里实际上有两个范围检查。JIT通常不知道_size < _items.Length,因此它不能基于第一个检查的结果来消除第二个检查。

数组T[]的局限性

如果使用普通的数组,不能简单地“添加”或“删除”元素,因为数组有一个固定的大小。因此,如果决定使用普通数组,但需要添加、删除或(天哪!)插入元素,最终可能会重新实现List。将有一个数组变量和一个计数变量:

private T[] _array; private int _count;

然后可能会编写一堆代码来执行List已经完成的操作,比如插入:

static T[] Insert(int index, T item, T[] array, ref int count) { Debug.Assert((uint)index <= (uint)count); if(count == array.Length) { int newCap = array.Length * 2; array = CopyToNewArray(array, count, newCap); } for(int i = count; i > index; i--) array[i] = array[i - 1]; array[index] = item; count++; return array; }

当然,这条路通向疯狂。幸运的是,永远不需要编写这样的代码:只需使用InternalList即可!

介绍InternalList

InternalList是List的替代品,定义如下:

[Serializable] public struct InternalList : IListAndListSource, IListRangeMethods, ICloneable> { public static readonly T[] EmptyArray = new T[0]; public static readonly InternalList Empty = new InternalList(0); private T[] _array; private int _count; public InternalList(int capacity) {...} public InternalList(T[] array, int count) { _array=array; _count=count; } public InternalList(IEnumerable items) : this(items.GetEnumerator()) {} public InternalList(IEnumerator items) {} public int Count {...} public int Capacity {...} public void Resize(int newSize, bool allowReduceCapacity = true) {...} public void Add(T item) {...} ... }

为了消除List所需的额外内存,InternalList是一个结构体而不是类;为了获得最大性能,它在不正确的数组索引使用时断言而不是抛出异常,以便在Release构建中(Debug.Assert消失)运行尽可能快。

InternalList还有一个InternalArray属性,它提供了对内部数组的访问。这实际上允许绕过普通List的某些棘手问题。例如,一个普通的List不允许这样做:

List pts; ... // 错误 CS1612: 不能修改 'List.this[int]' 的返回值,因为它不是一个变量 pts[0].X = 5;

但如果pts是一个InternalList,那么可以编写:

pts.InternalArray[0].X = 5;

InternalList还有其他List没有的东西,比如一个Resize()方法(以及Count的等效设置器),以及一个方便的Last属性来获取或设置最后一个项目。

但应该理解的是,InternalList只适用于需要比List更好性能的罕见情况。它确实有重大的缺点:

  • 不能写new InternalList(),因为C#不支持结构体默认构造函数,InternalList需要非空初始化;
  • 方法如Add()、Insert()和Resize()假设_array不为null;
  • 通过值传递这个结构体是危险的,因为对副本结构体的更改可能不会反映在原始列表中。特别是原始列表的_count不会改变,但_array的内容可能会改变。最好不要传递它,但如果必须传递它,请通过引用传递。这也意味着InternalList不应该被任何公共API公开,并且将InternalList存储在另一个集合中(例如Dictionary>)是可以的,但必须小心操作,以避免代码编译但无法按预期工作。

再次强调,当通过值传递InternalList时,_count和_array变量的副本被创建。对这些变量的更改不会影响其他副本,但对_array元素的更改会影响其他副本(顺便说一句,这与D中切片的变异行为类似)。如果想从公共API返回一个内部列表,可以将其转换为IList或IReadOnlyList,但要注意,代码对未来InternalList的更改可能不会被使用IList的客户正确看到,反之亦然。

最后,除了InternalList,还有一个静态类InternalList,它有一些静态方法(CopyToNewArray、Insert、RemoveAt、Move)来帮助管理原始数组。InternalList的大多数方法只是调用InternalList的方法。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485