可空引用类型与可空值类型
本教程共 100 篇 · 第 90 篇 · 更新于 2026-07-31 · 约 8 分钟阅读
90. 可空引用类型与可空值类型
本节目标:学完能区分可空引用类型 string? 与可空值类型 int?,并安全处理可能为 null 的值。
null 带来的老问题
C# 早期里,引用类型(如 string)默认可为 null,但编译器不提醒你”这里可能是空”。运行时一访问 .Length,就抛出 NullReferenceException,俗称”空引用异常”。
值类型(如 int)默认不能为 null,有时却确实需要”没有值”的语义,比如数据库里的可空字段。
Note可空性分两类:可空引用类型(string?,C# 8 引入)和可空值类型(int?,C# 2 就有)。二者机制不同,别混。
可空引用类型 string?
C# 8 起,引用类型默认”不可空”。写成 string? 才显式允许 null,编译器据此给你警告。
#nullable enable
string notNull = "hello";
// notNull = null; // 警告:可能为 null
string? maybeNull = null; // 合法
#nullable enable 打开可空注解上下文,之后编译器才帮你做静态检查。
Tip.NET 6+ 的新项目模板默认开启可空检查。读到
string?就该警觉:用到它前先判空。
为什么引入可空引用类型
引用类型”默认可为 null”是 C# 从早期语言继承来的老设计。发明 null 的 Tony Hoare 后来称它为”十亿美元的错误”——太多系统在运行期因意外 null 而崩溃。
C# 8 的可空引用类型不是改运行时,而是给编译器加了一层”静态注解”:默认引用不可空,只有显式写 ? 才允许 null。它把”这里可能为 null”从潜规则变成写在类型上的契约,编译器据此在写代码时就报警。
Note可空引用类型改变的是”你和编译器之间的约定”,运行期行为没变。它让你在编译阶段就接住大部分疏漏,而不是等上线后崩溃。
可空值类型 Nullable
值类型本不能为 null。加 ? 后变成 Nullable<T>,既能装值也能装”无值”。
int? age = null; // 合法,表示未知
int normal = 20; // 普通 int 不能赋 null
Console.WriteLine(age.HasValue ? age.Value : -1);
Nullable<T> 是结构体,带 HasValue 与 Value 两个成员。可空值类型本质是”值 + 是否有值”的包裹。
?. 空条件访问
?. 在对象为 null 时直接短路返回 null,不再往下访问,避免异常。
string? name = null;
int? len = name?.Length; // name 为 null,len 得到 null,不抛异常
Console.WriteLine(len ?? 0);
相比先 if (name != null) 再取长度,?. 一行搞定,清爽很多。
Note
?.返回的是可空类型:name?.Length 得到int?。要当普通 int 用,配??给默认值。
?? 空合并运算符
?? 表示”左边不为 null 就用左边,否则用右边”。它常和 ?. 搭档,给个兜底值。
string? input = null;
string result = input ?? "默认值";
Console.WriteLine(result); // 默认值
int length = input?.Length ?? 0; // 取长度,空则 0
C# 8 还加了 ??=(空合并赋值):左边为 null 才赋值。
string? cfg = null;
cfg ??= "缺省配置";
与模式匹配配合
判空也能用模式:is not null 表达非空,is null 判空。还能在声明模式里处理可空。
object? obj = "text";
if (obj is string s)
{
Console.WriteLine(s.Length); // 匹配成功才进,s 非空
}
Tip处理可空值类型时,
is int n这种声明模式既判空又取出值,比HasValue更优雅。
旧版本对比
C# 8 之前没有可空引用类型。那时的 string 默认允许 null,全靠开发者自觉,编译器不报错。
// 旧写法(无 #nullable 上下文)
string name = null; // 不报错,但埋雷
// 新写法应写 string? name = null; 让意图显式
Warning可空注解是编译期静态分析,不是运行期保险。它帮你提前发现疏漏,但不能替代运行时的 null 检查。
两类可空的本质区别
名字都带”可空”,机制却不同,用一张表说清:
| 维度 | 可空引用类型 string? | 可空值类型 int? |
|---|---|---|
| 引入版本 | C# 8 | C# 2 |
| 底层机制 | 编译期注解,仍是普通引用 | Nullable<T> 结构体包裹 |
| 运行期是否真防 null | 否,只提醒 | 是,值类型本身不能为 null |
| 典型用途 | 表示”可能没有对象” | 表示”可能没有数值”(如数据库字段) |
Tip记一句:引用可空是”编译器帮你盯”,值类型可空是”值真的能装无”。两者都写
?,含义机制却不同,别当成一回事。
新手易踩的坑
坑一:以为 string? 运行时会阻止 null。不会,它只是编译期提醒,运行期传 null 仍可能出错。
坑二:?. 返回可空类型,忘了 ?? 兜底,把 int? 赋给 int 编译失败。
坑三:在没开 #nullable enable 的文件里写 string?,效果可能达不到预期,检查项目设置。
坑四:过度使用 ! 强制断言非空(name!),绕过检查等于埋雷,能免则免。
Tip设计 API 时,明确哪些参数/返回值可为 null,用
?标出来,调用方一眼便知该不该判空。
Nullable 的结构与装箱
int? 本质是 Nullable<int> 结构体,内部两个字段:一个 bool HasValue 和一个 T value。它不是引用,不额外在堆上分配。
装箱(boxing,第 48 章详讲)时有个特别行为:若 HasValue 为 false,装箱结果是 null;为 true,则只装箱里面的 value。
int? a = null;
object o1 = a; // o1 是 null 引用
int? b = 5;
object o2 = b; // o2 装箱的是 5,不是 Nullable<int>
Console.WriteLine(o1 == null); // True
Note这点容易误解:可空值类型装箱后,null 就是真 null,非 null 就退化成底层值类型,没有”包裹”残留。
泛型里的可空
泛型约束要注意:T? 在 where T : struct 时表示 Nullable<T>;在 where T : class 时表示”可空引用注解”,语义不同。
T? FirstOrNull<T>(List<T> list) where T : struct
=> list.Count > 0 ? list[0] : null; // 返回 Nullable<T>
写泛型方法时,可空含义随约束变化。新手若混用,类型推断会让你困惑。
Tip不确定
T?在当前约束下代表啥,看T是值类型还是引用类型约束,两者机制完全不同。
迁移旧代码的策略
老项目默认没开可空检查,加 #nullable enable 后可能冒出一片警告。别慌,逐文件开启更稳:
- 先给公共 API 的签名标
?,表达真实意图。 - 用
?.、??处理已有可能为 null 的路径。 - 对确实非空但编译器不信的,用
!谨慎断言(尽量少用)。
#nullable enable
string? ReadConfig() => null;
string Get() => ReadConfig() ?? "默认"; // 用 ?? 兜底,消除警告
Warning用
!强制消警告是”我保证非空”,不是”编译器保证”。乱用会掩盖真实 null 风险,能判空就别用!。
可空与集合、字典
集合元素也可空:List<string?> 表示列表里允许有 null 项;Dictionary<int, string?> 表示值可空。
List<string?> names = ["甲", null, "乙"];
foreach (var n in names)
Console.WriteLine(n?.Length ?? 0); // null 项安全处理
Dictionary<int, string?> map = new() { [1] = null };
string? v = map[1];
Console.WriteLine(v ?? "无值");
Note
Dictionary的TryGetValue配合可空也常见:取不到键时给默认,比直接[]访问更安全。
警告等级与注解传播
可空上下文有两种:注解上下文(是否允许 ? 语法)与警告上下文(是否对可能的 null 报警)。项目级 #nullable enable 同时打开两者。
方法参数标了 string?,调用方传普通 string 不会警告;但反过来传 string? 给要求非空的参数,编译器就会提醒你先判空。
void Print(string s) => Console.WriteLine(s.Length);
string? maybe = "x";
// Print(maybe); // 警告:maybe 可能为 null
Print(maybe!); // 强制,但慎用
Tip让可空注解沿方法签名”流动”:该可为空的地方显式
?,编译器就能全程帮你盯紧 null。
一道常见面试题直觉
“可空引用类型能完全杜绝 NullReferenceException 吗?“答案是不能——它是编译期分析,不是运行期保险。
它把大量”可能为 null 却没判”的疏漏在写代码时就标红,大幅提升安全性,但:
- 反射、反序列化等绕过编译检查的路径仍可能注入 null;
- 用了
!强制断言就绕过了保护。
所以运行期必要的 null 检查仍不可省,尤其在处理外部输入时。
Warning别因为开了可空就觉得”天下无 null”。它是强大的提醒工具,不是运行时防护盾,外部数据照样要校验。
小结
string? 是编译期可空注解,提醒你”可能为 null”;int? 是 Nullable<T> 包裹,让值类型也能表示”无值”。?. 安全访问、?? 给默认值,二者配合基本消灭空引用异常。可空是提醒而非保险,关键处仍要判空。至此,本教程的异步与模式匹配核心已铺开,后续章将转向内存与文件等高级主题。