连续几篇文章都在写 Python 字符串,这出乎我的意料了。但是,有的问题,不写不行,特别是那种灵机一动想到的问题,最后你发现,很多人根本不懂却又误以为自己懂了。那就继续刨根问底,探究个明白吧。
在上一篇文章《你真的知道Python的字符串怎么用吗?》里,我突发奇想,将字符串跟列表做了比较,然后发现字符串竟然没有复制的方法。当时没有细想,只说要搁置疑问。
过后,有好学的小伙伴在后台留言,与我交流这个问题,给了我一些启发。为了彻底弄懂它,我继续查了不少资料,今天,就跟大家分享一下我发现的东西吧。
本文标题的问题分为两部分:(1)Python 中是否支持复制字符串?(2)如果不支持,为什么不支持?
请读者花几分钟想一下,想清楚后,把你的答案记住,然后再往下看。
让我们做一个约定(自愿遵守):如果看到最后,你推翻了现在的答案,建立了新的认知,这说明我写的内容有用,那请你任意赞赏,或者将本文分享给其他使用 Python 的小伙伴。

1. 什么是复制字符串?

首先,必须要大家对“复制”这个概念达成共识。复制,也叫拷贝,英文单词是 copy,具体意思是“将某事物通过某种方式制作成相同的一份或多份的行为”(释义来自维基百科)。复制的结果是,出现了多份极其相似但却相互独立的事物(副本),举例来说,你有一份文档 X,然后复制一份并重新命名为 Y,这两者是相互独立的,若你删除其中一个,另一个不会一起被删除。
这个词用在 Python 里,我们想表达的是同样的意思,即复制行为会产生新的独立对象,它与原始对象极其相似,但两者的生命周期没有直接的关联关系。下面先用列表来举例:
list1 = [1,2]
id(list1) 
>>> 1981119454856

list2 = list1.copy()
print(list1 == list2) 
>>> True
id(list2)
>>> 1981116983752
上例中,列表 list2 是 list1 的副本,两者字面量相等,但是内存地址(即 id )不相等,是两个相互独立的对象。如果字符串能够做到同样的效果,那我们就说,字符串可以被复制,否则,我们说字符串不可以被复制。

2. 怎样能复制字符串?

有了上面的概念和示例,请先思考,你会用什么方式复制字符串呢?(暂停,思考3分钟)
好了,先看看下面的几种方法:
s0 = "Python猫"

s1 = s0
s2 = str(s0)
s3 = s0[:]
s4 = s0 + ''
s5 = '%s' % s0
s6 = s0 * 1
s7 = "".join(s0)
import copy
s8 = copy.copy(s0)
你想到的复制方式是否在以上8种方式里呢?那么,如果把 s0 至 s8 的 id 打印出来,有哪些会跟 s0 不同呢?
答案是,它们的内存地址 id 完全相同,也就是说,一顿操作猛如虎,结果却始终只有一份字符串,根本没有复制出新的字符串!
Python猫 的老读者看到这,会心一笑,这不就是因为字符串的 Intern 机制嘛,短字符串在内存中只会存在一份,在《Python中的“特权种族”是什么?》这篇文章里提到过的。
但请别开心得太早,你可以把 s0 改成一个超长的字符串,例如:

s0 = “Python猫是来自喵星的客人,它喜欢地球和人类,正在学习Python,而且想借助Python变成人,它的微信公众号也叫Python猫,欢迎你关注哦,喵喵喵喵~~~~~~~”

然后,再重复上面的操作。最终,你会发现,s0 到 s8 的 id 还是完全相同。
是不是吃惊了呢?新的 s0 明明已经超过 Intern 机制的长度了,为什么不会产生新的字符串呢?
首先,请你相信,超出 Intern 机制的字符串可以存在多份,即你可以创建出值完全相同的多个字符串对象,因为字符串对象在内存中并不一定是唯一的:
s9 = "Python猫是来自喵星的客人,它喜欢地球和人类,正在学习Python,而且想借助Python变成人,它的微信公众号也叫Python猫,欢迎你关注哦,喵喵喵喵~~~"

print(id(s0) == id(s9))
>>> False 
上例表明,你可以创建出多个相同的字符串对象,但是这种方法与前面列举的8种不同,因为它是独立于 s0 的操作,并不是一种复制操作。从理论上讲,Python 完全可以提供一个方法,达到复制出新的副本的结果。现在的问题恰恰就是:为什么允许存在多个相等的字符串对象,但是却无法通过复制的方式来创建呢?

3. 为什么不允许复制字符串?

我发现,不仅字符串不允许复制,元组也如此,事实上,还有 int 、float 也不支持复制。它们都是不可变对象,为什么不可变对象就不支持复制操作呢?
在查资料的时候,我发现网上很多文章对于“不可变对象”的认识存在误区,这些人不知道 Intern 机制的存在,误以为字符串对象在内存只能有唯一一个,进而误以为不可变对象就是在内存中只有一份的对象。所以,这些文章很容易推断出错误的结论:因为字符串是不可变对象,所以字符串不支持复制。
事实上,不可变对象跟复制操作之间,并没有必然的强相关的关系。肯定是出于别的原因,设计者才给不可变对象加上这种限制,这个原因是什么呢?
在知乎上,有敏锐的同学提出了我的疑问“Python中如何复制一个值或字符串?”,可惜只有4个回答,而且都没答到点上。Stackoverflow上恰好也有一个问题“How can I copy a Python string?”,同样没多少人注意到,只有5个回答,好在最高票答案提到了一个点,即这样可以加快字典的查找速度。
然而,他说的这个点并不靠谱。字典要求键值是可哈希对象,可是计算字符串的哈希值是根据字面值计算,所以对多个相等的字符串对象,其哈希值其实是一样的,对计算和查找根本无影响。
w1 = "Python猫是来自喵星的客人,它喜欢地球和人类,正在学习Python,而且想借助Python变成人,它的微信公众号也叫Python猫,欢迎你关注哦,喵喵喵喵~~~"
w2 = "Python猫是来自喵星的客人,它喜欢地球和人类,正在学习Python,而且想借助Python变成人,它的微信公众号也叫Python猫,欢迎你关注哦,喵喵喵喵~~~"

print(w1 == w2) 
>>> True
print(id(w1) == id(w2)) 
>>> False 
print(hash(w1) == hash(w2)) 
>>> True
继续查资料,终于在《流畅的Python》找到了明确的解释:

这些细节是 CPython 核心开发者走的捷径和做的优化措施,对这门语言的用户而言无需了解,而且那些细节对其他 Python 实现可能没用,CPython 未来的版本可能也不会用。

这本《流畅的Python》是进阶首选书目之一,我曾读过部分章节,没想到在一个不起眼的小节里,作者 “惊讶地发现” 元组的不可复制性,在此之前,他还自以为“对元组无所不知”,哈哈哈。
虽然,我早猜测到原因是节省内存和提高速度,但看到这个明确的解释,知道这只是CPython 解释器的“善意的谎言”,而且在未来版本可能不会用,我感到特别意外。
它证实了我的猜测,同时,也提供了超预期的信息:其它 Python 解释器可能支持复制不可变对象,目前 CPython 算是一种妥协,在未来可能会恢复不可变对象的复制操作呢!
回到文章开头的两个问题,我们得到的答案是:Python 本身并不限制字符串的复制操作,只是当前版本的 CPython 做了优化,才导致出现这种“善意的谎言”,它这么做的原因为了对 Intern 机制做补充,设法使全部字符串对象在内存都只有一份,以达到节省内存的效果。
CPython 是用 C 语言实现的 Python 解释器,是官方的、使用最广泛的解释器。除了它,还有用 Java 实现的 Jython 解释器、用 .NET 实现的 IronPython 解释器、用 Python 实现的 PyPy 解释器,等等。其它解释器都是怎么应对字符串的复制操作的呢?唉,学无止境,本人才疏学浅没有涉猎,还是先搁置疑问吧。
这里,我就想提一个题外话,Python 最最最广为人诟病的就是 GIL(全局解释器锁),这导致它不支持真正意义的多线程,成为很多人指责 Python 慢的元凶。但是,这个问题是 CPython 解释器带来的,而像 Jython 解释器就不存在这个问题。
好了,就此打住吧。你是否还记得在文章开头时想到的答案呢?是否改变了最初的想法呢?欢迎关注公众号 Python猫 ,来跟我交流,一起来学习 Python ,做个合格的 Pythonista
参考学习:
《流畅的Python》
update:文中有一个大错误,请查看下篇解析《join() 方法的神奇用处与 Intern 机制的软肋