每隔一段时间,当程序员抱怨 null 错误/异常时,就会有人问我们没有 null 会做什么。
我对选项类型的酷炫有一些基本概念,但我没有最好的表达它的知识或语言技能。以普通程序员可以理解的方式编写的以下内容有什么很好的解释,我们可以将其指向该人?
- 默认情况下引用/指针可以为空是不可取的
- 选项类型如何工作,包括简化检查空情况的策略,例如
- 模式匹配和
- 一元理解
- 替代解决方案,例如消息吃零
- (我错过的其他方面)
每隔一段时间,当程序员抱怨 null 错误/异常时,就会有人问我们没有 null 会做什么。
我对选项类型的酷炫有一些基本概念,但我没有最好的表达它的知识或语言技能。以普通程序员可以理解的方式编写的以下内容有什么很好的解释,我们可以将其指向该人?
我认为为什么 null 是不可取的简要总结是无意义的状态不应该是可表示的。
假设我正在为一扇门建模。它可以处于以下三种状态之一:打开、关闭但未锁定和关闭并锁定。现在我可以按照以下方式对其进行建模
class Door
private bool isShut
private bool isLocked
并且很清楚如何将我的三个状态映射到这两个布尔变量中。但这留下了第四个不受欢迎的状态:isShut==false && isLocked==true
. 因为我选择作为我的表示的类型承认这种状态,所以我必须花费脑力来确保类永远不会进入这种状态(也许通过显式编码不变量)。相反,如果我使用的语言具有代数数据类型或检查枚举,我可以定义
type DoorState =
| Open | ShutAndUnlocked | ShutAndLocked
然后我可以定义
class Door
private DoorState state
并且没有更多的后顾之忧。类型系统将确保一个实例只有三种可能的状态class Door
。这是类型系统擅长的——在编译时明确排除一整类错误。
问题null
在于,每个引用类型都会在其空间中获得这种通常不受欢迎的额外状态。变量可以是任何string
字符序列,也可以是null
没有映射到我的问题域中的这个疯狂的额外值。一个Triangle
对象具有三个Point
s,它们本身具有X
和Y
值,但不幸的是,Point
s 或Triangle
本身可能是这个疯狂的 null 值,这对我正在工作的图形域毫无意义。等等。
当您确实打算对可能不存在的值进行建模时,您应该明确选择加入它。如果我打算为人建模的方式是每个人Person
都有 aFirstName
和 a LastName
,但只有一些人有MiddleName
s,那么我想说的话
class Person
private string FirstName
private Option<string> MiddleName
private string LastName
这里string
假定为不可为空的类型。NullReferenceException
然后,在尝试计算某人姓名的长度时,没有需要建立棘手的不变量,也没有意外的 s。类型系统确保任何处理 的代码都MiddleName
说明了它存在的可能性None
,而任何处理 的代码FirstName
都可以安全地假设那里有一个值。
因此,例如,使用上面的类型,我们可以编写这个愚蠢的函数:
let TotalNumCharsInPersonsName(p:Person) =
let middleLen = match p.MiddleName with
| None -> 0
| Some(s) -> s.Length
p.FirstName.Length + middleLen + p.LastName.Length
不用担心。相反,在对字符串等类型具有可为空引用的语言中,然后假设
class Person
private string FirstName
private string MiddleName
private string LastName
你最终会创作出类似的东西
let TotalNumCharsInPersonsName(p:Person) =
p.FirstName.Length + p.MiddleName.Length + p.LastName.Length
如果传入的 Person 对象不具有所有内容为非空的不变量,则会爆炸,或者
let TotalNumCharsInPersonsName(p:Person) =
(if p.FirstName=null then 0 else p.FirstName.Length)
+ (if p.MiddleName=null then 0 else p.MiddleName.Length)
+ (if p.LastName=null then 0 else p.LastName.Length)
或许
let TotalNumCharsInPersonsName(p:Person) =
p.FirstName.Length
+ (if p.MiddleName=null then 0 else p.MiddleName.Length)
+ p.LastName.Length
假设p
确保第一个/最后一个存在但中间可以为空,或者您可能会进行检查以引发不同类型的异常,或者谁知道。所有这些疯狂的实现选择和要考虑的事情都会突然出现,因为存在您不想要或不需要的愚蠢的可表示值。
Null 通常会增加不必要的复杂性。 复杂性是所有软件的敌人,您应该在合理的情况下努力降低复杂性。
(请注意,即使是这些简单的示例也有更多的复杂性。即使 aFirstName
不能是null
,astring
也可以表示""
(空字符串),这可能也不是我们打算建模的人名。因此,即使使用非可以为空的字符串,它仍然可能是我们“表示无意义的值”的情况。同样,您可以选择在运行时通过不变量和条件代码来解决这个问题,或者使用类型系统(例如,拥有一个NonEmptyString
类型)。后者可能是不明智的(“好”类型通常在一组常见操作上“关闭”,例如NonEmptyString
不关闭.SubString(0,0)
),但它在设计空间中展示了更多要点。归根结底,在任何给定的类型系统中,都有一些复杂性可以很好地摆脱,而其他复杂性本质上更难摆脱。本主题的关键在于,几乎在每个类型系统中,从“默认可空引用”到“默认不可空引用”的更改几乎总是一个简单的更改,它使类型系统在对抗复杂性和排除某些类型的错误和无意义的状态。所以这么多语言一次又一次地重复这个错误真是太疯狂了。)
选项类型的好处不在于它们是可选的。这是所有其他类型都不是。
有时,我们需要能够表示一种“空”状态。有时我们必须表示“无值”选项以及变量可能采用的其他可能值。因此,一种完全不允许这样做的语言会有点残废。
但通常情况下,我们不需要它,并且允许这样的“null”状态只会导致歧义和混乱:每次我在 .NET 中访问引用类型变量时,我都必须考虑它可能是 null。
通常,它实际上永远不会为空,因为程序员将代码构造为永远不会发生。但是编译器无法验证这一点,每次看到它时,您都必须问自己“这可以为空吗?我需要在这里检查是否为空吗?”
理想情况下,在 null 没有意义的许多情况下,它不应该被允许。
这在 .NET 中很难实现,几乎所有内容都可以为空。您必须依靠您所调用的代码的作者来 100% 遵守纪律和一致,并清楚地记录什么可以和不可以为空,或者您必须偏执并检查所有内容。
但是,如果默认情况下类型不可为空,则无需检查它们是否为空。您知道它们永远不能为空,因为编译器/类型检查器会为您强制执行。
然后我们只需要一个后门来处理我们确实需要处理空状态的罕见情况。然后可以使用“选项”类型。然后,在我们有意识地决定我们需要能够表示“无值”情况的情况下,我们允许 null,而在其他所有情况下,我们都知道该值永远不会为 null。
正如其他人所提到的,例如在 C# 或 Java 中,null 可能意味着以下两种情况之一:
必须保留第二个含义,但应完全消除第一个含义。甚至第二个含义也不应该是默认值。这是我们可以在需要时选择加入的东西。但是当我们不需要某些东西是可选的时,我们希望类型检查器保证它永远不会为空。
到目前为止,所有答案都集中在为什么null
是一件坏事,以及如果一种语言可以保证某些值永远不会为空,那么它是多么方便。
然后他们继续建议,如果您对所有值强制执行不可为空性,这将是一个非常巧妙的想法,如果您添加一个概念,例如Option
或Maybe
表示可能并不总是具有定义值的类型,则可以做到这一点。这是 Haskell 采用的方法。
都是好东西!但这并不排除使用显式可空/非空类型来实现相同的效果。那么,为什么 Option 仍然是一件好事呢?毕竟,Scala 支持可空值(必须这样做,因此它可以与 Java 库一起使用)但也支持Options
。
问:那么除了能够从语言中完全删除空值之外,还有什么好处呢?
A.组成
如果您从可识别 null 的代码进行简单的翻译
def fullNameLength(p:Person) = {
val middleLen =
if (null == p.middleName)
p.middleName.length
else
0
p.firstName.length + middleLen + p.lastName.length
}
到选项感知代码
def fullNameLength(p:Person) = {
val middleLen = p.middleName match {
case Some(x) => x.length
case _ => 0
}
p.firstName.length + middleLen + p.lastName.length
}
没有太大区别!但这也是使用 Options 的一种糟糕方式......这种方法更干净:
def fullNameLength(p:Person) = {
val middleLen = p.middleName map {_.length} getOrElse 0
p.firstName.length + middleLen + p.lastName.length
}
甚至:
def fullNameLength(p:Person) =
p.firstName.length +
p.middleName.map{length}.getOrElse(0) +
p.lastName.length
当您开始处理选项列表时,它会变得更好。想象一下 Listpeople
本身是可选的:
people flatMap(_ find (_.firstName == "joe")) map (fullNameLength)
这是如何运作的?
//convert an Option[List[Person]] to an Option[S]
//where the function f takes a List[Person] and returns an S
people map f
//find a person named "Joe" in a List[Person].
//returns Some[Person], or None if "Joe" isn't in the list
validPeopleList find (_.firstName == "joe")
//returns None if people is None
//Some(None) if people is valid but doesn't contain Joe
//Some[Some[Person]] if Joe is found
people map (_ find (_.firstName == "joe"))
//flatten it to return None if people is None or Joe isn't found
//Some[Person] if Joe is found
people flatMap (_ find (_.firstName == "joe"))
//return Some(length) if the list isn't None and Joe is found
//otherwise return None
people flatMap (_ find (_.firstName == "joe")) map (fullNameLength)
带有空检查(甚至是 elvis ?: 运算符)的相应代码会非常长。这里真正的技巧是 flatMap 操作,它允许以可空值永远无法实现的方式嵌套理解选项和集合。
由于人们似乎缺少它:null
是模棱两可的。
爱丽丝的出生日期是null
。这是什么意思?
Bob 的死亡日期是null
。这意味着什么?
“合理”的解释可能是 Alice 的出生日期存在但未知,而 Bob 的死亡日期不存在(Bob 还活着)。但是为什么我们会得到不同的答案呢?
另一个问题:null
是边缘情况。
null = null
吗?nan = nan
吗?inf = inf
吗?+0 = -0
吗?+0/0 = -0/0
吗?答案通常分别是“是”、“否”、“是”、“是”、“否”、“是”。疯狂的“数学家”称 NaN 为“无效性”,并说它与自身比较相等。SQL 将空值视为不等于任何值(因此它们的行为类似于 NaN)。有人想知道当您尝试将 ±∞、±0 和 NaN 存储到同一个数据库列中时会发生什么(有 2 53个NaN,其中一半是“负数”)。
更糟糕的是,数据库在处理 NULL 的方式上有所不同,并且其中大多数不一致(请参阅SQLite中的 NULL 处理以获取概述)。这太可怕了。
现在是强制性的故事:
我最近设计了一个有五列的(sqlite3)数据库表a NOT NULL, b, id_a, id_b NOT NULL, timestamp
。因为它是一个通用模式,旨在解决相当任意应用程序的通用问题,所以有两个唯一性约束:
UNIQUE(a, b, id_a)
UNIQUE(a, b, id_b)
id_a
只是为了与现有应用程序设计兼容而存在(部分原因是我还没有提出更好的解决方案),并没有在新应用程序中使用。由于 NULL 在 SQL 中的工作方式,我可以插入(1, 2, NULL, 3, t)
并且(1, 2, NULL, 4, t)
不违反第一个唯一性约束(因为(1, 2, NULL) != (1, 2, NULL)
)。
这特别有效,因为 NULL 如何在大多数数据库的唯一性约束中起作用(大概是这样更容易模拟“现实世界”的情况,例如,没有两个人可以拥有相同的社会安全号码,但不是所有人都有一个)。
FWIW,如果不首先调用未定义的行为,C++ 引用不能“指向”null,并且不可能构造一个具有未初始化引用成员变量的类(如果抛出异常,构造失败)。
旁注:有时您可能需要互斥指针(即其中只有一个可以是非 NULL),例如在假设的 iOStype DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
中。相反,我被迫做类似assert((bool)actionSheet + (bool)alertView == 1)
.
默认情况下,不希望有引用/指针可以为空。
我认为这不是空值的主要问题,空值的主要问题是它们可能意味着两件事:
支持 Option 类型的语言通常也禁止或不鼓励使用未初始化的变量。
选项类型如何工作,包括简化检查 null 情况的策略,例如模式匹配。
为了有效,需要在语言中直接支持选项类型。否则需要大量的样板代码来模拟它们。模式匹配和类型推断是使选项类型易于使用的两个关键语言特性。例如:
在 F# 中:
//first we create the option list, and then filter out all None Option types and
//map all Some Option types to their values. See how type-inference shines.
let optionList = [Some(1); Some(2); None; Some(3); None]
optionList |> List.choose id //evaluates to [1;2;3]
//here is a simple pattern-matching example
//which prints "1;2;None;3;None;".
//notice how value is extracted from op during the match
optionList
|> List.iter (function Some(value) -> printf "%i;" value | None -> printf "None;")
然而,在像 Java 这样不直接支持 Option 类型的语言中,我们会有类似的东西:
//here we perform the same filter/map operation as in the F# example.
List<Option<Integer>> optionList = Arrays.asList(new Some<Integer>(1),new Some<Integer>(2),new None<Integer>(),new Some<Integer>(3),new None<Integer>());
List<Integer> filteredList = new ArrayList<Integer>();
for(Option<Integer> op : list)
if(op instanceof Some)
filteredList.add(((Some<Integer>)op).getValue());
替代解决方案,例如消息吃零
Objective-C 的“消息吃零”与其说是一个解决方案,不如说是为了减轻空值检查的头痛。基本上,在尝试调用空对象上的方法时,表达式不会抛出运行时异常,而是将其计算为 null 本身。暂停难以置信,就好像每个实例方法都以if (this == null) return null;
. 但是随之而来的是信息丢失:您不知道该方法返回 null 是因为它是有效的返回值,还是因为对象实际上是 null。这很像异常吞咽,并且在解决之前概述的 null 问题方面没有任何进展。
汇编为我们带来了地址,也称为无类型指针。C 将它们直接映射为类型化指针,但将 Algol 的 null 作为唯一指针值引入,与所有类型化指针兼容。C 中 null 的一个大问题是,由于每个指针都可以为 null,因此如果没有手动检查,就永远无法安全地使用指针。
在高级语言中,使用 null 很尴尬,因为它确实传达了两个不同的概念:
拥有未定义的变量几乎没有用,并且每当它们发生时都会产生未定义的行为。我想每个人都会同意不惜一切代价避免未定义的事情。
第二种情况是可选的,最好明确提供,例如使用选项类型。
假设我们在一家运输公司,我们需要创建一个应用程序来帮助为我们的司机创建一个时间表。对于每个司机,我们都会存储一些信息,例如:他们拥有的驾驶执照以及在紧急情况下可以拨打的电话号码。
在 C 语言中,我们可以:
struct PhoneNumber { ... };
struct MotorbikeLicence { ... };
struct CarLicence { ... };
struct TruckLicence { ... };
struct Driver {
char name[32]; /* Null terminated */
struct PhoneNumber * emergency_phone_number;
struct MotorbikeLicence * motorbike_licence;
struct CarLicence * car_licence;
struct TruckLicence * truck_licence;
};
正如您所观察到的,在对我们的驱动程序列表进行任何处理时,我们都必须检查空指针。编译器不会帮助你,程序的安全性取决于你的肩膀。
在 OCaml 中,相同的代码如下所示:
type phone_number = { ... }
type motorbike_licence = { ... }
type car_licence = { ... }
type truck_licence = { ... }
type driver = {
name: string;
emergency_phone_number: phone_number option;
motorbike_licence: motorbike_licence option;
car_licence: car_licence option;
truck_licence: truck_licence option;
}
现在假设我们要打印所有司机的姓名以及他们的卡车执照号码。
在 C 中:
#include <stdio.h>
void print_driver_with_truck_licence_number(struct Driver * driver) {
/* Check may be redundant but better be safe than sorry */
if (driver != NULL) {
printf("driver %s has ", driver->name);
if (driver->truck_licence != NULL) {
printf("truck licence %04d-%04d-%08d\n",
driver->truck_licence->area_code
driver->truck_licence->year
driver->truck_licence->num_in_year);
} else {
printf("no truck licence\n");
}
}
}
void print_drivers_with_truck_licence_numbers(struct Driver ** drivers, int nb) {
if (drivers != NULL && nb >= 0) {
int i;
for (i = 0; i < nb; ++i) {
struct Driver * driver = drivers[i];
if (driver) {
print_driver_with_truck_licence_number(driver);
} else {
/* Huh ? We got a null inside the array, meaning it probably got
corrupt somehow, what do we do ? Ignore ? Assert ? */
}
}
} else {
/* Caller provided us with erroneous input, what do we do ?
Ignore ? Assert ? */
}
}
在 OCaml 中,这将是:
open Printf
(* Here we are guaranteed to have a driver instance *)
let print_driver_with_truck_licence_number driver =
printf "driver %s has " driver.name;
match driver.truck_licence with
| None ->
printf "no truck licence\n"
| Some licence ->
(* Here we are guaranteed to have a licence *)
printf "truck licence %04d-%04d-%08d\n"
licence.area_code
licence.year
licence.num_in_year
(* Here we are guaranteed to have a valid list of drivers *)
let print_drivers_with_truck_licence_numbers drivers =
List.iter print_driver_with_truck_licence_number drivers
正如您在这个简单的示例中所看到的,安全版本中没有什么复杂的:
而在 C 语言中,您可能只是忘记了一个空检查和繁荣......
注意:这些代码示例没有编译,但我希望你能明白。
微软研究院有一个有趣的项目叫做
规格#
它是一个具有非空类型的 C# 扩展和一些机制来检查您的对象是否不为空,尽管恕我直言,按合同原则应用设计对于空引用引起的许多麻烦情况可能更合适且更有帮助。
Robert Nystrom 在这里提供了一篇不错的文章:
http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/
描述了在他的Magpie编程语言中添加对缺席和失败的支持时的思考过程。
来自 .NET 背景,我一直认为 null 有一点,它很有用。直到我了解结构以及使用它们是多么容易避免大量样板代码。Tony Hoare于 2009 年在伦敦 QCon 发表演讲,为发明空引用道歉。引用他的话:
我称之为我的十亿美元错误。它是 1965 年空引用的发明。当时,我正在为面向对象语言 (ALGOL W) 中的引用设计第一个综合类型系统。我的目标是确保所有引用的使用都应该是绝对安全的,并由编译器自动执行检查。但我无法抗拒加入空引用的诱惑,仅仅是因为它很容易实现。这导致了无数的错误、漏洞和系统崩溃,在过去的四十年中可能造成了十亿美元的痛苦和损失。近年来,微软的一些程序分析器如 PREfix 和 PREfast 已被用于检查引用,并在存在可能为非空的风险时发出警告。更近的编程语言(如 Spec#)引入了非空引用的声明。这就是我在 1965 年拒绝的解决方案。
在程序员那里也看到这个问题
我一直将 Null(或 nil)视为没有值。
有时你想要这个,有时你不想要。这取决于您正在使用的域。如果缺席是有意义的:没有中间名,那么您的应用程序可以采取相应的行动。另一方面,如果不应存在空值:名字为空,则开发人员会接到众所周知的凌晨 2 点的电话。
我还看到代码重载和过度复杂的检查是否为空。对我来说,这意味着两件事之一:
a)应用程序树中更高的错误
b)糟糕/不完整的设计
从积极的方面来看 - Null 可能是检查是否缺少某些内容的更有用的概念之一,并且在进行数据验证时,没有 null 概念的语言最终会使事情变得过于复杂。在这种情况下,如果一个新变量没有被初始化,所说的语言通常会将变量设置为一个空字符串、0 或一个空集合。但是,如果空字符串或 0 或空集合是您的应用程序的有效值——那么您就有问题了。
有时通过为字段发明特殊/奇怪的值来表示未初始化状态来规避这一点。但是,当一个好心的用户输入特殊值时会发生什么?让我们不要陷入数据验证例程的混乱中。如果语言支持 null 概念,那么所有担忧都会消失。
向量语言有时可以不使用 null。
在这种情况下,空向量用作类型化的 null。