〇、前言
前文已经列举了全部有哪些常见的异常类型,本文继续介绍下各个类别的异常特点。
本文主要介绍:集合与索引异常 ,主要包括这几种: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"] = ...
特别注意:
- 键的等价性 :自定义类型作键必须同时重写
GetHashCode与Equals(record/record struct已合成);用可变对象或数组当键 是经典陷阱------对象入字典后被修改,哈希桶对不上,键就"丢"了,表现为间歇性KeyNotFoundException。 - 大小写与文化 :字符串键默认区分大小写且用序比较;配置/协议解析建议
StringComparer.OrdinalIgnoreCase。 - 并发 :
Dictionary<,>非线程安全,多线程读写不仅可能抛KeyNotFoundException,还可能内部死循环/数据损坏。读多写少可用ConcurrentDictionary或读写锁。 - 值类型键 (如元组、struct)会造成装箱与较大内存开销;高频场景考虑
Span-based 查找或ImmutableDictionary。 - 反序列化、配置映射缺失字段时,别依赖异常做"字段是否存在"的判断------用
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
// E. 反射写入前显式校验,统一转 ArgumentException
常用的几个排障要点:
- 判据 :出现"读看基类、写希望是子类"的数组或
IList<T>,就是预备役;换成不变泛型集合问题当场消失。 - 日志三件套 :
array.GetType().Name(真实运行时类型)、GetElementType()、待写值的GetType()。 - VS 异常设置中勾选"抛出时中断",它默认不中断。
- 测试补一条:传入派生类数组 + 写入不兼容值。
- 不要 catch 它降级 ------这是逻辑错误,不是数据问题。
- 非泛型集合(
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+ 快照。
三、通用排查清单
- 先分类 :带
Index/Key/Rank/TypeMismatch的 = bug;InvalidOperationException= 状态或并发问题。前四类不建议在生产用try/catch吞掉,应让它炸出来并带上上下文。 - 日志必须带"边界信息" :抛的时候同时记
index / Length / Count / key / actualType / Rank / GetLength(d),90% 的越界看这一行就能定位。 - 边界用例单测 :空集合、单元素、刚好满、多一、零长度数组、空字符串、Split 结果、非零下界数组、交错数组。
- 用语言特性代替手写下标 :
foreach、^与..、Span<T>、模式匹配、ref返回;需要下标时用for (int i = 0; i < c.Count; i++)并把上限缓存到局部变量(既防并发篡改长度,又帮 JIT 消除边界检查)。 - 字典一律走
TryGetValue/GetValueOrDefault;需要"不存在则计算插入"用GetOrAdd或GetValueRefOrAddDefault。 - API 设计层面消灭协变数组 :参数用
IReadOnlyList<T>/ReadOnlySpan<T>,返回用不可变集合;params object[]重载要警惕传入的真实数组类型。 - 并发 :可变集合不出锁、不出线程边界;需要共享就用
Concurrent*或Immutable*;枚举期间要更新的,一律"快照或新建替换"。 - 调试技巧 :VS 里对
IndexOutOfRangeException勾选"在该异常类型上中断"(它在默认设置里有时不被中断);对偶发问题用Debug.Assert(index >= 0 && index < arr.Length),断言失败比异常更早、信息更全。
四、小小的总结
这五个异常虽然触发点不同,但本质都是契约被破坏,而非"数据不好"。
- IndexOutOfRangeException / KeyNotFoundException 是**"越界与缺失"**,属于访问前没做校验;
- ArrayTypeMismatchException / RankException 是**"类型与维度不匹配"**,源于把本应由编译期或调用方保证的信息推迟到了运行期(协变数组、多维数组拷贝);
- InvalidOperationException 则是**"状态不对"**,多半是枚举期间修改集合或并发共享可变状态。
它们的共同特征是:抛异常的位置往往离根因很远------赋值处不报错、写入处才炸;枚举器在下一轮 MoveNext 才报错;params object\[\] 的行为取决于调用者传的是字面量还是已有数组。因此,把它们当成"可恢复的错误"去 catch 几乎总是错的,正确的做法是在入口和边界处就把它们消灭掉。
落到工程实践上,可以只记三条规则:
- 用 Try* 和长度判断替代下标与索引器(TryGetValue、GetValueOrDefault、ElementAtOrDefault、倒序遍历、RemoveAll);
- 用类型系统堵死协变与维度问题(对外只暴露 IReadOnlyList<T> / ReadOnlySpan<T>,params object\[\] 视为只读、要副本就造真 object\[\],泛型代码绝不把 T\[\] 提升为 object\[\]);
- 让可变集合不出锁、不出线程边界,枚举期间一律快照或新建替换。
配合日志里必记的边界信息(Length/Count/key/Rank/真实运行时类型)、VS 中勾选"抛出时中断"以及空集合与差一的单测,前四个异常可以做到线上零出现,剩下的 InvalidOperationException 也基本能被并发设计收敛掉。
一句话:凡是能用长度判断、Try* API、不可变快照和泛型不变性挡住的,就不要留给运行时去抛。