不要使用可变对象作为函数默认参数或者作为字典的键
假设我们正在写一段关于聊天室的代码,把聊天室作为一个类,在初始化时将聊天室中存在的用户传入进去,代码如下:
python
class ListChatRoom:
def __init__(self, users: list[str] = []):
self.users = users
def new_user(self, user: str):
self.users.append(user)
# 新建一个聊天室
# 全是奥特曼的聊天室
ultraman_chatroom = ListChatRoom()
# 杰克奥特曼加入聊天
ultraman_chatroom.new_user("Jack")
# 泰罗加入聊天
ultraman_chatroom.new_user("Taro")
# 新建怪兽聊天室
monster_chatroom = ListChatRoom()
# 哥斯拉加入聊天
monster_chatroom.new_user("Gojira")
如果以上代码你没有发现任何问题的话,很有可能你已经掉进了坑里!
我们看看以上代码中,monster_chatroom 和 ultraman_chatroom 都有哪些用户加入了聊天:
python
>>> print(ultraman_chatroom.users)
['Jack', 'Taro', 'Gojira']
>>> print(monster_chatroom.users)
['Jack', 'Taro', 'Gojira']
此时哥斯拉缓缓打出一个问号。。。
可以看到以上代码,不管是 ultraman_chatroom 还是 monster_chatroom, 里面的user都是相同的。 这说明,monster_chatroom.users 和 ultraman_chatroom.users 很有可能是同一个列表。
我们可以用python中id来验证两个对象是否为同一个对象:
python
>>> id(monster_chatroom.users)
1560336798080
>>> id(ultraman_chatroom.users)
1560336798080
破案了,两个列表的id确实相同,不同的Chatroom实例使用了相同的列表作为初始默认用户列表。
这是因为上述Chatroom这个类的 init 函数只会在定义时创建一个 user 列表,在之后的新建的Chatroom实例中,除非特别指定users的值,这些实例都会使用 users 默认列表的引用,而不是将这个列表复制一份。在大多数情况下,这并不是我们所预期的。
那么如何修改这个Chatroom类,让它按照我们所设想的那样工作呢?
python
from typing import Optional
class ListChatRoom:
def __init__(self, users: Optional[list[str]] = None):
if users is None:
users = []
self.users = users
def new_user(self, user: str):
self.users.append(user)
# 新建一个聊天室
# 全是奥特曼的聊天室
ultraman_chatroom = ListChatRoom()
# 杰克奥特曼加入聊天
ultraman_chatroom.new_user("Jack")
# 泰罗加入聊天
ultraman_chatroom.new_user("Taro")
# 新建怪兽聊天室
monster_chatroom = ListChatRoom()
# 哥斯拉加入聊天
monster_chatroom.new_user("Gojira")
此时,我们再查看ultraman_chatroom.users 和 monster_chatroom.users:
python
>>> print(ultraman_chatroom.users)
['Jack', 'Taro']
>>> print(monster_chatroom.users)
['Gojira']
哥斯拉终于跳出了奥特曼的包围圈。
因为再 user 为 None 时, 我们在 init 内部新建了一个列表,所以在不同的chatroom实例中,使用的users列表不会再指向同一个列表:
python
>>> id(monster_chatroom.users)
1838252693696
>>> id(ultraman_chatroom.users)
1838252691840
通过id可以看出,两个users列表并不指向同一个列表。
如果我们使用元组(tuple)来作为默认参数,也需要这么设置默认参数吗? 首先我们尝试使用第一种方法来给Chatroom设置默认参数。
python
class TupleChatRoom:
def __init__(self, users: tuple = ()):
self.users = users
def new_user(self, user: str):
self.users += (user, )
# 新建一个聊天室
# 全是奥特曼的聊天室
ultraman_chatroom = TupleChatRoom()
# 杰克奥特曼加入聊天
ultraman_chatroom.new_user("Jack")
# 泰罗加入聊天
ultraman_chatroom.new_user("Taro")
# 新建怪兽聊天室
monster_chatroom = TupleChatRoom()
# 哥斯拉加入聊天
monster_chatroom.new_user("Gojira")
python
>>> print(ultraman_chatroom.users)
('Jack', 'Taro')
>>> print(monster_chatroom.users)
('Gojira',)
ultraman_chatroom.users 的值与 monster_chatroom.users 的值并不相同!这是怎么回事?在作为函数默认参数时,python居然区别对待列表和元组两种类型!
虽然在 TupleChatRoom 中使用了 self.users += (user, ) 的方式添加新用户,但这并不是 TupleChatRoom 的实例不会共享同一元组的原因。如果在 ListChatRoom 中使用 self.users += [user, ] 来添加新用户, ListChatRoom 的实例仍然会共享同一个列表。
可变类型与不可变类型 (mutable and immutable types)
不可变性导致创建新对象
python 区别对待列表和元组的根本原因是:元组是不可变对象,列表是可变对象。
可变对象的值可以在创建之后被改变,而不可变对象的值,在创建之后不能被改变。
比如,对列表追加一个元素:
python
original_users = ["Tom"]
new_users = original_users
assert original_users is new_users
new_users += ["Jerry"]
assert new_users is original_users
new_users += ["Jack"]
assert new_users is original_users
然而,我们无法直接对一个元组追加一个元素,我们只能创建一个新的元组,并将原来元组的值复制过去,并新增加一个元素。
python
original_users = ("Tom",)
new_users = original_users
assert new_users is original_users
new_users += ("Jerry", )
assert new_users is not original_users
在 new_users += ("Jerry", ) 这一行,创建了一个新的元素: ("Tom", "Jerry") 并将这个元组赋值给了 new_users 这个变量.
现在我们可以推断出,不可变对象有一个重要的特性: 即在改变这个不可变对象时,python会创建一份新的不可变对象,并将改变后的值复制给变量。
常见的可变对象类型:
list, dict, set
常见的不可变类型:
tuple, frozenset, str, MappingProxyType, int, float, bool, None
用户自定义类型,默认为可变类型,但是可以通过设计获得不可变类型的性质。
所以ListChatRoom会共享同一个列表,而TupleChatRoom不会共享同一个列表的问题也迎刃而解了,即: 对于TupleChatRoom来说,在new_user中 self.users += (user, ) 这一行,创建了一个新的元组,并将元组赋值给了self.users,而对于ListChatRoom来说,在 self.users.append(user) 和 self.users += [user] 这两段代码都不会创建新的列表,只会在原来的列表中进行修改。
所以如果你在函数签名中,将可变对象设置为默认参数,之后在函数中的所有对这个参数的操作都是在对同一个对象进行操作。这在大多数情况下并不符合预期,并且很可能会导致内存泄漏。如果我们想要一个可变对象作为默认参数,应该使用None:
python
class ListChatRoom:
def __init__(self, users: Optional[list[str]] = None):
if users is None:
users = []
self.users = users
同样的, str 作为不可变类型,在对一个str实例进行修改时,不会在原来的字符串上修改,而是创建一个新的字符串对象:
python
>>> title = "dont use mutable types"
>>> id(title)
1525170857136
>>> title += " for default arguments"
>>> id(title)
1525168013104
在不可变容器中修改可变容器 (change mutable containers in immutable containers)
在上述代码中使用 += 拼接字符串后,字符串的id完全改变了,也就说明,+=拼接这个操作会新创建一个字符串。
那么假如在一个包含列表的元组中,可以直接访问元组的索引在获取到这个列表并在列表中添加元素吗?
用代码来表示:
python
tuple_with_list = ([], )
tuple_with_list[0].append(1)
这段代码会报错吗?
答案是:不会报错!而且tuple_with_list中的列表也被成功修改了:
python
>>> tuple_with_list[0]
[1]
由此可见,可变类型与不可变类型之间的界限时相当微妙的,python允许通过访问不可变容器来修改其中的可变容器。
那么以下代码会报错吗?
python
tuple_with_list = ([], )
tuple_with_list[0].extend([10, 20])
有上面的那个例子作为铺垫,很容易理解这段是不会报错的。
那么这段代码会报错吗?
python
tuple_with_list = ([], )
tuple_with_list[0] += [10, 20]
答案是:会报错的!但是仍然会成功修改列表。
python
>>> tuple_with_list = ([], )
>>> tuple_with_list[0] += [10, 20]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> tuple_with_list[0]
[10, 20]
这似乎让人迷惑,但秘密藏在 tuple_with_list[0] += [10, 20] 这行代码中,本质上,这行代码相当于:
python
# tuple_with_list[0] += [10, 20]
lst = tuple_with_list[0]
lst += [10, 20]
tuple_with_list[0] = lst # raise TypeError
tuple_with_list[0] += [10, 20] 这一行代码相当于以上三行代码,在回写回tuple的时候报错了。
如果想进一步分析python解释器的行为可以通过dis模块查看python代码的字节码,这里就不再展开分析了。
可变不可变类型与哈希 (mutable and immutable types and hash)
只有一个对象能够被哈希,且能够判断自身是否与其他对象相等时,这个对象才能作为键(key),被存储进字典里。
用代码来解释便是:
可哈希实例:
python
class HashableAndHasEq:
def __hash__(self):
return hash(self)
def __eq__(self, other):
return self == other
dictionary = {}
can_be_used_as_key = HashableAndHasEq()
dictionary[can_be_used_as_key] = 1 # OK
不可哈希:
python
class NotHashable:
__hash__ = None
not_hashable_obj = NotHashable()
dictionary = {}
dictionary[not_hashable_obj] = 1 # raise TypeError
arduino
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'NotHashable'
没有__eq__方法:
python
class NoEqualMethod:
__eq__ = None
no_eq_obj = NoEqualMethod()
dictionary = {}
dictionary[no_eq_obj] = 1 # raise TypeError
arduino
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'NoEqualMethod'
可见,没有__hash__或者没有__eq__都是不能作为字典的键的。
所有python内置的可变对象都是不可哈希的。是的 list, set, dict 都不能作为字典的键。
如:
python
>>> a = {}
>>> a[list()] = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> a[set()] = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'set'
>>> a[dict()] = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
这是因为如果强行给这些list, set, dict类型添加哈希方法,这些可变类型的实例在被修改后,会导致__hash__方法的值发生变化,从而无法通过这些实例获取到字典中对应的值。
对于python中不可变容器如: frozenset, tuple, MappingProxyType 只有在里面的元素都可以作为字典的键的时候,则该容器可以作为字典的键。
用代码表示:
python
>>> can_be_used_as_key = ((1,2,3), ) # 元组中包含一个(1,2,3)元组
>>> cannot_be_used_as_key = ([1,2,3], ) # 元组中包含[1,2,3]列表
>>> dictionary = {}
>>> dictionary[can_be_used_as_key] = 1
{((1, 2, 3),): 1}
>>> dictionary[cannot_be_used_as_key] = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
在上述代码中 can_be_used_as_key 包含了一个 (1,2,3)元组,该(1,2,3)元组是可以作为字典的键的,所以can_be_used_as_key也可以作为字典的键。
但是在 can_be_used_as_key 中,包含了一个 [1,2,3]列表,列表不能作为字典的键,所以can_be_used_as_key也不能作为字典的键了。
frozenset 这个类型比较有意思,因为只有能作为字典键的元素才会被添加到frozenset容器中,所以frozenset的实例永远可以作为字典的键。
MappingProxyType在元素为可变字典的时候,不能作为字典的键,但是里面的元素为不可变的映射(Immutable Mapping)时(如第三方库 immutables 中的 Map类型),是可以作为字典键的。