Python:鮮為人知的功能特性(上)

丹楓無跡發表於2019-03-01

GitHub 上有一個名為《What the f*ck Python!》的專案,這個有趣的專案意在收集 Python 中那些難以理解和反人類直覺的例子以及鮮為人知的功能特性,並嘗試討論這些現象背後真正的原理! 原版地址:github.com/satwikkansa…

最近,一位名為“暮晨”的貢獻者將其翻譯成了中文。 中文版地址:github.com/leisurelich…

原本每個的標題都是原版中的英文,有些取名比較奇怪,不直觀,我換成了可以描述主題的中文形式,有些是自己想的,不足之處請指正。另外一些 Python 中的彩蛋被我去掉了。

我將所有程式碼都親自試過了,加入了一些自己的理解和例子,所以會和原文稍有不同。

下篇已釋出:Python:鮮為人知的功能特性(下)

1. 字串駐留

>>> a = '!'
>>> b = '!'
>>> a is b
True
複製程式碼

>>> a = 'some_string'
>>> id(a)
140420665652016
>>> id('some' + '_' + 'string') # 注意兩個的id值是相同的.
140420665652016
複製程式碼

>>> a = 'wtf'
>>> b = 'wtf'
>>> a is b
True

>>> a = 'wtf!'
>>> b = 'wtf!'
>>> a is b
False

>>> a, b = 'wtf!', 'wtf!'
>>> a is b
True
複製程式碼

>>> 'a' * 20 is 'aaaaaaaaaaaaaaaaaaaa'
True
>>> 'a' * 21 is 'aaaaaaaaaaaaaaaaaaaaa'
False
複製程式碼

說明: 這些行為是由於 CPython 在編譯優化時,某些情況下會嘗試使用已經存在的不可變物件而不是每次都建立一個新物件。這種行為被稱作字串的駐留 string interning。發生駐留之後, 許多變數可能指向記憶體中的相同字串物件從而節省記憶體。

有一些方法可以用來猜測字串是否會被駐留:

  • 所有長度為 0 和長度為 1 的字串都被駐留(①中字串被駐留)
  • 字串在編譯時被實現('wtf' 將被駐留,但是 ''.join(['w', 't', 'f'] 將不會被駐留)
  • 字串中只包含字母、數字或下劃線時將會駐留,所以 'wtf!' 由於包含 '!' 而未被駐留
  • 當在同一行將 ab 的值設定為 'wtf!' 的時候,Python 直譯器會建立一個新物件,然後兩個變數同時指向這個物件。如果你在不同的行上進行賦值操作,它就不會“知道”已經有一個 'wtf!' 物件(因為 'wtf!' 不是按照上面提到的方式被隱式駐留的)。
  • 常量摺疊(constant folding)是 Python 中的一種窺孔優化(peephole optimization)技術。這意味著在編譯時表示式 'a' * 20 會被替換為 'aaaaaaaaaaaaaaaaaaaa' 以減少執行時的時鐘週期。只有長度小於 20 的字串才會發生常量摺疊。(為啥?想象一下由於表示式 'a' * 10 ** 10 而生成的 .pyc 檔案的大小)。

如果你在 .py 檔案中嘗試這個例子,則不會看到相同的行為,因為檔案是一次性編譯的。

2. 字典的鍵

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

>>> some_dict[5.5]
"Ruby"
>>> some_dict[5.0]
"Python"
>>> some_dict[5]
"Python"
複製程式碼

說明: Python 字典檢查鍵值是否相等是通過比較雜湊值是否相等來確定的。如果兩個物件在比較的時候是相等的,那它們的雜湊值必須相等,否則雜湊表就不能正常執行了。例如,如果 1 == 1.0 為真,那麼 hash(1) == hash(1.0) 必須也為真,但其實兩個數字(整數和浮點數)的內部結構是完全不一樣的。

3. finally 子句中的 return

def some_func():
    try:
        return 'from_try'
    finally:
        return 'from_finally'
複製程式碼

Output:

>>> some_func()
'from_finally'
複製程式碼

說明: 函式的返回值由最後執行的 return 語句決定。由於 finally 子句一定會執行,所以 finally 子句中的 return 將始終是最後執行的語句。

4. 同一個物件

class WTF:
    pass
複製程式碼

Output:

>>> WTF() == WTF() # 兩個不同的物件應該不相等
False
>>> WTF() is WTF() # 也不相同
False
>>> hash(WTF()) == hash(WTF()) # 雜湊值也應該不同
True
>>> id(WTF()) == id(WTF())
True
複製程式碼

說明: 當呼叫 id() 函式時,Python 建立了一個 WTF 類的物件並傳給 id() 函式,然後 id() 函式獲取其 id 值(也就是記憶體地址),然後丟棄該物件,該物件就被銷燬了。

當我們連續兩次進行這個操作時,Python會將相同的記憶體地址分配給第二個物件,因為在 CPythonid() 函式使用物件的記憶體地址作為物件的 id 值,所以兩個物件的 id 值是相同的。

綜上,物件的 id 值僅僅在物件的生命週期內唯一,在物件被銷燬之後或被建立之前,其他物件可以具有相同的 id 值。

class WTF(object):
  def __init__(self): print("I")
  def __del__(self): print("D")
複製程式碼

Output:

>>> WTF() is WTF()
I
I
D
D
False
>>> id(WTF()) == id(WTF())
I
D
I
D
True
複製程式碼

正如你所看到的,物件銷燬的順序是造成所有不同之處的原因。

5. for 迴圈分配目標賦值

>>> some_string = "wtf"
>>> some_dict = {}
>>> for i, some_dict[i] in enumerate(some_string): pass
>>> some_dict
{0: 'w', 1: 't', 2: 'f'}
複製程式碼

說明: 這一條仔細看一下很好理解,for 迴圈每次迭代都會給分配目標賦值,some_dict[i] = value 就相當於給字典新增鍵值對了。 有趣的是下面這個例子,你可曾覺得這個迴圈只會執行一次?

for i in range(4):
    print(i)
    i = 10
複製程式碼

6. 執行時機差異

>>> array = [1, 8, 15]
>>> g = (x for x in array if array.count(x) > 0)
>>> array = [2, 8, 22]
>>> list(g)
[8]
複製程式碼

>>> array_1 = [1, 2, 3, 4]
>>> g1 = (x for x in array_1)
>>> array_1 = [1, 2, 3, 4, 5]

>>> array_2 = [1, 2, 3, 4]
>>> g2 = (x for x in array_2)
>>> array_2[:] = [1, 2, 3, 4, 5]

>>> list(g1)
[1, 2, 3, 4]

>>> list(g2)
[1, 2, 3, 4, 5]
複製程式碼

說明: 在生成器表示式中 in 子句在宣告時執行,而條件子句則是在執行時執行。 ①中,在執行前 array 已經被重新賦值為 [2, 8, 22],因此對於之前的 1, 8, 15,只有 count(8) 的結果是大於 0 ,所以生成器只會生成 8。 ②中,g1g2 的輸出差異則是由於變數 array_1array_2 被重新賦值的方式導致的。

  • 在第一種情況下,array_1 被繫結到新物件 [1, 2, 3, 4, 5],因為 in 子句是在宣告時被執行的,所以它仍然引用舊物件 [1, 2, 3, 4](並沒有被銷燬)。
  • 在第二種情況下,對 array_2 的切片賦值將相同的舊物件 [1, 2, 3, 4] 原地更新為 [1, 2, 3, 4, 5]。因此 g2 和 array_2 仍然引用同一個物件[1, 2, 3, 4, 5]

7. 整數的預分配

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

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

>>> a = 257; b = 257
>>> a is b
True
複製程式碼

is 和 == 的區別

  • is 運算子檢查兩個運算物件是否引用自同一物件
  • == 運算子比較兩個運算物件的值是否相等

因此 is 代表引用相同,== 代表值相等。下面的例子可以很好的說明這點:

>>> [] == []
True
>>> [] is []  # 這兩個空列表位於不同的記憶體地址
False
複製程式碼

256 是一個已經存在的物件,而 257 不是

當啟動 Python 的時候,-5 到 256 的數值就已經被分配好了。這些數字因為經常使用所以適合被提前準備好。

當前的實現為 -5 到 256 之間的所有整數保留一個整數物件陣列,當你建立了一個該範圍內的整數時,你只需要返回現有物件的引用。所以改變 1 的值是有可能的。

但是,當 ab 在同一行中使用相同的值初始化時,會指向同一個物件。

>>> id(256)
10922528
>>> a = 256
>>> b = 256
>>> id(a)
10922528
>>> id(b)
10922528
>>> id(257)
140084850247312

>>> x = 257
>>> y = 257
>>> id(x)
140084850247440
>>> id(y)
140084850247344

>>> a, b = 257, 257
>>> id(a)
140640774013296
>>> id(b)
140640774013296
複製程式碼

這是一種特別為互動式環境做的編譯器優化,當你在實時直譯器中輸入兩行的時候,他們會單獨編譯,因此也會單獨進行優化, 如果你在 .py 檔案中嘗試這個例子,則不會看到相同的行為,因為檔案是一次性編譯的。

8. 容易疏忽的引用型別賦值

>>> row = [''] * 3
>>> board = [row] * 3
>>> board
[['', '', ''], ['', '', ''], ['', '', '']]
>>> board[0]
['', '', '']
>>> board[0][0]
''
>>> board[0][0] = "X"
>>> board
[['X', '', ''], ['X', '', ''], ['X', '', '']]
複製程式碼

說明: 我們來輸出 id 看下:

>>> id(row[0])
7536232
>>> id(row[1])
5143216
>>> id(row[2])
5143216
>>> id(board[0])
7416840
>>> id(board[1])
7416840
>>> id(board[2])
7416840
複製程式碼

row 是一個 list,其中三個元素都指向地址 5143216,當對 board[0][0] 進行賦值以後,row 的第一個元素指向 7536232。而 board 中的三個元素都指向 rowrow 的地址並沒有改變。

我們可以通過不使用變數 row 生成 board 來避免這種情況。

>>> board = [[''] * 3 for _ in range(3)]
>>> board[0][0] = "X"
>>> board
[['X', '', ''], ['', '', ''], ['', '', '']]
複製程式碼

這裡用了推導式,每次迭代都會生成一個新的 _ ,所以 board 中三個元素指向的是不同的變數。

9. 閉包函式

funcs = []
results = []
for x in range(7):
    def some_func():
        return x
    funcs.append(some_func)
    results.append(some_func())

funcs_results = [func() for func in funcs]
複製程式碼

Output:

>>> results
[0, 1, 2, 3, 4, 5, 6]
>>> funcs_results
[6, 6, 6, 6, 6, 6, 6]
複製程式碼

說明: 當在迴圈內部定義一個函式時,如果該函式在其主體中使用了迴圈變數,則閉包函式將與迴圈變數繫結,而不是它的值。因此,所有的函式都是使用最後分配給變數的值來進行計算的。

可以通過將迴圈變數作為命名變數傳遞給函式來獲得預期的結果。為什麼這樣可行?因為這會在函式內再次定義一個區域性變數。

funcs = []
for x in range(7):
    def some_func(x=x):
        return x
    funcs.append(some_func)
複製程式碼

Output:

>>> funcs_results = [func() for func in funcs]
>>> funcs_results
[0, 1, 2, 3, 4, 5, 6]
複製程式碼

10. 字串末尾的反斜槓

>>> print("\\ C:\\")
\ C:\
>>> print(r"\ C:")
\ C:
>>> print(r"\ C:\")

    File "<stdin>", line 1
      print(r"\ C:\")
                     ^
SyntaxError: EOL while scanning string literal
複製程式碼

說明: 在以 r 開頭的原始字串中,反斜槓並沒有特殊含義。直譯器所做的只是簡單的改變了反斜槓的行為,因此會直接傳遞反斜槓及後一個的字元。這就是反斜槓在原始字串末尾不起作用的原因。

11. == 和 not 運算子的優先順序

>>> not x == y
True
>>> x == not y
  File "<input>", line 1
    x == not y
           ^
SyntaxError: invalid syntax
複製程式碼

說明: 一句話,== 運算子的優先順序要高於 not 運算子。

12. 三引號

>>> print('wtfpython''')
wtfpython
>>> print("wtfpython""")
wtfpython
>>> # 下面的語句會丟擲 `SyntaxError` 異常
>>> # print('''wtfpython')
>>> # print("""wtfpython")
複製程式碼

說明: '''"""Python 中也是字串定界符,Python 直譯器在先遇到三個引號的的時候會嘗試再尋找三個終止引號作為定界符,如果不存在則會導致 SyntaxError 異常。

而 Python 提供隱式的字串連結:

>>> print("wtf" "python")
wtfpython
>>> print("wtf""")  # 相當於 "wtf" ""
wtf
複製程式碼

13. 消失的午夜0點

from datetime import datetime

midnight = datetime(2018, 1, 1, 0, 0)
midnight_time = midnight.time()

noon = datetime(2018, 1, 1, 12, 0)
noon_time = noon.time()

if midnight_time:
    print("Time at midnight is", midnight_time)

if noon_time:
    print("Time at noon is", noon_time)
複製程式碼

Output:

Time at noon is 12:00:00
複製程式碼

midnight_time 並沒有被輸出。

說明:Python 3.5 之前,如果 datetime.time 物件儲存的 UTC 的午夜 0 點, 那麼它的布林值會被認為是 False。 這個我特意下了個 python 3.4 驗證了下,真是這樣。

14. bool 值

mixed_list = [False, 1.0, "some_string", 3, True, [], False]
integers_found_so_far = 0
booleans_found_so_far = 0

for item in mixed_list:
    if isinstance(item, int):
        integers_found_so_far += 1
    elif isinstance(item, bool):
        booleans_found_so_far += 1
複製程式碼

Output:

>>> booleans_found_so_far
0
>>> integers_found_so_far
4
複製程式碼

說明: 布林值是 int 的子類

>>> isinstance(True, int)
True
>>> isinstance(False, int)
True
複製程式碼

在引入實際 bool 型別之前,0 和 1 是真值的官方表示。為了向下相容,新的 bool 型別需要像 0 和 1 一樣工作。

15. 類屬性和例項屬性

class A:
    x = 1

class B(A):
    pass

class C(A):
    pass
複製程式碼

Output:

>>> A.x, B.x, C.x
(1, 1, 1)
>>> B.x = 2
>>> A.x, B.x, C.x
(1, 2, 1)
>>> A.x = 3
>>> A.x, B.x, C.x
(3, 2, 3)
>>> a = A()
>>> a.x, A.x
(3, 3)
>>> a.x += 1
>>> a.x, A.x
(4, 3)
複製程式碼

class SomeClass:
    some_var = 15
    some_list = [5]
    another_list = [5]
    def __init__(self, x):
        self.some_var = x + 1
        self.some_list = self.some_list + [x]
        self.another_list += [x]
複製程式碼

Output:

>>> some_obj = SomeClass(420)
>>> some_obj.some_list
[5, 420]
>>> some_obj.another_list
[5, 420]
>>> another_obj = SomeClass(111)
>>> another_obj.some_list
[5, 111]
>>> another_obj.another_list
[5, 420, 111]
>>> another_obj.another_list is SomeClass.another_list
True
>>> another_obj.another_list is some_obj.another_list
True
複製程式碼

說明:

  • 類變數和例項變數在內部是通過類物件的字典來處理(__dict__ 屬性),如果在當前類的字典中找不到的話就去它的父類中尋找。
  • += 運算子會在原地修改可變物件,而不是建立新物件。因此,修改一個例項的屬性會影響其他例項和類屬性。

16. yield 的 bug

some_iterable = ('a', 'b')

def some_func(val):
    return "something"
複製程式碼

Output:

>>> [x for x in some_iterable]
['a', 'b']
>>> [(yield x) for x in some_iterable]
<generator object <listcomp> at 0x7f70b0a4ad58>
>>> list([(yield x) for x in some_iterable])
['a', 'b']
>>> list((yield x) for x in some_iterable)
['a', None, 'b', None]
>>> list(some_func((yield x)) for x in some_iterable)
['a', 'something', 'b', 'something']
複製程式碼

說明: 這是 CPython 在理解和生成器表示式中處理 yield 的一個錯誤,在 Python 3.8 中修復,在 Python 3.7 中有棄用警告。 請參閱 Python 錯誤報告和 Python 3.7Python 3.8 的新增條目。

來源和解釋可以在這裡找到: stackoverflow.com/questions/3… 相關錯誤報告: bugs.python.org/issue10544

17. 元組的相對不可變性

>>> some_tuple = ("A", "tuple", "with", "values")
>>> another_tuple = ([1, 2], [3, 4], [5, 6])

>>> some_tuple[2] = "change this"
TypeError: 'tuple' object does not support item assignment
>>> another_tuple[2].append(1000) # 這裡不出現錯誤
>>> another_tuple
([1, 2], [3, 4], [5, 6, 1000])
>>> another_tuple[2] += [99, 999]
TypeError: 'tuple' object does not support item assignment
>>> another_tuple
([1, 2], [3, 4], [5, 6, 1000, 99, 999])
複製程式碼

說明: 元組中不可變的元素的標識(即元素的地址),如果元素是引用型別,元組的值會隨著引用的可變物件的變化而變化。所以 another_tuple[2].append(1000) 是可以的。 += 操作符在原地修改了列表。元素賦值操作並不工作,但是當異常丟擲時,元素已經在原地被修改了。+= 並不是原子操作,而是 extend= 兩個動作,這裡 = 操作雖然會丟擲異常,但 extend 操作已經修改成功了。

18. 消失的外部變數

e = 7
try:
    raise Exception()
except Exception as e:
    pass
複製程式碼

Output: python2

>>> print(e)
# prints nothing
複製程式碼

Output: python3

>>> print(e)
NameError: name 'e' is not defined
複製程式碼

說明: 當使用 as 為目標分配異常的時候,將在 except 子句的末尾清除該異常。

這就好像:

except E as N:
    foo
複製程式碼

會被翻譯成:

except E as N:
    try:
        foo
    finally:
        del N
複製程式碼

這意味著必須將異常分配給其他名稱才能在 except 子句之後引用它。而異常之所以會被清除,是因為附加了回溯資訊(trackback),它們與棧幀(stack frame)形成一個引用迴圈,使得該棧幀中的所有本地變數在下一次垃圾回收發生之前都處於活動狀態(不會被回收)。

子句在 Python 中並沒有獨立的作用域。示例中的所有內容都處於同一作用域內,所以變數 e 會由於執行了 except 子句而被刪除。而對於有獨立的內部作用域的函式來說情況就不一樣了。下面的例子說明了這一點:

def f(x):
    del(x)
    print(x)

x = 5
y = [5, 4, 3]
複製程式碼

Output:

>>>f(x)
UnboundLocalError: local variable 'x' referenced before assignment
>>>f(y)
UnboundLocalError: local variable 'x' referenced before assignment
>>> x
5
>>> y
[5, 4, 3]
複製程式碼

19. bool 型別

True = False
if True == False:
    print("I've lost faith in truth!")
複製程式碼

Output:

I've lost faith in truth!
複製程式碼

說明: 最初,Python 並沒有 bool 型(人們用 0 表示假值, 用非零值比如 1 作為真值)。後來他們新增了 True, False, 和 bool 型,但是,為了向後相容,他們沒法把 TrueFalse 設定為常量,只是設定成了內建變數。 Python 3 由於不再需要向後相容,終於可以修復這個問題了,所以這個例子無法在 Python 3.x 中執行。

20. append 方法陷阱

some_list = [1, 2, 3]
some_dict = {
  "key_1": 1,
  "key_2": 2,
  "key_3": 3
}

some_list = some_list.append(4)
some_dict = some_dict.update({"key_4": 4})
複製程式碼

Output:

>>> print(some_list)
None
>>> print(some_dict)
None
複製程式碼

說明: 大多數修改序列/對映物件的方法,比如 list.appenddict.updatelist.sort 等等,都是原地修改物件並返回 None,這樣可以避免建立物件的副本來提高效能。


掃碼關注我的個人公眾號

相關文章