Python 字符串为什么不可变:从 replace 到变量重绑定
副标题: replace 看起来修改了字符串,实际发生的是创建结果并让变量改指向它。理解这一区别,才能看懂字符串方法、别名行为和循环拼接的成本。
适读人群: 刚开始学习 Python 字符串、变量与对象关系的读者
阅读时间: 6 分钟
从一段括号消除代码说起
下面的函数反复删除成对的括号,直到字符串不再变化:
def is_valid_by_elimination(s: str) -> bool:
while True:
previous = s
s = s.replace("()", "").replace("[]", "").replace("{}", "")
if len(s) == len(previous):
return s == ""
这里容易产生一个疑问:
previous = s
s = s.replace("()", "")
既然 previous 和 s 原来指向同一个字符串,第二行为什么不会同时改变
previous?
答案是:Python 字符串是不可变对象。replace 没有修改原字符串,而是计算出一个
结果,随后赋值语句让变量 s 改为指向这个结果。previous 仍然指向原字符串。
“不可变”到底是什么意思
字符串不可变,指的是:
一个
str对象创建以后,它所表示的字符序列不能被原地修改。
例如:
text = "cat"
text[0] = "b"
Python 会抛出异常:
TypeError: 'str' object does not support item assignment
不能把已有字符串中的 c 原地改成 b。如果需要 "bat",必须得到另一个字符串值:
text = "cat"
changed = "b" + text[1:]
assert text == "cat"
assert changed == "bat"
原来的 text 没有发生变化。
对象不变,不代表变量不能重新赋值
变量和对象需要分开理解:
变量:保存对对象的引用,可以重新指向别的对象
对象:真正的数据;字符串对象自身不能被修改
看这个例子:
text = "hello"
text = text.upper()
assert text == "HELLO"
表面上看,text 从小写变成了大写。实际过程是:
1. text 指向字符串值 "hello"
2. text.upper() 计算出字符串值 "HELLO"
3. 赋值让 text 改为指向 "HELLO"
4. "hello" 本身从未被修改
因此,不可变限制的是字符串对象,不是变量名。
replace 为什么不会修改原字符串
str.replace 返回替换后的结果。调用它本身不会改变原字符串:
source = "red-green-red"
result = source.replace("red", "blue")
assert source == "red-green-red"
assert result == "blue-green-blue"
如果忽略返回值,看起来就会像是什么都没有发生:
text = "hello"
text.replace("h", "H")
assert text == "hello"
要保留替换结果,需要接收返回值:
text = text.replace("h", "H")
assert text == "Hello"
upper、lower、strip、切片和字符串拼接也遵循相同的值语义:它们不会原地改写
已有字符串。
回到 previous 和 s
把括号例子缩小:
s = "()[]"
previous = s
s = s.replace("()", "")
assert previous == "()[]"
assert s == "[]"
赋值 previous = s 没有复制字符串内容。开始时,两个变量都引用相同的字符串值:
previous ----+
+----> "()[]"
s -----------+
执行 s = s.replace("()", "") 后,只有 s 被重新赋值:
previous ----------> "()[]"
s -----------------> "[]"
所以后续可以比较新旧长度:
if len(s) == len(previous):
如果长度相同,说明这一轮没有删除任何配对括号,继续循环也不会产生新结果。
字符串与列表有什么不同
列表是可变对象。多个变量引用同一个列表时,通过任意一个变量原地修改列表,其他 变量都能看到变化:
letters = ["c", "a", "t"]
alias = letters
letters[0] = "b"
assert letters == ["b", "a", "t"]
assert alias == ["b", "a", "t"]
字符串不允许这种原地修改:
word = "cat"
alias = word
word = "b" + word[1:]
assert word == "bat"
assert alias == "cat"
区别不在于“赋值是否有效”,而在于操作是修改原对象,还是让变量指向另一个值。
+= 也不是原地修改字符串
下面的代码看起来像是在原字符串后追加内容:
text = "Py"
alias = text
text += "thon"
assert text == "Python"
assert alias == "Py"
对于字符串,+= 的效果仍然是计算拼接结果并重新绑定 text。它不会把 alias
所引用的字符串原地扩展。
这一点和列表不同:
items = [1, 2]
alias = items
items += [3]
assert items == [1, 2, 3]
assert alias == [1, 2, 3]
列表的 += 会原地扩展现有列表,因此别名也能看到变化。
不可变性带来的实际影响
1. 字符串可以安全地作为字典键
字典键需要稳定的哈希值。字符串内容不能在放入字典后突然改变,因此可以作为键:
counts = {"python": 3}
assert counts["python"] == 3
2. 多个变量共享字符串时不会互相修改
函数接收字符串后,不可能通过字符串操作原地改变调用方持有的那个值。函数只能返回 另一个字符串,由调用方决定是否接收。
3. 反复拼接可能产生额外成本
因为字符串不能原地增长,在循环中反复拼接较大的文本可能反复创建中间结果:
result = ""
for part in parts:
result += part
构建大量文本时,通常先收集片段,再一次合并:
result = "".join(parts)
这不仅表达得更直接,也避免依赖解释器对连续字符串拼接的特定优化。
常见误区
误区一:赋值改变了字符串对象
name = "Ada"
name = "Grace"
这里改变的是 name 的指向,不是把原来的 "Ada" 对象改写成 "Grace"。
误区二:字符串方法会自动保存结果
name.strip()
如果没有接收返回值,name 仍然指向原来的字符串。通常应该写成:
name = name.strip()
误区三:可以只靠 id 判断语义
Python 实现可能复用某些字符串对象,也可能让没有实际变化的操作返回原对象。因此,
不要把 id 是否变化当成字符串 API 的语义保证。真正稳定的规则是:任何字符串操作
都不能改变原字符串的字符内容。
完整可运行示例
def is_valid_by_elimination(s: str) -> bool:
while True:
previous = s
s = s.replace("()", "").replace("[]", "").replace("{}", "")
if len(s) == len(previous):
return s == ""
source = "red-green-red"
result = source.replace("red", "blue")
assert source == "red-green-red"
assert result == "blue-green-blue"
assert is_valid_by_elimination("{[()]}") is True
assert is_valid_by_elimination("([)]") is False
assert is_valid_by_elimination("") is True
小结
记住一句话即可:
Python 字符串对象不能被原地修改,但变量随时可以重新指向另一个字符串。
因此:
replace、upper、strip、切片和拼接产生结果,不修改原字符串;s = s.replace(...)改变的是变量s的指向;- 其他变量仍然可以保留对旧字符串的引用;
- 大量字符串片段通常使用
"".join(parts)合并。
理解“对象不可变”和“变量重新绑定”的区别后,括号消除代码中的 previous = s 就不再
神秘:previous 留住旧值,s 接收本轮计算后的值,两者可以安全地进行比较。