Python 簡單入門指北(試讀版)

bestswifter發表於2017-11-03

本文是我小專欄中 Python 簡單入門指北 一文的前半部分,如果你能堅持讀完並且覺得有一定收穫,建議閱讀原文,只需一杯咖啡錢就可以閱讀更精彩的部分,也可以訂閱小專欄或者加入我的知識星球,價格都是 66 元永久。

Python 是一門非常容易上手的語言,通過查閱資料和教程,也許一晚上就能寫出一個簡單的爬蟲。但 Python 也是一門很難精通的語言,因為簡潔的語法背後隱藏了許多黑科技。本文主要針對的讀者是:

  1. 毫無 Python 經驗的小白
  2. 有一些簡單 Python 經驗,但只會複製貼上程式碼,不知其所以然的讀者
  3. 覺得單獨一篇文章太瑣碎,質量沒保證,卻沒空讀完一本書,但又想對 Python 有全面瞭解的讀者

當然, 用一篇文章來講完某個語言是不可能的事情,我希望讀完本文的讀者可以:

  1. 對 Python 的整體知識結構形成初步的概念
  2. 瞭解 Python 特有的知識點,比如裝飾器、上下文、生成器等等,不僅會寫 Demo,還對背後的原理有一定了解
  3. 避免 C++/Java 等風格的 Python 程式碼,能夠寫出地道的 Python 程式碼
  4. 能夠熟練的使用 Python 編寫指令碼實現日常的簡單需求,能夠維護小型 Python 專案,能夠閱讀較複雜的 Python 原始碼

如果以上介紹符合你對自己的定位,在開始閱讀前,還需要明確幾點:

  1. 本文不會只介紹用法,那樣太膚淺
  2. 本文不會深入介紹某個知識點,比如分析原始碼等,那樣太囉嗦,我希望做一名引路人,描述各個知識點的概貌並略作引申,為讀者指出下一步的研究方向
  3. 程式碼註釋非常重要,一定要看,幾乎所有的程式碼段都可以執行,強烈建議手敲一遍!

0. 準備工作

請不要在學習 Python2 還是 Python3 之間猶豫了,除非你很明確自己只接觸 Python2,否則就從 Python3 學起,新版本的語言總是意味著進步的生產力(Swift 和 Xcode 除外)。Python 2 和 3 之間語法不相容,但這並不影響熟悉 Python3 的開發者迅速寫出 Python 2 的程式碼,反之亦然。所以與其在反覆糾結中浪費時間,不如立刻行動起來。

推薦使用 CodeRunner 來執行本文中的 demo,它比文字編輯器功能更強大,比如支援自動補全和斷點除錯,又比 PyCharm 輕量得多。

1. 資料結構

1.1 陣列

1.1.1 列表推導

如果要對陣列中的所有內容做一些修改,可以用 for 迴圈或者 map 函式:

array = [1, 2, 3, 4, 5, 6]
small = []

for n in array: 
    if n < 4:
        small.append(n * 2)

print(small)  # [2, 4, 6]複製程式碼

比較地道的 Python 寫法是使用列表推導:

array = [1, 2, 3, 4, 5, 6]
small = [n * 2 for n in array if n < 4]複製程式碼

for in 可以寫兩次,類似於巢狀的 for 迴圈,會得到一個笛卡爾積:

signs = ['+', '-']
numbers = [1, 2]

ascii = ['{sign}{number}'.format(sign=sign, number=number) 
        for sign in signs for number in numbers]
# 得到:['+1', '+2', '-1', '-2']複製程式碼

1.1.2 元組

元組可以簡單的理解為不可變的陣列,也就是沒有 appenddel 等方法,一旦建立,就無法新增或刪除元素,元素自身的值也不能改變,但元素內部的屬性是否可變並不受元組的影響,這一點符合其他語言中的常識。

t = (1, [])
t[0] = 3  # 丟擲錯誤 TypeError: 'tuple' object does not support item assignment
t[1].append(2)  # 正常執行,現在的 t 是 (1, [2])複製程式碼

除了不可變性以外,有時候元組也會被當做不具名的資料結構,這時候元素的位置就不再是可有可無的了:

coordinate = (33.9425, -118.408056)
# coordinate 的第一個位置用來表示精度,第二個位置表示維度複製程式碼

在解析元組資料時,可以一一對應的寫上變數名:

t = (1, 2)
a, b = t # a = 1, b = 2複製程式碼

有時候變數名比較長, 但我只關心其中某一個,可以這樣寫:

t = (1, 2)
a, _ = t # a = 1複製程式碼

如果元組中元素特別多,即使挨個寫下劃線也比較累,可以用 * 來批量解包:

t = (1, 2, 3, 4, 5)
first, *middle, last = t
# first = 1
# middle = [2, 3, 4]
# last = 5複製程式碼

當然,如果元素數量較多,含義較複雜,我還是建議使用具名元組:

import collections

People = collections.namedtuple('People', ['name', 'age'])
p = People('bestswifter', '22')
p.name # 22複製程式碼

具名元組更像是一個不能定義方法的簡化版的類,能提供友好的資料展示。

元組的一個小技巧是可以避免用臨時變數來交換兩個數的值:

a = 1 
b = 2
a, b = b, a
# a = 2, b = 1複製程式碼

1.1.3 陣列切片

切片的基本格式是 array[start:end:step],表示對 array 在 start 到 end 之前以 step 為間隔取切片。注意這裡的區間是 [start, end),也就是左閉右開。比如:

s = 'hello'
s[0:5:2]
# 表示取 s 的第 0、2、4 個字元,結果是 'hlo'複製程式碼

再舉幾個例子

s[0:5]  # 不寫 step 預設就是 1,因此得到 'hello'
s[1:]   # 不寫 end 預設到結尾,因此還是得到 'ello'
s[n:]   # 獲取 s 的最後 len(s) - n 個元素
s[:2]   # 不寫 start 預設從 0 開始,因此得到 'he'
s[:n]   # 獲取 s 的前 n 個元素
s[:-1]  # 負數表示倒過來數,因此這會刨除最後一個字元,得到 'hell'
s[-2:]  # 同上,表示獲取最後兩個字元,得到 'lo'
s[::-1] # 獲取字串的倒序排列,相當於 reverse 函式複製程式碼

step 和它前面的冒號要麼同時寫,要麼同時不寫,但 start 和 end 之間的冒號不能省,否則就不是切片而是獲取元素了。再次強調 array[start:end] 表示的區間是 [a, b),也許你會覺得這很難記,但同樣的,這會得出以下美妙的公式:

array[:n] + array[n:] = array (0 <= n <= len(array))

用程式碼來表示就是:

s = 'hello'
s[:2] + s[2:] == s  
# True,因為 s[:2] 是 'he',s[2:] 是 'llo'複製程式碼

切片不僅可以用來獲取陣列的一部分值,修改切片也可以直接修改陣列的對應部分,比如:

a = [1, 2, 3, 4, 5, 6]
a[1:3] = [22, 33, 44]
# a = [1, 22, 33, 44, 4, 5, 6]複製程式碼

並沒有人規定切片的新值必須和原來的長度一致:

a = [1, 2, 3, 4, 5, 6]
a[1:3] = [3]
# a = [1, 3, 4, 5, 6]

a[1:4] = []
# a = [1, 6],相當於刪除了中間的三個數字複製程式碼

但切片的新值必須也是可迭代的物件,比如這樣寫是不合法的:

a = [1, 2, 3, 4, 5, 6]
a[1:3] = 3
# TypeError: can only assign an iterable複製程式碼

1.1.4 迴圈與遍歷

一般來說,在 Python 中我們不會寫出 for (int i = 0; i < len(array); ++i) 這種風格的程式碼,而是使用 for in 這種語法:

for i in [1, 2, 3]:
    print(i)複製程式碼

雖然大家都知道 for in 語法,但它的某些靈活用法或許就不是那麼眾所周知了。有時候,我們會在 if 語句中對某個變數的值做多次判斷,只要滿足一個條件即可:

name = 'bs'
if name == 'hello' or name == 'hi' or name == 'bs' or name == 'admin':
    print('Valid')複製程式碼

這種情況推薦用 in 來代替:

name = 'bs'
if name in ('hello', 'hi', 'bs', 'admin'):
    print('Valid')複製程式碼

有時候,如果我們想要把某件事重複固定的次數,用 for in 會顯得有些囉嗦,這時候可以藉助 range 型別:

for i in range(5):
    print('Hi') # 列印五次 'Hi'複製程式碼

range 的語法和切片類似,比如我們需要訪問陣列所有奇數下標的元素,可以這麼寫:

a = [1, 2, 3, 4, 5]
for i in range(0, len(a), 2):
    print(a[i])複製程式碼

在這種寫法中,我們不僅能獲得元素,還能知道元素的下標,這與使用 enumerate(iterable [, start ]) 函式類似:

a = [1, 2, 3, 4, 5]
for i, n in enumerate(a):
    print(i, n)複製程式碼

1.1.5 魔術方法

也許你已經注意到了,陣列和字串都支援切片,而且語法高度統一。這在某些強型別語言(比如我經常接觸的 Objective-C 和 Java)中是不可能的,事實上,Python 能夠支援這樣統一的語法,並非巧合,而是因為所有用中括號進行下標訪問的操作,其實都是呼叫這個類的 __getitem__ 方法。

比如我們完全可以讓自己的類也支援通過下標訪問:

class Book:
    def __init__(self):
        self.chapters = [1, 2, 3]

    def __getitem__(self, n):
        return self.chapters[n]

b = Book()
print(b[1]) # 結果是 2複製程式碼

需要注意的是,這段程式碼幾乎不會出問題(除非陣列越界),這是因為我們直接把下標傳到了內部的 self.chapters 陣列上。但如果要自己處理下標,需要牢記它不一定是數字,也可以是切片,因此更完整的邏輯應該是:

def __getitem__(self, n):
    if isinstance(n, int): # n是索引
        # 處理索引
    if isinstance(n, slice): # n是切片
        # 通過 n.start,n.stop 和 n.step 來處理切片複製程式碼

與靜態語言不同的是,任何實現了 __getitem__ 都支援通過下標訪問,而不用宣告為實現了某個協議,這種特性也被稱為 “鴨子型別”。鴨子型別並不要求某個類 是什麼,僅僅要求這個類 能做什麼

順便說一句,實現了 __getitem__ 方法的類都是可迭代的,比如:

b = Book()
for c in b:
    print(c)複製程式碼

後續的章節還會介紹更多 Python 中的魔術方法,這種方法的名稱前後都有兩個下劃線,如果讀作 “下劃線-下劃線-getitem” 會比較拗口,因此可以讀作 “dunder-getitem” 或者 “雙下-getitem”,類似的,我想每個人都能猜到 __setitem__ 的作用和用法。

1.2 字典

1.2.1 初始化字典

最簡單的建立一個字典的方式就是直接寫字面量:

{'a': 61, 'b': 62, 'c': 63, 'd': 64, 'e': 65}複製程式碼

字典字面量由大括號包住(注意區別於陣列的中括號),鍵值對之間由逗號分割,每個鍵值對內部用冒號分割鍵和值。

如果陣列的每個元素都是二元的元組,這個陣列可以直接轉成字典:

dict([('a', 61), ('b', 62), ('c', 63), ('d', 64), ('e', 65)])複製程式碼

就像陣列可以推導一樣,字典也可以推導:

a = [('a', 61), ('b', 62), ('c', 63), ('d', 64), ('e', 65)]
d = {letter: number for letter, number in a} # 這裡用到了元組拆包複製程式碼

只要記得外面還是大括號就行了。

兩個獨立的陣列可以被壓縮成一個字典:

numbers = [61, 62, 63, 64, 65]
letters = ['a', 'b', 'c', 'd', 'e']
dict(zip(letters, numbers))複製程式碼

正如 zip 的意思所表示的,超出長處的那部分陣列會被拋棄。

1.2.2 查詢字典

最簡單方法是直接寫鍵名,但如果鍵名不存在會丟擲 KeyError

d = {'a': 61}
d['a'] # 值是 61
d['b'] # KeyError: 'b'複製程式碼

可以用 if key in dict 的判斷來檢查鍵是否存在,甚至可以先 trycatch KeyError ,但更加優雅簡潔一些的寫法是用 get(k, default) 方法來提供預設值:

d = {'a': 61}
d.get('a', 62) # 得到 61
d.get('b', 62) # 得到 62複製程式碼

不過有時候,我們可能不僅僅要讀出預設屬性,更希望能把這個預設屬效能寫入到字典中,比如:

d = {}
# 我們想對字典中某個 Value 做操作,如果 Key 不存在,就先寫入一個空值
if 'list' not in d:
    d['list'] = []
d['list'].append(1)複製程式碼

這種情況下,seddefault(key, default) 函式或許更合適:

d.setdefault('key', []).append(1)複製程式碼

這個函式雖然名為 set,但作用其實是查詢,僅僅在查詢不到時才會把預設值寫入字典。

1.2.3 遍歷字典

直接遍歷字典實際上是遍歷了字典的鍵,因此也可以通過鍵獲取值:

d = {'a': 61, 'b': 62, 'c': 63, 'd': 64, 'e': 65}
for i in d:
    print(i, d[i])
#b 62
#a 61
#e 65
#d 64
#c 63複製程式碼

我們也可以用字典的 keys() 或者 values() 方法顯式的獲取鍵和值。字典還有一個 items() 方法,它返回一個陣列,每個元素都是由鍵和值組成的二元元組:

d = {'a': 61, 'b': 62, 'c': 63, 'd': 64, 'e': 65}
for (k, v) in d.items():
    print(k, v)
#e 65
#d 64
#a 61
#c 63
#b 62複製程式碼

可見 items() 方法和字典的構造方法互為逆操作,因為這個公式總是成立的:

dict(d.items()) == d

1.2.4 字典的魔術方法

在 1.1.4 節中介紹過,通過下標訪問最終都會由 __getitem__ 這個魔術方法處理,因此字典的 d[key] 這種寫法也不例外, 如果鍵不存在,則會走到 __missing__ 方法,再給一次挽救的機會。比如我們可以實現一個字典, 自動忽略鍵的大小寫:

class MyDict(dict):
    def __missing__(self, key):
        if key.islower():
            raise KeyError(key)
        else:
            return self[key.lower()]

d = MyDict({'a': 61})
d['A'] # 返回 61
'A' in d # False複製程式碼

這個字典比較簡陋,比如 key 可能不是字串,不過我沒有處理太多情況,因為它主要是用來演示 __missing__ 的用法,如果想要最後一行的 in 語法正確工作,需要重寫 __contains__ 這個魔術方法,過程類似,就不贅述了。

雖然通過自定義的函式也能實現相似的效果,不過這個自定義字典對使用者更加透明,如果不在文件中說明,呼叫方很難察覺到字典的內部邏輯被修改了。 Python 有很多強大的功能,可以具備這種內部進行修改,但是對外保持透明的能力。這可能是我們第一次體會到,後續還會不斷的經歷。

1.2.5 集合

集合更像是不會有重複元素的陣列,但它的本質是以元素的雜湊值作為 Key,從而實現去重的邏輯。因此,集合也可以推導,不過得用字典的語法:

a = [1,2,3,4,5,4,3,2,1]
d = {i for i in a if i < 5}
# d = {1, 2, 3, 4},注意這裡的大括號複製程式碼

回憶一下,二進位制邏輯運算一共有三個運算子,按位或 |,按位與 & 和異或 ^,這三個運算子也可以用在集合之間,而且含義變化不大。比如:

a = {1, 2, 3}
b = {3, 4, 5}
c = a | b
# c = {1, 2, 3, 4, 5}複製程式碼

這裡的 | 運算表示交集,也就是 c 中的任意元素,要麼在 a,要麼在 b 集合中。類似的,按位與 & 運算求的就是交集:

a = {1, 2, 3}
b = {3, 4, 5}
c = a & b
# c = {3}複製程式碼

而異或則表示那些只在 a 不在 b 或者只在 b 不在 a 的元素。或者換個說法,表示那些在集合 a 和 b 中出現了且僅出現了一次的元素:

a = {1, 2, 3}
b = {3, 4, 5}
c = a ^ b
# c = {1, 2, 4, 5}複製程式碼

還有一個差集運算 -,表示在集合 a 中但不在集合 b 中的元素:

a = {1, 2, 3}
b = {3, 4, 5}
c = a - b
# c = {1, 2}複製程式碼

回憶一下韋恩圖,就會得到以下公式(雖然並沒有什麼卵用):

A | B = (A ^ B) | (A & B)
A ^ B = (A - B) | (B - A)

1.3 字串

1.3.1 字串編碼

用 Python 寫過爬蟲的人都應該感受過被字串編碼支配的恐懼。簡單來說,編碼指的是將可讀的字串轉換成不太可讀的數字,用來儲存或者傳輸。解碼則指的是將數字還原成字串的過程。常見的編碼有 ASCII、GBK 等。

ASCII 編碼是一個相當小的字符集合,只有一百多個常用的字元,因此只用一個位元組(8 位)就能表示,為了儲存本國語言,各個國家都開發出了自己的編碼,比如中文的 GBK。這就帶來了一個問題,如果我想要在一篇文章中同時寫中文和日文,就無法實現了,除非能對每個字元指定編碼,這個成本高到無法接受。

Unicode 則是一個最全的編碼方式,每個 Unicode 字元佔據 6 個位元組,可以表示出 2 ^ 48 種字元。但隨之而來的是 Unicode 編碼後的內容不適合儲存和傳送,因此誕生了基於 Unicode 的再次編碼,目的是為了更高效的儲存。

更詳細的概念分析和配圖說明可以參考我的這篇文章:字串編碼入門科普,這裡我們主要聊聊 Python 對字串編碼的處理。

首先,編碼的函式是 encode,它是字串的方法:

s = 'hello'
s.encode()         # 得到 b'hello'
s.encode('utf16')  # 得到 b'\xff\xfeh\x00e\x00l\x00l\x00o\x00'複製程式碼

encode 函式有兩個引數,第一個引數不寫表示使用預設的 utf8 編碼,理論上會輸出二進位制格式的編碼結果,但在終端列印時,被自動還原回字串了。如果用 utf16 進行編碼,則會看到編碼以後的二進位制結果。

前面說過,編碼是字元轉到二進位制的轉化過程,有時候在某個編碼規範中,並沒有指定某個字元是如何編碼的,也就是找不到對應的數字,這時候編碼就會報錯:

city = 'São Paulo'
b_city = city.encode('cp437')
# UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>複製程式碼

此時需要用到 encode 函式的第二個引數,用來指定遇到錯誤時的行為。它的值可以是 'ignore',表示忽略這個不能編碼的字元,也可以是 'replace',表示用預設字元代替:

b_city = city.encode('cp437', errors='ignore') 
# b'So Paulo'
b_city = city.encode('cp437', errors='replace')
# b'S?o Paulo'複製程式碼

decode 完全是 encode 的逆操作,只有二進位制型別才有這個函式。它的兩個引數含義和 encode 函式完全一致,就不再介紹了。

從理論上來說,僅從編碼後的內容上來看,是無法確定編碼方式的,也無法解碼出原來的字元。但不同的編碼有各自的特點,雖然無法完全倒推,但可以從概率上來猜測,如果發現某個二進位制內容,有 99% 的可能性是 utf8 編碼生成的,我們就可以用 utf8 進行解碼。Python 提供了一個強大的工具包 Chardet 來完成這一任務:

octets = b'Montr\xe9al'
chardet.detect(octets)
# {'encoding': 'ISO-8859-1', 'confidence': 0.73, 'language': ''}
octets.decode('ISO-8859-1')
# Montréal複製程式碼

返回結果中包含了猜測的編碼方式,以及可信度。可信度越高,說明是這種編碼方式的可能性越大。

有時候,我們拿到的是二進位制的字串字面量,比如 68 65 6c 6c 6f,前文說過只有二進位制型別才有 decode 函式,所以需要通過二進位制的字面量生成二進位制變數:

s = '68 65 6c 6c 6f'
b = bytearray.fromhex(s)
b.decode()  # hello複製程式碼

1.3.2 字串的常用方法

字串的 split(sep, maxsplit) 方法可以以指定的分隔符進行分割,有點類似於 Shell 中的 awk -F ' '',第一個 sep 參數列示分隔符,不填則為空格:

s = 'a b c d e'
a = s.split()
# a = ['a', 'b', 'c', 'd', 'e']複製程式碼

第二個引數 maxsplit 表示最多分割多少次,因此返回陣列的長度是 maxsplit + 1。舉個例子說明下:

s = 'a;b;c;d;e'
a = s.split(';')
# a = ['a', 'b', 'c', 'd', 'e']

b = s.split(';', 2)
# b = ['a', 'b', 'c;d;e']複製程式碼

如果想批量替換,則可以用 replace(old, new[, count]) 方法,由中括號括起來的參數列示選填。

old = 'a;b;c;d;e'
new = old.replace(';', ' ', 3)
# new = 'a b c d;e'複製程式碼

strip[chars] 用於移除指定的字元們:

old = "*****!!!Hello!!!*****"
new = old.strip('*')  # 得到 '!!!Hello!!!'
new = old.strip('*!')  # 得到 'Hello'複製程式碼

如果不傳引數,則預設移除空格。其實 strip 等價於分別執行 lstrip()rstrip(),即分別從左側和右側進行移除。比如 lstrip() 表示從左側第一個字元開始,移除空格,直到第一個非空格字元為止,所以字串中間的空格,無論是 lstrip 還是 strip() 都是無法移除的。

old = '  Hello world  '
new = old.strip()   # 得到 'Hello wrold'
new = old.lstrip()  # 得到 'Hello world  '複製程式碼

最後一個常用方法是 join,其實這個可以理解為字串的構造方法,它可以把陣列轉換成字串:

array = 'a b c d e'.split() # 之前說過,結果是 ['a', 'b', 'c', 'd', 'e']
s = ';'.join(array) # 以分號為連線符,把陣列中的元素連線起來
# s = 'a;b;c;d;e'複製程式碼

所以 join 可以理解為 split 的逆操作,這個公式始終是成立的:

c.join(string.split(c)) = string

上面這些字串處理的函式,大多返回的還是字串,因此可以鏈式呼叫,避免使用臨時變數和多行程式碼,但也要避免過長(超過 3 個)的鏈式呼叫,以免影響可讀性。

1.3.3 字串格式化

最初級的字串格式化方法是使用 + 來拼接:

class Person:
    def __init__(self):
        self.name = 'bestswifter'
        self.age = 22
        self.sex = 'm'

p = Person()
print('Name: ' + p.name + ', Age: ' + str(p.age) + ', Sex: ' + p.sex)
# 輸出:Name: bestswifter, Age: 22, Sex: m複製程式碼

這裡必須要把 int 型別的年齡轉成字串以後才能進行拼接,這是因為 Python 是強型別語言,不支援型別的隱式轉換。

這種做法的缺點在於如果輸出結構比較複雜,極容易出現引號匹配錯誤的問題,可讀性非常低。

Python 2 中的做法是使用佔位符,類似於 C 語言中 printf

content = 'Name: %s, Age: %i, Sex: %c' % (p.name, p.age, p.sex)
print(content)複製程式碼

從結構上看,要比上一種寫法清楚得多, 但每個變數都需要指定型別,這和 Python 的簡潔不符。實際上每個物件都可以通過 str() 函式轉換成字串,這個函式的背後是 __str__ 魔術方法。

Python 3 中的寫法是使用 format 函式,比如我們來實現一下 __str__ 方法:

class Person:
    def __init__(self):
        self.name = 'bestswifter'
        self.age = 22
        self.sex = 'm'
    def __str__(self):
        return 'Name: {user.name}, Age: {user.age}, Sex: {user.sex}'.format(user=self)

p = Person()
print(p)
# 輸出:Name: bestswifter, Age: 22, Sex: m複製程式碼

除了把物件傳給 format 函式並在字串中展開以外, 也可以傳入多個引數,並且通過下標訪問他們:

print('{0}, {1}, {0}'.format(1, 2))
# 輸出:1, 2, 1,這裡的 {1} 表示第二個引數複製程式碼

1.3.4 HereDoc

Heredoc 不是 Python 特有的概念, 命令列和各種指令碼中都會見到,它表示一種所見即所得的文字。

假設我們在寫一個 HTML 的模板,絕大多數字符串都是常量,只有有限的幾個地方會用變數去替換,那這個字串該如何表示呢?一種寫法是直接用單引號去定義:

s = '<HTML><HEAD><TITLE>\nFriends CGI Demo</TITLE></HEAD>\n<BODY><H3>ERROR</H3>\n<B>%s</B><P>\n<FORM><INPUT TYPE=button VALUE=Back\nONCLICK=\'window.history.back()\'></FORM>\n</BODY></HTML>'複製程式碼

這段程式碼是自動生成的還好,如果是手動維護的,那麼可讀性就非常差,因為換行符和轉義後的引號增加了理解的難度。如果用 heredoc 來寫,就非常簡單了:

s = '''<HTML><HEAD><TITLE>
Friends CGI Demo</TITLE></HEAD>
<BODY><H3>ERROR</H3>
<B>%s</B><P>
<FORM><INPUT TYPE=button VALUE=Back
ONCLICK='window.history.back()'></FORM>
</BODY></HTML>
'''複製程式碼

Heredoc 主要是用來書寫大段的字串常量,比如 HTML 模板,SQL語句等等。

2 函式

2.1 函式是一等公民

一等公民指的是 Python 的函式能夠動態建立,能賦值給別的變數,能作為參傳給函式,也能作為函式的返回值。總而言之,函式和普通變數並沒有什麼區別。

函式是一等公民,這是函數語言程式設計的基礎,然而 Python 中基本上不會使用 lambda 表示式,因為在 lambda 表示式的中僅能使用單純的表示式,不能賦值,不能使用 while、try 等語句,因此 lambda 表示式要麼難以閱讀,要麼根本無法寫出。這極大的限制了 lambda 表示式的使用場景。

上文說過,函式和普通變數沒什麼區別,但普通變數並不是函式,因為這些變數無法呼叫。但如果某個類實現了 __call__ 這個魔術方法,這個類的例項就都可以像函式一樣被呼叫:

class Person:
    def __init__(self):
        self.name = 'bestswifter'
        self.age = 22
        self.sex = 'm'

    def __call__(self):
        print(self)

    def __str__(self):
        return 'Name: {user.name}, Age: {user.age}, Sex: {user.sex}'.format(user=self)

p = Person()
p() # 等價於 print(p)複製程式碼

2.2 函式引數

2.2.1 函式傳參

對於熟悉 C 系列語言的人來說,函式傳參的方式一目瞭然。預設是拷貝傳值,如果傳指標是引用傳值。我們先來看一段簡單的 Python 程式碼:

def foo(arg):
    arg = 5
    print(arg)

a = 1
foo(a)
print(a)
# 輸出 5 和 1複製程式碼

這段程式碼的結果符合我們的預期,從這段程式碼來看,Python 也屬於拷貝傳值。但如果再看這段程式碼:

def foo(arg):
    arg.append(1)
    print(arg)

a = [1]
foo(a)
print(a) # 輸出兩個 [1, 1]複製程式碼

你會發現引數陣列在函式內部被改變了。就像是 C 語言中傳遞了變數的指標一樣。所以 Python 到底是拷貝傳值還是引用傳值呢?答案都是否定的

Python 的傳值方式可以被理解為混合傳值。對於那些不可變的物件(比如 1.1.2 節中介紹過的元組,還有數字、字串型別),傳值方式是拷貝傳值;對於那些可變物件(比如陣列和字典)則是引用傳值。

2.2.2 預設引數

Python 的函式可以有預設值,這個功能很好用:

def foo(a, l=[]):
    l.append(a)
    return l

foo(2,[1])  # 給陣列 [1] 新增一個元素 2,得到 [1,2]
foo(2)      # 沒有傳入陣列,使用預設的空陣列,得到 [2]複製程式碼

然而如果這樣呼叫:

foo(2)  # 利用預設引數,得到 [2]
foo(3)  # 竟然得到了 [2, 3]複製程式碼

函式呼叫了兩次以後,預設引數被改變了,也就是說函式呼叫產生了副作用。這是因為預設引數的儲存並不像函式裡的臨時變數一樣儲存在棧上、隨著函式呼叫結束而釋放,而是儲存在函式這個物件的內部:

foo.__defaults__  # 一開始確實是空陣列
foo(2)  # 利用預設引數,得到 [2]
foo.__defaults__  # 如果列印出來看,已經變成 [2] 了
foo(3)  # 再新增一個元素就得到了 [2, 3]複製程式碼

因為函式 foo 作為一個物件,不會被釋放,因此這個物件內部的屬性也不會隨著多次呼叫而自動重置,會一直保持上次發生的變化。基於這個前提,我們得出一個結論:函式的預設引數不允許是可變物件,比如這裡的 foo 函式需要這麼寫:

def foo(a, l=None):
    if l is None:
        l = []
    l.append(a)
    return l

print(foo(2)) # 得到 [2]
print(foo(3)) # 得到 [3]複製程式碼

現在,給引數新增預設值的行為在函式體中完成,不會隨著函式的多次呼叫而累積。

對於 Python 的預設引數來說:

如果預設值是不可變的,可以直接設定預設值,否則要設定為 None 並在函式體中設定預設值。

2.2.3 多引數傳遞

當引數個數不確定時,可以在引數名前加一個 *

def foo(*args):
    print(args)

foo(1, 2, 3)  # 輸出 [1, 2, 3]複製程式碼

如果直接把陣列作為引數傳入,它其實是單個引數,如果要把陣列中所有元素都作為單獨的引數傳入,則在陣列前面加上 *

a = [1, 2, 3]    
foo(a)  # 會輸出 ([1,2,3], )   因為只傳了一個陣列作為引數
foo(*a) # 輸出 [1, 2, 3]複製程式碼

這裡的單個 * 只能接收非關鍵字引數,也就是僅有引數值的哪些引數。如果想接受關鍵字引數,需要用 ** 來表示:

def foo(*args, **kwargs):
    print(args)
    print(kwargs)

foo(1,2,3, a=61, b=62)
# 第一行輸出:[1, 2, 3]
# 第二行輸出:{'a': 61, 'b': 62}複製程式碼

類似的,字典變數傳入函式只能作為單個引數,如果要想展開並被 **kwargs 識別,需要在字典前面加上兩個星號 **

a = [1, 2, 3]
d = {'a': 61, 'b': 62}
foo(*a, **d)複製程式碼

2.2.4 引數分類

Python 中函式的引數可以分為兩大類:

  1. 定位引數(Positional):表示引數的位置是固定的。比如對於函式 foo(a, b) 來說,foo(1, 2)foo(2, 1) 就是截然不同的,a 和 b 的位置是固定的,不可隨意調換。
  2. 關鍵詞引數(Keyword):表示引數的位置不重要,但是引數名稱很重要。比如 foo(a = 1, b = 2)foo(b = 2, a = 1) 的含義相同。

有一種引數叫做僅限關鍵字(Keyword-Only)引數,比如考慮這個函式:

def foo(*args, n=1, **kwargs):
    print(n)複製程式碼

這個函式在呼叫時,如果引數 n 不指定名字,就會被前面的 *args 處理掉,如果指定的名字不是 n,又會被後面的 **kwargs 處理掉,所以引數 n 必須精確的以 (n = xxx) 的形式出現,也就是 Keyworld-Only。

2.3 函式內省

在 2.2.2 節中,我們檢視了函式變數的 __defaults__ 屬性,其實這就是一種內省,也就是在執行時動態的檢視變數的資訊。

前文說過,函式也是物件,因此函式的變數個數,變數型別都應該有辦法獲取到,如果你需要開發一個框架,也許會對函式有各種奇葩的檢查和校驗。

以下面這個函式為例:

g = 1
def foo(m, *args, n, **kwargs):
    a = 1
    b = 2複製程式碼

首先可以獲取函式名,函式所在模組的全域性變數等:

foo.__globals__   # 全域性變數,包含了 g = 1
foo.__name__      # foo複製程式碼

我們還可以看到函式的引數,函式內部的區域性變數:

foo.__code__.co_varnames  # ('m', 'n', 'args', 'kwargs', 'a', 'b')
foo.__code__.co_argcount  # 只計算引數個數,不考慮可變引數,所以得到 2複製程式碼

或者用 inspect 模組來檢視更詳細的資訊:

import inspect
sig = inspect.signature(foo)  # 獲取函式簽名

sig.parameters['m'].kind      # POSITIONAL_OR_KEYWORD 表示可以是定位引數或關鍵字引數
sig.parameters['args'].kind   # VAR_POSITIONAL 定位引數構成的陣列
sig.parameters['n'].kind      # KEYWORD_ONLY 僅限關鍵字引數
sig.parameters['kwargs'].kind # VAR_KEYWORD 關鍵字引數構成的字典
inspect.getfullargspec(foo)       
# 得到:ArgSpec(args=['m', 'n'], varargs='args', keywords='kwargs', defaults=None)複製程式碼

本節的新 API 比較多,但並不要求記住這些 API 的用法。再次強調,本文的寫作目的是為了建立讀者對 Python 的總體認知,瞭解 Python 能做什麼,至於怎麼做,那是文件該做的事。

2.4 裝飾器

2.4.1 設計模式的消亡

經典的設計模式有 23 個,雖然設計模式都是常用程式碼的總結,理論上來說與語法無關。但不得不承認的是,標準的設計模式在不同的語言中,有的因為語法的限制根本無法輕易實現(比如在 C 語言中實現組合模式),有的則因為語言的特定功能,變得冗餘囉嗦。

以策略模式為例,有一個抽象的策略類,定義了策略的介面,然後使用者選擇一個具體的策略類,構造他們的例項並且呼叫策略方法。具體程式碼可以參考:策略模式在百度百科的定義

然而這些物件本身並沒有作用,它們僅僅是可以呼叫相同的方法而已,只不過在 Java 中,所有的任務都需要由物件來完成。即使策略本身就是一個函式,但也必須把它包裹在一個策略物件中。所以在 Python 中更優雅寫法是直接把策略函式作為變數使用。不過這就引入一個問題,如何判斷某個函式是個策略呢,畢竟在物件導向的寫法中,只要檢查它的父類是否是抽象的策略類即可。

也許你已經見過類似的寫法:

strategy
def strategyA(n):
    print(n * 2)複製程式碼

下面就開始介紹裝飾器。

2.4.2 裝飾器的基本原理

首先,裝飾器是個函式,它的引數是被裝飾的函式,返回值也是一個函式:

def decorate(origin_func):  # 這個引數是被裝飾的函式
    print(1)  # 先輸出點東西
    return origin_func  # 把原函式直接返回

@decorate    # 注意這裡不是函式呼叫,所以不用加括號,也不用加被修飾的函式名
def sayHello():
    print('Hello')

sayHello()  # 如果沒有裝飾器,只會列印 'Hello',實際結果是列印 1 再列印 'Hello'複製程式碼

因此,使用裝飾器的這種寫法:

@decorate
def foo():
    pass複製程式碼

和下面這種寫法是完全等價的, 初學者可以把裝飾器在心中默默的轉換成下一種寫法,以方便理解:

def foo():
    pass
foo = decorate(foo)複製程式碼

需要注意的是,裝飾器函式 decorate 在模組被匯入時就會執行,而被裝飾的函式只在被呼叫時才會執行,也就是說即使不呼叫 sayHello 函式也會輸出 1,但這樣就不會輸出 Hello 了。

有了裝飾器,配合前面介紹的函式物件,函式內省,我們可以做很多有意思的事,至少判斷上一節中某個函式是否是策略是非常容易的。在裝飾器中,我們還可以把策略函式都儲存到陣列中, 然後提供一個“推薦最佳策略”的功能, 其實就是遍歷執行所有的策略,然後選擇最好的結果。

2.4.3 裝飾器進階

上一節中的裝飾器主要是為了介紹工作原理,它的功能非常簡單,並不會改變被裝飾函式的執行結果,僅僅是在匯入時裝飾函式,然後輸出一些內容。換句話說,即使不執行函式,也要執行裝飾器中的 print 語句,而且因為直接返回函式的緣故,其實沒有真正的起到裝飾的效果。

如何做到裝飾時不輸出任何內容,僅在函式執行最初輸出一些東西呢?這是常見的 AOP(面向切片程式設計) 的需求。這就要求我們不能再直接返回被裝飾的函式,而是應該返回一個新的函式,所以新的裝飾器需要這麼寫:

def decorate(origin_func):
    def new_func():
        print(1)
        origin_func()
    return new_func

@decorate
def sayHello():
    print('Hello')

sayHello() # 執行結果不變,但是僅在呼叫函式 sayHello 時才會輸出 1複製程式碼

這個例子的工作原理是,sayHello 函式作為引數 origin_func 被傳到裝飾器中,經過裝飾以後,它實際上變成了 new_func,會先輸出 1 再執行原來的函式,也就是 sayHello

這個例子很簡陋,因為我們知道了 sayHello 函式沒有引數,所以才能定義一個同樣沒有引數的替代者:nwe_func。如果我們在開發一個框架,要求裝飾器能對任意函式生效,就需要用到 2.2.3 中介紹的 *** 這種不定引數語法了。

如果檢視 sayHello 函式的名字,得到的結果將是 new_func

sayHello.__name__  # new_func複製程式碼

這是很自然的,因為本質上其實執行的是:

new_func = decorate(sayHello)複製程式碼

而裝飾器的返回結果是另一個函式 new_func,兩者僅僅是執行結果類似,但兩個物件並沒有什麼關聯。

所以為了處理不定引數,並且不改變被裝飾函式的外觀(比如函式名),我們需要做一些細微的修補工作。這些工作都是模板程式碼,所以 Python 早就提供了封裝:

import functools

def decorate(origin_func):
    @functools.wraps(origin_func)  # 這是 Python 內建的裝飾器
    def new_func(*args, **kwargs):
        print(1)
        origin_func(*args, **kwargs)
    return new_func複製程式碼

2.4.4 裝飾器工廠

在 2.4.2 節的程式碼註釋中我解釋過,裝飾器後面不要加括號,被裝飾的函式自動作為引數,傳遞到裝飾器函式中。如果加了括號和引數,就變成手動呼叫裝飾器函式了,大多數時候這與預期不符(因為裝飾器的引數一般都是被裝飾的函式)。

不過裝飾器可以接受自定義的引數,然後返回另一個裝飾器,這樣外面的裝飾器實際上就是一個裝飾器工廠,可以根據使用者的引數,生成不同的裝飾器。還是以上面的裝飾器為例,我希望輸出的內容不是固定的 1,而是使用者可以指定的,程式碼就應該這麼寫:

import functools

def decorate(content):                        # 這其實是一個裝飾器工廠
    def real_decorator(origin_func):          # 這才是剛剛的裝飾器
        @functools.wraps(origin_func)
        def new_func():
            print('You said ' + str(content)) # 現在輸出內容可以由使用者指定
            origin_func()
        return new_func                       # 在裝飾器裡,返回的是新的函式
    return real_decorator                     # 裝飾器工廠返回的是裝飾器複製程式碼

裝飾器工廠和裝飾器的區別在於它可以接受引數,返回一個裝飾器:

@decorate(2017)
def sayHello():
    print('Hello')

sayHello()複製程式碼

其實等價於:

real_decorator = decorate(2017)      # 通過裝飾器工廠生成裝飾器
new_func = real_decorator(sayHello)  # 正常的裝飾器工作邏輯
new_func()                           # 呼叫的是裝飾過的函式複製程式碼

3 物件導向

3.1 物件記憶體管理

3.1.1 物件不是盒子

C 語言中我們定義變數用到的語法是:

int a = 1;複製程式碼

這背後的含義是定義了一個 int 型別的變數 a,相當於申請了一個名為 a 的盒子(儲存空間),裡面裝了數字 1。

圖片名稱
圖片名稱

然後我們改變 a 的值:a = 2;,可以列印 a 的地址來證明它並沒有發生變化。所以只是盒子裡裝的內容(指標指向的位置)發生了改變:

圖片名稱
圖片名稱

但是在 Python 中,變數不是盒子。比如同樣的定義變數:

a = 1複製程式碼

這裡就不能把 a 理解為 int 型別的變數了。因為在 Python 中,變數沒有型別,值才有,或者說只有物件才有型別。因為即使是數字 1,也是 int 類的例項,而變數 a 更像是給這個物件貼的一個標籤。

圖片名稱
圖片名稱

如果執行賦值語句 a = 2,相當於把標籤 a 貼在另一個物件上:

圖片名稱
圖片名稱

基於這個認知,我們現在應該更容易理解 2.2.1 節中所說的函式傳參規則了。如果傳入的是不可變型別,比如 int,改變它的值實際上就是把標籤掛在新的物件上,自然不會改變原來的引數。如果是可變型別,並且做了修改,那麼函式中的變數和外面的變數都是指向同一個物件的標籤,所以會共享變化。

3.1.2 預設淺複製

根據上一節的描述,直接把變數賦值給另一個變數, 還算不上覆制:

a = [1, 2, 3]
b = a
b == a   # True,等同性校驗,會呼叫 __eq__ 函式,這裡只判斷內容是否相等
b is a   # True,一致性校驗,會檢查是否是同一個物件,呼叫 hash() 函式,可以理解為比較指標複製程式碼

可見不僅僅陣列相同,就連變數也是相同的,可以把 b 理解為 a 的別名。

如果用切片,或者陣列的建構函式來建立新的陣列,得到的是原陣列的淺拷貝:

a = [1, 2, 3]
b = list(a)
b == a   # True,因為陣列內容相同
b is a   # False,現在 a 和 b 是兩個變數,恰好指向同一個陣列物件複製程式碼

但如果陣列中的元素是可變的,可以看到這些元素並沒有被完全拷貝:

a = [[1], [2], [3]]
b = list(a)
b[0].append(2)
a # 得到 [[1, 2], [2], [3]],因為 a[0] 和 b[0] 其實還是掛在相同物件上的不同標籤複製程式碼

如果想要深拷貝,需要使用 copy 模組的 deepcopy 函式:

import copy 

b = copy.deepcopy(a)
b[0].append(2)
a  # 變成了 [[1, 2], [2], [3]]
a  # 還是 [[1], [2], [3]]複製程式碼

此時,不僅僅是每個元素的引用被拷貝,就連每個元素自己也被拷貝。所以現在的 a[0]b[0] 是指向兩個不同物件的兩個不同變數(標籤),自然就互不干擾了。

如果要實現自定義物件的深複製,只要實現 __deepcopy__ 函式即可。這個概念在幾乎所有物件導向的語言中都會存在,就不詳細介紹了。

3.1.3 弱引用

Python 記憶體管理使用垃圾回收的方式,當沒有指向物件的引用時,物件就會被回收。然而物件一直被持有也並非什麼好事,比如我們要實現一個快取,預期目標是快取中的內容隨著真正物件的存在而存在,隨著真正物件的消失而消失。如果因為快取的存在,導致被快取的物件無法釋放,就會導致記憶體洩漏。

Python 提供了語言級別的支援,我們可以使用 weakref 模組,它提供了 weakref.WeakValueDictionary 這個弱引用字典來確保字典中的值不會被引用。如果想要獲取某個物件的弱引用,可以使用 weakref.ref(obj) 函式。

3.2 Python 風格的物件

3.2.1 靜態函式與類方法

靜態函式其實和類的方法沒什麼關係,它只是恰好定義在類的內部而已,所以這裡我用函式(function) 來形容它。它可以沒有引數:

class Person:
    @staticmethod   # 用 staticmethod 這個修飾器來表明函式是靜態的
    def sayHello():
        print('Hello')

Person.sayHello() # 輸出 'Hello`複製程式碼

靜態函式的呼叫方式是類名加上函式名。類方法的呼叫方式也是這樣,唯一的不同是需要用 @staticmethod 修飾器,而且方法的第一個引數必須是類:

class Person:
    @classmethod    # 用 classmethod 這個修飾器來表明這是一個類方法
    def sayHi(cls):
        print('Hi: ' + cls.__name__)

Person.sayHi() # 輸出 'Hi: Person`複製程式碼

類方法和靜態函式的呼叫方法一致,在定義時除了修飾器不一樣,唯一的區別就是類方法需要多宣告一個引數。這樣看起來比較麻煩,但靜態函式無法引用到類物件,自然就無法訪問類的任何屬性。

於是問題來了,靜態函式有何意義呢?有的人說類名可以提供名稱空間的概念,但在我看來這種解釋並不成立,因為每個 Python 檔案都可以作為模組被別的模組引用,把靜態函式從類裡抽取出來,定義成全域性函式,也是有名稱空間的:

# 在 module1.py 檔案中:
def global():
    pass 

class Util:
    @staticmethod
    def helper():
        pass

# 在 module2.py 檔案中:
import module1
module1.global()        # 呼叫全域性函式
module1.Util.helper()   # 呼叫靜態函式複製程式碼

從這個角度看,定義在類中的靜態函式不僅不具備名稱空間的優點,甚至呼叫語法還更加囉嗦。對此,我的理解是:靜態函式可以被繼承、重寫,但全域性函式不行,由於 Python 中的函式是一等公民,因此很多時候用函式替代類都會使程式碼更加簡潔,但缺點就是無法繼承,後面還會有更多這樣的例子。

3.2.2 屬性 attribute

Python (等多數動態語言)中的類並不像 C/OC/Java 這些靜態語言一樣,需要預先定義屬性。我們可以直接在初始化函式中建立屬性:

class Person:
    def __init__(self, name):
        self.name = name

bs = Person('bestswifter')
bs.name  # 值是 'bestswifter'複製程式碼

由於 __init__ 函式是執行時呼叫的,所以我們可以直接給物件新增屬性:

bs.age = 22
bs.age  # 因為剛剛賦值了,所以現在取到的值是 22複製程式碼

如果訪問一個不存在的屬性,將會丟擲異常。從以上特性來看,物件其實和字典非常相似,但這種過於靈活的特性其實蘊含了潛在的風險。比如某個封裝好的父類中定義了許多屬性, 但是子類的使用者並不一定清楚這一點,他們很可能會不小心就重寫了父類的屬性。一種隱藏並保護屬性的方式是在屬性前面加上兩個下劃線:

class Person:
    def __init__(self):
        self.__name = 'bestswifter'

bs = Person()

bs.__name          # 這樣是無法獲取屬性的
bs._Person__name   # 這樣還是可以讀取屬性複製程式碼

這是因為 Python 會自動處理以雙下劃線開頭的屬性,把他們重名為 _Classname__attrname 的格式。由於 Python 物件的所有屬性都儲存在例項的 __dict__ 屬性中,我們可以驗證一下:

bs = Person()
bs.__dict__ 
# 得到 {'_Person__name': 'bestswifter'}複製程式碼

但很多人並不認可通過名稱改寫(name mangling) 的方式來儲存私有屬性,原因很簡單,只要知道改寫規則,依然很容易的就能讀寫私有屬性。與其自欺欺人,不如採用更簡單,更通用的方法,比如給私有屬性前面加上單個下劃線 _

注意,以單個下劃線開頭的屬性不會觸發任何操作,完全靠自覺與共識。任何稍有追求的 Python 程式設計師,都不應該讀寫這些屬性。

3.2.3 特性 property

使用過別的面嚮物件語言的讀者應該都清楚屬性的 gettersetter 函式的重要性。它們封裝了屬性的讀寫操作,可以新增一些額外的邏輯,比如校驗新值,返回屬性前做一些修飾等等。最簡陋的 gettersetter 就是兩個普通函式:

class Person:
    def get_name(self):
        return self.name.upper()

    def set_name(self, new_name):
        if isinstance(new_name, str):
            self.name = new_name.lower()

    def __init__(self, name):
        self.name = name

bs = Person('bestswifter')
bs.get_name()   # 得到大寫的名字: 'BESTSWIFTER'
bs.set_name(1)  # 由於新的名字不是字串,所以無法賦值
bs.get_name()   # 還是老的名字: 'BESTSWIFTER'複製程式碼

工作雖然完成了,但方法並不高明。在 1.2.3 節中我們就見識到了 Python 的一個特點:“內部高度封裝,完全對外透明”。這裡手動呼叫 gettersetter 方法顯得有些愚蠢、囉嗦,比如對比下面的兩種寫法,在變數名和函式名很長的情況下,差距會更大:

bs.name += '1995'
bs.set_name(bs.get_name() + '1995')複製程式碼

Python 提供了 @property 關鍵字來裝飾 gettersetter 方法,這樣的好處是可以直接使用點語法,瞭解 Objective-C 的讀者對這一特性一定倍感親切:

class Person:
    @property                        # 定義 getter
    def name(self):                  # 函式名就是點語法訪問的屬性名
        return self._name.upper()    # 現在真正的屬性是 _name 了

    @name.setter                     # 定義 setter
    def name(self, new_name):        # 函式名不變
        if isinstance(new_name, str):
            self._name = new_name.lower()  # 把值存到私有屬性 _name 裡

    def __init__(self, name):
        self.name = name

bs = Person('bestswifter')
bs.name      # 其實呼叫了 name 函式,得到大寫的名字: 'BESTSWIFTER'
bs.name = 1  # 其實呼叫了 name 函式,因為型別不符,無法賦值
bs.name      # 還是老的名字: 'BESTSWIFTER'複製程式碼

我們已經在 2.4 節詳細學習了裝飾器,應該能意識到這裡的 @property@xxx.setter 都是裝飾器。因此上述寫法實際上等價於:

class Person:
    def get_name(self):
        return self._name.upper()

    def set_name(self, new_name):
        if isinstance(new_name, str):
            self._name = new_name.lower()
    # 以上是老舊的 getter 和 setter 定義
    # 如果不用 @property,可以定義一個 property 類的例項
    name = property(get_name, set_name)複製程式碼

可見,特性的本質是給類建立了一個類屬性,它是 property 類的例項,構造方法中需要把 gettersetter 等函式傳入,我們可以列印一下類的 name 屬性來證明:

Person.name  # <property object at 0x107c99868>複製程式碼

理解特性的工作原理至關重要。以這裡的 name 特性為例,我們訪問了物件的 name 屬性,但是它並不存在,所以會嘗試訪問類的 name 屬性,這個屬性是 property 類的例項,會對讀寫操作做特殊處理。這也意味著,如果我們重寫了類的 name 屬性,那麼物件的讀寫方法就不會生效了:

bs = Person()
Person.name = 'hello'
bs.name  # 例項並沒有 name 屬性,因此會訪問到類的屬性 name,現在的值是 'hello` 了複製程式碼

如果訪問不存在的屬性,預設會丟擲異常,但如果實現了 __getattr__ 函式,還有一次挽救的機會:

class Person:
    def __getattr__(self, attr):
        return 0

    def __init__(self, name):
        self.name = name

bs = Person('bestswifter')
bs.name    # 直接訪問屬性
bs.age     # 得到 0,這是 __getattr__ 方法提供的預設值
bs.age = 1 # 動態給屬性賦值
bs.age     # 得到 1,注意!!!這時候就不會再呼叫 __getattr__ 方法了複製程式碼

由於 __getattr__ 只是兜底策略,處理一些異常情況,並非每次都能被呼叫,所以不能把重要的業務邏輯寫在這個方法中。

3.2.4 特性工廠

在上一節中,我們利用特性來封裝 gettersetter,對外暴露統一的讀寫介面。但有些 gettersetter 的邏輯其實是可以複用的,比如商品的價格和剩餘數量在賦值時,都必須是大於 0 的數字。這時候如果每次都要寫一遍 setter,程式碼就顯得很冗餘,所以我們需要一個能批量生產特性的函式。由於我們已經知道了特性是 property 類的例項,而且是類的屬性,所以程式碼可以這樣寫:

def quantity(storage_name):  # 定義 getter 和 setter
    def qty_getter(instance):
        return instance.__dict__[storage_name]
    def qty_setter(instance, value):
        if value > 0:
            # 把值儲存在例項的 __dict__ 字典中
            instance.__dict__[storage_name] = value 
        else:
            raise ValueError('value must be > 0')
    return property(qty_getter, qty_setter) # 返回 property 的例項複製程式碼

有了這個特性工廠,我們可以這樣來定義特性:

class Item:
    price = quantity('price')
    number = quantity('number')

    def __init__(self):
        pass

i = Item()
i.price = -1 
# Traceback (most recent call last):
# ...
# ValueError: value must be > 0複製程式碼

作為追求簡潔的程式設計師,我們不禁會問,在 price = quantity('price') 這行程式碼中,屬性名重複了兩次,能不能在 quantity 函式中自動讀取左邊的屬性名呢,這樣程式碼就可以簡化成 price = quantity() 了。

答案顯然是否定的,因為右邊的函式先被呼叫,然後才能把結果賦值給左邊的變數。不過我們可以採用迂迴策略,變相的實現上面的需求:

def quantity():
    try:
        quantity.count += 1
    except AttributeError:
        quantity.count = 0

    storage_name = '_{}:{}'.format('quantity', quantity.count)    

    def qty_getter(instance):
        return instance.__dict__[storage_name]
    def qty_setter(instance, value):
        if value > 0:
            instance.__dict__[storage_name] = value
        else:
            raise ValueError('value must be > 0')
    return property(qty_getter, qty_setter)複製程式碼

這段程式碼中我們利用了兩個技巧。首先函式是一等公民, 所以函式也是物件,自然就有屬性。所以我們利用 try ... except 很容易的就給函式工廠新增了一個計數器物件 count,它每次呼叫都會增加,然後再拼接成儲存時用的鍵 storage_name ,並且可以保證不同 property 例項的儲存鍵名各不相同。

其次,storage_namegettersetter 函式中都被引用到,而這兩個函式又被 property 的例項引用,所以 storage_name 會因為被持有而延長生命週期。這也正是閉包的一大特性:能夠捕獲自由變數並延長它的生命週期和作用域。

我們來驗證一下:

class Item:
    price = quantity()
    number = quantity()

    def __init__(self):
        pass

i = Item()
i.price = 1
i.number = 2
i.price     # 得到 1,可以正常訪問
i.number    # 得到 2,可以正常訪問
i.__dict__  # {'_quantity:0': 1, '_quantity:1': 2}複製程式碼

可見現在儲存的鍵名可以被正確地自動生成。

3.2.5 屬性描述符

檔案描述符的作用和特性工廠一樣,都是為了批量的應用特性。它的寫法也和特性工廠非常類似:

class Quantity:
    def __init__(self, storage_name):
        self.storage = storage_name
    def __get__(self, instance, owner):
        return instance.__dict__[self.storage]
    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.storage] = value
        else:
            raise ValueError('value must be > 0')複製程式碼

主要有以下幾個改動:

  1. 不用返回 property 類的例項了,因此 gettersetter 方法的名字是固定的,這樣才能滿足協議。
  2. __get__ 方法的第一個引數是描述符類 Quantity 的例項,第二個引數 self 是要讀取屬性的例項,比如上面的 i,也被稱作託管例項。第三個引數是託管類,也就是 Item
  3. __set__ 方法的前兩個引數含義類似,第三個則是要讀取的屬性名,比如 price

和特性工廠類似,屬性描述符也可以實現 storage_name 的自動生成,這裡就不重複程式碼了。看起來屬性描述符和特性工廠幾乎一樣,但由於屬性描述符是類,它就可以繼承。比如這裡的 Quantity 描述符有兩個功能:自動儲存和值的校驗。自動儲存是一個非常通用的邏輯,而值的校驗是可變的業務邏輯,所以我們可以先定義一個 AutoStorage 描述符來實現自動儲存功能,然後留下一個空的 validate 函式交給子類去重寫。

而特性工廠作為函式,自然就沒有上述功能,這兩者的區別類似於 3.2.1 節中介紹的靜態函式與全域性函式的區別。

3.2.6 例項屬性的查詢順序

我們知道類的屬性都會儲存在 __dict__ 字典中,即使沒有顯式的給屬性賦值,但只要字典裡面有這個欄位,也是可以讀取到的:

class Person:
    pass

p = Person()
p.__dict__['name'] = 'bestswifter'
p.name  # 不會報錯,而是返回字典中的值,'bestswifter'複製程式碼

但我們在特性工廠和屬性描述符的實現中,都是直接把屬性的值儲存在 __dict__ 中,而且鍵就是屬性名。之前我們還介紹過,特性的工作原理是沒有直接訪問例項的屬性,而是讀取了 property 的例項。那直接把值存在 __dict__ 中,會不會導致特性失效,直接訪問到原始內容呢?從之前的實踐結果來看,答案是否定的,要解釋這個問題,我們需要搞明白訪問例項屬性的查詢順序。

假設有這麼一段程式碼:

o = cls()   # 假設 o 是 cls 類的例項
o.attr      # 試圖訪問 o 的屬性 attr複製程式碼

再對上一節中的屬性描述符做一個簡單的分類:

  1. 覆蓋型描述符:定義了 __set__ 方法的描述符
  2. 非覆蓋型描述符:沒有定義 __set__ 方法的描述符

在執行 o.attr 時,查詢順序如下:

  1. 如果 attr 出現在 cls 或父類的 __dict__ 中,且 attr 是覆蓋型描述符,那麼呼叫 __get__ 方法。
  2. 否則,如果 attr 出現在 o__dict__ 中,返回 o.__dict__[attr]
  3. 否則,如果attr 出現在 cls 或父類的 __dict__ 中,如果 attr 是非覆蓋型描述符,那麼呼叫 __get__ 方法。
  4. 否則,如果沒有非覆蓋型描述符,直接返回 cls.__dict__[attr]
  5. 否則,如果 cls 實現了 __getattr__ 方法,呼叫這個方法
  6. 丟擲 AttributeError

所以,在訪問類的屬性時,覆蓋型描述符的優先順序是高於直接儲存在 __dict__ 中的值的。

3.3 多繼承

本節內容部分摘自我的這篇文章:從 Swift 的面向協議程式設計說開去,本節聊的是多繼承在 Python 中的知識,如果想閱讀關於多繼承的討論,請參考原文。

3.3.1 多繼承的必要性

很多語言類的書籍都會介紹,多繼承是個危險的行為。誠然,狹義上的多繼承在絕大多數情況下都是不合理的。這裡所謂的 “狹義”,指的是一個類擁有多個父類。我們要明確一個概念:繼承的目的不是程式碼複用,而是宣告一種 is a 的關係,程式碼複用只是 is a 關係的一種外在表現。

因此,如果你需要狹義上的多繼承,還是應該先問問自己,真的存在這麼多 is a 的關係麼?你是需要宣告這種關係,還是為了程式碼複用。如果是後者,有很多更優雅的解決方案,因為多繼承的一個直接問題就是菱形問題(Diamond Problem)。

但是廣義上的多繼承是必須的,不能因為害怕多繼承的問題就忽略多繼承的優點。廣義多繼承 指的是通過定義介面(Interface)以及介面方法的預設實現,形成“一個父類,多個介面”的模式,最終實現程式碼的複用。當然,不是每個語言都有介面的概念,比如 Python 裡面叫 Mixin,會在 3.3.3 節中介紹。

廣義上的多繼承非常常見,有一些教科書式的例子,比如動物可以按照哺乳動物,爬行動物等分類,也可以按照有沒有翅膀來分類。某一個具體的動物可能滿足上述好幾類。在實際的開發中也到處都是廣義多繼承的使用場景,比如 iOS 或者安卓開發中,系統控制元件的父類都是固定的,如果想讓他們複用別的父類的程式碼,就會比較麻煩。

如果您已讀到這裡並且感覺還不錯,可以在我的小專欄閱讀原文,僅需一杯咖啡的錢。你也可以訂閱小專欄或者加入我的知識星球,價格都是 66 元永久。

相關文章