提问人:Roman A. Taycher 提问时间:10/21/2010 最后编辑:M. GruberRoman A. Taycher 更新时间:2/16/2018 访问量:46648
不带 null 的语言的最佳解释
Best explanation for languages without null
问:
每隔一段时间,当程序员抱怨 null 错误/异常时,就会有人问我们没有 null 怎么办。
我对选项类型的酷有一些基本的想法,但我没有知识或语言技能来最好地表达它。对以下内容的一个很好的解释是什么,以一种普通程序员可以理解的方式编写,我们可以将这个人指向?
- 默认情况下,引用/指针可为空是不可取的
- 选项类型的工作原理,包括简化检查 null 情况的策略,例如
- 模式匹配和
- 一元推导
- 替代解决方案,例如消息吞噬 nil
- (我错过的其他方面)
答:
默认情况下,引用/指针可为 null 的不可取性。
我不认为这是空值的主要问题,空值的主要问题是它们可能意味着两件事:
- 引用/指针未初始化:这里的问题与一般的可变性相同。首先,它使分析代码变得更加困难。
- 变量为 null 实际上意味着什么:这就是 Option 类型实际上形式化的情况。
支持 Option 类型的语言通常也禁止或阻止使用未初始化的变量。
选项类型的工作原理,包括简化检查空情况的策略,例如模式匹配。
为了有效,需要在语言中直接支持选项类型。否则,需要大量的样板代码来模拟它们。模式匹配和类型推断是使 Option 类型易于使用的两个关键语言功能。例如:
在 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());
替代解决方案,例如消息吞噬 nil
Objective-C 的“消息吃零”与其说是一种解决方案,不如说是试图减轻 null 检查的头疼。基本上,表达式在尝试对 null 对象调用方法时不会引发运行时异常,而是计算结果为 null 本身。暂停怀疑,就好像每个实例方法都以 开头。但随之而来的是信息丢失:你不知道该方法返回 null 是因为它是有效的返回值,还是因为对象实际上是 null。这很像异常吞噬,并且在解决前面概述的 null 问题方面没有任何进展。if (this == null) return null;
评论
Assembly 为我们带来了地址,也称为无类型指针。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 语言中,您可能忘记了空检查和繁荣......
注意:这些代码示例没有编译,但我希望你明白了。
评论
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
问题在于,每个引用类型在其空间中都会获得这种通常不需要的额外状态。变量可以是任何字符序列,也可以是这个疯狂的额外值,它没有映射到我的问题域中。一个对象有三个 s,它们本身有 和 值,但不幸的是,s 或 本身可能是这个疯狂的 null 值,对我正在研究的图形域毫无意义。等。null
string
null
Triangle
Point
X
Y
Point
Triangle
当您确实打算对可能不存在的值进行建模时,您应该明确选择加入它。如果我打算对人进行建模的方式是每个人都有 a 和 a,但只有一些人有 s,那么我想这样说Person
FirstName
LastName
MiddleName
class Person
private string FirstName
private Option<string> MiddleName
private string LastName
其中假定此处为不可为 null 的类型。然后,在尝试计算某人名字的长度时,没有棘手的不变量需要建立,也没有意外的 s。类型系统确保任何处理帐户的代码都有可能,而任何处理 的代码都可以安全地假设那里有一个值。string
NullReferenceException
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
不用担心。相反,在对字符串等类型具有可为 null 引用的语言中,则假设
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 对象没有所有内容都为非 null 的不变性,则该对象会爆炸,或者
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
假设确保第一个/最后一个存在,但中间可以为 null,或者您可能执行抛出不同类型的异常的检查,或者谁知道是什么。所有这些疯狂的实现选择和需要考虑的事情都会出现,因为有你不想要或不需要的愚蠢的可表示值。p
Null 通常会增加不必要的复杂性。复杂性是所有软件的敌人,您应该在合理的情况下努力降低复杂性。
(请注意,即使是这些简单的例子也更加复杂。即使 a 不能 ,a 也可以表示(空字符串),这可能也不是我们打算建模的人名。因此,即使使用不可为 null 的字符串,我们仍然可能“表示无意义的值”。同样,你可以选择在运行时通过不变量和条件代码,或者使用类型系统(例如,有一个类型)来解决这个问题。后者可能是不明智的(“好”类型通常被“关闭”在一组常见操作上,例如 不是封闭的),但它在设计空间中展示了更多的点。归根结底,在任何给定的类型系统中,都有一些复杂性是它非常擅长摆脱的,而其他复杂性则本质上是更难摆脱的。本主题的关键是,在几乎每个类型系统中,从“默认可为 null 的引用”到“默认不可为 null 的引用”的更改几乎总是一个简单的更改,它使类型系统在应对复杂性和排除某些类型的错误和无意义状态方面做得更好。因此,如此多的语言一次又一次地重复这个错误是非常疯狂的。FirstName
null
string
""
NonEmptyString
NonEmptyString
.SubString(0,0)
评论
选项类型的好处不在于它们是可选的。所有其他类型都不是。
有时,我们需要能够表示一种“空”状态。有时我们必须表示一个“无值”选项以及变量可能采用的其他可能值。因此,完全不允许这样做的语言会有点残缺。
但通常,我们不需要它,允许这样的“null”状态只会导致歧义和混淆:每次我在 .NET 中访问引用类型变量时,我都必须考虑它可能是 null。
通常,它实际上永远不会为空,因为程序员对代码进行结构化,使其永远不会发生。但是编译器无法验证这一点,每次看到它时,你都必须问自己“这会是空的吗?我需要在这里检查空吗?
理想情况下,在许多 null 没有意义的情况下,不应允许它。
这在 .NET 中很难实现,因为在 .NET 中,几乎所有内容都可能为 null。你必须依靠你所调用的代码的作者是 100% 有纪律和一致的,并清楚地记录了什么可以是空的,什么不能是空的,或者你必须偏执并检查一切。
但是,如果类型在默认情况下不可为 null,则无需检查它们是否为 null。您知道它们永远不能为 null,因为编译器/类型检查器会为您强制执行。
然后,对于我们确实需要处理 null 状态的极少数情况,我们只需要一个后门。然后可以使用“选项”类型。然后,当我们有意识地决定需要能够表示“无值”情况时,我们允许 null,并且在所有其他情况下,我们知道该值永远不会为 null。
正如其他人所提到的,例如,在 C# 或 Java 中,null 可能表示以下两种情况之一:
- 变量未初始化。理想情况下,这应该永远不会发生。除非初始化变量,否则变量不应存在。
- 该变量包含一些“可选”数据:它需要能够表示没有数据的情况。这有时是必要的。也许您正在尝试在列表中查找一个对象,但您事先不知道它是否存在。然后,我们需要能够表示“未找到对象”。
第二种含义必须保留,但第一种含义应该完全消除。甚至第二种含义也不应该是默认值。如果需要,我们可以选择加入。但是,当我们不需要某些东西是可选的时,我们希望类型检查器保证它永远不会为空。
评论
由于人们似乎错过了它:是模棱两可的。null
爱丽丝的出生日期是。这是什么意思?null
Bob 的死亡日期是 。那是什么意思?null
一个“合理”的解释可能是爱丽丝的出生日期存在但未知,而鲍勃的死亡日期不存在(鲍勃还活着)。但为什么我们得到了不同的答案呢?
另一个问题:是边缘情况。null
- 是?
null = null
- 是?
nan = nan
- 是?
inf = inf
- 是?
+0 = -0
- 是?
+0/0 = -0/0
答案通常分别是“是”、“否”、“是”、“是”、“否”、“是”。疯狂的“数学家”称NaN为“无效性”,并说它与自身相等。SQL 将 null 视为不等于任何值(因此它们的行为类似于 NaNs)。人们想知道,当您尝试将 ±∞、±0 和 NaN 存储到同一个数据库列中时会发生什么(有 253 个 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 中的工作方式,我可以插入 and 并且不违反第一个唯一性约束(因为)。(1, 2, NULL, 3, t)
(1, 2, NULL, 4, t)
(1, 2, NULL) != (1, 2, NULL)
这之所以有效,是因为 NULL 在大多数数据库的唯一性约束下工作(大概是为了更容易模拟“真实世界”的情况,例如,没有两个人可以拥有相同的社会安全号码,但并非所有人都有一个)。
FWIW,如果不首先调用未定义的行为,C++ 引用就无法“指向”null,并且不可能使用未初始化的引用成员变量构造类(如果引发异常,则构造失败)。
旁注:有时您可能需要互斥指针(即其中只有一个可以是非 NULL),例如在假设的 iOS 中。相反,我被迫做类似的事情。type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
assert((bool)actionSheet + (bool)alertView == 1)
评论
assert(actionSheet ^ alertView)
向量语言有时可以避免没有 null。
在本例中,空向量用作类型化的 null。
评论
罗伯特·尼斯特罗姆(Robert Nystrom)在这里提供了一篇不错的文章:
http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/
描述了他在喜鹊编程语言中添加对缺席和失败的支持时的思维过程。
我一直认为 Null(或 nil)是没有值的。
有时你想要这个,有时你不想要。这取决于您正在使用的域。如果缺席是有意义的:没有中间名,那么你的应用程序可以采取相应的行动。另一方面,如果 null 值不应该存在:名字为 null,则开发人员会接到众所周知的凌晨 2 点的电话。
我还看到代码过载且检查 null 过于复杂。对我来说,这意味着两件事之一:
a)应用程序树
中更高的错误b)糟糕/不完整的设计
从积极的一面来看,Null 可能是检查是否缺少某些东西的更有用的概念之一,而没有 null 概念的语言在进行数据验证时最终会使事情变得过于复杂。在这种情况下,如果未初始化新变量,则所述语言通常会将变量设置为空字符串、0 或空集合。但是,如果空字符串或 0 或空集合是应用程序的有效值,则说明存在问题。
有时,通过为字段发明特殊/奇怪的值来表示未初始化的状态来规避这种情况。但是,当善意的用户输入特殊值时会发生什么?我们不要陷入数据验证例程的混乱之中。 如果语言支持空概念,那么所有问题都会消失。
评论
到目前为止,所有的答案都集中在为什么是一件坏事上,以及如果一种语言可以保证某些值永远不会为空,那么它是多么方便。null
然后,他们继续建议,如果对所有值强制执行不可空性,那将是一个非常巧妙的想法,如果您添加一个概念,例如 或 来表示可能并不总是具有定义值的类型,则可以做到这一点。这就是 Haskell 所采用的方法。Option
Maybe
都是好东西!但它并不排除使用显式可为 null/非 null 类型来实现相同的效果。那么,为什么 Option 仍然是一件好事呢?毕竟,Scala 支持可为 null 的值(必须如此,因此它可以与 Java 库一起使用),但也支持。Options
问。那么,除了能够从语言中完全删除空值之外,还有什么好处呢?
一个。组成
如果对 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
}
没有太大区别!但这也是使用选项的一种糟糕的方式......这种方法要简洁得多:
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
当您开始处理选项列表时,它会变得更好。想象一下,列表本身是可选的:people
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)
带有 null 检查(甚至 elvis ?: 运算符)的相应代码会很长。这里真正的诀窍是 flatMap 操作,它允许以可为 null 的值永远无法实现的方式嵌套理解 Options 和集合。
评论
flatMap
(>>=)
flatMap
Option<T>
Microsoft Research有一个名为
规范#
它是一个具有非 null 类型的 C# 扩展,并且具有某种机制来检查对象是否为 null,尽管恕我直言,应用合同设计原则可能更合适,并且对于由 null 引用引起的许多麻烦情况更有帮助。
来自.NET背景,我一直认为null是有道理的,它很有用。直到我了解了结构体,以及使用它们是多么容易,避免了大量的样板代码。2009 年,Tony Hoare 在伦敦 QCon 上发表演讲,为发明空引用而道歉。引用他的话:
我称之为我十亿美元的错误。这是 null 的发明 1965年参考。当时,我正在设计第一个 面向对象中引用的综合类型系统 语言 (ALGOL W)。我的目标是确保所有引用的使用 应该是绝对安全的,检查由自动执行 编译器。但我无法抗拒输入空的诱惑 参考,仅仅是因为它很容易实现。这导致了 无数的错误、漏洞和系统崩溃,这些错误、漏洞和系统崩溃 在过去的四十年里,可能造成了十亿美元的痛苦和损害 年。近年来,许多程序分析仪,如 PREfix 和 Microsoft 中的 PREfast 已用于检查引用,并给出 如果存在风险,则警告可能为非 null。最近 像 Spec# 这样的编程语言引入了 非 null 引用。这是我在 1965 年拒绝的解决方案。
在程序员那里也可以看到这个问题
评论