英文原文:
https://www.jacksondunstan.com/articles/5145
像 List 和 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 和 Dictionary<TKey, TValue> 这样的集合类型有构造函数允许我们设置初始容量。它们还有默认构造函数,将控制权交给类的实现。我们来看看 Unity 2018.3 中对 mscorlib.dll 的反编译,看看 List 做了什么:
private static readonly T[] EmptyArray = new T[0];
public List()
{
_items = EmptyArray;
}
这里根本没有分配。如果打算永远不向 List 添加元素,这很好,但通常情况并非如此。当不可避免地添加元素时,此代码执行:
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一样,这可能太多导致浪费,或者太少导致许多增长操作和潜在的更多浪费。同样的建议也适用:向构造函数传递一个初始容量值。
Boxing Structs
struct是避免 GC 的好方法,但有时它们实际上可能是 GC 的原因。这发生在 List 和 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 需要比较元素以查看是否包含给定元素,它通过使用必须将 IntStruct 装箱为 Object 的 ObjectEqualityComparer 来执行此操作,以便它可以对其调用 Object.Equals。同样,Dictionary 的索引器必须根据给定键的哈希码检查其存储的键的哈希码。它还使用 ObjectEqualityComparer 并将 IntStruct 装箱到 Object,以便它可以调用 Object.GetHashCode。
为了避免这种装箱和随之而来的 GC 分配,请避免在 List 和 Dictionary 上调用在使用结构元素时需要装箱的函数。如上所示,Unity 的分析器可用于确定是否属于这种情况。或者,不要使用结构元素或使用当前处于预览状态的替代集合,例如 NativeList 和 NativeHashMap。
使用 LINQ 而不是内置方法
System.Linq 提供的 LINQ 扩展方法通常与 List 和 Dictionary 等集合类型提供的内置名称具有相似的名称。例如,考虑这些令人困惑的相似代码行:
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 单独实现 ICollection、IEnumerable、IList、IReadOnlyCollection、IReadOnlyList、ICollection、IEnumerable 和 IList。这意味着由于多态性,采用这些接口类型中的任何一种的函数都可以接受 List 参数。
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 参数上调用 Add 会导致虚拟方法调用,而在 List 参数上调用 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 之类的接口类型而不是 List 之类的类类型的最坏影响可能是,它会导致 foreach 循环创建垃圾,以便 GC 稍后收集。这是因为 foreach 循环归结为对 IList 调用 IEnumerator IEnumerable.GetEnumerator()。 IEnumerator 类型是一个接口,这意味着返回的类型必须是一个类或一个装箱的结构。无论哪种方式都会导致 GC 分配。
当类型已知时,如List变量的情况,foreach循环的结果是调用List.Enumerator List.GetEnumerator()。这将返回一个List.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、IDictionary<TKey, TValue>,甚至是 IEnumerable。这将引发所有的非虚拟化缺点,也会阻止使用List提供的专门方法,这些方法与同等的LINQ函数做同样的工作,但速度更快,而且通常没有任何GC分配。支持这种抽象的主要论点是,它将代码与被传入的IEnumerable的具体实现解耦。
虽然这在某种程度上是正确的,但它需要付出很大的代价。考虑到游戏的重要性,值得考虑权衡。游戏是否可能真的使用 IList 或 IDictionary<TKey, TValue> 的不同实现?将 List 类型切换为 MyList 类型以修复编译器错误需要多少工作?通常答案是“不太可能”和“几分钟”,这会导致决定避免传递接口,特别是考虑到去虚拟化的效率提升。