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

当列表和数据长大时

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2015年9月22日

CPOL

6分钟阅读

viewsIcon

16728

downloadIcon

164

本文将深入探讨代码有时会变得缓慢的原因之一。

引言

本文将深入探讨代码有时会变得缓慢的原因之一。关于通用且易于实现的“List”列表的用法,以及当迭代元素变得丰富时其用法,可以通过哪些简单步骤实现多重改进,以及当列表不再足够满足性能要求时您可以做什么。本文将为您提供一个示例项目,该项目将在您自己的计算机上实际执行基准测试,如果您愿意,还可以查看代码实现。

背景

当我发现列表在添加之前使用简单的 lambda 表达式进行存在性检查时,我经常会遇到时间窃取者,但这究竟有多糟糕呢?

        
        if (compareMethod == CompareMethod.LinqAnyBothEquals)
        {
            if (!list.Any(s => s.ID == data.ID && s.Name == data.Name))
                list.Add(data);
        }

以及我们在易于获得的改进和未来发展方面有哪些选择?
我决定进行测量并提供一些建议,当您遇到列表操作突然花费大量时间时,您应该怎么做,以及何时表明您已经超越了列表数据结构,以及如果您确实希望数据数量增长并重新获得性能,下一步该怎么做。

使用代码

示例代码是一个适用于 VS 2015 的控制台应用程序,其中包含一个 program.cs 文件,其中包含所有内容,以及一个包含少量定向到控制台窗口的实用程序的扩展类。
如果您觉得我的解释过于肤浅,或者只是想亲自观看性能测试运行,您应该使用该项目,它运行几分钟即可完成。

测试计划

这些测试将基于我们虚构的 TestData 派生类 Data1 和 Data2 的样本数据。基类包含两个属性:ID 和 Name。
这些类型的对象使用递增的 id 号和随机的 10 位数字名称进行实例化。
创建包含 1000、10000 和 100000 个元素的此类列表。这些列表模拟了通过随机抽取 25% 的元素进行访问,并且对于每个元素,都随机地决定是否将其从主列表中删除并添加到测试集中,确保之间会有一些全扫描。这并非旨在进行完全相同的运行,而是可比的实际运行。但是,数字会因每次测试运行而略有不同,但分布趋势保持不变。

        var sw = new Stopwatch();
        ("Compare method " + compareMethod).ToConsole();
        foreach (var amt in amountsOfData)
        {
            int manyData = amt;
            "".ToConsole();
            GetMeAlistWithManyData(rnd, list, pickLength, manyData);
            "Get 25 % of data, each element delete or not at random from list".ToConsole();
            int aboutTwentyFivePct = (int)(manyData * 0.25);
            var arr = new T[aboutTwentyFivePct];
            for (int i = 0; i < aboutTwentyFivePct; i++)
            {
                int pos = rnd.Next(0, list.Count());
                arr[i] = list[pos];
                if (rnd.Next(0, 1) == 1)
                {
                    list.Remove(arr[i]);
                }
            }


然后,将尝试将测试集中的每个元素添加回列表结构,并首先检查其是否存在。
将进行实验,以增强 Data 类并优化数据结构功能。

Lambda 罪魁祸首

Lambda 太棒了,它几乎可以让你做任何事情,让你快速生成可工作的代码,但它是通过反射来实现的,这需要一点时间,究竟需要多少时间?

      "Testing to add each element, but only if it does not exist".ToConsole();
        sw.Start();
        foreach (T data in arr)
        {
            if (compareMethod == CompareMethod.LinqAnyBothEquals)
            {
                if (!list.Any(s => s.ID == data.ID && s.Name == data.Name))
                    list.Add(data);
            }
            else if (compareMethod == CompareMethod.Contains)
            {
                if (!list.Contains(data))
                    list.Add(data);
            }
            else
            {
                throw new NotImplementedException("This method is not implemented, comparemethod " + compareMethod);
            }
        }
        sw.Stop();

当您处理少于 1000 个元素的数据时,真的不应该太担心使用 Lambda,尽管使用吧!如果它能让您的生活更轻松,我知道它经常能让我的生活更轻松。

rows duration
1000 10 毫秒
10000 1107 毫秒
100000 69143 毫秒

您可以看到,当列表增长时,这些存在性检查变得很昂贵,但嘿!列表有一个 **.Contains** 方法!

Contains 的脆弱性

您可能知道,泛型 List 类型实现了一个 Contains 方法,很容易让人认为您可以使用它来检查列表是否包含该对象。

        for (int i = 0; i < 3; i++)
            {
                Data1 objX = GetNewData(rnd, pickLength);
                list.Add(objX);
            }
        var objValueCopy = new Data1 { ID = list[2].ID, Name = list[2].Name };
        if (list.Contains(objValueCopy)){
            //jump around!               
        } else {
            //No i'm a reference to a new object with identical values!
        }

正如您可能凭借知识或直觉所怀疑的那样,上面的代码中没有跳转,如果没有帮助,Contains 会查找是否已包含该确切对象,而在我们的情况下,这有时才有用。

Contains 是粘土

但是,如果您的 Data 类定义了如何进行比较,您就可以使用 Contains 来以除引用之外的其他方式进行比较,这里 Data2 实现 IEqualityComparer 来定义如何测试该类的相等性。

    public class TestData
    {
        public int ID { get; set; }
        public string Name { get; set; }
    };
    
    // Plain vanilla dataclass with nothing special to help    
    public class Data1 : TestData
    {
    }
    
    /// Implementing IEqualityComparer to help    
    public class Data2 : Data1, IEqualityComparer, IEqualityComparer
    {
        public bool Equals(Data2 x, Data2 y)
        {
            return x.ID == y.ID && x.Name == y.Name;
        }
        public new bool Equals(object x, object y)
        {
            if (x is Data2)
            {
                if (y is Data2)
                {
                    return Equals((Data2)x, (Data2)y);
                }
            }
            return false;
        }
        public int GetHashCode(Data2 obj)
        {
            return (ID.ToString() + Name).GetHashCode();
        }
        public int GetHashCode(object obj)
        {
            if (obj is Data2)
            {
                return (obj as Data2).GetHashCode();
            }
            return obj.GetHashCode();
        }
    }

现在我们可以使用 Contains 来获得结果了

rows duration
1000 1 毫秒
10000 186 毫秒
100000 18330 毫秒

部分结论

要点一:实现 IEqualityComparer 可以显著提高列表中存在性检查的性能!

要点二:改进体现在小型列表中,并且最初效果最大,因为与 100K+ 数据集相比,比较次数最终仍将是一个主要瓶颈。

要点三:当需要进行包含性检查时,列表在处理大量数据时性能不佳,如果数据量增长,请考虑除列表以外的其他方法。

IEqualityComparer 是王道

许多不同的 CLR 类都支持并使用 IEqualityComparer,另一个性能非常高的数据结构是 HashSet。

    sw.Start();
    foreach (T data in arr)
    {
        if (compareMethod == CompareMethod.LinqAnyBothEquals)
        {
            if (!hashSet.Any(s => s.ID == data.ID && s.Name == data.Name))
                hashSet.Add(data);
        }
        else if (compareMethod == CompareMethod.Contains)
        {
            if (!hashSet.Contains(data))
                hashSet.Add(data);
        }
        else
        {
            throw new NotImplementedException("This method is not implemented, comparemethod " + compareMethod);
        }
    }
    sw.Stop();
    
rows duration
1000 0 毫秒
10000 0 毫秒
100000 5 毫秒

实际上速度非常快,不是吗?
但这仅适用于 Data2 类型的数据对象集,Data1 类型仍然会遇到性能问题。

rows duration
1000 12 毫秒
10000 754 毫秒
100000 73748 毫秒

HashSet 还有其他优势

如果我们无法控制数据类,我们是否就无能为力了?
不,您可以在实例化 HashSet 时指定 EqualityComparer,这样事情就会变得不同。

    public class TestDataEqualityComparer : IEqualityComparer
    {
        public bool Equals(TestData x, TestData y)
        {
            return x.ID == y.ID && x.Name == y.Name;
        }
        public int GetHashCode(TestData obj)
        {
            return (obj.ID.ToString() + obj.Name).GetHashCode();
        }
    }

将此比较器的实例传递给构造函数并操作 Data1 对象

rows duration
1000 0 毫秒
10000 1 毫秒
100000 28 毫秒

这几乎一样好了!

部分结论

要点四:是否使用 HashSet 并不重要,如果使用 Linq 进行比较,您的结果一样糟糕。

要点五:考虑为大量数据使用 HashSet,如果您无法控制数据类以包含此类内容,请在构造函数中提供 EqualityComparer。

一点建议

在本文中,我讨论了范围包含性检查。值得考虑的是,如果您不想检查相等性,修改数据类是不明智的。您可以辩称从引用相等性转移到值相等性,但例如,如果您只想查看 ID 是否已存在,例如,因为您想更新对象或其值属性可能已更改,而这并非您的程序流所决定。那么您应该创建一个具有有意义名称的比较器,例如传递一个名为 **TestDataIdentityEqualityComparer** 的东西,这样可以使您的代码易于阅读并避免人们在其他上下文中意外使用 Data 类。最好提供自定义比较器,以避免混淆。

结论

如果您使用的泛型列表包含 100 多个元素,并且使用 lambda 表达式在添加时测试具有相同值的现有对象,请考虑改用 HashSet,如果您要处理的数据类未实现 IEqualityComparer,请记住提供一个 EqualityComparer,您的代码将运行得更快。


至于何时是切换的最佳时机,是在 100 个元素还是 1000 个元素时,这一点尚有争议,但我希望已经证明,即使您因为易用性和涉及的数据量而继续使用列表,通过让您的 Data 类实现 IEqualityComparer,您也可以实现显着的性能提升。

历史

这是本文的第一版

© . All rights reserved.