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

豌豆花下貓發表於2018-12-01

圖片描述
上篇文章《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+本精選電子書。

相關文章