[C#] 5 种常见的集合误用

56 篇文章 2 订阅

英文原文:https://www.jacksondunstan.com/articles/5145

List<T> 和 Dictionary<TKey, TValue> 等集合类型是 C# 中的基本工具。可悲的是,我不断在一个又一个的代码库中看到它们同样的滥用情况。今天我们将看看最重要的 5 个问题,并学习如何轻松避免它们!

两次字典查找

让我们从一个非常常见且非常浪费的开始。它看起来是这样的:

if (myDictionary.Contains(myKey))
{
    int value = myDictionary[myKey];
    // ... use value
}

这里的问题是 Contains 执行查找来查找键,返回 true,然后索引器(即 myDictionary[myKey])立即执行完全相同的查找以返回相应的值。当实际上只需要一次查找时,会执行两次查找:

int value;
if (myDictionary.TryGetValue(myKey, out value))
{
    // ... use value
}

TryGetValue 进行查找并设置输出参数(如果找到),否则设置该类型的默认值。然后它返回是否找到密钥。如果是,则无需再次查找键,因为值已经获得。

在 C# 7 中,从 Unity 2018.3 开始,我们提供了一项新的语言功能,使这一切变得更加简单:

if (myDictionary.TryGetValue(myKey, out int value))
{
    // ... use value
}

无论哪种方式,我们都将两次查找减少为一次,以快速提升性能。

无初始容量

List<T> 和 Dictionary<TKey, TValue> 等集合类型具有允许我们设置初始容量的构造函数。它们还有默认构造函数,将控制权交给类的实现。我们看一下 Unity 2018.3 中 mscorlib.dll 的反编译,看看 List<T> 做了什么:

private static readonly T[] EmptyArray = new T[0];
 
public List()
{
    _items = EmptyArray;
}

这里根本没有分配。如果意图是永远不向列表添加元素,那么这很好,但通常情况并非如此。当不可避免地添加元素时,将执行以下代码:

public void Add(T item)
{
    if (_size == _items.Length)
    {
        GrowIfNeeded(1);
    }
    _items[_size++] = item;
    _version++;
}
 
private void GrowIfNeeded(int newCount)
{
    int num = _size + newCount;
    if (num > _items.Length)
    {
        Capacity = Math.Max(Math.Max(Capacity * 2, 4), num);
    }
}

添加第一个元素的结果是分配了 4 个元素的数组。如果意图只存储少于 4 个的值,那么内存就会被浪费。如果要储存超过 4 个,则需要大量增加容量。例如,添加 100 个元素意味着为数组分配 4、8、16、32、64 和 128 个元素,最终浪费 28 个元素。如果我们简单地将初始容量 100 传递给构造函数,我们就可以避免除了一次分配之外的所有分配以及所有复制到连续更大的数组的操作。我们并不总是事先知道正确的大小,但是一个好的猜测至少可以节省大部分的重新分配和复制。

对于 Dictionary<TKey, TValue>,这是它的默认构造函数:

public Dictionary()
{
    Init(10, null);
}

这里我们看到初始容量为 10。与 List 不同,将进行初始分配。就像 List 一样,这可能太多会导致浪费,也可能太少会导致许多增长操作并可能导致更多浪费。同样的建议也适用:将初始容量值传递给构造函数。

结构体的装箱

结构是避免 GC 的好方法,但有时它们实际上可能是 GC 的原因。这种情况发生在 List<T> 和 Dictionary<TKey, TValue> 等泛型类中,因为 C# 有时需要装箱。装箱是指需要将结构放入新分配的类中,以便可以在其上调用虚拟方法(例如 Object.Equals)。

举例来说,考虑此测试脚本中的 TestList 和 TestDictionary:

using System.Collections.Generic;
using UnityEngine;
 
class TestScript : MonoBehaviour
{
    struct IntStruct
    {
        public int Value;
    }
 
    bool TestList(List<IntStruct> list)
    {
        return list.Contains(new IntStruct { Value = 1 });
    }
 
    int TestDictionary(Dictionary<IntStruct, int> dict)
    {
        return dict[new IntStruct { Value = 1 }];
    }
 
    void Start()
    {
        const int size = 1000;
        List<IntStruct> list = new List<IntStruct>(size);
        Dictionary<IntStruct, int> dict = new Dictionary<IntStruct, int>(size);
        for (int i = 0; i < size; ++i)
        {
            list.Add(new IntStruct { Value = i });
            dict[new IntStruct { Value = i }] = i;
        }
        TestList(list);
        TestDictionary(dict);
    }
}

TestList 和 TestDictionary 函数似乎不应该导致任何 GC 分配,因为 IntStruct 显然是一个结构。不幸的是,他们这样做:
在这里插入图片描述
List.Contains 需要比较元素以查看是否包含给定元素,它通过使用 ObjectEqualityComparer 来完成此操作,该对象必须将 IntStruct 装箱到对象,以便可以对其调用 Object.Equals。同样,Dictionary 的索引器必须根据给定键的哈希码检查其存储键的哈希码。它还使用 ObjectEqualityComparer 并将 IntStruct 装箱到 Object,以便它可以调用 Object.GetHashCode。

为了避免这种装箱以及随之而来的 GC 分配,请避免在使用结构元素时调用 List 和 Dictionary 上需要装箱的函数。如上所示,Unity 的分析器可用于确定是否属于这种情况。或者,不要使用 struct 元素或使用当前预览版中的替代集合,例如 NativeList 和 NativeHashMap。

使用 LINQ 代替内置方法

System.Linq 提供的 LINQ 扩展方法通常具有与列表和字典等集合类型提供的内置名称类似的名称。例如,考虑这些令人困惑的相似代码行:

int len = list.Count;   // Built-in Count property get
int len = list.Count(); // LINQ extension method named Count()
 
bool HasOne(List<int> list)
{
    return list.Contains(1); // Built-in Contains(T) method
}
 
bool HasOne(IEnumerable<int> list)
{
    return list.Contains(1); // LINQ extension method named Contains(T)
}
 
bool isEmpty = list.Count == 0; // Built-in Count property get
bool isEmpty = list.Empty();    // LINQ extension method named Empty()

人们很容易忽略对 Count 属性的快速调用与对 LINQ 扩展方法的缓慢且引发 GC 的调用之间的区别。可以通过不添加 using System.Linq; 来避免这种情况,但有时需要为文件中对性能不太敏感的其他部分提供该信息。在这种情况下,请考虑将代码拆分为一个用于常规代码的文件和一个用于性能关键代码的文件,以便可以仅将使用添加到常规代码文件中。

传递接口

集合类型实现了很多接口。 List<T> 单独实现 ICollection<T>、IEnumerable<T>、IList<T>、IReadOnlyCollection<T>、IReadOnlyList<T>、ICollection、IEnumerable 和 IList。这意味着由于多态性,采用任何这些接口类型的函数都可以接受 List<T> 参数。

void TakeList(List<int> list)
{
}
 
void TakeInterface(IList<int> list)
{
}
 
List<int> list = new List<int>();
TakeList(list);      // Pass class
TakeInterface(list); // Pass interface (polymorphism)

不幸的是,这通常会导致性能下降,因为它阻止了去虚拟化。因此,对 IList<T> 参数上的 Add 的调用会导致虚拟方法调用,而对 List<T> 参数上的 Add 的相同调用则不会。由于 IL2CPP 差异很大,因此性能差异可能会很大。

void TakeList(List<int> list)
{
    list.Add(1); // Non-virtual method call. Faster.
}
 
void TakeInterface(IList<int> list)
{
    list.Add(1); // Virtual method call. Slower.
}

也许使用 IList<T> 等接口类型而不是 List<T> 等类类型的最坏影响是,它会导致 foreach 循环创建垃圾供 GC 稍后收集。这是因为 foreach 循环归结为对 IList<T> 的 IEnumerator<T> IEnumerable<T>.GetEnumerator() 的调用。 IEnumerator<T> 类型是一个接口,这意味着返回的类型必须是类或装箱的结构。无论哪种方式都会导致 GC 分配。

当类型已知时(如 List<T> 变量的情况),foreach 循环会导致调用 List<T>.Enumerator List<T>.GetEnumerator()。这将返回一个 List<T>.Enumerator,它是一个不涉及 GC 分配的结构。此外,对其方法的调用是虚拟化的,因此循环的每次迭代都可以调用非虚拟的 MoveNext 方法和 Current 属性 get。结果是循环速度更快,但不影响 GC。

int TakeList(List<int> list)
{
    int sum = 0;
    foreach (int x in list) // Calls GetEnumerator(). Returns struct. No GC.
    {
        sum += x;
    }
    return sum;
}
 
int TakeInterface(IList<int> list)
{
    int sum = 0;
    foreach (int x in list) // Calls GetEnumerator(). Returns class. GC alloc!
    {
        sum += x;
    }
    return sum;
}

不幸的是,一些人的建议是有意使用尽可能不专业的接口。通常是 IList<T>、IDictionary<TKey, TValue>,甚至是 IEnumerable<T>。这将触发所有的去虚拟化缺点,并且还会阻止使用 List<T> 提供的专用方法,这些方法与等效的 LINQ 函数完成相同的工作,但速度更快,并且通常不需要任何 GC 分配。支持这种抽象的主要论点是,它将代码与传入的 IEnumerable<T> 的具体实现分离。

虽然这在某种程度上是正确的,但它付出了巨大的代价。考虑到游戏的重要性,值得考虑权衡。游戏是否可能实际上使用 IList<T> 或 IDictionary<TKey, TValue> 的不同实现?将 List<T> 类型切换为 MyList<T> 类型来修复编译器错误需要做多少工作?通常答案是“不太可能”和“几分钟”,这导致我们决定避免通过接口,特别是考虑到去虚拟化的效率增益。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值