C# – Nullable Reference Types

前言

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,希望以后还会进步吧。

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

 

posted @ 2025-10-07 11:44  兴杰  阅读(2)  评论(0)    收藏  举报