65.9K
CodeProject 正在变化。 阅读更多。
Home

从零开始的扩展泛型集合

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.64/5 (9投票s)

2010年3月8日

CPOL

17分钟阅读

viewsIcon

27064

downloadIcon

119

从头开始构建一个通用集合类,而不是简单地包装 List 或 ArrayList。

引言

我决定尝试制作一个自定义的通用集合类,并开始研究通用集合类的实现,例如 List<T>ICollection<T> 这样的接口。但随着研究的深入,我发现有用的信息很多,但最终,在我查看了一个又一个简单和复杂的 CollectionBaseICollection<T> 的实现示例时,一个问题浮现在脑海中:它们的核心都只是在内部包装了一个 ArrayListList<T>,这在我看来,违背了实现一个基础类或接口的初衷。如果你能写一个成功包装 List<T> 的类,为什么还要费力去实现 ICollectionIList 呢,如果你可以直接在你的类里放一个 List<T> 呢?如果我不想包装一个比我考虑的更基础的接口更高级的现有结构呢?有什么选择吗?

背景

最终,我有了自己的集合类,我认为即使在 3.0 时代它仍然很有用 - HashSet<T> 是一个快速的唯一值列表,在 Contains 检查方面可以胜过我的集合,但它依赖于唯一性且无法排序,而 List<T> 在排序方面比我的集合稍快,但快不了多少。两者都没有原生提供一个单值排序的唯一值列表。(请参阅最后的性能测试以获取实际数字。)

即使通过扩展 List<T> 来添加 Unique 函数会使其过时,我认为知道这一点仍然很有价值。我以前从未将集合分解到这个层面,并学到了一些新概念。

开始编码

我开始问的最基本的问题就是:

给定一个类 A,如何实现 A[index]? 很快就有了使用内部数组的直接答案,但最明显的缺点是静态大小,因为我们显然需要一个动态的 Add 选项,该选项不会在每次 Add 时都重新创建数组。我立即看到的、导致我编写了过多代码的、不太明显的缺点是:它完全违背了我的意愿,即它实现了一个预定义的集合类 (Array),即使它比 List<T> 及其类似物要简单得多。 11

我最终的答案是 **嵌套类**。嵌套类就是一个在你的类内部声明的类,在这种情况下,我声明了一个具有泛型类型属性的嵌套类,并且可以将该泛型类型值传递给嵌套类中的构造函数。在这段代码中,我们既看到了一个带有“泛型”值类型的类声明,也看到了一个承载相同 T 类型的嵌套类。请注意,我没有实现任何继承,它们直接从 System.Object 派生。

public class basicList<T> 
{
    //basicList constructors
    //...
    private class basicItem
    {
        private T _data;
        public T Value { get { return _data; } set { _data = value; } }
        public basicItem(T t) { _data = t; }
    }
}

请注意,嵌套类是 private 的,但构造函数/属性是 public 的 - 如果你有一个公共嵌套类,它可以在父类外部访问,但由于它是私有的,只有父类才能访问它;但是,你的父类需要使用的方法(包括构造函数)**不能**是 private 的。

现在,我们需要一个父类 basicList 上的方法来创建 basicItem,如下所示

public class basicList<T> 
{
  public Add(T t) { basicItem newItem = new basicItem(t);}
  //basicItemClass
  //...
}

你可能在说,我们创建了一个带有数据的 basicItem 很好,但一旦离开 Add,我们如何找到它呢?

**答案:我们需要实现某种形式的索引。** 这可能是更有常识或比我更忙的人会选择实现 ListArrayList 的地方,因为当你想到“我可以用什么来存储值的集合并轻松地通过索引访问?”时,它们很容易就会浮现在脑海中。但是,这个项目的一部分是 **不实现其他集合类**,部分原因是我想要直接访问数据,而不是仅仅通过另一个集合,部分原因也是我当初开始这个项目的整个目的。

那么,用一个包含 T 数据的 basicItem 对象集合,我该如何为索引跟踪它们呢?答案就是让我最终前进的动力:每个 basicItem 都需要一个指向另一个 basicItem 的引用属性,我们需要跟踪对象的数量,并且父对象需要保留一个指向其中一个对象的引用。这个引用链将使类不被垃圾回收,并提供简单的枚举实现。出于性能原因,我选择了向前或向后移动的能力,所以我需要跟踪

  • basicList:
    • 指向第一个 basicItem 的引用
    • 指向最后一个 basicItem 的引用
    • 当前项目数
  • basicItem:
    • 指向前一个 basicItem 的引用
    • 指向下一个 basicItem 的引用

当在基础的 Add 函数中构造一个 basicItem 时,如果它不是第一个,它可以知道前一个项目,但不知道下一个,因为在基础的 Add 中,新项目被追加,后面没有东西。

新的 basicItem 构造函数和 basicList 构造函数,以及 Add 方法

public class basicList<T> 
{
    private basicItem _firstItem, _lastItem;
    private int _count;
    
    public basicList()
    {
        _firstItem = null;
        _lastItem = null;
        _count = 0;
    }
    
    public void Add(T t)
    {
        basicItem n = new basicItem(t, _lastItem);
        if (_count == 0) { _firstItem = n; } //store first node
        else { _lastItem.Next = n; }
        //not first, put reference to previous last

        _lastItem = n;
        _count++;
    }
    
    private class basicItem
    {
        //next and previous as from a zero-based perspective
        private basicItem _prev, _next;
        private T _data;
        //properties
        public basicItem Previous { get { return _prev; } set { _prev = value; } }
        public basicItem Next { get { return _next; } set { _next = value; } }
        public T Value { get { return _data; } set { _data = value; } }
        //constructors
        public basicItem(T t) : this(t, null) { }
        public basicItem(T t, basicItem prev)
        {
            _prev = prev;
            _data = t;
        }
    }
}

现在我们开始有进展了!当调用 basicList.Add() 并传入一个值时,它首先调用 basicItem 构造函数,并将给定值和指向 _lastItem 的链接存储在 Previous 中。请注意,如果它是集合中的第一个项目,它会从 basicList 构造函数获得一个 null 引用,如果不是,则前一个 _lastItem 会获得一个指向新的 basicItemNext 链接。默认情况下,通过 Add 添加的项目是追加的,因此创建的项目始终是 _lastItem_count 增加 1,如果它是第一个项目,它也会被放入 _firstItem。此系统还具有一个 **优点**,即不硬编码 basicItem 中的索引,而是通过使其相对,我们可以通过简单地更改 basicItem 的链接来任意地移动/删除/添加。

**现在来实现索引!** 有了我们现有的东西,我们知道 _firstItem 且其索引始终为 0,我们有 _lastItem 且我们知道其索引始终为 _count - 1。我们可以在这里实现两件事 - 一是泛型的 IEnumerator<T> 以支持 foreach 操作,二是 this[int index] 以允许直接访问。既然我们要实现 IEnumerator<T>,我们的父类也可以从 IENumerable<T> 继承,而无需额外成本,只需将其 IEnumerator<T> 的实现指向我们正在创建的那个即可。顺便说一句,这是 basicList 类将获得的唯一继承 - 它可能没有增加太多,但我认为最好还是加上。

新代码

public class basicList<T> : IEnumerable<T> 
{
    //snip unchanged basicList constructor 
    //and Add method and basicItem class
    
    public IEnumerator<T> GetEnumerator()
    {
        basicItem current = _lastItem;
        while (current != null)
        {
            yield return current.Value;
            current = current.Previous;
        }
    }
    
    IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
    //IENumerable<T> implementation

    public T this[int index]
    {
        get
        {
            int curIndex = 0;
            basicItem curItem = _firstItem;
            while(curIndex < index)
            {
                curItem = _firstItem.Next;
                curIndex++;
            }
            if(curItem != null) { return curItem.Value;}
            return default(T);
        }
        set
        {
            int curIndex = 0;
            basicItem curItem = _firstItem;
            while(curIndex < index)
            {
                curItem = _firstItem.Next;
                curIndex++;
            }
            if(curItem != null) { curItem.Value = value;}
        }
    }
}

**我们现在拥有一个功能齐全的通用列表!** 我们可以 Add 一个项目,通过 foreach 遍历集合,或者通过遍历我们的集合(从开头或结尾,稍后详述)直接按索引获取/设置值。

扩展列表功能

好的,那么一个可以追加项目、枚举和按索引访问的列表是最基础的列表。当然,我们可以在保留零继承(除了免费的那个)并保持完全通用的同时实现一些其他功能!当然可以!

基本列表功能 - 零继承且完全通用

  • AddRange
  • AddAt
  • RemoveAt
  • Clear
  • Contains

在本节中,我将不重写 AddAt/RemoveAt 的代码,因为我实现了一些尚未介绍的性能增强 - 但基本上,由于索引不是硬编码的,您只需重新连接链接即可。对于 RemoveAt,您获取指定索引处的 basicItem,然后将 PreviousNext 直接链接在一起,然后将项目设置为 null(实际删除)或者如果要在原地重新插入,则将其重新链接到所需位置。对于 AddAt,您获取前一个和后一个项目,并将它们链接到新项目,然后将新项目链接到前一个和后一个。在这两种情况下,都需要检查第一个/最后一个项目是否需要替换,并且需要更新计数。

AddRange 再简单不过了,只需获取一个 T[] 并遍历它们,对每个值调用 Add。对于 Clear,我实际上钩住了构造函数来调用它,但它将所有内容置为 null,将值重置为 0,并遍历集合移除链接,以确保我们不会有一个庞大的链条漂浮在集合不清理的深渊中。此时我们可以实现 Contains,尽管我们仅限于 Object 继承的 Equals

public class basicList<T> : IEnumerable<T> 
{
    public basicList() { this.Clear(); } //moved to Clear function
    
    public void AddRange(T[] t) { for (int i = 0; i < t.Length; i++) { Add(t[i]); } }
    
    public bool Contains(T t)
    {
        foreach (T tVal in this) { if (tVal.Equals(t)) { return true; } }
        return false;
    }
    
    public void Clear()
    {
        if (_firstItem != null)
        {
            basicItem curItem = _firstItem;
            while (curItem.Next != null)
            {
                curItem = curItem.Next;
                basicItem prevItem = curItem.Previous;
                prevItem = null;
            }
        }
        _firstItem = null;
        _lastItem = null;
        _count = 0;
    }
}

这就是一个不受限制的通用 Object 派生无继承列表所能达到的极限。我们只有基本 Object 函数,而没有限制 T。此时,如果我们想要更多,我们可以子类化 basicList,或者只是使用对 T 的最小限制来获得其余想要的功能。

性能增强

我将直接进入实现排序和扩展功能的部分,但不幸的是,这相当复杂,并使用了我将要描述的。与其重写整个代码段使其有意义而不解释这个系统,不如我先解释一下。

我实现的是我称之为 **指针系统**。这是我的列表类能够与预定义的集合类竞争(甚至超越其中一些)的主要原因。我知道指针的含义,但我实际上并没有使用指针。我使用的是对象引用,但为了我自己的清晰起见,我称它们为指针,因为通过获取一个指针,你基本上就获取了它引用的对象。

回想一下,_firstNode_lastNode 基本上是第一个和最后一个节点的引用,它们的位置已知为 0 和(Count - 1)。这个系统维护了更多的这些引用,除了不是固定在第一个和最后一个,它们可以在这些边界内的任何地方自由移动。必然地,这个系统很难跟踪,但它对于执行除追加项目之外的任何操作都至关重要。请记住,**在这个阶段,要获取索引[y],我们必须从 0 开始迭代 0 -> y**。通过确定 y 更靠近结尾还是开头并从那里开始,可以获得一些性能提升,而这正是我最初的实现。但当我们需要逐个遍历集合并在节点之间来回切换时(例如排序操作所需要的)会发生什么?我告诉你 - 在小型集合中,它并不明显,但在大型集合中,它非常明显,因为要到达集合的中间,它总是必须从开头或结尾开始。

在我的实现中,我允许用户选择性地设置使用的指针数量;在大型集合中,更多的指针数量会有所帮助,尽管我所有的测试都使用了默认数量,即 4。

由于本文已经很长了,我将简要解释实现方式,您可以在源代码中看到。

  • 指针存储在两个单独的数组中,一个 basicItem[] _curPointers 和一个 int[] _curPointerIndices
    • _curPointers[] 包含实际的对象引用
    • _curPointerIndices[] 对应于 basicList[index] 到当前节点索引
  • 当调用获取指针的函数时,_getClosestPointer 函数只是返回最策略性指针的索引,然后将其传递给 _getNodeAt_getNodeAt 再将其传递给调用者。
    • 如果没有,它会在零或结尾处创建一个,取决于哪个更靠近 _createNewPointer
    • 如果存在一些,但创建新的指针在零或结尾处会更近
      • 如果有一个空位可以创建一个,就获取它并创建它
      • 如果不是,则替换 _calcPointerWorthLeast 中战略性最差的指针(基于与其他指针以及开头/结尾的距离)。
  • 函数然后获取该索引,并只使用 _getClosestPointer,而获取对象引用的函数使用 _getNodeAt
    • 函数跟踪它们从指针获取的工作对象及其索引,或者只跟踪索引,具体取决于函数。
    • 它们在需要时更新指针/索引,无论是通过直接输入到 _curPointers / _curPointerIndices 还是使用 _updatePointer
    • 如果在使用的过程中添加/删除了任何节点,受影响的指针索引会相应地使用 _movePointers 进行更新。
    • 指针支持反向查找,给定一个 basicItem_getPointerIndex 可以返回指针的索引以进行更新。

增强功能

既然我已经稍微解释了一下,让我们继续进行最后一部分,设置 Sort 和重构 Contains,并启用 Add 上的自动排序。首先,要进行任何除了基本 Object 派生的 Equals(obj) 之外的比较,我们需要最终对我们的 T 添加一些限制,即要求它实现 IComparable<T>,通过将 where T:Comparable<T> 添加到类名。我选择增强当前列表,而不是在此停止类并继承它,然后将请求的继承添加到 T,因为 IComparable<T> 非常容易实现。它只有一个方法 - CompareTo,给定 t1.CompareTo(obj t2),如果 t1 < t2 则返回 -1,如果 t1 = t2 则返回 0,如果 t1 > t2 则返回 1。我通常会在自定义类中通过重定向到特定类型的比较来实现,并避免普通的 Object 技巧。

既然我们可以保证 TIComparable,我们就可以更新 Contains 并实现排序。一个基本的排序例程会很好,但既然是我写的,它就不那么 basic 了。但在那之前,这是更新后的 Contains 方法;回想一下,在此之前,我们只能使用 Object.Equals;现在要快得多。

public bool Contains(T t)
{
    //new and improved
    foreach (T tVal in this) { if (tVal.CompareTo(t) == 0) { return true; } } 
    return false;
}

它现在使用 CompareTo == 0。您还可以做的另一件事是首先快速比较 Object.getHashCode,因为它比 CompareTo 快(仍然比 Equals 快)。但请记住,getHashCode 不是一个万无一失的比较运算符,如果哈希码相等,并不意味着两个对象相等,因此需要第二次检查 CompareTo。我用整数测试过,它显著加快了值检查,但当我使用字符串时,它却减慢了速度 - 我猜想哈希码相等的可能性更大,或者计算字符串的哈希码需要更长时间,而且由于这是一个旨在尽量减少限制的通用 List,我不想设置一个特殊的字符串处理程序、一个特殊的整数处理程序等,所以将其改回最可靠的比较。如果我打算用它处理字符串,我只会覆盖一个新字符串目标类中继承 basicList 的比较。

**开始排序!** 本质上,排序只依赖于几件事 - 你有一个可靠的比较两个值的方法,我们现在有了 CompareTo,并且你在切换时可以跟踪你的位置。后者通过我上面描述的指针系统要复杂得多,但对于排序操作来说非常令人印象深刻。

我这里使用的排序是几个部分的组合。我将发布实际的排序处理代码,尽管其中大部分代码可能难以理解,因为我们没有深入介绍指针系统,并且排序函数本身会调用另一个函数,然后又调用另一个函数。尽管如此,这是当前的 _handleSortCall(公共的 Sort 会查看 _autosort,如果已自动排序,则忽略该调用)。

private void _handleSortCall()
{
    //private method allows public sort to decide if 
    //it wants to actually sort or not and permits 
    //autosort property handler to bypass
    basicItem nEnd = _firstItem, nStart = _firstItem;
    int posEnd = 0; //start is always 0
    //working index so we don't have to enumerate 
    //every one in the sort op function to place it
    int workingIndex = 0;
    while (nEnd.Next != null)
    {//start at the 2nd node and work until there are no more nodes
        basicItem thisNode = nEnd.Next;
        workingIndex++;
        //first check to see if it even needs to be moved
        //since we are moving forward, we just need 
        //to make sure the previous is worth less
        if (thisNode.Value.CompareTo(thisNode.Previous.Value) < 0)
        {
            //remove before looking so we don't find the same value
            _removeNodeAtIndex(workingIndex, ref thisNode);
            int newIndex = _findProperIndexByValue(thisNode.Value, 0);
            _addNodeAtIndex(newIndex, ref thisNode);
            posEnd++;
            workingIndex++;
        }
        else
        {//sort not necessary
            nEnd = thisNode;
            posEnd++;
        }
    }
}

由于这是基本的排序调用,因此没有传递任何参数。下一个更具体的函数是 _findProperIndexByValue,它给定一个值,找出下一个值更大的第一个索引,基本上是一个升序查找器。为了做到这一点,它创建最多 10 个区域,一次检查一个起始值,然后递归,缩小区域,重复直到区域少于 20 个可能性,此时,它调用另一个函数 _findFirstBiggerValueIndex,您大概可以猜到它的功能。函数 _removeNodeAtIndex_addNodeAtIndex 正如您所想的那样,是 AddAtRemoveAt 函数背后的主力,并且对于排序操作至关重要,因为我们基本上是从一个位置移除一个节点并将其放到另一个位置。

由于我们从头开始向前移动,因此前一个工作节点后面的任何值都保证等于或小于当前节点,这意味着对于我们向前移动的每个节点,我们只需检查值是否大于上一个值,如果大于,我们可以继续移动,因为任何差距将由后面的节点回移来覆盖。

结语

如果您读到这里,我为您感到非常自豪。这比我预期的要长得多,我甚至开始缩短它,一旦我们进入了复杂的部分。我确实有一丝希望,尽管有明显的替代方案,但有人会发现这很有用。从零开始构建一个几乎能与已建立的王者匹敌的工具,有一种特殊的自豪感。如果没有,这对我们双方来说都是一次很棒的脑力锻炼。

性能测试!

我对我的小实验与大名鼎鼎的类相比如何感到好奇,所以我进行了一个使用 500K int 集合的实验。我还进行了一个 10K string 集合的实验,但结果在各个方面都非常相似,所以我直到对这个结果满意才搁置它。关于字符串,我只想说的是,我的未排序列表的 Add 时间与所有其他列表完全相同,而它的排序时间与 List<string>ArrayList 完全相同,所以我就不隐藏差的结果了。

用于性能测试的代码(在代码中,加上两个简短的验证,确保最后 50 个和前 50 个是正确的)

for (int x = 500000; x > 0; x--) { list.Add(x); }
list.Sort();
for (int x = 500000; x > 0; x--) { if (!list.Contains(x)) { list.Add(x, null); } }

我为其中每个操作包装了一个小计时器函数,以提供经过的毫秒数,以下是结果

Collection Class Add Test Sort Test Contains Test
basicList !_autoSort 141 ms. 203 ms. 在 10 秒内停止 - 完成 8022 次。估计需要 623 次。
basicList _autoSort 453 ms. 0 ms. 在 10 秒内停止 - 完成 11648 次。估计需要 429 次。
ArrayList 109 ms. 718 ms. 在 10 秒内停止 - 完成 652 次。估计需要 7692 次。
List<int> 31 ms. 78 ms. 在 10 秒内停止 - 完成 805 次。估计需要 6250 次。
Hashset 125 ms. 不适用 765 ms.
Hashtable(int, null) 281 ms. 不适用 968 ms.
SortedList<int, object> added (int, null) 约 1 分钟添加了 100k 不适用 不适用
Dictionary<int, object> added (int, null) 156 ms. 不适用 765 ms.
SortedDictionary<int, object> added (int, null) 703 ms. 0 ms. 781 ms.

我确实使用了 3.0 进行测试,正如您从 HashSet 看到的,但将程序集降级到了 2.0,因为它不需要更多。有趣的是,SortedList 甚至无法开始。未排序列表在 AddSort 方面都输给了通用 List,但正如您所见,它处理了 10 倍多的 Contains,并且排序时间远好于 ArrayListautoSorted basicList 在 10 秒内处理了 50% 更多的 Contains,但对我来说还不足以实现一个唯一的函数。如果我能将其降低到 Dictionary 的排序时间,那么我终于拥有了一个完全可定制的、具有竞争力的唯一值自动排序 Set,它与预定义的 Set 处于同等水平。在我看来,非唯一的未排序和已排序列表与其他列表处于相当的水平。但我有偏见!

未来计划

目前(截至最初撰写时),类中还有一个唯一的属性/公共属性/处理方法,它是注释掉的。您可以实现它,这只需要在 Add/AddAt/AddRange 中添加另一个条件来处理唯一值。我没有实现它的原因不是因为难度,而是因为我对 Contains 检查的性能不满意。由于 Unique 会在每次 Add 时命中 Contains 检查,因此满足我的要求是完全必要的。但当然,我是在 500K 集合上进行性能测试的,而大多数应用程序可能都没问题。

结论

希望您觉得这个练习很有趣。但即使是一小部分,也足以让我对编写它感到满意!

请随时告诉我您发现的错误,我进行了相当彻底的测试,但我确信我没有发现所有错误。

作为注释,如果您习惯于每个函数上的常规 ///文档,我表示歉意 - 我只在我认为必要的地方进行了注释,我不喜欢翻阅数百万页的大部分注释。但是,如果这能获得一些下载,并且有此要求,我可以更新它。我还没有看到是否有人对下载实际代码感兴趣,我感兴趣的是这个概念。

历史

  • 2010/3/8 - 发布此内容!
© . All rights reserved.