Python基礎

Python基礎

December 29, 2021·Jingyao Zhang
Jingyao Zhang

image

函式知識點總整理


呼叫函式


Python 內建了許多實用的函式可以直接呼叫。 要呼叫一個函式需要知道函式的名稱與參數,可以從 Python 官方網站查詢文件,也可以透過 help 函式查詢說明,例如 help(abs)

呼叫函式時,如果傳入的參數數量不正確,會拋出 TypeError 錯誤,且 Python 會明確告訴你所呼叫的函式需要幾個參數;如果傳入的參數數量正確,但參數型態不被函式接受,也會拋出 TypeError 錯誤。

有些函式可以接受任意多個參數,並回傳最大值,例如 max 函式:

max(1, 3)  # 3
max(1, -3, 2)  # 2
    2

資料型態轉換

Python 內建的函式還包含資料型態轉換函式,如 intstr 等。

函式名稱其實就是指向一個函式物件的參考,因此完全可以把函式名稱指定給一個變數,相當於給這個函式取了一個「別名」:

m = max  # 變數 m 指向 max 函式
m(1, 3, 5)  # 因此可以透過變數 m 呼叫 max 函式
    5

定義函式


在 Python 中定義函式需要使用 def 陳述式,接著依序寫出函式名稱、左括號、參數、右括號和冒號,然後在縮排區塊中撰寫函式主體,最後使用 return 陳述式回傳。範例:自訂絕對值函式 my_abs

def my_abs(x):
        if x >= 0:
                return x
        else:
                return -x

函式主體內的陳述式在執行時只要執行到 return 陳述式就會結束,並回傳結果。如果函式主體中沒有 return 陳述式,函式執行到最後也會回傳,只是回傳的結果為 None,也可以寫成 return Nonereturn

空函式

如果要定義一個什麼都不做的空函式,可以在函式主體部分使用 pass 陳述式。例如:

if height < 180:
        pass

參數檢查

如上所述,呼叫函式時若參數個數不正確,Python 直譯器會拋出 TypeError 錯誤並提示,但如果參數型態不正確,Python 直譯器不會像呼叫內建函式那樣自動檢查,這可能會導致函式主體執行過程中出現問題,可以使用 Python 內建的 isinstance 函式檢查參數型態:

def my_abs(x):
        if not isinstance(x, (int, float)):
                raise TypeError("bad operand type")  # 若傳入錯誤的參數型態,函式會拋出錯誤
        if x >= 0:
                return x
        else:
                return -x

回傳多個值

可以使用 return A, B 陳述式回傳兩個值,當然也可以擴展到多個值。但其實這只是一種假象,Python 函式回傳的依然是單一值,只是當回傳值不只一個時,回傳值會自動包裝成一個 tuple。

函式的參數


定義函式時,參數的名稱和位置確定下來,函式的介面定義就完成了。雖然 Python 函式定義非常簡單,但卻異常靈活。

位置參數

範例:計算次方的函式:

def power(x):
        return x ** x

對於函式 power,參數 x 就是一個位置參數,呼叫 power 函式時,必須傳入且僅能傳入一個參數 x。 但上述函式實際使用時不太方便,若要計算三次方、四次方…豈不是要寫很多函式。可以稍微修改一下 power 函式:

def power(x, n):
        s = 1
        while n > 0:
                n -= 1
                s *= x
        return s

這個修改後的 power 函式便可以計算任意次方數,而且此時它擁有兩個位置參數 xn,呼叫時,需要按位置順序依次給這兩個參數賦值。

預設參數

但實務上二次方的需求遠大於其他次方,此時可以使用預設參數,將第二個參數 n 的預設值設為 2:

def power(x, n = 2):
        s = 1
        while n > 0:
                n -= 1
                s *= x
        return s

於是呼叫 power(5) 時就等同於呼叫 power(5, 2),而對於 n ≠ 2 的情況,則需明確傳入 n 的值。 使用預設參數可以簡化函式的呼叫,但有幾個注意事項:

  • 預設參數必須放在必要參數的後面;
  • 有多個預設參數時,可以按順序提供部分預設參數,也可以不按順序提供部分預設參數,但不按順序提供時,需要把參數名稱寫上;

(如 add_student('Jensen', 'Male', city = 'Hefei'),意思是 city 用傳入的值,其他預設參數繼續使用預設值)

  • 預設參數必須指向不可變的物件;

(例如:

def add_end(L = []):
        L.append('END')
        return L

連續呼叫兩次 add_end() 後,回傳 ['END', 'END'],這是因為預設參數是變數,指向 [],每次呼叫預設參數的值都會改變。)

可變參數

Python 還可以定義可變參數,也就是傳入的參數個數是可變的,只需在參數前加上一個 * 號即可:

def calc(seed, *nums):
        sum = 0
        for n in nums:
                sum += n * n
        return seed, sum
        
calc(1, 2, 3, 4)  # 1 對應參數 seed,後面幾個參數都對應可變參數 nums
    (1, 29)

calc 函式內部,參數 nums 接收到的是一個 tuple,呼叫該函式時,可以傳入任意個參數,包括 1 個參數(即必要參數 seed)。 如果已經有一個 list 或 tuple,可以在 list 或 tuple 前面加一個 * 來將其中的元素變為可變參數傳入:

alist = [2, 5, 6, 7]
calc(1, *alist)  # 將 list 轉換為可變參數
    (1, 114)

關鍵字參數

關鍵字參數允許傳入0 個或任意個帶參數名稱的參數,這些關鍵字參數在函式內部自動封裝為一個 dict。與可變參數類似,也可以先組裝一個 dict,再在 dict 前加上 ** 來將關鍵字參數傳入:

def person(name, age, **kw):
        print("name: ", name, ", age: ", age, ", otr: ", kw)


info = {'city': 'HFE', 'major': 'Computer Science'}
        
person('Jensen', 22)  # 可以只傳入必要參數
person('Jensen', 22, city = 'Hefei', uni = 'HFUT')  # 傳入兩個帶參數名稱的參數
person('Jensen', 22, **info)  # 將事先組裝好的 info 解析傳入
    name:  Jensen , age:  22 , otr:  {}
    name:  Jensen , age:  22 , otr:  {'city': 'Hefei', 'uni': 'HFUT'}
    name:  Jensen , age:  22 , otr:  {'city': 'HFE', 'major': 'Computer Science'}

命名關鍵字參數

若要限制關鍵字參數的名稱,只接收 cityjob 作為關鍵字參數,需要在函式的參數表中加入一個分隔符 *,分隔符後面的參數被視為命名關鍵字參數:

def person(name, age, *, city, uni):  # 分隔符 * 後面的參數即為命名關鍵字參數
        print("name: ", name, ", age: ", age, ", city: ", city, ", uni: ", uni)

如果函式參數表中已經有一個可變參數,則後面跟著的命名關鍵字參數就不再需要加一個分隔符 * 了:

def person(name, age, *args, city, uni):  # 已有可變參數,不需要再加分隔符
        print("name: ", name, ", age: ", age, ", city: ", city, ", uni: ", uni)
        print("\nargs: ", args)

命名關鍵字參數必須傳入參數名稱,否則 Python 直譯器會將其視為位置參數,導致錯誤。

命名關鍵字參數也可以有預設值,呼叫時可不傳入。

參數組合

在 Python 中定義函式,上述幾種參數都可以組合使用,順序必須遵循:必要參數、預設參數、可變參數、命名關鍵字參數和關鍵字參數。但不建議使用太多組合,否則函式介面的可讀性會很差:

def final(a, b, c = 0, *args, d, **kw):
        print("a: ", a, ", b: ", b , ", d: ", d, ", args: ", args, ", d: ", d, ", kw: ", kw)
        
final(1, 2, 3, 4, d = 5, name = 'final function')
    a:  1 , b:  2 , d:  5 , args:  (4,) , d:  5 , kw:  {'name': 'final function'}

遞迴函式


在 Python 函式主體內部呼叫自身本身,這個函式就是遞迴函式。範例:階乘函式:

def fact(n):
        if n == 1:  # 只有當 n==1 時需要特殊處理
                return 1
        return n * fact(n - 1)

fact(1), fact(10)
    (1, 3628800)

使用遞迴函式需要注意避免堆疊溢位。在電腦中,函式呼叫是透過記憶體中的堆疊這種資料結構實現的,每進入一個函式呼叫,堆疊就會加一層堆疊框架,每當函式回傳,堆疊中就會減一層堆疊框架。由於堆疊的大小有限,因此遞迴次數過多會導致堆疊溢位。例如執行 fact(10000),Python 直譯器會拋出 RecursionError: maximum recursion depth exceeded in comparison

可以透過尾遞迴優化解決遞迴呼叫堆疊溢位。尾遞迴是指函式回傳時呼叫自身本身,且 return 陳述式不能包含運算式。這樣無論遞迴本身呼叫多少次,都只佔用一個堆疊框架,不會出現堆疊溢位的問題。上面的 fact 函式的 return 陳述式使用了乘法運算式,因此不是尾遞迴:

def fact(n):
        return fact_iter(n, 1)

def fact_iter(num, product):
        if num == 1:
                return product
        return fact_iter(num - 1, num * product)

fact(10)  # 執行 fact(10000) 依舊會報錯
    3628800

可以發現,上面改進的 fact 函式僅回傳遞迴函式本身,num - 1num * product 在函式呼叫前就會被計算,不影響函式呼叫。可惜的是,包括 Python 在內的大多數程式語言都沒有對尾遞迴做優化,因此即便有改進的 fact 函式也會導致堆疊溢位,例如執行 fact(10000),Python 直譯器依舊會拋出 RecursionError 的錯誤。

Python 進階特性


切片


要取一個 list 或 tuple 的前三個元素,可以使用 Python 提供的切片功能,一行就能完成:

names = ['Jensen', 'Eric', 'Jerry', 'Anderson', 'Taylor']
names[0:3]  # 輸出 ['Jensen', 'Eric', 'Jerry']

names[0:3] 表示從索引 0 開始取直到索引 3 為止,但不包含索引 3。也就是索引為 012 剛好三個元素,如果開始的索引是 0 則可以省略,如 names[:3]。(小技巧:可以透過 names[:] 原樣複製整個列表)

類似地,Python 也支援倒數切片,如取最後一個元素 names[-1],取後三個元素 names[-3:]

切片操作很實用,先建立一個 0-99 的列表:

L = list(range(100))

可以透過切片操作輕鬆取得某一段子列表:

L[:10]  # 取前十個元素;輸出:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
L[-10:]  # 取後十個元素;輸出:[90, 91, 92, 93, 94, 95, 96, 97, 98, 99]
L[25:35]  # 取第 25-35 個元素;輸出:[25, 26, 27, 28, 29, 30, 31, 32, 33, 34]
L[:10:2]  # 前十個元素間隔一個取一個;輸出:[0, 2, 4, 6, 8]
L[::10]  # 所有元素,每間隔 10 個取一個;輸出:[0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
    [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]

字串也可以視為一種 list,其中的每個元素就是字串中的每個字元。因此,字串也可以使用切片操作,得到的子列表依然是字串。

(上述操作,對於元組 Tuple 也適用,Tuple 和 List 非常類似,但 Tuple 一旦初始化就不能修改。)

迭代


若給定一個 listtuple,可以透過迴圈來遍歷它們,這種遍歷就是迭代。在 Python 中使用 for ... in 來完成迭代,不僅可以用在 listtuple 上,還可以作用在其他可迭代物件(如 dict)上:

d = {'a': 1, 'b': 2, 'c': 3}
for key in d:
        print(key)  # 輸出:a, b, c,預設迭代 dict 的 key

迭代 dict 時,預設是迭代 key,若要迭代 value 可以用 for value in d.values(),若要同時迭代 key 和 value,可以用 for k, v in d.items()。如前所述,字串也可以視為一種列表,因此也可用於 for 迴圈。簡而言之,無論是 list 還是其他資料型態,只要是可迭代物件,for 迴圈就可以正常運作。

可以透過 collections.abc 模組的 Iterable 型態判斷:

from collections.abc import Iterable
isinstance('abc', Iterable)  # str 是否可迭代? True
isinstance(123, Iterable)  # int 是否可迭代? False
isinstance([1, 2, 3], Iterable)  # list 是否可迭代? True

可以使用 Python 內建的 enumerate 函式把一個 list 變成索引-元素對,從而可以在 for 迴圈中同時迭代索引和元素本身,這種用法在 PyTorch 的 train loop 中非常常見:

L = [100, 'Jensen', 99, 'Eric', 98]  # 不僅僅作用於 list,其他可迭代物件如字串、元組都適用
for idx, value in enumerate(L):
        print(idx, value)  # 會同時印出索引和列表中的元素值
    0 100
    1 Jensen
    2 99
    3 Eric
    4 98

上述 for 迴圈同時引用了兩個變數,這在 Python 中非常常見:

for x, y in ((1, 2), (2, 3), (3, 4)):
        print(x, y)
    1 2
    2 3
    3 4

列表生成式


列表生成式是 Python 內建的簡單卻強大的建立 list 的生成器,範例:生成 list([1, 2, 3, 4, 5, 6, 7, 8, 9]) 可以用 list(range(1, 10)) 簡單生成。

若要生成 [1*1, 2*2, ..., 100*100] 的列表怎麼辦?可以使用列表生成式 [x * x for x in range(1, 101)]

for 迴圈後面還可以加上 if 判斷,可以篩選出僅偶數的平方 [x * x for x in range(1, 101) if x % 2 == 0]

還可以使用兩層迴圈,生成全排列:

[m + n for m in 'ABC' for n in 'XYZ']  # 兩層全排列,三層以上的迴圈就很少用了
    ['AX', 'AY', 'AZ', 'BX', 'BY', 'BZ', 'CX', 'CY', 'CZ']

運用列表生成式可以寫出非常簡潔的程式碼,範例:列出目前目錄下的所有檔案和目錄名稱:

import os
[dir for dir in os.listdir('.')]
    ['Functions.ipynb', '.ipynb_checkpoints', 'Advanced Feature.ipynb']

如前所述,for 迴圈可以同時引用兩個變數,因此列表生成式也可以使用多個變數來生成 list:

d = {'a': 1, 'b':2 , 'c': 3}
[k + '=' + v for k, v in d.items()]  # 輸出:['a=1', 'b=2', 'c=3']

if…else

在一個列表生成式中,for 前面的 if ... else 是運算式必須要帶 else 子句,而 for 後面的 if 是過濾條件,不能帶 else。

如執行 [x for x in range(1, 11) if x % 2 == 0 else 0] 會報錯,執行 [x if x % 2 == 0 for x in range(1, 11)] 也會報錯,然而執行下面這行則運作正常:

[x if x % 2 == 0 else 0 for x in range(1, 11)]  # for 前面的 if ... else 是運算式必須要帶 else 子句
    [0, 2, 0, 4, 0, 6, 0, 8, 0, 10]

生成器


如果建立了一個非常龐大的列表,但只需要存取其中的幾個元素,那麼這個列表佔用的很多空間都白白浪費了。在 Python 中可以使用一種邊迴圈邊計算的機制:生成器。生成器可以讓列表元素依照某種演算法推算出來,不必建立完整的 list。

建立生成器的方法有多種,首先把一個列表生成式的 [] 換成 () 就建立了一個生成器 g = (x * x for x in range(1, 10))。可以透過 next 函式將生成器中的每個元素依序印出。

因為生成器保存的是演算法,每次呼叫 next 函式就計算生成器的下一個元素值,直到計算到最後一個元素後拋出 StopIteration 錯誤。但一直呼叫 next 的方式實在太笨了,因為生成器也是可迭代物件,可以使用 for 迴圈:

g = (x * x for x in range(1, 10))
for i in g:
        print(i)
    1
    4
    9
    16
    25
    36
    49
    64
    81

若需要推算的演算法非常複雜,無法用列表生成式實現時,可以用函式來實現,例如費波那契數列函式:

def fib(max):
        n, a, b = 0, 0, 1
        while n < max:
                print(b)
                a, b = b, a + b
                n += 1
        return 'done'

上述函式可以印出費波那契數列的前 N 個數,可以從第一個元素開始推算出後續任意元素,這種邏輯非常類似生成器。要把 fib 函式變成生成器,只需要把 print(b) 改成 yield b 就好了:

def fib(max):
        n, a, b = 0, 0, 1
        while n < max:
                yield b  # 將 print(b) 改成 yield b
                a, b = b, a + b
                n += 1
        return 'done'

fib(10)  # 輸出: <generator object fib at 0xXXXXX>,說明 fib(10) 是一個生成器
    <generator object fib at 0x7f38c9d9ade0>

但生成器與一般函式執行流程不一樣,一般函式是順序執行,執行到 return 或最後一行後回傳,而生成器在每次呼叫 next 函式時執行,遇到 yield 陳述式回傳,下次執行時再從上次回傳的 yield 處繼續執行,範例:

(注意:多次呼叫生成器會產生多個彼此獨立的生成器物件。)

def odd():
        print('step 1')
        yield 1
        print('step 2')
        yield 3
        print('step 3')
        yield 5
        
o = odd()

next(o)  # 輸出:step 1;回傳:1
next(o)  # 輸出:step 2;回傳:3
next(o)  # 輸出:step 3;回傳:5  此時後面已經沒有 yield 可以執行了,再呼叫就會拋 "StopIteration" 錯誤
    step 1
    step 2
    step 3





    5

生成器物件建立好後,可以使用 for 迴圈迭代,但會發現 for 迴圈拿不到生成器 return 回傳的值,因此必須捕捉 StopIteration 錯誤,回傳值就包含在 StopIterationvalue 中:

g = fib(10)
while True:
        try:
                x = next(g)
                print('g: ', x)
        except StopIteration as e:
                print('Generator return value: ', e.value)
                break
    g:  1
    g:  1
    g:  2
    g:  3
    g:  5
    g:  8
    g:  13
    g:  21
    g:  34
    g:  55
    Generator return value:  done

迭代器


根據上文可以發現,可以直接用於 for 迴圈的資料型態有:

  • 集合資料型態:如 listtupledictsetstr 等;
  • 生成器(generator);

上述這些可以直接用於 for 迴圈的物件統稱為可迭代(Iterable)物件,可以使用 isinstance 函式判斷一個物件是否為可迭代物件:isinstance([], Iterable)

能夠被 next 函式呼叫並不斷回傳下一個值的物件稱為迭代器(Iterator),同樣可以用 isinstance 函式判斷一個物件是否為迭代器:isinstance([], Iterator)

因此,生成器既是可迭代物件,也是迭代器。但像 listdictstr 等雖然是可迭代物件,卻不是迭代器,可以使用 iter 函式將它們轉換為迭代器。

Iterator 的計算是惰性的,只有在需要回傳下一個資料時才會進行計算,本質上 Python 的 for 迴圈就是透過不斷呼叫 next 函式來實現的。


類別與實例


物件導向最重要的概念就是「類別」與「實例」,以 Student 類別為例,在 Python 中,定義類別是透過 class 關鍵字:

class Student(object):
    pass

class 後面緊接著類別名稱,也就是 Student,類別名稱通常是大寫開頭的單字。接著是 (object),表示這個類別繼承自哪個類別。通常如果沒有適合的父類別,就預設繼承自 object,這是所有類別最終都會繼承的基礎類別。

定義好類別之後,就可以根據類別建立對應的實例,建立實例是透過「類別名稱 + ()」來實現:

class Student(object):  # 預設繼承 Object 類別
    pass

stu = Student()  # stu 是 Student 類別的一個實例
stu, Student
(<__main__.Student at 0x7ff5c8788d30>, __main__.Student)

執行上面的程式碼會發現,變數 stu 指向的就是一個 Student 實例,後面的 0x...... 是實例在記憶體中的位址,每個實例的位址都不一樣,而 Student 本身則是一個類別。

可以自由地為一個實例變數綁定屬性,例如 stu.name = 'Jensen'。通常會使用 __init__ 建構子,在建立實例時就把一些必要的屬性強制傳入:

class Student(object):
    
    def __init__(self, name, score):  # 第一個參數永遠是 self,代表實例本身,不需手動傳入
        self.name = name
        self.score = score
        
stu = Student('Jensen', 100)  # 建立實例時會自動呼叫建構子,建構子後建立實例就不能傳入空參數,必須傳入建構子對應的參數(self 不用傳)
stu.name, stu.score
('Jensen', 100)

資料封裝

物件導向程式設計中一個非常重要的概念就是資料封裝。在上面的 Student 類別中,每個實例都擁有各自的 namescore 這些資料,可以透過函式來存取這些資料:

def print_score(stu):
    print('%s: %s' % (stu.name, stu.score))

也可以將 print_score 函式封裝在 Student 類別內部,這樣就把資料封裝起來了,這些封裝資料的函式和類別本身相關聯,被稱為類別的方法:

class Student(object):
    
    def __init__(self, name, score):
        self.name = name
        self.score = score
        
    def print_score(self):  # 類別的方法必須要有 self 參數
        print('%s: %s' % (self.name, self.score))
        
stu = Student('Jensen', 20)
stu.print_score()
Jensen: 20

存取限制


在上面的範例中,雖然資料已經封裝在類別內部,但外部程式碼還是可以直接存取實例變數來修改資料的值:stu.name = 'Shen Yiming',這樣會讓資料變得不可靠。

如果要讓內部屬性不能被外部程式碼直接存取,可以在屬性名稱前加上兩個底線 __,讓屬性變成類別的私有屬性,只能被類別的方法存取,外部程式碼無法存取,透過存取限制來保護資料,使程式更健壯:

class Student(object):
    
    def __init__(self, name, score):
        self.__name = name  # 屬性變數名稱前加兩個底線 __,即私有屬性
        self.__score = score
        
    def print_score(self):
        print('%s: %s' % (self.__name, self.__score))
        
stu = Student('Jensen', 20)
# stu.__name  # 若執行這行會報錯,沒有 __name 屬性
stu.print_score()
Jensen: 20

如果又希望外部程式碼可以有限度地修改內部資料,可以再為 Student 類別新增一個 set 方法:

def Student(object):
    ...
    
    def set_name(self, name):  # set 方法,可以在方法內加上參數檢查,避免傳入無效參數
        self.__name = name
        
    def set_score(self, score):
        self.__score = score

需要注意的是,Python 中還有很多像 __XXX__ 這樣的變數名稱,這些不是私有變數,而是特殊變數,可以直接透過 類別名稱.XXX 等方式存取,所以不可以用這些名稱作為變數名稱。

還有一些像 _name 這樣的變數名稱,這種實例變數在外部是可以直接存取的,但這種寫法是希望你把它當作私有變數,不要隨意存取。

事實上,私有變數如 __name 在實例內部會被改成 _Student__name,所以可以在外部透過 stu._Student__name 存取私有變數 __name,但強烈不建議這麼做。

請注意下面的錯誤用法:

stu = Student('Jensen', 20)
stu.__name = 'Yiming'  # 設定 __name 變數

上面的寫法只是為實例變數 stu 綁定了一個 __name 屬性,和類別內部的私有變數 __name 沒有任何關係,因為它已經被改名為 _Student__name

繼承與多型


在物件導向程式設計中,定義一個類別可以從現有的類別繼承,新定義的類別稱為子類別,被繼承的稱為父類別或基底類別、超類別。

繼承最主要的好處就是子類別可以獲得父類別的所有功能:

class Animal(object):  # 父類別
    
    def run(self):
        print('Animal is running...')
        
class Dog(Animal):  # 子類別,繼承自 Animal 類別,自動獲得 Animal 的 run 方法
    pass

class Cat(Animal):
    pass

但貓和狗繼承自父類別的 run 方法範圍太廣,子類別可以覆寫父類別的方法,只要在子類別中重新定義該方法即可:

class Animal(object):
    
    def run(self):
        print('Animal is running...')
        
class Dog(Animal):
    
    def run(self):  # 覆寫父類別的 run 方法
        print('Dog is running...')
        
class Cat(Animal):
    
    def run(self):  # 覆寫父類別的 run 方法
        print('Cat is running...')
        

dog, cat = Dog(), Cat()
dog.run()  # 子類別的 run 方法會覆蓋父類別的 run,執行時總是呼叫子類別的 run 方法
cat.run()
Dog is running...
Cat is running...

多型

多型指的是子類別的實例既屬於子類別本身,也屬於其繼承的父類別:

a, b, c = list(), Animal(), Dog()

print(isinstance(a, list))  # a 是 list 類型
print(isinstance(b, Animal))  # b 是 Animal 類型
print(isinstance(c, Dog))  # c 是 Dog 類型
print(isinstance(c, Animal))  # c 不僅是 Dog 類型,也是 Animal 類型
print(isinstance(b, Dog))  # b 不是 Dog 類型
True
True
True
True
False

從上面的程式碼可以發現,在繼承關係中,如果一個實例的資料型態是某個子類別,那它的資料型態也可以被視為父類別。但反過來卻不行,例如上面的 bAnimal 類型但不是 Dog 類型。

再看一個多型的例子:

class Animal(object):
    
    def run(self):
        print('Animal is running...')
        
class Dog(Animal):
    
    def run(self):
        print('Dog is running...')
        
class Cat(Animal):
    
    def run(self):
        print('Cat is running...')
        
def run_twice(animal):
    animal.run()
    animal.run()
    
run_twice(Animal())  # 正常印出
run_twice(Dog())  # 不需要修改 run_twice,任何以 Animal 為參數的函式都可以不加修改正常運作,這就是多型的威力
run_twice(Cat())  # 其實任何有 run 方法的類別實例都可以傳入正常運作
Animal is running...
Animal is running...
Dog is running...
Dog is running...
Cat is running...
Cat is running...

對於一個變數,只要知道它是 Animal 類型,不需要確切知道它的子類型就可以放心呼叫 run 方法,而實際呼叫的是 AnimalDog 還是 Catrun,由執行時該物件的實際型態決定。呼叫端只管呼叫,不管細節,而當新增一種 Animal 子類時,只要確保 run 方法寫正確,不用管原本的程式怎麼呼叫,這就是著名的開放封閉原則:

  • 對擴充開放:允許新增 Animal 子類;
  • 對修改封閉:不需要修改依賴 Animal 型態的 run_twice 等函式;

繼承還可以一層一層往下繼承,就像家族樹一樣。而任何類別最終都可以追溯到 object 類別,這些繼承關係就像一棵倒過來的樹。

靜態語言 vs 動態語言

對於靜態語言如 Java 來說,如果需要傳入 Animal 型態,則傳入的物件必須是 Animal 或其子類,否則無法呼叫 run 方法。

對於 Python 這種動態語言來說則不一定要傳入 Animal 型態,只要傳入的物件有 run 方法就可以了。

取得物件資訊


可以用 type 函式來判斷物件型態,基本型態都可以透過 type 判斷:

print(type(123))  
print(type('Jensen'))
print(type(None))
# 如果一個變數指向函式或類別,也可以用 type 判斷
a = abs
print(type(abs))
print(type(a))  # 變數指向函式
<class 'int'>
<class 'str'>
<class 'NoneType'>
<class 'builtin_function_or_method'>
<class 'builtin_function_or_method'>

type 回傳的是對應的類別型態,在 if 判斷式中可以比較兩個變數的 type 來判斷:

# type 回傳的是對應類別的型態,如 str、int
type(123) == type(456), type('jensen') == str, type(123) == str
(True, True, False)

如果要判斷一個物件是否為函式,可以使用 Python 內建的 types 模組中的常數:

import types
def func():
    pass

type(func) == types.FunctionType  # True,自訂函式型態
type(abs) == types.BuiltinFunctionType  # True,內建函式型態
type(lambda x: x) == types.LambdaType  # True,匿名函式型態
type((x for x in range(10))) == types.GeneratorType  # True,產生器型態

使用 isinstance()

對於類別的繼承關係來說,使用 type 就很不方便,如果要判斷類別型態,可以用 isinstance 函式:

a = Animal()  # 父類別
d = Dog()  # 子類別,父類別是 Animal
h = Husky()  # 子類別,父類別是 Dog

isinstance(h, Dog)  # True
isinstance(h, Animal)  # True
isinstance(d, Animal)  # True
isinstance(d, Husky)  # False,子類屬於父類型態,反過來不成立

能用 type 判斷的基本型態也可以用 isinstance 判斷:

isinstance('a', str)  # True
isinstance(123, int)  # True
isinstance(func, types.FunctionType)  # True(詳見上面 block)
isinstance(b'a', bytes)  # True

還可以判斷一個變數是否屬於某些型態之一:

isinstance([1, 2, 3], (list, tuple))  # True,[1, 2, 3] 屬於 (list, tuple) 中的 list
isinstance('jensen', (str, int))  # True,'jensen' 屬於 (str, int) 中的 str
True

使用 dir()

如果想取得一個物件的所有屬性和方法,可以使用 dir 函式,它會回傳一個包含字串的 list:

dir('Jensen')
['__add__',
 '__class__',
 '__contains__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getitem__',
 '__getnewargs__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__iter__',
 '__le__',
 '__len__',
 '__lt__',
 '__mod__',
 '__mul__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__rmod__',
 '__rmul__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 'capitalize',
 'casefold',
 'center',
 'count',
 'encode',
 'endswith',
 'expandtabs',
 'find',
 'format',
 'format_map',
 'index',
 'isalnum',
 'isalpha',
 'isascii',
 'isdecimal',
 'isdigit',
 'isidentifier',
 'islower',
 'isnumeric',
 'isprintable',
 'isspace',
 'istitle',
 'isupper',
 'join',
 'ljust',
 'lower',
 'lstrip',
 'maketrans',
 'partition',
 'replace',
 'rfind',
 'rindex',
 'rjust',
 'rpartition',
 'rsplit',
 'rstrip',
 'split',
 'splitlines',
 'startswith',
 'strip',
 'swapcase',
 'title',
 'translate',
 'upper',
 'zfill']

前面提到像 __XXX__ 這樣的屬性和方法在 Python 中有特殊用途,例如 __len__ 方法會回傳長度,在 Python 中可以用 len 函式取得物件長度,實際上就是呼叫該物件的 __len__ 方法,所以 len('Jensen')'Jensen'.__len__() 是等價的。

當自己寫類別時,如果也想用 len 函式,可以在類別中寫一個 __len__ 方法:

class TestLen(object):
    
    def __len__(self):  # 有這個方法後就可以用 len(TestLen)
        return 120

test = TestLen()
len(test)
120

除此之外,其他都是一般屬性或方法,例如 'Jensen'.upper() 就會回傳大寫字串。

可以透過 getattrsetattrhasattr 等函式來檢查物件的狀態:

class TestClass(object):
    
    def __init__(self, x):
        self.x = x
    
    def power(self):
        return self.x * self.x
    
test = TestClass(9)
hasattr(test, 'x')  # True,是否有 x 屬性
setattr(test, 'y', 10)  # True,設定一個屬性 y,值為 10
hasattr(test, 'y')  # True,是否有 y 屬性
getattr(test, 'y')  # 10,取得 y 屬性的值
# getattr(test, 'z')  # 若取消註解,會拋出 AttributeError
getattr(test, 'z', 404)  # 404,取得 z 屬性的值,若沒有 z 屬性則回傳 404

hasattr(test, 'power')  # True,是否有 power 方法
func = getattr(test, 'power')  # 將 power 方法指派給 func 變數
func()  # 81,呼叫 func 指向的函式,等同於 test.power()
81

實例屬性與類別屬性


由於 Python 是動態語言,因此根據類別建立的實例可以任意綁定屬性,可以透過實例變數或 self 變數來實現:

class Student(object):
    
    def __init__(self, name):
        self.name = name  # 透過 self 變數綁定屬性
        
s = Student('Jensen')  
s.score = 99  # 透過實例變數綁定屬性

如果 Student 類別本身需要綁定一個屬性,可以直接在類別中定義該屬性,這種屬性稱為「類別屬性」,屬於整個類別所有:

class Student(object):
    
    name = 'Students'

類別屬性可以直接透過類別名稱存取,例如 Student.name。當類別的實例沒有 name 屬性時,也會呼叫到類別的 name 屬性。如果實例中有與類別屬性同名的屬性,則實例屬性會覆蓋類別屬性。因此,撰寫程式時請避免將類別屬性與實例屬性使用相同名稱,以免混淆。

物件導向進階程式設計


在正常情況下,建立一個類別實例後,可以隨時為該實例動態綁定任何屬性與方法,這正是動態語言的彈性優勢:

class Student(object):
    pass

s = Student()
s.name = 'Jensen'  # 動態地為實例綁定一個屬性

也可以為實例動態綁定一個方法:

from types import MethodType
def set_score(self, score):  # 定義一個函式作為實例方法
    self.score = score

s.set_name = MethodType(set_name, s)  # 動態地為實例綁定一個方法  

但上述方法有明顯的限制:屬性和方法只綁定在實例s上,對其他Student的實例並無作用。若要讓所有實例都能共用相同的屬性和方法,可以選擇綁定類別屬性與類別方法:

def set_score(self, score):
    self.score = score
    
Student.name = 'NAME'  # 為類別綁定屬性
Student.set_score = set_score  # 為類別綁定方法
s = Student(); s.set_score(99)  # 綁定後,所有類別實例都可呼叫
s.name  # 輸出 'NAME'
s.score  # 輸出 99

動態語言允許在程式執行過程中動態為類別加上功能,這在 Java 等靜態語言中是無法想像的。

使用 __slots__

如果想限制實例的屬性,例如只允許 Student 實例新增 nameage 屬性,可以在定義類別時指定特殊變數 __slots__ 來限制實例可新增的屬性:

class Student(object):
    __slots__ = ('name', 'age')  # 用 tuple 定義允許綁定的屬性名稱
    
s = Student()
s.name = 'Eric'  # 綁定 name 屬性
s.age = '22'  # 綁定 age 屬性
# s.score = 99  # 若取消註解則會拋出 AttributeError

上例中,由於 score 未被包含在 __slots__,所以無法綁定該屬性,強行綁定會出現 AttributeError。

需注意,__slots__ 只對當前類別的實例有效,對繼承的子類別無效,除非子類別也定義 __slots__,此時子類別實例允許的屬性就是自身 __slots__ 加上基底類別的 __slots__

使用 @property


直接將屬性公開雖然簡單,但無法檢查參數,例如 s.score = 10000 就不合理。可以在 Student 類別的 set_score 方法中加上 if 判斷,但每次都要呼叫 set_score 太麻煩,不如直接賦值方便。

這時可以用 Python 內建的 @property 裝飾器,將方法變成屬性呼叫:

class Student(object):
    
    @property  # 將 getter 方法變成屬性
    def score(self):
        return self._score
    
    @score.setter  # 將 setter 方法變成屬性賦值
    def score(self, value):
        if not isinstance(value, int):
            raise ValueError('Score 必須是整數!')
        if value < 0 or value > 100:
            raise ValueError('Score 必須介於 0 ~ 100!')
        self._score = value

s = Student()
# s.score = 101  # 若取消註解,會拋出 ValueError
s.score = 90  # 實際上會呼叫 s.set_score(90)
s.score  # 實際上會呼叫 s.get_score()
90

也可以定義唯讀屬性,只定義 getter 而不定義 setter,例如下例中 birth 可讀寫,age 則為唯讀屬性:

class Student(object):
    
    @property
    def birth(self):
        return self._birth
    
    @birth.setter
    def birth(self, value):
        self._birth = value
       
    @property
    def age(self):
        return 2021 - self.birth

需注意,屬性的方法名稱不要與實例變數名稱重複:

class Student(object):
    
    @property
    def birth(self):
        return self.birth  # 這樣會造成遞迴呼叫,導致 RecursionError

上述寫法會報 RecursionError,因為 s.birth 會轉為呼叫 get_birth(),而 return self.birth 又會呼叫自己,造成無窮遞迴。

多重繼承


繼承是物件導向程式設計的重要方式,透過繼承,子類別可以擴充基底類別的功能。

例如 Animal 類別可能有 DogBird 子類,Dog 又可細分為 HoundsHerdings 等,Bird 也可能有 FlyableRunnable 等子類。

鴕鳥 Ostrich 既屬於 Bird 也屬於 Runnable,若一層層寫繼承會很複雜,Python 支援多重繼承(MixIn):

class Ostrich(Bird, Runnable):  # Ostrich 同時繼承 Bird 與 Runnable
    pass

如此一來,不需複雜的繼承鏈,只要組合不同類別的功能即可快速構建所需子類。

自訂類別


前面已介紹 __slots____len__ 等特殊方法,此外 Python 類別還有許多特殊函式可協助自訂類別。

__str__

預設列印類別實例時,會顯示如 <__main__.Student object at 0xXXXXX>,若想格式化輸出,只需在類別內定義 __str__ 方法:

class Student(object):
    
    def __init__(self, name):
        self.name = name
     
    def __str__(self):
        return 'Student 物件 (name: %s)' % self.name

print(Student('Jensen'))  # 輸出 "Student 物件 (name: Jingyao Zhang)"
Student('Jensen')  # 在互動式介面下仍會顯示預設格式,因為呼叫的是 __repr__ 方法

若想在互動式介面下也美觀顯示,可在 __str__ 後加上 __repr__ = __str__

__iter__

若希望類別可用於 for...in 迴圈,像 list 一樣,可實作 __iter__ 方法,讓實例成為可迭代物件,Python 會不斷呼叫其 __next__ 方法直到遇到 StopIteration,例如斐波那契數列:

class Fib(object):
    
    def __init__(self):
        self.a, self.b = 0, 1
        
    def __iter__(self):
        return self  # 實例本身就是迭代物件
    
    def __next__(self):
        self.a, self.b = self.b, self.a + self.b
        if self.a > 100000:
            raise StopIteration()
        return self.a
    
for n in Fib():
    print(n)
1
1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
1597
2584
4181
6765
10946
17711
28657
46368
75025

__getitem__

Fib 實例雖可用於 for 迴圈,但還不能像 list 一樣用索引取值,需實作 __getitem__ 方法,例如可用下標取得斐波那契數列:

class Fib(object):
    
    def __getitem__(self, n):
        a, b = 1, 1
        for x in range(n):
            a, b = b, a + b
        return a

f = Fib()
f[10]  # 輸出 89,可用下標取得第 11 個元素

但上述寫法不支援切片語法(如 f[5:10]),因為 __getitem__ 的參數可能是 int 也可能是 slice,需分別處理:

class Fib(object):
    
    def __getitem__(self, n):
        if isinstance(n, int):  # 若為整數
            a, b = 1, 1
            for x in range(n):
                a, b = b, a + b
            return a
        if isinstance(n, slice):  # 若為切片
            start  = n.start
            end = n.stop
            if start is None:
                start = 0
            L = []
            a, b = 1, 1
            for x in range(end):
                if x >= start:
                    L.append(a)
                a, b = b, a + b
            return L

f = Fib()
f[10]  # 輸出 89
f[:5]  # 輸出 [1, 1, 2, 3, 5]
f[5:10]  # 輸出 [8, 13, 21, 34, 55]
[8, 13, 21, 34, 55]

但這樣還不夠完整,尚未處理 step 參數(如 f[:10:2])及負數索引,要完全仿 list 還需更多工作。

若想讓物件像 dict 一樣,__getitem__ 的參數也可能是 key(如 str),對應還有 __setitem__(賦值)與 __delitem__(刪除)方法。

__getattr__

若呼叫不存在的屬性會拋出 AttributeError,除了預先定義該屬性,也可寫 __getattr__ 動態回傳屬性:

class Student(object):
    
    def __init__(self):
        self.name = 'Jensen'
    
    def __getattr__(self, attr):
        if attr == 'score':
            return 99
        if attr == 'age':
            return lambda: 22
        raise AttributeError('\'Student\' 物件沒有屬性 \'%s\'' % attr)
        
s = Student()
s.name  # 輸出 'Jensen'
s.score  # 輸出 99
s.age()  # 輸出 22,呼叫匿名函式

__call__

物件實例可有自己的屬性與方法,通常用 instance.method() 呼叫,若想直接用 instance() 形式,可實作 __call__ 方法:

class Student(object):
    
    def __init__(self, name):
        self.name = name
        
    def __call__(self):
        print('我的名字是 %s。' % self.name)
        
s = Student('Jensen')
s()  # 輸出 '我的名字是 Jensen。'

__call__ 也可定義參數,讓實例直接呼叫就像函式一樣。可用 callable 判斷變數是否可呼叫:

callable(max)  # True,max 可呼叫
callable(None)  # False,None 不可呼叫
callable('hello')  # False,字串不可呼叫
callable(Student())  # True,若移除 __call__ 方法則為 False

使用列舉類別


若需定義常數,通常用大寫變數搭配整數,如 JAN = 1, FEB = 2, ...,但型態仍為 int。Python 可用內建 Enum 類別定義列舉型態,每個常數都是類別唯一實例:

from enum import Enum

Month = Enum('Month', ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'))  # Month 型態的列舉類,可用 Month.Jan 取用

for name, member in Month.__members__.items():  # 可列舉所有成員,value 為自動賦值的 int,預設從 1 開始
    print(name, ' ==> ', member, ', ', member.value)

也可自訂列舉類別:

from enum import Enum, unique

@unique  # @unique 可檢查是否有重複值
class Weekday(Enum):
    Sun = 0
    Mon = 1
    Tue = 2
    Wed = 3
    Thu = 4
    Fri = 5
    Sat = 6
    
# 取用列舉型態有多種方式:
day1 = Weekday.Mon
print(day1)
print(Weekday.Tue)
print(Weekday['Wed'])
print(Weekday.Sun.value)
print(Weekday(6))
print(day1 == Weekday.Mon)
print(Weekday.Mon == Weekday['Fri'])
# Weekday(7)  # 若取消註解會拋出 ValueError,因為沒有 value 為 7 的成員

for name, member in Weekday.__members__.items():
    print(name, ' ==> ', member, ', ', member.value)
Weekday.Mon
Weekday.Tue
Weekday.Wed
0
Weekday.Sat
True
False
Sun  ==>  Weekday.Sun ,  0
Mon  ==>  Weekday.Mon ,  1
Tue  ==>  Weekday.Tue ,  2
Wed  ==>  Weekday.Wed ,  3
Thu  ==>  Weekday.Thu ,  4
Fri  ==>  Weekday.Fri ,  5
Sat  ==>  Weekday.Sat ,  6

使用元類(metaclass)


type()

動態語言與靜態語言最大差異在於函式與類別的定義不是編譯時決定,而是執行時動態建立。

例如要定義 Hello 類別,可寫一個 hello.py 模組:

class Hello(object):
    
    def hello(self, name = 'world'):
        print('Hello, %s.' % name)

當 Python 解譯器載入 hello 模組時,會依序執行所有語句,動態建立出 Hello 類別物件:

from hello import Hello

h = Hello()
h.hello()  # 輸出 "Hello, world."
print(type(Hello))  # 輸出 "<class 'type'>"
print(type(h))  # 輸出 "<class 'hello.Hello'>"

類別定義是執行時動態建立的,type 除了可查詢型態,也可動態建立類別。例如可用 type 建立 Hello 類,無需 class 關鍵字:

def func(self, name = 'world'):  # 先定義類別方法
    print('Hello, %s.' % name)
    
Hello = type('Hello', (object,), dict(hello = func))  # 建立 Hello 類
h = Hello()
h.hello()
print(type(Hello))
print(type(h))
Hello, world.
<class 'type'>
<class '__main__.Hello'>

建立類別物件時,type 需依序傳入三個參數:

  • 類別名稱
  • 繼承的基底類別(tuple,可多重繼承)
  • 方法名稱與函式綁定(如上例將 func 綁定到 hello)

type 建立的類別與 class 關鍵字定義的本質完全相同,但一般仍建議用 class 定義。

metaclass

除了 type 可動態建立類別,也可用 metaclass(元類)控制類別建立行為。一般是先定義類別,再根據類別建立實例,元類則是「如何建立類別」的規範。

先定義元類 ==> 建立類別 ==> 建立實例

也就是說,元類允許建立或修改類別,可將類別視為元類建立的「實例」。元類是 Python 物件導向中最難理解、最難使用的魔法。

範例:用 metaclass 為自訂類別 MyList 增加 add 方法:

# 先定義 ListMetaclass,依慣例元類名稱以 Metaclass 結尾
class ListMetaclass(type):  # metaclass 是類別的模板,必須繼承 type
    
    # __new__ 依序接收:準備建立的類別物件、類別名稱、繼承基底類別、方法集合
    def __new__(cls, name, bases, attrs):  
        attrs['add'] = lambda self, value: self.append(value)
        return type.__new__(cls, name, bases, attrs)
    
# 定義類別時指定 metaclass,魔法就會生效,建立 MyList 時會呼叫 ListMetaclass.__new__()
class MyList(list, metaclass = ListMetaclass):  
    pass

L = MyList()
L.add(1)  # add 方法,普通 list 沒有 add
L  # 輸出 [1]
[1]

一般情況下不會用這麼複雜的方法,直接在 MyList 定義 add 方法更簡單。但有時確實需要用元類修改類別定義,ORM 就是典型例子。

ORM(物件關聯對映,Object Relational Mapping)是將關聯式資料庫的一行對映為一個物件,即一個類別對應一個資料表,可大幅簡化程式。

要寫一個 ORM 框架,所有類別都只能動態定義,因為只有使用者才能根據資料表結構定義對應類別。範例:實作一個 ORM 框架:

# User 介面的基底類別 Model 與屬性型態 StringField、IntegerField 由 ORM 框架提供,魔法方法如 save 全由基底類自動完成。以下實作 ORM 框架
# 先定義 Field 類別,負責保存資料表欄位名稱與型態
class Field(object):
    
    def __init__(self, name, column_type):
        self.name = name
        self.column_type = column_type
        
    def __str__(self):
        return '<%s: %s>' % (self.__class__.__name__, self.name)
    
# 在 Field 基礎上進一步定義各型態欄位
class StringField(Field):
    
    def __init__(self, name):
        super(StringField, self).__init__(name, 'varchar(100)')
        
class IntegerField(Field):
    
    def __init__(self, name):
        super(IntegerField, self).__init__(name, 'bigint')
        
# 接著實作 ModelMetaclass
class ModelMetaclass(type):
    
    def __new__(cls, name, bases, attrs):
        if name == 'Model':
            return type.__new__(cls, name, bases, attrs)
        print('發現模型: %s' % name)
        mappings = dict()
        for k, v in attrs.items():
            if isinstance(v, Field):
                print('發現對應: %s ==> %s' % (k, v))
                mappings[k] = v
        for k in mappings.keys():
            attrs.pop(k)  # 移除
        attrs['__mappings__'] = mappings
        attrs['__table__'] = name
        return type.__new__(cls, name, bases, attrs)
    
# 以及基底類別 Model
class Model(dict, metaclass=ModelMetaclass):
    
    def __init__(self, **kw):
        super(Model, self).__init__(**kw)
        
    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            raise AttributeError(r"'Model' 物件沒有屬性 '%s'" % key)
            
    def __setattr__(self, key, value):
        self[key] = value
        
    def save(self):
        fields = []
        params = []
        args = []
        for k, v in self.__mappings__.items():
            fields.append(v.name)
            params.append('?')
            args.append(getattr(self, k, None))
        sql = 'insert into %s (%s) values (%s)' % (self.__table__, ','.join(fields), ','.join(params))
        print('SQL: %s' % sql)
        print('ARGS: %s' % str(args))

# 定義介面,建立 User 類別對應資料表 User
class User(Model):
    
    # 類別屬性對應資料表欄位
    id = IntegerField('id')
    name = StringField('username')
    email = StringField('email')
    password = StringField('password')
        

u = User(id = 12345, name = 'Jensen', email = 'jensen.acm@gmail.com', password = 'test123')
u.save()  # 執行正常,只要連接資料庫並執行 SQL,即可完成功能
發現模型: User
發現對應: id ==> <IntegerField: id>
發現對應: name ==> <StringField: username>
發現對應: email ==> <StringField: email>
發現對應: password ==> <StringField: password>
SQL: insert into User (id,username,email,password) values (?,?,?,?)
ARGS: [12345, 'Jensen', 'jensen.acm@gmail.com', 'test123']

當定義 class User(Model) 時,Python 會先在 User 定義中尋找 metaclass,若無則往基底類別 Model 尋找,找到後用 Model 中定義的 metaclass(ModelMetaclass)建立 User 類別。

在 ModelMetaclass 中,主要做了:

  • 排除對 Model 類別的修改;
  • 在 User 中尋找所有屬性,若為 Field 屬性則存入 __mappings__ 字典,並從類別屬性移除,否則執行時會出錯;
  • 將表名存入 __table__,這裡簡化直接用類別名稱。

在 Model 類別中可定義各種操作資料庫的方法,如 save、delete、find、update 等。

錯誤、除錯與測試


錯誤處理


在程式執行過程中,如果發生錯誤,可以事先約定回傳一個錯誤碼,這樣就能知道是否有錯以及錯誤原因。

在作業系統提供的呼叫中,回傳錯誤碼非常常見,例如開啟檔案的函式 open,成功時回傳檔案描述符(即一個整數),出錯時回傳 -1

但用錯誤碼來表示是否出錯非常麻煩,函式本來應該回傳的正常結果和錯誤碼混在一起,呼叫端需要寫很多判斷是否出錯的程式碼。一旦出錯,還需要一層一層往上回報,直到有某個函式能處理這個錯誤。

高階語言通常都內建一套 try...except...finally... 的錯誤處理機制,Python 也不例外。

try

先來看一個 try 的例子:除數不可為 0

try:  # 編寫程式時若認為某些程式碼可能會出錯,可以用 try 包住這段程式碼
    print('try...')
    r = 10 / 0  # 除數不可為 0,這裡出錯後續程式碼不會執行,直接跳到 except 區塊(即錯誤處理部分)
    print('result: ', r)
except ZeroDivisionError as e:  # except 捕捉到 ZeroDivisionError
    print('except: ', e)  # 印出錯誤訊息,若有 finally 區塊則會執行 finally,否則結束
finally:
    print('finally...')
print('END')
try...
except:  division by zero
finally...
END

若將上述 block 中的 r = 10 / 0 改為 r = 10 / 2,則執行結果如下:

try...
result: 5.0
finally...
END

由於沒有發生 ZeroDivisionError,所以 except 區塊不會執行,但無論有沒有錯誤,只要有 finally 區塊,finally 一定會被執行。

錯誤有很多種類,例如 ZeroDivisionErrorValueErrorAttributeError 等,若發生不同的錯誤,應由不同的 except 區塊處理,也就是可以有多個 except 來捕捉不同型態的錯誤:

try:
    print('try...')
    r = 10 / int('a')  # int 會拋出 ValueError
    print('result: ', r)
except ValueError as e:  # except 捕捉 ValueError
    print('ValueError: ', e)
except ZeroDivisionError as e:
    print('ZeroDivisionError: ', e)
else:  # 若沒有任何錯誤發生,會自動執行 else 區塊
    print('No ERROR!')
finally:
    print('finally...')
print('END')

Python 中的錯誤也是類別,所有錯誤型態都繼承自 BaseException,所以在使用 except 時要注意,它不只會捕捉該型態的錯誤,也會捕捉其子類別的錯誤:

try:
    foo()  # 會拋出 UnicodeError
except ValueError as e:    # 錯誤會被這個 except 捕捉,因為 UnicodeError 是 ValueError 的子類
    print('ValueError: ', e)
except UnicodeError as e:  # 這個 except 永遠不會捕捉到 UnicodeError,因為已經被上面的 except 捕捉了
    print('UnicodeError: ', e)

使用 try...except 結構捕捉錯誤還有一個很大的優點,就是可以跨越多層呼叫,例如 main 呼叫 barbar 呼叫 foo,如果 foo 出錯,只要最外層的 main 捕捉到就能處理:

def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:  # 不需要在每個可能出錯的地方捕捉,只要在適當層級捕捉即可,簡化程式
        bar('0')
    except Exception as e:  # except 會捕捉到 division by zero
        print('Error: ', e)
    finally:
        print('finally...')

main()
Error:  division by zero
finally...

呼叫堆疊(Call Stack)

如果錯誤沒有被捕捉,會一路往上拋,最後被 Python 直譯器捕捉,並印出錯誤訊息後程式結束,例如:

Traceback (most recent call last):
    File "error.py", line 11, in <module>
      main()
    File "error.py", line 9, in main
      bar('0')
    File "error.py", line 6, in bar
      return foo(s) * 2
    File "error.py", line 3, in foo
      return 10 / int(s)
ZeroDivisionError: division by zero

可以根據上述錯誤追蹤資訊依序分析,最終定位到錯誤發生在 error.py 第三行 return 10 / int(s),因為最後印出 ZeroDivisionError: division by zero,根據錯誤型態和訊息可以判斷 int(s) 本身沒錯,只是 int(s) 回傳了 0,導致 10 / 0 出錯。

記錄錯誤

如果不捕捉錯誤,Python 直譯器會自動印出錯誤堆疊,但程式也會終止。既然可以捕捉錯誤,就可以把錯誤堆疊記錄下來,讓程式繼續執行,日後再分析錯誤原因。Python 內建的 logging 模組可以做到這一點:

import logging

def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:
        bar('0')
    except Exception as e:
        logging.exception(e)  # 可設定 logging 把錯誤資訊寫入日誌檔,方便日後追查
        
main()  # 同樣出錯,但程式印完錯誤堆疊後會繼續執行,正常結束
print('END')  
ERROR:root:division by zero
Traceback (most recent call last):
  File "/tmp/ipykernel_15563/3467350787.py", line 11, in main
    bar('0')
  File "/tmp/ipykernel_15563/3467350787.py", line 7, in bar
    return foo(s) * 2
  File "/tmp/ipykernel_15563/3467350787.py", line 4, in foo
    return 10 / int(s)
ZeroDivisionError: division by zero


END

拋出錯誤

錯誤是類別,捕捉錯誤就是捕捉到該類別的一個實例。Python 內建函式會拋出許多型態的錯誤,也可以自己撰寫函式拋出自訂的錯誤。

若要拋出錯誤,可以根據需求定義一個錯誤類別,選擇適當的繼承關係,最後用 raise 拋出錯誤實例:

class FooError(ValueError):  # 自訂錯誤類別,繼承自 ValueError
    pass

def foo(s):
    n = int(s)
    if n == 0:
        raise FooError('invalid value: %s' % s)  # 拋出自訂錯誤
    return 10 / n

foo('0')
---------------------------------------------------------------------------

FooError                                  Traceback (most recent call last)

/tmp/ipykernel_15563/3824456867.py in <module>
      8     return 10 / n
      9 
---> 10 foo('0')


/tmp/ipykernel_15563/3824456867.py in foo(s)
      5     n = int(s)
      6     if n == 0:
----> 7         raise FooError('invalid value: %s' % s)  # 拋出自訂錯誤
      8     return 10 / n
      9 


FooError: invalid value: 0

如果可以選擇 Python 內建的錯誤型態,請盡量使用內建型態,只有在必要時才自訂錯誤型態。

有時候底層程式碼不清楚如何處理錯誤,可以將錯誤往上拋,交給高層呼叫者處理:

def foo(s):
    n = int(s)
    if n == 0:
        raise ValueError('invalid value: %s' % s)  # 拋出 ValueError
    return 10 / n

def bar():
    try:
        foo('0')
    except ValueError as e:  # 底層捕捉到 ValueError
        print('ValueError!')  # 印出錯誤僅作記錄
        raise  # 底層不知道如何處理,繼續往上拋給高層處理
        
bar()
ValueError!



---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

/tmp/ipykernel_15563/1057959981.py in <module>
     12         raise  # 底層呼叫者不知道如何處理錯誤,繼續上拋給高層呼叫者處理
     13 
---> 14 bar()


/tmp/ipykernel_15563/1057959981.py in bar()
      7 def bar():
      8     try:
----> 9         foo('0')
     10     except ValueError as e:  # 底層呼叫者捕獲到ValueError
     11         print('ValueError!')  # 打印错误只是单纯的记录一下


/tmp/ipykernel_15563/1057959981.py in foo(s)
      2     n = int(s)
      3     if n == 0:
----> 4         raise ValueError('invalid value: %s' % s)  # 拋出 ValueError
      5     return 10 / n
      6 


ValueError: invalid value: 0

raise 若不帶參數會把目前的錯誤原樣拋出。此外,在 exceptraise 一個錯誤還可以把一種型態的錯誤轉換成另一種型態:

try:
    10 / 0
except ZeroDivisionError:  # 捕捉到 ZeroDivisionError
    raise ValueError('Input Error!')  # 轉換成 ValueError

只要轉換邏輯合理即可,但絕不應該把一個 IOError 轉成完全無關的 ValueError

最後更新於