"null!"语句是什么意思?

230

我最近看到了以下代码:

public class Person
{
    //line 1
    public string FirstName { get; }

    //line 2
    public string LastName { get; } = null!;

    //assign null is possible
    public string? MiddleName { get; } = null;

    public Person(string firstName, string lastName, string middleName)
    {
        FirstName = firstName;
        LastName = lastName;
        MiddleName = middleName;
    }

    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
        MiddleName = null;
    }
}

基本上,我尝试深入了解新的c# 8功能之一 NullableReferenceTypes
实际上,已经有很多关于它的文章和信息了。例如,这篇文章就很不错。
但是我没有找到任何关于这个新语句 null! 的信息。
有人能为我提供一个解释吗?
我为什么需要使用它?
line1line2之间有什么区别?


46
!空引用安全操作符,告诉编译器,即使它通常不允许,也应该睁一只眼闭一只眼,因为我们更清楚情况。 null! 本身的实际用途很小,因为它几乎完全否定了可为空(nullable)引用类型的有用性。 当你知道表达式不可能是 null ,但编译器不知道时,它就更有用了。 - Jeroen Mostert
1
@JeroenMostert 像强制执行一样的东西?我的意思是,即使这很不寻常,我们也要强制执行它。 - isxaker
5
是的,但这种情况超出寻常——因为在新规则下,string 不是可空引用类型,所以不应该为 null。赋值 null! 实际上是在说:“我知道这不应该为 null,但猜猜怎么着,我还是这么做了。” 几乎没有任何程序需要这样做——唯一的原因是你知道你会在其他人能够得到 NullReferenceException 之前分配一个非空值,并且想要表明你没有忘记分配它。这种情况可能出现,但不太可能,因此作为示例并不是很好。 - Jeroen Mostert
8
@JeroenMostert 我发现在单元测试中使用null非常有用。即使一个引用不应该为null,但并不意味着它不会为null,因此有时在这种情况下测试正确的行为是恰当的。 - Jesse
1
@JeroenMostert 它对于像Godot这样的游戏引擎也非常有用,因为你通常会将节点和资源的引用字段初始化推迟到 _Ready() 回调方法中,而不是构造函数中。它有效地让编译器保持安静。 - knightofiam
1
从注释中可以看出,Kotlin的lateinit var有相应的等效写法。 - Rifat
6个回答

411

TL;DR

理解 null! 的关键是理解 ! 运算符。 你可能以前使用过它作为 "非" 运算符。然而,自从C# 8.0 及其新的 "可空引用类型" 特性出现后,这个运算符获得了第二个含义。它可以用于 类型 来控制 Nullability,因此被称为 "Null Forgiving Operator"

基本上,null!! 运算符应用于值 null。这覆盖了值 null 的可为空性,告诉编译器 null 是一个 "非空" 类型。


典型用法

假设这个定义:

class Person
{
    // Not every person has a middle name. We express "no middle name" as "null"
    public string? MiddleName;
}

使用方法如下:
void LogPerson(Person person)
{
    Console.WriteLine(person.MiddleName.Length);  // WARNING: may be null
    Console.WriteLine(person.MiddleName!.Length); // No warning
}

这个操作符基本上是为了在这种情况下关闭编译器的空检查。
技术解释
你需要理解什么是 "null!" 的基础知识。
空安全性
C# 8.0试图帮助你管理你的空值。与其默认允许你将 null 赋给所有东西,他们反过来要求你明确地标记你想能够容纳 null 值的一切。
这是一个非常有用的功能,它可以通过强制你做出决策并执行它来避免 NullReferenceException。
工作原理
当谈论空安全性时,变量可以处于两种状态。
可为空 - 可以为 null。 不可为空 - 不能为 null。
自 C# 8.0 以来,所有引用类型默认都是不可为空的。自 C# 2.0 以来,值类型就已经是不可为空的了!
"可空性" 可以通过两个新的(类型级别的)操作符进行修改:
  • ! = 从NullableNon-Nullable
  • ? = 从Non-NullableNullable

这些运算符是彼此的对应关系。 编译器使用您使用这些运算符定义的信息来确保空值安全。

示例

? 运算符的用法。

这个运算符告诉编译器一个变量可以保存一个空值。它在定义变量时使用。

  • 可为空的 string? x;

    • x 是一个引用类型 - 所以默认情况下是非空的。
    • 我们使用 ? 运算符 - 使其可为空。
    • x = null 正常工作。
  • 不可为空的 string y;

    • y 是一个引用类型 - 所以默认情况下是非空的。
    • y = null 会生成一个警告,因为你将一个空值赋给了一个不应该为空的变量。

有趣的是:使用 object? 实际上只是 System.Nullable<object> 的语法糖。

! 运算符的使用。

这个运算符告诉编译器,某个可能为空的东西是可以安全访问的。 你表达了在这种情况下“不关心”空安全的意图。 它用于访问变量。

string x;
string? y;
  • x = y
    • 非法! 警告: "y" 可能为 null
    • 赋值语句的左侧是非空的,但右侧是可空的。
    • 因此,它是不正确的,从语义上讲是错误的。
  • x = y!
    • 合法!
    • y 是一个引用类型,应用了 ? 类型修饰符,所以如果没有其他证明,它是可空的。
    • 我们对 y 应用 !,这样可以覆盖其空值设置,使其成为非空的
    • 赋值语句的左右两侧都是非空的。从语义上讲是正确的。

警告: ! 运算符只在类型系统级别上关闭编译器检查 - 在运行时,值仍然可能为 null。

请谨慎使用!

你应该尽量避免使用Null-Forgiving-Operator,因为使用它可能是系统设计缺陷的症状,会使编译器所保证的null安全性失效。
原因
使用"!"操作符会导致非常难以发现的错误。如果一个属性被标记为非空,你会认为可以安全地使用它。但在运行时,突然遇到NullReferenceException异常,让你感到困惑。因为通过"!"绕过了编译器的检查,实际上值变成了null。
那么为什么还存在这个操作符呢?
下面详细介绍了一些合理使用情况。然而,在99%的情况下,你最好选择其他解决方案。请不要在代码中滥用"!"来消除警告。
在某些(边缘)情况下,编译器无法检测到可为空的值实际上是不可为空的。 更容易迁移遗留代码库。 在某些情况下,你只是不关心某个东西是否变为null。 在进行单元测试时,您可能希望检查代码在出现null时的行为。

好吗?但是null!是什么意思?

它告诉编译器null不是一个可为空的值。听起来很奇怪,对吧?

这与上面的例子中的y!是一样的。之所以看起来奇怪,是因为你将操作符应用于null字面量。但概念是相同的。在这种情况下,null字面量与任何其他表达式/类型/值/变量相同。

null字面量类型是唯一默认可为空的类型!但正如我们所学到的,任何类型的可空性都可以通过!覆盖为非可为空。

类型系统不关心变量的实际/运行时值,只关心其编译时类型,在你的例子中,你想要赋给LastNamenull!)的变量是非可为空的,就从类型系统的角度而言是有效的。

考虑一下这段(无效)代码。

object? null;
LastName = null!;

7
空值检查仅支持C#8及以上的版本。 - Patrick Hollweck
3
“一个字符串可以是null”这个说法不再成立了。如果你使用了C# 8并启用了NullableReferenceTypes特性,那么当你试图将null赋给字符串时,VS会立即提醒你。但是另一方面,你仍然可以引入可为空的字符串类型(例如string? s = null),此时不会出现警告。 - isxaker
4
尽量不要使用! Null-Forgiving-Operator, 否则将会抵消编译器保证的null安全性。你将如何为参数验证编写单元测试?这是我在 Noda Time 中最频繁使用! 的情况。 - Jon Skeet
4
@JonSkeet,我认为单元测试在这里是一个特殊情况,因为您故意使用null。在“您应该尽量不使用”中,我指的是“正常”的代码——在这种代码中,使用此运算符可能会产生意外的结果。也就是说,即使您声明了它是非空的,某些东西仍然变成了null。我想告诉人们不要随处添加!来摆脱警告,因为这样就无法获得空安全性的好处。稍后可能会编辑以更清晰地表达。 - Patrick Hollweck
1
我肯定会推荐稍后编辑 - 目前,任何广泛使用空值忽略运算符的测试人员都可能因您当前的文本而感到不满,因为它没有给出任何关于建议限制性的指示。在一些代码库中,“永远不”很可能无法实现 - 例如,在 Noda Time 中,我们在解析结果中具有一个不变式,这意味着我从不暴露空值,但是在解析本身失败时,我仍然会得到一个空值。我认为“尽力避免”更具积极的语气。这只是我的个人意见。 - Jon Skeet
显示剩余23条评论

25

null!用于为非空变量分配null,这是一种承诺变量在实际使用时不会为空的方式。

在Visual Studio扩展中,我会使用null!,其中属性通过反射由MEF初始化:

[Import] // Set by MEF
VSImports vs = null!;
[Import] // Set by MEF
IClassificationTypeRegistryService classificationRegistry = null!; 

(我讨厌变量在这个系统中以神奇的方式获取值,但事实就是这样。)

我还在单元测试中使用它来标记由设置方法初始化的变量:

public class MyUnitTests
{
    IDatabaseRepository _repo = null!;

    [OneTimeSetUp]
    public void PrepareTestDatabase()
    {
        ...
        _repo = ...
        ...
    }
}

如果在这种情况下你不使用null!,则每次读取变量时都必须使用感叹号,这将是一个没有好处的麻烦。

注意:null!是一个好主意的案例相当少见。我认为这是一种最后的手段。


5
这是唯一一个真正解释为什么人们会在他们的源代码中写入null!的答案。 - user3840170
2
不,它并不会这样做。在这两种情况下,最好根本不要分配变量。没有理由用null默认初始化这些成员。 - Patrick Hollweck
2
@PatrickHollweck 不,未分配值会导致警告CS8618(或在结构体构造函数中,错误CS0843)。 - Qwertie
不冒犯,但这是循环逻辑。同意Patrick的观点。现在看来,这也暴露了C# 8推理中的一个重大缺陷。通过将ref类型默认为所有非可空类型,Microsoft已经剥夺了初始化对象的一个重要状态,即null。如果我们现在被迫使用这种新逻辑,那么最好为每种类型创建一个默认值,而不是使用这些虚假的忽略null的旧方法。否则,这看起来很傻... - Stokely
1
逻辑中没有圆圈。 - Qwertie
比起那个接受的冗长解释,这个解释好多了,它没有解释你在第一句话中提到的内容 - 这是一个承诺,值不会为空。这就是我在自己的代码中感到困惑的地方 - 我有一个非空属性,在构造函数中调用的方法中进行初始化,但编译器抱怨我没有初始化它。Rider很贴心地建议使用null!,现在我明白为什么它会建议这样做了。 - Andris

20
当"可空引用类型"功能开启时,编译器会跟踪代码中哪些值可能为null或非null。但有时编译器的知识不足。比如,您可能正在使用延迟初始化模式,在该模式下构造函数未对所有字段进行初始化,但总是调用一个初始化方法来保证字段非null。在这种情况下,您面临一个折衷:
1.如果将字段标记为可空,编译器将满意,但在使用该字段时不必要地进行null检查。 2.如果将字段保留为非空,编译器将抱怨该字段未由构造函数初始化(您可以使用“null!”来禁止此警告),然后该字段可以在不进行空检查的情况下使用。
请注意,使用“!”抑制运算符会带来一定的风险。想象一下,您实际上并没有像您认为的那样一致地初始化所有字段。然后使用“null!”来初始化字段就掩盖了null值的事实。一些未经注意的代码可能接收到null值,因此失败。
更一般地说,您可能具有某些领域知识:“如果我检查了某个方法,那么我知道某个值不是null”。
if (CheckEverythingIsReady())
{
   // you know that `field` is non-null, but the compiler doesn't. The suppression can help
   UseNonNullValueFromField(this.field!);
}

你必须对你的代码不变性有信心才能这样做("我知道更好")。


4

我认为没有具体的C#版本作为前提条件,这个问题是无法讨论的。

public string RpyDescription { get; set; } = null!;

在.NET 6 Core中,这是很常见的...实际上如果您想描述一个字符串为非可空,则必须使用它。存在这个功能的原因之一是当您使用SQL数据库时,一个字段可以设置为“允许空值”。尤其是当使用接受null的JSON结构时,EF需要知道。

Value Type(数据存储在Stack中的内存位置)的Reference Type(指向数据存储的内存位置的指针)。

.NET 6(C#10)默认启用项目模板的可空上下文(在此之前,默认情况下禁用可空上下文)。

在EF/Core中,了解数据库null和模型/实体null之间的关系非常重要。


你是对的,实际上我会更进一步,无论是API、数据库、GUI还是JSON结构。你需要为其他程序员提供“Null规则”,例如汽车有什么类型的轮子。如果我们允许这个值为空,但是我们的数据库需要它是一个非空值,或者我们正在创建的JSON需要是一个非空值,那么这将远不如现在的情况,我们可以明确地说明Null规则。边缘情况应该通过创建一些合理的默认值并注释为什么要实例化该对象而没有该值来处理。 - jdmneon
为什么不使用public string RpyDescription { get; set; } = ""; - Qwertie
1
@Qwertie 基本上(在使用 EF 时),“string?Property” 表示一个可选属性,“string Property = string.Empty;” 是一个带有默认值 string.Empty 的强制属性,“string Property = null!;” 是一个没有默认值的强制属性,即必须在将实体写入数据库之前显式设置该属性。 - hhravn
@Qwertie 在我的工作(银行业)中,将空字符串分配为默认值非常危险。我说了两次“非常”吗? - Persk

2
一个有效的使用案例是在运行时分配对象的值,例如使用IOption属性映射时所需的情况。
你可以选择以下方式之一:
  1. 将属性标记为可为空,并在每个地方处理空检查。
  2. 设置默认值为"",这将允许配置属性未定义,然后在使用时检查string.IsNullOrEmpty()
  3. 将默认值设置为null!,这将禁用IDE警告,并且如果未设置属性,则会在配置启动时强制应用程序退出。

0

这个问题需要更新,在 C# 10 中,对象关系映射中,这个运算符与 ? 运算符结合使用至关重要,这是一种告诉项目中其他程序员、未来的程序员以及提醒自己数据最终将如何被消耗以及有关该数据的空值规则的方法。

public string Data { get; set; } = null!;
public string? Nullable { get; set; }

这样做的好处是,您不必查看API文档(您可能没有),也不必查看数据库(您甚至可能无权访问)。通过查看类,您已经知道了有关空值规则的信息。

缺点是,如果您实例化该类,并且没有获取实例化NOT NULL值所需的信息,则会得到一些奇怪的默认值,这些默认值并不总是有意义的。首先,这种情况并不像人们想象的那样常见,通常是由于懒惰编程造成的。当您实例化一个类的实例时,应立即计算或分配这些NOT NULL属性。如果您声明了一辆汽车,并且在数据库或API中汽车有轮子,如果您声明了一辆汽车而没有将属性值分配给轮子。

那么您真正有意义地实例化了汽车吗?您肯定没有按照数据库理解汽车的方式定义汽车,它是一辆没有轮子的汽车,这是不应该的。这样做可能很方便,但违反了面向对象编程的基本原则。

是否有例外情况?例如,在那个时间点上或直到其他事件发生在这些边缘情况下,可能无法知道该值的情况。如果您的默认值影响数据库,手动创建一个有意义的默认值,您将准确知道问题所在

对于讨论单元测试的人,为什么要测试空情况?在对象的上下文中,空情况是不可能的,没有必要了解没有车轮的汽车会如何表现。这是一个愚蠢、设计不良的测试。

我甚至可以说,当涉及字符串时,这个操作符应该绝大多数时间被使用。当我们声明 int、bool 或 float 时,我们有理解这些值除非明确声明为 null 否则不能为 null。为什么你们要提倡使用不同的规则来处理字符串呢!?之前不能这样做是设计上的缺陷。


1
我还要补充一点,很多时候人们会默认使用“Code First”模型。但在现实生活中,数据及其相关规则几乎总是在应用程序之前存在的。使用“Database First”编写应用程序的数量被严重低估了。编写“Code First”很好,但很多时候并不切实际。你并不总是能够完全控制你的数据库。 - jdmneon

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接