C# 集合类型深入
大约 19 分钟约 5620 字
C# 集合类型深入
简介
C# 提供丰富的集合类型:List、Dictionary、HashSet、Queue、Stack、ConcurrentDictionary 等。理解各集合的内部实现、时间复杂度和适用场景,有助于选择最优数据结构。集合是 .NET 中最基础的数据结构,几乎所有业务代码都会频繁使用它们。然而,选择不当的集合类型或忽略其内部行为,往往是性能问题的根源。
本文将从内部实现原理、时间复杂度分析、生产环境最佳实践、常见陷阱和进阶模式五个维度,全面深入地讲解 C# 集合体系。
特点
集合体系总览
ICollection 接口层次
// .NET 集合接口继承体系
// IEnumerable<T> — 可枚举(foreach 支持)
// ICollection<T> — 添加/删除/计数
// IList<T> — 索引访问、插入、移除
// ISet<T> — 集合运算(并/交/差/对称差)
// IDictionary<K,V> — 键值对访问
// IReadOnlyCollection<T> — 只读计数
// IReadOnlyList<T> — 只读索引访问
// IReadOnlyDictionary<K,V> — 只读键值对访问
// 选择集合时先问自己三个问题:
// 1. 需要索引访问吗? → List<T> / ImmutableArray<T>
// 2. 需要键值查找吗? → Dictionary<K,V> / FrozenDictionary<K,V>
// 3. 需要去重吗? → HashSet<T> / FrozenSet<T>
// 4. 需要排序吗? → SortedSet<T> / SortedDictionary<K,V>
// 5. 需要线程安全吗? → ConcurrentDictionary / Channel<T>各集合时间复杂度对比
| 集合类型 | 查找 | 插入 | 删除 | 排序遍历 | 内存开销 |
|---|---|---|---|---|---|
| List<T> | O(n) / O(1)索引 | O(1)均摊 / O(n)中间 | O(n) | 无序 | 低 |
| Dictionary<K,V> | O(1)均摊 | O(1)均摊 | O(1)均摊 | 无序 | 中 |
| HashSet<T> | O(1)均摊 | O(1)均摊 | O(1)均摊 | 无序 | 中 |
| SortedSet<T> | O(log n) | O(log n) | O(log n) | 有序 | 高 |
| SortedDictionary<K,V> | O(log n) | O(log n) | O(log n) | 有序 | 高 |
| LinkedList<T> | O(n) | O(1)已知节点 | O(1)已知节点 | 无序 | 高 |
| Queue<T> | O(n) | O(1)入队 | O(1)出队 | 无序 | 低 |
| Stack<T> | O(n) | O(1)压栈 | O(1)弹栈 | 无序 | 低 |
| PriorityQueue<T,E> | O(1)Peek | O(log n)入队 | O(log n)出队 | 按优先级 | 中 |
实现
List 内部机制
// List<T> 基于数组,容量自动翻倍
var list = new List<int>(capacity: 4); // 预分配容量避免扩容
list.Add(1); list.Add(2); list.Add(3); list.Add(4);
list.Add(5); // 触发扩容: 4 → 8,分配新数组并复制
// Capacity vs Count
Console.WriteLine($"容量: {list.Capacity}, 元素数: {list.Count}");
// ==========================================
// 扩容机制的隐藏成本
// ==========================================
// 当 Add 触发扩容时:
// 1. 分配新的两倍大小数组
// 2. 将旧数组元素复制到新数组(Array.Copy)
// 3. 旧数组变为垃圾,等待 GC 回收
// 如果在循环中频繁 Add 且未预分配容量,
// 可能产生大量临时的中间数组,增加 GC 压力
// 反面示例 — 未预分配容量
var badList = new List<int>(); // 默认容量 0
for (int i = 0; i < 100000; i++)
badList.Add(i);
// 扩容序列: 0→4→8→16→32→64→...→131072
// 共发生约 15 次扩容,每次都要复制整个数组
// 正确做法 — 预分配容量
var goodList = new List<int>(100000);
for (int i = 0; i < 100000; i++)
goodList.Add(i);
// 零次扩容
// ==========================================
// Remove 和 RemoveAt 的陷阱
// ==========================================
// Remove/RemoveAt 是 O(n) 操作,需要移动后续元素
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
numbers.RemoveAt(0); // 移除第一个元素,后面 9 个全部前移
// 如果需要在循环中删除元素,倒序遍历或使用 RemoveAll
// 正确的批量删除方式
numbers.RemoveAll(x => x % 2 == 0); // 一次遍历,O(n) 完成所有删除
// 反面示例 — 正序删除会导致跳过元素
var items = new List<int> { 1, 2, 3, 4, 5 };
foreach (var item in items.ToList()) // ToList() 创建副本才安全
{
if (item % 2 == 0) items.Remove(item);
}
// ==========================================
// List 的线程安全问题
// ==========================================
// List<T> 不是线程安全的,并发读写会导致数据损坏
// 不要在多线程中直接使用 List<T>,改用:
// - ConcurrentBag<T>(无序)
// - ConcurrentQueue<T>(FIFO)
// - ImmutableList<T>(不可变)
// - lock 保护(最简单但性能最差)
// ==========================================
// 使用 Collection<T> 替代 List<T> 实现插入/删除钩子
// ==========================================
public class ObservableList<T> : Collection<T>
{
public event Action<T>? ItemAdded;
public event Action<T>? ItemRemoved;
protected override void InsertItem(int index, T item)
{
base.InsertItem(index, item);
ItemAdded?.Invoke(item);
}
protected override void RemoveItem(int index)
{
ItemRemoved?.Invoke(this[index]);
base.RemoveItem(index);
}
}
// ==========================================
// Span<T> 和 List<T> 的配合
// ==========================================
var data = new List<int> { 10, 20, 30, 40, 50 };
// CollectionsMarshal.AsSpan (.NET 5+) 可以零拷贝获取底层 Span
Span<int> span = CollectionsMarshal.AsSpan(data);
span[0] = 100; // 直接修改 List 内部元素,无需索引器
Console.WriteLine(data[0]); // 100
// 注意:如果在操作 Span 期间 List 发生扩容,
// Span 将指向已废弃的旧数组,导致数据损坏!Dictionary 与哈希冲突
// Dictionary 内部: 桶数组 + 链表/红黑树
var dict = new Dictionary<string, int>();
// ==========================================
// 哈希表的内部结构 (.NET 6+)
// ==========================================
// .NET 6 之前: 桶数组(int[]) + entries数组(Entry[])
// .NET 6+: 改进为更紧凑的内存布局
// 每个 Entry 包含: hashCode, key, value, next
// 哈希冲突通过链地址法解决(next 指针链接同桶元素)
// ==========================================
// 自定义比较器 — 忽略大小写
// ==========================================
var caseInsensitive = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
caseInsensitive["Hello"] = 1;
Console.WriteLine(caseInsensitive["hello"]); // 1
// 自定义比较器 — 按属性去重
public class PersonComparer : IEqualityComparer<Person>
{
public bool Equals(Person? x, Person? y) => x?.Id == y?.Id;
public int GetHashCode(Person obj) => obj.Id.GetHashCode();
}
public record Person(int Id, string Name);
var personSet = new HashSet<Person>(new PersonComparer());
// ==========================================
// GetHashCode 的重要性
// ==========================================
// GetHashCode 返回值决定了元素被放到哪个桶
// 如果两个对象的 GetHashCode 相同,就会发生哈希冲突
// 哈希冲突越多,Dictionary 退化为链表查找,性能从 O(1) 退化为 O(n)
// 反面示例 — GetHashCode 实现不当
public class BadKey
{
public int Id { get; set; }
public override int GetHashCode() => 0; // 所有对象哈希值相同!
public override bool Equals(object? obj) => obj is BadKey other && Id == other.Id;
}
// 使用 BadKey 作为 Dictionary 的 key,所有元素都在同一个桶里
// 查找退化为 O(n) 线性查找
// 正确做法 — 使用良好的哈希分布
public class GoodKey
{
public int Id { get; set; }
public string Name { get; set; } = "";
public override int GetHashCode()
{
// 使用 HashCode.Combine 确保良好的哈希分布
return HashCode.Combine(Id, Name);
}
public override bool Equals(object? obj)
=> obj is GoodKey other && Id == other.Id && Name == other.Name;
}
// ==========================================
// TryGetValue 避免两次查找
// ==========================================
if (dict.TryGetValue("key", out var value))
Console.WriteLine(value);
else
dict["key"] = 42; // 只查找一次
// 反面示例 — 两次查找
if (dict.ContainsKey("key")) // 第一次查找
{
value = dict["key"]; // 第二次查找!
}
// GetOrAdd 模式
if (!dict.ContainsKey("key")) dict["key"] = ComputeValue();
// 等价于 (.NET 8+)
dict.TryAdd("key", ComputeValue());
// ==========================================
// Dictionary 的容量与负载因子
// ==========================================
// Dictionary 的默认初始容量为 0(首次插入时分配)
// 可以通过构造函数指定初始容量来避免 rehash
var largeDict = new Dictionary<string, int>(capacity: 10000);
// 当元素数量超过桶数组的某个比例时触发 rehash
// rehash 会重新分配更大的桶数组并重新计算所有元素的位置
// 这是一次 O(n) 的操作
// ==========================================
// foreach Dictionary 的遍历顺序
// ==========================================
// Dictionary 不保证遍历顺序!
// 即使你按顺序插入,遍历顺序也可能不同
// .NET Framework 和 .NET Core 的实现不同,行为也可能不同
var orderedDict = new SortedDictionary<string, int>(StringComparer.Ordinal);
// 如果需要有序遍历,使用 SortedDictionary 或排序后的 KeyValuePair 列表
// ==========================================
// 多线程下的 Dictionary 读取
// ==========================================
// Dictionary 支持并发读取(多个线程同时读)
// 但不支持并发读写(一个线程写的同时另一个线程读/写)
// 并发读写会导致数据损坏、无限循环或异常
// 多线程场景使用 ConcurrentDictionaryHashSet 与集合运算
// ==========================================
// HashSet 基本操作
// ==========================================
var setA = new HashSet<int> { 1, 2, 3, 4, 5 };
var setB = new HashSet<int> { 4, 5, 6, 7, 8 };
// 并集
setA.UnionWith(setB); // setA = {1,2,3,4,5,6,7,8}
// 交集
setA.IntersectWith(setB); // setA = {4,5}
// 差集(A 有而 B 没有)
setA.ExceptWith(setB); // setA = {1,2,3}
// 对称差集(两集合各自独有的元素)
setA.SymmetricExceptWith(setB); // setA = {1,2,3,6,7,8}
// 判断子集/超集
setA.IsSubsetOf(setB); // setA 是否是 setB 的子集
setA.IsSupersetOf(setB); // setA 是否是 setB 的超集
setA.Overlaps(setB); // 是否有交集
// ==========================================
// 使用 HashSet 去重
// ==========================================
var numbers = new[] { 1, 2, 3, 2, 1, 4, 5, 3 };
var unique = new HashSet<int>(numbers); // {1,2,3,4,5}
// 按属性去重
var people = new List<Person>
{
new(1, "Alice"),
new(2, "Bob"),
new(1, "Alice2"), // Id 重复
};
var uniquePeople = new HashSet<Person>(people, new PersonComparer());
Console.WriteLine(uniquePeople.Count); // 2
// ==========================================
// SortedSet — 自动排序的集合
// ==========================================
var sortedSet = new SortedSet<int> { 5, 3, 8, 1, 9 };
// 内部使用红黑树实现
// 遍历时自动排序: 1, 3, 5, 8, 9
// 范围查询
var range = sortedSet.GetViewBetween(3, 8); // {3, 5, 8}
// 自定义排序
var descending = new SortedSet<int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
descending.Add(1); descending.Add(5); descending.Add(3);
// 遍历: 5, 3, 1
// ==========================================
// 优先队列 PriorityQueue (.NET 6+)
// ==========================================
var pq = new PriorityQueue<string, int>();
pq.Enqueue("低优先级任务", 3);
pq.Enqueue("高优先级任务", 1);
pq.Enqueue("中优先级任务", 2);
while (pq.TryDequeue(out var task, out var priority))
{
Console.WriteLine($"优先级 {priority}: {task}");
}
// 输出:
// 优先级 1: 高优先级任务
// 优先级 2: 中优先级任务
// 优先级 3: 低优先级任务
// 实际应用 — 任务调度器
public class TaskScheduler
{
private readonly PriorityQueue<Action, int> _queue = new();
public void Schedule(Action action, int priority = 0)
=> _queue.Enqueue(action, priority);
public void RunNext()
{
if (_queue.TryDequeue(out var action, out _))
action();
}
public int PendingCount => _queue.Count;
}LinkedList 与特殊集合
// ==========================================
// LinkedList<T> — 双向链表
// ==========================================
var linkedList = new LinkedList<int>();
linkedList.AddLast(1);
linkedList.AddLast(2);
linkedList.AddLast(3);
// O(1) 已知节点的插入/删除
var node = linkedList.Find(2); // O(n) 查找
linkedList.AddBefore(node, 10); // O(1) 插入
linkedList.Remove(node); // O(1) 删除
// 适用场景:频繁在已知位置插入/删除
// 不适用场景:需要索引访问(O(n))
// ==========================================
// Queue<T> — FIFO 队列
// ==========================================
var queue = new Queue<int>();
queue.Enqueue(1);
queue.Enqueue(2);
queue.Enqueue(3);
queue.Dequeue(); // 1
queue.Peek(); // 2(不移除)
// 实际应用 — BFS 广度优先搜索
public class TreeNode
{
public int Value { get; set; }
public List<TreeNode> Children { get; } = new();
}
public static List<int> Bfs(TreeNode root)
{
var result = new List<int>();
var queue = new Queue<TreeNode>();
queue.Enqueue(root);
while (queue.Count > 0)
{
var current = queue.Dequeue();
result.Add(current.Value);
foreach (var child in current.Children)
queue.Enqueue(child);
}
return result;
}
// ==========================================
// Stack<T> — LIFO 栈
// ==========================================
var stack = new Stack<int>();
stack.Push(1);
stack.Push(2);
stack.Push(3);
stack.Pop(); // 3
stack.Peek(); // 2(不移除)
// 实际应用 — 括号匹配检测
public static bool IsBalanced(string expression)
{
var stack = new Stack<char>();
var pairs = new Dictionary<char, char>
{
['('] = ')', ['['] = ']', ['{'] = '}'
};
foreach (var ch in expression)
{
if (pairs.ContainsKey(ch))
stack.Push(ch);
else if (pairs.ContainsValue(ch))
{
if (stack.Count == 0) return false;
var open = stack.Pop();
if (pairs[open] != ch) return false;
}
}
return stack.Count == 0;
}
// ==========================================
// ReadOnlyCollection / ReadOnlyDictionary
// ==========================================
// 返回只读视图防止外部修改
public class UserService
{
private readonly List<string> _users = new();
public IReadOnlyList<string> GetAllUsers() => _users.AsReadOnly();
// 或者用 .NET 5+ 的方式
public IReadOnlyList<string> GetAllUsers2() => _users;
// List<T> 实现了 IReadOnlyList<T>,但调用者可以强制转换回来修改!
// AsReadOnly() 创建的 ReadOnlyCollection<T> 包装器更安全
}并发集合
// ==========================================
// ConcurrentDictionary — 线程安全字典
// ==========================================
var concurrent = new ConcurrentDictionary<string, int>();
// 原子操作
concurrent.TryAdd("count", 0);
concurrent.AddOrUpdate("count", 1, (_, old) => old + 1);
concurrent.GetOrAdd("new_key", _ => ExpensiveCompute());
// ==========================================
// GetOrAdd 的陷阱
// ==========================================
// GetOrAdd 的工厂方法可能被多次调用!
// 如果多个线程同时调用 GetOrAdd 且 key 不存在,
// 工厂方法可能被并行执行多次,但只有第一个结果被保留
var cache = new ConcurrentDictionary<string, string>();
// 危险:ExpensiveCompute 可能被调用多次
cache.GetOrAdd("key", k => ExpensiveCompute(k));
// 安全:使用 Lazy<T> 确保只计算一次
cache.GetOrAdd("key", k => new Lazy<string>(() => ExpensiveCompute(k))).Value;
static string ExpensiveCompute(string key)
{
Console.WriteLine($"计算 {key}...");
Thread.Sleep(1000);
return $"结果_{key}";
}
// ==========================================
// ConcurrentBag — 无序线程安全集合
// ==========================================
// 适合生产者-消费者在同一线程的场景
// 内部使用 ThreadLocal 存储,避免竞争
var bag = new ConcurrentBag<int>();
Parallel.For(0, 1000, i => bag.Add(i));
// ==========================================
// ConcurrentQueue — FIFO 线程安全队列
// ==========================================
var queue = new ConcurrentQueue<int>();
for (int i = 0; i < 100; i++) queue.Enqueue(i);
while (queue.TryDequeue(out var item)) { /* 处理 */ }
// ==========================================
// ConcurrentStack — LIFO 线程安全栈
// ==========================================
var stack = new ConcurrentStack<int>();
stack.PushRange(new[] { 1, 2, 3 });
int[] results = new int[3];
stack.TryPopRange(results);
// ==========================================
// BlockingCollection — 阻塞式集合
// ==========================================
var blockingCollection = new BlockingCollection<string>(boundedCapacity: 10);
// 生产者线程
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
blockingCollection.Add($"item_{i}");
blockingCollection.CompleteAdding(); // 通知消费者完成
});
// 消费者线程
Task.Run(() =>
{
foreach (var item in blockingCollection.GetConsumingEnumerable())
Console.WriteLine(item);
});
// ==========================================
// Channel<T> — 高性能异步生产者-消费者
// ==========================================
var channel = Channel.CreateBounded<int>(capacity: 100);
// 生产者
async Task ProduceAsync()
{
for (int i = 0; i < 1000; i++)
await channel.Writer.WriteAsync(i);
channel.Writer.Complete();
}
// 消费者
async Task ConsumeAsync()
{
await foreach (var item in channel.Reader.ReadAllAsync())
Console.WriteLine(item);
}
// BoundedChannelFullMode 控制满时行为
var options = new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait, // 等待(默认)
// FullMode = BoundedChannelFullMode.DropWrite, // 丢弃新写入的
// FullMode = BoundedChannelFullMode.DropNewest, // 丢弃队列中最新的
SingleReader = true, // 优化:只有一个消费者
SingleWriter = true, // 优化:只有一个生产者
};
var optimizedChannel = Channel.CreateBounded<int>(options);
// UnboundedChannel — 无界通道(不推荐,可能导致内存溢出)
var unbounded = Channel.CreateUnbounded<int>();不可变集合
using System.Collections.Immutable;
// ==========================================
// 不可变集合 — 任何修改返回新实例
// ==========================================
var list = ImmutableList.Create(1, 2, 3);
var newList = list.Add(4); // list 不变,newList = [1,2,3,4]
// ==========================================
// Builder 模式 — 批量修改
// ==========================================
var builder = ImmutableList.CreateBuilder<int>();
for (int i = 0; i < 1000; i++) builder.Add(i);
var immutable = builder.ToImmutable();
// ==========================================
// ImmutableDictionary
// ==========================================
var dict = ImmutableDictionary<string, int>.Empty
.Add("a", 1)
.Add("b", 2)
.SetItem("a", 10); // 修改返回新实例
// ==========================================
// ImmutableArray vs ImmutableList
// ==========================================
// ImmutableArray — 底层是真正的数组,内存连续
// 优点:索引访问 O(1),缓存友好
// 缺点:添加/删除是 O(n),需要复制整个数组
var immArray = ImmutableArray.Create(1, 2, 3);
// ImmutableList — 底层是 AVL 树
// 优点:添加/删除 O(log n),共享节点
// 缺点:索引访问 O(log n),缓存不友好
var immList = ImmutableList.Create(1, 2, 3);
// 选择建议:
// 创建后不再修改 → ImmutableArray
// 需要频繁"复制并修改" → ImmutableList
// ==========================================
// FrozenSet / FrozenDictionary (.NET 8+)
// ==========================================
// 创建后不可变,针对读取做了极致优化
// 适合配置、映射表等创建后不变的只读场景
var frozen = dict.ToFrozenDictionary();
var value = frozen["a"]; // 比普通 Dictionary 更快
// FrozenDictionary 的优势:
// 1. 使用针对特定数据分布优化的哈希策略
// 2. 没有 mod 操作的"容量-1"限制
// 3. 缓存行对齐,减少伪共享
// 4. 枚举器无分配(struct enumerator)
// 实际应用 — 配置映射
public static class ErrorCodeMapping
{
private static readonly FrozenDictionary<int, string> ErrorMessages =
new Dictionary<int, string>
{
[404] = "资源未找到",
[500] = "服务器内部错误",
[403] = "无权限访问",
[400] = "请求参数错误",
}.ToFrozenDictionary();
public static string GetMessage(int code)
=> ErrorMessages.GetValueOrDefault(code, "未知错误");
}
// ==========================================
// FrozenSet (.NET 8+)
// ==========================================
var frozenSet = new[] { "GET", "POST", "PUT", "DELETE" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public static bool IsValidHttpMethod(string method)
=> frozenSet.Contains(method);
// ==========================================
// 不可变集合的性能考量
// ==========================================
// 不可变集合每次修改都创建新实例
// 批量修改应使用 Builder 或 ToBuilder()
// 反面示例 — 链式添加
var bad = ImmutableList<int>.Empty;
for (int i = 0; i < 10000; i++)
bad = bad.Add(i); // 每次都创建新实例,O(n log n)
// 正确做法 — 使用 Builder
var goodBuilder = ImmutableList.CreateBuilder<int>();
for (int i = 0; i < 10000; i++)
goodBuilder.Add(i); // O(log n) 每次
var good = goodBuilder.ToImmutable();集合初始化与 LINQ 集成
// ==========================================
// 集合表达式 (.NET 8+ C# 12)
// ==========================================
int[] array = [1, 2, 3, 4, 5];
List<string> names = ["Alice", "Bob", "Charlie"];
Span<int> span = [1, 2, 3]; // 直接初始化 Span
// 空集合
int[] empty = [];
// 展开运算符 (..)
int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second]; // [1,2,3,4,5,6]
// ==========================================
// 集合选择器 — LINQ 的 ToList/ToDictionary/ToHashSet
// ==========================================
var users = new List<User>
{
new("Alice", 30),
new("Bob", 25),
new("Charlie", 35),
};
// ToDictionary — 注意键冲突
var nameToAge = users.ToDictionary(u => u.Name);
// 如果有两个同名用户会抛异常!使用下面的方式避免
var safeDict = users
.GroupBy(u => u.Name)
.ToDictionary(g => g.Key, g => g.First().Age);
// ToLookup — 一键多值
var ageToNames = users.ToLookup(u => u.Age, u => u.Name);
foreach (var group in ageToNames)
Console.WriteLine($"年龄 {group.Key}: {string.Join(", ", group)}");
// ToHashSet — 去重
var uniqueNames = users.Select(u => u.Name).ToHashSet();
// ==========================================
// 使用 IReadOnlyList/IReadOnlyCollection 作为参数类型
// ==========================================
// 好的做法:参数类型使用接口,不暴露具体实现
public void PrintAll(IReadOnlyList<string> items)
{
for (int i = 0; i < items.Count; i++)
Console.WriteLine(items[i]);
}
// 更好的做法:使用 IEnumerable<T> 只表示"可遍历"
public void ProcessItems(IEnumerable<int> items)
{
// 不需要索引时用 IEnumerable,兼容性最好
foreach (var item in items)
Console.WriteLine(item);
}
record User(string Name, int Age);优点
缺点
生产环境最佳实践
集合选择决策树
// 场景 1: 需要按键查找
// 读多写少 → Dictionary<K,V>
// 创建后不变 → FrozenDictionary<K,V> (.NET 8+)
// 多线程读写 → ConcurrentDictionary<K,V>
// 场景 2: 需要有序遍历
// 插入后排序 → SortedSet<T> 或 SortedDictionary<K,V>
// 已有数据排序 → List<T>.Sort() 或 LINQ OrderBy
// 场景 3: 需要去重
// 基本去重 → HashSet<T>
// 创建后不变 → FrozenSet<T> (.NET 8+)
// 场景 4: 生产者-消费者
// 同步阻塞 → BlockingCollection<T>
// 异步非阻塞 → Channel<T>
// 场景 5: 需要索引访问
// 频增删 → List<T>
// 创建后不变 → ImmutableArray<T>
// 频繁复制修改 → ImmutableList<T>
// 场景 6: 优先级处理
// 简单优先级 → PriorityQueue<TElement, TPriority>防御性编程
// 返回空集合而不是 null
public IReadOnlyList<string> GetNames()
{
return _names.Count > 0
? _names
: Array.Empty<string>(); // 不分配内存
}
// 防止外部修改内部集合
private readonly List<string> _items = new();
// 方式 1: AsReadOnly
public IReadOnlyList<string> Items => _items.AsReadOnly();
// 方式 2: 返回副本(调用者可以安全修改)
public List<string> GetItemsCopy() => new List<string>(_items);
// 方式 3: 不可变集合(推荐)
public IImmutableList<string> Items => _items.ToImmutableList();
// 参数校验
public void AddRange(IEnumerable<string> items)
{
ArgumentNullException.ThrowIfNull(items);
// 不要用 .ToList() 一次性物化,因为 items 可能已经是一个 List
// 直接遍历即可
foreach (var item in items)
_items.Add(item);
}总结
List<T> 适合随机访问和尾部添加,Dictionary<K,V> 适合键值查找,HashSet<T> 适合去重和集合运算。多线程使用 ConcurrentDictionary 和 Channel<T>。不可变场景使用 ImmutableDictionary<K,V>。.NET 8+ 的 FrozenDictionary<K,V> 适合创建后不变的只读场景。建议根据操作模式(查找/插入/遍历)和线程安全需求选择集合类型。
选择集合的核心原则:
- 先分析操作模式 — 哪些操作最频繁?读多还是写多?
- 考虑数据规模 — 小数据量时差异不大,大数据量时选择很关键
- 预分配容量 — 已知大小时一定要指定初始容量
- 使用正确的接口 — 参数用
IReadOnlyList<T>而不是List<T> - 避免过早优化 — 先用最简单的集合,有性能问题再换
关键知识点
- 先明确这个主题影响的是语法层、运行时层,还是性能与可维护性层。
- 学习时要同时关注语言表面写法和编译器、JIT、GC 等底层行为。
- 真正有价值的是知道"为什么这样写"和"在什么边界下不能这样写"。
项目落地视角
- 把示例改成最小可运行样例,并观察编译输出、运行结果和异常行为。
- 如果它会进入团队代码规范,最好同步补充命名约定、禁用场景和替代方案。
- 涉及性能结论时,优先用 Benchmark 或实际热点链路验证,而不是凭感觉判断。
常见误区
- 只记语法糖,不知道底层成本。
- 把适用于小样例的写法直接搬到高并发或大对象场景里。
- 忽略框架版本、语言版本和运行时差异,导致结论失真。
- 用 List 做频繁的中间插入/删除(应考虑 LinkedList)。
- 在 foreach 循环中直接修改集合(会抛 InvalidOperationException)。
- 把 Dictionary 当有序集合使用(遍历顺序不保证)。
- 忽略 GetHashCode 的实现质量(导致哈希冲突和性能退化)。
- 多线程中使用非线程安全的集合(导致数据损坏)。
进阶路线
- 继续向源码、IL、JIT 行为和 BCL 实现层深入。
- 把知识点和代码评审、性能诊断、面试复盘结合起来。
- 把同类主题做横向对比,例如值类型与引用类型、迭代器与 async 状态机、反射与 Source Generator。
- 研究 .NET 8 FrozenDictionary 的内部实现原理。
- 了解
Channel<T>的内部无锁队列实现。
适用场景
- 当你准备把《C# 集合类型深入》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合在需要理解语言特性、运行时行为或 API 边界时阅读。
- 当代码开始出现性能瓶颈、可维护性问题或语义歧义时,这类主题会直接影响实现质量。
落地建议
- 先写最小可运行样例,再把结论迁移到真实业务代码。
- 同时记录这个特性的收益、限制和替代方案,避免为了"高级"而使用。
- 涉及内存、并发或序列化时,最好配合调试器或基准测试验证。
排错清单
- 先确认问题属于编译期、运行期还是语义误用。
- 检查是否存在隐式转换、装箱拆箱、闭包捕获或上下文切换等隐藏成本。
- 查看异常栈、日志和最小复现代码,优先排除使用姿势问题。
复盘问题
- 如果把《C# 集合类型深入》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《C# 集合类型深入》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《C# 集合类型深入》最大的收益和代价分别是什么?
