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

Python 增强提案

PEP 637 – 支持带关键字参数的索引

作者:
Stefano Borini
发起人:
Steven D’Aprano
讨论至:
Python-Ideas 邮件列表
状态:
已拒绝
类型:
标准跟踪
创建日期:
2020年8月24日
Python 版本:
3.10
发布历史:
2020年9月23日
决议:
Python-Dev 帖子

目录

注意

本 PEP 已被拒绝。总的来说,引入新语法的成本并未超过其所带来的感知收益。详情请参阅 Resolution 标题字段中的链接。

摘要

目前,函数调用中允许使用关键字参数,但项访问中不允许。本 PEP 建议扩展 Python 以允许在项访问中使用关键字参数。

以下示例展示了普通函数调用的关键字参数

>>> val = f(1, 2, a=3, b=4)

该提案将扩展语法以允许索引操作中的类似构造

>>> val = x[1, 2, a=3, b=4]  # getitem
>>> x[1, 2, a=3, b=4] = val  # setitem
>>> del x[1, 2, a=3, b=4]    # delitem

并且还将提供适当的语义。还提供了参数的单星和双星解包

>>> val = x[*(1, 2), **{a=3, b=4}]  # Equivalent to above.

本 PEP 是 PEP 472 的后续,该 PEP 因在2019年缺乏兴趣而被拒绝。此后,人们对该功能重新产生了兴趣。

概述

背景

PEP 472 于2014年开放。该 PEP 详细介绍了各种用例,并通过从 python-ideas 邮件列表上的广泛讨论中提取实现策略而创建,尽管未能就应使用哪种策略达成明确共识。许多边界情况已得到更仔细的检查,并被认为是笨拙的、不向后兼容的,或两者兼而有之。

尽管存在了5年,但该 PEP 最终在2019年被拒绝 [1],主要原因是对该功能缺乏兴趣。

然而,随着 PEP 484 中类型提示的引入,方括号表示法一直被用于丰富类型注解,例如将整数列表指定为 Sequence[int]。此外,数据分析包(如 pandas 和 xarray)的使用量也呈爆炸式增长,这些包使用名称来描述表中的列(pandas)或nd-array中的轴(xarray)。这些包允许用户通过名称访问特定数据,但目前无法使用索引表示法([])实现此功能。

因此,在 python-ideas 上的许多不同讨论中,偶尔会有人表达对更灵活的语法(允许命名信息)的兴趣,最近是 Caleb Donovick [2] 在2019年和 Andras Tantos [3] 在2020年提出的。这些请求促使 python-ideas 邮件列表上出现了强烈的活跃度,各种选项被重新讨论,并且现在已经就实现策略达成了普遍共识。

用例

以下实际用例展示了在哪些情况下关键字规范可以改进表示法并提供额外价值

  1. 为索引提供更具沟通性的含义,例如防止索引意外颠倒
    >>> grid_position[x=3, y=5, z=8]
    >>> rain_amount[time=0:12, location=location]
    >>> matrix[row=20, col=40]
    
  2. 用关键字丰富类型注解,尤其是在使用泛型时
    def function(value: MyType[T=int]):
    
  3. 在某些领域,例如计算物理学和化学,使用诸如 Basis[Z=5] 的表示法是一种领域特定语言表示法,用于表示精确度级别
    >>> low_accuracy_energy = computeEnergy(molecule, BasisSet[Z=3])
    
  4. Pandas 目前使用以下表示法
    >>> df[df['x'] == 1]
    

    这可以替换为 df[x=1]

  5. xarray 有命名维度。目前这些通过函数 .isel 处理
    >>> data.isel(row=10)  # Returns the tenth row
    

    这也可以替换为 data[row=10]。一个更复杂的例子

    >>> # old syntax
    >>> da.isel(space=0, time=slice(None, 2))[...] = spam
    >>> # new syntax
    >>> da[space=0, time=:2] = spam
    

    另一个例子

    >>> # old syntax
    >>> ds["empty"].loc[dict(lon=5, lat=6)] = 10
    >>> # new syntax
    >>> ds["empty"][lon=5, lat=6] = 10
    
    >>> # old syntax
    >>> ds["empty"].loc[dict(lon=slice(1, 5), lat=slice(3, None))] = 10
    >>> # new syntax
    >>> ds["empty"][lon=1:5, lat=6:] = 10
    
  6. 其参数是另一个函数(及其参数)的函数/方法需要某种方式来确定哪些参数是用于目标函数的,哪些参数是用于配置它们如何运行目标函数的。这对于位置参数来说很简单(如果不可扩展),但我们需要某种方式来区分关键字参数。 [4]

    索引表示法将提供一种 Pythonic 的方式,将关键字参数传递给这些函数,而不会使调用者的代码变得混乱。

    >>> # Let's start this example with basic syntax without keywords.
    >>> # the positional values are arguments to `func` while
    >>> # `name=` is processed by `trio.run`.
    >>> trio.run(func, value1, value2, name="func")
    >>> # `trio.run` ends up calling `func(value1, value2)`.
    
    >>> # If we want/need to pass value2 by keyword (keyword-only argument,
    >>> # additional arguments that won't break backwards compatibility ...),
    >>> # currently we need to resort to functools.partial:
    >>> trio.run(functools.partial(func, param2=value2), value1, name="func")
    >>> trio.run(functools.partial(func, value1, param2=value2), name="func")
    
    >>> # One possible workaround is to convert `trio.run` to an object
    >>> # with a `__call__` method, and use an "option" helper,
    >>> trio.run.option(name="func")(func, value1, param2=value2)
    >>> # However, foo(bar)(baz) is uncommon and thus disruptive to the reader.
    >>> # Also, you need to remember the name of the `option` method.
    
    >>> # This PEP allows us to replace `option` with `__getitem__`.
    >>> # The call is now shorter, more mnemonic, and looks+works like typing
    >>> trio.run[name="func"](func, value1, param2=value2)
    
  7. 星号参数的可用性将使 PEP 646 可变泛型受益,特别是以 a[*x]a[*x, *y, p, q, *z] 的形式。该 PEP 在其“解包:星号运算符”部分详细描述了这种表示法。

需要注意的是,如何解释该表示法取决于实现。本 PEP 仅定义并规定 Python 在传递关键字参数方面的行为,而不规定这些参数应如何由实现类解释和使用。

索引操作的当前状态

在详细阐述索引表示法的新语法和语义之前,有必要分析索引表示法目前的运作方式、在哪些上下文中以及它与函数调用的区别。

下标操作 obj[x] 实际上是函数调用语法的一种替代和特殊形式,与 obj(x) 相比,它有许多不同之处和限制。当前的 Python 语法完全侧重于位置来表达索引,并且还包含用于引用非点选(切片)的语法糖。一些常见的例子

>>> a[3]       # returns the fourth element of 'a'
>>> a[1:10:2]  # slice notation (extract a non-trivial data subset)
>>> a[3, 2]    # multiple indexes (for multidimensional arrays)

这会转换为一个 __(get|set|del)item__ 双下划线调用,该调用会传递一个包含索引的参数(对于 __getitem____delitem__)或两个包含索引和值的参数(对于 __setitem__)。

索引调用的行为在多个方面与函数调用有根本不同

第一个区别在于对读者的含义。函数调用表示“任意函数调用,可能带有副作用”。索引操作表示“查找”,通常指向实体的一个子集或特定子方面(如类型表示法的情况)。这种根本区别意味着,虽然我们无法阻止滥用,但实现者应该意识到,引入关键字参数以改变查找行为可能会违反这种内在含义。

索引表示法与函数相比的第二个区别是,索引可用于获取和设置操作。在 Python 中,函数不能位于赋值的左侧。换句话说,以下两种都是有效的

>>> x = a[1, 2]
>>> a[1, 2] = 5

但只有第一种是有效的

>>> x = f(1, 2)
>>> f(1, 2) = 5  # invalid

这种不对称性很重要,它让人明白这两种形式之间存在自然的不平衡。因此,它们不应透明且对称地行为并非理所当然。

第三个区别是,函数为其参数分配了名称,除非传递的参数被 *args 捕获,在这种情况下,它们会成为 args 元组中的条目。换句话说,函数已经具有匿名参数语义,就像索引操作一样。然而,__(get|set|del)item__ 并不总是接收一个元组作为 index 参数(以便与 *args 行为一致)。实际上,给定一个简单类

class X:
    def __getitem__(self, index):
        print(index)

索引操作基本上将方括号内的内容“按原样”转发到 index 参数中

>>> x=X()
>>> x[0]
0
>>> x[0, 1]
(0, 1)
>>> x[(0, 1)]
(0, 1)
>>>
>>> x[()]
()
>>> x[{1, 2, 3}]
{1, 2, 3}
>>> x["hello"]
hello
>>> x["hello", "hi"]
('hello', 'hi')

第四个区别是,由于解析器的支持,索引操作知道如何将冒号表示法转换为切片。这是有效的

a[1:3]

这个不是

f(1:3)

第五个区别是没有零参数形式。这是有效的

f()

这个不是

a[]

规范

在描述规范之前,强调“位置索引”、“最终索引”和“关键字参数”之间的命名差异很重要,因为理解所涉及的基本不对称性至关重要。__(get|set|del)item__ 本质上是一个索引操作,并且通过索引,即“最终索引”来检索、设置或删除元素。

目前的现状是直接从方括号之间传递的内容(即“位置索引”)构建“最终索引”。换句话说,方括号中传递的内容被直接用于生成 __getitem__ 中的代码用于索引操作。正如我们之前对字典所见,d[1] 的位置索引是 1,最终索引也是 1(因为它就是添加到字典中的元素),而 d[1, 2] 的位置索引是 (1, 2),最终索引也是 (1, 2)(因为它再次是添加到字典中的元素)。然而,字典不接受位置索引 d[1,2:3],因为无法将位置索引转换为最终索引,因为切片对象是不可哈希的。位置索引是目前 __getitem__ 中所谓的 index 参数。尽管如此,没有什么能阻止构建一个类似字典的类,该类通过例如将位置索引转换为字符串来创建最终索引。

本 PEP 扩展了当前现状,并通过结合位置索引和关键字参数(如果传入)的增强语法,赋予了创建最终索引更大的灵活性。

上述内容带来了一个重要的观点。关键字参数在索引操作的上下文中,可用于做出索引决策以获得最终索引,因此必须接受函数中非常规的值。例如,请参见用例1,其中接受切片。

本 PEP 的成功实施将产生以下行为

  1. 空的下标在任何情况下仍然是非法的(参见被拒绝的想法)
    obj[]  # SyntaxError
    
  2. 单个索引值在传递时仍为单个索引值
    obj[index]
    # calls type(obj).__getitem__(obj, index)
    
    obj[index] = value
    # calls type(obj).__setitem__(obj, index, value)
    
    del obj[index]
    # calls type(obj).__delitem__(obj, index)
    

    即使索引后跟关键字,情况也如此;请参见下面的第5点。

  3. 逗号分隔的参数仍解析为元组并作为单个位置参数传递
    obj[spam, eggs]
    # calls type(obj).__getitem__(obj, (spam, eggs))
    
    obj[spam, eggs] = value
    # calls type(obj).__setitem__(obj, (spam, eggs), value)
    
    del obj[spam, eggs]
    # calls type(obj).__delitem__(obj, (spam, eggs))
    

    上述几点意味着不希望支持关键字参数下标的类无需做任何事情,因此该功能完全向后兼容。

  4. 关键字参数(如果有)必须在位置参数之后
    obj[1, 2, spam=None, 3]  # SyntaxError
    

    这就像函数调用一样,混合位置参数和关键字参数会导致 SyntaxError。

  5. 关键字下标(如果有)将像函数调用中的关键字一样处理。示例
    # Single index with keywords:
    
    obj[index, spam=1, eggs=2]
    # calls type(obj).__getitem__(obj, index, spam=1, eggs=2)
    
    obj[index, spam=1, eggs=2] = value
    # calls type(obj).__setitem__(obj, index, value, spam=1, eggs=2)
    
    del obj[index, spam=1, eggs=2]
    # calls type(obj).__delitem__(obj, index, spam=1, eggs=2)
    
    # Comma-separated indices with keywords:
    
    obj[foo, bar, spam=1, eggs=2]
    # calls type(obj).__getitem__(obj, (foo, bar), spam=1, eggs=2)
    
    obj[foo, bar, spam=1, eggs=2] = value
    # calls type(obj).__setitem__(obj, (foo, bar), value, spam=1, eggs=2)
    
    del obj[foo, bar, spam=1, eggs=2]
    # calls type(obj).__detitem__(obj, (foo, bar), spam=1, eggs=2)
    

    请注意

    • 单个位置索引不会仅仅因为添加了关键字值而变成元组。
    • 对于 __setitem__,索引和值的顺序保持不变。关键字参数位于末尾,这对于函数定义来说是正常的。
  6. 关于关键字下标,与函数调用中的关键字适用相同的规则
    • 解释器将每个关键字下标与相应方法中的命名参数匹配;
    • 如果一个命名参数被使用了两次,则这是一个错误;
    • 如果所有关键字都使用完后还有剩余的命名参数(没有值),则会为其分配默认值(如果有);
    • 如果任何此类参数没有默认值,则这是一个错误;
    • 如果在所有命名参数都被填充后仍有剩余的关键字下标,并且方法具有 **kwargs 参数,则它们将作为字典绑定到 **kwargs 参数;
    • 但如果没有定义 **kwargs 参数,则这是一个错误。
  7. 下标中允许序列解包
    obj[*items]
    

    这允许使用诸如 [:, *args, :] 的表示法,这可以被视为 [(slice(None), *args, slice(None))]。允许多个星号解包

    obj[1, *(2, 3), *(4, 5), 6, foo=5]
    # Equivalent to obj[(1, 2, 3, 4, 5, 6), foo=3)
    

    必须遵循以下符号等价关系

    obj[*()]
    # Equivalent to obj[()]
    
    obj[*(), foo=3]
    # Equivalent to obj[(), foo=3]
    
    obj[*(x,)]
    # Equivalent to obj[(x,)]
    
    obj[*(x,),]
    # Equivalent to obj[(x,)]
    

    特别注意情况 3:单个元素的序列解包不会表现得像只传递了一个参数。一个相关的例子如下

    obj[1, *(), foo=5]
    # Equivalent to obj[(1,), foo=5]
    # calls type(obj).__getitem__(obj, (1,), foo=5)
    

    然而,正如我们之前所见,为了向后兼容性,单个索引将按原样传递

    obj[1, foo=5]
    # calls type(obj).__getitem__(obj, 1, foo=5)
    

    换句话说,单个位置索引仅在不存在序列解包的情况下“按原样”传递。如果存在序列解包,则索引将变为元组,无论解包后索引中的元素数量如何。

  8. 允许字典解包
    items = {'spam': 1, 'eggs': 2}
    obj[index, **items]
    # equivalent to obj[index, spam=1, eggs=2]
    

    应遵守以下符号等价关系

    obj[**{}]
    # Equivalent to obj[()]
    
    obj[3, **{}]
    # Equivalent to obj[3]
    
  9. 允许仅关键字下标。位置索引将为空元组
    obj[spam=1, eggs=2]
    # calls type(obj).__getitem__(obj, (), spam=1, eggs=2)
    
    obj[spam=1, eggs=2] = 5
    # calls type(obj).__setitem__(obj, (), 5, spam=1, eggs=2)
    
    del obj[spam=1, eggs=2]
    # calls type(obj).__delitem__(obj, (), spam=1, eggs=2)
    

    关于将空元组作为哨兵值的选择曾引起争议。详情请参阅“被拒绝的想法”部分。

  10. 关键字参数必须允许切片语法
    obj[3:4, spam=1:4, eggs=2]
    # calls type(obj).__getitem__(obj, slice(3, 4, None), spam=slice(1, 4, None), eggs=2)
    

    这可能会为通用函数调用接受相同的语法开辟可能性,但这不属于本建议的一部分。

  11. 关键字参数允许默认值
    # Given type(obj).__getitem__(obj, index, spam=True, eggs=2)
    obj[3]               # Valid. index = 3, spam = True, eggs = 2
    obj[3, spam=False]   # Valid. index = 3, spam = False, eggs = 2
    obj[spam=False]      # Valid. index = (), spam = False, eggs = 2
    obj[]                # Invalid.
    
  12. 上述语义必须扩展到 __class__getitem__:自 PEP 560 以来,类型提示的调度方式是,对于 x[y],如果没有找到 __getitem__ 方法,并且 x 是一个类型(类)对象,并且 x 有一个类方法 __class_getitem__,则会调用该方法。相同的更改也应应用于此方法,以便可以接受 list[T=int] 这样的写法。

标准类(dict, list等)中的索引行为

本 PEP 中提出的任何内容都不会改变当前使用索引的核心类的行为。为自定义类的索引操作添加关键字与修改例如标准 dict 类型以处理关键字参数不同。事实上,dict(以及 list 和其他具有索引语义的标准库类)将保持不变,并将继续不接受关键字参数。换句话说,如果 d 是一个 dict,则语句 d[1, a=2] 将引发 TypeError,因为它们的实现不支持使用关键字参数。所有其他类(list、dict等)也是如此。

边界情况和陷阱

随着新符号的引入,需要分析一些边界情况。

  1. 从技术上讲,如果一个类这样定义它的 getter
    def __getitem__(self, index):
    

    那么调用者可以使用关键字语法调用它,如下两种情况

    obj[3, index=4]
    obj[index=1]
    

    结果行为将自动出错,因为它就像尝试用两个值调用 index 参数,并将引发 TypeError。在第一种情况下,index 将是 3,在第二种情况下,它将是空元组 ()

    请注意,此行为适用于所有目前依赖索引的现有类,这意味着新行为在这方面不会引入向后兼容性问题。

    希望明确强调此行为的类可以将其参数定义为仅位置参数

    def __getitem__(self, index, /):
    
  2. setter 符号也会出现类似情况
    # Given type(obj).__setitem__(obj, index, value):
    obj[1, value=3] = 5
    

    这不会造成任何问题,因为值是自动传递的,并且 Python 解释器将引发 TypeError: got multiple values for keyword argument 'value'

  3. 如果下标双下划线方法声明为使用位置或关键字参数,则在将参数传递给方法时可能会出现一些令人惊讶的情况。给定签名
    def __getitem__(self, index, direction='north')
    

    如果调用者使用这个

    obj[0, 'south']
    

    他们可能会对方法调用感到惊讶

    # expected type(obj).__getitem__(obj, 0, direction='south')
    # but actually get:
    type(obj).__getitem__(obj, (0, 'south'), direction='north')
    

    解决方案:最佳实践建议,关键字下标应尽可能标记为仅关键字参数

    def __getitem__(self, index, *, direction='north')
    

    解释器不需要强制执行此规则,因为在某些情况下这可能是所需的行为。但 linter 可能会选择警告不使用仅关键字标志的下标方法。

  4. 正如我们所见,单个值后跟关键字参数不会转换为元组,即:d[1, a=3] 被视为 __getitem__(d, 1, a=3),而不是 __getitem__(d, (1,), a=3)。如果添加关键字参数会改变传递索引的类型,那将是极其令人困惑的。换句话说,向单值下标添加关键字不会将其更改为元组。对于需要传递实际元组的情况,必须使用适当的语法
    obj[(1,), a=3]
    # calls type(obj).__getitem__(obj, (1,), a=3)
    

    在这种情况下,调用传递的是单个元素(根据上述规则按原样传递),只是这个单个元素恰好是一个元组。

    请注意,此行为仅揭示了 obj[1,] 表示法是 obj[(1,)] 的简写(以及 obj[1]obj[(1)] 的简写,具有预期行为)这一事实。当存在关键字时,可以省略最外层括号的规则不再成立

    obj[1]
    # calls type(obj).__getitem__(obj, 1)
    
    obj[1, a=3]
    # calls type(obj).__getitem__(obj, 1, a=3)
    
    obj[1,]
    # calls type(obj).__getitem__(obj, (1,))
    
    obj[(1,), a=3]
    # calls type(obj).__getitem__(obj, (1,), a=3)
    

    这在传递两个条目时尤其相关

    obj[1, 2]
    # calls type(obj).__getitem__(obj, (1, 2))
    
    obj[(1, 2)]
    # same as above
    
    obj[1, 2, a=3]
    # calls type(obj).__getitem__(obj, (1, 2), a=3)
    
    obj[(1, 2), a=3]
    # calls type(obj).__getitem__(obj, (1, 2), a=3)
    

    尤其是在将元组提取为变量时

    t = (1, 2)
    obj[t]
    # calls type(obj).__getitem__(obj, (1, 2))
    
    obj[t, a=3]
    # calls type(obj).__getitem__(obj, (1, 2), a=3)
    

    为什么?因为在 obj[1, 2, a=3] 的情况下,我们传递了两个元素(然后它们被打包成一个元组并作为索引传递)。在 obj[(1, 2), a=3] 的情况下,我们传递了一个单个元素(按原样传递),它恰好是一个元组。最终结果是它们是相同的。

C 接口

索引操作的解析通过调用以下函数执行

  • PyObject_GetItem(PyObject *o, PyObject *key) 用于获取操作
  • PyObject_SetItem(PyObject *o, PyObject *key, PyObject *value) 用于设置操作
  • PyObject_DelItem(PyObject *o, PyObject *key) 用于删除操作

这些函数在 Python 可执行文件中广泛使用,并且也是公共 C API 的一部分,由 Include/abstract.h 导出。显然,此函数的签名无法更改,需要实现不同的 C 级函数来支持扩展调用。我们建议

  • PyObject_GetItemWithKeywords(PyObject *o, PyObject *key, PyObject *kwargs)
  • PyObject_SetItemWithKeywords(PyObject *o, PyObject *key, PyObject *value, PyObject *kwargs)
  • PyObject_GetItemWithKeywords(PyObject *o, PyObject *key, PyObject *kwargs)

增强的调用将需要新的操作码。目前,实现使用 BINARY_SUBSCRSTORE_SUBSCRDELETE_SUBSCR 来调用旧函数。我们建议为新操作使用 BINARY_SUBSCR_KWSTORE_SUBSCR_KWDELETE_SUBSCR_KW。编译器将生成这些新的操作码。旧的 C 实现将调用扩展方法,并将 NULL 作为 kwargs 传递。

最后,必须将以下新槽添加到 PyMappingMethods 结构中

  • mp_subscript_kw
  • mp_ass_subscript_kw

这些槽将具有适当的签名来处理包含关键字的字典对象。

“如何教学”建议

在反馈会议期间,有人要求详细说明一种可能的教学方式,例如面向学生、数据科学家和类似受众。本节将解决这一需求。

我们只会从使用的角度而非实现的视角描述索引,因为这是上述受众可能遇到的方面。只有一小部分用户需要实现自己的双下划线函数,这被认为是高级用法。一个恰当的解释可能是

索引操作通常用于通过索引引用较大数据集的子集。在常见情况下,索引由一个或多个数字、字符串、切片等组成。

某些类型可能不仅允许使用索引进行索引,还允许使用命名值。这些命名值通过方括号在方括号之间给出,使用与函数调用关键字参数相同的语法。名称的含义及其用法可在类型的文档中找到,因为它因类型而异。

老师现在将展示一些实际的现实世界示例,解释所示库中该功能的语义。显然,在撰写本文时,这些示例尚不存在,但最有可能实现该功能的库是 pandas 和 numpy,可能是作为通过名称引用列的方法。

参考实现

目前正在这里开发参考实现 [6]

变通方法

每个改变 Python 语言的 PEP 都应该 “清楚地解释为什么现有语言规范不足以解决 PEP 所解决的问题”

一些与提议的扩展大致等效的,我们称之为变通方法,已经可能实现。这些变通方法提供了启用新语法的替代方案,同时将语义留待其他地方定义。

这些变通方法如下。在这些方法中,助手 HP 并非旨在通用。例如,模块或包可能需要使用其自己的助手。

  1. 用户定义的类可以具有 getitemdelitem 方法,它们分别获取和删除存储在容器中的值
    >>> val = x.getitem(1, 2, a=3, b=4)
    >>> x.delitem(1, 2, a=3, b=4)
    

    setitem 则不行。这不是有效的语法

    >>> x.setitem(1, 2, a=3, b=4) = val
    SyntaxError: can't assign to function call
    
  2. 一个帮助类,此处称为 H,可以用于交换容器和参数的角色。换句话说,我们使用
    H(1, 2, a=3, b=4)[x]
    

    作为替代

    x[1, 2, a=3, b=4]
    

    此方法适用于 getitemdelitem,也适用于 setitem。这是因为

    >>> H(1, 2, a=3, b=4)[x] = val
    

    是有效的语法,并且可以赋予适当的语义。

  3. 一个辅助函数,这里称为 P,可以用来将参数存储在单个对象中。例如
    >>> x[P(1, 2, a=3, b=4)] = val
    

    是有效的语法,并且可以赋予适当的语义。

  4. 切片的 lo:hi:step 语法有时非常有用。这种语法在变通方法中无法直接使用。然而
    s[lo:hi:step]
    

    提供了一种在任何地方都可用的变通方法,其中

    class S:
        def __getitem__(self, key): return key
    
    s = S()
    

    定义了辅助对象 s

被拒绝的想法

之前的 PEP 472 解决方案

PEP 472 提出了大量的想法,现在都应被视为已拒绝。D’Aprano 给作者的一封私人电子邮件明确指出

我已经仔细阅读了 PEP 472 的全文,恐怕我无法支持该 PEP 中目前存在的任何策略。

我们同意这些选项不如目前提出的选项,原因各异。

为了使本文档保持简洁,我们不会在这里介绍 PEP 472 中提出的所有选项的异议。只需说它们经过了讨论,每个提出的替代方案都有一个或几个不可接受的缺陷。

添加新的双下划线方法

有人提议引入新的双下划线方法 __(get|set|del)item_ex__,如果它们存在,则会在 __(get|set|del)item__ 三重奏之后调用它们。

此选择的基本原理是使如何向方括号添加关键字参数支持的直觉更明显,并与函数行为保持一致。给定

def __getitem_ex__(self, x, y): ...

这些都只是简单地工作并轻松产生相同的结果

obj[1, 2]
obj[1, y=2]
obj[y=2, x=1]

换句话说,这个解决方案将统一 __getitem__ 的行为与传统函数签名,但由于我们无法更改 __getitem__ 并破坏向后兼容性,我们将拥有一个优先使用的扩展版本。

这种方法的缺点是:

  • 它会减慢下标操作。对于每次下标访问,都会在类上检查这个新的双下划线属性,如果它不存在,则执行默认的键转换函数。有人提出了不同的想法来解决这个问题,从仅在类实例化时包装方法,到添加一个位标志来表示这些方法的存在。无论解决方案如何,新的双下划线方法只有在类创建时添加才有效,如果稍后添加则无效。这将是不寻常的,并且会禁止(并意外地表现)出于任何原因可能需要的猴子补丁方法。
  • 它增加了机制的复杂性。
  • 将需要一个漫长而痛苦的过渡期,在此期间,库将不得不以某种方式支持两种调用约定,因为最有可能的是,在参数匹配正确条件时,扩展方法将委托给传统方法,或者某些类将支持传统双下划线方法,而其他类将支持扩展双下划线方法。虽然这不会影响调用代码,但会影响开发。
  • 它可能会导致混合情况,即为 getter 定义了扩展版本,但没有为 setter 定义。
  • __setitem_ex__ 签名中,value 必须成为第一个元素,因为索引的长度取决于指定的索引。这看起来会很尴尬,因为视觉表示与签名不匹配
    obj[1, 2] = 3
    # calls type(obj).__setitem_ex__(obj, 3, 1, 2)
    
  • 该解决方案依赖于所有关键字索引必然映射到位置索引,或者它们必须具有名称的假设。这个假设可能不正确:xarray(带有标记维度的 numpy 数组的主要 Python 包)支持通过额外维度(所谓的“非维度坐标”)进行索引,这些维度不直接对应于底层 numpy 数组的维度,并且它们没有匹配的位置。换句话说,匿名索引是一种合理的用例,该解决方案会将其删除,尽管可以争辩说使用 *args 将解决该问题。

添加适配器函数

与上述类似,都是通过一个预函数将“新式”索引转换为“旧式”索引,然后进行传递。存在与上述类似的问题。

创建新的“kwslice”对象

这项提案已在 PEP 472 的“新参数内容”P4 中探讨过

obj[a, b:c, x=1]
# calls type(obj).__getitem__(obj, a, slice(b, c), key(x=1))

这个解决方案要求所有需要关键字参数的人手动解析元组和/或键对象来提取它们。这很麻烦,并且使得 get/set/del 函数始终接受任意关键字参数,无论它们是否有意义。我们希望开发者能够指定哪些参数有意义,哪些没有。

使用单个位来改变行为

一个特殊的类双下划线标志

__keyfn__ = True

__get|set|delitem__ 的签名更改为“类似函数”的调度,这意味着

>>> d[1, 2, z=3]

将导致调用

>>> type(obj).__getitem__(obj, 1, 2, z=3)
# instead of type(obj).__getitem__(obj, (1, 2), z=3)

此选项已被拒绝,因为它感觉一个方法的签名取决于另一个双下划线方法的特定值是很奇怪的。这对静态类型检查器和人类来说都将是令人困惑的:静态类型检查器将不得不为此硬编码一个特殊情况,因为在 Python 中确实没有其他地方一个双下划线方法的签名取决于另一个双下划线方法的值。一个必须实现 __getitem__ 双下划线方法的人,在编写双下划线方法之前,必须查看类(或其任何子类)中是否存在 __keyfn__。此外,添加一个设置了 __keyfn__ 标志的基类将破坏当前方法的签名。如果该标志在运行时更改,或者该标志是通过调用一个随机返回 True 或其他值的函数生成的,这将更成问题。

允许空索引表示 obj[]

当前的提案阻止了 obj[] 成为有效表示法。然而,一位评论者指出

我们有 Tuple[int, int] 作为两个整数的元组。我们有 Tuple[int] 作为单个整数的元组。有时我们需要表示一个**空**值的元组,因为那是 () 的类型。但我们目前被迫将其写为 Tuple[()]。如果允许 Tuple[],那个奇怪的边缘情况就会消失。

所以,我可能可以接受在语法上允许 obj[],只要 dict 类型可以拒绝它。

本提案已经规定,在未给定位置索引的情况下,传递的值必须是空元组。允许空索引表示法将使字典类型自动接受它,以使用空元组作为键来插入或引用该值。此外,诸如 Tuple[] 的类型表示法可以很容易地写成 Tuple 而无需索引表示法。

然而,随后与 Brandt Bucher 在实现过程中的讨论揭示,obj[] 的情况将符合可变泛型的自然演进,从而进一步加强了上述评论。最终,在 D’Aprano、Bucher 和作者之间讨论后,我们决定目前将 obj[] 表示法保留为语法错误,并可能通过额外的 PEP 来扩展该表示法,以保持 obj[]obj[()] 的等效性。

未提供位置索引的哨兵值

关于在这种情况下要传递什么值作为索引的问题

obj[k=3]

已经引起了相当大的争论。

一个显然合理的选择是根本不传递任何值,通过使用仅关键字参数功能,但不幸的是,这与 __setitem__ 双下划线方法不兼容,因为值总是作为位置元素传递,而且我们不能“跳过”索引,除非我们引入一种非常奇怪的行为,即第一个参数在指定时指索引,在未指定时指值。这极具欺骗性且容易出错。

上述考虑使得拥有一个仅关键字的双下划线方法变得不可能,并引出了一个问题:当没有传递索引时,应该传递什么实体作为索引位置?

obj[k=3] = 5
# would call type(obj).__setitem__(obj, ???, 5, k=3)

一个提议的 hack 是让用户指定在未指定索引时要使用的实体,通过为 index 指定默认值,但这必然强制也要为 value 指定一个(永远不会被使用,因为值总是按设计传递)默认值,因为我们不能在默认参数之后有非默认参数

def __setitem__(self, index=SENTINEL, value=NEVERUSED, *, k)

这看起来很难看、多余且令人困惑。因此,我们必须接受,当使用 obj[k=3] 表示法时,Python 实现必须传递某种形式的哨兵索引。这也意味着这些参数的默认参数将永远不会被使用(但目前的实现已经是这种情况,因此没有改变)。

此外,一些类可能希望使用 **kwargs,而不是仅关键字参数,这意味着如果有一个像这样的定义

def __setitem__(self, index, value, **kwargs):

而用户想要传递一个关键字 value

x[value=1] = 0

期望一个像这样的调用

type(obj).__setitem__(obj, SENTINEL, 0, **{"value": 1})

反而会被命名为 value 的参数意外捕获,从而产生 duplicate value error。用户不应该担心这两个参数的实际局部名称,因为它们在所有实际用途上都只是位置参数。不幸的是,使用仅位置值将确保这种情况不会发生,但它仍然无法解决即使没有提供索引也需要同时传递 indexvalue 的需求。关键在于,不应阻止用户使用关键字参数来引用列 indexvalue(或 self),仅仅因为类实现者碰巧在参数列表中使用了这些名称。

此外,我们还要求三个双下划线方法以相同的方式行为:如果只有 __setitem__ 接收到这个哨兵,而 __get|delitem__ 不接收,因为它们可以用允许不指定索引的签名来逃脱,从而允许用户指定的默认索引,那将是极其不便的。

无论哨兵的选择是什么,它都会使以下情况退化,从而无法在双下划线中区分

obj[k=3]
obj[SENTINEL, k=3]

现在问题转变为哪个实体应该代表哨兵:选项有

  1. 空元组
  2. NotImplemented
  3. 一个新的哨兵对象(例如 NoIndex)

对于选项 1,调用将变为

type(obj).__getitem__(obj, (), k=3)

因此,使得 obj[k=3]obj[(), k=3] 退化且无法区分。

此选项听起来很有吸引力,因为

  1. 我们询问了 numpy 社区 [5],得到的普遍共识是空元组感觉很合适。
  2. 它与函数中 *args 在未给定位置参数时的行为表现出相似之处
    >>> def foo(*args, **kwargs):
    ...     print(args, kwargs)
    ...
    >>> foo(k=3)
    () {'k': 3}
    

    尽管我们确实接受了与函数相比,当传递单个值时行为存在以下不对称性,但那个问题已经过去了

    >>> foo(5, k=3)
    (5,) {'k': 3}   # for indexing, a plain 5, not a 1-tuple is passed
    

对于选项2,使用 None,有人反对说 NumPy 用它来表示插入新的轴/维度(也有 np.newaxis 别名)

arr = np.array(5)
arr.ndim == 0
arr[None].ndim == arr[None,].ndim == 1

虽然这不是一个不可逾越的问题,但它肯定会对 numpy 产生连锁反应。

上述两者唯一的问题是,空元组和 None 都可能是合法的索引,并且能够区分这两种退化情况可能具有价值。

因此,另一种策略(选项3)是使用一个现有实体,它不太可能用作有效索引。一个选项可以是当前内置常量 NotImplemented,它目前由运算符方法返回,以报告它们未实现特定操作,并且应该尝试不同的策略(例如询问另一个对象)。不幸的是,它的名称和传统用法指向一个不可用的功能,而不是用户未传递某些内容的事实。

这给我们留下了选项 4:一个新的内置常量。这个常量必须是不可哈希的(所以它永远不会成为有效的键),并且有一个清晰的名称,使其上下文显而易见:NoIndex。这将解决上述所有问题,但问题是:它值得吗?

从快速查询来看,python-ideas 上的大多数人似乎认为它不关键,空元组是一个可接受的选项。因此,结果系列将是

obj[k=3]
# type(obj).__getitem__(obj, (), k=3). Empty tuple

obj[1, k=3]
# type(obj).__getitem__(obj, 1, k=3). Integer

obj[1, 2, k=3]
# type(obj).__getitem__(obj, (1, 2), k=3). Tuple

以下两种表示法将是退化的

obj[(), k=3]
# type(obj).__getitem__(obj, (), k=3)

obj[k=3]
# type(obj).__getitem__(obj, (), k=3)

常见异议

  1. 只需使用方法调用。

    其中一个用例是类型提示,其中专门使用索引,函数调用则完全不考虑。此外,函数调用不处理切片表示法,而切片表示法在某些情况下常用于数组。

    一个问题是,在 Python 3.9 中,类型提示的创建已扩展到内置类型,因此您不再需要导入 Dict、List 等。

    如果没有 [] 内部的关键字参数,您将无法做到这一点

    Vector = dict[i=float, j=float]
    

    但出于显而易见的原因,使用内置函数创建自定义类型提示的调用语法不是一个选项

    dict(i=float, j=float)
    # would create a dictionary, not a type
    

    最后,函数调用不允许使用类似 setitem 的表示法,如概述中所示:f(1, x=3) = 5 等操作是不允许的,而索引操作则允许。

参考资料


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

最后修改: 2025-02-01 08:59:27 GMT