GitHub 上有一個名為《What the f*ck Python!》的專案,這個有趣的專案意在收集 Python 中那些難以理解和反人類直覺的例子以及鮮為人知的功能特性,並嘗試討論這些現象背後真正的原理! 原版地址:github.com/satwikkansa…。 最近,一位名為“暮晨”的貢獻者將其翻譯成了中文。 中文版地址:github.com/leisurelich…
原本每個的標題都是原版中的英文,有些取名比較奇怪,不直觀,我換成了可以描述主題的中文形式,有些是自己想的,不足之處請指正。另外一些 Python 中的彩蛋被我去掉了。
我將所有程式碼都親自試過了,加入了一些自己的理解和例子,所以會和原文稍有不同。
21. 子類關係
>>> from collections import Hashable
>>> issubclass(list, object)
True
>>> issubclass(object, Hashable)
True
>>> issubclass(list, Hashable)
False
複製程式碼
子類關係應該是可傳遞的,對吧?即,如果 A
是 B
的子類,B
是 C
的子類,那麼 A
應該 是 C
的子類。
說明:
- Python 中的子類關係並不必須是傳遞的,任何人都可以在元類中隨意定義
__subclasscheck__
。 - 當
issubclass(cls, Hashable)
被呼叫時,它只是在cls
中尋找__hash__()
方法或繼承自__hash__()
的方法。 - 由於
object
是可雜湊的(hashable),而list
是不可雜湊的,所以它打破了這種傳遞關係。
22. 神祕的鍵型轉換
class SomeClass(str):
pass
some_dict = {'s': 42}
複製程式碼
Output:
>>> type(list(some_dict.keys())[0])
<class 'str'>
>>> s = SomeClass('s')
>>> some_dict[s] = 40
>>> some_dict # 預期: 兩個不同的鍵值對
{'s': 40}
>>> type(list(some_dict.keys())[0])
<class 'str'>
複製程式碼
說明:
- 由於
SomeClass
會從str
自動繼承__hash__()
方法,所以s
物件和's'
字串的雜湊值是相同的。 - 而
SomeClass('s') == 's'
為True
是因為SomeClass
也繼承了str
類__eq__()
方法。 - 由於兩者的雜湊值相同且相等,所以它們在字典中表示相同的鍵。
如果想要實現期望的功能, 我們可以重定義 SomeClass
的 __eq__()
方法.
class SomeClass(str):
def __eq__(self, other):
return (
type(self) is SomeClass
and type(other) is SomeClass
and super().__eq__(other)
)
# 當我們自定義 __eq__() 方法時, Python 不會再自動繼承 __hash__() 方法
# 所以我們也需要定義它
__hash__ = str.__hash__
some_dict = {'s':42}
複製程式碼
Output:
>>> s = SomeClass('s')
>>> some_dict[s] = 40
>>> some_dict
{'s': 40, 's': 42}
>>> keys = list(some_dict.keys())
>>> type(keys[0]), type(keys[1])
<class 'str'> <class '__main__.SomeClass'>
複製程式碼
23. 鏈式賦值表示式
>>> a, b = a[b] = {}, 5
>>> a
{5: ({...}, 5)}
複製程式碼
說明: 根據 Python 語言參考,賦值語句的形式如下:
(target_list "=")+ (expression_list | yield_expression)
複製程式碼
賦值語句計算表示式列表(expression list)(請記住,這可以是單個表示式或以逗號分隔的列表,後者返回元組)並將單個結果物件從左到右分配給目標列表中的每一項。
(target_list "=")+
中的 +
意味著可以有一個或多個目標列表。在這個例子中,目標列表是 a, b
和 a[b]
。表示式列表只能有一個,是 {}, 5
。
這話看著非常的晦澀,我們來看一個簡單的例子:
a, b = b, c = 1, 2
print(a, b, c)
複製程式碼
Output:
1 1 2
複製程式碼
在這個簡單的例子中,目標列表是 a, b
和 b, c
,表示式是 1, 2
。將表示式從左到右賦給目標列表,上述例子就可以拆分成:
a, b = 1, 2
b, c = 1, 2
複製程式碼
所以結果就是 1 1 2
。
那麼,原例子就不難理解了,拆解開來就是:
a, b = {}, 5
a[b] = a, b
複製程式碼
這裡不能寫作 a[b] = {}, 5
,因為這樣第一句中的 {}
和第二句中的 {}
其實就是不同的物件了,而實際他們是同一個物件。這就形成了迴圈引用,輸出中的 {...}
指與 a
引用了相同的物件。
我們來驗證一下:
>>> a[b][0] is a
True
複製程式碼
可見確實是同一個物件。
以下是一個簡單的迴圈引用的例子:
>>> some_list = some_list[0] = [0]
>>> some_list
[[...]]
>>> some_list[0]
[[...]]
>>> some_list is some_list[0]
True
>>> some_list[0][0][0][0][0][0] == some_list
True
複製程式碼
24. 空間移動
import numpy as np
def energy_send(x):
# 初始化一個 numpy 陣列
np.array([float(x)])
def energy_receive():
# 返回一個空的 numpy 陣列
return np.empty((), dtype=np.float).tolist()
複製程式碼
Output:
>>> energy_send(123.456)
>>> energy_receive()
123.456
複製程式碼
說明:
energy_send()
函式中建立的 numpy
陣列並沒有返回,因此記憶體空間被釋放並可以被重新分配。
numpy.empty()
直接返回下一段空閒記憶體,而不重新初始化。而這個記憶體點恰好就是剛剛釋放的那個(通常情況下,並不絕對)。
25. 不要混用製表符(tab)和空格(space)
tab 是 8 個空格,而用空格表示則一個縮排是 4 個空格,混用就會出錯。python3 裡直接不允許這種行為了,會報錯:
TabError: inconsistent use of tabs and spaces in indentation
很多編輯器,例如 pycharm,可以直接設定 tab 表示 4 個空格。
26. 迭代字典時的修改
x = {0: None}
for i in x:
del x[i]
x[i+1] = None
print(i)
複製程式碼
Output(Python 2.7- Python 3.5):
0
1
2
3
4
5
6
7
複製程式碼
說明: Python 不支援 對字典進行迭代的同時修改它,它之所以執行 8 次,是因為字典會自動擴容以容納更多鍵值(譯: 應該是因為字典的初始最小值是8,擴容會導致雜湊表地址發生變化而中斷迴圈)。 在不同的 Python 實現中刪除鍵的處理方式以及調整大小的時間可能會有所不同,python3.6 開始,到 5 就會擴容。
而在 list
中,這種情況是允許的,list
和 dict
的實現方式是不一樣的,list
雖然也有擴容,但 list
的擴容是整體搬遷,並且順序不變。
list = [1]
j = 0
for i in list:
print(i)
list.append(i + 1)
複製程式碼
這個程式碼可以一直執行下去直到 int
越界。但一般不建議在迭代的同時修改 list
。
27. _del_
class SomeClass:
def __del__(self):
print("Deleted!")
複製程式碼
Output:
>>> x = SomeClass()
>>> y = x
>>> del x # 這裡應該會輸出 "Deleted!"
>>> del y
Deleted!
複製程式碼
說明:
del x
並不會立刻呼叫x.__del__()
,每當遇到del x
,Python 會將 x
的引用數減 1,當 x
的引用數減到 0 時就會呼叫x.__del__()
。
我們再加一點變化:
>>> x = SomeClass()
>>> y = x
>>> del x
>>> y # 檢查一下y是否存在
<__main__.SomeClass instance at 0x7f98a1a67fc8>
>>> del y # 像之前一樣,這裡應該會輸出 "Deleted!"
>>> globals() # 好吧, 並沒有。讓我們看一下所有的全域性變數
Deleted!
{'__builtins__': <module '__builtin__' (built-in)>, 'SomeClass': <class __main__.SomeClass at 0x7f98a1a5f668>, '__package__': None, '__name__': '__main__', '__doc__': None}
複製程式碼
y.__del__()
之所以未被呼叫,是因為前一條語句(>>> y
)對同一物件建立了另一個引用,從而防止在執行del y
後物件的引用數變為 0。(這其實是 Python 互動直譯器的特性,它會自動讓 _
儲存上一個表示式輸出的值。)
呼叫globals()
導致引用被銷燬,因此我們可以看到 Deleted!
終於被輸出了。
28. 迭代列表時刪除元素
在前面我附加了一個迭代列表時新增元素的例子,現在來看看迭代列表時刪除元素。
list_1 = [1, 2, 3, 4]
list_2 = [1, 2, 3, 4]
list_3 = [1, 2, 3, 4]
list_4 = [1, 2, 3, 4]
for idx, item in enumerate(list_1):
del item
for idx, item in enumerate(list_2):
list_2.remove(item)
for idx, item in enumerate(list_3[:]):
list_3.remove(item)
for idx, item in enumerate(list_4):
list_4.pop(idx)
複製程式碼
Output:
>>> list_1
[1, 2, 3, 4]
>>> list_2
[2, 4]
>>> list_3
[]
>>> list_4
[2, 4]
複製程式碼
說明:
在迭代時修改物件是一個很愚蠢的主意,正確的做法是迭代物件的副本,list_3[:]
就是這麼做的。
del、remove、pop 的不同:
del var_name
只是從本地或全域性名稱空間中刪除了 var_name(這就是為什麼list_1
沒有受到影響)。remove
會刪除第一個匹配到的指定值,而不是特定的索引,如果找不到值則丟擲ValueError
異常。pop
則會刪除指定索引處的元素並返回它,如果指定了無效的索引則丟擲IndexError
異常。
為什麼輸出是 [2, 4]?
列表迭代是按索引進行的,所以當我們從 list_2
或 list_4
中刪除 1 時,列表的內容就變成了[2, 3, 4]
。剩餘元素會依次位移,也就是說,2
的索引會變為 0,3
會變為 1。由於下一次迭代將獲取索引為 1 的元素(即3
), 因此2
將被徹底的跳過。類似的情況會交替發生在列表中的每個元素上。
29. 迴圈變數洩漏!
①
for x in range(7):
if x == 6:
print(x, ': for x inside loop')
print(x, ': x in global')
複製程式碼
Output:
6 : for x inside loop
6 : x in global
複製程式碼
②
# 這次我們先初始化x
x = -1
for x in range(7):
if x == 6:
print(x, ': for x inside loop')
print(x, ': x in global')
複製程式碼
Output:
6 : for x inside loop
6 : x in global
複製程式碼
③
x = 1
print([x for x in range(5)])
print(x, ': x in global')
複製程式碼
Output(Python 2):
[0, 1, 2, 3, 4]
(4, ': x in global')
複製程式碼
Output(Python 3):
[0, 1, 2, 3, 4]
1 : x in global
複製程式碼
說明:
在 Python 中,for
迴圈使用所在作用域並在結束後保留定義的迴圈變數。如果我們曾在全域性名稱空間中定義過迴圈變數,它會重新繫結現有變數。
Python 2.x 和 Python 3.x 直譯器在列表推導式示例中的輸出差異,在文件 What’s New In Python 3.0 中可以找到相關的解釋:
"列表推導不再支援句法形式
[... for var in item1, item2, ...]
。使用[... for var in (item1, item2, ...)]
代替。另外注意,列表推導具有不同的語義:它們更接近於list()
建構函式中生成器表示式的語法糖,特別是迴圈控制變數不再洩漏到周圍的作用域中。"
簡單來說,就是 python2 中,列表推導式依然存在迴圈控制變數洩露,而 python3 中不存在。
30. 當心預設的可變引數!
def some_func(default_arg=[]):
default_arg.append("some_string")
return default_arg
複製程式碼
Output:
>>> some_func()
['some_string']
>>> some_func()
['some_string', 'some_string']
>>> some_func([])
['some_string']
>>> some_func()
['some_string', 'some_string', 'some_string']
複製程式碼
說明:
Python 中函式的預設可變引數並不是每次呼叫該函式時都會被初始化。相反,它們會使用最近分配的值作為預設值。當我們明確的將 []
作為引數傳遞給 some_func
的時候,就不會使用 default_arg
的預設值, 所以函式會返回我們所期望的結果。
>>> some_func.__defaults__ # 這裡會顯示函式的預設引數的值
([],)
>>> some_func()
>>> some_func.__defaults__
(['some_string'],)
>>> some_func()
>>> some_func.__defaults__
(['some_string', 'some_string'],)
>>> some_func([])
>>> some_func.__defaults__
(['some_string', 'some_string'],)
複製程式碼
避免可變引數導致的錯誤的常見做法是將 None
指定為引數的預設值,然後檢查是否有值傳給對應的引數。例:
def some_func(default_arg=None):
if not default_arg:
default_arg = []
default_arg.append("some_string")
return default_arg
複製程式碼
31. 捕獲異常
這裡講的是 python2
some_list = [1, 2, 3]
try:
# 這裡會丟擲異常 ``IndexError``
print(some_list[4])
except IndexError, ValueError:
print("Caught!")
try:
# 這裡會丟擲異常 ``ValueError``
some_list.remove(4)
except IndexError, ValueError:
print("Caught again!")
複製程式碼
Output:
Caught!
ValueError: list.remove(x): x not in list
複製程式碼
說明:
如果你想要同時捕獲多個不同型別的異常時,你需要將它們用括號包成一個元組作為第一個引數傳遞。第二個引數是可選名稱,如果你提供,它將與被捕獲的異常例項繫結。
也就是說,程式碼原意是捕獲 IndexError, ValueError
兩種異常,但在 python2 中,必須寫成(IndexError, ValueError)
,示例中的寫法解析器會將 ValueError
理解成繫結的異常例項名。
在 python3 中,不會有這種誤解,因為必須使用as
關鍵字。
32. +=就地修改
①
a = [1, 2, 3, 4]
b = a
a = a + [5, 6, 7, 8]
複製程式碼
Output:
>>> a
[1, 2, 3, 4, 5, 6, 7, 8]
>>> b
[1, 2, 3, 4]
複製程式碼
②
a = [1, 2, 3, 4]
b = a
a += [5, 6, 7, 8]
複製程式碼
Output:
>>> a
[1, 2, 3, 4, 5, 6, 7, 8]
>>> b
[1, 2, 3, 4, 5, 6, 7, 8]
複製程式碼
說明:
a += b
並不總是與 a = a + b
表現相同。
表示式 a = a + [5,6,7,8]
會生成一個新列表,並讓 a
引用這個新列表,同時保持 b
不變。
表示式 a += [5, 6, 7, 8]
實際上是使用的是 extend()
函式,就地修改列表,所以 a
和 b
仍然指向已被修改的同一列表。
33. 外部作用域變數
a = 1
def some_func():
return a
def another_func():
a += 1
return a
複製程式碼
Output:
>>> some_func()
1
>>> another_func()
UnboundLocalError: local variable 'a' referenced before assignment
複製程式碼
說明:
當在函式中引用外部作用域的變數時,如果不對這個變數進行修改,則可以直接引用,如果要對其進行修改,則必須使用 global
關鍵字,否則解析器將認為這個變數是區域性變數,而做修改之前並沒有定義它,所以會報錯。
def another_func()
global a
a += 1
return a
複製程式碼
Output:
>>> another_func()
2
複製程式碼
34. 小心鏈式操作
>>> (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
複製程式碼
根據 docs.python.org/2/reference…
形式上,如果 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 == False in [False]
就相當於False == False and False in [False]
1 > 0 < 1
就相當於1 > 0 and 0 < 1
雖然上面的例子似乎很愚蠢,但是像 a == b == c
或 0 <= x <= 100
就很棒了。
35. 忽略類作用域的名稱解析
① 生成器表示式
x = 5
class SomeClass:
x = 17
y = (x for i in range(10))
複製程式碼
Output:
>>> list(SomeClass.y)
[5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
複製程式碼
② 列表推導式
x = 5
class SomeClass:
x = 17
y = [x for i in range(10)]
複製程式碼
Output(Python 2):
>>> SomeClass.y
[17, 17, 17, 17, 17, 17, 17, 17, 17, 17]
複製程式碼
Output(Python 3):
>>> SomeClass.y
[5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
複製程式碼
說明:
- 類定義中巢狀的作用域會忽略類內的名稱繫結。
- 生成器表示式有它自己的作用域。
- 從 Python 3 開始,列表推導式也有自己的作用域。
36. 元組
①
x, y = (0, 1) if True else None, None
複製程式碼
Output:
>>> x, y # 期望的結果是 (0, 1)
((0, 1), None)
複製程式碼
②
t = ('one', 'two')
for i in t:
print(i)
t = ('one')
for i in t:
print(i)
t = ()
print(t)
複製程式碼
Output:
one
two
o
n
e
tuple()
複製程式碼
說明:
- 對於 1,正確的語句是
x, y = (0, 1) if True else (None, None)
。 - 對於 2,正確的語句是
t = ('one',)
或者t = 'one'
, (缺少逗號) 否則直譯器會認為t
是一個字串,並逐個字元對其進行迭代。 ()
是一個特殊的標記,表示空元組。
37. else
① 迴圈末尾的 else
def does_exists_num(l, to_find):
for num in l:
if num == to_find:
print("Exists!")
break
else:
print("Does not exist")
複製程式碼
Output:
>>> some_list = [1, 2, 3, 4, 5]
>>> does_exists_num(some_list, 4)
Exists!
>>> does_exists_num(some_list, -1)
Does not exist
複製程式碼
② try 末尾的 else
try:
pass
except:
print("Exception occurred!!!")
else:
print("Try block executed successfully...")
複製程式碼
Output:
Try block executed successfully...
複製程式碼
說明:
迴圈後的 else
子句只會在迴圈執行完成(沒有觸發 break、return
語句)的情況下才會執行。
try
之後的 else
子句也被稱為 "完成子句",因為在 try
語句中到達 else
子句意味著 try
塊實際上已成功完成。
38. 名稱改寫
class Yo(object):
def __init__(self):
self.__honey = True
self.bitch = True
複製程式碼
Output:
>>> Yo().bitch
True
>>> Yo().__honey
AttributeError: 'Yo' object has no attribute '__honey'
>>> Yo()._Yo__honey
True
複製程式碼
說明:
python 中不能像 Java 那樣使用 private
修飾符建立私有屬性。但是,直譯器會通過給類中以 __(雙下劃線)開頭且結尾最多隻有一個下劃線的類成員名稱加上 __類名_
來修飾。這能避免子類意外覆蓋父類的“私有”屬性。
舉個例子:有人編寫了一個名為 Dog
的類,這個類的內部用到了 mood
例項屬性,但是沒有將其開放。現在,你建立了 Dog
類的子類 Beagle
,如果你在毫不知情的情況下又建立了一個 mood
例項屬性,那麼在繼承的方法中就會把 Dog
類的 mood
屬性覆蓋掉。
為了避免這種情況,python 會將 __mood
變成 _Dog__mood
,而對於 Beagle 類來說,會變成 _Beagle__mood
。這個語言特性就叫名稱改寫(name mangling)。
39. +=更快
>>> timeit.timeit("s1 = s1 + s2 + s3", setup="s1 = ' ' * 100000; s2 = ' ' * 100000; s3 = ' ' * 100000", number=100)
0.25748300552368164
# 用 "+=" 連線三個字串:
>>> timeit.timeit("s1 += s2 + s3", setup="s1 = ' ' * 100000; s2 = ' ' * 100000; s3 = ' ' * 100000", number=100)
0.012188911437988281
複製程式碼
說明:
連線兩個以上的字串時 +=
比 +
更快,因為在計算過程中第一個字串(例如, s1 += s2 + s3
中的 s1
)不會被銷燬。(就是 +=
執行的是追加操作,少了一個銷燬新建的動作。)