Python技术迷

又双叒叕踩坑了!还在用 Python 等号 (=)

面试题:又双叒叕踩坑了!还在用 Python 等号 (=)

rows = [[]] * 3
rows[0].append("A")
print(rows)

输出不是:

[['A'], [], []]

而是:

[['A'], ['A'], ['A']]

这题我第一次看到的时候也愣了一下。不是不会 Python,是这个 = 太容易让人按 Java、C++ 那套脑子去理解。

这地方我一般不先讲“变量”“对象”“引用”这些大词,先看内存味更重一点的东西。

a = []
b = a

print(id(a))
print(id(b))

b.append(100)

print(a)
print(b)

结果大概率长这样:

140475018952768
140475018952768
[100]
[100]

两个 id 一样,说明 a 和 b 根本不是两份列表。

b = a 这行,不是把列表复制了一份给 b,只是又贴了一个名字上去。

这就是 Python 里 = 最容易坑人的地方:它做的是绑定,不是复制。

我见过不少人面试时答得很顺:“Python 一切皆对象。”

但一到代码就写成这样:

base_tags = ["new"]
user_tags = base_tags

user_tags.append("vip")

然后线上一查,所有默认标签都带了 vip。

这种 bug 最烦的地方是它不报错。日志也干净。你查半天业务逻辑,最后发现是两个变量绑在同一个列表上。

我一般会先加一行日志,不看值,先看地址。

print("base_tags", id(base_tags), base_tags)
print("user_tags", id(user_tags), user_tags)

如果两个 id 一样,后面的业务先别看了,大概率就是别名问题。

正确写法看场景。

如果只是一层列表,直接拷贝一份就行:

base_tags = ["new"]
user_tags = base_tags.copy()

user_tags.append("vip")

print(base_tags)
print(user_tags)

输出:

['new']
['new', 'vip']

也可以这样写:

user_tags = list(base_tags)

或者:

user_tags = base_tags[:]

这几个在一层列表里问题不大。

但你别急着高兴,坑还没完。

看这个:

tpl = [["read"], ["write"]]
copied = tpl.copy()

copied[0].append("admin")

print(tpl)
print(copied)

输出:

[['read', 'admin'], ['write']]
[['read', 'admin'], ['write']]

这就不是 copy() 没生效,而是它只拷贝了外层。

外层列表确实是新的,但里面那两个小列表,还是同一批对象。

这种我一般叫“外壳换了,里面没换”。

想彻底拆开,用 deepcopy:

from copy import deepcopy

tpl = [["read"], ["write"]]
copied = deepcopy(tpl)

copied[0].append("admin")

print(tpl)
print(copied)

输出:

[['read'], ['write']]
[['read', 'admin'], ['write']]

不过 deepcopy 也别上来就用。

它不是银弹。对象层级深、数据量大时,性能会疼。尤其是接口里拿到一坨配置、订单明细、权限树,随手 deepcopy 一下,压测时 CPU 就会教你做人。

我的习惯是:能明确只改哪一层,就只复制哪一层。

比如只需要复制用户权限列表,不动角色模板:

role_template = {
"role": "operator",
"permissions": ["read", "export"]
}

user_role = {
"role": role_template["role"],
"permissions": role_template["permissions"].copy()
}

user_role["permissions"].append("delete")

print(role_template)
print(user_role)

这样比闭着眼 deepcopy(role_template) 更稳一点,也更能看出你知道自己在改什么。

还有一个面试里特别爱绕的点:不可变对象。

x = 10
y = x
y = y + 1

print(x)
print(y)

输出:

10
11

这时候很多人又说:“你看,这不就是复制了吗?”

不是。

x = 10 是让 x 绑定到整数对象 10。

y = x 是让 y 也绑定到 10。

y = y + 1 不是把原来的 10 改成 11,而是算出一个新对象 11,再让 y 绑定过去。

整数、字符串、元组这类不可变对象,表现上很像“复制”,但底层思路还是绑定。

真正危险的是可变对象:list、dict、set,还有你自己写的对象。

再看一个面试官很喜欢塞的小坑:

defadd_item(item, bucket=[]):
    bucket.append(item)
return bucket

print(add_item("A"))
print(add_item("B"))
print(add_item("C"))

输出:

['A']
['A', 'B']
['A', 'B', 'C']

这段代码我一看到默认参数是 [],基本就不用往下看了。

默认参数只在函数定义时创建一次。后面每次调用,用的都是同一个列表。

改法也简单:

defadd_item(item, bucket=None):
if bucket isNone:
        bucket = []

    bucket.append(item)
return bucket

这个写法不华丽,但线上安全。

再补一个更隐蔽的,批量初始化二维数组。

board = [[0] * 3] * 3
board[1][1] = 9

print(board)

输出:

[[0, 9, 0], [0, 9, 0], [0, 9, 0]]

原因还是同一个:里面三行其实是同一个列表。

要这么写:

board = [[0for _ in range(3)] for _ in range(3)]
board[1][1] = 9

print(board)

输出:

[[0, 0, 0], [0, 9, 0], [0, 0, 0]]

这里别嫌列表推导式麻烦,少写那几个字符,后面排查能把人看麻。

所以这道题真要面试回答,我不会只背一句“Python 的赋值是引用传递”。

这个说法还不够准。

更像这样:

= 只是把名字绑定到对象上。

如果对象是可变的,多个名字绑到同一个对象,任何一个名字改里面的内容,其他名字都能看到。

如果想要新对象,就显式拷贝。浅拷贝还是深拷贝,看你要不要连里面的对象一起拆开。

写 Python 最怕的不是语法不会。

是你以为自己复制了一份,实际上只是多贴了一个标签。这个坑不炸则已,一炸基本都在数据被改脏之后。