C#
集合、泛型与常用数据结构
按业务语义选择数组、列表、字典和集合,并使用泛型约束建立类型安全的内存仓储。
发布于 2026年7月23日
集合、泛型与常用数据结构
集合不是可以随意互换的容器。数组表达固定长度,List<T> 强调有序增删,Dictionary<TKey,TValue> 建立键到值的映射,HashSet<T> 表达唯一性。泛型则让这些结构在保留静态类型的同时复用算法。
一、学习目标
- 按业务语义选择数组、列表、字典和集合
- 理解容量、数量、索引与枚举的区别
- 使用比较器定义键和值的相等规则
- 编写泛型方法、接口和约束
- 通过只读接口缩小可变边界
- 为任务管理器建立内存仓储
二、数组与集合表达式
数组长度固定,适合已知大小、连续索引的数据:
TaskStatus[] allowed =
[
TaskStatus.Todo,
TaskStatus.Done
];
Console.WriteLine(allowed.Length);
集合表达式 [...] 需要目标类型。下面两者含义不同:
int[] ids = [1, 2, 3];
List<int> mutableIds = [1, 2, 3];
数组本身仍可修改元素,只是不能改变长度。对调用方不应修改的输入,可以接收 ReadOnlySpan<T> 或 IReadOnlyList<T>,但要根据生命周期选择:Span<T> 受引用安全规则约束,不应为了“性能感”到处使用。
切片可创建新数组:
int[] firstTwo = ids[..2];
如果只需临时读取窗口,可使用 ids.AsSpan(0, 2) 避免复制,但不要在尚未测量的业务代码中提前复杂化。
三、List:有序可变序列
var tasks = new List<string>
{
"阅读文档",
"编写测试"
};
tasks.Add("构建发布包");
tasks.Remove("阅读文档");
Count 是当前元素数,Capacity 是内部数组可容纳的数量。增长容量通常是摊销成本,不要每次添加都手工调整。
循环时不要改变会影响枚举器的集合:
tasks.RemoveAll(title => title.StartsWith("临时", StringComparison.Ordinal));
需要保留快照时显式复制:
string[] snapshot = tasks.ToArray();
把内部 List<T> 直接返回给调用方,会让调用方绕过服务规则。公开返回 IReadOnlyList<T> 仍不代表底层永远不变;若要求稳定快照,就返回复制后的数组。
四、Dictionary:键值映射
按 ID 查任务适合字典:
var byId = new Dictionary<int, StudyTask>();
byId.Add(task.Id, task);
if (byId.TryGetValue(task.Id, out StudyTask? found))
{
Console.WriteLine(found.Title);
}
查询未知键时优先使用 TryGetValue,不要先 ContainsKey 再索引,这会做两次查找。
字符串键必须明确比较规则:
var commands = new Dictionary<string, Action>(
StringComparer.OrdinalIgnoreCase)
{
["list"] = ListTasks,
["add"] = AddTask
};
面向协议、命令和配置键通常使用序数比较;自然语言搜索和排序才考虑文化规则。
字典键在存活期间不能改变影响哈希码和相等性的字段。使用可变对象作为键,很容易让已经存入的条目无法再次找到。
五、HashSet:唯一性与集合运算
var requested = new HashSet<string>(
["read", "write"],
StringComparer.Ordinal);
var granted = new HashSet<string>(
["read"],
StringComparer.Ordinal);
requested.ExceptWith(granted);
Console.WriteLine(string.Join(", ", requested)); // write
HashSet<T> 适合成员测试、去重、交集和差集,不保证适合用户界面的稳定展示顺序。需要排序时显式调用 OrderBy 并物化结果。
同一业务集合应使用一致的比较器。两个集合如果采用不同大小写规则,集合运算可能产生难以解释的结果。
六、队列与栈
同步任务按进入顺序处理,可使用 Queue<T>:
var pending = new Queue<int>();
pending.Enqueue(10);
pending.Enqueue(11);
int nextId = pending.Dequeue();
撤销记录常用 Stack<T>:
var history = new Stack<string>();
history.Push("done:10");
string latest = history.Pop();
普通集合不是线程安全容器。多线程生产消费不要仅把 Queue<T> 换成 ConcurrentQueue<T> 就结束设计,还需要明确等待、完成、取消和背压;异步流水线通常考虑 Channel<T>。
七、泛型方法
static T Require<T>(T? value, string name) where T : class
{
return value ?? throw new ArgumentNullException(name);
}
泛型在编译期保留类型关系,不需要把值转换成 object 再拆箱。
查找 ID 的通用方法:
static TEntity? FindById<TEntity>(
IEnumerable<TEntity> source,
int id,
Func<TEntity, int> idSelector)
{
foreach (TEntity item in source)
{
if (idSelector(item) == id)
{
return item;
}
}
return default;
}
委托参数把“如何取得 ID”交给调用方,而算法保留遍历控制。若只有一个领域实体使用,普通方法可能更清晰,不要为了泛型而泛型。
八、约束表达能力
public interface IEntity
{
int Id { get; }
}
public sealed class InMemoryRepository<TEntity>
where TEntity : class, IEntity
{
private readonly Dictionary<int, TEntity> _items = [];
public void Add(TEntity entity)
{
if (!_items.TryAdd(entity.Id, entity))
{
throw new InvalidOperationException($"ID {entity.Id} 已存在");
}
}
public TEntity? Find(int id) =>
_items.GetValueOrDefault(id);
}
约束告诉编译器实现可以安全调用哪些成员,也向使用者表达允许的类型。常见约束包括 class、struct、notnull、基类、接口和 new()。
Dictionary<TKey,TValue> 的键要求 TKey : notnull,因为空键无法形成可靠映射。自定义泛型容器也应让约束反映真实不变量。
九、内存任务仓储
public sealed class InMemoryTaskRepository
{
private readonly Dictionary<int, StudyTask> _tasks = [];
public void Add(StudyTask task)
{
if (!_tasks.TryAdd(task.Id, task))
{
throw new InvalidOperationException("任务 ID 已存在");
}
}
public StudyTask? Find(int id) =>
_tasks.GetValueOrDefault(id);
public IReadOnlyList<StudyTask> List() =>
_tasks.Values.OrderBy(task => task.Id).ToArray();
}
仓储内部按 ID 高效查找,对外返回排序快照。存储结构和展示顺序是两个不同决定。
十、常见错误
返回内部可变集合
调用方可绕过验证增删元素。返回快照,或只暴露完成用例所需的方法。
使用可变对象作为字典键
字段变化后哈希桶位置不再匹配。键应不可变,并稳定实现相等性。
假设 HashSet 保持输入顺序
实现细节不是公开契约。展示前显式排序。
用 List<object> 保存同类业务对象
这会丢失类型信息并引入转换。使用 List<StudyTask> 或建立真正的共同接口。
十一、练习与自测
练习:
- 用字典实现添加、查找和删除任务,并拒绝重复 ID。
- 返回按 ID 排序的任务快照,证明调用方不能修改仓储。
- 用
HashSet<string>计算本地与远端任务标签差集。 - 为
InMemoryRepository<TEntity>添加Remove和List。
自测:
Count和Capacity有何区别?- 为什么字符串字典应显式选择比较器?
IReadOnlyList<T>是否保证底层对象不会变化?- 泛型约束如何同时帮助实现者与调用者?
十二、官方资料
上一篇:语法、类型系统与内存语义 | 下一篇:控制流、方法与参数设计