上篇文章《Python是否支持複製字符串呢?》剛發出一會,@發條橙 同窗就在後臺留言,指出了一處錯誤。我一驚,立刻去驗證,居然真的錯了,並且在徹底沒意料到的地方!我開始覺得只是疏漏,一細想,發現不簡單,遇到了百思不得其解的問題了。因此,這篇文章還得再聊聊字符串。python
照例先總結下本文內容:(1)join() 方法除了在拼接字符串時速度較快,它仍是目前看來最通用有效的複製字符串的方法 (2)Intern 機制(字符串滯留)並不是萬能的,本文探索一下它的軟肋有哪些微信
我先把那個問題化簡一下吧:ide
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
實驗一下,看看:ui
# 代碼加上 ss3 = ''.join('hi') print(id(ss0) == id(ss3)) >>> False
ss3 仍然是獨立對象,難道這種寫法仍是在運行期時拼接?那怎麼判斷某種寫法在編譯期仍是在運行期起做用呢?繼續實驗:spa
s0 = "Python貓" import copy s1 = copy.copy(s0) s2 = copy.copy("Python貓") print(id(s0) == id(s1)) >>> True print(id(s0) == id(s2)) >>> False
看來,不能經過是否顯性傳值來判斷。3d
那就只能從 join 方法的實現原理入手查看了。經某交流羣的小夥伴提醒,能夠去 Python Tutor 網站,看看可視化執行過程。可是,很遺憾,也沒看出什麼底層機制。
我找了分析 CPython 源碼的資料(含上期薦書欄目的《Python源碼剖析》)來學習,可是,這些資料只比較 join() 方法與 + 號拼接法在原理與使用內存上的差別,並沒說起爲什麼 Intern 機制對前者會失效,而對後者倒是生效的。
現象已經產生,我只能暫時解釋說,join 方法會不受 Intern 機制控制,它有獨享內存的「特權」。
那就是說,其實有複製字符串的方法!上篇《Python是否支持複製字符串呢?》因爲沒有發現這點,最後得出了錯誤的結論!
因爲這個特例,我要修改上篇文章的結論了:Python 自己並不限制字符串的複製操做,CPython 解釋器出於優化性能的考慮,加入了一些小把戲,試圖使字符串對象在內存中只有一份,儘管如此,仍存在有效複製字符串的方法,那就是 join() 方法。
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 方法最具通用性。
總而言之,由於從新學習 join 方法的神奇用處與 Intern 機制的例外狀況,我得以修正上篇文章的錯誤。在此過程當中,我獲得了新的知識,以及思考學習的樂趣。
《超人》電影中有一句著名的臺詞,在今年上映的《頭號玩家》中也出現了:
有的人從《戰爭與和平》裏看到的只是一個普通的冒險故事,有的人則能經過閱讀口香糖包裝紙上的成分表來解開宇宙的奧祕。
我讀到的是一種敏銳思辨的思想、孜孜求索的態度和以小窺大的方法。做爲一個低天賦的人,受此鼓舞,我會繼續追問那些看似沒意義的問題(「如何刪除字符串」、「如何複製字符串」...),一點一點地學習 Python ,以個人方式理解它。同時,但願能給個人讀者們帶來一些收穫。
PS.很多人在期待 「Python貓」 系列,別急哈,讓那隻貓再睡幾天,等它醒來,我替你們催它!
字符串系列文章:
Python貓系列:
-----------------
本文原創並首發於微信公衆號【Python貓】,後臺回覆「愛學習」,免費得到20+本精選電子書。