具有Maybe类型而不是null的语言如何处理边缘条件?


53

埃里克·利珀特(Eric Lippert)在讨论为什么C#使用a null而不是Maybe<T>类型时提出了一个非常有趣的观点:

类型系统的一致性很重要;我们是否总能知道在任何情况下都不能将非空引用视为无效?在引用类型为非空字段的对象的构造函数中该怎么办?在这样的对象的终结器中,该对象被终结是因为应该填充引用的代码引发了异常,该怎么办?对于您而言,保证其安全性的类型系统很危险。

这有点大开眼界。这些概念引起了我的兴趣,并且我在编译器和类型系统上做了一些尝试,但我从未考虑过这种情况。具有Maybe类型而不是null的语言如何处理诸如初始化和错误恢复之类的极端情况,在这种情况下,据称保证的非null引用实际上并不处于有效状态?


我想Maybe是否是语言的一部分,可能是因为它是通过null指针在内部实现的,并且只是语法糖。但是我不认为任何语言都能做到这一点。
panzi 2014年

1
@panzi:锡兰使用流敏感类型来区分Type?(也许)和Type(不为null)
Lukas Eder 2014年

1
@RobertHarvey Stack Exchange中是否没有“很好的问题”按钮?
user253751 2014年

2
@panzi这是一个不错且有效的优化方法,但对解决此问题无济于事:当某项不是a时Maybe T,它一定不能为true None,因此您不能将其存储初始化为null指针。

@immibis:我已经推送了。我们在这里很少有几个好问题要问。我认为这个人应该发表评论。
罗伯特·哈维

Answers:


45

该引号指出如果标识符的声明和分配(此处为实例成员)彼此分开,则会发生问题。作为一个简短的伪代码草图:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

现在的场景是,在构造实例期间,将引发错误,因此在完全构造实例之前将中止构造。该语言提供了一种析构函数方法,该方法将在释放内存之前运行,例如,以手动释放非内存资源。它也必须在部分构造的对象上运行,因为在中止构造之前可能已经分配了手动管理的资源。

如果使用null,则析构函数可以测试是否已将变量分配为if (foo != null) foo.cleanup()。如果没有空值,则该对象现在处于未定义状态–的值是bar多少?

但是,由于三个方面的组合,存在此问题:

  • 没有默认值,例如null或没有保证成员变量的初始化。
  • 声明和赋值之间的区别。强制立即进行变量分配(例如,使用let功能语言中的语句)很容易实现强制保证的初始化-但以其他方式限制了语言。
  • 析构函数的特定形式,它是语言运行时调用的一种方法。

选择不出现这些问题的另一种设计很容易,例如,通过始终将声明与赋值结合在一起,并让语言提供多个终结器块而不是单个终结方法,可以轻松地进行选择:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

因此,不存在null并不存在问题,但是结合使用一组其他功能却不存在null的问题。

现在有趣的问题是C#为什么选择一种设计而不选择另一种设计。在这里,引号的上下文列出了C#语言中null的许多其他参数,这些参数可以概括为“熟悉和兼容”,这是有充分理由的。


终结器必须处理nulls的另一个原因是:由于参考周期的可能性,不能保证终结的顺序。但是我想您的FINALIZE设计也可以解决此问题:如果foo已经完成,则其FINALIZE部分将无法运行。
2014年

14

您以相同的方式保证任何其他数据都处于有效状态。

可以构造语义并控制流程,这样就不能在没有完全为其创建值的情况下拥有某种类型的变量/字段。除了创建对象并让构造函数为其字段分配“初始”值外,您只能通过同时为其所有字段指定值来创建对象。除了声明变量然后分配初始值外,您只能引入带有初始化的变量。

例如,在Rust中,您通过创建一个struct类型的对象,Point { x: 1, y: 2 }而不是编写一个具象的构造函数self.x = 1; self.y = 2;。当然,这可能与您所想到的语言风格冲突。

另一种补充方法是使用活动性分析来防止在初始化存储之前对其进行访问。只要可以证明在第一次读取之前已将其分配给变量,就可以声明变量而无需立即对其进行初始化。它还可以捕获一些与故障相关的情况,例如

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

从技术上讲,您还可以为对象定义一个任意的默认初始化,例如,将所有数字字段归零,为数组字段创建空数组,等等。但这是任意的,效率不如其他选项,并且可以掩盖错误。


7

Haskell的工作方式如下:(由于Haskell不是面向对象的语言,因此与Lippert的陈述并不完全相反)。

警告:一个严肃的Haskell忠实拥护者长期以来的回答。

TL; DR

该示例完全说明了Haskell与C#有何不同。与其将结构构造的后勤工作委托给构造函数,而必须在周围的代码中进行处理。空值(或Nothing在Haskell中)无法出现在我们期望非空值的地方,因为空值只能出现在称为的特殊包装类型内Maybe,这些包装类型不能与/不能直接转换为常规非包装材料。可为空的类型。为了使用通过将其包装到中而变为可为空的值Maybe,我们必须首先使用模式匹配来提取该值,这迫使我们将控制流转移到一个分支中,在该分支中我们肯定知道我们具有非空值。

因此:

我们是否总能知道在任何情况下都不能将非空引用视为无效?

是。 IntMaybe Int是两种完全独立的类型。Nothing在平原中查找Int将类似于在字符串中查找字符串“ fish” Int32

在引用类型为非空字段的对象的构造函数中该怎么办?

没问题:Haskell中的值构造函数除了将给出的值放到一起再做其他任何事情。所有初始化逻辑都在调用构造函数之前发生。

在这样的对象的终结器中,该对象被终结是因为应该填充引用的代码引发了异常,该怎么办?

Haskell中没有终结器,因此我无法真正解决。但是,我的第一反应仍然有效。

完整答案

Haskell没有空值,并使用Maybe数据类型表示可空值。也许是像这样定义的算术数据类型

data Maybe a = Just a | Nothing

对于那些不熟悉Haskell的人,请将其读为“ A Maybe是a Nothing或a Just a”。特别:

  • Maybe类型构造函数:可以(错误地)认为它是泛型类(a类型变量在哪里)。C#比喻为class Maybe<a>{}
  • Just是一个值构造函数:它是一个函数,它接受一个type类型的参数a并返回一个Maybe a包含该值的type 值。因此,代码x = Just 17类似于int? x = 17;
  • Nothing是另一个值构造函数,但不带任何参数,Maybe返回的值除“ Nothing”外没有其他值。x = Nothing类似于int? x = null;(假设我们将aHaskell中的约束为Int,可以通过编写完成x = Nothing :: Maybe Int)。

既然该Maybe类型的基础知识已不复存在,Haskell如何避免在OP的问题中讨论的问题?

好吧,Haskell 与到目前为止讨论的大多数语言确实有很大不同,因此,我将首先解释一些基本的语言原理。

首先,在Haskell中,一切都是不可变的。一切。名称是指值,而不是指可以存储值的内存位置(仅此就是消除错误的巨大来源)。不同于C#,其中变量声明和分配是两个独立的操作,在Haskell值通过定义其值被创建(例如x = 15y = "quux"z = Nothing),它可以永远不会改变。因此,代码如下:

ReferenceType x;

在Haskell中是不可能的。初始化值没有问题,null因为必须将所有内容显式初始化为值才能存在。

其次,Haskell不是一种面向对象的语言:它是一种纯粹的功能性语言,因此从严格意义上讲没有对象。取而代之的是,有些简单的函数(值构造函数)会接受其参数并返回一个合并的结构。

接下来,绝对没有命令式样式代码。通过这个,我的意思是大多数语言都遵循这样的模式:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

程序行为表示为一系列指令。在面向对象的语言中,类和函数的声明在程序流中也起着巨大的作用,但实际上,程序执行的“实质”采取了一系列要执行的指令的形式。

在Haskell中,这是不可能的。相反,程序流完全由链接功能决定。甚至命令式的do注释也只是将匿名函数传递给>>=运算符的语法糖。所有功能均采用以下形式:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

body-expression任何可以求值的值都可以在哪里。显然有更多的语法功能可用,但要点是完全没有语句序列。

最后,而且可能最重要的是,Haskell的类型系统非常严格。 如果我不得不总结Haskell类型系统的中心设计理念,我会说:“在编译时要使尽可能多的事情出错,而在运行时要尽可能少地出错。” 没有任何隐式转换(想将an Int提升为Double?Use fromIntegral函数)。唯一可能在运行时发生无效值的方法是使用Prelude.undefined(显然该值必须存在并且不可能删除)。

考虑到所有这些,让我们看一下amon的“残破”示例,并尝试在Haskell中重新表达此代码。首先,数据声明(对命名字段使用记录语法):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

foo并且bar实际上是匿名字段而不是实际字段的访问器函数,但是我们可以忽略此详细信息)。

NotSoBroken值构造是不能采取任何操作,而只是采取FooBar(这是不可为空),并制作NotSoBroken了出来。没有放置命令性代码或什至手动分配字段的地方。所有初始化逻辑都必须在其他位置进行,最有可能在专用工厂功能中进行。

在该示例中,Broken始终构造失败。没有办法以NotSoBroken类似的方式破坏值构造函数(根本没有地方可以编写代码),但是我们可以创建同样具有缺陷的工厂函数。

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(第一行是类型签名声明:makeNotSoBroken将a Foo和a Bar作为参数并产生a Maybe NotSoBroken)。

返回类型必须为Maybe NotSoBroken,而不仅仅是NotSoBroken因为我们告诉它要计算为Nothing,这是的值构造函数Maybe。如果我们写任何不同的东西,这些类型就根本不会排列在一起。

除了绝对没有意义之外,此功能甚至无法实现其实际目的,我们在尝试使用它时会看到。让我们创建一个函数useNotSoBroken,该函数期望a NotSoBroken作为参数:

useNotSoBroken :: NotSoBroken -> Whatever

useNotSoBroken接受a NotSoBroken作为参数并产生a Whatever)。

并像这样使用它:

useNotSoBroken (makeNotSoBroken)

在大多数语言中,这种行为可能会导致空指针异常。在Haskell中,类型不匹配:makeNotSoBroken返回a Maybe NotSoBroken,但是useNotSoBroken期望a NotSoBroken。这些类型不可互换,并且代码无法编译。

为了解决这个问题,我们可以使用一条case语句根据Maybe值的结构进行分支(使用称为模式匹配的功能):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

显然,此代码段需要放置在某些上下文中才能进行实际编译,但是它演示了Haskell如何处理可为空值的基础知识。这是上述代码的分步说明:

  • 首先,makeNotSoBroken对进行评估,以确保产生type值Maybe NotSoBroken
  • case语句检查此值的结构。
  • 如果值为Nothing,则评估“此处的处理情况”代码。
  • 如果该值与某个Just值匹配,则执行另一个分支。请注意,matching子句如何同时将值标识为Just构造并将其内部NotSoBroken字段绑定到名称(在本例中为x)。x然后可以像正常值那样使用NotSoBroken

因此,模式匹配为实施类型安全性提供了强大的工具,因为对象的结构与控制的分支密不可分。

我希望这是一个容易理解的解释。如果这没有任何意义,请跳入“ 学习Haskell,以求大成!”!,这是我读过的最好的在线语言教程之一。希望您会看到与我一样的这种语言。


TL; DR应该放在顶部:)
andrew.fox

@ andrew.fox好点。我会编辑。
接近

0

我认为您的报价是一个草率的论点。

当今的现代语言(包括C#)向您保证构造函数要么完全完成,要么没有完成。

如果构造函数中存在异常,并且对象部分未初始化,则具有nullMaybe::none处于未初始化状态对析构函数代码没有任何实际影响。

您将只需要以任何一种方式来处理它。当有外部资源要管理时,您必须以任何方式显式管理那些资源。语言和库可以提供帮助,但是您必须对此进行一些思考。

顺便说一句:在C#中,null值几乎等于Maybe::none。您只能分配null在类型级别上声明为可空的变量和对象成员:

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

这与以下代码段没有任何不同:

Maybe<String> optionalString = getOptionalString();

因此,总而言之,我看不出可空性与Maybe类型相反。我什至建议C#潜入它自己的Maybe类型并将其称为Nullable<T>

使用扩展方法,可以很容易地对Nullable进行清理以遵循monadic模式:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
这是什么意思,“构造函数要么完全完成,要么没有完成”?例如,在Java中,构造函数中(非最终)字段的初始化不受数据竞争的保护-是否符合完全完成条件?
蚊蚋

@gnat:您的意思是“例如,在Java中,构造函数中(非最终)字段的初始化不受数据竞争的影响”。除非您执行涉及多个线程的极其复杂的操作,否则构造函数内部竞争条件的机会几乎(或应该)几乎是不可能的。除了从对象构造函数内部之外,不能访问未构造对象的字段。而且,如果构造失败,则您将没有对该对象的引用。
罗兰·德普

null作为每种类型的隐式成员之间的最大区别Maybe<T>是与Maybe<T>,您还可以拥有just T,其中没有任何默认值。
2014年

在创建数组时,通常不必读取所有元素就无法确定所有元素的有用值,也无法静态地验证没有读取任何元素而没有计算出有用值。最好的办法是初始化数组元素,使它们可以被识别为不可用。
2014年

@svick:在C#(OP所讨论的语言)中,null它不是每种类型的隐式成员。对于null要lebal价值,你需要定义为可以为空明确的类型,这使得T?(语法糖Nullable<T>)本质上等价于Maybe<T>
罗兰·德普

-3

C ++通过访问在构造函数主体之前发生的初始化程序来实现此目的。C#在构造函数主体之前运行默认的初始化程序,它为所有对象大致分配0,floats变为0.0,bools变为false,引用变为null,等等。在C ++中,您可以使其运行其他初始化程序以确保非null的引用类型永远不会为null。 。

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
问题是关于可能具有类型的语言
t 2014年

3
引用变为null ” –问题的全部前提是我们没有null,而指示缺少值的唯一方法是使用AFAIK C ++中没有的Maybe类型(也称为Option)。标准库。缺少空值使我们能够保证字段作为类型系统的属性始终有效。与手动确保没有代码路径存在变量的地方相比,这是一个更有力的保证null
阿蒙2014年

尽管c ++本身没有显式的Maybe类型,但类似std :: shared_ptr <T>这样的东西足够接近,我认为c ++处理变量初始化可能在构造函数的“范围外”发生的情况仍然有意义。实际上,引用类型(&)是必需的,因为它们不能为null。
FryGuy 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.