Python:为什么 "NONE "不是 "NOTHING"?

194 阅读8分钟

Python:为什么 "无 "不是 "无"?

以及如何在没有它的情况下生活

这次我们来谈谈Python中(不)著名的None,它的各种形状和使用模式。让我们试着找到如何在没有它的情况下生存的方法。

在我的职业生涯中,我在函数式编程的世界里呆了好几年。你知道,这种严格的类型化的东西。后来,当我回到Python时,我注意到他们在此期间引入了类型注解,这有多酷啊?在某个时间点上,我开始深入研究,以了解我是否也能在Python中应用一些FP的概念。

从字面上看,我从 "无 "开始,发现 "无"。这就是这个故事的全部内容;-)

所以,这是我最喜欢的Python函数。

def magic_function():    # mysterious things happening here    print("Pure magic!")

乍一看,它像是一个没有输入的函数,没有返回,因为它没有一个返回语句。现在我们来试试一些奇怪的东西。

>>> magic = magic_function()Pure magic!

不出所料,这个函数会打印到stdout,但有趣的是,我们把magic_function()的结果分配给了变量magic。这怎么可能呢?我们怎么能把 "什么都没有"(这就是这个函数应该返回的结果)分配给一个变量呢?为什么我们没有得到一个错误?

>>> magic.__class__<class 'NoneType'>

自然地,了解情况的 Python 专家知道,每一个没有明确返回语句的(!)函数都隐含地返回None(除非它抛出一个异常) - 这就是我们赋值给'magic'的东西。所以,magic变成了一个'class NoneType'类型的变量--magic与None是相同的。

如果一个东西有一个身份,即使它被称为,它也不可能是无,对吗?反过来说,如果一个函数真的能够返回字面意义上的无(事实上这已经是一个矛盾的说法),那么它怎么可能被分配给一个变量?一个人怎么能把 "无 "赋给一个东西呢?

好吧,None是一个 "真正 "类型的 "真正 "实例。这似乎是 "None不是Nothing "的第一个证据,对吗?

多亏了类型注释,我们可以使我们的函数签名更加吸引人。

def magic_function() -> None:    ....

一个使用magic_function()的人现在立刻知道她不能指望这个函数能返回任何有意义的东西。闻起来就像古老的C语言中的 "void "这个东西。

void magic_function(void) {    ....}

像这样的结构不应该被称为 "函数",这就是为什么它们被命名为 "过程"。

不幸的是,这并不是全部的事实,因为在 Python 中None似乎是每个类型的有效值。我们可以通过重写我们的魔术函数,使其只在每月的第一天返回一些魔术,来轻松证明这一点。

from datetime import datetimefrom typing import Optional

这里我们定义该函数可以选择返回字符串。但它也允许返回None。作为一个简单的题外话,在引入typing.Optional之前,你必须要写。

from typing import Union

从3.10版本开始,你甚至可以写得更简明。

def maybe_magic_function() -> str | None:    ....

所有这三种变体都是等价的,每一种变体都会让静态类型检查器如 mypy等静态类型检查器感到非常高兴。就个人而言,我反而倾向于对这种特殊性有意见。让我解释一下原因。

None作为返回值通常用来表示不同的东西。

  1. 函数返回None或一个具体的值---由**Optional[Any]**表示
  2. 函数返回None表示失败--用**Optional[Any]**表示
  3. 函数返回None表示没有返回值--用None表示

在任何这些情况下,返回None只不过是一种惯例。我们不可能从None中得出任何明确的意图。正因为如此,你经常会发现无数的**"如果某个东西是None "的**级联(又称 "末日金字塔")。想想看,在你的职业生涯中,有多少行代码是为了检查这些None-conventions而写的(也请看十亿美元的错误)。

而且,None还有一个微妙之处。我们已经发现,None是一个有身份甚至有类型的对象(NoneType类)。现在,在注释变量和函数的时候,我们使用的是类型,对吗?

比如。

def magic(i: int) -> Union[str, None]:    ...

str "是一个类型,"int "是一个类型,但是None是一个 "NoneType类 "的对象。我们是不是把类型和对象混在一起了?

有趣的是,我们不允许写。

>>> def maybe_magic_function(i: int) -> Union[str, NoneType]:    ...Traceback (most recent call last):  File "<stdin>", line 1, in <module>NameError: name 'NoneType' is not defined

我们不能使用 NoneType,因为 Python 说:"名称'NoneType'没有定义"。如果NoneType是未定义的,但是调用 type(None) 的结果是 <class 'NoneType'>,那么它告诉我们关于None 的什么呢?这是否意味着None是一个未定义类型的对象?如果一个东西的类型是未定义的,它怎么能成为一个对象呢?永无止境的问题...

你看,围绕 ""有很多神话......请随意深入挖掘 :-)

不管怎么说,为了不搞那些特殊的东西,我怎样才能使返回值明确地表示不存在(即Nothing)或失败(不同种类)这样的概念?我们怎样才能从我们的词汇表中禁止None,而采用更明确的东西?

类型来拯救

正如我们所知,Python的类型系统是动态的,并通过鸭子类型的概念来实现(一个对象拥有的方法比具体类型更重要)。但Python中的每个对象都有一个类型,这是由解释器自动推断出来的。随着类型注解和静态类型检查器的引入,添加类型信息给我们带来了处理静态类型语言的(虚拟)印象,至少在IDE中是这样。

那么,我们怎样才能利用所有这些来发挥我们的优势呢?

表示不存在

我们可以使用一个叫做Maybe的专用类型来表示不存在,它有两个子类型。Just封装了一个具体的值,而Nothing表示不存在。

class Maybe(abc.ABC):    @abc.abstractmethod    def get_or_else(self, fallback):        ...

我把这个类型称为 "也许"并不是一个巧合。诚然,这可能是众所周知的 Maybe monad 的最天真和不完整的实现 :-)。现在不要在意 "单体 "这个词,我们在这里要学习的是代表不存在的概念 :-)

通过用Maybe注释我们的 magic_function() 作为结果类型,就没有了不确定性的空间。结果类型要么是一个Just,要么是一个Nothing,句号

使用结构模式匹配 (Python ≥ 3.10),你可以很容易地从一个Just中解开封装的值,并相应地处理Nothing 的情况。

match magic_function():    case Just(value=magic):       print(f"{magic=}")    case Nothing():       print("no magic")

或者,使用 Python < 3.10 中的get_or_else() 方法。

result = magic_function()print(result.get_or_else("no magic")

关于 Maybe monad 还有很多东西要讲,但这已经远远超出了本文的范围。对于好奇的人,让我参考returns 包,它有一个成熟的、完全类型注释的实现。

表示 "未定义"

下一个有趣的情况是数学意义上的函数,这些函数在其输入集方面只有部分定义。我们都知道这样的函数,比如除以0,负数的平方根,等等。

想想一个计算特定日期的日名的函数。

from datetime import datetime

在编程语言中,我们通常会在异常中结束,因为不可能以一种有意义的方式表示 "未定义",至少在 Python 中是没有的。

我们在这里也可以使用 Maybe。要么一个计算返回一个具体的值,否则我们可以认为它是未定义的。作为一个有益的副作用,我们摆脱了那些讨厌的运行时异常。

def dayname(year, month, day) -> Maybe:    try:        return Just(datetime(year, month, day).strftime("%A"))    except ValueError:        return Nothing()

我们甚至可以给自己写一个方便的装饰器,这样我们就不必用错误处理代码来污染我们的函数了。

def maybe_undefined(func) -> Callable[..., Maybe]:    @functools.wraps(func)    def wrapper(*args, **kwargs) -> Maybe:        try:            return Just(func(*args, **kwargs))        except Exception:            return Nothing()    return wrapper

有了它,我们就可以很好地装饰函数,当我们事先已经知道它们对于某些输入值将是未定义的。

@maybe_undefineddef dayname(year, month, day):    return datetime(year, month, day).strftime("%A")

表示失败

我们再次使用一个小类的层次结构来表示成功和失败,我们称它为结果

class Result(abc.ABC):    @abc.abstractmethod    def get(self):        ...    @abc.abstractmethod    def get_or_else(self, fallback):        ...

Result现在封装了一个失败情况下的异常。这感觉如何呢?如果计算成功,你会得到一个Success对象,否则就是Failure。这一次,Failure对象也包含补充信息,它本身就是一个异常对象。

这意味着解压一个Failure仍然会导致一个异常。

>>> dayname(2021, 10, 10).get()'Sunday'>>> dayname(2021, 10, 32).get()Traceback (most recent call last):  ...ValueError: day is out of range for month

我们可以通过使用get_or_else()来避免这个问题...

>>> dayname(2021, 10, 32).get_or_else('Invalid date entered')'Invalid date entered'>>> dayname(2021, 10, 10).get_or_else('Invalid date entered')'Sunday'

或者再次进行模式匹配...

match dayname(2021, 10, 32):    case Success(value=day):       print(f"{day=}")    case Failure(error=failure):       print(f"{failure=}")

还是那句话,看看return包,它对Result有更好的实现。

BTW:还有一种类型叫做Either,它有两个子类型,通常叫做LeftRight。它与Result非常相似,不同的是Right代表计算的快乐路径(如Success),而Left则代表不快乐的路径--这不一定是一个特殊的情况。

现在怎么办?也许,结果,要么?

让我总结一下。

Maybe用来表示一个函数确实返回了一个具体的值或者没有。打个比方,想想数学函数,它对某些输入值是未定义的。(BTW: 在Scala中,Maybe被称为Option,有Some(value) ore None(!) 作为具体的子类型)

Result通常被用来 "物化 "异常,并以一种定义的方式处理它们。异常是有史以来最讨厌的、破坏性的副作用。如果处理不当,它们会给你带来巨大的混乱。所以,去避免它们吧!

Either是用来表示可以有两种可能的输出的操作,其中一种比另一种更 "快乐 "一些。你也可以认为Either是对Result的一种概括。

结论

最后一个问题。在所有介绍的例子中,你是否发现有一个None的出现?你没有!所以,是的,在 Python 中没有None也能活。

使用具体的类型来表示具体的计算结果,我们能够摆脱,我们甚至能够使异常明确化,这样程序的自然控制流就保持不变。