〇、前言

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

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

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

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

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

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

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

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

image

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

2.1 IndexOutOfRangeException —— 越界访问

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

注意它是“数组/连续内存”语义的异常:string[i]、T[]、Span、多维数组都会抛它;而 List[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 { ["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();
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 协变写入 IList list = new List(); list.Add(42); 💥
6Unsafe.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,就是预备役;换成不变泛型集合问题当场消失。
  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/Memory(天然一维连续),彻底告别维度问题。

 

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

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

var list = new List { 1, 2, 3 };
foreach (var x in list)
    if (x == 2) list.Remove(x);        // 💥 下一轮 MoveNext 抛 InvalidOperationException

var dict = new Dictionary{["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();
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();
// 注意:ConcurrentDictionary 的枚举器是“某一时刻的快照”,修改不抛异常,
// 但可能看到重复/遗漏项——业务上仍需按幂等设计。
特别注意:
  • 延迟执行是隐形杀手:var q = list.Where(...) 只是表达式树,真正枚举可能在几层调用之后、甚至另一个线程里,那时原集合早被改了。需要尽早 .ToList()/.ToArray() 物化,或传入不可变集合。
  • 线程安全 ≠ 不抛这个异常:多线程共用一个 Dictionary 即使不加修改,也可能因内部结构被破坏而抛 IndexOutOfRangeException 或死循环。ConcurrentDictionary 只保证自身一致性,不保证“枚举期间全局静止”。
  • 结构体枚举器的坑:List.GetEnumerator() 返回 struct,若被装箱成 IEnumerator(如 foreach (var x in (IEnumerable)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、模式匹配、ref 返回;需要下标时用 for (int i = 0; i < c.Count; i++) 并把上限缓存到局部变量(既防并发篡改长度,又帮 JIT 消除边界检查)。
  5. 字典一律走 TryGetValue / GetValueOrDefault;需要“不存在则计算插入”用 GetOrAdd 或 GetValueRefOrAddDefault。
  6. API 设计层面消灭协变数组:参数用 IReadOnlyList/ReadOnlySpan,返回用不可变集合;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 / ReadOnlySpan,params object[] 视为只读、要副本就造真 object[],泛型代码绝不把 T[] 提升为 object[]);
  3. 让可变集合不出锁、不出线程边界,枚举期间一律快照或新建替换。

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

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

 

"

原文地址: https://www.cveoy.top/t/topic/qHOK 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录