没有null的语言的最佳解释

时间:2010-10-21 15:43:26

标签: programming-languages functional-programming null nullpointerexception non-nullable

当程序员抱怨空错误/异常时,有人经常会问我们做什么而没有空。

我对选项类型的酷感有一些基本的想法,但我没有最好的表达它的知识或语言技巧。什么是伟大的解释以下以一种平易近乎程序员可以接近的方式编写的内容,我们可以指出那个人?

  • 默认情况下,引用/指针的可取性是可以为空的
  • 选项类型的工作原理,包括轻松检查空案例的策略
    • 模式匹配和
    • monadic comprehensions
  • 替代解决方案,例如消息吃零
  • (我错过的其他方面)

11 个答案:

答案 0 :(得分:422)

我认为为什么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个,它们本身有XY个值,但遗憾的是PointTriangle本身可能是这个疯狂的空值,对我正在工作的图形域没有意义。等等。

如果您打算对可能不存在的值建模,那么您应该明确选择它。如果我打算为人们建模的方式是每个Person都有FirstNameLastName,但只有一些人有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对象没有所有非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

假设p确保第一个/最后一个存在但是中间可以为空,或者您可以执行检查以抛出不同类型的异常,或者谁知道什么。所有这些疯狂的实现选择和要考虑的事情都会突然出现,因为这是你不想要或不需要的这个愚蠢的可表示的值。

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

(请注意,即使这些简单的示例也更复杂。即使FirstName不能nullstring也可以代表""(空字符串) ),这可能也不是我们打算建模的人名。因此,即使使用不可空的字符串,我们仍然可能“代表无意义的价值观”。再一次,你可以选择与之斗争通过运行时的不变量和条件代码,或者通过使用类型系统(例如,具有NonEmptyString类型)。后者可能是不明智的(“好”类型通常在一组常见操作中“关闭” ,例如NonEmptyString并未关闭.SubString(0,0)),但它在设计空间中展示了更多的点。在一天结束时,在任何给定类型的系统中,都会有一些复杂性。擅长摆脱,以及其他本质上难以摆脱的复杂性。本主题的关键在于,在每个类型系统中,从“默认为可空的引用”到“非-N默认情况下,ullable引用“几乎总是一个简单的改变,使得类型系统在处理复杂性和排除某些类型的错误和无意义状态方面更加出色。所以很多语言一次又一次地重复这个错误真是太疯狂了。)

答案 1 :(得分:63)

关于选项类型的好处不是它们是可选的。这是所有其他类型

有时候,我们需要能够代表一种“空”状态。有时我们必须表示“无值”选项以及变量可能采用的其他可能值。因此,一种不容忽视的语言将会有点瘫痪。

经常,我们不需要它,并且允许这样的“空”状态只会导致模糊和混淆:每次我访问引用类型变量在.NET中,我必须考虑它可能是null

通常情况下,实际永远不会为空,因为程序员会构造代码,使其永远不会发生。但是编译器无法验证,并且每次看到它时,你都要问自己“这可能是空的吗?我需要在这里检查null吗?”

理想情况下,在很多情况下,null无效,不应该允许

在.NET中实现这一点很棘手,几乎所有东西都可以为空。你必须依赖你所要求的代码的作者100%自律和一致,并清楚地记录什么可以和不能为空,或者你必须偏执并检查一切。< / p>

但是,如果类型默认情况下不可为空,那么您无需检查它们是否为空。你知道它们永远不会为null,因为编译器/类型检查器会为你强制执行。

然后我们只需要一个后门来处理我们需要处理空状态的罕见情况。然后可以使用“选项”类型。然后我们在我们有意识地决定我们需要能够表示“无价值”的情况下允许null,而在其他情况下,我们知道该值永远不会为空。

正如其他人所提到的,例如在C#或Java中,null可能意味着两件事之一:

  1. 变量未初始化。理想情况下,这应该永远不会发生。除非已初始化变量,否则变量不应存在
  2. 变量包含一些“可选”数据:它需要能够表示没有数据的情况。这有时是必要的。也许你正试图在列表中找到一个对象,而你事先并不知道它是否在那里。然后我们需要能够表示“没有找到对象”。
  3. 必须保留第二个含义,但应完全消除第一个含义。甚至第二个含义也不应该是默认值。我们可以选择加入,如果需要的话。但是当我们不需要某些东西是可选的时,我们希望类型检查器保证它永远不会为空。

答案 2 :(得分:43)

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

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

这都是好东西!但它并不排除使用显式可空/非空类型来实现相同的效果。那么,为什么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
}

没有太大区别!但它也是糟糕的使用选项的方式......这种方法更清晰:

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操作,它允许以可空值永远无法实现的方式嵌套理解Options和集合。

答案 3 :(得分:38)

由于人们似乎错过了它:null含糊不清。

Alice的出生日期为null。这是什么意思?

鲍勃的死亡日期为null。这是什么意思?

“合理”的解释可能是爱丽丝的出生日期存在但未知,而鲍勃的死亡日期不存在(鲍勃还活着)。但为什么我们得到不同的答案呢?


另一个问题:null是边缘情况。

  • null = null
  • nan = nan
  • inf = inf
  • +0 = -0
  • +0/0 = -0/0

答案通常分别是“是”,“否”,“是”,“是”,“否”,“是”。疯狂的“数学家”称NaN为“无效”,并称它与自身相等。 SQL将null视为不等于任何东西(因此它们的行为类似于NaN)。人们想知道当你试图将±∞,±0和NaN存储到同一个数据库列中时会发生什么(有2个 53 NaNs,其中一半是“负数”)。

更糟糕的是,数据库在处理NULL方面有所不同,而且大多数数据库都不一致(有关概述,请参阅NULL Handling in SQLite)。这太可怕了。


现在是强制性的故事:

我最近设计了一个包含五列a NOT NULL, b, id_a, id_b NOT NULL, timestamp的(sqlite3)数据库表。因为它是一个通用模式,旨在解决相当随意的应用程序的一般问题,所以有两个唯一性约束:

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)之类的东西。

答案 4 :(得分:16)

默认情况下,引用/指针的可取性是不可取的。

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

  1. 引用/指针未初始化:此处的问题与一般的可变性相同。首先,它使分析代码变得更加困难。
  2. 变量为null实际上意味着什么:这是Option类型实际形式化的情况。
  3. 支持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());
    

    替代解决方案,例如消息吃零

    Objective-C的“消息吃零”并不是一个解决方案,而是试图减轻空检查的头痛。基本上,不是在尝试调用null对象上的方法时抛出运行时异常,而是将表达式计算为null本身。暂不相信,就像每个实例方法都以if (this == null) return null;开头一样。但是有信息丢失:你不知道该方法是否返回null,因为它是有效的返回值,或者因为该对象实际上是null。这很像吞咽异常,并且在解决以前概述的null问题方面没有取得任何进展。

答案 5 :(得分:11)

汇编为我们带来了地址,也称为无类型指针。 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中,你可能只是忘记了空检查和繁荣......

注意:这些代码示例没有编译,但我希望你能得到这些想法。

答案 6 :(得分:5)

Microsoft Research有一个名为

的有趣项目
  

规格#

它是一个带有非null类型的C#扩展名和检查你的对象不是空的的一些机制,尽管如此,恕我直言,应用设计合同原则可能更合适,对于由空引用引起的许多麻烦情况更有帮助。

答案 7 :(得分:3)

Robert Nystrom在这里提供了一篇很好的文章:

http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/

描述他在为Magpie编程语言添加对缺席和失败的支持时的思维过程。

答案 8 :(得分:3)

来自.NET背景,我一直认为null有一点,它很有用。直到我了解了结构以及它们与它们一起工作是多么容易,避免了大量的样板代码。 Tony Hoare于2009年在QCon伦敦发表演讲,apologized for inventing the null reference。引用他:

  

我称之为十亿美元的错误。这是null的发明   在1965年参考。那时,我正在设计第一个   综合类型系统,用于面向对象的引用   语言(ALGOL W)。我的目标是确保所有参考文献的使用   应绝对安全,并自动执行检查   编译器。但我无法抗拒陷入无效的诱惑   参考,只是因为它很容易实现。这导致了   无数的错误,漏洞和系统崩溃,都有   在过去的四十年中,可能造成了十亿美元的痛苦和伤害   年份。近年来,许多程序分析器如PREfix和   Microsoft中的PREfast已用于检查引用,并给出   警告如果存在风险,则可能是非空的。更近   像Spec#这样的编程语言引入了声明   非空引用。这是我在1965年拒绝的解决方案。

同时查看此问题at programmers

答案 9 :(得分:1)

我一直认为Null(或nil)是没有值

有时候你想要这个,有时你却不想要。这取决于您正在使用的域。如果缺席是有意义的:没有中间名,那么您的申请可以采取相应的行动。另一方面,如果空值不应该存在:第一个名称为null,那么开发人员将获得众所周知的早上2点电话。

我也看到了代码重载和过度复杂的检查null。对我来说,这意味着两件事之一: a)应用程序树中更高的错误
b)糟糕/不完整的设计

从积极的方面来说 - Null可能是检查某些东西是否缺失的更有用的概念之一,而没有null概念的语言会在需要进行数据验证时过度复杂化。在这种情况下,如果未初始化新变量,则所述语言通常将变量设置为空字符串0或空集合。但是,如果应用程序的空字符串或0或空集合有效值,则表示您遇到问题。

有时通过发明字段的特殊/奇怪值来表示未初始化状态,从而避免了这种情况。但是当一个善意的用户输入特殊值时会发生什么?让我们不要陷入数据验证程序的混乱局面。 如果语言支持null概念,则所有问题都将消失。

答案 10 :(得分:0)

有时,矢量语言可以通过没有空值来逃避。

在这种情况下,空向量用作类型化的空值。