PEP 637 – 支持使用关键字参数进行索引
- 作者:
- Stefano Borini
- 赞助商:
- Steven D’Aprano
- 讨论地址:
- Python-Ideas 邮件列表
- 状态:
- 已拒绝
- 类型:
- 标准跟踪
- 创建日期:
- 2020-08-24
- Python 版本:
- 3.10
- 历史记录:
- 2020-09-23
- 决议:
- Python-Dev 线程
注意
这个 PEP 已经被拒绝了。总的来说,引入新语法的成本没有超过其预期的益处。有关详细信息,请参阅决议标题字段中的链接。
摘要
目前,在函数调用中允许使用关键字参数,但在项访问中不允许。这个 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 的继承者,该提案由于缺乏兴趣在 2019 年被拒绝。从那时起,人们对该功能的兴趣有所恢复。
概述
背景
PEP 472 在 2014 年提出。该 PEP 详细介绍了各种用例,并通过提取 python-ideas 邮件列表上的广泛讨论中的实现策略创建,尽管在应该使用哪种策略方面没有达成明确的共识。许多极端情况已经过更仔细的审查,被认为是别扭的、向后不兼容的或两者兼而有之。
该 PEP 最终在 2019 年被拒绝 [1],主要原因是,尽管该功能存在 5 年之久,但仍然缺乏兴趣。
然而,随着 PEP 484 中引入类型提示,方括号表示法已被一致地用于丰富类型注释,例如,将整数列表指定为 Sequence[int]。此外,用于数据分析的软件包(如 pandas 和 xarray)已不断增长,它们使用名称来描述表中的列(pandas)或 nd 数组中的轴(xarray)。这些软件包允许用户按名称访问特定数据,但目前无法使用索引符号 ([]) 来实现此功能。
因此,人们对更灵活的语法表示出了新的兴趣,该语法将允许使用命名信息,并在 python-ideas 上的许多不同线程中偶尔表示出来,最近在 2019 年由 Caleb Donovick [2] 和 2020 年由 Andras Tantos [3] 表示出来。这些请求促使 python-ideas 邮件列表上进行了一项强烈的活动,在那里重新讨论了各种选项,并且现在已经就实现策略达成了普遍共识。
用例
以下实际用例展示了关键字规范将改进表示法并提供额外价值的不同情况
- 为了使索引具有更具传达性的含义,防止例如意外反转索引
>>> grid_position[x=3, y=5, z=8] >>> rain_amount[time=0:12, location=location] >>> matrix[row=20, col=40]
- 为了使用关键字丰富类型注释,尤其是在使用泛型时
def function(value: MyType[T=int]):
- 在某些领域,例如计算物理和化学,使用
Basis[Z=5]
这样的表示法是表示精度级别的领域特定语言表示法>>> low_accuracy_energy = computeEnergy(molecule, BasisSet[Z=3])
- Pandas 目前使用
df['x']
这样的表示法>>> df[df['x'] == 1]
可以替换为
df[x=1]
。 - 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
- 参数为另一个函数(及其参数)的函数/方法需要某种方式来确定哪些参数是为目标函数准备的,哪些参数用于配置它们如何运行目标函数。对于位置参数来说,这是简单的(如果不是可扩展的话),但我们需要某种方式来区分这些参数的关键字。 [4]
索引表示法将提供一种 Python 式的方式来将关键字参数传递给这些函数,而不会使调用者的代码混乱。
>>> # 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)
- 星号参数的可用性将有利于 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__
dunder 调用,该调用传递一个包含索引的参数(对于 __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 的成功实施将导致以下行为
- 无论上下文如何,空下标仍然是非法的(参见被拒绝的建议)。
obj[] # SyntaxError
- 单个索引值在传递时仍然是单个索引值。
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 点。
- 用逗号分隔的参数仍然被解析为一个元组,并作为一个单独的位置参数传递。
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))
以上几点意味着,不想在子脚本中支持关键字参数的类根本不需要做任何事情,因此此功能完全向后兼容。
- 如果有关键字参数,它们必须在位置参数之后。
obj[1, 2, spam=None, 3] # SyntaxError
这与函数调用类似,混合使用位置参数和关键字参数会产生 SyntaxError。
- 如果有关键字子脚本,则会像在函数调用中一样处理它们。示例
# 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__
,索引和值的顺序保持一致。关键字参数位于最后,这在函数定义中是正常的。
- 关键字子脚本的相同规则适用于函数调用中的关键字。
- 解释器将每个关键字子脚本与相应方法中的命名参数匹配;
- 如果命名参数使用两次,则会发生错误;
- 如果在使用完所有关键字后还剩余命名参数(没有值),则会分配它们的默认值(如果有);
- 如果任何此类参数没有默认值,则会发生错误;
- 如果在填充所有命名参数后还剩余关键字子脚本,并且该方法具有
**kwargs
参数,则它们将作为字典绑定到**kwargs
参数; - 但是,如果没有定义
**kwargs
参数,则会发生错误。
- 允许在子脚本中进行序列解包。
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)
换句话说,只有在没有序列解包的情况下,单个位置索引才会“按原样”传递。如果存在序列解包,则无论解包后索引中元素数量多少,索引都会变成元组。
- 允许使用字典解包。
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]
- 允许使用仅关键字的子脚本。位置索引将为空元组。
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)
关于使用空元组作为哨兵的选择,已经进行了讨论。在“被拒绝的建议”部分提供了详细信息。
- 关键字参数必须允许使用切片语法。
obj[3:4, spam=1:4, eggs=2] # calls type(obj).__getitem__(obj, slice(3, 4, None), spam=slice(1, 4, None), eggs=2)
这可能会为接受通用函数调用的相同语法打开可能性,但这不属于本建议的范围。
- 关键字参数允许使用默认值。
# 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.
- 上述给出的语义必须扩展到
__class__getitem__
:自 PEP 560 以来,类型提示已分派,因此对于x[y]
,如果找不到__getitem__
方法,并且x
是一个类型(类)对象,并且x
具有类方法__class_getitem__
,则调用该方法。应将相同的更改应用于此方法,以便可以接受诸如list[T=int]
之类的写法。
标准类(dict、list 等)中的索引行为
本 PEP 中提出的任何内容都不会改变当前使用索引的核心类的行为。为自定义类添加索引操作的关键字与修改例如标准 dict 类型以处理关键字参数不同。实际上,dict(以及 list 和其他具有索引语义的 stdlib 类)将保持不变,并且将继续不接受关键字参数。换句话说,如果 d
是一个 dict
,语句 d[1, a=2]
将引发 TypeError
,因为它们的实现不支持使用关键字参数。所有其他类(list、dict 等)也一样。
极端情况和陷阱
随着新的表示法的引入,需要分析一些极端情况。
- 从技术上讲,如果一个类定义了它们的 getter 如下所示
def __getitem__(self, index):
那么调用者可以使用关键字语法调用它,例如以下两种情况
obj[3, index=4] obj[index=1]
最终的行为将自动成为错误,因为它就像尝试用
index
参数的两个值调用该方法一样,并且会引发TypeError
。在第一种情况下,index
将是3
,在第二种情况下,它将为空元组()
。请注意,此行为适用于所有当前存在的依赖于索引的类,这意味着新行为无法在这一点上引入向后兼容性问题。
希望明确强调此行为的类可以将它们的參數定义为仅位置的。
def __getitem__(self, index, /):
- 类似的情况发生在 setter 表示法中
# Given type(obj).__setitem__(obj, index, value): obj[1, value=3] = 5
这不会造成问题,因为值是自动传递的,Python 解释器将引发
TypeError: got multiple values for keyword argument 'value'
。 - 如果将子脚本 dunders 声明为使用位置或关键字参数,则在将参数传递给方法时可能会出现一些令人惊讶的情况。给定以下签名
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 可以选择对未使用仅关键字标记的子脚本方法发出警告。
- 正如我们所看到的,单个值后跟一个关键字参数不会更改为元组,即:
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_SUBSCR
、STORE_SUBSCR
和 DELETE_SUBSCR
来调用旧函数。我们建议使用 BINARY_SUBSCR_KW
、STORE_SUBSCR_KW
和 DELETE_SUBSCR_KW
来执行新操作。编译器必须生成这些新的操作码。旧的 C 实现将调用扩展方法,将 NULL
作为 kwargs 传递。
最后,必须将以下新的插槽添加到 PyMappingMethods
结构中
mp_subscript_kw
mp_ass_subscript_kw
这些插槽将具有适当的签名来处理包含关键字的字典对象。
“如何教授”建议
在反馈会议期间,一个请求是详细说明可能用于教授此功能的叙述,例如,面向学生、数据科学家和类似的受众。本节旨在满足这种需求。
我们将只从使用的角度来描述索引,而不是从实现的角度,因为这是上面提到的受众可能会遇到的方面。只有一部分用户需要实现自己的 dunder 函数,这可以被认为是高级用法。一个合适的解释可能是
索引操作通常用于通过索引引用较大数据集的子集。在常见情况下,索引由一个或多个数字、字符串、切片等组成。某些类型可能允许索引不仅使用索引,还可以使用命名值进行。这些命名值在方括号之间给出,使用与函数调用关键字参数相同的语法。名称的含义及其使用方式可以在类型的文档中找到,因为它在不同类型之间有所不同。
教师现在将展示一些实用的现实世界示例,解释所展示库中功能的语义。在撰写本文时,这些示例显然还不存在,但最有可能实现该功能的库是 pandas 和 numpy,可能是作为按名称引用列的一种方法。
参考实现
一个参考实现目前正在这里开发 [6].
变通方法
每个改变 Python 语言的 PEP 都应该 “清楚地解释为什么现有的语言规范不足以解决 PEP 解决的问题”.
已经可以实现一些与所提议扩展的粗略等效项,我们称之为变通方法。变通方法提供了一种替代启用新语法的方案,同时将语义留待他处定义。
这些变通方法如下。在其中,帮助程序 H
和 P
不打算成为通用的。例如,模块或包可能需要使用其自己的帮助程序。
- 用户定义的类可以被赋予
getitem
和delitem
方法,它们分别获取和删除存储在容器中的值>>> 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
- 一个帮助程序类,这里称为
H
,可以用来交换容器和参数的角色。换句话说,我们使用H(1, 2, a=3, b=4)[x]
作为
x[1, 2, a=3, b=4]
的替代方法。此方法适用于
getitem
、delitem
以及setitem
。这是因为>>> H(1, 2, a=3, b=4)[x] = val
是有效的语法,可以赋予适当的语义。
- 一个帮助程序函数,这里称为
P
,可以用来将参数存储在一个对象中。例如>>> x[P(1, 2, a=3, b=4)] = val
是有效的语法,可以赋予适当的语义。
- 对于切片,
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 中提出的所有选项的异议。需要说的是,它们已经被讨论过,每个提出的替代方案都存在一个或多个不可接受的因素。
添加新的 dunder
有人建议引入新的 dunder __(get|set|del)item_ex__
,如果存在,这些 dunder 将在 __(get|set|del)item__
三元组之上调用。
围绕此选择的理由是使关于如何向方括号添加 kwd 参数支持的直觉更加明显,并且与函数行为保持一致。鉴于
def __getitem_ex__(self, x, y): ...
这些都只是工作并且毫不费力地产生相同的结果
obj[1, 2]
obj[1, y=2]
obj[y=2, x=1]
换句话说,这个解决方案将统一 __getitem__
的行为到传统的函数签名,但是由于我们无法改变 __getitem__
并破坏向后兼容性,所以我们将有一个扩展版本,它优先使用。
发现此方法存在以下问题:
- 它会减慢下标操作的速度。对于每一次下标访问,这个新的 dunder 属性都会在类上进行调查,如果它不存在,那么就会执行默认的键转换函数。已经提出了不同的想法来处理这个问题,从仅在类实例化时包装方法,到添加一个位标志来指示这些方法的可用性。无论解决方案如何,新的 dunder 只有在类创建时添加才会有效,而不是在之后添加。这将是不寻常的,并且会阻止(并且以意外的方式运行)对方法的猴子补丁,无论出于何种原因可能需要这些补丁。
- 它增加了机制的复杂性。
- 将需要一个漫长而痛苦的过渡期,在此期间库将不得不以某种方式支持这两种调用约定,因为很可能,扩展方法将在参数匹配正确条件时委托给传统方法,或者某些类将支持传统 dunder 而另一些类将支持扩展 dunder。虽然这不会影响调用代码,但会影响开发。
- 它可能会导致混合情况,其中为 getter 定义了扩展版本,但为 setter 未定义。
- 在
__setitem_ex__
签名中,值必须是第一个元素,因为索引的长度是任意的,取决于指定的索引。这看起来很奇怪,因为可视化表示不匹配签名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 函数,使其始终接受任意关键字参数,无论它们是否有意义。我们希望开发人员能够指定哪些参数有意义,哪些参数没有意义。
使用单个位来改变行为
一个特殊的类 dunder 标志
__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)
此选项已被拒绝,因为它感觉奇怪,因为方法的签名取决于另一个 dunder 的特定值。这对于静态类型检查器和人类来说都会令人困惑:静态类型检查器必须为此硬编码一个特殊情况,因为在 Python 中没有其他地方,dunder 的签名依赖于另一个 dunder 的值。必须实现 __getitem__
dunder 的人类必须查看类(或其任何子类)中是否存在 __keyfn__
,然后才能编写 dunder。此外,添加具有设置了 __keyfn__
标志的基类会破坏当前方法的签名。如果在运行时更改标志,或者如果标志是通过调用返回随机 True 或其他内容的函数生成的,则这将更加成问题。
允许空索引符号 obj[]
当前提案阻止 obj[]
成为有效的表示法。但是,一位评论者表示
我们有Tuple[int, int]
作为两个整数的元组。我们有Tuple[int]
作为单个整数的元组。有时我们需要拼写一个 *没有* 值的元组,因为这是()
的类型。但是我们目前被迫将其写成Tuple[()]
。如果我们允许Tuple[]
,那么奇怪的边缘情况将被消除。所以,只要字典类型可以拒绝它,我可能就可以接受从语法上允许
obj[]
。
此提案已经确定,在没有给出位置索引的情况下,传递的值必须是空元组。允许使用空索引表示法会使字典类型自动接受它,以使用空元组作为键插入或引用值。此外,诸如 Tuple[]
之类的类型表示法可以很容易地写成 Tuple
,而无需使用索引表示法。
但是,在与 Brandt Bucher 的实现过程中的后续讨论中,发现情况 obj[]
将符合可变泛型的自然演变,为上述评论提供了更多力量。最后,在 D’Aprano、Bucher 和作者之间的讨论之后,我们决定暂时将 obj[]
表示法保留为语法错误,并可能通过另一个 PEP 来扩展表示法,以保持等效性 obj[]
与 obj[()]
相同。
没有给定位置索引的哨兵值
关于在以下情况下传递哪个值作为索引的主题
obj[k=3]
已经被广泛讨论。
一个看似合理的选项是通过使用关键字仅参数功能完全不传递任何值,但不幸的是,它不能很好地与 __setitem__
dunder 一起使用,因为总是传递值的按位置元素,我们不能“跳过”索引 1,除非我们引入一种非常奇怪的行为,其中第一个参数在指定时引用索引,而在未指定索引时引用值。这非常具有欺骗性且容易出错。
以上考虑因素使得无法拥有关键字仅 dunder,并引发了当未传递索引时要传递哪个实体的问题。
obj[k=3] = 5
# would call type(obj).__setitem__(obj, ???, 5, k=3)
一个提议的黑客方法是让用户通过为 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
。如果用户在所有实际目的上都只是按位置传递,则用户不应该担心这两个参数的实际局部名称。不幸的是,使用按位置传递的值将确保不会发生这种情况,但它仍然不能解决即使在未提供索引的情况下也需要传递 index
和 value
的问题。关键是用户不应该被阻止使用关键字参数引用列 index
、value
(或 self
),仅仅因为类实现者碰巧在参数列表中使用了这些名称。
此外,我们还要求三个 dunder 以相同的方式运行:如果只有 __setitem__
接收到这个哨兵,而 __get|delitem__
没有,因为它们可以逃避允许不指定索引的签名,从而允许用户指定的默认索引,这将非常不方便。
无论哨兵的选择是什么,它都会使以下情况退化,因此无法在 dunder 中区分
obj[k=3]
obj[SENTINEL, k=3]
现在问题变为哪个实体应该代表哨兵:选项是
- 空元组
- None
- NotImplemented
- 一个新的哨兵对象(例如 NoIndex)
对于选项 1,调用将变为
type(obj).__getitem__(obj, (), k=3)
因此使得 obj[k=3]
和 obj[(), k=3]
退化且无法区分。
这个选项听起来很有吸引力,因为
- 询问了 NumPy 社区 [5],并且对回复的一般共识是空元组感觉很合适。
- 它显示了与函数中
*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)
常见反对意见
- 只使用方法调用。
用例之一是类型,其中索引仅用于排他性使用,而函数调用不在考虑范围内。此外,函数调用不处理切片符号,而切片符号在某些情况下经常用于数组。
一个问题是类型提示创建已在 Python 3.9 中扩展到内置函数,因此您不再需要导入 Dict、List 等。
如果
[]
内部没有 kwdargs,则您将无法执行此操作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