Rust中的Option::None和其他语言中的null有什么区别?

6

我想知道Rust语言中的Option::None和其他编程语言中的"null"之间的主要区别是什么。为什么None被认为更好?


3
这个问题假设在其他语言中 null 的含义是相同的,但事实并非如此。很多语言也不把它称作 null,比如 Ruby 中的 nil,Perl 中的 undef。你能给一些其他编程语言中的 null 的例子吗? - Schwern
2个回答

25
一个关于如何避免赋予“空值”的简短历史课程!
在最开始的时候,有C语言。在C语言中,有指针这个东西。通常情况下,初始化指针的时间会被延后或者说是可选的,因此C社区决定将一块”特别的“内存(通常是内存地址0)留给我们,作为一个约定,表示“这里没有值”。如果函数需要返回T*的话,它可以返回NULL(在C语言中全部大写,因为它是一个宏),表示失败或缺少值。在像C这样的低级语言中,在那个时代,这种方式相当有效。我们才刚刚从汇编语言世界中脱颖而出,进入了有类型(并且相对安全)的语言世界。
C++和后来的Java基本上模仿了这种方法。在C++中,每个指针都可以是NULL(稍后添加了nullptr,它不是一个宏,是一个稍微更加类型安全的NULL)。在Java中,问题变得更加明显:每个非原始值(基本上就是不是数字或字符的每个值)都可以是null。如果我的函数以String为参数,则我必须随时准备好处理某些幼稚的年轻程序员可能传递给我的null情况。如果我从函数中获得String作为结果,那么我必须检查文档以确定它是否存在。
这就给我们带来了NullPointerException,即使在今天,每天都有成千上万的年轻程序员因此陷入困境而涌向本网站寻求帮助。
很明显,在静态类型语言中,“每个值可能是null”不是一种可持续的方法。(动态类型语言通常在任何地方都更习惯于处理失败,因为基本上这是它们的天性,因此像Ruby中的nil或Python中的None之类的存在要略微容易接受些。)
Kotlin通常被誉为“更好的Java”,它通过引入可空类型来解决这个问题。并不是每种类型都是可空的。如果我有一个String,那么我实际上有一个String。如果我打算让我的String是可空的,那么我可以使用String?选择性地加入可空性,这是一种既可以是字符串又可以是null的类型。关键是,这是类型安全的。如果我有一个String?类型的值,那么在进行null检查或做出断言之前,我不能调用它的String方法。因此,如果x具有String?类型,则除非我首先执行以下操作之一,否则无法执行x.toLowerCase()
  1. 将其放入if (x!= null)中,以确保x不是null(或者其他证明我的情况的控制流形式)。
  2. 使用?空安全调用运算符执行x?.toLowerCase()。这将编译为if (x!= null)检查,并返回一个String?,如果原始字符串为null,则该值为null
  3. 使用!!断言该值不为null。如果我错了,这个断言会被检查并抛出异常。

请注意,(3)是Java默认情况下的做法。不同之处在于,在Kotlin中,“我断言我比类型检查器更懂”这种情况是可选的,你必须费点心思才能进入可能导致空指针异常的情况。(我略过了平台类型,这是类型系统中一种方便的黑客方式,用于与Java互操作。这里并不重要)

Nullable types 是 Kotlin 解决这个问题的方式,Typescript(使用 --strict 模式)和 Scala 3(打开 null 检查)也采用了类似的方式。然而,在 Rust 中实现这种方式需要对编译器进行重大改变,因为 nullable types 要求语言支持 子类型。在像 Java 或 Kotlin 这样首先使用面向对象原则构建的语言中,引入新的子类型很容易。例如,StringString? 的子类型,同时也是 Any(基本上就是 java.lang.Object)和 Any?(任何值以及 null)的子类型。因此,像 "A" 这样的字符串具有 Stringprincipal type,但也通过子类型拥有所有这些其他类型。
Rust 实际上没有这个概念。Rust 使用 trait objects 提供了一些类型强制转换,但那并不是真正的子类型。不能说 Stringdyn Displaysubtype,只能说一个 String(在非定长的情况下)可以自动转换为 dyn Display
因此,我们需要重新考虑。在这里,nullable types 并不适用。但幸运的是,还有另一种处理“这个值可能存在也可能不存在”的方法,称为 optional types。我不敢猜测哪种语言首先尝试了这个想法,但它肯定是由 Haskell 推广并在更多的函数式语言中普遍存在。
在函数式语言中,我们经常有一个类似于面向对象语言中的 principal types 的概念。也就是说,一个值 x 有一个 “最佳” 类型 T。在具有子类型的面向对象语言中,x 可能有其他类型,这些类型是 Tsupertypes。然而,在没有子类型的函数式语言中,x 确实只有一个类型。还有其他可以与 T unify 的类型,例如(使用 Haskell 的符号)forall a. a。但不能真正地说 x 的类型是 forall a. a
整个 Kotlin 中的可空类型技巧依赖于一个事实,即 “abc” 同时是 StringString?,而 null 只是 String?。由于我们没有子类型化,因此需要为 StringString? 情况分别使用两个不同的值。
如果我们在 Rust 中有这样的结构:
struct Foo(i32);

那么Foo(0)Foo类型的一个值。没有什么好说的,它不是一个Foo?或可选的Foo之类的东西。它只有一种类型。

然而,还有一个相关的值叫做Some(Foo(0)),它是一个Option<Foo>。请注意,Foo(0)Some(Foo(0))不是同一个值,它们只是以一种相当自然的方式相关。区别在于,虽然Foo 必须存在,但Option<Foo>可以是Some(Foo(0)),也可以是None,这有点像Kotlin中的null。我们仍然必须检查该值是否为None,然后才能执行任何操作。在Rust中,我们通常使用模式匹配或者使用几个内置函数来进行模式匹配。这是相同的思想,只是使用了函数式编程技术来实现。

因此,如果我们想要从一个Option中获取值,或者如果它不存在,则获取默认值,我们可以编写:

my_option.unwrap_or(0)

如果我们想要对内部进行某些操作,如果存在则进行操作,否则将其置为null,则可以写成:
my_option.and_then(|inner_value| ...)

?. 在 Kotlin 中的基本作用是什么。如果我们想要断言一个值存在,否则就会出现错误,我们可以这样写:

my_option.unwrap()

最后,我们处理这个问题的通用瑞士军刀是模式匹配。
match my_option {
  None => {
    ...
  }
  Some(value) => {
    ...
  }
}

因此,我们有两种不同的方法来解决这个问题:基于子类型的可空类型和基于组合的可选类型。 "哪个更好" 有点涉及到观点,但我会尝试总结一下我在双方看到的论点。
支持可空类型的倡导者往往关注人机交互。能够快速进行“null检查”,然后使用 相同 的值非常方便,而不必不停地跳跃解包和包装值。还可以将文字值传递给期望 String?Int? 的函数,而不必担心捆绑它们或不断检查它们是否在 Some 中。
另一方面,可选类型的优点在于更少的 "魔法"。如果 Kotlin 中不存在可空类型,我们会有些运气不好。但是,如果 Rust 中不存在 Option,则某人可以自己用几行代码编写它。它不是特殊语法,只是存在并且有一些(普通)函数定义在其上的类型。它也是由组合构建的,这意味着(自然地)它更好地组成。也就是说,(T?)? 等价于 T?(前者仍然只有一个 null 值),而 Option<Option<T>> 不同于 Option<T>。我不建议直接编写返回 Option<Option<T>> 的函数,但在编写通用函数时可能会受到此影响(即您的函数返回 S?,并且调用者恰好使用 Int? 实例化 S)。
我在 这篇文章 中更详细地介绍了两者之间的区别,在那里有人问了基本相同的问题,但是使用的是 Elm 而不是 Rust。

1
快速提醒- Rust的 "Option"确实有一些应用了一些小技巧, 主要是使用了仍未稳定的try trait,以及作为语言项(lang item), 因此它可以用来帮助完成例如for循环的解糖。尽管如此,模仿大部分在稳定版本上所做的事情仍然完全可行。 - Aiden4

4
在这个问题中有两个隐含的假设: 第一,其他语言中的“null”(以及nilundefnone)都是相同的。它们并不相同。
第二个假设是"null"和"none"提供类似的功能。Null有很多不同的用途: 值未知(SQL三值逻辑),值是一个标记(C的空指针和空字节),出现错误(Perl的undef),以及表示没有值(Rust的Option::none)。
Null本身至少有三种不同的形式: 无效值、关键字和特殊对象或类型。

关键字作为空值

许多语言选择一个特定的关键字来表示null。Perl有undef,Go和Ruby有nil,Python有None。它们很有用,因为它们与false或0不同。

无效值作为空值

与具有关键字不同,这些是语言内部的东西,意味着一个特定的值,但仍然被用作null。最好的例子是C的空指针和空字节。

特殊对象和类型作为空值

越来越多的语言使用特殊对象和类型表示空值。它们拥有关键字的所有优点,但不像通用的“出现错误”那样,它们可以在每个领域具有非常具体的意义。它们还可以是用户定义的,提供了超出语言设计者预期的灵活性。并且,如果您使用对象,则可以附加其他信息。

例如,在 Rust 中,std::ptr::null 表示空原始指针。Go 有 error 接口。Rust 有 Result::ErrOption::None


未知值的null

大多数编程语言使用二值或二进制逻辑:真或假。但一些特别是SQL,使用三值或三进制逻辑:真、假和未知。在这里,null意味着“我们不知道值是什么”。它既不是真也不是假,也不是错误、空、零:该值是未知的。这改变了逻辑的工作方式。

如果将任何东西与未知值进行比较,甚至是自身,结果都是未知的。这使您可以基于不完整的数据得出逻辑结论。例如,Alice身高7'4'',我们不知道Bob有多高。Alice比Bob高吗?未知。

未初始化的null

当您声明一个变量时,它必须包含某些值。即使在C语言中,它以前所说的不会为您初始化变量,它仍然包含内存中存在的任何值。现代语言将为您初始化值,但它们必须将其初始化为某些内容。通常,这种内容是null。

null作为标记

当你有一系列事物需要处理且需要知道列表何时结束时,可以使用哨兵值。这可以是任何不是列表有效值的值。例如,如果您有一组正数,可以使用-1。
经典的例子是C语言。在C中,您不知道数组的长度。您需要一个东西来告诉您停止。您可以传递数组的大小,或者您可以使用哨兵值。您读取数组直到看到哨兵为止。在C中,字符串只是以“空字节”(即无效字符0)结尾的字符数组。如果您有指针数组,可以以空指针结束它。缺点是1)并不总是有真正的无效值2)如果您忘记了哨兵,则会越过数组末尾,发生糟糕的事情。
一个更现代的例子是如何知道何时停止迭代。例如,在Go中,for thing := range things { ... },当范围中没有更多项时,range将返回nil,导致for循环退出。虽然这更灵活,但它与经典哨兵具有相同的问题:你需要一个永远不会出现在列表中的值。如果null是有效值呢?
Python和Ruby等语言选择通过在迭代完成时引发特殊异常来解决此问题。两者都会引发StopIteration,它们的循环将捕获并退出循环。这避免了选择哨兵值的问题,Python和Ruby迭代器可以返回任何内容。
空作为错误
虽然许多语言使用异常进行错误处理,但有些语言不使用。相反,它们返回一个特殊值以指示错误,通常为空。 C、Go、Perl和Rust是很好的例子。
这与哨兵的问题相同,你需要使用一个无效的返回值。有时这是不可能的。例如,C语言中的函数只能返回给定类型的单个值。如果它返回指针,则可以返回空指针以指示错误。但如果它返回数字,则必须选择另一个有效数字作为错误值。这种混淆错误和返回值的做法是有问题的。
Go通过允许函数返回多个值来解决这个问题,通常是返回值和error。然后这两个值可以独立检查。Rust只能返回单个值,因此它通过返回特殊类型Result来解决这个问题。它包含一个带有返回值的Ok或带有错误代码的Err
在Rust和Go中,它们不仅仅是值,还可以调用方法来扩展它们的功能。Rust的Result::Err具有特殊类型的额外优势,您无法意外地将Result::Err用作其他任何东西。

空作为无值

最后,我们有“没有给定选项”。简单地说,有一组有效选项,结果是其中之一都不是。这与“未知”不同,因为我们知道该值不在有效值集合中。例如,如果我问“这辆车是哪种水果”,结果将为空,表示“这辆车不是任何水果”。
当请求集合中键的值,而该键不存在时,将得到“没有值”。例如, Rust的HashMap get将在键不存在时返回None
这并不是一个错误,尽管人们经常感到困惑。例如,如果您向函数传递无意义的参数,Ruby会引发ArgumentError。例如,array.first(-2)要求获取前-2个值,这是无意义的,并将引发ArgumentError

Option vs Result

这最终将我们带回到Option :: None。它是null的“特殊类型”版本,具有许多优点。

Rust在许多方面使用Option: 指示未初始化的值,指示简单错误,作为无值和Rust特定事物。文档提供了许多示例

  • 初始值
  • 返回不适用于其整个输入范围(部分函数)的函数的返回值
  • 否则报告简单错误的返回值,在错误时返回None
  • 可选结构字段
  • 可以借出或“取出”的结构字段
  • 可选函数参数
  • 可空指针
  • 从困境中交换东西

在这么多地方使用它会削弱它作为特殊类型的价值。它还与Result重叠,这是您应该用来从函数返回结果的内容。

我看到的最好建议是使用Result<Option<T>, E>来区分三种可能性:有效结果、无结果和错误。Jeremy Fitzhardinge提供了一个很好的示例,说明从键/值存储中查询返回什么结果

......我非常支持返回Result<Option<T>, E>,其中Ok(Some(value))表示“这就是你要找的东西”,Ok(None)表示“你要找的东西肯定不存在”,而Err(...)则表示“我无法确定你要找的东西是否存在,因为出现了一些问题”。


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