我满心欢喜开启了 C# 的可空引用类型,以为从此再也不会有 NullReferenceException 了,结果线上还是被一个 NRE 打脸——因为编译器的非空承诺,根本管不到从 JSON 反序列化、外部接口这些边界进来的 null 的深度复盘
这是一次让我对"编译器说没问题"和"运行时真没问题"之间那道鸿沟,有了刻骨认知的事故。C# 8 出了"可空引用类型(Nullable Reference Types,NRT)",我如获至宝:开启后,引用类型默认不可为 null,可能为 null 的要显式写 ?,编译器还会在你可能解引用 null 的地方给警告。我兴冲冲在项目里全局打开 <Nullable>enable</Nullable>,把警告都消干净,心想:这下从根上消灭 NullReferenceException 了吧!
可上线没多久,异常监控里赫然躺着一个 NullReferenceException。我盯着堆栈百思不得其解:那行代码访问的是一个我明明声明为非空(没加 ?)的属性,编译器全程没给任何警告、绿油油地通过了——它怎么可能是 null?顺着堆栈往上扒,真相浮出水面:那个对象是从一段外部传来的 JSON 反序列化出来的,JSON 里恰好缺了那个字段,反序列化器就给它填了 null;而我的类型声明信誓旦旦说它"非空",编译器信了、我也信了,可运行时的数据根本不买账。
故障现场:声明为"非空"的属性,运行时却是 null
我把出事的代码和数据还原出来,问题一目了然:
#nullable enable
public class UserProfile
{
public string Name { get; set; } // 声明为非空 string(没有 ?)
public string City { get; set; } // 声明为非空 string(没有 ?)
}
// 外部传来的 JSON——注意: 它没有 City 字段!
string json = "{ \"Name\": \"张三\" }";
var profile = JsonSerializer.Deserialize(json);
// 编译器认为 City 是非空 string, 这里【没有任何警告】
int len = profile.City.Length; // 运行时: profile.City 是 null → NullReferenceException ✗
// ^^^^^^^^^^^^
// 编译器: "City 是非空的, 放心用" —— 它被我的声明骗了
// 运行时: City 实际是 null(JSON 里没这字段, 反序列化填了 null)
看着这段代码我愣住了:编译器是依据我的类型声明来判断"非空"的——我说 City 是 string(非空),它就当真,在所有用到 City 的地方都默认它不为 null、不给警告。可反序列化器是运行时根据实际数据来填值的,JSON 里没 City,它就老老实实塞了个 null 进去。我的"声明"和"实际数据"对不上,而编译器只看声明、看不见运行时真正流进来的数据,这个 null 就这么大摇大摆地绕过了所有静态检查,直到运行到 .Length 才爆炸。我以为开了 NRT 就有了一道铁壁,却没意识到这道壁只在"编译器能看见的世界"里有效。
第一件事:搞懂 NRT 的边界——它是"编译期静态分析",不是"运行时强制非空"
冷静下来,我去把 NRT 的文档认真读了一遍,才明白我对它的期待从一开始就错了:
【可空引用类型(NRT)到底是什么、不是什么】
它是什么:
- 一个【编译期】的【静态分析】特性
- 你用 string(非空) / string?(可空) 标注"意图"
- 编译器据此做【流分析】, 在你可能解引用一个"它认为可能为null"的值时给警告
- 目的: 在【写代码/编译】阶段, 帮你把潜在的 null 解引用暴露出来
它【不是】什么:
- ✗ 不是运行时检查: 声明 string(非空)不会在运行时阻止 null 被赋进去
- ✗ 不改变 CLR 行为: 引用类型在运行时【本来就能是 null】, NRT 没改这一点
- ✗ 不是保证: 它是"基于你声明的意图"的分析, 你的声明若与实际数据不符, 它就被骗了
最关键的【盲区】——编译器看不见的"边界":
反序列化(JSON/XML)、反射、外部 API 返回、与未启用 NRT 的旧代码/库交互、
default(T)、未初始化字段……
→ 这些地方的值是【运行时】产生的, 编译器的静态流分析【管不到】,
它们可以把 null 塞进一个被声明为"非空"的引用里, 而编译期毫不知情。
一句话: NRT 帮你管好"你自己写的、编译器看得见的代码内部",
但管不住"从外部边界流进来的、运行时才确定的数据"。
这一下点醒了我:我把一个"编译期的、基于声明意图的静态分析",当成了"运行时的、强制的非空保证"。NRT 的价值是在我写代码时提醒我"这儿可能有 null,处理一下",它管的是我亲手写的、它能分析到的那部分代码;可程序的数据不只来自我写的代码,还大量来自边界之外——反序列化、外部接口、反射。这些地方的值是运行时才定的,编译器的静态分析看不见也管不着,自然挡不住一个 null 从这里溜进来、冒充一个"非空"的值。编译器的"没警告",只代表"在它能看见的范围内没问题",不代表"运行时真的没问题"。
第二件事:正解——在边界上把"外部数据"翻译成"可信的内部模型"
找到根因,正解就清晰了:NRT 的非空只在"我的代码内部"可信;凡是从边界(反序列化、外部 API)进来的数据,都要在边界上显式校验/兜底,把"不可信的外部数据"翻译成"满足非空契约的内部模型"后再往里传。别让外部的 null 直接冒充内部的非空值。
// 错误做法: 直接信任反序列化的结果, 当它真的满足"非空"声明
var profile = JsonSerializer.Deserialize(json);
int len = profile.City.Length; // profile/City 都可能是 null → NRE
// 正解1: 把可能缺失的字段如实声明为可空, 让编译器逼你处理
public class UserProfileDto
{
public string? Name { get; set; } // 如实: 反序列化可能给 null
public string? City { get; set; } // 如实: JSON 里可能没这字段
}
// 正解2: 在边界上校验 + 兜底, 翻译成"内部可信模型"
var dto = JsonSerializer.Deserialize(json)
?? throw new ArgumentException("payload 不能为空");
var profile = new UserProfile // 内部模型, 字段真正非空
{
Name = dto.Name ?? throw new ValidationException("Name 必填"),
City = dto.City ?? "未知", // 给个合理兜底
};
// 此后在内部代码里, profile.City 才真的可以放心当非空用
int len = profile.City.Length; // ✓ 安全
这套做法的精髓,是承认"边界内外是两个世界":边界之外的数据天然不可信(可能缺字段、可能是 null),要如实地用 string? 声明它们的"可能为空",然后在边界上集中校验、兜底、转换,只让"已经被验证过、真正满足非空契约"的数据进入内部。内部代码因此可以信任 NRT 的非空声明;而所有的"不确定性"都被挡在了边界上、被显式处理掉了。
【和 NRT 共处的几条实践】
1. DTO 如实声明: 反序列化用的类, 可能缺的字段就用 string? 标注,
别为了"看着干净"硬声明成非空——那是在骗编译器, 也在骗自己
2. 边界校验: 在数据入口(反序列化后、API 返回后)集中校验+兜底,
把外部数据转成内部可信模型, 再往业务层传
3. 别用 ! 强压警告: dto.City!.Length 是在对编译器说"我保证非空",
你保证不了就别用; 滥用 ! 等于把 NRT 的好处全扔了
4. 反序列化器配置: 可要求必填字段缺失时直接抛错(而非填 null),
让"数据不符契约"在边界就暴露
5. 把"编译无警告"理解为"内部自洽", 而非"运行时绝对安全"
第三件事:其他"编译器看不见、null 偷偷溜进来"的坑
顺着"NRT 管不到运行时边界"这条线,我把项目里同类的盲区都排查了一遍,它们都在编译器照不到的角落里:
第一个,和未启用 NRT 的旧库交互。调用一个没开 NRT 的第三方库,它的返回类型对编译器是"未知可空性",编译器不警告,但它完全可能返回 null。对外部库的返回值要当可空处理。
第二个,反射 / default(T) 创建的对象。用反射或 default 创建实例时,引用类型字段是 null,绕过了构造函数里的非空初始化,编译器的流分析对此无能为力。
第三个,滥用 null! 和 ! 操作符。为了消警告,在初始化时写 = null!、在访问时写 x!.Foo,这是在向编译器"赌咒发誓"非空,赌错了就是运行时 NRE,而且把警告也压没了、更难发现。
第四个,集合元素、泛型的可空性。List<string> 里也可能混进 null(尤其来自反序列化),Dictionary 的 TryGetValue 失败时 out 参数是 default。这些细节 NRT 的标注也容易表达不全。
第四件事:NRT 能管和不能管的边界,一张表理清
我把"哪些地方 NRT 的非空可信、哪些地方它鞭长莫及"整理成一张表,这是我现在判断"这个非空声明能不能信"的依据:
| 数据来源 | NRT 能管吗 | 为什么 | 该怎么办 |
|---|---|---|---|
| 我代码内 new / 直接赋值 | ✓ 能 | 编译器流分析看得见 | 信任声明 |
| JSON/XML 反序列化 | ✗ 不能 | 运行时按数据填值,可能填 null | DTO 用 ?,边界校验 |
| 外部 API / RPC 返回 | ✗ 不能 | 返回什么由对端决定 | 当可空,校验后用 |
| 反射 / default(T) | ✗ 不能 | 绕过构造函数初始化 | 显式判空 |
| 未启用 NRT 的旧库 | ✗ 不能 | 可空性"未知",编译器不警告 | 当可空处理 |
| 用 ! 压制的地方 | ✗ 失效 | 你手动告诉编译器"别管" | 少用,确有把握才用 |
这张表让我彻底想明白:NRT 的非空保证,只在"编译器能完整看到数据从哪来、到哪去"的代码内部成立;一旦数据跨过了边界(从外部世界流入),它的承诺就失效了。判断一个非空声明可不可信,关键就看这个值究竟是不是从编译器看得见的地方来的。
第五件事:我对"开了 NRT"的几个想当然
这次事故,本质是我对 NRT 这个特性抱了一堆过高且错位的期待。把它们列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "开了 NRT 就再也不会有 NRE 了" | 它是编译期辅助,不是运行时强制,边界外的 null 照样进来 |
| "声明为非空,运行时它就一定非空" | 声明只是意图;运行时数据不符,它照样是 null |
| "编译器没警告 = 运行时安全" | 没警告只代表内部自洽,管不到运行时流入的数据 |
| "反序列化的对象满足我的非空声明" | 反序列化按数据填值,缺字段就填 null,不看你的声明 |
| "用 ! 把警告消掉就万事大吉" | ! 是关掉检查、自担风险,赌错就是运行时 NRE |
| "静态类型检查能覆盖所有 null 风险" | 静态分析只覆盖它看得见的;边界处的运行时数据是盲区 |
第六件事:用 NRT、处理外部数据时,我现在的自检习惯
现在每当我用 NRT、或处理一个引用类型的值,我都会先按这张图问自己:
这张图的精髓,是"分清这个值是'编译器看得见的内部'还是'看不见的外部边界'、边界数据一律校验后再当非空"。设计就把外部数据如实声明可空、在边界集中校验兜底转内部模型、排查就顺着这个 null 往上找它从哪个编译器看不见的边界溜进来的。这套习惯,让我从"开了 NRT 就高枕无忧"变成了"分清编译器管得到和管不到、把不确定性挡在边界"——核心始终是:NRT 是编译期基于声明意图的静态分析、不是运行时强制非空;它管得到我代码内部、管不到反序列化/外部API/反射/旧库等边界流入的运行时数据;声明非空但实际数据是 null,编译器被骗、运行时 NRE;正解是边界数据如实声明可空、在入口集中校验兜底、转成满足非空契约的内部模型再用。
我立下的几条规矩
这场"开了 NRT 还吃 NRE"的事故,换来了我用静态类型工具时,刻进骨子里的几条铁律:
- NRT(以及一切静态检查)是编译期辅助、不是运行时保证;它管"我写的代码",管不到"运行时流入的数据"。
- 引用类型在 CLR 运行时本来就能是 null,NRT 没改变这一点,只是帮你在编译期发现潜在解引用。
- "编译器没警告"只代表"在它看得见的范围内自洽",不等于"运行时绝对安全"。
- 反序列化、外部 API、反射、旧库是 NRT 的盲区:这些边界的值如实用可空类型声明。
- 在数据入口(边界)集中校验+兜底,把不可信的外部数据翻译成满足非空契约的内部模型再往里传。
- 别滥用 ! 压制警告——那是关掉检查、自担风险;消警告应靠真正处理 null,而非掩盖。
- 区分"编译器能看见的内部世界"和"运行时才确定的外部边界",把不确定性挡在边界上。
附:我现在做"边界净化"的统一入口模板
这是我现在处理任何外部输入(反序列化、API 返回)时,固定套的"边界净化"模板——把"解析外部原始数据"和"转成内部可信模型"两步显式分开,绝不让一个没经校验的外部对象直接溜进业务层冒充非空:
/// 边界净化: 外部 JSON → 校验 → 内部可信模型
public static class BoundaryGuard
{
public static UserProfile ParseUserProfile(string json)
{
// 1) 解析成"如实标注可空"的 DTO(外部世界, 一切皆可能为 null)
var dto = JsonSerializer.Deserialize(json);
if (dto is null)
throw new ValidationException("请求体为空或非法 JSON");
// 2) 集中校验 + 兜底, 把不确定性在这里全部消化掉
var name = dto.Name;
if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("Name 必填且不能为空");
// 3) 产出内部模型: 此后它的非空声明才真正可信
return new UserProfile
{
Name = name, // 已校验, 真非空
City = dto.City ?? "未知", // 缺失给兜底, 真非空
};
}
}
// 业务层只接触 ParseUserProfile 的产物, 永远拿到的是"已净化、真非空"的对象
var profile = BoundaryGuard.ParseUserProfile(rawJson);
int len = profile.City.Length; // ✓ 这里的非空, 是被边界净化兑现过的非空
这个 BoundaryGuard 把我这次的教训钉死在了架构里:外部世界和内部世界之间,有一道明确的"净化关卡";外部数据如实当可空、在关卡处校验兜底、只让兑现了非空契约的内部模型通过。关卡之后,我才敢信任 NRT 的非空声明——因为这份非空,不再是我对编译器的一句空头承诺,而是被这道关卡真正验证、真正兑现过的。编译器管不到的边界,我用这道关卡亲手把它管了起来。
这件事之后,我对项目里所有反序列化用的 DTO 做了一次彻底排查:凡是可能缺失的字段,该标可空的全标上可空,该在入口校验的全补上校验。改完我跑了一遍统计,光是被我硬声明成非空、实则反序列化可能给 null 的字段,就有几十处——每一处都是一颗潜伏的 NRE 定时炸弹,只是恰好还没被那条缺字段的数据触发而已。那一刻我才真切体会到:开了 NRT、消干净了警告带来的那种安全感,有多虚假;真正的安全,从来不是编译器替我宣布的,而是我自己在每一个边界上亲手兑现的。
更让我警醒的是,这个认知远不止于 null 和 NRT。我后来回想,自己其实在很多地方都犯过同一个错:拿到一个工具给的绿灯,就以为万事大吉,从不去想这盏绿灯的前提和边界。静态类型、单元测试通过、监控没报警、评审过了——这些都是有边界的保证,只在各自看得见的范围内有效,可我总忍不住把它们当成无条件的护身符。这次 NRE 像一记响亮的耳光,让我对所有这类绿灯都多了一份清醒:绿灯说的是它看得见的地方没问题,看不见的地方,得我自己去看。
写在最后
回头看,这场由"把编译期静态分析当成运行时强制保证"引发的"开了 NRT 还吃 NRE"事故,真正教给我的,远不止"边界要校验、DTO 用可空"这一个技巧。它让我对"一件工具能给你的'保证', 永远有一个它'看得见、管得着'的范围; 在这个范围之内它确实可靠, 可一旦事情越过了这个范围(尤其是从'外部'流进来), 它的保证就不再成立——而最危险的, 是我们把'范围内的可靠'误当成了'无条件的可靠', 于是对范围之外的风险彻底失了戒备",有了一次刻骨的体会。我栽跟头,是因为我把一个'有适用范围的保证(编译期、对我能看见的代码)', 当成了'无条件的、绝对的保证'——我看到编译器绿灯、没有警告, 就以为'null 这个问题被彻底根除了', 却没去想: 这个'绿灯', 是建立在'编译器能看见全部数据来源'这个前提上的; 而反序列化、外部接口这些'边界', 恰恰是它看不见的地方;我对'它管得着的内部'放了心, 这没错; 错在我把这份放心, 不假思索地延伸到了'它根本管不着的边界之外'。这让我领悟到一个关于"工具、保证与其适用边界"的深刻认知:任何工具、检查、保证, 都有一个'它能生效的范围/前提'; 在范围内它值得信赖, 在范围外它形同虚设;真正用好一个工具, 不只是知道'它能做什么', 更要清楚地知道'它的边界在哪里、在哪些情况下它会失效'——因为风险, 恰恰最容易从'我们以为被保护、实则保护伞够不到'的那个边界地带钻进来;而最深的陷阱, 是一个'大部分时候都管用'的保证带来的虚假安全感: 它让我们对它失效的那些场景, 失去了本该有的警惕。这给了我一种看待"一切'它能帮我兜底'的工具与机制"时的清醒:每当我依赖一个工具/检查给我的"保证"时,要追问"这个保证成立的前提是什么?它的边界在哪里?在哪些地方(尤其是与外部世界交接的边界)它其实管不到、我得自己补上防线?"——对它范围内的能力充分信任、对它范围外的盲区主动设防, 而不是把'局部的、有前提的可靠'当成'全局的、无条件的安全';"认清每个保证的适用边界、并在边界之外自己补上校验与防护",是用好工具、也是不被工具的虚假安全感所害的关键。认清静态保证只在编译器看得见的范围内成立、边界之外是盲区、最危险的是把局部可靠当无条件安全——这,是我用一次开了 NRT 还吃 NRE 的事故,换来的、关于 C#、也关于如何看待工具的保证与其边界的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次开心地用上某个"帮你杜绝某类问题"的工具时,先想想"它的保证到哪儿为止?边界之外谁来兜?",并在数据入口补上自己的校验,那我对着那个"声明非空却是 null"的 NRE 折腾的大半天,就值了。
—— 别看了 · 2026