集合与索引类异常【C# Exception 详解系列(三)】

〇、前言

前文已经列举了全部有哪些常见的异常类型,本文继续介绍下各个类别的异常特点。

本文主要介绍:集合与索引异常 ,主要包括这几种:IndexOutOfRangeException、KeyNotFoundException、ArrayTypeMismatchException、RankException、InvalidOperationException。

点击链接可以查看本系列文章:C# Exception 详解系列。

一、集合与索引类异常总览

异常 谁抛的 一句话原因 首选替代方案
IndexOutOfRangeException 数组、string、Span<T>、Memory<T>、多维数组 下标 <0 或 ≥ Length 判 Length/Count、^ 与范围、ElementAtOrDefault
KeyNotFoundException Dictionary/ConcurrentDictionary/SortedDictionary 的 [key] 键不存在 TryGetValue、GetValueOrDefault(.NET 8+)、TryAdd
ArrayTypeMismatchException 协变数组写入时 运行时实际数组装不下这个类型 别用协变数组,改用 List<T>/IReadOnlyList<T>
RankException Array.Copy/CopyTo/ConstrainedCopy 等 源与目标维度数(Rank)不同 判 Rank、GetLength(d),分清交错数组与多维数组
InvalidOperationException 枚举器版本校验、空集合取元素等 集合被改 / 对象处于错误状态 倒序遍历、快照、RemoveAll、并发集合

这 5 个异常有一个共同点:它们几乎都代表"程序逻辑错了",而不是"用户输入不好"。因此通用策略是"用检查或更安全的 API 把异常消灭在发生之前",而不是在外层 catch。唯一例外是 InvalidOperationException,它有一半场景是状态机/并发问题,需要靠设计来规避。

核心原则:凡是能通过长度判断、Try* API、不可变快照或类型系统(泛型不变性)在编译期/调用期挡住的,就不要留给运行时去抛。

这五个异常里,除了 InvalidOperationException 的一半场景需要靠并发设计解决,其余四个都可以做到"线上零出现"。

二、各个异常简介与简单示例

2.1 IndexOutOfRangeException ------ 越界访问

触发条件 :下标 < 0 或 ≥ Length。

注意它是"数组/连续内存"语义的异常:string[i]、T[]、Span<T>、多维数组都会抛它;而 List<T>[i] 抛的是 ArgumentOutOfRangeException ------这是面试和排障时最容易记混的一点。

复制代码
// ① 切分后想当然地取下标(外部数据最常见)
var parts = "a,b".Split(',');
var third = parts[2];                 // 💥 IndexOutOfRangeException

// ② 差一错误(<=)
int[] a = new int[5];
for (int i = 0; i <= a.Length; i++)   // 💥 i == 5 时
    a[i] = i;

// ③ C# 8 的 ^ 运算符:^0 本身就是非法的
var last = a[^0];                     // 💥 永远是越界,编译不报错

// ④ 多维数组按"扁平思维"访问
int[,] m = new int[3, 4];
var v = m[0, 12];                     // 💥 每维独立校验

// ✅ 正确姿势
if (parts.Length > 2) third = parts[2];          // 明确长度判断
third = parts.ElementAtOrDefault(2) ?? "";       // LINQ,性能略低但安全
var last = a[^1];                                // ^1 才是最后一个
// Span/Memory 场景用 TrySlice / Slice 前先判 Length
if (span.Length >= 3) var chunk = span.Slice(0, 3);

特别注意:

  • 性能层面 :JIT 会把循环内的边界检查"提升"到循环外(bounds check elision),所以手动加****if (i < len) 不会拖慢,反而常能帮 JIT 消除检查;但不要用 unsafe 指针去绕开检查来换性能 ,越界会变成内存破坏,难以复现。
  • 空集合取 [0] 是线上最高频的越界来源之一,尤其 FirstOrDefault() 之后没判默认值、Split/正则匹配结果为空、JSON 数组为空。
  • 多线程共用一个可变下标(共享 int index++)也会偶发越界,这类 bug 只在压测时出现。

2.2 KeyNotFoundException ------ 字典键不存在

复制代码
// 异常触发示例
var cfg = new Dictionary<string, string> { ["Host"] = "localhost" };
var port = cfg["Port"];             // 💥 The given key 'Port' was not present in the dictionary.

// ✅ 单次查找:TryGetValue(避免 ContainsKey + 索引器 的两次哈希)
if (cfg.TryGetValue("Port", out var port)) { ... }

// ✅ .NET 6+:直接给默认值,语义最干净
var port = cfg.GetValueOrDefault("Port", "5432");

// ✅ 需要"没有就插入"时,避免两次查找(.NET 7+)
ref var val = ref CollectionsMarshal.GetValueRefOrAddDefault(cfg, "Port", out bool exists);
val = "5432";   // ref 直接写回,零额外分配、零二次哈希

// ✅ 并发场景
var cd = new ConcurrentDictionary<string,int>();
cd.GetOrAdd("k", _ => Compute());   // 不要 cd["k"] ?? cd["k"] = ...

特别注意:

  1. 键的等价性 :自定义类型作键必须同时重写 GetHashCode 与 Equals(record/record struct 已合成);用可变对象或数组当键 是经典陷阱------对象入字典后被修改,哈希桶对不上,键就"丢"了,表现为间歇性 KeyNotFoundException。
  2. 大小写与文化 :字符串键默认区分大小写且用序比较;配置/协议解析建议 StringComparer.OrdinalIgnoreCase。
  3. 并发 :Dictionary<,> 非线程安全,多线程读写不仅可能抛 KeyNotFoundException,还可能内部死循环/数据损坏。读多写少可用 ConcurrentDictionary 或读写锁。
  4. 值类型键 (如元组、struct)会造成装箱与较大内存开销;高频场景考虑 Span-based 查找或 ImmutableDictionary。
  5. 反序列化、配置映射缺失字段时,别依赖异常做"字段是否存在"的判断------用 TryGetValue 才能区分"配置漏了"和"程序错了"。

2.3 ArrayTypeMismatchException ------ 协变数组的写入陷阱

引用类型数组允许协变(string[] → object[]),但运行时对象仍是子类型数组,因此每次写入都要做类型校验,失败就在写入那一行抛异常。赋值处永远不报错,错误被推迟到运行期。

复制代码
object[] arr = new string[1];
arr[0] = "hi";   // ✅
arr[0] = 42;     // 💥 ArrayTypeMismatchException
arr[0] = null;   // ✅ null 永远兼容
// object[] b = new int[3];  // ❌ 值类型不参与协变,编译期就拦住
# 场景 说明
1 params object[] 实参恰好是协变数组时零成本透传、不包层。Log("api", tags) 里 tags 仍是 String[],方法内写入即炸。(object[])tags 只是引用转换,救不了;(object)tags 才包层,但语义变成"一个元素"
2 Array.SetValue / 反射 参数是 object,编译器无任何信息,全部推迟到运行期存储点
3 Array.Copy 维度相同但某元素赋不进去时逐元素校验,可能拷了一半才炸,目标数组已污染。要"全或无"用 Array.ConstrainedCopy
4 泛型里把 T[] 提升为 object[]/Array 去写 where T : class 时 object[] o = arr; 编译通过;dynamic d = arr; d[0]=42; 完全绕过编译检查
5 IList<T> 协变写入 IList<object> list = new List<string>(); list.Add(42); 💥
6 Unsafe.As / MemoryMarshal.Cast 连运行期校验都绕过了------不抛异常,直接堆损坏。这是"协变最多只是抛异常"这一错觉的反例

代价:

  • 类型安全:错误从编译期推到运行期,且堆栈离根因远。

  • 性能:每次写入插入类型校验;Array.Copy 元素类型不一致时退化为逐元素拷贝,失去 memmove 快速路径。

  • API:同一方法传字面量正常、传已有数组就炸,形成"海森堡式"接口。

    // 【如何避免?】
    // A. 对外只用只读视图,物理上写不进去
    void Print(IReadOnlyList items) { }
    void Process(ReadOnlySpan data) { }

    // B. params 方法体视为只读;要副本就造真 Object[]
    object[] snap = new object[args.Length];
    Array.Copy(args, snap, args.Length); // ⚠️ (object[])args.Clone() 救不了,仍是 String[]

    // C. 泛型写回保持 T[] 本体
    static void Fill(T[] arr, T val) => Array.Fill(arr, val);

    // D. 异构容器别用数组:元组/record、List、手动 union

    // E. 反射写入前显式校验,统一转 ArgumentException

    常用的几个排障要点:

    1. 判据 :出现"读看基类、写希望是子类"的数组或 IList<T>,就是预备役;换成不变泛型集合问题当场消失。
    2. 日志三件套 :array.GetType().Name(真实运行时类型)、GetElementType()、待写值的 GetType()。
    3. VS 异常设置中勾选"抛出时中断",它默认不中断。
    4. 测试补一条:传入派生类数组 + 写入不兼容值。
    5. 不要 catch 它降级 ------这是逻辑错误,不是数据问题。
    6. 非泛型集合(ArrayList)不会抛这个,而是在取出强转 时抛 InvalidCastException,别混淆。

    2.4 RankException ------ 维度不匹配

    Rank 是数组的维度数(int[,] → 2,int[][] → 1)。该异常基本只出现在数组拷贝族方法中:

    复制代码
    int[,] src = new int[2, 3];
    int[] dst = new int[6];
    src.CopyTo(dst, 0);             // 💥 RankException(2 维拷不进 1 维)
    
    int[][] jagged = { new[] { 1, 2 }, new[] { 3, 4 } };
    int[,] rect = new int[2, 2];
    Array.Copy(jagged, rect, 2);    // 💥 RankException(jagged 的 Rank=1,且元素类型也不兼容)
    
    // ✅ 先校验再按维遍历
    void CopyChecked(Array src, Array dst)
    {
        if (src.Rank != dst.Rank) throw new ArgumentException("维度数必须一致");
        for (int d = 0; d < src.Rank; d++)
            if (src.GetLength(d) != dst.GetLength(d))
                throw new ArgumentException($"第{d}维长度不等");
        src.CopyTo(dst, 0);
    }

    特别注意:

    • 交错数组 T[][] ≠ 多维数组 T[,] 。前者是"一维数组套一维数组",每行长度可不同、可在托管堆上不连续;后者是单个连续块。两者互拷必挂,序列化(BinaryFormatter/旧协议)互通也常踩坑。
    • Array.CreateInstance 能造出非零下界 的数组(GetLowerBound(d) != 0),这种数组传给很多只认 SZArray 的 API 会行为诡异;用 Array.IsSZArray(反射/内部)或统一要求 GetLowerBound(d)==0 做防御。
    • 只接受一维数组的 API(如 Array.Sort、BinarySearch、多数 Buffer/Marshal 操作)在传多维数组时通常抛 ArgumentException 而非 RankException------别因为没看到 RankException 就以为维度没问题。
    • 高性能场景优先用 Span<T>/Memory<T>(天然一维连续),彻底告别维度问题。

    2.5 InvalidOperationException ------ 枚举期间集合被修改

    枚举器内部持有 _version,集合每次增删改都会自增;MoveNext() 发现版本不一致就立即抛出 "Collection was modified; enumeration operation may not execute."

    复制代码
    var list = new List<int> { 1, 2, 3 };
    foreach (var x in list)
        if (x == 2) list.Remove(x);        // 💥 下一轮 MoveNext 抛 InvalidOperationException
    
    var dict = new Dictionary<string,int>{["a"]=1};
    foreach (var k in dict.Keys) // 会抛异常的是增删操作(Add、Remove、Clear),它们会递增 _version
        dict.Remove(k);          // 💥 抛异常,InvalidOperationException
        // dict[k] = 2;          // 更新已有键的值时不会递增 _version,因此枚举器在下一轮 MoveNext 检测到版本一致,不会抛出异常
    
    // ✅ 1) 倒序索引遍历(删除不移位影响后续下标)
    for (int i = list.Count - 1; i >= 0; i--)
        if (list[i] == 2) list.RemoveAt(i);
    
    // ✅ 2) 先快照再遍历(简单粗暴,小集合首选)
    foreach (var x in list.ToList())
        if (x == 2) list.Remove(x);
    
    // ✅ 3) 用集合自身的高效批量 API
    list.RemoveAll(x => x == 2);           // 一次遍历、无额外分配
    
    // ✅ 4) 收集再删除(跨多个集合/复杂条件)
    var toRemove = new List<string>();
    foreach (var kv in dict) if (kv.Value < 0) toRemove.Add(kv.Key);
    foreach (var k in toRemove) dict.Remove(k);
    
    // ✅ 5) 真正的并发读写
    var cd = new ConcurrentDictionary<string,int>();
    // 注意:ConcurrentDictionary 的枚举器是"某一时刻的快照",修改不抛异常,
    // 但可能看到重复/遗漏项------业务上仍需按幂等设计。

    特别注意:

    • 延迟执行是隐形杀手 :var q = list.Where(...) 只是表达式树,真正枚举可能在几层调用之后、甚至另一个线程里,那时原集合早被改了。需要尽早 .ToList()/.ToArray() 物化,或传入不可变集合。
    • 线程安全 ≠ 不抛这个异常 :多线程共用一个 Dictionary 即使不加修改,也可能因内部结构被破坏而抛 IndexOutOfRangeException 或死循环。ConcurrentDictionary 只保证自身一致性,不保证"枚举期间全局静止"。
    • 结构体枚举器的坑 :List<T>.GetEnumerator() 返回 struct,若被装箱成 IEnumerator<T>(如 foreach (var x in (IEnumerable<int>)list)),每次 Current 都是副本,可能出现"永远停在第一项"的怪象;同时修改检测依然生效。
    • 同一异常的其他常见来源(便于搜日志时不误判):Single()/First() 空集合、Queue.Dequeue/Stack.Pop 空、IEnumerator 未 MoveNext 就读 Current、LINQ 的 OrderBy 比较器违反约定、EF Core 的 DbContext 并发使用、Task 重复 Start、yield return 迭代器中状态非法。
    • ASP.NET Core 中最典型:把注入为 Scoped/Singleton 的可变集合作为全局缓存又在枚举时更新------要么换成 ConcurrentDictionary,要么 lock + 快照。

    三、通用排查清单

    1. 先分类 :带 Index/Key/Rank/TypeMismatch 的 = bug;InvalidOperationException = 状态或并发问题。前四类不建议在生产用 try/catch 吞掉,应让它炸出来并带上上下文。
    2. 日志必须带"边界信息" :抛的时候同时记 index / Length / Count / key / actualType / Rank / GetLength(d),90% 的越界看这一行就能定位。
    3. 边界用例单测 :空集合、单元素、刚好满、多一、零长度数组、空字符串、Split 结果、非零下界数组、交错数组。
    4. 用语言特性代替手写下标 :foreach、^ 与 ..、Span<T>、模式匹配、ref 返回;需要下标时用 for (int i = 0; i < c.Count; i++) 并把上限缓存到局部变量(既防并发篡改长度,又帮 JIT 消除边界检查)。
    5. 字典一律走 TryGetValue / GetValueOrDefault ;需要"不存在则计算插入"用 GetOrAdd 或 GetValueRefOrAddDefault。
    6. API 设计层面消灭协变数组 :参数用 IReadOnlyList<T>/ReadOnlySpan<T>,返回用不可变集合;params object[] 重载要警惕传入的真实数组类型。
    7. 并发 :可变集合不出锁、不出线程边界;需要共享就用 Concurrent* 或 Immutable*;枚举期间要更新的,一律"快照或新建替换"。
    8. 调试技巧 :VS 里对 IndexOutOfRangeException 勾选"在该异常类型上中断"(它在默认设置里有时不被中断);对偶发问题用 Debug.Assert(index >= 0 && index < arr.Length),断言失败比异常更早、信息更全。

    四、小小的总结

    这五个异常虽然触发点不同,但本质都是契约被破坏,而非"数据不好"。

    • IndexOutOfRangeException / KeyNotFoundException 是**"越界与缺失"**,属于访问前没做校验;
    • ArrayTypeMismatchException / RankException 是**"类型与维度不匹配"**,源于把本应由编译期或调用方保证的信息推迟到了运行期(协变数组、多维数组拷贝);
    • InvalidOperationException 则是**"状态不对"**,多半是枚举期间修改集合或并发共享可变状态。

    它们的共同特征是:抛异常的位置往往离根因很远------赋值处不报错、写入处才炸;枚举器在下一轮 MoveNext 才报错;params object\[\] 的行为取决于调用者传的是字面量还是已有数组。因此,把它们当成"可恢复的错误"去 catch 几乎总是错的,正确的做法是在入口和边界处就把它们消灭掉。

    落到工程实践上,可以只记三条规则:

    1. 用 Try* 和长度判断替代下标与索引器(TryGetValue、GetValueOrDefault、ElementAtOrDefault、倒序遍历、RemoveAll);
    2. 用类型系统堵死协变与维度问题(对外只暴露 IReadOnlyList<T> / ReadOnlySpan<T>,params object\[\] 视为只读、要副本就造真 object\[\],泛型代码绝不把 T\[\] 提升为 object\[\]);
    3. 让可变集合不出锁、不出线程边界,枚举期间一律快照或新建替换。

    配合日志里必记的边界信息(Length/Count/key/Rank/真实运行时类型)、VS 中勾选"抛出时中断"以及空集合与差一的单测,前四个异常可以做到线上零出现,剩下的 InvalidOperationException 也基本能被并发设计收敛掉。

    一句话:凡是能用长度判断、Try* API、不可变快照和泛型不变性挡住的,就不要留给运行时去抛。

    相关推荐
    java_nnnn2 小时前
    Spring MVC小案例(1)
    java·后端·spring·mvc
    陈随易2 小时前
    从今天起,你只需要一个浏览器之Ego lite
    前端·后端·程序员
    打工仔折腾 AI3 小时前
    AI Agent 工程化实战(二):5 分钟跑通 Deep Agent 并逐层拆解底层链路
    java·数据库·人工智能·后端·python·langchain·ai agent 实战
    学长毕设3 小时前
    基于springboot的农资管理系统-附源码
    java·spring boot·后端
    拖孩4 小时前
    一个人 + AI 做的小程序,今天,正式回本了
    前端·后端·微信小程序
    进击的丸子4 小时前
    代理网络环境下 Linux Pro 在线激活实操指南
    javascript·后端·安全
    念何架构之路5 小时前
    Go reflect包值操作 Value 的读写、可设置性与 interface 布局
    开发语言·后端·golang
    小林ixn5 小时前
    用 FastAPI + SQLAlchemy 从零手搓一个博客系统:一套能落地、能讲清楚的 RESTful 后端
    后端·python·fastapi
    不识君5 小时前
    esl-client 2.0.0 发布:Java 项目接入 FreeSWITCH,从引入到使用
    spring boot·后端·开源