join()方法的神奇用處與Intern機制的軟肋

圖片描述
上篇文章《Python是否支持複製字符串呢?》剛發出一會,@發條橙 同學就在後臺留言,指出了一處錯誤。我一驚,馬上去驗證,竟然真的錯了,而且在完全沒意料到的地方!我開始以爲只是疏漏,一細想,發現不簡單,遇到了百思不得其解的問題了。所以,這篇文章還得再聊聊字符串。

照例先總結下本文內容:(1)join() 方法除了在拼接字符串時速度較快,它還是目前看來最通用有效的複製字符串的方法 (2)Intern 機制(字符串滯留)並非萬能的,本文探索一下它的軟肋有哪些

1. join()方法不止是拼接

我先把那個問題化簡一下吧:

ss0 = 'hi'
ss1 = 'h' + 'i'
ss2 = ''.join(ss0)

print(ss0 == ss1 == ss2) >>> True
print(id(ss0) == id(ss1)) >>> True
print(id(ss0) == id(ss2)) >>> False

上面代碼中,奇怪的地方就在於 ss2 竟然是一個獨立的對象!按照最初想當然的認知,我認定它會被 Intern 機制處理掉,所以是不會佔用獨立內存的。上篇文章快寫完的時候,我突然想到 join 方法,所以沒做驗證就臨時加進去,導致了意外的發生。

按照之前在“特權種族”那篇文章的總結,我對字符串 Intern 機制有這樣的認識:

Python中,字符串使用Intern機制實現內存地址共用,長度不超過20,且僅包括下劃線、數字、字母的字符串纔會被intern;涉及字符串拼接時,編譯期優化結果會與運行期計算結果不同。

爲什麼 join 方法拼接字符串時,可以不受 Intern 機制作用呢?

回看那篇文章,發現可能存在編譯期與運行期的差別!

# 編譯對字符串拼接的影響
s1 = "hell"
s2 = "hello"
"hell" + "o" is s2 
>>>True
s1 + "o" is s2 
>>>False
# "hell" + "o"在編譯時變成了"hello",
# 而s1+"o"因爲s1是一個變量,在運行時才拼接,所以沒有被intern

實驗一下,看看:

# 代碼加上
ss3 = ''.join('hi')
print(id(ss0) == id(ss3)) >>> False

ss3 仍然是獨立對象,難道這種寫法還是在運行期時拼接?那怎麼判斷某種寫法在編譯期還是在運行期起作用呢?繼續實驗:

s0 = "Python貓"
import copy
s1 = copy.copy(s0)
s2 = copy.copy("Python貓")

print(id(s0) == id(s1))
>>> True
print(id(s0) == id(s2))
>>> False

看來,不能通過是否顯性傳值來判斷。

那就只能從 join 方法的實現原理入手查看了。經某交流羣的小夥伴提醒,可以去 Python Tutor 網站,看看可視化執行過程。但是,很遺憾,也沒看出什麼底層機制。

我找了分析 CPython 源碼的資料(含上期薦書欄目的《Python源碼剖析》)來學習,但是,這些資料只比較 join() 方法與 + 號拼接法在原理與使用內存上的差異,並沒提及爲何 Intern 機制對前者會失效,而對後者卻是生效的。

現象已經產生,我只能暫時解釋說,join 方法會不受 Intern 機制控制,它有獨享內存的“特權”。

那就是說,其實有複製字符串的方法!上篇《Python是否支持複製字符串呢?》由於沒有發現這點,最後得出了錯誤的結論!

由於這個特例,我要修改上篇文章的結論了:Python 本身並不限制字符串的複製操作,CPython 解釋器出於優化性能的考慮,加入了一些小把戲,試圖使字符串對象在內存中只有一份,儘管如此,仍存在有效複製字符串的方法,那就是 join() 方法。

2. Intern 機制失效的情況

join() 方法的神奇用處使我不得不改變對 Intern 機制的認識,本小節就帶大家重新學習一下 Intern 機制吧。

所謂 Intern 機制,即字符串滯留(string interning),它通過維護一個字符串常量池(string intern pool),從而試圖只保存唯一的字符串對象,達到既高效又節省內存地處理字符串的目的。在創建一個新的字符串對象後,Python 先比較常量池中是否有相同的對象(interned),有的話則將指針指向已有對象,並減少新對象的指針,新對象由於沒有引用計數,就會被垃圾回收機制回收掉,釋放出內存。

Intern 機制不會減少新對象的創建與銷燬,但最終會節省出內存。這種機制還有另一個好處,即被 Interned 的相同字符串作比較時,幾乎不花時間。實驗數據如下(資料來源:http://t.cn/ELu9n7R):

Intern 機制的大致原理很好理解,然而影響結果的還有 CPython 解釋器的其它編譯及運行機制,字符串對象受到這些機制的共同影響。實際上,只有那些“看起來像” Python 標識符的字符串纔會被處理。源代碼StringObject.h的註釋中寫道:

/ … … This is generally restricted to strings that “looklike” Python identifiers, although the intern() builtin can be used to force interning of any string … … /

這些機制的相互作用,不經意間帶來了不少混亂的現象:

# 長度超過20,不被intern VS 被intern
'a' * 21 is 'aaaaaaaaaaaaaaaaaaaaa'
>>> False
'aaaaaaaaaaaaaaaaaaaaa' is 'aaaaaaaaaaaaaaaaaaaaa'
>>> True

# 長度不超過20,不被intern VS 被intern
s = 'a'
s * 5 is 'aaaaa'
>>> False
'a' * 5 is 'aaaaa'
>>> True


# join方法,不被intern VS 被intern
''.join('hi') is 'hi'
>>> False
''.join('h') is 'h'
>>> True

# 特殊符號,不被intern VS 被"intern"
'python!' is 'python!'
>>> False
a, b = 'python!', 'python!'
a is b
>>> True

這些現象當然都能被合理解釋,然而由於不同機制的混合作用,就很容易造成誤會。比如第一個例子,很多介紹 Intern 機制的文章在比較出 'a' * 21 的id有變化後,就認爲 Intern 機制只對長度不超過20的字符串生效,可是,當看到長度超過20的字符串的id還相等時,這個結論就變錯誤了。當加入常量合併(Constant folding) 的機制後,長度不超過20的字符串會被合併的現象纔得到解釋。可是,在 CPython 的源碼中,只有長度不超過1字節的字符串纔會被 intern ,爲何長度超標的情況也出現了呢? 再加入 CPython 的編譯優化機制,才能解釋。

所以,看似被 intern 的兩個字符串,實際可能不是 Intern 機制的結果,而是其它機制的結果。同樣地,看似不能被 intern 的兩個字符串,實際可能被其它機制以類似方式處理了。

如此種種,便提高了理解 Intern 機制的難度。

就我在上篇文章中所關心的“複製字符串”話題而言,只有當 Intern 機制與其它這些機制統統失效時,才能做到複製字符串。目前看來,join 方法最具通用性。

3. 學習的方法論

總而言之,因爲重新學習 join 方法的神奇用處與 Intern 機制的例外情況,我得以修正上篇文章的錯誤。在此過程中,我得到了新的知識,以及思考學習的樂趣。

《超人》電影中有一句著名的臺詞,在今年上映的《頭號玩家》中也出現了:

有的人從《戰爭與和平》裏看到的只是一個普通的冒險故事,

有的人則能通過閱讀口香糖包裝紙上的成分表來解開宇宙的奧祕。

我讀到的是一種敏銳思辨的思想、孜孜求索的態度和以小窺大的方法。作爲一個低天賦的人,受此鼓舞,我會繼續追問那些看似沒意義的問題(“如何刪除字符串”、“如何複製字符串”...),一點一點地學習 Python ,以我的方式理解它。同時,希望能給我的讀者們帶來一些收穫。

PS.不少人在期待 “Python貓” 系列,別急哈,讓那隻貓再睡幾天,等它醒來,我替大家催它!

字符串系列文章:

詳解Python拼接字符串的七種方式

你真的知道Python的字符串是什麼嗎?

你真的知道Python的字符串怎麼用嗎?

Python是否支持複製字符串呢?

Python貓系列:

有了Python,我能叫出所有貓的名字

Python對象的身份迷思:從全體公民到萬物皆數

-----------------

本文原創並首發於微信公衆號【Python貓】,後臺回覆“愛學習”,免費獲得20+本精選電子書。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章