集合与索引类异常【C# Exception 详解系列(三)】
〇、前言
前文已经列举了全部有哪些常见的异常类型,本文继续介绍下各个类别的异常特点。
本文主要介绍:集合与索引异常,主要包括这几种: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 的一半场景需要靠并发设计解决,其余四个都可以做到“线上零出现”。

二、各个异常简介与简单示例
2.1 IndexOutOfRangeException —— 越界访问
触发条件:下标 < 0 或 ≥ Length。
注意它是“数组/连续内存”语义的异常:string[i]、T[]、Span、多维数组都会抛它;而 List 抛的是 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"] = ...
- 键的等价性:自定义类型作键必须同时重写
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 协变写入 |
IList |
| 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
原文地址: https://www.cveoy.top/t/topic/qHOK 著作权归作者所有。请勿转载和采集!