为什么这些列表方法(append,sort,extend,remove,clear,reverse)返回的是None而不是结果列表呢?

35
我注意到许多对列表进行修改的操作会返回None,而不是返回列表本身。例如:
>>> mylist = ['a', 'b', 'c']
>>> empty = mylist.clear()
>>> restored = mylist.extend(range(3))
>>> backwards = mylist.reverse()
>>> with_four = mylist.append(4)
>>> in_order = mylist.sort()
>>> without_one = mylist.remove(1)
>>> mylist
[0, 2, 4]
>>> [empty, restored, backwards, with_four, in_order, without_one]
[None, None, None, None, None, None]

这个决定背后的思考过程是什么?
对我来说,这似乎是在阻碍,因为它阻止了列表处理的“链接”(例如mylist.reverse().append('a string')[:someLimit])。我想可能是因为“权力机构”认为列表推导是一个更好的范式(一个有效的观点),所以不想鼓励其他方法 - 但是阻止一种直观的方法似乎有些荒谬,即使存在更好的替代方案。

这个问题特指Python在设计上决定对于像.append这样的列表修改方法返回None。然而,初学者常常编写错误的代码,期望.append(特别是)返回刚刚修改的同一个列表。但请将这类问题标记为重复问题,并关闭它们。在这些情况下,"代码执行结果为None而不是列表"是一个应该通过调试独立发现的问题。创建一个合适的MRE会留下类似这个问题的疑问,因此可以认为它是一个重复问题。

请参阅如何收集重复计算的结果到一个列表、字典等(或者复制一个每个元素都被修改的列表)?来解答关于"如何重复向列表追加元素"的简单问题(或者归结为该问题的调试问题)。这是一个专门为初学者准备的新规范问题。

要获取列表的修改版本,请参考:

同样的问题也适用于其他内置数据类型的一些方法,例如set.discard(参见如何使用列表推导从列表中的集合中删除特定元素)和dict.update(参见为什么python的dict.update()不返回对象?)。
同样的道理也适用于设计自己的API。请参阅使原地操作返回对象是一个坏主意吗?

可能是为什么Python的list.append会返回false?的重复问题。 - lvc
@KarlKnechtel,我怀疑"此问题是一个适当的关闭目标..."应该留作评论或元数据,而不是编辑后的问题的一部分?无论如何 - 你能详细说明为什么你认为这个问题应该关闭吗? 我已经创建了一个MRE,我知道行为是什么以及如何解决 - 我询问的是导致该行为在语言中实现的设计理念,而不是关于“如何实现我想要的目标”,我认为其他人也会发现这种哲学很有趣。 - scubbo
不;我的意思是,关闭其他问题(具体来说,是前面所描述的那种问题)作为这个问题的重复问题是合适的。如果我认为这个问题应该被关闭,我会投票来关闭它。 - Karl Knechtel
啊,明白了 - 谢谢!我误解了“闭包目标”的含义。 - scubbo
我尝试改进措辞,因为它应该对每个人都尽可能清晰明了。 - Karl Knechtel
6个回答

43
Python的一般设计原则是,对于会就地修改对象的函数,应该返回None。我不确定这是否是我会选择的设计方案,但基本上是为了强调没有返回新对象。
Guido van Rossum(我们的Python BDFL)在Python-Dev邮件列表上表示了这种设计选择。

I'd like to explain once more why I'm so adamant that sort() shouldn't return 'self'.

This comes from a coding style (popular in various other languages, I believe especially Lisp revels in it) where a series of side effects on a single object can be chained like this:

x.compress().chop(y).sort(z)

which would be the same as

x.compress()
x.chop(y)
x.sort(z)

I find the chaining form a threat to readability; it requires that the reader must be intimately familiar with each of the methods. The second form makes it clear that each of these calls acts on the same object, and so even if you don't know the class and its methods very well, you can understand that the second and third call are applied to x (and that all calls are made for their side-effects), and not to something else.

I'd like to reserve chaining for operations that return new values, like string processing operations:

y = x.rstrip("\n").split(":").lower()

There are a few standard library modules that encourage chaining of side-effect calls (pstat comes to mind). There shouldn't be any new ones; pstat slipped through my filter when it was weak.


2
“当我的筛选器变弱时,pstat溜过了它。” 我很喜欢它。pstats 直到今天仍然是那个逃脱的家伙:stats.strip_dirs().add() => <pstats.Stats object at 0x0000018866977640> - Nathaniel Jones
回到这篇文章十年后(感谢@Karl Knechtel的出色编辑!),Guido对那种编程范式的回应是合理的。然而,我在这段时间内爱上了FP,我觉得这是一个伪命题——如果没有方法在原地改变对象,而总是返回一个新对象,那么可以用以下方式表达被链接的代码:x1 = x.compress(); x2=x1.chop(y); x3 = x2.sort(z)——我认为这样更直观地替换了链式调用。话虽如此——Python不是FP(大多数情况下?故意的?),所以这种范式并不适用。 - scubbo
或者换句话说,“Python中的一般设计原则是对于在原地改变对象的函数返回None”,这是一个正确的陈述,但并不能解释为什么这些操作会在原地进行修改。 - scubbo

18

我不能代表开发者发言,但我认为这种行为非常直观。

如果一个方法在原始对象上起作用并直接修改它,则不返回任何内容,因为没有新信息——显然,您已经有了对(现在已变异的)对象的引用,所以为什么要再次返回它呢?

如果,但是,一个方法或函数创建一个新对象,那么它当然必须返回它。

因此,l.reverse() 不返回任何东西(因为列表已经被反转,但标识符 l 仍然指向该列表),但 reversed(l) 必须返回新生成的列表,因为 l 仍然指向旧的,未修改的列表。

编辑:我刚从另一个答案中了解到,这个原则被称为命令-查询分离(Command-Query separation)


2
这显然是一种我需要培养的心态,因为我的第一反应是“为什么不呢?”。在我看来,返回一个引用允许不同的技术(“链式”),而不会阻止任何其他技术(顺序修改),因此只有当链接本身是“不好”的时候,它才会更糟。然而,在其他评论中提出的论点(甚至由 Guido 本人提出!)断言,链接确实是不好的。感谢您的意见!我真的很享受从懂行的人那里学习! - scubbo
2
@scubbo:Python之禅(import this)指出:“做一件事情应该有且最好只有一种明显的方法。” 这有助于回答“为什么不呢?”尽管我必须承认,在Python的多个领域中,“有多种方式可以做到它”似乎是指导原则 :) - Tim Pietzcker
1
我希望我的过去自己能够想到包含“Guido的评论”的链接 :( 另外,Tim,你第一个链接到“另一个答案”的链接也链接到了维基百科页面(虽然我怀疑你现在也缺乏上下文来纠正它) - scubbo
另一种选择是“流畅”的表示法(调用链接),例如PHP、Scala、R tidyverse,有时也在JS、Java、C++中实现。 “我觉得这个行为非常直观”不是一个理由,而是循环论证;如果您已经学会了流畅范例,您就不会这样认为。(无论流畅性是否更好/更糟,如可维护性、多线程等都是另一个非常活跃的辩论话题。) - smci

3

有人可能会认为函数签名本身就清楚地表明该函数改变了列表而不是返回一个新的列表:如果该函数返回一个列表,那么它的行为将会更加不明显。


那时候类型提示(因此,在IDE中查看签名定义)是否被广泛使用?如果没有,我认为期望程序员经常考虑方法的签名是不合理的,如果他们无法通过鼠标悬停/快捷键观察到它-我认为他们只会假设签名符合他们的期望。或者在没有类型提示的情况下,是否可以在IDE中显示签名定义?我只是在类型提示流行之后才开始使用IDE,所以我从未进行过测试。 - scubbo
typing 模块最初是在 2015 年发布的 3.5 版本中添加的。IDE 必须从其自己的类型信息数据库开始,例如内置函数等。我认为这个论点很简单:“知道函数返回 None 是提醒它改变了对象”;但我认为逻辑更可能是相反的。除此之外,名称应该被选择为使显而易见哪些方法是命令,哪些是查询(根据其他地方引用的“命令-查询分离”模型)。 - Karl Knechtel

2

如果您在寻求修复代码方面的帮助后被发送到这里:

以后,请尝试自己查找代码中的问题,通过仔细研究代码运行时发生的情况。与其因为出现错误消息而放弃,不如检查每个计算的结果,并查看代码从您预期的方式开始工作的位置。

如果您的代码调用了像.append.sort这样的方法,则会注意到返回值None,而列表在原地进行了修改。仔细研究以下示例:

>>> x = ['e', 'x', 'a', 'm', 'p', 'l', 'e']
>>> y = x.sort()
>>> print(y)
None
>>> print(x)
['a', 'e', 'e', 'l', 'm', 'p', 'x']

y得到了特殊的None值,因为这就是返回的值。x改变了,因为排序是在原地进行的。

这是有意为之的,这样像x.sort().reverse()这样的代码就会出错。请参阅其他答案以了解Python开发人员希望它以这种方式运作的原因。

解决问题

首先,仔细考虑代码的意图。x应该改变吗?我们实际上需要一个单独的y吗?

让我们先考虑.sort。如果x应该改变,则调用x.sort()本身,而不将结果分配给任何位置。

如果需要一个已排序的副本,使用y = x.sorted()。有关详细信息,请参见如何获取列表的已排序副本?

对于其他方法,我们可以像这样获得修改后的副本:

.clear -> 没有必要这样做;“清除的副本”只是一个空列表。只需使用y = []即可。

.append.extend -> 可能最简单的方法是使用 + 运算符。要从列表 l 添加多个元素,请使用 y = x + l 而不是 .extend。要添加单个元素 e首先将其包装在列表中y = x + [e]。在3.5及以上版本中另一种方法是使用拆包:y = [*x, *l] 用于 .extendy = [*x, e] 用于 .append。有关 .append 的更多信息,请参见 如何让列表 append() 方法返回新列表;有关 .extend 的更多信息,请参见 如何在Python中连接两个列表?

.reverse -> 首先,需要考虑是否需要实际的副本。内置函数reversed会给你一个可以用于反向循环元素的迭代器。要创建实际的副本,只需将该迭代器传递给listy = list(reversed(x))。有关详细信息,请参见如何获取列表的反向副本(在.reverse之后链接方法时避免单独语句)

.remove -> 找出将被删除元素的索引(使用.index),然后使用切片找到该点之前和之后的元素,并将它们组合起来。作为一个函数:

def without(a_list, value):
    index = a_list.index(value)
    return a_list[:index] + a_list[index+1:]

我们可以类似地翻译.pop以创建一个修改后的副本,尽管当然.pop实际上返回列表中的一个元素。

另请参见在Python中快速返回不包含特定元素的列表的方法

(如果您计划删除多个元素,请强烈考虑使用列表推导式(或filter)代替。它会比任何必须对其进行迭代以删除列表中项目的解决方案更简单。这种方式自然而然地提供了一个修改后的副本。)


当然,对于上述任何一种方法,我们也可以通过明确地复制并在副本上使用原地方法来制作修改后的副本。最优雅的方法将取决于上下文和个人喜好。


-1

众所周知,在Python中,list是一个可变对象,而可变对象的特点之一就是可以修改该对象的状态,而无需将其新状态分配给变量。我们应该更多地展示这个主题,以了解这个问题的根源。

内部状态可以改变的对象被称为可变对象。另一方面,不可变对象在创建后不允许对对象进行任何更改。对象的可变性是使Python成为动态类型语言的特征之一。

Python中的每个对象都有三个属性:

  • 标识符 - 这指的是计算机内存中对象引用的地址。
  • 类型 - 这指的是创建的对象类型。例如 integer, list, string 等。
  • 值 - 这指的是对象存储的值。例如 str = "a"

虽然ID类型一旦创建就不能更改,但可变对象的值可以更改。

让我们逐步讨论下面的代码,以说明它在Python中的含义:

创建一个包含城市名称的列表

cities = ['London', 'New York', 'Chicago']

以十六进制格式打印内存地址中创建的对象的位置

print(hex(id(cities)))

Output [1]: 0x1691d7de8c8

将一个新城市添加到城市列表中。
cities.append('Delhi')

按逗号分隔打印列表cities中的元素

for city in cities:
    print(city, end=', ')

Output [2]: London, New York, Chicago, Delhi

以十六进制格式打印内存地址中创建的对象的位置

print(hex(id(cities)))

Output [3]: 0x1691d7de8c8

上述示例向我们展示了,我们能够通过向对象cities添加另一个城市'Delhi'来改变其内部状态,然而,对象的内存地址并没有改变。这证实了我们没有创建新的对象,而是更改或突变了同一对象。因此,我们可以说,对象作为具有引用变量名称cities的列表类型是一个可变对象
虽然不可变对象的内部状态不能被更改。例如,考虑以下代码和相关的错误信息,尝试更改元组在索引0处的值。
创建一个名为foo的元组。
foo = (1, 2)

将索引0的值从1更改为3

foo[0] = 3
    
TypeError: 'tuple' object does not support item assignment 

从这些例子中,我们可以得出结论:可变对象在执行操作时不应该返回任何东西,因为它直接修改了对象的内部状态,并且返回新的修改后的对象没有意义。与之不同的是,不可变对象在执行操作后应该返回修改后状态的新对象。

-2

首先,我必须说明的是,我所建议的无疑是一种糟糕的编程实践,但如果你想在lambda函数中使用append函数而且你不关心代码的可读性,那么就有方法可以做到。

想象一下,你有一个列表嵌套的列表,并且你想要使用 map lambda 将元素附加到每个内部列表中。以下是如何完成此操作:

my_list = [[1, 2, 3, 4],
           [3, 2, 1],
           [1, 1, 1]]
my_new_element = 10
new_list = list(map(lambda x: [x.append(my_new_element), x][1], my_list))

print(new_list)

工作原理:

lambda想要计算输出时,首先应该计算[x.append(my_new_element), x]表达式。为了计算这个表达式,将运行append函数,并且表达式的结果将是[None, x],通过指定您想要列表的第二个元素,[None,x][1]的结果将是x

使用自定义函数更易读且更好的选择:

def append_my_list(input_list, new_element):
    input_list.append(new_element)
    return input_list


my_list = [[1, 2, 3, 4],
           [3, 2, 1],
           [1, 1, 1]]
my_new_element = 10
new_list = list(map(lambda x: append_my_list(x, my_new_element), my_list))

print(new_list)

这个问题涉及到一般的可变方法,不仅限于.append;并且有单独的相关问题可以回答关于如何解决每种方法的问题。例如,如果你特别想解释.append方法,那么像这样的回答反而适用于如何让列表的append()方法返回新列表; 但我怀疑这种技术是否比现有答案更有意义。 - Karl Knechtel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接