1. 程式人生 > 實用技巧 >Python 世界的黑客帝國!

Python 世界的黑客帝國!

相比於子彈時間和火爆場景,我更喜歡《黑客帝國》故事背景的假設 —— 人們熟悉的世界是虛構的,是機器給人大腦輸入的幻象,而幻象是不完美的,存在一些不符合自然規律的地方,這些地方或多或少的展示了幻象世界的破綻和真實世界的樣子,如果你看過《黑客帝國》動畫版《超越極限》和《世界紀錄》,將會有更深刻的感受

我們熟悉的、賴以生存的 Python 世界,其實也是個虛擬的,這個虛擬世界展示給我們無比絢爛的場景和功能的同時,也存在一些超乎常理和認知的地方,今天就帶你一起探尋一些那些超自然點,以及它背後的真實世界

神奇的海象操作符

海象操作符:=是 Python3.8 引入的一個新特性,意為節省一次臨時變數的賦值操作,例如:

a = [1,2,3,4,5]
n = len(a)
if n > 4:
    print(n)

意思是,如果列表 a 的長度大於 4,則列印 a 的長度,為了避免對列表長度的兩次求解,利用變數 n 儲存 a 的長度,合情合理

如果用 海象操作符(:=),會是這樣:

a = [1,2,3,4,5]
if n := len(n) > 4:
    print(n)

可以看到,省去了臨時變數 n 的定義,通過海象操作符,一舉兩得

不得不說,Python 為能讓我們提高效率,真是挖空心思,剛剛釋出的正式版 Python3.9,也是為提升效率做了多處改善

海象的表演

不過,看下面的程式碼

>>> a = "wtf_walrus"
>>> a
'wtf_walrus'

>>> a := "wtf_walrus"  # 報錯!
  File "<stdin>", line 1
    a := 'wtf_walrus'
      ^
SyntaxError: invalid syntax

>>> (a := "wtf_walrus") # 奇蹟發生,竟然通過了!
'wtf_walrus'
>>> a
'wtf_walrus'

再來一段

>>> a = 6, 9  # 元組賦值
>>> a  # 結果正常
(6, 9)

>>> (a := 6, 9)  # 海象賦值,表示式結果正常
(6, 9)
>>> a  # 臨時變數竟然不同
6

>>> a, b = 6, 9 # 解包賦值
>>> a, b
(6, 9)
>>> (a, b = 16, 19) # Oh no!
  File "<stdin>", line 1
    (a, b = 6, 9)
          ^
SyntaxError: invalid syntax

>>> (a, b := 16, 19) # 這裡竟然打印出三員元組!
(6, 16, 19)

>>> a # 問題是 a 竟然沒變
6

>>> b
16

解密海象

  • 非括號表示式的海象賦值操作海象操作符(:=)適用於一個表示式內部的作用域,沒有括號,相當於全域性作業域,是會受到編譯器限制的
  • 括號裡的賦值操作相應的,賦值操作符(=)不能放在括號裡,因為它需要在全域性作用域中執行,而非在一個表示式內
  • 海象操作符的本質海象操作符的語法形式為Name := expr,Name為正常的識別符號,expr為正常的表示式,因此可迭代的裝包和解包表現的結果會和期望不同(a := 6, 9)實際上會被解析為((a := 6), 9),最終,a 的值為 6,驗證一下:>>> (a := 6, 9) == ((a := 6), 9) True >>> x = (a := 696, 9) >>> x (696, 9) >>> x[0] is a # 引用了相同的值 True同樣的,(a, b := 16, 19) 相當於(a, (b := 16), 19,原來如此

不安分的字串

先建立一個感知認識

>>> a = "some_string"
>>> id(a)
140420665652016
>>> id("some" + "_" + "string") # 不同方式建立的字串實質是一樣的.
140420665652016

奇特的事情即將發生

>>> a = "wtf"
>>> b = "wtf"
>>> a is b
True

>>> a = "wtf!"
>>> b = "wtf!"
>>> a is b  # 什麼鬼!
False

如果將這段程式碼寫入指令碼檔案,用 Python 執行,結果卻是對的:

a = "wtf!"
b = "wtf!"
print(a is b)  # 將打印出 True

還有更神奇的,在 Python3.7 之前的版本中,會有下面的現象:

>>> 'a' * 20 is 'aaaaaaaaaaaaaaaaaaaa'
True
>>> 'a' * 21 is 'aaaaaaaaaaaaaaaaaaaaa'
False

20 個字元a組合起來等於 20個a的字串,而 21 個就不相等

揭祕字串

計算機世界裡,任何奇特的現象都有其必然原因

  • 這些字串行為,是由於 Cpython 在編譯優化時,某些情況下會對不可變的物件做儲存,新建時之間建立引用,而不是建立新的,這種技術被稱作字串駐留(string interning),這樣做可以節省記憶體和提高效率
  • 上面程式碼中,字串被隱式駐留了,什麼情況下才會被駐留呢?所有長度為 0 和 1 的字串都會被駐留字串在編譯時被駐留,運算中不會("wtf"會駐留,"".join("w", "t", "f")則不會)只有包含了字母、數值和下劃線的字串才會被駐留,這就是為什麼"wtf!"不會被駐留的原因(其中還有字元!)
  • 如果 a 和 b 的賦值 “wtf!” 語句在同一行,Python 直譯器會建立一個物件,然後讓兩個變數指向這個物件。如果在不同行,直譯器就不知道已經有了"wtf!"物件,所以會建立新的(原因是"wtf!"不會被駐留)
  • 像 IPython 這樣的互動環境中,語句是單行執行的,而指令碼檔案中,程式碼是被同時編譯的,具有相同的編譯環境,所以就能理解,程式碼檔案中不同行的不被駐留字串引用同一個物件的現象
  • 常量摺疊(constant folding)是 Python 的一個優化技術:窺孔優化(peephole optimization),如a = "a"*20,這樣的語句會在編譯時展開,以減少執行時的執行消耗,為了不至於讓 pyc 檔案過大,將展開字元限制在 20 個以內,不然想想這個"a"*10**100語句將產生多大的 pyc 檔案
  • 注意 Python3.7 以後的版本中 常量摺疊 的問題得到了改善,不過還不清楚具體原因(矩陣變的更復雜了)

小心鏈式操作

來一段騷操作

>>> (False == False) in [False] # 合乎常理
False
>>> False == (False in [False]) # 也沒問題
False
>>> False == False in [False] # 現在感覺如何?
True

>>> True is False == False
False
>>> False is False is False
True

>>> 1 > 0 < 1
True
>>> (1 > 0) < 1
False
>>> 1 > (0 < 1)
False

不知到你看到這段程式碼的感受,反正我看第一次到時,懷疑我學的 Python 是假冒的~

到底發生了什麼

按照 Python 官方文件,表示式章節,值比較小節的描述(https://docs.python.org/2/reference/expressions.html#not-in):

通常情況下,如果 a、b、c、…、y、z 是表示式,op1、op2、…、opN 是比較運算子,那麼a op1 b op2 c ... y opN z等價於a op1 b and b op2 c and ... y opN z,除了每個表示式只被計算一次的特性

基於以上認知,我們重新審視一下上面看起來讓人迷惑的語句:

  • False is False is False等價於(False is False) and (False is False)
  • True is False == False等價於True is False and False == False,現在可以看出,第一部分True is False的求值為False, 所以整個表示式的值為False
  • 1 > 0 < 1等價於1 > 0 and 0 < 1,所以表示式求值為True
  • 表示式(1 > 0) < 1等價於True < 1,另外int(True)的值為 1,且True + 1的值為 2,那麼1 < 1就是False了

到底 is(是) 也不 is(是)

我直接被下面的程式碼驚到了

>>> a = 256
>>> b = 256
>>> a is b
True

>>> a = 257
>>> b = 257
>>> a is b
False

再來一個

>>> a = []
>>> b = []
>>> a is b
False

>>> a = tuple()
>>> b = tuple()
>>> a is b
True

同樣是數字,同樣是物件,待遇咋就不一樣尼……

我們逐一理解下

is 和 == 的區別

  • is操作符用於檢查兩個變數引用的是否同一個物件例項
  • ==操作符用於檢查兩個變數引用物件的值是否相等
  • 所以is用於引用比較,==用於值比較,下面的程式碼能更清楚的說明這一點:>>> class A: pass >>> A() is A() # 由於兩個物件例項在不同的記憶體空間裡,所以表示式值為 False False

256 是既存物件,而 257 不是

這個小標題讓人很無語,還確實物件和物件不一樣

在 Python 裡 從 -5 到 256 範圍的數值,是預先初始化好的,如果值為這個範圍內的數字,會直接建立引用,否則就會建立

這就解釋了為啥 同樣的 257,記憶體物件不同的現象了

為什麼要這樣做?官方的解釋為,這個範圍的數值比較常用(https://docs.python.org/3/c-api/long.html)

不可變的空元組

和 -5 到 256 數值預先建立一樣,對於不可變的物件,Python 直譯器也做了預先建立,例如 對空的 Tuple 物件

這就能解釋為什麼 空列表物件之間引用不同,而空元組之間的引用確實相同的現象了

被吞噬的 Javascript

先看看過程

some_dict = {}
some_dict[5.5] = "Ruby"
some_dict[5.0] = "JavaScript"
some_dict[5] = "Python"

print(some_dict[5.5])  # Ruby
print(some_dict[5.0])  # Python  Javascript 去哪了?

背後的原因

  • Python 字典物件的索引,是通過鍵值是否相等和比較鍵的雜湊值來進行查詢的
  • 具有相同值的不可變物件的雜湊值是相同的>>> 5 == 5.0 True >>> hash(5) == hash(5.0) True
  • 於是我們就能理解,當執行some_dict[5] = "Python"時,會覆蓋掉前面定義的some_dict[5.0] = "Javascript",因為 5 和 5.0 具有相同的雜湊值

需要注意的是:有可能不同的值具有相同的雜湊值,這種現象被稱作雜湊衝突

關於字典物件使用雜湊值作為索引運算的更深層次的原因,有興趣的同學可以參考 StackOverflow 上的回答,解釋的很精彩,網址是:https://stackoverflow.com/questions/32209155/why-can-a-floating-point-dictionary-key-overwrite-an-integer-key-with-the-same-v/32211042

總結

限於篇幅(精力)原因,今天就介紹這幾個 Python 宇宙中的異常現象,更多的異常現象,收錄在 satwikkansal 的 wtfpython 中(https://github.com/satwikkansal/wtfpython)。

任何華麗美好的背後都是各種智慧、技巧、妥協、辛苦的支撐,不是有那麼一句話嘛:如果你覺得輕鬆自如,比如有人在負重前行。而這個人就是我們喜愛的 Python 及其 編譯器,要更好的理解一個東西,需要了解它背後的概念原理和理念,期望通過這篇短文對你有所啟發

完整專案程式碼獲取⬅點這裡