当程序员抱怨空错误/异常时,经常有人问我们在没有空的情况下该怎么做。
我对选项类型的简洁性有一些基本了解,但是我没有足够的知识或语言技能来表达它。对以下内容的一个很好的解释是什么,使我们可以指向普通程序员,使之接近普通程序员?
- 默认情况下,不希望引用/指针为空
- 选项类型如何工作,包括简化检查空情况的策略,例如
- 模式匹配和
- 一元理解
- 替代解决方案,例如吃零信息
- (我错过了其他方面)
当程序员抱怨空错误/异常时,经常有人问我们在没有空的情况下该怎么做。
我对选项类型的简洁性有一些基本了解,但是我没有足够的知识或语言技能来表达它。对以下内容的一个很好的解释是什么,使我们可以指向普通程序员,使之接近普通程序员?
Answers:
我认为为什么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
都有a FirstName
和a LastName
,但只有一些人有MiddleName
s,那么我想说些类似的话
class Person
private string FirstName
private Option<string> MiddleName
private string LastName
其中,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
不用担心。相反,在一种对字符串等类型具有可空引用的语言中,则假定
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
确保第一个/最后一个存在,但是Middle可以为null,或者您可能会进行检查以抛出不同类型的异常,或者谁知道。所有这些疯狂的实现选择和需要考虑的事情,因为您不需要或不需要这个愚蠢的可表示值。
Null通常会增加不必要的复杂性。 复杂性是所有软件的大敌,您应该在合理的情况下努力降低复杂性。
(请注意,即使是这些简单的示例也有更多的复杂性。即使a FirstName
not be null
,a string
也可以表示""
(空字符串),这可能也不是我们要建模的人名。因此,即使非同样,您可以选择在运行时通过不变式和条件代码,或者通过使用类型系统(例如,使用NonEmptyString
类型)来对付可空字符串,这仍然是“代表无意义的值”的情况。后者可能是不明智的(“好”类型通常在一组常见操作上被“关闭”,例如NonEmptyString
在.SubString(0,0)
),但它展示了设计空间中的更多要点。归根结底,在任何给定的类型系统中,都有一些复杂性可以很好地消除,而其他复杂性从本质上讲很难消除。该主题的关键在于,几乎在每个类型系统中,从“默认为空的引用”到“默认为非空引用”的更改几乎总是一个简单的更改,这使类型系统在应对复杂性和性能方面大为改善。排除某些类型的错误和无意义的状态。因此,如此多的语言不断重复出现此错误是非常疯狂的。)
关于选项类型的好处不是它们是可选的。是所有其他类型都不是。
有时,我们需要能够代表一种“空”状态。有时,我们必须表示“无值”选项以及变量可能采用的其他可能值。因此,一种完全禁止这种语言的语言将变得有些残废。
但是通常,我们并不需要它,而允许这种“空”状态只会导致模棱两可和混乱:每次我在.NET中访问引用类型变量时,我都必须考虑它可能为null。
通常,它实际上永远不会为空,因为程序员对代码进行结构化以使其永远不会发生。但是编译器无法验证,并且每次看到它时,您都必须问自己“这是否可以为null?我是否需要在此处检查null?”
理想情况下,在很多情况下null都没有意义的情况下,不应允许使用 null 。
在几乎所有内容都可以为null的.NET中实现这一点很棘手。您必须依靠要调用的代码的作者严格遵守100%的规范,并且要清楚地记录什么可以为null或不能为null,否则您必须保持偏执并检查所有内容。
但是,如果类型默认不是不可为null 的,则无需检查它们是否为null。您知道它们永远不能为null,因为编译器/类型检查器会为您强制执行该操作。
然后,我们只需要在极少数情况下,我们一个后门做需要处理空状态。然后可以使用“选项”类型。然后,在我们有意识地决定需要能够表示“无值”情况的情况下,我们允许null;在其他情况下,我们知道该值永远不会为null。
正如其他人提到的那样,例如在C#或Java中,null可以表示以下两种情况之一:
第二个含义必须保留,但第一个含义应完全消除。甚至第二个含义也不应该是默认值。我们可以在需要的时候以及何时需要它。但是,当我们不需要某些可选内容时,我们希望类型检查器保证它永远不会为空。
到目前为止,所有的答案都集中在为什么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
当您开始处理选项列表时,它会变得更好。想象一下,列表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操作,该操作允许以空值永远无法实现的方式来嵌套嵌套选项和集合。
flatMap
被称为(>>=)
,即单子的“绑定”运算符。没错,Haskellers非常喜欢对flatMap
事物执行ping操作,因此我们将其置于语言的徽标中。
由于人们似乎想念它:null
模棱两可。
爱丽丝的生日是null
。这是什么意思?
鲍勃的去世日期是null
。那是什么意思?
一种“合理”的解释可能是爱丽丝的生日存在但未知,而鲍勃的死亡日期不存在(鲍勃还活着)。但是为什么我们得到不同的答案?
另一个问题:null
是一种边缘情况。
null = null
吗nan = nan
吗inf = inf
吗+0 = -0
吗+0/0 = -0/0
吗答案通常分别是 “是”,“否”,“是”,“是”,“否”,“是”。疯狂的“数学家”称NaN为“空”,并说它与自身相等。SQL将null视为不等于任何值(因此,它们的行为类似于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),例如在假设的iOS中type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
。相反,我被迫做类似的事情assert((bool)actionSheet + (bool)alertView == 1)
。
assert(actionSheet ^ alertView)
?还是您的语言不能异或?
默认情况下,不希望具有引用/指针为空。
我认为这不是null的主要问题,而null的主要问题是它们可能意味着两件事:
支持选项类型的语言通常也禁止或不鼓励使用未初始化的变量。
选项类型如何工作,包括简化检查空值情况(例如模式匹配)的策略。
为了有效,需要使用该语言直接支持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;")
但是,在不直接支持Option类型的Java之类的语言中,我们会有类似以下内容:
//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确实很尴尬,因为它实际上传达了两个不同的概念:
拥有未定义的变量几乎是无用的,并且每当它们发生时都会产生未定义的行为。我想每个人都同意应该不惜一切代价避免未定义的事物。
第二种情况是可选的,最好是显式提供,例如,使用option type。
假设我们在一家运输公司中,我们需要创建一个应用程序来帮助我们为驾驶员制定时间表。对于每个驾驶员,我们存储一些信息,例如:他们拥有的驾驶执照以及在紧急情况下拨打的电话号码。
在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
某些Algol语言的发明是“不指向任何东西的引用”(维基百科同意,请参见en.wikipedia.org/wiki/Null_pointer#Null_pointer)。但是,当然,汇编程序员很可能初始化了指向无效地址的指针(读取:Null = 0)。
来自.NET背景,我一直以为null有一点,它很有用。直到我了解结构以及如何使用它们轻松避免大量样板代码。Tony Hoare于2009年在QCon London上发表演讲,对发明空引用表示歉意。引用他的话:
我称之为我的十亿美元错误。这是在1965年发明空引用的。那时,我正在设计第一个用于面向对象语言(ALGOL W)的引用的综合类型系统。我的目标是确保所有对引用的使用都绝对安全,并由编译器自动执行检查。但是我无法抗拒引入空引用的诱惑,仅仅是因为它是如此容易实现。这导致了无数的错误,漏洞和系统崩溃,在最近四十年中可能造成十亿美元的痛苦和破坏。近年来,许多程序分析器(例如Microsoft中的PREfix和PREfast)已用于检查引用,并在有可能不为空的风险时发出警告。诸如Spec#之类的最新编程语言引入了非空引用的声明。这是解决方案,我在1965年拒绝了。
程序员也看到这个问题
Robert Nystrom在这里提供了一篇不错的文章:
http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/
描述在为Magpie编程语言添加对缺席和失败的支持时的思考过程。
我一直将Null(或nil)视为缺少值。
有时您想要这样做,有时则不需要。这取决于您使用的域。如果缺少是有意义的:没有中间名,则您的应用程序可以采取相应措施。另一方面,如果不应存在null值:名字为null,则开发人员会在凌晨2点接到电话。
我也看到过代码过重和检查null的情况过于复杂。对我来说,这意味着两件事之一:
a)应用程序树中更高的错误
b)设计不良/设计不完整
从积极的方面来看,Null可能是检查某些内容是否缺失的更有用的概念之一,而没有null概念的语言在进行数据验证时最终会使事情变得过于复杂。在这种情况下,如果未初始化新变量,则这些语言通常会将变量设置为空字符串,0或空集合。但是,如果空字符串或0或空集合是有效值您的应用程序的,那么您就遇到了问题。
有时通过为字段表示特殊的/怪异的值来表示未初始化状态来避免这种情况。但是,当一个好主意的用户输入特殊值时会发生什么呢?而且不要让数据验证例程陷入混乱。如果该语言支持null概念,那么所有关注点都将消失。