没有null的语言的最佳解释

Rom*_*her 223 null programming-languages functional-programming nullpointerexception non-nullable

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

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

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

Bri*_*ian 428

我认为为什么无效的简洁总结是无意义的状态不应该是可表示的.

假设我正在塑造一扇门.它可以处于以下三种状态之一:打开,关闭但解锁,关闭和锁定.现在我可以按照它的方式对其进行建模

class Door
    private bool isShut
    private bool isLocked
Run Code Online (Sandbox Code Playgroud)

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

type DoorState =
    | Open | ShutAndUnlocked | ShutAndLocked
Run Code Online (Sandbox Code Playgroud)

然后我可以定义

class Door
    private DoorState state
Run Code Online (Sandbox Code Playgroud)

并没有更多的担忧.类型系统将确保实例中只有三种可能的状态class Door.这就是类型系统擅长的类型 - 在编译时明确排除整类错误.

问题null在于每个引用类型在其空间中获得通常不期望的额外状态.一个string变量可以是任何字符序列,或者它可能是这个疯狂的额外null不映射到我的问题域值.一个Triangle对象有三个Points,它们本身具有值XY值,但不幸的是Points或它Triangle本身可能是这个疯狂的空值,对于我正在使用的图形域是没有意义的.等等.

当您打算对可能不存在的值建模时,您应该明确选择它.如果我打算模仿人的方式是每个人Person都有a FirstName和a LastName,但只有一些人有MiddleNames,那么我想说一些像

class Person
    private string FirstName
    private Option<string> MiddleName
    private string LastName
Run Code Online (Sandbox Code Playgroud)

其中,string假设这里是一个非空类型.然后,NullReferenceException在尝试计算某人姓名的长度时,没有棘手的不变量来建立并且没有意外的s.类型系统确保任何代码处理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
Run Code Online (Sandbox Code Playgroud)

无后顾之忧.相比之下,在具有像字符串这样的类型的可空引用的语言中,则假设

class Person
    private string FirstName
    private string MiddleName
    private string LastName
Run Code Online (Sandbox Code Playgroud)

你最终会创作出类似的东西

let TotalNumCharsInPersonsName(p:Person) =
    p.FirstName.Length + p.MiddleName.Length + p.LastName.Length
Run Code Online (Sandbox Code Playgroud)

如果传入的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)
Run Code Online (Sandbox Code Playgroud)

或者可能

let TotalNumCharsInPersonsName(p:Person) =
    p.FirstName.Length
    + (if p.MiddleName=null then 0 else p.MiddleName.Length)
    + p.LastName.Length
Run Code Online (Sandbox Code Playgroud)

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

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

(请注意,即使这些简单的例子也有更多的复杂性.即使a FirstName不能null,string也可以表示""(空字符串),这可能也不是我们打算建模的人名.因此,即使是非可空的字符串,它仍然可能是我们"代表无意义的值"的情况.再次,你可以选择在运行时通过不变量和条件代码,或通过使用类型系统(例如,有一个NonEmptyString类型)来对抗这个.后者可能是不明智的("好"类型通常在一组常见操作中"关闭",例如NonEmptyString并未被关闭.SubString(0,0)),但它在设计空间中展示了更多的观点.在一天结束时,在任何给定类型的系统中,有一些复杂性,它将非常好地摆脱,而其他复杂性本质上更难以摆脱.这个主题的关键在于,在几乎所有类型的系统中,从"默认的可空引用"到"默认的非可空引用"的变化几乎总是一个简单的变化,使得类型系统在对抗复杂性方面做得更好.排除某些类型的错误和无意义的状态.所以很多语言一次又一次地重复这个错误真是太疯狂了.)

  • @akaphenom:谢谢,但请注意并非所有人都喝啤酒(我不喝酒).但我很欣赏你只是使用简化的世界模型来表达感激之情,所以我不会对你的世界模型的有缺陷的假设进行更多的狡辩.:P(在现实世界中如此复杂!:)) (66认同)
  • 什么,你从未锁过门? (59认同)
  • 我不明白为什么人们会对特定域的语义进行研究.Brian以简洁明了的方式用null来表示缺陷,是的,他通过说每个人都有名字和姓氏简化了他的例子中的问题域.这个问题回答了'T',布莱恩 - 如果你曾经在波士顿,我欠你一杯啤酒,你在这里做的所有帖子! (57认同)
  • 回复:名字 - 的确如此.也许你关心的是建造一个悬挂的门,但锁定的锁闩伸出来,防止门关闭.世界上有很多复杂的东西.关键是在软件中实现"世界状态"和"程序状态"之间的映射时不要添加_more_复杂性. (31认同)
  • 奇怪的是,这个世界上有三个国家的大门!它们在一些酒店用作卫生间门.按钮从内部起到钥匙的作用,将门从外面锁起来.一旦锁闩螺栓移动,它就会自动解锁. (4认同)
  • +1,但请注意,并非所有人都有姓氏(“姓氏”和“姓氏”似乎更准确);我听说有些人只有一个名字(这意味着他们不适合大多数数据模型)。阿拉伯名称可能特别复杂。并非所有信用卡都具有16位数字。 (2认同)
  • 顺便说一下,为了更好地阅读软件中的表示主题,我建议使用CLU语言,通过Liskov的绝版"程序开发中的抽象和规范". (2认同)
  • @Brian,感谢您履行我对OP的承诺,即来自功能编程/ F#社区的人将给出一个不错的答案! (2认同)
  • ...旋转门又如何呢?它们可以锁定,但是它们是否真的打开过或关闭过? (2认同)
  • *或者它可能是这个疯狂的额外null**值,不会映射到我的问题域.***好说,接受.. (2认同)

jal*_*alf 65

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

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

通常,我们不需要它,并且允许这种"空"状态只会导致模糊和混淆:每次我访问.NET中的引用类型变量时,我都必须考虑它可能为null.

通常,它实际上永远不会为null,因为程序员构造代码以便它永远不会发生.但编译器无法验证,并且每次看到它时,你都要问自己"这可能是null吗?我需要在这里检查null吗?"

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

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

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

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

正如其他人所提到的,例如在C#或Java中,null可能意味着以下两种情况之一:

  1. 变量未初始化.理想情况下,这应该永远不会发生.除非初始化,否则不应存在变量.
  2. 变量包含一些"可选"数据:它需要能够表示没有数据的情况.这有时是必要的.也许你正试图在列表中找到一个对象,而你事先并不知道它是否在那里.然后我们需要能够表示"没有找到对象".

第二个含义必须保留,但第一个应该完全消除.甚至第二个含义也不应该是默认值.如果我们需要它,我们可以选择加入.但是当我们不需要某些东西是可选的时,我们希望类型检查器保证它永远不会为空.


Kev*_*ght 44

到目前为止,所有的答案都集中在为什么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
}
Run Code Online (Sandbox Code Playgroud)

选项感知代码

def fullNameLength(p:Person) = {
  val middleLen = p.middleName match {
    case Some(x) => x.length
    case _ => 0
  }
  p.firstName.length + middleLen + p.lastName.length
}
Run Code Online (Sandbox Code Playgroud)

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

def fullNameLength(p:Person) = {
  val middleLen = p.middleName map {_.length} getOrElse 0
  p.firstName.length + middleLen + p.lastName.length
}
Run Code Online (Sandbox Code Playgroud)

甚至:

def fullNameLength(p:Person) =       
  p.firstName.length +
  p.middleName.map{length}.getOrElse(0) +
  p.lastName.length
Run Code Online (Sandbox Code Playgroud)

当您开始处理选项列表时,它会变得更好.想象一下,List people本身是可选的:

people flatMap(_ find (_.firstName == "joe")) map (fullNameLength)
Run Code Online (Sandbox Code Playgroud)

这是如何运作的?

//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)
Run Code Online (Sandbox Code Playgroud)

具有空检查(甚至elvis?:运算符)的相应代码将非常长.这里真正的技巧是flatMap操作,它允许以可空值永远无法实现的方式嵌套理解Options和集合.

  • +1,这是一个值得强调的好点.一个附录:在哈斯克尔土地,`flatMap`将被称为`(>> =)',也就是"绑定"运营商的单子.没错,Haskellers喜欢"flatMap"这么多东西,我们把它放在我们语言的标识中. (7认同)
  • 我给了你赏金,因为我真的很喜欢你所说的关于作曲的内容,其他人似乎没有提及. (4认同)

tc.*_*tc. 38

因为人们似乎错过了它: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)
Run Code Online (Sandbox Code Playgroud)

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).


Ste*_*sen 16

默认情况下,具有引用/指针的不可取性是可以为空的.

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

  1. 引用/指针是未初始化的:这里的问题与一般的可变性相同.首先,它使分析代码变得更加困难.
  2. 变量为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;")
Run Code Online (Sandbox Code Playgroud)

但是,在像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());
Run Code Online (Sandbox Code Playgroud)

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

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

  • 我在这里寻找Java,因为C#可能会有一个更好的解决方案......但我很欣赏你的不满,人们真正的意思是"一种具有c语言的语言".我继续前进,取代了"c-like"声明. (4认同)

blt*_*txd 11

大会给我们带来了地址,也称为无类指针.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;
};
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,在我们的驱动程序列表中的任何处理中,我们都必须检查空指针.编译器不会帮助你,程序的安全性依赖于你的肩膀.

在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;
}
Run Code Online (Sandbox Code Playgroud)

我们现在说我们要打印所有驱动程序的名称以及他们的卡车许可证号码.

在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 ? */
  }
}
Run Code Online (Sandbox Code Playgroud)

在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
Run Code Online (Sandbox Code Playgroud)

正如您在这个简单的示例中所看到的,安全版本中没有任何复杂的内容:

  • 这很简洁.
  • 您可以获得更好的保证,并且根本不需要空检查.
  • 编译器确保您正确处理该选项

在C中,你可能只是忘记了空检查和繁荣......

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

  • 作为@tc.说,null与装配无关.在汇编中,类型通常是*不可为空的.加载到通用寄存器中的值可能为零,也可能是某个非零整数.但它永远不会是空的.即使您将内存地址加载到寄存器中,在大多数常见架构中,也没有单独的"空指针"表示.这是在更高级语言中引入的概念,如C. (2认同)

Jah*_*ine 5

微软研究院有一个名为的博弈项目

规格#

它是一个非空类型的C#扩展和一些检查你的对象不是空的机制,虽然,恕我直言,通过契约原则应用设计可能更合适,对于由空引用引起的许多麻烦的情况更有帮助.