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

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

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

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

固然, 用一篇文章來說完某個語言是不可能的事情,我但願讀完本文的讀者能夠:編程

  1. 對 Python 的總體知識結構造成初步的概念
  2. 瞭解 Python 特有的知識點,好比裝飾器、上下文、生成器等等,不只會寫 Demo,還對背後的原理有必定了解
  3. 避免 C++/Java 等風格的 Python 代碼,可以寫出地道的 Python 代碼
  4. 可以熟練的使用 Python 編寫腳本實現平常的簡單需求,可以維護小型 Python 項目,可以閱讀較複雜的 Python 源碼

若是以上介紹符合你對本身的定位,在開始閱讀前,還須要明確幾點:swift

  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 等方法,一旦建立,就沒法新增或刪除元素,元素自身的值也不能改變,但元素內部的屬性是否可變並不受元組的影響,這一點符合其餘語言中的常識。app

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、二、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 元永久。

相關文章
相關標籤/搜索