Python

本篇簡介基礎概念。

主張:Python 為一種基於易於撰寫與閱讀且擁有強大功能性的程式語言。

常用的應用為:

  • 網際應用程式:伺服器後端程式與溝通。
  • 桌面腳本:方便操作檔案或系統排程而撰寫的編程。
  • 科學用途:進行較深入的數學運算,如矩陣、代數、微積分等等。
  • 非高效使用者界面:反應不須即時的圖形化使用者界面 (GUI, Graphical User Interface)。
  • 內嵌語言:主程式需要一種高階語言作為編程使用。

官方使用 C 語言來做實作直譯器,稱為 CPython,實現如下重要的概念:

  • 得益於 C 語言程式庫的延伸性,提昇執行效能。
  • 原型 (Prototype) 規範,可以與多種程式語言交換資料。
  • WSGI (Web Server Gateway Interface) 協定可以使網頁框架變得更簡單。
  • 模組 (Module) 規範使製作與分享擴充功能更容易,令 Python 擁有大量的模組社群。
  • 語法相較於其他多功能的程式語言而言更簡單明確,除了完整的物件導向功能, 還具有 Bootstrapping 的特性,並且可以使用標準庫內的高階功能。 其中魔法 (Magic Function / Method / Name) 名稱(如 __init__)的概念是非常獨特的。
  • 能夠自解譯,當 Python 作為字串 (String) 輸入時,可以直接執行。

使用 Python 必須先知道它的缺點:

  • 速度不夠快:跟所有高階程式語言一樣,Python 的速度源自於底層優化,當程式碼複雜度已經是極限低時, 因為那些方便安全的功能,Python 的速度永遠不是它的強項,你只能等待 CPython 更新或改善程式庫來強化性能。 根據測試,它在相同邏輯下會比優化的 C 語言慢 20~200 倍。
  • 相對龐大:同於高階程式語言的問題,Python 包裝成產品是 Byte Code 加上虛擬層,仍沒有機器碼小。

Syntax

Python 是看換行記號的,換而言之,你可以將每一行程式碼視為一條指令, 而不需要擔心它還有其他部分突然出現在檔案的其他地方,影響閱讀。

# 不會視為 1 + 1 - 2 * 9,而是兩次運算
1 + 1
-2 * 9

# 若括弧 ([{ 為開啟狀態,可以做換行
# 1 + 1 - 2 * 9
(
    1 + 1
    -2 * 9
)
(1 + 1
    - 2 * 9)

程式碼區塊 (Code Block) 如果位階不是 Global,則必須縮排,縮排符號為 \t 或 4 個空白, 且同個檔案中只能擇一。官方建議使用後者。

如果手動換行,新的一行必須跟上一行有一樣或更多的縮排。

def func(a, b):
    return a + b  # func 的子程式碼

「優先權」運算子大幅減少。為了長得像英文書寫,捨棄了很多老派程式語言強制使用的括弧 () 符號。 當然如果括弧會讓程式碼更好看,仍建議放置。

# 舊習慣
if (font_size > 10):
    font_size = 10
    (a, b) = (b, a)

# 建議使用
if font_size > 10:
    font_size = 10
    a, b = b, a

Python 沒有 Entry Point 的設計,所以可以理解成由上而下的執行,就跟直譯模式一樣。 不過如果是撰寫檔案,還是建議將程式碼封裝在 Function 中來呼叫,避免先後順序產生問題。 以下則是「模擬」Entry Point 的做法,假裝有一個入口程式可以執行。 這個方法也是官方建議撰寫龐大架構(多個模組)時的守則:如果沒有需求,程式碼不應該在 Global 執行。

# main.py
def main():
    print("Hello World!")

if __name__ == '__main__':
    main()

Builtins

本節介紹程式碼常用的內建功能。

Data Types

Python 有一般程式語言常見的數據種類:

名稱語法說明
boolTrue, FalseBoolean 布林值,代表 0 和 1。
int-10, 0b010, 0x010, 100_000Integer 整數,支援二進位、十六進位和長度標記。
float10.0050, 30., .02, 5e-2Floating Point 浮點數,支援科學記號標示。
complex1j, 10.j, .02jComplex Number 複數,可以與其他數字運算,有實部和虛部。
bytesb"x00"Bytes 字節,尚未編碼的字串,除非壓縮需求,否則建議不要使用。
str"sss", 'a1', "''", "\"", f"{n} mm"String 字串,代表一個或多個文字,使用反斜線代表特殊記號。Python 3.6 開始支援 Format Literal。

與原始的數據類型相比,Python 的數據沒有 unsigned 的種類,也沒有 long 的長度調整,增加了方便性。 而且,boolintfloatcomplex 都可以混和運算。

至於字串則是 Unicode 8 編碼而非二進制編碼,本身就具備各種常用的演算法且支援檢索功能, 並可以藉由物件導向的方式呼叫,如 'sbc'.find('c') 可以獲得 2。 而多行字串可以紀錄換行字元和縮排,用三個引號 """ 表示。

s = """
line1
line2
"""

在 Python 的物件導向概念中,數據類型也是物件,所以可以繼承作為更多用途。

Indexing

使用檢索運算子 (index operator) [] 按順序取值。 索引跟大部分的程式語言一樣都是從 0 開始,並且之支援反向檢索,-1 代表最後一項。 如果檢索失敗會引發 IndexError

a = 'abc'
ac = a[0] + a[-1]  # 'ac'

檢索功能不只有數字,通過運算子自訂,也可以使用任何物件。

另一種內建的語法為分割器 (slice),使用分號 : 在檢索運算子中。 分割器的第一項為開始,第二項為結束,第三項為間隔數;如果沒有標示則默認為頭尾和間隔 1。 透過這種方法,可以將內容切片為新的複製品或參照。

bcd = 'abcdef'[1:4]  # 'bcd'
abcd = 'abcdef'[:-2]  # 'abcd'
fedcba = 'abcdef'[::-1]  # 'fedcba'

其實相當於建立 slice 物件填入而已。

fedcba = 'abcdef'[slice(None, None, -1)]

像是知名模組 NumPy 使用 Tuple 容器一樣:

matrix[:, -1] == matrix[(slice(None), -1)]

Containers

除了特殊需求,Python 內建了各種完善且快速的容器。

Python 的記憶體都不是連續規劃的,所以可以儲存不同類型的內容,但是仍建議儲存有規則的內容。

而容器的大小稱為長度 (Length),可以由 len() 函式獲得。

Tuple

元組 (Tuple) 是一種唯讀的鏈狀容器。 使用工廠函式 tuple 可以建立空的或轉換可迭代物件。

# 小括弧表示 Tuple,某些情形下可以省略但不建議
a = ()
a = (1, 2, 3)
a = (
    "abc",
    "def",
)
a = 1,
a = tuple([1, 2, 3])

常用來解構和包裝物件,其他容器也可以,但是 Tuple 的成本最低。

# 解構,按順序分配給 b=1, c=2, d=3
# 如果數量錯誤會出錯
b, c, d = a
# 回傳兩個值,其實是用 Tuple 包裝
return a, b
# For 迴圈解構
for a, b in [(1, 2), (3, 4)]:
    ...

檢索使用整數 intslice

a = ('a', 10, 'bc')
abc = a[0] + a[-1]  # 'abc'

比較演算法可以直接用運算子達成,可是必須兩方皆為 Tuple。

(1, 2, 3) == (4, 5, 6)

List

清單 (List) 是能夠修改的鏈狀容器,支援排序演算法等操作。 使用工廠函式 list 可以建立空的或轉換可迭代物件。

# 中括弧表示 List
a = []
a = [1, 2, 3]
a = [
    "abc",
    "def",
]
a = list((1, 2, 3))

檢索使用整數 intslice

a = ['a', 10, 'bc']
abc = a[0] + a[-1]  # 'abc'

透過 appendinsert 方法來新增內容。

a.append(5)  # 新增 5 到最尾端
a.insert(0, '888')  # 在 0 處插入 '888',後方的索引都會移位

透過索引 del 運算子和 pop 方法來刪除內容。索引不存在會引發 IndexError

a.pop()  # 刪除最後一項
a.pop(0)  # 刪除第一項
b = a.pop(0)  # 刪除第一項,該值交給 b
del a[5:10]  # 刪除 5 到 9

透過索引指派運算子來改變內容。索引不存在會引發 IndexError

a[3] = 'gcc'
a[1:6] = b[7:12:-1]  # 長度必須一樣
a[:] = 'gcc'  # 從其他可迭代物件複製值,長度忽略,a = ['g', 'c', 'c']

比較演算法可以直接用運算子達成,可是必須兩方皆為 List。

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

List 還支援排序,一般是使用 Hash 函式獲得的值,或可以自訂。

a.sort()  # 由小到大
a.sort(reverse=True)  # 由大到小
a.sort(key=getitem_func)  # 由指定函式給值,該函式輸入一個項目後返回可分辨大小的數值

其他容器若想要排序,可以使用內建的 sorted 函式,可以獲得一個排列好的新 List。 sorted 函式支援任何可迭代物件,而且支援 reversekey 選項。

new_list = sorted((5, 6, 8, 7))  # [5, 6, 7, 8]

Dict

字典 (Dict) 是一種可修改的樹狀容器。 使用工廠函式 dict 可以建立空的或轉換兩兩一對的可迭代物件。 任何可雜湊的 (Hashable) 物件都可以作為索引,每個索引都會配一個值, 值可以是任何物件。這種關係稱為鍵值 (Key Value)。

內建的可雜湊值諸如前面介紹過的數據類型、Tuple 還有等等提到的 Frozen Set。 其他自訂類型可以實作雜湊用的魔法函數 __hash__ 達成。 相同雜湊值的物件會被視為相同值,因此可修改物件都不支援雜湊值。

# 大括弧表示 Dict
# 每一項依序由 Key、冒號、Value 組成
a = {}
a = {2: "2", 4: "abc", "U": {0: [1, 2, 3]}}
a = {
    "Name": "GOGO",
    "Old": 80,
}
a = dict([(1, 2), (3, 4)])

檢索使用 Key,不存在會引發 KeyError。 若要避免空索引,可以使用 get 方法。

a = {'key': 10, 'a': 20}
n30 = a['key'] + a['a']  # 30
a.get('r', 70)  # 70

透過索引指派運算子來新增和改變內容,另外還有 update 方法可以更新。

a['new'] = 50  # 更新 Key 和 Value
a.update([('n': 600)])  # 從兩兩相對的可迭代物件更新 Key 和 Value

用索引 del 運算子和 pop 方法來刪除內容。索引不存在會引發 KeyError

del a['old']
b = a.pop('old')  # 刪除 'old' 並回傳它的 Value

比較演算法可以直接用運算子達成,可是必須兩方皆為 Dict。

{'a': 10} != {'a': 6}

使用包含運算子可以檢查 Key 是否存在。

'a' in {'a': 5}

一般的迭代器只有一個項目,但是 Dict 有兩個,因此有三種方法。

for k in a:  # 遍歷 Key
    ...
for k in a.keys():  # 遍歷 Key
    ...
for v in a.values():  # 遍歷 Value
    ...
for k, v in a.items():  # 遍歷 Key 和 Value
    ...

Set

集合 (Set) 為可修改的樹狀容器,其實就是 Dict 的 Key 部份,用來比較不可重複或無序的物件。 使用工廠函式 set 可以建立空的或轉換可迭代物件。

# 大括弧表示 Set
a = set()
a = {1, 2, 3}
a = {
    "Jay",
    "Joy",
}
a = set([1, 2, 2, 3])

Set 無序不可檢索,使用 addupdate 新增可雜湊物件。

a = {12, 13, 14}
a.add(5)  # 新增 5
a.update([1, 2, 3])  # 從可迭代物件新增

使用 pop 方法從 Set 移除末端的一個物件,不過使用者無法掌握。

a.pop()

還有比較用的運算子,同於數學運算。

# 產生新的容器
a | b  # 聯集
a ^ b  # 補集
a & b  # 交集
a - b  # 差集
a < b  # 子集
# 產生布林值
a <= b  # 包含子集
a > b  # 超集
a >= b  # 包含超集
a == b  # 相等

其中聯集、補集、交集、差集可以使用指派運算子將自身替換掉。

a |= b  # 同於 update,但是限定 Set 類型

另外還有唯讀的版本 Frozen Set(工廠函式 frozenset),支援雜湊功能。

a = frozenset({1, 2, 3})

Closure & Iterator

當我們想表示一個容器是從另一個容器轉換過來時, 會需要複製和處理的過程。 如果手續不是很複雜,Python 提供了語法糖表示。

在下面的語法中,可以挑選大於 0 的數值,建立出新的 List。

a = [i for i in old_list if i > 0]

等同:

a = []
for i in old_list:
    if i > 0:
        a.append(i)

而其他容器也是類似。注意優先權運算子必須在混合逗號和指派時使用,因此 Tuple 必須使用工廠函式。

a = {k: v << 1 for k, v in old_dict.items() if k in test_set}  # dict
func((i for i in my_list if i is not None), 70)  # Iterator
a = tuple(i * i for i in range(20))  # tuple

這種直接將 For 迴圈的語法變成表達式的方式稱為 Closure。 其實它跟 Function 是有點關係的,因為它代表一連串的指令,只是延後執行了。

在 Python 中 Function 也是物件,因此你可以在任何時候定義它,甚至作為輸入和輸出的變數。 這樣子就可以延遲執行或多次執行一種只在特定期間的任務,例如 sort 函式的 key 選項。 所以會有巢狀式:

def func(...):
    def wrapper(...):
        ...
    return wrapper

一般過濾的程式用迴圈遍歷即可,但是如果有多道手續,就會做很多次。 為了節省時間,便使用了迭代器 (Iterator)。

Iterator 會在處理完一個之後等待,直到下一次呼叫再繼續執行。 這裡便會使用關鍵字 yield,可以視為一次 return,等完後才會繼續執行。 將這些內容取出來的方法有兩種,next 函式和 For 迴圈, 前者可以逐步取出;後者則是嵌套成迴圈,可以用 break 關鍵字中斷。

def iterator():
    yield 0  # 第一次傳 0
    yield 10  # 第二次傳 10

r = iterator()  # 創造一個一次性實體
n0 = next(r)  # 0
n10 = next(r)  # 10
# 再呼叫會引發 StopIteration 錯誤

for i in iterator():  # 執行兩次的迴圈
    print(i)  # 0 10

關鍵字 yield 有傳出和傳入的功能,統稱為 Generator,只有傳出的稱為 Iterator。

b = yield  # 傳入 b
b = (yield i) + 1  # 傳出 i 再傳入,加 1 指派給 b

使用 send 方法來傳入物件,按順序配合 next 函式傳出。不像單純傳出可以用 For 迴圈連續取值。

r = iterator()
r.send(20)
v = next(r)

換句話說 Closure 語法其實只是匿名 Iterator 的縮寫。 而一般 For 迴圈會留下指派的名稱,Iterator 和 Closure 不會影響現有的名稱,可視為 Nested Local。

# Closure
r = (i for i in my_list if i is not None)
# Iterator
def _iter():
    for i in my_list:
        if i is not None:
            yield i
r = _iter()

Builtins Iterator

內建迭代器 range 提供數字的計數,參數規則與分割器一樣。

list(range(3))  # [0, 1, 2]
list(range(3, 7))  # [3, 4, 5, 6]
list(range(3, 8, 2))  # [3, 5, 7]

內建迭代器 enumerate 提供可迭代物件的計數器。

for i, (x, y) in enumerate([(12., 20.05), (4., 60.5)]):
    print(i, x, y)  # 0 12.0 20.05, 1 4.0 60.5

更多迭代器可以從 itertools 模組導入。

Function Arguments

函式 (Function) 是 Python 中非常普遍的存在。 Python 的函式不支援重載 (Overloading),所以是使用引數 (Arguments) 的規則管理,迭代器也相同。

Call and Return

呼叫 (Call),類似數學的語法,函式名稱在前,小括弧在後。 即使沒有引數,仍需要寫上,不然只代表函式本身(注意函式也是物件),而非它的計算值。

func()

呼叫之後,函式會帶入引數,運算並回傳 (Return) 回傳值 (Return Value)。 在定義函式時,def 關鍵字定義引數類型return 關鍵字就是表示計算結束並回傳回傳值

def func():
    ...
    return return_value

函式中可以有多個回傳點,通常在判斷式中。 若不寫回傳值,將會回傳 None;若不寫回傳點,將會在最後一行回傳。

回傳值可以自行決定須不需要保留。

a = f()  # 使用指派式會保留結果
g(f() + 10)  # 寫在表達式中可以參與運算後捨棄
f()  # 若都沒運算會直接捨棄

Positional & Keyword Arguments

依照位置或關鍵字輸入。 按語法順序如下:

f(a, b, c, ..., d=d, e=e, f=f, ...)

定義上就比較多元,基本語法跟輸入一樣。 不過 = 語法代表預設值,因此不會規定使用的語法。

def f(a, b, c, ..., d=1, e=2, f=3, ...):
    ...

而擴充語法為 ***,代表收集多餘的引數,* 代表多餘的位置引數;** 代表多餘的關鍵字引數。 在 * 前的為位置優先輸入,* 後的為強制關鍵字輸入。

其中 * 的值為 Tuple;** 的值為 Dict。前者可以不寫名稱,這樣多餘的引數就不允許存在。

def f(a, b, ..., c=1, d=2, *args, e, f, ..., g=3, h=4, ..., **kwrds):
    ...

Python 3.8 後可以使用 / 語法強制前面的引數依順序輸入,不可以使用關鍵字語法。

def f(a, b, ..., c=1, d=2, ..., /, e, f, ..., g=3, h=4, ...):
    ...

Decorator

Python 提供一種比較抽象的方式處理函式包裝的需求(如前處理和後處理),稱為裝飾器 (Decorator)。 其語法為前方一個 @ 符號,置於函式或類型上方一行,可以多個,每個一行,由外層包向內層。

裝飾器就是函式,只是它們只能填入一個函式引數,且回傳函式。

def unit(func):
    def wrapper(s):
        f0(s + " mm")
    return wrapper

@unit
def f0(s):
    print(s)

f0('50')  # 50mm

裝飾器可以是任何可呼叫 (Callable) 物件,所以其實類型也可以作為裝飾器。 如 Python 3.7 提供的 Data Class。

from dataclasses import dataclass


@dataclass
class MyData:
    a: int = 10

Reference Counter

其實 Python 中並不叫做「變數 (Variables)」,而是「名稱 (Names)」,這麼做是為了區分 C 語言中的概念。

在較低層級的程式語言中,為了能夠自由調整記憶體的使用時機,於是分為「Stack」和「Heap」。 其中 Stack 為 Code Block 內的行程,分為一個 Global 與多個 Local, Global 為整個程式的執行時間;Local 則是暫時的執行期間,如函式和類型定義。 而 Heap 則是自訂的存活期間,在 C 語言中是使用指標 (Pointer) 表示。

// 規劃 50 個連續的 int 空間,並將第一個位址回傳給變數 ptr
int *ptr = (int *)malloc(sizeof(int) * 50);
...;
// 釋放從 ptr 開始的連續記憶體空間
free(ptr);

在 Python 中,使用 Reference Counter 的概念。 可以將「名稱」視為自動化的「指標」,從「指派」開始規劃,直到其結束 Steak。 如果該值曾經轉移給其他名稱,只要「名稱」存在,該記憶體就會保存,直到所有名稱被刪除。 使用 is 運算子可以檢查記憶體是否相同。

a = [1, 2, 3]  # 規劃容器
b = a  # b 與 a 共用相同記憶體位置
print(b is a)  # True
b.pop(0)  # a 的第一項也會移除
del a  # a 被移除,但是容器依然存在於 b

然而,這些操作只要使用到「指派運算子」,都會重新規劃記憶體。 如一些唯讀的類型,大部分不會有編輯的方法,重新指派後就會刪除舊的、變成新的,就沒有「取別名」的效果。 而檢索指派運算子 []、屬性存取運算子 . 也是同理,只是父名稱不會變。

a = 10  # 規劃 10
b = a  # b 與 a 共用相同記憶體位置
print(b is a)  # True
# a = a + 20 的縮寫,將 a 從 10 轉移到 30
# 由於 10 還有 b 一個名稱,因此不會被刪除
a += 20
print(b is a)  # False

Python 的區域示意如下,比較特別的是類別的定義區是屬於其屬性 (Attributes) 的。

# (Global)

def f1():
    # (Local)

    def f2():
        # (Nested Local)
        pass

# (Global)

class Object:
    # (Object Attributes)
    pass

# (Global)

如果在 Nested Local 沒有找到名稱的話,會從上一層 Local 尋找,以此類推,直到在 Global 尋找。 最後如果都找不到,會引發 NameError

在 Local 中可以使用 global 關鍵字宣告此區段的某些名稱是屬於 Global 的,這樣整個範圍內就可以修改 Global 的名稱。 而在 Nested Local 可以使用 nonlocal 關鍵字宣告上一層 Local 的名稱。 至於單純引用沒有做指派就不用特別宣告。

g = 20

def f():
    global g
    g = 60

f()
print(g)  # 60

Modules

本節要介紹模組導入的概念。 Python 預設有一組搜尋路徑,分別是執行位置、程式庫位置(包含內建和外部的)、執行檔位置、自訂位置。 比較重要的是第一個,因為在執行期間,可以透過修改 sys.path 這個 List 來增加搜尋位置(但是不建議)。 這些搜尋位置稱為 Root,可以使用絕對導入 (Absolute Import) 語法。

導入時相當於把整個 py 檔執行一遍並記住,所以只會執行一次,其他次只會提取名稱而已。 導入後稱為模組 (Module),通常是一個 py 檔或是目錄 (Directory)。 可以使用屬性存取運算子 . 來存取內容,能夠修改現有名稱的內容,但是不能使用指派語法。

如果導入失敗會發生 ImportError,如果是模組名稱找不到,Python 3.6 開始會引發 ModuleNotFoundError

import os
import sys

print(sys.path)

如果模組是一個目錄,則會導入它的魔法模組 __init__.py,如果沒有就會視為空模組。 不過目錄內的其他模組可以使用屬性存取運算子 . 來存取。

不過使用 import 語法導入時必須在引用時使用他的全名,因此會使用 from ... import 語法省去其前綴模組。 如果名稱重複會有被蓋掉的風險,可以使用 as 語法更改名稱,不用擔心導入前的物件會被刪除。

import os.path
my_path = os.path.join("aaa", "bbb")
from os.path import join
my_path = join("aaa", "bbb")
from os.path import join as pth_join
my_path = pth_join("aaa", "bbb")

而且 from ... import 語法的方便之處是可以導入多個同模組的名稱,而且加上括弧後可以換行放置。

from os.path import join as pth_join, sep
from os.path import (
    join as pth_join,
    sep,
)

因為只有 Root 可以使用絕對導入的語法,前綴會變得比較長,因此另外還有相對導入 (Relative Import) 語法。 此語法是在開頭加上一個 . 符號,表示從此模組的上一層模組(也就是 py 檔的目錄)開始尋找。 再增加 . 就會再往上一層,不過不建議使用以免誤導。

from .module_a import my_list

使用絕對導入搭配魔法模組 __init__.py 還可以避免遞迴導入 (Recursive Import),也就是兩個模組互相導入的狀況,會無法分辨該先執行誰。 可以在 __init__.py 中導入全部的子模組,在其他模組只要導入該目錄就好。 而共用的內容通常也會獨立放置以降低相依性,避免遞迴導入。

而定義魔法名稱 __all__(List 物件)成為公開名稱清單也有助於 API 的開發。

from .module_a import my_list

__all__ = ['my_list', 'f1', 'f2', 'f3']

def f1(): pass
def f2(): pass
def f3(): pass

從 Python 3.7 提供延遲載入功能,藉由定義魔法名稱 __getattr__ (PEP 562) 這個函式來動態導入某些較費時的物件。

def __getattr__(name):
    return ...

Objective Orientation

經過前面章節的介紹,屬性存取運算子 . 是物件導向程式設計非常重要的一部分。 如果 Function 是為了某個類型而運作,則稱為 Method。 可以簡單的理解成:

def f1(me, she):
    ...
def f2(me, he):
    ...
def f3(me, they):
    ...

可以注意到,「me」的角色是主角,有三件事情環繞著它做,而且不能假他人之手。 因此在 Python 中,第一項參數被稱為「self」,且這「類」角色會變成一個「類型」。

下面這個範例中,將示範如何使用 Python 的物件定義功能。

from math import hypot


class Point:

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def distance(self, p):
        return hypot(self.x - p.x, self.y - p.y)


if __name__ == '__main__':
    p1 = Point(0, 0)
    p2 = Point(30, 40)
    print(type(p1))  # <class '__main__.Point'>
    # 原形表示法,self 表示 p1
    print(Point.distance(p1, p2))  # 50
    # 常用表示法
    print(p1.distance(p2))  # 50

上面的魔法方法 (Magic Method) __init__ 代表初始化 Point 物件時的參數, 並且方法中能用指派運算式 self.x = x 存入參數。 而一般方法 distance 可以計算並得到結果。

還有繼承概念在物件導向也是非常重要的。在不寫的情況下,所有的類型皆繼承自 object 類型。

class A:

    def method1(self):
        print("A.method1")

    def method2(self):
        print("A.method2")


class B(A):

    def method1(self):
        super(B, self).method1()
        print("B.method1")

    def method3(self):
        print("B.method3")

Python 中不存在重載,因此全部都會覆蓋 (Overriding)。 在上面的範例中,B 繼承 A 會把所有內容獲得,但是因為定義 method1 會掩蓋原本的功能, 因此要使用 super(Type, obj) 這個函式透過載入上一個類型來呼叫其方法。 或直接寫 A.method1(self) 也可以,可是就會定死型別,擴充時就會不方便。 也因為無法重載,Python 的類型定義不可以分開寫,除非用繼承的方式。

很多初始化方法都會先載入上一個類型的內容。

class SpecialItem(Item):

    def __init__(self, name):
        super(SpecialItem, self).__init__(name)
        ...

另外還有抽象類別 (Abstract Class) 和抽象方法 (Abstract Method) 以避免菱形繼承問題。 在名字好記的內建模組 abc 中就有提供抽象類別的功能,簡單來說就是建立「有名無實」的方法,以確定其可以被父類別呼叫,再由子類別做實作。

from abc import ABC, abstractmethod


class Interface(ABC):

    def method1(self):
        self.method2()

    @abstractmethod
    def method2(self):
        raise NotImplementError

再來是魔法方法,它們負責實作運算子、工廠函數轉型、一些內建函式的功能。 向之前提過的 __hash__ 就是這個例子。

class Point:

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, p):
        return self.__class__(self.x + p.x, self.y + p.y)


if __name__ == '__main__':
    p1 = Point(20, 50)
    p2 = Point(30, 40)
    p3 = p1 + p2
    print(p3.x, p3.y)  # 50 90

上面的魔法屬性 __class__ 會回傳自己的類型本身,而 __add__ 會提供相加運算子的功能。 還有 __int____str____iter__ 等可以轉型、變成迭代物件等功能可以使用。 而指派運算子 = 是 Reference Counter 的一環,所以不提供實作。 更多魔法方法可以看官方網站的介紹。

其實,魔法屬性 __dir__ 是一個 Dict,而它會紀錄所有的屬性名稱。這是一個遞迴概念,因為 Dict 物件也有 __dir__。 所以可以簡單理解為,Python 的物件導向是由 Dict 容器做出來的。 因此每次 Dict 類型效能增加,都會使整個性能大幅提昇。

Exceptions

在 Python 中不會發生真的「崩潰」錯誤,至少在純 Python 的情況下。 因為 Python 有一套例外機制,任何錯誤都是有來頭的,像之前提到的 IndexError 等。

如果在一個可能會發生錯誤的地方想要避免例外,可以提前檢查,如檢查 Key 的存在以避免 KeyError。 但是某些情況是無法掌握的,如 API 的輸入是不合法的,就必須提前阻止使用者輸入的正確性。 在 Python 中使用 raise 語法引發例外。

def f(t):
    if t < 1:
        raise ValueError("times must larger or equal than 1")
    ...

其實所有的例外都是繼承自 Exception 這個內建類型,相關的也會用繼承的方式表示, 如 ModuleNotFoundError 繼承自 ImportError。 開發者可以製作自己的例外,raise 語法中可以用 Exception 或任何子類型,以及其物件(加上訊息字串初始化)。

如果要避免一個不確定例外的產生,可以使用 try ... except ... else ... finally 語法。 這個語法有四個部分,有 try ... excepttry ... finally 兩種簡單組合,而前者可選 elsefinally 兩個區塊。 其中 try 區塊可以允許引發錯誤;except 區塊會捕抓例外類型(包含子類型)的實體並執行對應指令,沒捕抓到會視為例外引發; else 區塊會在沒有發生例外時執行;finally 區塊是 try 區塊執行完後一定會執行的部分,即使已經執行 return

try:
    if n < 0:
        raise ValueError("n must be positive")
except ValueError as e:  # 使用 as 關鍵字導入例外實體
    print(f"value wrong! {e}")
except (IndexError, NameError):  # 多個例外
    pass
else:
    print("you're right!")
finally:
    print("done!")

Context Manager

單純的 try ... finally 可以在 return 後執行額外的工作,例如關閉檔案。

f = open("f.txt", "w+")
try:
    return f.read()
finally:
    f.close()

而這個行為容易產生一些誤解,因此設計了語法糖,也就是 with 語法。 當一個類型擁有 __enter__(self)__exit__(self, type, value, traceback) 兩個魔法方法時, 可以使用 with 語法建立實體,此實體可以幫忙在錯誤發生時完成 finally 的工作。

以下用 open 工廠函數建立的 File 類型物件為例,這也是最常用的應用。 這樣就會達到上面的效果。

with open("f.txt", "w+") as f:
    return f.read()

Doc String

Python 提供使用註解轉換為說明手冊的功能,這些註解稱為 Doc String。 這些「註解」其實是普通字串,只是沒有指派而已,使用多行字串或是單行皆可以通過。

由於是註解,不可以使用運算前的樣式,如 + 運算子或 Format Literal。 而且必須擺在有文字的第一行。

以下是模組的 Doc String。

# example.py
# -*- encoding: utf-8 -*-

"""About my module:

...
"""

__email__ = "example@gmail.com"

from ... import ...

以下是函式和類型的 Doc String。

def foo():
    """About my function ..."""
    ...

class A:

    """About my object ..."""

    def __init__(self):
        """Startup!"""

取得文字則是從魔法名稱 __doc__ 字串物件中取得,函式和類型則是從魔法屬性。 而內建函式 help 可以在直譯器中顯示 Doc String。

Annotations

Python 3.5 開始逐漸重視類型標注的重要性,之前都使用較狹隘的函式註解標示。

def add_unit(*ns: float, unit: str) -> str:
    s = ""
    for n in ns:
        s += f"{n} {unit}"
    return s

或純註解 # type: int 等。因此在 Python 3.6 提供如下語法:

a: int  # 名稱標示
a: int = 10  # 結合指派表達式

注意 Python 是不會在執行期間檢查類型的,必須手動做。而官方提供了 MyPy 這個工具協助檢查類型標示。

雖然所有的物件都繼承自 object 類型,但是為避免誤導,MyPy 使用 Any 作為未標示的基底。 Python 3.5 提供了內建模組 typing,從裡面可以導入泛型的容器類別。 至於自訂的類型也可以直接使用。

from typing import Dict, Set

a: Dict[str, Set[str]] = {'classmate': {'Ann'}}

Python 3.7 提供了 Python 4 的未來功能 annotations 來支援遞迴標示。

# 使用前
class A:
    def method(self) -> 'A':
        ...

from __future__ import annotations

# 使用後
class A:
    def method(self) -> A:
        ...

而 Python 3.7 的魔法名稱 __annotations__ 和類型的魔法屬性是 Dict 物件,可以用來檢查,但是函式中無法收集。 建議交給靜態分析器做就好了。

a: int = 10
print(__annotations__['a'] is int)