当程序员经常抱怨null错误/异常时,有人会问没有null我们该怎么办。

我对选项类型的酷有一些基本的概念,但我没有最好地表达它的知识或语言技能。对于下面的内容,什么样的解释对普通程序员来说是可以理解的呢?

默认情况下,不希望引用/指针为空 选项类型如何工作,包括简化检查空情况的策略,如 模式匹配和 单细胞生物的理解 替代解决方案,如消息吃nil (我错过的其他方面)


当前回答

我认为为什么空是不可取的简单总结是,无意义的状态不应该是可表示的。

假设我在建模一扇门。它可以处于三种状态之一:打开、关闭但未解锁、关闭并锁定。现在我可以用

class Door
    private bool isShut
    private bool isLocked

如何将三个状态映射到这两个布尔变量是很清楚的。但这就留下了第四种不希望出现的状态:isShut==false && isLocked==true。因为我所选择的类型承认这种状态,所以我必须花费精力来确保类永远不会进入这种状态(可能通过显式地编码一个不变量)。相反,如果我使用一种具有代数数据类型的语言或允许我定义的受控枚举

type DoorState =
    | Open | ShutAndUnlocked | ShutAndLocked

那么我就可以定义

class Door
    private DoorState state

再也没有什么担心了。类型系统将确保类Door的实例只有三种可能的状态。这正是类型系统所擅长的——在编译时显式地排除了一整类错误。

null的问题在于,每个引用类型都会在其空间中获得这个通常不希望的额外状态。字符串变量可以是任意字符序列,也可以是这个疯狂的额外空值,它没有映射到我的问题域。三角形对象有三个点,它们本身有X和Y值,但不幸的是,这些点或三角形本身可能是这个疯狂的空值,对我所处的绘图域毫无意义。等。

当您确实打算对一个可能不存在的值建模时,那么您应该显式地选择它。如果我打算对人建模的方式是每个人都有一个名字和一个姓,但只有一些人有中间名,那么我想说一些像这样的话

class Person
    private string FirstName
    private Option<string> MiddleName
    private string LastName

这里的string被假定为非空类型。这样,在尝试计算某人名字的长度时,就不需要建立棘手的不变量,也不会出现意外的nullreferenceexception。类型系统确保任何处理midlename的代码都考虑到它为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确保first/last在那里,但middle可以为空,或者你可能会检查抛出不同类型的异常,或者谁知道呢。所有这些疯狂的实现选择和需要考虑的事情突然出现,因为有这种愚蠢的可代表价值,你不想要或不需要。

Null通常会增加不必要的复杂性。复杂性是所有软件的敌人,您应该在合理的情况下努力降低复杂性。

(Note well that there is more complexity to even these simple examples. Even if a FirstName cannot be null, a string can represent "" (the empty string), which is probably also not a person name that we intend to model. As such, even with non-nullable strings, it still might be the case that we are "representing meaningless values". Again, you could choose to battle this either via invariants and conditional code at runtime, or by using the type system (e.g. to have a NonEmptyString type). The latter is perhaps ill-advised ("good" types are often "closed" over a set of common operations, and e.g. NonEmptyString is not closed over .SubString(0,0)), but it demonstrates more points in the design space. At the end of the day, in any given type system, there is some complexity it will be very good at getting rid of, and other complexity that is just intrinsically harder to get rid of. The key for this topic is that in nearly every type system, the change from "nullable references by default" to "non-nullable references by default" is nearly always a simple change that makes the type system a great deal better at battling complexity and ruling out certain types of errors and meaningless states. So it is pretty crazy that so many languages keep repeating this error again and again.)

其他回答

微软研究院有个有趣的项目叫做

spec#

它是一个c#扩展,具有非空类型和一些机制来检查你的对象是否为空,尽管,恕我直言,应用契约设计原则可能更合适,对许多由空引用引起的麻烦情况更有帮助。

默认情况下,不希望引用/指针为空。

我不认为这是null的主要问题,null的主要问题是它们可能意味着两件事:

引用/指针是未初始化的:这里的问题与一般的可变性相同。首先,它使分析代码变得更加困难。 变量为空实际上意味着一些事情:这是Option类型实际形式化的情况。

支持Option类型的语言通常也禁止或不鼓励使用未初始化的变量。

选项类型的工作方式包括简化检查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;")

然而,在像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's "message eating nil" is not so much a solution as an attempt to lighten the head-ache of null checking. Basically, instead of throwing a runtime exception when trying to invoke a method on a null object, the expression instead evaluates to null itself. Suspending disbelief, it's as if each instance method begins with if (this == null) return null;. But then there is information loss: you don't know whether the method returned null because it is valid return value, or because the object is actually null. It's a lot like exception swallowing, and doesn't make any progress addressing the issues with null outlined before.

程序集为我们带来了地址,也称为无类型指针。C语言直接将它们映射为类型化指针,但引入Algol的null作为唯一指针值,与所有类型化指针兼容。在C语言中,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语言中,你可能只是忘记了一个空检查,然后……

注意:这些代码示例没有编译,但我希望你明白了。

我认为为什么空是不可取的简单总结是,无意义的状态不应该是可表示的。

假设我在建模一扇门。它可以处于三种状态之一:打开、关闭但未解锁、关闭并锁定。现在我可以用

class Door
    private bool isShut
    private bool isLocked

如何将三个状态映射到这两个布尔变量是很清楚的。但这就留下了第四种不希望出现的状态:isShut==false && isLocked==true。因为我所选择的类型承认这种状态,所以我必须花费精力来确保类永远不会进入这种状态(可能通过显式地编码一个不变量)。相反,如果我使用一种具有代数数据类型的语言或允许我定义的受控枚举

type DoorState =
    | Open | ShutAndUnlocked | ShutAndLocked

那么我就可以定义

class Door
    private DoorState state

再也没有什么担心了。类型系统将确保类Door的实例只有三种可能的状态。这正是类型系统所擅长的——在编译时显式地排除了一整类错误。

null的问题在于,每个引用类型都会在其空间中获得这个通常不希望的额外状态。字符串变量可以是任意字符序列,也可以是这个疯狂的额外空值,它没有映射到我的问题域。三角形对象有三个点,它们本身有X和Y值,但不幸的是,这些点或三角形本身可能是这个疯狂的空值,对我所处的绘图域毫无意义。等。

当您确实打算对一个可能不存在的值建模时,那么您应该显式地选择它。如果我打算对人建模的方式是每个人都有一个名字和一个姓,但只有一些人有中间名,那么我想说一些像这样的话

class Person
    private string FirstName
    private Option<string> MiddleName
    private string LastName

这里的string被假定为非空类型。这样,在尝试计算某人名字的长度时,就不需要建立棘手的不变量,也不会出现意外的nullreferenceexception。类型系统确保任何处理midlename的代码都考虑到它为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确保first/last在那里,但middle可以为空,或者你可能会检查抛出不同类型的异常,或者谁知道呢。所有这些疯狂的实现选择和需要考虑的事情突然出现,因为有这种愚蠢的可代表价值,你不想要或不需要。

Null通常会增加不必要的复杂性。复杂性是所有软件的敌人,您应该在合理的情况下努力降低复杂性。

(Note well that there is more complexity to even these simple examples. Even if a FirstName cannot be null, a string can represent "" (the empty string), which is probably also not a person name that we intend to model. As such, even with non-nullable strings, it still might be the case that we are "representing meaningless values". Again, you could choose to battle this either via invariants and conditional code at runtime, or by using the type system (e.g. to have a NonEmptyString type). The latter is perhaps ill-advised ("good" types are often "closed" over a set of common operations, and e.g. NonEmptyString is not closed over .SubString(0,0)), but it demonstrates more points in the design space. At the end of the day, in any given type system, there is some complexity it will be very good at getting rid of, and other complexity that is just intrinsically harder to get rid of. The key for this topic is that in nearly every type system, the change from "nullable references by default" to "non-nullable references by default" is nearly always a simple change that makes the type system a great deal better at battling complexity and ruling out certain types of errors and meaningless states. So it is pretty crazy that so many languages keep repeating this error again and again.)

到目前为止,所有的答案都集中在为什么null是一个坏东西,以及如果一种语言可以保证某些值永远不会为null,那么它是多么方便。

然后他们继续建议,如果对所有值强制执行非空性,这将是一个非常棒的想法,如果您添加一个像Option或Maybe这样的概念来表示可能不总是有定义值的类型,就可以做到这一点。这就是Haskell所采用的方法。

这些都是好东西!但它并不排除使用显式可空/非空类型来实现相同的效果。那么,为什么Option仍然是一个好东西呢?毕竟,Scala支持可空值(这是必须的,所以它可以与Java库一起工作),但也支持选项。

问:除了能够从语言中完全删除空值之外,还有什么好处呢?

答:作文

如果从支持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

当你开始处理List of Options时,它会变得更好。假设List 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操作,它允许以一种可空值永远无法实现的方式嵌套理解Options和集合。