非空引用类型的默认值与非空值类型的默认值

14

这不是我第一次关于可为空引用类型的问题,因为我已经尝试了几个月。但是,我越尝试,就越感到困惑,并且我看不到这个功能所添加的价值。

以这段代码为例

string? nullableString = default(string?);
string nonNullableString = default(string);

int? nullableInt = default(int?);
int nonNullableInt = default(int);

执行该操作将得到:

nullableString => null

nonNullableString => null

nullableInt => null

nonNullableInt => 0

(非可空) 整数的默认值一直是 0, 但是对我来说,非可空字符串的默认值为 null 没有意义。为什么会做出这个选择呢?这与我们一直所熟悉的非空原则相悖。我认为非可空字符串的默认值应该是 String.Empty

我的意思是,在 C# 的实现深处,必须已经指定了 int 的默认值为 0。我们也可以选择 12,但没有,共识是 0。那么,当可空引用类型特性被激活时,我们就不能指定一个 string 的默认值为 String.Empty 吗?此外,微软似乎希望在不久的将来使用 .NET 5 项目默认激活它,因此该功能将成为 "正常" 行为。

现在用一个对象来举例:

Foo? nullableFoo = default(Foo?);
Foo nonNullableFoo = default(Foo);

这将得到:

nullableFoo => null

nonNullableFoo => null

再次地,对我来说这没有意义,我认为一个Foo的默认值应该是new Foo()(或如果没有无参构造函数则给出编译错误)。

为什么默认情况下将不应该为空的对象设置为null呢?

现在更进一步扩展这个问题:

string nonNullableString = null;
int nonNullableInt = null;
编译器对第一行给出了警告,通过在我们的 .csproj 文件中进行简单配置,可以将其转换为错误: <WarningsAsErrors>CS8600</WarningsAsErrors>。而对于第二行,预期会给出编译错误。 因此,非可空值类型和非可空引用类型之间的行为不同,但这是可以接受的,因为我可以覆盖它。 然而,在执行此操作时:
string nonNullableString = null!;
int nonNullableInt = null!;

编译器对第一行完全没有问题,没有任何警告。

最近在尝试可空引用类型功能时,我发现了 null! ,我本以为编译器对第二行也没问题,但实际情况并非如此。现在我真的很困惑,不知道为什么微软会决定实现不同的行为。

考虑到它根本不能保护非可空引用类型变量中的 null,因此这个新特性似乎没有改变任何东西,也没有改善开发人员的生活(与非可空值类型不同,它们可能是不可能为空的,因此不需要进行 null 检查)。

所以最后看起来唯一增加的价值就是签名方面的。开发人员现在可以明确指出方法的返回值是否可以为空或属性是否可以为空(例如,在表示数据库表的 C# 中,NULL 是允许在列中出现的值)。

除此之外,我不知道怎样有效地使用这个新特性,请给我其他有用的例子,以展示您如何使用可空引用类型?

我真的很希望能够善用这个功能,以改善我的开发人员生活,但我真的看不到如何做到...

谢谢


1
您忽略了 string nonNullableString = default(string); 生成的警告,这就是为什么您看不到任何好处。 您明确地将 null 存储到非可空变量中,因此编译器会抱怨并告诉您出了问题。 这就是这个功能的好处,特别是当您将警告视为错误时。 - Panagiotis Kanavos
1
我并没有忽略那些警告,事实上我的配置现在是<WarningsAsErrors>CS8600;CS8625</WarningsAsErrors>。我只是对为什么这种情况可能发生感到非常困惑。我们永远无法确定一个非空引用类型不会为空,但对于值类型,我们可以确定它不会为空... - Jérôme MEVEL
我已经在下面回答了你问题中的实质部分。另外,我建议你在这里提问时尽量使用一些不那么带有情绪色彩的语言。你选择的措辞(“这没有意义”,“这完全违反了我们一直习惯的原则”)让你的问题看起来更像是在抱怨而不是真诚地询问。人们不喜欢被抱怨,所以如果Roslyn团队的一个真正的程序员读到了你的帖子,他们可能会采取更多的“为自己辩护”态度而不是“让这个家伙理解一下”的态度,这并不有助于讨论。 - V0ldek
@JérômeMEVEL 许多显然无效/错误的代码在语法上是完全合法的,无法被检测为错误...而许多完全安全和合法的事情在计算上是不可能证明有效的(参见“停机问题”)。编译器的工作是阻止明确的问题,尝试警告您可能存在的问题,但在您可能是正确的时候不会主动阻碍您。在这种情况下:它给了你一个警告-你还想要什么? - Marc Gravell
1
@V0ldek 非常感谢你的长篇回答,对于我的措辞不当,很抱歉。英语不是我的母语。我的目的不是简单地发牢骚,而是想展示这个功能的行为方式为什么让我感到困惑,以便获得不同的意见并更好地理解它。 - Jérôme MEVEL
我认为这只是一个非常令人困惑的命名特性。它被称为“可空引用类型”,但实际上它只是“到处都是可空警告”。每个我谈论此特性的人都很困惑,并且有着与你相同的期望 - 它实际上意味着一个不可为空且具有非空默认值的类型。 - user1568891
2个回答

10

您对编程语言设计的运作方式感到非常困惑。

默认值

(非空)整数的默认值一直是 0,但对我来说,非空字符串的默认值是 null 毫无意义。为什么会这样选择?完全违反了我们一直所使用的非空原则。我认为非空字符串的默认值应该是 String.Empty

变量的默认值是C#语言自始至终基本功能之一。 规范定义了默认值:

对于 value_type 类型的变量,默认值与其 default 构造函数的结果相同([请参见]默认构造函数)。 对于 reference_type 类型的变量,默认值为 null

从实践角度来看,这是有道理的。因为默认值的基本用法之一是在声明给定类型的新值数组时。由于此定义,运行时可以将分配的数组中的所有位都清零-值类型的默认构造函数始终是所有字段中的全零值,并且null表示为空引用的全零引用。这实际上是规范中的下一个行。

通常通过在使用之前内存管理器或垃圾收集器将存储器初始化为所有位零来执行初始化。因此,使用所有位零表示空引用非常方便。

现在具有可空引用类型(NRT)功能是在C#8中发布的。这里的选择不是“尽管有NRT,让我们实现默认值为null”,而是“让我们不要浪费时间和资源来彻底重新设计默认关键字的工作方式,因为我们正在引入NRT”。 NRT是针对程序员的注释,按设计它们对运行时没有任何影响

我认为,不能为引用类型指定默认值与不能在值类型上定义无参数构造函数的情况类似-运行时需要快速的全零默认值,而null的值对于引用类型来说是合理的默认值。并非所有类型都会有合理的默认值-对于 TcpClient 来说什么是合理的默认值?

如果您想要自己的自定义默认值,请实现静态的 Default 方法或属性,并记录该方法,以便开发人员可以将其用作该类型的默认值。无需更改语言的基本原则。

我的意思是,在C#的实现中,必须规定整数类型int的默认值为0。我们也可以选择1或2作为默认值,但共识是0。因此,当可为空引用类型功能被激活时,我们是否可以指定字符串类型string的默认值为String.Empty呢?
正如我所说,底层是将一块内存清零非常快速和方便的方法。没有运行时组件负责检查给定类型的默认值,并在创建新数组时重复该值,因为那样会非常低效。
你的提议基本上意味着运行时必须在运行时检查字符串的可空元数据,并将所有零值的非空字符串视为空字符串。这将是一个非常深入入手的更改,仅针对空字符串的这一特殊情况进行操作。使用静态分析器警告您将null分配给非空字符串而不是合理的默认值的成本效益要高得多。幸运的是,我们有这样的分析器,即NRT功能,它始终拒绝编译包含此类定义的类。
string Foo { get; set; }

通过发出警告并强制我更改为:
string Foo { get; set; } = "";

(顺便说一下,我建议打开警告视为错误,但这是个人口味问题。)

再次,这对我来说没有意义,我认为 Foo 的默认值应该是 new Foo()(如果没有无参数构造函数则会导致编译错误)。为什么要将本不应为空的对象默认设置为 null?

这将使您无法声明没有默认构造函数的引用类型的数组。大多数基本集合都使用数组作为底层存储,包括 List<T>。而且这还需要在创建大小为 N 的数组时分配 N 个默认实例,这非常低效。此外,构造函数可能会产生副作用。我不会再思考这将会破坏多少东西,但可以肯定的是,这并不是一项容易的更改。考虑到 NRT 非常复杂(Roslyn 存储库中的 NullableReferenceTypesTests.cs 文件单独就有约 130,000 行代码),引入这样的更改的成本效益...并不太好。

感叹号操作符(!)和可空值类型

编译器对第一行没有任何警告。最近在使用可空引用类型特性时,我发现了 null!,我原以为编译器也会对第二行不发出警告,但事实并非如此。现在我真的很困惑为什么 Microsoft 决定实现不同的行为。

null 值仅对引用类型和可空值类型有效。可空类型也是在规范中定义的

可空类型可以表示其基础类型的所有值以及一个额外的 null 值。可空类型写作 T?,其中 T 是基础类型。此语法是 System.Nullable<T> 的简写形式,两种形式可互换使用。(...) 可空类型 T? 的实例具有两个公共只读属性:

  • 一个 HasValue 类型为 bool 的属性
  • 一个 Value 类型为 T 的属性。当 HasValuetrue 时,该实例被称为非空。非空实例包含一个已知的值,并且 Value 返回该值。
你不能把 null 赋值给 int 的原因非常明显——int 是一个占用32位并表示整数的值类型。而 null 值是一个特殊的引用值,大小为机器字并表示内存中的位置。将 null 赋值给 int 没有任何有意义的语义。为了允许将 null 分配给值类型以表示“无值”情况,Nullable<T> 专门存在。但请注意,这样做:
int? x = null;

Nullable<T> 只是语法糖。如果 Nullable<T> 的值为全0,则表示“没有值”,因为这意味着 HasValuefalse。没有任何魔法的 null 值被分配到任何地方,它与说 = default 是一样的,只是创建一个给定类型 T 的新的全零结构体并将其分配。

所以,答案是 - 没有人有意尝试设计不兼容 NRT 的方式。 Nullable 值类型是自 C#2 引入以来就像这样工作的更基本的特性。您提出的工作方式无法转换为合理的实现 - 您是否希望所有值类型都可以为空?然后所有这些值类型都必须具有占用额外字节并且可能会破坏填充的 HasValue 字段(我认为代表 int 的语言作为40位类型而不是32位类型被认为是异端邪说 :))。

感叹号操作符是用于告诉编译器“我知道我正在取消引用可空类型/将 null 赋值给非可空类型,但我比你聪明,我确信不会出错”。它禁用静态分析警告。但它不能魔法般地扩展基础类型以适应 null 值。

摘要

考虑到它完全无法保护非可空引用类型变量中的 null,这个新功能似乎没有改变任何事情,也没有改善开发人员的生活(与不可为空值类型相反,不可为 null,因此不需要进行 null 检查)

因此,最终看起来唯一增加的价值只是在于签名。开发人员现在可以明确方法的返回值是否可以为 null 或不是,或者属性是否可以为 null 或不是(例如在表示数据库表的 C# 中,其中 NULL 是允许在列中出现的值)。

来自NRT 的官方文档

这个新功能比早期版本 C# 中处理引用变量提供了显著的优势,早期版本中无法从变量声明中确定设计意图。编译器对引用类型的空引用异常没有提供安全性 (...) 这些警告在编译时发出。编译器在可为空上下文中不添加任何空检查或其他运行时结构。在运行时,可为空引用和非可为空引用是等效的。

因此,您的说法是正确的,“唯一增加的价值只是在于签名”以及静态分析,这就是我们首先需要签名的原因。这并不是对开发人员生活的改善?请注意您的这一行:

string nonNullableString = default(string);

会发出警告。如果您没有忽略它(或者更好的做法是将"对待警告作为错误"打开),那么您将得到价值——编译器为您发现了代码中的错误。

它能保护您免受在运行时将null分配给非空引用类型的影响吗?不行。但它能提高开发人员的生产力,这是肯定的。该功能的强大之处在于编译时发出的警告和可为空性分析。如果您忽略 NRT 发出的警告,那就是自己找麻烦。您可以忽略编译器的帮助,但这并不意味着它无用。毕竟,您也可以将整个代码放入 unsafe 上下文中,并使用 C 语言进行编程,但这并不意味着 C# 是无用的,因为您可以规避其安全保证。


7
再说一遍,我认为Foo的默认值应该是new Foo()(如果没有无参构造函数则会出现编译错误)这个对我来说没有意义。但这是一种观点,而实际上并不是这样实现的,默认情况下,对于引用类型,default表示null,即使根据可空性规则是无效的。编译器会发现这一点,并在Foo nonNullableFoo = default(Foo);行上警告您:
警告CS8600:将null文字或可能的null值转换为非可空类型。
至于string nonNullableString = null!;
编译器对第一行完全没问题,没有任何警告。
你告诉它忽略它;这就是!的含义。如果您告诉编译器不要抱怨某些事情,那么它不会因为没有抱怨而受到责备。
所以最后,它似乎唯一增加的价值就在于签名。 不,它有更多的有效性,但是如果您忽略了它所提出的警告(上面的CS8600),并且如果您压制了它为您执行的其他操作():是的,它将会变得不太有用。所以......不要那样做?

谢谢Marc。请问您能解释一下default(int)为什么是0吗?我理解了,但为什么default(string)是null而不是空字符串呢? - daremachine
啊..我不知道字符串是引用类型。现在我明白了。谢谢。 - daremachine
我并没有忽略这些警告,事实上,现在我的默认配置是 <WarningsAsErrors>CS8600;CS8625</WarningsAsErrors>。此外,我知道使用 null! 的含义,因此我从不使用它。这只是举个例子来说明我对这个特性感到困惑的原因。您能否详细解释一下为什么这个特性“更具有效性”?我不明白如何处理那些本应为非空引用类型却实际上是 null 的情况。这只会在代码中增加混乱... - Jérôme MEVEL
2
@JérômeMEVEL 现实检验:NullReferenceException 是 .NET 中人们经常遇到的最常见异常之一;NRT 功能使得在编译时发现可能的 NRT 变得可能,而不是在运行时遇到它。这就是 NRT 的目的和意义。现在,有一些限制-V0ldek(另一个答案)提到了数组中的 nulls 作为一个很好的例子-但是合理的近似,只要你不习惯使用 !,并且只要你阅读并采取措施来解决它给出的警告:你应该会发现你几乎再也看不到任何 NullReferenceException - Marc Gravell
@JérômeMEVEL 实际上,大多数情况下您期望引用是非空的,这意味着您的大部分代码看起来完全相同,没有额外的“混淆”。 - Marc Gravell
显示剩余3条评论

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