代码之家  ›  专栏  ›  技术社区  ›  Roman A. Taycher

无空语言的最佳解释

  •  219
  • Roman A. Taycher  · 技术社区  · 14 年前

    每当程序员抱怨空错误/异常时,总会有人问我们不使用空做什么。

    我对选项类型的酷性有一些基本的概念,但是我没有最好地表达它的知识或语言技能。什么是 伟大的 以一种普通程序员可以接近的方式来解释下面的内容,我们可以把这个人引向哪一个?

    • 默认情况下,具有引用/指针的不可接受性为空。
    • 选项类型如何工作,包括易于检查空情况的策略,例如
      • 模式匹配和
      • 单元理解
    • 其他解决方案,如消息吃零
    • (我错过的其他方面)
    11 回复  |  直到 7 年前
        1
  •  422
  •   Brian    14 年前

    我认为,为什么空值是不受欢迎的简单概括是 无意义的状态不应该是可表示的 .

    假设我在做一扇门的模型。它可以处于三种状态之一:打开、关闭但未锁定,以及关闭和锁定。现在我可以按照

    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 变量可以是任何字符序列,也可以是这个疯狂的额外变量。 无效的 没有映射到我的问题域的值。一 Triangle 对象有三个 Point S,他们自己有 X Y 价值观,但不幸的是 S或 三角形 它本身可能是一个疯狂的空值,对于我正在工作的图形域来说毫无意义。等。

    当您确实打算建模一个可能不存在的值时,那么您应该明确地选择它。如果我想塑造人们的方式是 Person 有一个 FirstName 和A LastName 但是只有一些人 MiddleName S,那么我想说

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

    哪里 一串 这里假定为不可为空的类型。那么就没有复杂的不变量可以建立,也没有意外的不变量。 NullReferenceException 当试图计算某人名字的长度时。类型系统确保处理 中间名 说明它可能 None ,而任何处理 第一名字 可以安全地假设那里有一个值。

    例如,使用上面的类型,我们可以编写这个愚蠢的函数:

    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 确保第一个/最后一个存在,但中间可以为空,或者您可以执行引发不同类型异常的检查,或者谁知道。所有这些疯狂的实现选择和需要考虑的事情突然出现,因为有一个你不想要或不需要的愚蠢的可代表的价值。

    空通常会增加不必要的复杂性。 复杂性是所有软件的敌人,只要合理,您就应该努力降低复杂性。

    (请注意,即使是这些简单的例子,也更复杂。即使是 第一名字 不能 无效的 A 一串 可以代表 "" (空字符串),也可能不是我们要建模的人名。因此,即使使用不可为空的字符串,我们仍然可能“表示无意义的值”。同样,您可以选择在运行时通过不变量和条件代码或使用类型系统(例如 NonEmptyString 类型)。后者可能是不明智的(“好的”类型通常在一组公共操作上是“封闭的”,例如 非空字符串 未关闭 .SubString(0,0) 但是它在设计空间中展示了更多的点。最后,在任何给定类型的系统中,它都会有一些非常擅长消除的复杂性,而其他的复杂性本质上很难消除。这个话题的关键是 每一个 类型系统,从“默认为空的引用”到“默认为非空的引用”的变化几乎总是一个简单的变化,这使得类型系统在处理复杂性和排除某些类型的错误和无意义状态方面做得更好。所以太疯狂了,这么多语言不断重复这个错误。)

        2
  •  63
  •   Stack Overflow is garbage    14 年前

    选项类型的好处不是它们是可选的。就是那个 不是所有其他类型的 .

    有时 我们需要能够表示一种“空”状态。有时我们必须表示一个“无值”选项以及一个变量可能采用的其他可能值。因此,一种完全不允许这样做的语言将会有点残废。

    但是 经常 我们不需要,而且 允许 这样的“空”状态只会导致歧义和混乱:每次我访问.NET中的引用类型变量时,我都必须考虑到 可能是空的 .

    通常,它永远不会 事实上 为空,因为程序员构造代码以使其永远不会发生。但是编译器无法验证这一点,每次你看到它,你都必须问自己“这能是空的吗?”我需要在这里检查空值吗?”

    理想情况下,在许多情况下,空值没有意义, 不应该允许这样做 .

    在.NET中很难实现这一点,因为几乎所有内容都可以为空。你必须依赖于你所调用的代码的作者,使其100%的纪律性和一致性,并且清楚地记录了什么可以或不能为空,或者你必须偏执和检查 一切 .

    但是,如果类型不可以为空 默认情况下 ,则不需要检查它们是否为空。您知道它们永远不能为空,因为编译器/类型检查器为您强制执行它。

    然后我们就需要一个后门来处理罕见的案件 需要处理空状态。然后可以使用“选项”类型。然后我们允许空值出现在我们已经做出了一个有意识的决定的情况下,我们需要能够表示“无值”的情况,并且在其他情况下,我们知道值永远不会是空值。

    正如其他人提到的,例如,在C语言或Java中,NULL可以指两件事情之一:

    1. 变量未初始化。理想情况下, 从未 发生。变量不应该 存在 除非它被初始化。
    2. 变量包含一些“可选”数据:它需要能够表示 没有数据 . 这有时是必要的。也许你试图在一个列表中找到一个对象,但你事先不知道它是否在那里。然后我们需要能够表示“没有发现任何物体”。

    第二个意义必须保留,但第一个意义应该完全消除。即使第二个意思也不应该是默认的。这是我们可以选择的 如果我们需要的时候 . 但是当我们不需要可选的东西时,我们希望类型检查器 保证 它永远不会是空的。

        3
  •  43
  •   Kevin Wright    14 年前

    到目前为止,所有的答案都集中在为什么 null 是件坏事,如果一种语言能保证某些价值观 从未 为零。

    然后他们继续建议,如果您对 全部的 值,如果添加类似 Option Maybe 表示可能不总是具有定义值的类型。这是哈斯克尔采取的方法。

    都是好东西!但它并不排除使用显式的可空/非空类型来实现相同的效果。那么,为什么选择仍然是一件好事?毕竟,scala支持可以为空的值(即 因此,它可以与Java库一起工作,但支持 Options 也。

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

    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?:operators)会非常长。这里真正的技巧是flatmap操作,它允许以一种不可为空值的方式嵌套理解选项和集合。

        4
  •  38
  •   Matt Joiner    14 年前

    因为人们似乎错过了它: null 含糊不清。

    爱丽丝的出生日期是 无效的 . 这是什么意思?

    鲍勃的死亡日期是 无效的 . 那是什么意思?

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


    另一个问题: 无效的 是一个边缘案例。

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

    答案是 通常 分别为“是”、“否”、“是”、“是”、“否”、“是”。疯狂的“数学家”称南为“虚无”,并说它比自己平等。SQL将空值视为不等于任何内容(因此它们的行为类似于NaN)。当您试图将±、±0和nan存储到同一个数据库列中时会发生什么情况(有2个 五十三 南,其中一半是“负的”)。

    更糟的是,数据库处理空值的方式不同,而且大多数不一致(请参见 NULL Handling in SQLite 概述)。太可怕了。


    现在,对于强制性的故事:

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

    UNIQUE(a, b, id_a)
    UNIQUE(a, b, id_b)
    

    id_a 只存在于与现有应用程序设计兼容的情况下(部分原因是我没有找到更好的解决方案),并且在新应用程序中没有使用。由于空在SQL中的工作方式,我可以插入 (1, 2, NULL, 3, t) (1, 2, NULL, 4, t) 并且不违反第一个唯一性约束(因为 (1, 2, NULL) != (1, 2, NULL) )

    这尤其有效,因为在大多数数据库的唯一性约束中,空值是如何工作的(假设这样更容易模拟“真实世界”的情况,例如,没有两个人可以拥有相同的社会保险号,但并非所有人都有一个)。


    FWWW,不首先调用未定义的行为,C++引用不能“指向”null,并且不可能构造具有未初始化的引用成员变量的类(如果抛出异常,则构造失败)。

    旁注:有时您可能需要互斥指针(即只有一个指针可以是非空的),例如在假设的IOS中 type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed . 相反,我被迫做像 assert((bool)actionSheet + (bool)alertView == 1) .

        5
  •  16
  •   Stephen Swensen    13 年前

    默认情况下,具有引用/指针的不可接受性为空。

    我不认为这是空值的主要问题,空值的主要问题是它们有两个含义:

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

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

    选项类型如何工作,包括易于检查空情况(如模式匹配)的策略。

    为了有效,需要在语言中直接支持选项类型。否则,需要大量的锅炉板代码来模拟它们。模式匹配和类型推断是使选项类型易于使用的两个关键语言功能。例如:

    在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这样的语言中,如果没有直接支持选项类型,我们会有如下类似的情况:

    //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的“消息吃不到”并不是一个解决方案,而是一种减轻检查无效时头疼的尝试。基本上,在尝试对空对象调用方法时,表达式不会引发运行时异常,而是计算为空本身。挂起不信任,就好像每个实例方法都以 if (this == null) return null; . 但是还有信息丢失:您不知道方法是否返回了空值,因为它是有效的返回值,或者对象实际上是空值。这很像“异常吞咽”,并且在解决前面概述为空的问题方面没有任何进展。

        6
  •  11
  •   bltxd    14 年前

    汇编给我们带来了地址,也称为非类型指针。C直接将它们映射为类型指针,但引入了algol的空值作为唯一的指针值,与所有类型指针兼容。在C语言中,空值的一个大问题是,由于每个指针都可以为空,所以在没有手动检查的情况下,永远不能安全地使用指针。

    在高级语言中,空值很难理解,因为它实际上传达了两个截然不同的概念:

    • 告诉你什么是 未定义 .
    • 告诉你什么是 可选择的 .

    拥有未定义的变量几乎是无用的,并且在任何时候都会导致未定义的行为。我想每个人都会同意,无论如何都要避免不明确的事情发生。

    第二种情况是可选性,最好明确提供,例如 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语言中,你可能只是忘记了一个空的检查和繁荣…

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

        7
  •  5
  •   Jahan Zinedine    9 年前

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

    规格说明

    它是C扩展 非空类型 以及一些机制 检查对象是否不为空 但是,imho,应用 按合同设计 对于许多由空引用引起的麻烦情况,原则可能更合适,也更有用。

        8
  •  3
  •   Corbin March    14 年前

    罗伯特·尼斯特罗姆在这里提供了一篇很好的文章:

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

    在为他的缺席和失败添加支持时描述他的思想过程 Magpie 程序设计语言。

        9
  •  3
  •   Community CDub    8 年前

    从.NET的背景来看,我一直认为空值有一个点,它很有用。直到我开始了解结构以及如何轻松地使用它们,从而避免使用大量样板代码。 Tony Hoare 2009年在伦敦Qcon演讲时, apologized for inventing the null reference . 引用他:

    我称之为我的十亿美元错误。这是虚空的发明 1965年的参考文献。当时,我正在设计第一个 面向对象引用的综合类型系统 语言(algol w)。我的目标是确保所有引用的使用 应绝对安全,检查由 编译器。但我还是忍不住要放一个空的 引用,只是因为它很容易实现。这导致了 无数的错误、漏洞和系统崩溃, 可能在过去的四十年里造成了十亿美元的痛苦和损害 年。近年来,许多程序分析程序,如前缀和 Microsoft中的Precast用于检查引用,并给出 警告如果存在风险,它们可能不为空。最近 像spec这样的编程语言已经引入了 非空引用。这就是我在1965年拒绝的解决方案。

    也看到这个问题 at programmers

        10
  •  1
  •   Peter Mortensen icecrime    14 年前

    我一直把空(或零)看作是 缺少值 .

    有时你想要这个,有时你不想要。这取决于你正在工作的领域。如果缺席是有意义的:没有中间名,那么您的申请可以相应地采取行动。另一方面,如果空值不应该存在:名字是空的,那么开发人员会接到一个众所周知的2a.m.电话。

    我还看到过代码过载和对空值的检查过于复杂。对我来说,这意味着两件事之一:
    a)应用程序树中较高的错误
    b)设计不良/不完整

    从积极的方面来看,空值可能是检查是否缺少某些东西的更有用的概念之一,而没有空值概念的语言在进行数据验证时,最终会使事情复杂化。在这种情况下,如果一个新变量没有初始化,那么languages通常会将变量设置为空字符串、0或空集合。但是,如果空字符串或0或空集合 有效值 对于您的应用程序——那么您有一个问题。

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

        11
  •  0
  •   Joshua    14 年前

    矢量语言有时可以避免没有空值。

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