惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

IT之家
IT之家
Recent Announcements
Recent Announcements
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The GitHub Blog
The GitHub Blog
MyScale Blog
MyScale Blog
爱范儿
爱范儿
GbyAI
GbyAI
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
美团技术团队
Y
Y Combinator Blog
博客园 - 叶小钗
Apple Machine Learning Research
Apple Machine Learning Research
Martin Fowler
Martin Fowler
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
罗磊的独立博客
M
MIT News - Artificial intelligence
博客园 - Franky
V
Visual Studio Blog
I
InfoQ
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
腾讯CDC
博客园 - 司徒正美
L
LangChain Blog

博客园 - 兴杰

JavaScript Library – Astro 工具 - pnpm 前端基础几何计算 – basic geometric tan sin cos CSS & JS Effect – 用 SVG 画曲线和箭头 CSS & JS Effect – 画直角三角形与边框 CSS & JS Effect – 画箭头 ASP.NET Core Library – Magick.NET CSS & JS Effect – Smooth scrollIntoView JavaScript Library – Embla Carousel DOM – checkVisibility 富文本编辑器 ProseMirror 第二篇 の Schema 富文本编辑器 ProseMirror 第一篇 の 初探 JavaScript – Pipeline Operator 工具 – Playwright 工具 – Rsbuild JavaScript Library – Markdown CSS & JS Effect – 大杂烩 BOM – Clipboard API OData – 大杂烩 OData – 基础语法 Basic
C# – Nullable Reference Types
兴杰 · 2025-10-07 · via 博客园 - 兴杰

前言

Nullable Reference Types 是 C# 8.0 推出的东东,用了好几年了,今天心血来潮特写一篇介绍它。

参考:

官方 docs – Nullable reference types

官方 docs – Attributes for null-state static analysis interpreted by the C# compiler

官方 docs – Unconstrained type parameter annotations

前世

我们先讲一些比较基础和早期(8.0 之前)的情况。

C# 类型可以抽象分成两大类,第一个是 value types 值类型,第二个是 reference types 引用类型。

Value Types 对比 Reference Types

主要两个区别:

  1. 指针和拷贝

    int v1 = 100;
    int v2 = v1;
    v1 = 300;
    Console.WriteLine(v2); // 100

    值类型 (e.g. int) 是拷贝的概念,v2 = v1 意思是把 v1 的值 copy paste to v2,然后它俩就没关系了,修改 v1 不会影响到 v2

    引用类型 (e.g. array) 则是指针概念

    var v1 = new int[1] { 100 };
    var v2 = v1;
    v1[0] = 300;
    Console.WriteLine(v2[0]); // 300

    v2 = v1 不是拷贝,而是一种指向概念,这两个 variable hold 的值是同一个内存地址,修改 v1 等同于修改 v2

  2. nullable vs non-nullable

    引用类型天生是 nullable,值类型则不可为 null

    public class Person
    {
      public int Id { get; set; }
      public int[] Ids { get; set; }
    }
    
    var person = new Person();
    Console.WriteLine(person.Id == 0);     // true
    Console.WriteLine(person.Ids == null); // true
    
    person.Id = null; // Cannot convert null to 'int' because it is a non-nullable value typeCS0037

    int Id 不可能是 null,它至少会是 default(int) 也就是 0

    尝试赋值 nullint 会直接报错。

    int[] 则是 nullabledefault(int[]) 会得到 null

提醒:C# 的 string 是引用类型,DateTime 是值类型(它是 struct)。

Nullable Value Types

值类型没有 null 概念是很不方便的,你不能说 0 就代表 "没有",0 就是一个数,它只是默认,但不是 "没有"。

所以 C# 搞了一个 Nullable<T>

长这样:

int? number = null;
number = 1;

// int? 是语法糖,它的本质是 Nullable<int>
Nullable<int> number2 = null;
number2 = 1;

Nullable 不是 class,而是 struct

image

使用方式是这样:

int? number = 1;
if (number.HasValue) // 或者 number != null
{
  Console.WriteLine(number.Value); // 透过 .Value 获取到 int 值
}

var number1 = number.GetValueOrDefault(); // 返回值,如果没有值就返回 default 值,比如 0

先判断一下,确定有值才使用,或者当没有值时,采用默认值。

总之,就是要有明确对 null 情况的处理就是了。

有了 Nullable<T> struct, 值类型就具备 nullable 能力了。

今生

值类型和引用类型都具备 nullable 能力。

但是值类型同时具备 non-nullable 能力,而引用类型却不具备。

还有,值类型默认是 non-nullable,而引用类型则一定是 nullable

这也造成了开发时的混乱。

Nullable Reference Types

于是,在 C# 8.0 推出了 Nullable Reference Types 概念。

首先,需要开启这个机制:

打开 .csproj 文件,加入

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable> <!--加入这一句-->
    <LangVersion>8.0</LangVersion>
  </PropertyGroup>

</Project>

开启后

int[] numbers = null; // Warning: Converting null literal or possible null value to non-nullable type

赋值 null 给引用类型会出现一个警告。

因为引用类型默认必须是 non-nullable,这和值类型规则一致。

int[] numbers = { 1, 2, 3 }; // 不给 null 就没有警告了

另外,在类型右边加上问号 int[]? 代表是 nullable

int[]? numbers2 = null; // ok

这也和值类型规则一致。

使用方式也类似

int[]? numbers2 = null;

Console.WriteLine(numbers2.Length); // 直接使用会有 Warning: Dereference of a possibly null reference,因为如果是 null 会 runtime error

// 先判断确定不是 null 才使用
if (numbers2 != null)
{
  Console.WriteLine(numbers2.Length); // ok, no warning
}

Console.WriteLine(numbers2!.Length); // 惊叹号 ! 是 bypass 检测的意思,放了就不会有 warning 了。(自己要小心 runtime error)

Console.WriteLine(numbers2?.Length); // ?. 的意思是, 如果 number2 是 null,那直接中断,返回 null。

先判断一下,确定不是 null 才使用,或者中断返回 null,或者断言一定有值。

总之,就是要有明确对 null 情况的处理就是了。

nullable value types 和 nullable reference types 的区别

表面上看,它俩确实挺相像的,但从本质上,它俩是不同的思路:

  1. 值类型本身是不能 null 的,它是利用了 Nullable<T> struct 才间接实现了 nullable

  2. 引用类型一定是 nullable,开启 nullable reference types 机制只是一种 IDE 层面的检测(也因此它只是个 warning,而不是 compile error)。

  3. default 的返回值

    Console.WriteLine(default(int) == 0);       // true
    Console.WriteLine(default(int?) == null);   // true
    
    Console.WriteLine(default(int[]) == null);  // true,也是 true 哦!
    Console.WriteLine(default(int[]?) == null); // true
  4. 注意:default 对值类型的 nullable 是有区别的,但对于引用类型来说,default 始终只返回 null

所以,我们最好是把它俩分开看待,不要混肴。比如说 int[]? 是不是等于 Nullable<int[]>?当然不是!

当 nullable 遇上 generic 泛型约束

参考:官网 docs – Nullable reference types #constraints

首先,讲一点关于泛型 generic 和约束 constraints 相关的基础知识:

这是一个带有泛型的 Person

// C# 7.0 测试

public class Person<T>{}

var v1 = new Person<int>();   // 值类型
var v2 = new Person<int?>();  // nullable 值类型
var v3 = new Person<int[]>(); // 引用类型

泛型没有任何约束,可以用值类型、nullable 值类型、也可以用引用类型。

加上约束

public class Person<T> where T : class {}
    
var v1 = new Person<int>();   // The type 'int' must be a reference type
var v2 = new Person<int?>();  // The type 'int?' must be a reference type

var v3 = new Person<int[]>(); // ok

T : class 表示:T 必须是引用类型(e.g. interface、array、class instance 都是)

只有 v3int[] 是引用类型,可以通过 。

intint? 是值类型,会报错。

把约束换成

public class Person<T> where T : struct {}
    
var v1 = new Person<int>();   // 值类型可以

var v2 = new Person<int?>();  // The type 'int?' must be a non-nullable value type
var v3 = new Person<int[]>(); //  type 'int[]' must be a non-nullable value type

T : struct 表示:T 必须是值类型,而且是 non-nullable(e.g. int、bool、DateTime)。

注意哦,Nullable<T> 不行哦。

C# 没有办法约束 T 必须是 Nullable<T>,做不到。

public class Person<T> where T : Nullable<int> {} // Error: 'int?' is not a valid constraint

不只是 Nullable<T>,其实任何 struct 都不行。

public struct MyNullable { }

public class Person<T> where T : MyNullable {} // Error: 'MyNullable' is not a valid constraint

C# 8.0 的泛型约束

C# 8.0 改了/也多了一些相关的泛型约束

public class Person<T> where T : class {}
    
var v1 = new Person<int[]>();  // ok
var v2 = new Person<int[]?>(); // Warning: Nullability of type argument 'int[]?' doesn't match 'class' constraint.

T : class 表示:T 是引用类型,而且是 non-nullable

因此 init[]? 会发出警告。

public class Person<T> where T : class? {}
    
var v1 = new Person<int[]>();  // ok
var v2 = new Person<int[]?>(); // ok

T : class? 则表示:T 是引用类型,nullable 也行。

提醒:依然没有 T : struct? 哦。

另外,8.0 还多了一个

public class Person<T> where T : notnull {}

var v1 = new Person<int>();    // ok
var v2 = new Person<int?>();   // Warning: Nullability of type argument 'int?' doesn't match 'notnull' constraint.

var v3 = new Person<int[]>();  // ok
var v4 = new Person<int[]?>(); // Warning: Nullability of type argument 'int[]?' doesn't match 'notnull' constraint.

T : notnull 表示:可以是值类型或引用类型,但是不能是 nullable。 

当 nullable 遇上 generic 泛型

参考:官网 docs – Nullable reference types #Generics

上一 part 我们聚焦在泛型约束,这一 part 我们来看看泛型的使用。

C# 8.0 的限制

我们先看 C# 8.0 的版本(9.0 有改动)

// C# 8.0 测试

public class Person<T>
{
  public T Value { get; set; } // Warning: Non-nullable property 'Value' must contain a non-null value when exiting constructor
}

var v1 = new Person<int>().Value;    // non-nullable 值类型
var v2 = new Person<int?>().Value;   // nullable 值类型
var v3 = new Person<int[]>().Value;  // non-nullable 引用类型
var v4 = new Person<int[]?>().Value; // nullable 引用类型

出现了一个 warning,警告 Value 可能为 null

这是合理的,试想想,假如传入的 Tint[]Value 默认值是 default(int[]) 得出 null

这样就不 match 了,int[] 不允许 null,但是 default(int[]) 却只能得出 null

怎么破?

public class Person<T>
{
  public T? Value { get; set; } // Error: A nullable type parameter must be known to be a value type or non-nullable reference type
}

既然有可能是 null,那就改成 T? 看看。

结果报错了!这是 C# 8.0 的一个局限,T? 只能用于当 T 约束是 non-nullable 值类型或 non-nullable 引用类型。

改成

public class Person<T> where T : class
{
  public T? Value { get; set; }
}
// 或者
public class Person<T> where T : struct
{
  public T? Value { get; set; }
}

这样就可以了。

注:T : notnullT : class? 是不行的哦:

public class Person<T> where T : notnull
{
  public T? Value { get; set; } // Error: A nullable type parameter must be known to be a value type or non-nullable reference type
}

public class Person<T> where T : class?
{
  public T? Value { get; set; } // Error: A nullable type parameter must be known to be a value type or non-nullable reference type
}

为什么它有诸多限制?我个人的理解是这样:

T? 意味着 T 一定要是 non-nullable,否则就会出现 Nullable<Nullable<int>> 这种嵌套的问题(当然编译器可以处理,但也许是当初还没有完善吧)。

另外,nullable 值类型和 nullabla 引用类型本质上有很大区别,混用可能会导致一些不明确的逻辑(也许当初还没约定好吧),因此 C# 8.0 一定要明确泛型约束后才能使用 T?

好,细节就不管了,反正 C# 9.0 后限制就突破了。

C# 9.0

C# 9.0 突破了之前的限制:

// 没有约束 ok
public class Person<T>
{
  public T? Value { get; set; }
}

// 约束成 nullable 引用类型 ok
public class Person<T> where T : class?
{
  public T? Value { get; set; }
}

// 约束成 non-nullable 引用/值类型 ok
public class Person<T> where T : notnull
{
  public T? Value { get; set; }
}

虽然可以无忧无虑的使用 T? 但是它有一些潜规则,我们需要知道。

public class Person<T>
{
  public T? Value { get; set; }
}

var v1 = new Person<int[]>().Value;  // int[]?

我们一个一个看,int[] 变成了 int[]?

合理,因为里面是 T?

var v2 = new Person<int[]?>().Value; // int[]?

int[]? 出来还是 int[]? 。

合理,总不可能嵌套成 int[]?? 吧。

var v3 = new Person<int?>().Value;   // int?

int? 出来还是 int?

和上一题一样,没有变成嵌套的 Nullable<Nullable<int>>,合理。

var v4 = new Person<int>().Value;    // int

咦...前面三个出来都是 nullable,符合 T? 的表达,怎么这一个出来不是 nullable 呢?

没错,这就是混用的局限和潜规则。

查看 sharplab.io 

图片1

透过反编译(我们写的 C# → 编译 → IL → 反编译 → 优化过的 C#),我们可以看到最终 C# 的 source code 是 T 而已,不是 T? 或 Nullable<T>

因此,传入 int 结果当然只能是 int,不可能变成 int?

因为nullable 值类型和引用类型是完全不同的实现方式(值类型需要 Nullable struct),

所以在不明确 T 的情况下,根本不可能有一种代码能实现两种类型的 nullable

我们试试明确 T 为值类型看看

image

T? 变成了 Nullable<T>,效果

var v1 = new Person<int>().Value;    // int?

成功变成 int? 了。

潜规则怎么记?

T? 表示它理应是一个 nullable

绝大多数情况下,它也确实会是 nullable

只有在 T 不明确(可能是值类型和引用类型)时,如果 Tnon-nullable 值类型,它不会变成 nullable

比如

public class Person<T> where T : notnull // T 可能是值类型或引用类型
{
  public T? Value { get; set; }
}

var v1 = new Person<int>().Value;    // int    没有变成 nullable
var v2 = new Person<int[]>().Value;  // int[]? 变成 nullable

还有

public class Person<T> // T 可能是值类型或引用类型
{
  public T? Value { get; set; }
}

var v1 = new Person<int>().Value;    // int    没有变成 nullable
var v2 = new Person<int[]>().Value;  // int[]? 变成 nullable

记住了哦。

Attributes for Nullable Reference Types

参考:官方 docs – Attributes for null-state static analysis interpreted by the C# compiler

Nullable Reference Types 使用方式大致如上,日常够用了。

不过,有时候会遇到一些比较奇葩的场景,这时可能需要借助 Attributes 的能力,让代码更加 type-safe。

我们一个一个 Attributes 看一看 🚀。

AllowNull

AllowNull Attribute 用在 class property 的 setter 上。

public class Person
{
  private string _value = "default value";

  public string Value
  {
    get => _value;
    set => _value = value;
  }
}

有一个 Person 类,里面有一个 Value 属性。

Value 是 non-nullable,但是我们希望允许赋值 null 给它,因为可以在 setter 里面做 null 的处理(比如给默认值)。

如果我们把它改成 string?

public class Person
{
  private string _value = "default value";

  public string? Value
  {
    get => _value;
    set => _value = value ?? "default value";
  }
}


var person = new Person
{
  Value = null // 可以给 null
};

DoSomething(person);

static void DoSomething(Person person)
{
  Console.WriteLine(person.Value.Length); // Warning : Dereference of a possibly null reference
  // 因为 Value 是 nullable 所以这里有警告
}

这样连 getter 也变成 nullable 了,不是我们期望的。

唯一的解法就是使用 AllowNull Attribute。

public class Person
{
  private string _value = "default value";

  [AllowNull] // 加上 AllowNull 让 setter 允许 set null 进来,与此同时,getter 依然是 non-nullable
  public string Value
  {
    get => _value;
    set => _value = value ?? "default value";
  }
}

var person = new Person
{
  Value = null // 可以给 null
};

DoSomething(person);

static void DoSomething(Person person)
{
  Console.WriteLine(person.Value.Length); // 没有 warning 了
}

提醒:只有引用类型可以使用 AllowNull,值类型不行。

因为倘若要允许给值类型 setnull,那它的类型就要变成 Nullable<T>,但是我们并没有要换掉 Value 的类型,所以根本办不到。

反观,引用类型它不管是不是 nullable,它本质上就只有一个 string 类型而已,string? 只是 IDE 层面的东西,最终的 source code 是没有 string? 这个写法的。

DisallowNull

DisallowNull 顾名思义,就是 AllowNull 的相反。

public class Person
{
  private int? _value;

  [DisallowNull]
  public int? Value
  {
    get => _value;
    set => _value = value;
  }
}


var person = new Person();
person.Value = null; // Warning: A possible null value may not be used for a type marked with [NotNull] or [DisallowNull]
// 不可以 set null

DoSomething(person);

static void DoSomething(Person person)
{
  Console.WriteLine(person.Value.HasValue); // 需要处理 Value 可能是 null 的情况
}

Value setter 不允许赋值 null,但 Value 本身是 nullable

也就是说,一开始允许 Valuenull,但后来赋值就不允许是 null 了。

当然,使用 Value 的时候,一定要处理 null 的情况。

注:property 是引用类型或值类型都可以使用 DisallowNull Attribute。

NotNull

NotNull Attribute 用在参数上。

有一个 throw when null 函数

static void ThrowWhenNull(object? value)
{
  if (value == null) throw new ArgumentNullException();
}

如果参数是 null 就 throw error。

它的使用方式是这样:

static void LogMessage(string? message)
{
  ThrowWhenNull(message);

  Console.WriteLine(message.Length); // Warning: Dereference of a possibly null reference
}

messagenullable,经过 ThrowWhenNull 函数检查,如果是 null 就 throw error,如果不是 null 就继续往下。

按理说,既然已经检测了,使用 message.Length 时,message 就不可能是 null,但这里却依旧 warning。

显然 IDE 不够聪明,我们需要使用 NotNull Attribute 帮助它。

// 在参数 value 左边加上 NotNull Attribute
static void ThrowWhenNull([NotNull] object? value)
{
  if (value == null) throw new ArgumentNullException();
}

static void LogMessage(string? message)
{
  ThrowWhenNull(message);

  Console.WriteLine(message.Length); // 没有 warning 了
}

它的意思是:参数 value 本来是 nullable,但只要函数允许完,参数就变成 non-nullable

因此在 ThrowWhenNull 后,message.Length 就不会有 nullable 的 warning 了。

其实我们不需要自己实现 ThrowWhenNull,因为有 built-in 的:

static void LogMessage(string? message)
{
  ArgumentNullException.ThrowIfNull(message);
  Console.WriteLine(message.Length);
}

源码:

image

它就是用 NotNull Attribute 实现的。

NotNullWhen

NotNullWhen Attribute 和 NotNull 挺类似的,区别在于:NotNull 是看函数有没有执行完(没有 throw),而 NotNullWhen 则是看函数的返回值 bool

// 如果函数返回 true,那 value 就不是 null
static bool NotNullOrEmpty([NotNullWhen(true)] string? value)
{
  return value != null && value.Length > 0;
}

static void LogMessage(string? message)
{
  if (NotNullOrEmpty(message))
  {
    Console.WriteLine(message.Length); // 没有 warning
  }
}

built-in 的 string.IsNullOrEmpty 函数就用到了 NotNullWhen Attribute:

image

再一个例子:

static bool TryParse(string? format, out string? parsed)
{
  parsed = format == "test" ? "yes" : null;
  return parsed != null;
}

static void LogMessage(string? message)
{
  if (TryParse("test", out var parsed))
  {
    Console.WriteLine(parsed.Length); // Warning: Dereference of a possibly null reference
  }
}

parsed.Length 出现了警告,因为 IDE 不够聪明,以为 parsed 可能是 null

out string? parsed 加上 NotNullWhen(true)

static bool TryParse(string format, [NotNullWhen(true)] out string? parsed)

这样就不会有 warning 了。

NotNullIfNotNull

NotNullIfNotNull Attribute 用在定义函数返回值,它会依据某个参数是不是 nullable 来决定返回值是不是 nullable

// 如果 format 是 nullable 那 return 也是 nullable,
// 如果 format 是 non-nullable 那 return 也是 non-nullable
[return: NotNullIfNotNull(nameof(format))]
static string? Parse(string? format)
{
  return format == null ? null : "yes";
}

static void LogMessage(string message)
{
  // 如果 message 是 nullable 那 parsed 就是 nullable 
  // 如果 message 是 non-nullable 那 parsed 就是 non-nullable
  var parsed = Parse(message);
  Console.WriteLine(parsed.Length); // 没有 warning
}

注意看,它和 NotNullWhen 用法不太一样,它判断的是参数的类型,而不是实质的 value。

比如说参数类型是 string? 那 return 就是 string?,不管最终参数值是不是 null。 

我个人的感觉NotNullIfNotNull没有 NotNullWhen 来的好用。

MemberNotNull

MemberNotNull Attribute 用在 class method 上

public class Person
{
  public string? Name { get; set; }
  public int? Age { get; set; }

  [MemberNotNull(nameof(Name), nameof(Age))]
  public void Init()
  {
    Name = "default value";
    Age = 0;
  }
}

它的逻辑是这样:

NameAge 一开始是 nullable,在调用 Init 方法后,NameAge 就变成 non-nullable 了。

使用方式:

var person = new Person();
Console.WriteLine(person.Name.Length); // Warning: Dereference of a possibly null reference
person.Init();
Console.WriteLine(person.Name.Length); // 不会 warning 了

MemberNotNullWhen

MemberNotNullWhen Attribute 和 MemberNotNull 用法雷同,区别是它会返回 bool

public class Person
{
  public string? Name { get; set; }
  public int? Age { get; set; }

  // 返回 true 表示 initialize 成功
  [MemberNotNullWhen(true, nameof(Name), nameof(Age))]
  public bool Init()
  {
    Name = "default value";
    Age = 0;
    return true;
  }
}

var person = new Person();

if (person.Init()) // 判断成功才使用
{
  Console.WriteLine(person.Name.Length); // 没有 warning
}

// initialize 可能失败,所以这里依然会有 warning
Console.WriteLine(person.Name.Length); // Warning: Dereference of a possibly null reference

DoesNotReturn

DoesNotReturn Attribute 用在函数/方法上。

static void ThrowError()
{
  throw new Exception("whatever");
}

static void LogMessage(string? message)
{
  ThrowError();

  Console.WriteLine(message.Length); // Warning: Dereference of a possibly null reference
}

虽然已经 throw error 了,但是 warning 依旧。

因为 IDE 不够 smart,它感知不到。

我们需要添加 DoesNotReturn 来帮助它。

[DoesNotReturn]
static void ThrowError()
{
  throw new Exception("whatever");
}

static void LogMessage(string? message)
{
  ThrowError();

  Console.WriteLine(message.Length); // 没 warning 了
}

总结

比起 TypeScript,感觉 C# 对于 nullable 的处理不是很完善。

尤其是还需要搞一些 Attribute 来帮助 IDE,希望以后还会进步吧。

先写这些,以后有用到更复杂的例子再回来补。