没有null的语言的最佳解释
每当程序员抱怨空错误/exception,每当有人问我们做什么,而没有null。
我对选项types的冷静有一些基本的想法,但我没有知识或语言技能来最好地expression它。 对于普通程序员来说,下面这样一个很好的解释是什么,我们可以指出这个人呢?
- 具有引用/指针的默认情况下是不可用的
- 选项types如何工作,包括缓解检查空例如的策略
- 模式匹配和
- 一元的理解
- 消息吃零的替代解决scheme
- (我错过了其他方面)
我认为为什么null是不可取的简洁的总结是无意义的状态不应该是可表示的 。
假设我正在模拟一扇门。 它可以处于以下三种状态之一:打开,closures但解锁,然后closures并locking。 现在我可以用它来模拟它
class Door private bool isShut private bool isLocked
而且我很清楚如何将我的三个状态映射到这两个布尔variables。 但是,这留下了第四个不想要的状态: isShut==false && isLocked==true
。 因为我select的types承认这个状态,所以我必须花费精力来确保类不会进入这个状态(可能是通过明确地编码一个不variables)。 相反,如果我使用代数数据types的语言,或者使用枚举来检查我的定义
type DoorState = | Open | ShutAndUnlocked | ShutAndLocked
那么我可以定义
class Door private DoorState state
不用担心了 types系统将确保class Door
实例只有三种可能的状态。这就是types系统擅长的 – 在编译时显式排除一整类错误。
null
的问题在于每个引用types在其空间中获得这个额外的状态,这通常是不希望的。 一个string
variables可以是任何字符序列,也可以是这个疯狂的额外的null
值,不映射到我的问题域。 一个Triangle
对象有三个Point
s,它们本身有X
和Y
值,但不幸的是, Point
s或Triangle
本身可能是这个疯狂的空值,这对我正在工作的graphics域是没有意义的。
当你打算build立一个可能不存在的价值时,你应该明确地select它。 如果我打算塑造人的方式是每个Person
都有一个FirstName
和一个LastName
,但只有一些人有MiddleName
,那么我想说一些像
class Person private string FirstName private Option<string> MiddleName private string LastName
这里的string
被认为是不可空的types。 然后,在试图计算某人姓名的长度时,不会出现任何棘手的不variables,也不会出现意外的NullReferenceException
。 types系统确保处理MiddleName
任何代码都有可能成为None
,而处理FirstName
任何代码都可以安全地假定在那里有一个值。
例如,使用上面的types,我们可以编写这个愚蠢的函数:
let TotalNumCharsInPersonsName(p:Person) = let middleLen = match p.MiddleName with | None -> 0 | Some(s) -> s.Length p.FirstName.Length + middleLen + p.LastName.Length
不用担心 相比之下,在一个带有空string的types的引用的语言,然后假设
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对象不具有非空的所有variables,或者
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
确保第一个/最后一个在那里,但是中间可以是空的,或者你可以做检查,抛出不同types的exception,或者谁知道什么。 所有这些疯狂的实现select以及需要考虑的事情都是因为这个愚蠢的可代表价值而不需要或不需要的。
空通常会增加不必要的复杂性。 复杂性是所有软件的敌人,你应该努力在合理的时候降低复杂性。
(注意,即使这些简单的例子也有更多的复杂性,即使FirstName
不能为null
,一个string
也可能代表""
(空string),这可能也不是我们打算build模的人名。 ,即使是不可空的string,它仍然可能是我们“代表无意义的值”的情况。同样,你可以select在运行时通过不variables和条件代码或使用types系统(例如,一个NonEmptyString
types)后者可能是不明智的(“好”types通常是“closures”一系列常用操作,例如NonEmptyString
没有closures.SubString(0,0)
),但是它展示了更多的点在devise领域,在一天结束的时候,在任何给定types的系统中,都会有一些复杂性,它会很好地摆脱,而其他的复杂性本来就难以摆脱。几乎在每一种types的系统中,从“默认可空引用” 到“默认的不可空引用”几乎总是一个简单的变化,使得types系统更好地处理复杂性,排除某些types的错误和无意义的状态。 所以这么多语言一再重复这个错误是非常疯狂的。)
选项types的好处并不在于它们是可选的。 这是所有其他types都没有 。
有时 ,我们需要能够表示一种“空”状态。 有时我们必须表示一个“无价值”选项以及一个variables可能采取的其他可能值。 所以,一个语言不能容忍这个将会有点残缺。
但是经常 ,我们不需要它,并且允许这样的“空”状态只会导致模棱两可和混淆:每次我在.NET中访问一个引用typesvariables时,我都必须考虑它可能是空的 。
通常情况下,它永远不会是空的,因为程序员将代码结构化,使其永远不会发生。 但编译器无法validation,并且每次看到它时,都必须问自己:“这个可以为null吗?我需要在这里检查null吗?
理想情况下,在许多情况下,null是没有意义的, 不应该被允许 。
在.NET中实现这一点非常棘手,几乎所有东西都可以为空。 你必须依靠你所调用的代码的作者是100%的纪律和一致的,并已经清楚地logging什么可以或不可以为空,或者你必须是偏执狂和检查一切 。
但是,如果types默认是不可空的 ,那么你不需要检查它们是否为空。 你知道它们不能为null,因为编译器/types检查器会为你执行。
然后,我们只需要一个后门,在罕见的情况下,我们需要处理一个空状态。 然后可以使用“选项”types。 那么我们在有意识地决定我们需要能够表示“没有价值”的情况下,在其他情况下,我们知道这个值永远不会为空。
正如其他人所提到的,例如在C#或Java中,null可以表示以下两种情况之一:
- 该variables是未初始化的。 理想情况下,这应该不会发生。 一个variables不应该存在,除非它被初始化。
- 该variables包含一些“可选”数据:它需要能够表示没有数据的情况 。 这有时是必要的。 也许你正试图在列表中find一个对象,而事先并不知道它是否在那里。 那么我们需要能够代表“没有发现任何物体”。
第二个意思是要保留的,但是第一个意思应该完全消除。 甚至第二个含义不应该是默认的。 这是我们可以select, 如果当我们需要它的东西 。 但是当我们不需要某些东西是可选的,我们希望types检查器保证它永远不会为空。
到目前为止,所有的答案都集中在为什么null
是一件坏事,以及如果一种语言可以保证某些值永远不会为null,那么它是否便利。
然后,他们继续build议,如果对所有值强制执行不可为空性,这将是一个非常简洁的想法,如果您添加一个像Option
或Maybe
这样的概念来表示可能不总是具有定义值的types,则可以这样做。 这是Haskell采取的方法。
这都是好东西! 但是它并不排除使用显式的可为空/非空types来实现相同的效果。 那么为什么Option还是一件好事呢? 毕竟,Scala支持可空值( 必须是,所以它可以与Java库一起工作),但也支持Options
。
问:除了能从语言中彻底清除空值之外,还有什么好处呢?
A.构成
如果你从无效的代码做一个天真的翻译
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
当你开始处理选项列表,它会变得更好。 想象一下,列表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)
相应的带有空检查的代码(甚至elvis?:运算符)将会很痛苦。 这里真正的技巧是flatMap操作,它允许以可为空的值永远不能实现的方式嵌套理解选项和集合。
由于人们似乎错过了它: null
是模棱两可的。
爱丽丝的出生date是null
。 这是什么意思?
鲍勃的死亡date是null
。 那是什么意思?
“合理的”解释可能是爱丽丝的出生date是存在的,但是是未知的,而鲍勃的死亡date不存在(鲍勃还活着)。 但为什么我们得到不同的答案?
另一个问题: null
是一个边缘情况。
- 是
null = null
? -
nan = nan
? - 是
inf = inf
? - 是
+0 = -0
? - 是
+0/0 = -0/0
?
答案通常分别是 “是”,“否”,“是”,“是”,“否”,“是”。 疯狂的“math家”称NaN为“无效”,并认为它与自身相等。 SQL将null视为不等于任何东西(所以它们的行为就像NaN)。 当你试图将±∞,±0和NaN存储到同一个数据库列(有2 53个 NaN,其中一半是“否定的”)时,会发生什么情况。
更糟糕的是,数据库在处理NULL的方式上有所不同,其中大多数不一致(请参阅SQLite中的NULL处理概述)。 这太可怕了
现在为了强制性的故事:
我最近devise了一个(sqlite3)数据库表,有五列a NOT NULL, b, id_a, id_b NOT NULL, timestamp
。 因为这是一个通用的模式,旨在解决相当任意的应用程序的一般问题,有两个唯一性约束:
UNIQUE(a, b, id_a) UNIQUE(a, b, id_b)
id_a
只存在与现有的应用程序devise的兼容性(部分原因是我还没有提出更好的解决scheme),并没有在新的应用程序中使用。 由于NULL在SQL中的工作方式,我可以插入(1, 2, NULL, 3, t)
和(1, 2, NULL, 4, t)
并且不违反第一个唯一性约束(因为(1, 2, NULL) != (1, 2, NULL)
)。
这特别是因为NULL在大多数数据库的唯一性约束条件下工作的原因(大概是为了模拟“真实世界”情况比较容易,例如,没有两个人可以有相同的社会安全号码,但并不是所有的人都有)。
FWIW,不首先调用未定义的行为,C ++引用不能“指向”null,并且不可能用未初始化的引用成员variables(如果抛出exception,构造失败)构造一个类。
旁注:偶尔你可能需要互斥指针(即只有其中一个可以是非NULL),例如在假设的iOS type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
。 相反,我不得不做类似于assert((bool)actionSheet + (bool)alertView == 1)
。
具有引用/指针的默认情况下是不可用的。
我不认为这是空值的主要问题,空值的主要问题是它们可能意味着两件事情:
- 引用/指针是未初始化的:这里的问题与一般的可变性相同。 首先,它使分析代码变得更加困难。
- variables为null实际上意味着某种东西:这种情况是选项types实际上forms化的。
支持选项types的语言通常也禁止或阻止使用未初始化的variables。
选项types如何工作,包括缓解检查空例如模式匹配的策略。
为了有效,需要直接在语言中支持Optiontypes。 否则,需要大量的锅炉代码来模拟它们。 模式匹配和types推断是使选项types易于使用的两个关键语言特征。 例如:
在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这样的没有直接支持Optiontypes的语言中,我们会有这样的东西:
//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());
消息吃零的替代解决scheme
Objective-C的“吃零”信息不是一个解决scheme,因为它试图减轻无效检查的头痛。 基本上,当试图调用null对象上的方法时,不是抛出运行时exception,而是将expression式计算为null。 暂时不相信,就好像每个实例方法都以if (this == null) return null;
开头, if (this == null) return null;
。 但是之后就会出现信息丢失:您不知道该方法是否返回null,因为它是有效的返回值,或者因为该对象实际上是null。 这很像吞咽exception,并没有任何进展解决之前提到的空问题。
大会给我们带来的地址也被称为无types指针。 C直接将它们映射为键入的指针,但将Algol的空值作为唯一指针值引入,与所有types指针兼容。 C中的空值是一个很大的问题,因为每个指针都可以为null,所以如果没有手动检查,就不能安全地使用指针。
在高级语言中,空值是非常尴尬的,因为它传达了两个不同的概念:
- 告诉某事是未定义的 。
- 告诉某事是可选的 。
有未定义的variables几乎是无用的,并产生未定义的行为,只要发生。 我想每个人都会同意不要花钱,不要不pipe。
第二种情况是可选性,最好是明确提供,例如选项types 。
假设我们在一家运输公司,我们需要创build一个应用程序来帮助我们为司机创build一个时间表。 对于每位司机,我们都会储存一些信息,例如:驾驶执照和紧急情况下的电话号码。
在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,你可能只是忘记了一个空检查和繁荣…
注:这些代码示例没有编译,但我希望你有想法。
微软研究院有一个名为“intersting”的项目
规格#
这是一个非空types的C#扩展,并且有一些机制可以检查你的对象是否为空 ,尽pipe,恕我直言, 通过契约原则应用devise可能更适合于更多的空引用引起麻烦的情况。
Robert Nystrom在这里提供了一篇不错的文章:
http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/
描述他的想法过程中增加对缺失和失败的支持,以他的喜鹊编程语言。
来自.NET的背景,我一直认为null有一个点,它的用处。 直到我开始了解结构,以及与它们一起工作是多么容易,避免了大量的样板代码。 托尼·霍尔(Tony Hoare)于2009年在伦敦QCon大会上发言, 为发明空值参考而道歉 。 引用他的话:
我把它称为我十亿美元的错误。 这是1965年空引用的发明。当时,我正在devise面向对象语言(ALGOL W)的第一个全面的引用types系统。 我的目标是确保所有引用的使用都是绝对安全的,编译器会自动执行检查。 但是,我忍不住引入空引用的诱惑,仅仅因为它很容易实现。 这导致了无数的错误,漏洞和系统崩溃,在过去的四十年里可能造成了十亿美元的痛苦和损失。 近年来,许多像PREfix和PREfast这样的微软程序分析器都被用来检查引用,如果存在非空的风险,则会发出警告。 更近期的编程语言如Spec#引入了非空引用的声明。 这是我于1965年拒绝的解决办法。
程序员也可以看到这个问题
我一直把Null(或者无)视为没有价值 。
有时候你想要这个,有时候你不需要。 这取决于你正在使用的域名。 如果缺席是有意义的:没有中间名,那么你的申请可以采取相应的行动。 另一方面,如果空值不应该在那里:第一个名字是空的,那么开发者得到谚语两个电话。
我也看到代码重载和过度复杂的检查null。 对我来说,这意味着两件事之一:
a)应用程序树中更高的错误
b)坏/不完整的devise
从积极的方面来看,Null可能是检查是否有缺失的更有用的概念之一,没有null概念的语言在数据validation的时候会导致过度复杂的事情。 在这种情况下,如果一个新的variables没有被初始化,那么这个variables通常会设置variables为一个空string0或者一个空集合。 但是,如果一个空string或0或空集合是您的应用程序的有效值 – 那么你有一个问题。
有时候,通过为字段创build特殊/奇怪的值来表示一个未初始化的状态。 但是,当一个善意的用户input特殊的价值时会发生什么? 让我们不要陷入这将使数据validation例程。 如果语言支持空概念,所有的担忧都会消失。
vector语言有时可以不带null。
在这种情况下,空向量用作types化的空值。