[Python]不要使用可变对象作为函数默认参数或者作为字典的键

不要使用可变对象作为函数默认参数或者作为字典的键

假设我们正在写一段关于聊天室的代码,把聊天室作为一个类,在初始化时将聊天室中存在的用户传入进去,代码如下:

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类型),是可以作为字典键的。

相关推荐
刚正的热带野猪8 分钟前
文件格式校验方案
java·后端
很咸的蜡笔8 分钟前
开源项目推荐|throttled-py - 支持多种策略及存储选项的 Python 限流库
python
南部余额10 分钟前
playwright解决重复登录问题,通过pytest夹具自动读取storage_state用户状态信息
前端·爬虫·python·ui·pytest·pylawright
Anthony_492612 分钟前
深入理解MySQL事务:从版本链到MVCC的全面解析
数据库·后端·mysql
Postkarte不想说话14 分钟前
JSON序列化与反序列化-----使用JSON for Modern C++库
后端
五行星辰16 分钟前
SpringBoot集成Log4j2终极指南:从基础配置到性能调优
java·后端
Fw9965332 分钟前
Spring如何解决获取到不完整Bean的问题
后端
Aska_Lv1 小时前
springboot-tomcat 线程处理web接口解读
后端
苏三说技术1 小时前
千万级大表的优化技巧
后端
天上掉下来个程小白1 小时前
Redis-06.Redis常用命令-列表操作命令
java·redis·后端·springboot·苍穹外卖