浏览知识库目录

C#

集合、泛型与常用数据结构

按业务语义选择数组、列表、字典和集合,并使用泛型约束建立类型安全的内存仓储。

集合、泛型与常用数据结构

集合不是可以随意互换的容器。数组表达固定长度,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);
}

约束告诉编译器实现可以安全调用哪些成员,也向使用者表达允许的类型。常见约束包括 classstructnotnull、基类、接口和 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> 或建立真正的共同接口。


十一、练习与自测

练习:

  1. 用字典实现添加、查找和删除任务,并拒绝重复 ID。
  2. 返回按 ID 排序的任务快照,证明调用方不能修改仓储。
  3. HashSet<string> 计算本地与远端任务标签差集。
  4. InMemoryRepository<TEntity> 添加 RemoveList

自测:

  • CountCapacity 有何区别?
  • 为什么字符串字典应显式选择比较器?
  • IReadOnlyList<T> 是否保证底层对象不会变化?
  • 泛型约束如何同时帮助实现者与调用者?

十二、官方资料

上一篇:语法、类型系统与内存语义 | 下一篇:控制流、方法与参数设计