Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 505 – None-aware 运算符

作者:
Mark E. Haase <mehaase at gmail.com>, Steve Dower <steve.dower at python.org>
状态:
推迟
类型:
标准跟踪
创建日期:
2015年9月18日
Python 版本:
3.8

目录

摘要

一些现代编程语言拥有所谓的“null 合并”或“null 感知”运算符,包括 C# [1]、Dart [2]、Perl、Swift 和 PHP(从版本 7 开始)。此外,ECMAScript(又名 JavaScript)也有增加这些运算符的第 3 阶段草案提案 [3] [4]。这些运算符为涉及空引用的常见模式提供了语法糖。

  • null 合并”运算符是一个二元运算符,如果其左操作数不为 null,则返回左操作数。否则返回右操作数。
  • null 感知成员访问”运算符仅在实例非 null 时访问实例成员。否则返回 null。(这也被称为“安全导航”运算符。)
  • null 感知索引访问”运算符仅在集合非 null 时访问集合中的元素。否则返回 null。(这是另一种类型的“安全导航”运算符。)

本 PEP 提出了三种 Python 的 None 感知运算符,基于上述定义和其他语言的实现。具体而言:

  • None 合并”二元运算符 ?? 在左侧求值结果不为 None 时返回左侧,否则求值并返回右侧。包含一个合并 ??= 增强赋值运算符。
  • None 感知属性访问”运算符 ?. (“maybe dot”) 在左侧求值结果不为 None 时求值整个表达式。
  • None 感知索引”运算符 ?[] (“maybe subscript”) 在左侧求值结果不为 None 时求值整个表达式。

有关所需语法更改的具体细节和示例,请参阅 语法变更 部分。

有关可更新以使用新运算符的代码的更实际示例,请参阅 示例 部分。

语法和语义

None 的特殊性

None 对象表示缺少值。对于这些运算符而言,缺少值表示表达式的其余部分也缺少值,不应被求值。

一个被拒绝的提案是,将任何在布尔上下文中求值为“假”的值视为没有值。然而,这些运算符的目的是传播“缺少值”状态,而不是“假”状态。

有人争论说这使得 None 变得特殊。我们认为 None 已经很特殊,并且将其用作这些运算符的测试和结果不会以任何方式改变现有语义。

有关替代方法的讨论,请参阅 被拒绝的想法 部分。

语法变更

Python 语法的以下规则已更新为:

augassign: ('+=' | '-=' | '*=' | '@=' | '/=' | '%=' | '&=' | '|=' | '^=' |
            '<<=' | '>>=' | '**=' | '//=' | '??=')

power: coalesce ['**' factor]
coalesce: atom_expr ['??' factor]
atom_expr: ['await'] atom trailer*
trailer: ('(' [arglist] ')' |
          '[' subscriptlist ']' |
          '?[' subscriptlist ']' |
          '.' NAME |
          '?.' NAME)

合并规则

coalesce 规则提供了 ?? 二元运算符。与大多数二元运算符不同,右侧不会被求值,直到左侧被确定为 None

?? 运算符比其他二元运算符绑定更紧密,因为大多数现有实现都不会传播 None 值(它们通常会引发 TypeError)。已知可能导致 None 的表达式可以替换为默认值,而无需额外的括号。

在存在 ?? 运算符时,求值运算符优先级时如何放置隐式括号的一些示例:

a, b = None, None
def c(): return None
def ex(): raise Exception()

(a ?? 2 ** b ?? 3) == a ?? (2 ** (b ?? 3))
(a * b ?? c // d) == a * (b ?? c) // d
(a ?? True and b ?? False) == (a ?? True) and (b ?? False)
(c() ?? c() ?? True) == True
(True ?? ex()) == True
(c ?? ex)() == c()

特别是对于像 a ?? 2 ** b ?? 3 这样的情况,以任何其他方式对子表达式加括号都会导致 TypeError,因为 int.__pow__ 不能用 None 调用(而且使用 ?? 运算符本身就意味着 ab 可能为 None)。然而,通常情况下,虽然不需要括号,但如果它有助于提高可读性,则应添加。

还为 ?? 运算符添加了增强赋值。增强合并赋值仅在其当前值为 None 时重新绑定名称。如果目标名称已经有值,则不求值右侧。例如:

a = None
b = ''
c = 0

a ??= 'value'
b ??= undefined_name
c ??= shutil.rmtree('/')    # don't try this at home, kids

assert a == 'value'
assert b == ''
assert c == 0 and any(os.scandir('/'))

maybe-dot 和 maybe-subscript 运算符

maybe-dot 和 maybe-subscript 运算符作为原子(atom)的后缀添加,因此它们可以像常规运算符一样在所有相同的位置使用,包括作为赋值目标的一部分(更多细节如下)。由于现有的求值规则并未直接嵌入到语法中,我们将在下面指定所需的更改。

假设 atom 总是成功求值。然后,每个 trailer 从左到右求值,应用其自己的参数(无论是其参数、下标还是属性名)以生成下一个 trailer 的值。最后,如果存在,则应用 await

例如,await a.b(c).d[e] 目前被解析为 ['await', 'a', '.b', '(c)', '.d', '[e]'] 并求值:

_v = a
_v = _v.b
_v = _v(c)
_v = _v.d
_v = _v[e]
await _v

当存在一个 None 感知运算符时,从左到右的求值可能会短路。例如,await a?.b(c).d?[e] 的求值如下:

_v = a
if _v is not None:
    _v = _v.b
    _v = _v(c)
    _v = _v.d
    if _v is not None:
        _v = _v[e]
await _v

注意

await 在此上下文中几乎肯定会失败,就像代码尝试 await None 的情况一样。我们不打算在这里添加 None 感知 await 关键字,而只是将其包含在此示例中以完善规范,因为 atom_expr 语法规则包含该关键字。如果它有自己的规则,我们就不会提到它。

带括号的表达式由 atom 规则(上文未显示)处理,该规则将隐式终止上述转换的短路行为。例如,(a?.b ?? c).d?.e 的求值如下:

# a?.b
_v = a
if _v is not None:
    _v = _v.b

# ... ?? c
if _v is None:
    _v = c

# (...).d?.e
_v = _v.d
if _v is not None:
    _v = _v.e

当用作赋值目标时,None 感知操作只能在“加载”上下文中使用。也就是说,a?.b = 1a?[b] = 1 将引发 SyntaxError。在表达式中更早使用(a?.b.c = 1)是允许的,尽管除非与合并操作结合使用,否则不太有用:

(a?.b ?? d).c = 1

读取表达式

对于 maybe-dot 和 maybe-subscript 运算符,其意图是包含这些运算符的表达式应像这些运算符的常规版本一样被读取和解释。在“正常”情况下,像 a?.b?[c]a.b[c] 这样的表达式的最终结果将是相同的,正如我们目前不会将“a.b”读作“从 a 读取属性 b *如果它有属性 a,否则引发 AttributeError*”,也没有必要将“a?.b”读作“从 a 读取属性 b *如果 a 不为 None*”(除非在侦听者需要了解特定行为的上下文中)。

对于使用 ?? 运算符的合并表达式,表达式应读作“或…如果为 None”或“与…合并”。例如,表达式 a.get_value() ?? 100 将读作“调用 a dot get_value 或 100 如果为 None”,或“调用 a dot get_value 与 100 合并”。

注意

用口头文本阅读代码总是会有信息损失,因此我们不试图定义一种明确的说话方式来表达这些运算符。这些建议旨在为添加新语法的含义提供上下文。

示例

本节介绍了一些常见 None 模式的示例,并展示了如何转换为使用 None 感知运算符可能的样子。

标准库

使用 find-pep505.py 脚本 [5] 对 Python 3.7 标准库进行分析,发现了多达 678 个代码片段可以用 None 感知运算符之一替换:

$ find /usr/lib/python3.7 -name '*.py' | xargs python3.7 find-pep505.py
<snip>
Total None-coalescing `if` blocks: 449
Total [possible] None-coalescing `or`: 120
Total None-coalescing ternaries: 27
Total Safe navigation `and`: 13
Total Safe navigation `if` blocks: 61
Total Safe navigation ternaries: 8

其中一些如下所示,作为在使用新运算符之前和之后的示例。

来自 bisect.py

def insort_right(a, x, lo=0, hi=None):
    # ...
    if hi is None:
        hi = len(a)
    # ...

更新为使用 ??= 增强赋值语句后:

def insort_right(a, x, lo=0, hi=None):
    # ...
    hi ??= len(a)
    # ...

来自 calendar.py

encoding = options.encoding
if encoding is None:
    encoding = sys.getdefaultencoding()
optdict = dict(encoding=encoding, css=options.css)

更新为使用 ?? 运算符后:

optdict = dict(encoding=options.encoding ?? sys.getdefaultencoding(),
               css=options.css)

来自 email/generator.py(值得注意的是,在这种情况下,无法用 ?? 替换 or):

mangle_from_ = True if policy is None else policy.mangle_from_

更新后:

mangle_from_ = policy?.mangle_from_ ?? True

来自 asyncio/subprocess.py

def pipe_data_received(self, fd, data):
    if fd == 1:
        reader = self.stdout
    elif fd == 2:
        reader = self.stderr
    else:
        reader = None
    if reader is not None:
        reader.feed_data(data)

更新为使用 ?. 运算符后:

def pipe_data_received(self, fd, data):
    if fd == 1:
        reader = self.stdout
    elif fd == 2:
        reader = self.stderr
    else:
        reader = None
    reader?.feed_data(data)

来自 asyncio/tasks.py

try:
    await waiter
finally:
    if timeout_handle is not None:
        timeout_handle.cancel()

更新为使用 ?. 运算符后:

try:
    await waiter
finally:
    timeout_handle?.cancel()

来自 ctypes/_aix.py

if libpaths is None:
    libpaths = []
else:
    libpaths = libpaths.split(":")

更新后:

libpaths = libpaths?.split(":") ?? []

来自 os.py

if entry.is_dir():
    dirs.append(name)
    if entries is not None:
        entries.append(entry)
else:
    nondirs.append(name)

更新为使用 ?. 运算符后:

if entry.is_dir():
    dirs.append(name)
    entries?.append(entry)
else:
    nondirs.append(name)

来自 importlib/abc.py

def find_module(self, fullname, path):
    if not hasattr(self, 'find_spec'):
        return None
    found = self.find_spec(fullname, path)
    return found.loader if found is not None else None

部分更新后:

def find_module(self, fullname, path):
    if not hasattr(self, 'find_spec'):
        return None
    return self.find_spec(fullname, path)?.loader

大量更新后(可以说过度,但由风格指南决定):

def find_module(self, fullname, path):
    return getattr(self, 'find_spec', None)?.__call__(fullname, path)?.loader

来自 dis.py

def _get_const_info(const_index, const_list):
    argval = const_index
    if const_list is not None:
        argval = const_list[const_index]
    return argval, repr(argval)

更新为使用 ?[]?? 运算符后:

def _get_const_info(const_index, const_list):
    argval = const_list?[const_index] ?? const_index
    return argval, repr(argval)

jsonify

此示例来自一个使用 Flask 框架作为其前端的 Python 网络爬虫。此函数从 SQL 数据库检索网站信息,并将其格式化为 JSON 以发送给 HTTP 客户端:

class SiteView(FlaskView):
    @route('/site/<id_>', methods=['GET'])
    def get_site(self, id_):
        site = db.query('site_table').find(id_)

        return jsonify(
            first_seen=site.first_seen.isoformat() if site.first_seen is not None else None,
            id=site.id,
            is_active=site.is_active,
            last_seen=site.last_seen.isoformat() if site.last_seen is not None else None,
            url=site.url.rstrip('/')
        )

first_seenlast_seen 都允许在数据库中为 null,也允许在 JSON 响应中为 null。JSON 没有表示 datetime 的原生方式,因此服务器的契约规定任何非 null 日期都表示为 ISO-8601 字符串。

在不了解 first_seenlast_seen 属性确切语义的情况下,无法知道是否可以安全或高效地多次访问该属性。

修复此代码的一种方法是用显式值赋值和完整的 if/else 块替换每个条件表达式:

class SiteView(FlaskView):
    @route('/site/<id_>', methods=['GET'])
    def get_site(self, id_):
        site = db.query('site_table').find(id_)

        first_seen_dt = site.first_seen
        if first_seen_dt is None:
            first_seen = None
        else:
            first_seen = first_seen_dt.isoformat()

        last_seen_dt = site.last_seen
        if last_seen_dt is None:
            last_seen = None
        else:
            last_seen = last_seen_dt.isoformat()

        return jsonify(
            first_seen=first_seen,
            id=site.id,
            is_active=site.is_active,
            last_seen=last_seen,
            url=site.url.rstrip('/')
        )

这增加了十行代码和四个新的代码路径到函数中,大大增加了明显的复杂性。使用 None 感知属性运算符重写后,代码更短,意图更清晰:

class SiteView(FlaskView):
    @route('/site/<id_>', methods=['GET'])
    def get_site(self, id_):
        site = db.query('site_table').find(id_)

        return jsonify(
            first_seen=site.first_seen?.isoformat(),
            id=site.id,
            is_active=site.is_active,
            last_seen=site.last_seen?.isoformat(),
            url=site.url.rstrip('/')
        )

Grab

下一个示例来自一个名为 Grab 的 Python 抓取库:

class BaseUploadObject(object):
    def find_content_type(self, filename):
        ctype, encoding = mimetypes.guess_type(filename)
        if ctype is None:
            return 'application/octet-stream'
        else:
            return ctype

class UploadContent(BaseUploadObject):
    def __init__(self, content, filename=None, content_type=None):
        self.content = content
        if filename is None:
            self.filename = self.get_random_filename()
        else:
            self.filename = filename
        if content_type is None:
            self.content_type = self.find_content_type(self.filename)
        else:
            self.content_type = content_type

class UploadFile(BaseUploadObject):
    def __init__(self, path, filename=None, content_type=None):
        self.path = path
        if filename is None:
            self.filename = os.path.split(path)[1]
        else:
            self.filename = filename
        if content_type is None:
            self.content_type = self.find_content_type(self.filename)
        else:
            self.content_type = content_type

此示例包含了几个需要提供默认值的很好的例子。重写为使用条件表达式减少了代码总行数,但不一定提高了可读性:

class BaseUploadObject(object):
    def find_content_type(self, filename):
        ctype, encoding = mimetypes.guess_type(filename)
        return 'application/octet-stream' if ctype is None else ctype

class UploadContent(BaseUploadObject):
    def __init__(self, content, filename=None, content_type=None):
        self.content = content
        self.filename = (self.get_random_filename() if filename
            is None else filename)
        self.content_type = (self.find_content_type(self.filename)
            if content_type is None else content_type)

class UploadFile(BaseUploadObject):
    def __init__(self, path, filename=None, content_type=None):
        self.path = path
        self.filename = (os.path.split(path)[1] if filename is
            None else filename)
        self.content_type = (self.find_content_type(self.filename)
            if content_type is None else content_type)

第一个三元表达式很整洁,但它颠倒了操作数的直观顺序:它应该在 ctype 有值时返回它,并使用字符串字面量作为备用。其他三元表达式不直观且过长,必须换行。整体可读性变差,而不是改善。

使用 None 合并运算符重写后:

class BaseUploadObject(object):
    def find_content_type(self, filename):
        ctype, encoding = mimetypes.guess_type(filename)
        return ctype ?? 'application/octet-stream'

class UploadContent(BaseUploadObject):
    def __init__(self, content, filename=None, content_type=None):
        self.content = content
        self.filename = filename ?? self.get_random_filename()
        self.content_type = content_type ?? self.find_content_type(self.filename)

class UploadFile(BaseUploadObject):
    def __init__(self, path, filename=None, content_type=None):
        self.path = path
        self.filename = filename ?? os.path.split(path)[1]
        self.content_type = content_type ?? self.find_content_type(self.filename)

此语法具有直观的操作数顺序。例如,在 find_content_type 中,首选值 ctype 出现在备用值之前。语法的简洁性还使得代码行数更少,视觉上需要解析的代码更少,并且从左到右、从上到下阅读更准确地遵循执行流程。

被拒绝的想法

本节中的前三个想法是经常提出的替代方案,以避免将 None 视为特殊。有关这些想法为何被拒绝的更多背景信息,请参阅 PEP 531PEP 532 中对其的处理以及相关的讨论。

无值协议

运算符可以通过定义一个协议来指示何时值表示“无值”,从而推广到用户定义类型。这样的协议可能是一个 dunder 方法 __has_value__(self),如果该值应被视为有值,则返回 True,如果该值应被视为无值,则返回 False

通过这种泛化,object 将实现一个等价于此的 dunder 方法:

def __has_value__(self):
    return True

NoneType 将实现一个等价于此的 dunder 方法:

def __has_value__(self):
    return False

在规范部分,所有 x is None 的使用都将被 not x.__has_value__() 替换。

这种泛化将允许特定领域的“无值”对象像 None 一样合并。例如,pyasn1 包有一种名为 Null 的类型,表示 ASN.1 null

>>> from pyasn1.type import univ
>>> univ.Null() ?? univ.Integer(123)
Integer(123)

类似地,像 math.nanNotImplemented 这样的值也可以被视为表示无值。

然而,这些值的“无值”性质是领域特定的,这意味着它们 应该 被语言视为一个值。例如,math.nan.imag 是明确定义的(它是 0.0),因此将 math.nan?.imag 短路返回 math.nan 是不正确的。

由于 None 已经被语言定义为表示“无值”的值,并且当前的规范不会排除将来切换到协议(尽管对内置对象的更改将不兼容),因此此想法目前被拒绝。

布尔感知运算符

这个建议与添加无值协议本质上相同,因此上述讨论也适用。

?? 运算符类似的行为可以通过 or 表达式实现,但是 or 检查其左操作数是否为假值,而不是专门检查 None。这种方法很有吸引力,因为它对语言的改动较少,但最终无法正确解决根本问题。

假设检查的是真值而不是 None,那么就不再需要 ?? 运算符了。然而,将此检查应用于 ?.?[] 运算符会阻止完全有效的操作被应用。

考虑以下示例,其中 get_log_list() 可能会返回一个包含当前日志消息的列表(可能为空),或者在未启用日志记录时返回 None

lst = get_log_list()
lst?.append('A log message')

如果 ?. 检查的是真值而不是特定的 None,并且日志没有用任何项初始化,那么将永远不会添加任何项。这违反了代码的明显意图,即添加一个项。append 方法在空列表上可用,所有其他列表方法也一样,没有理由假设这些成员不应使用,因为列表目前为空。

此外,没有合理的替代结果可以替换表达式。正常的 lst.append 返回 None,但根据这个想法,lst?.append 可能会根据 lst 的值返回 []None。与上一节中的示例一样,这没有任何意义。

由于检查真值而不是 None 导致看起来有效的表达式不再按预期执行,因此此想法被拒绝。

异常感知运算符

可以说,当遇到 None 时短路表达式的原因是为了避免在正常情况下引发 AttributeErrorTypeError。作为测试 None 的替代方法,?.?[] 运算符可以处理操作引发的 AttributeErrorTypeError,并跳过表达式的其余部分。

这为 a?.b.c?.d.e 产生了类似于此的转换:

_v = a
try:
    _v = _v.b
except AttributeError:
    pass
else:
    _v = _v.c
    try:
        _v = _v.d
    except AttributeError:
        pass
    else:
        _v = _v.e

一个悬而未决的问题是,当处理异常时,表达式应该返回什么值。上面的例子只是保留了部分结果,但这对于用默认值替换没有帮助。另一种选择是强制结果为 None,这就引出了一个问题:为什么 None 特殊到足以作为结果,却又不够特殊到可以作为测试。

其次,这种方法会掩盖作为表达式一部分隐式执行的代码中的错误。对于 ?.,属性或 __getattr__ 实现中的任何 AttributeError 都将被隐藏,?[]__getitem__ 实现也类似。

同样,简单的打字错误,例如 {}?.ietms(),可能会被忽略。

现有处理这类错误的约定,即 getattr 内置函数和由 dict 建立的 .get(key, default) 方法模式,表明已经可以显式使用这种行为。

由于这种方法会隐藏代码中的错误,因此被拒绝。

None 感知函数调用

None 感知语法适用于属性和索引访问,因此很自然会问它是否也应该适用于函数调用语法。它可能写成 foo?(),其中 foo 仅在它不为 None 时才被调用。

这已被推迟,理由是所提议的运算符旨在帮助遍历部分填充的层次数据结构,而不是遍历任意类层次结构。这体现在以下事实:其他主流语言中,已提供此语法的,都没有发现支持可选函数调用的类似语法有价值。

一种类似于 C# 所用的变通方法是编写 maybe_none?.__call__(arguments)。如果可调用对象为 None,则不求值表达式。(C# 等效项在其可调用类型上使用 ?.Invoke()。)

? 一元后缀运算符

为了泛化 None 感知行为并限制引入的新运算符数量,建议使用一个拼写为 ? 的一元后缀运算符。其思想是 ? 可能会返回一个特殊对象,该对象会覆盖返回 self 的 dunder 方法。例如,如果 foo 不为 None,则 foo? 将求值为 foo,否则将求值为 NoneQuestion 的实例:

class NoneQuestion():
    def __call__(self, *args, **kwargs):
        return self

    def __getattr__(self, name):
        return self

    def __getitem__(self, key):
        return self

有了这个新运算符和新类型,像 foo?.bar[baz] 这样的表达式,如果 foo 是 None,则求值为 NoneQuestion。这是一个巧妙的泛化,但在实践中很难使用,因为大多数现有代码不会知道 NoneQuestion 是什么。

回到上面一个主要的例子,考虑以下情况:

>>> import json
>>> created = None
>>> json.dumps({'created': created?.isoformat()})

JSON 序列化器不知道如何序列化 NoneQuestion,任何其他 API 也不会。这个提案实际上需要标准库和任何第三方库中 大量的专门逻辑

同时,? 运算符可能也 过于通用,因为它能与其他任何运算符结合。以下表达式应该意味着什么?

>>> x? + 1
>>> x? -= 1
>>> x? == 1
>>> ~x?

这种程度的泛化是无用的。本文中实际提议的运算符有意限制在少数几个运算符上,这些运算符有望使编写常见代码模式变得更容易。

内置的 maybe

Haskell 有一个名为 Maybe 的概念,它封装了可选值的想法,而不依赖于任何特殊关键字(例如 null)或任何特殊实例(例如 None)。在 Haskell 中,Maybe 的目的是避免单独处理“有”和“无”。

一个名为 pymaybe 的 Python 包提供了一个粗略的近似。文档显示了以下示例:

>>> maybe('VALUE').lower()
'value'

>>> maybe(None).invalid().method().or_else('unknown')
'unknown'

函数 maybe() 返回一个 Something 实例或一个 Nothing 实例。与上一节中描述的一元后缀运算符类似,Nothing 覆盖了 dunder 方法,以允许在缺少值时进行链式调用。

请注意,最终需要 or_else() 来从 pymaybe 的包装器中检索底层值。此外,pymaybe 不会短路任何求值。尽管 pymaybe 具有一些优点,并且本身可能有用,但它也说明了为什么纯 Python 实现的合并功能远不如语言内置支持强大。

添加内置 maybe 类型以启用此场景的想法被拒绝。

只需使用条件表达式

另一种常见的初始化默认值的方法是使用三元运算符。这是来自流行的 Requests 包 的摘录:

data = [] if data is None else data
files = [] if files is None else files
headers = {} if headers is None else headers
params = {} if params is None else params
hooks = {} if hooks is None else hooks

这种特殊的写法有一个不好的效果,即将操作数置于不直观的顺序:大脑思考的是“如果可能,使用 data,并使用 [] 作为备用”,但代码却将备用值置于首选值 之前

这个包的作者本可以这样写:

data = data if data is not None else []
files = files if files is not None else []
headers = headers if headers is not None else {}
params = params if params is not None else {}
hooks = hooks if hooks is not None else {}

这种操作数排序更直观,但它需要多出 4 个字符(用于“not “)。它还突出了标识符的重复性:data if datafiles if files 等。

使用 None 合并运算符编写时,示例如下:

data = data ?? []
files = files ?? []
headers = headers ?? {}
params = params ?? {}
hooks = hooks ?? {}

参考资料


来源: https://github.com/python/peps/blob/main/peps/pep-0505.rst

最后修改时间: 2025-02-01 08:55:40 GMT