4. 深入了解流程控制

除了剛才介紹的 while,這章節還會介紹一些 Python 的陳述式語法。

4.1. if 陳述式

或許最常見的陳述式種類就是 if 了。舉例來說:

>>> x = int(input("Please enter an integer: "))
Please enter an integer: 42
>>> if x < 0:
...     x = 0
...     print('Negative changed to zero')
... elif x == 0:
...     print('Zero')
... elif x == 1:
...     print('Single')
... else:
...     print('More')
...
More

在陳述式中,可以沒有或有許多個 elif 敘述,且 else 敘述並不是必要的。關鍵字 elif 是「else if」的縮寫,用來避免過多的縮排。一個 if ... elif ... elif ... 序列可以用來替代其他程式語言中的 switchcase 陳述式。

如果你要將同一個值與多個常數進行比較,或者檢查特定的型別或屬性,你可能會發現 match 陳述式也很有用。更多的細節,請參閱 match 陳述式

4.2. for 陳述式

在 Python 中的 for 陳述式有點不同於在 C 或 Pascal 中的慣用方式。相較於只能疊代 (iterate) 一個等差數列(如 Pascal),或給予使用者定義疊代步驟與終止條件(如 C),Python 的 for 陳述式疊代任何序列(list 或者字串)的元素,順序與它們出現在序列中的順序相同。例如(無意雙關):

>>> # 測量一些字串:
>>> words = ['cat', 'window', 'defenestrate']
>>> for w in words:
...     print(w, len(w))
...
cat 3
window 6
defenestrate 12

在疊代一個集合的同時修改該集合的內容,很難取得想要的結果。比較直觀的替代方式,是疊代該集合的副本,或建立一個新的集合:

# 建立一個範例集合
users = {'Hans': 'active', 'Éléonore': 'inactive', '景太郎': 'active'}

# 策略:對副本進行疊代
for user, status in users.copy().items():
    if status == 'inactive':
        del users[user]

# 策略:建立一個新集合
active_users = {}
for user, status in users.items():
    if status == 'active':
        active_users[user] = status

4.3. range() 函式

如果你需要疊代一個數列的話,使用內建 range() 函式就很方便。它可以生成一等差數列:

>>> for i in range(5):
...     print(i)
...
0
1
2
3
4

給定的結束值永遠不會出現在生成的序列中;range(10) 生成的 10 個數值,即對應存取一個長度為 10 的序列內每一個項目的索引值。也可以讓 range 從其他數值開始計數,或者給定不同的公差(甚至為負;有時稱之為 step):

>>> list(range(5, 10))
[5, 6, 7, 8, 9]

>>> list(range(0, 10, 3))
[0, 3, 6, 9]

>>> list(range(-10, -100, -30))
[-10, -40, -70]

欲疊代一個序列的索引值,你可以搭配使用 range()len() 如下:

>>> a = ['Mary', 'had', 'a', 'little', 'lamb']
>>> for i in range(len(a)):
...     print(i, a[i])
...
0 Mary
1 had
2 a
3 little
4 lamb

然而,在多數的情況,使用 enumerate() 函式將更為方便,詳見迴圈技巧

如果直接印出一個 range 則會出現奇怪的輸出:

>>> range(10)
range(0, 10)

在很多情況下,由 range() 回傳的物件表現得像是一個 list(串列)一樣,但實際上它並不是。它是一個在疊代時能夠回傳所要求的序列中所有項目的物件,但它不會真正建出這個序列的 list,以節省空間。

我們稱這樣的物件為 iterable(可疊代物件),意即能作為函式及架構中可以一直取得項目直到取盡的對象。我們已經了解 for 陳述式就是如此的架構,另一個使用 iterable 的函式範例是 sum()

>>> sum(range(4))  # 0 + 1 + 2 + 3
6

待會我們可以看到更多回傳 iterable 和使用 iterable 為引數的函式。在資料結構章節中,我們會討論更多關於 list() 的細節。

4.4. breakcontinue 陳述式

break 陳述式,終止包含它的最內部 forwhile 迴圈:

>>> for n in range(2, 10):
...     for x in range(2, n):
...         if n % x == 0:
...             print(f"{n} equals {x} * {n//x}")
...             break
...
4 equals 2 * 2
6 equals 2 * 3
8 equals 2 * 4
9 equals 3 * 3

continue 陳述式讓所屬的迴圈繼續執行下個疊代:

>>> for num in range(2, 10):
...     if num % 2 == 0:
...         print(f"Found an even number {num}")
...         continue
...     print(f"Found an odd number {num}")
...
Found an even number 2
Found an odd number 3
Found an even number 4
Found an odd number 5
Found an even number 6
Found an odd number 7
Found an even number 8
Found an odd number 9

4.5. 迴圈的 else 子句

forwhile 迴圈中,break 陳述句可能與 else 子句配對。如果迴圈完成而沒有執行 breakelse 子句會被執行。

for 迴圈中,else 子句會在迴圈完成最終的疊代後執行。

while 迴圈中,它會在迴圈條件變為 false 後執行。

在任何一種迴圈中,如果迴圈由 break 終止,則不會執行 else 子句。當然其他提早結束迴圈的方式(例如 return 或引發例外)也會跳過 else 子句的執行。

下面的 for 迴圈對此進行了舉例說明,該迴圈用以搜尋質數:

>>> for n in range(2, 10):
...     for x in range(2, n):
...         if n % x == 0:
...             print(n, 'equals', x, '*', n//x)
...             break
...     else:
...         # 迴圈結束但沒有找到因數
...         print(n, 'is a prime number')
...
2 is a prime number
3 is a prime number
4 equals 2 * 2
5 is a prime number
6 equals 2 * 3
7 is a prime number
8 equals 2 * 4
9 equals 3 * 3

(沒錯,這是正確的程式碼。請看仔細:else 子句屬於 for 迴圈,並非 if 陳述式。)

理解 else 子句的一個方法是將它想像成與迴圈內的 if 配對。當迴圈執行時,它會運行如 if/if/if/else 的序列。if 在迴圈內,會遇到多次。如果條件曾經為真,break 就會發生。如果條件從未為真,迴圈外的 else 子句就會執行。

else 子句用於迴圈時,相較於搭配 if 陳述式使用,它的行為與 try 陳述式中的 else 子句更為相似:try 陳述式的 else 子句在沒有發生例外 (exception) 時執行,而迴圈的 else 子句在沒有任何 break 發生時執行。更多有關 try 陳述式和例外的介紹,見處理例外

4.6. pass 陳述式

pass 陳述式不執行任何動作。它可用在語法上需要一個陳述式但程式不需要執行任何動作的時候。例如:

>>> while True:
...     pass  # 忙碌等待鍵盤中斷 (Ctrl+C)
...

這經常用於建立簡單的 class(類別):

>>> class MyEmptyClass:
...     pass
...

pass 亦可作為一個函式或條件判斷主體的預留位置,在你撰寫新的程式碼時讓你保持在更抽象的思維層次。pass 會直接被忽略:

>>> def initlog(*args):
...     pass   # 記得要實作這個!
...

在最後這個例子中,許多人會使用刪節號字面值 ... 來取代 pass。這種用法對 Python 並沒有特殊的意義,且並非語言定義的一部分(你也可以在這裡使用任何常數運算式),但 ... 也慣例上被用作預留主體 (placeholder body)。見 Ellipsis 物件

4.7. match 陳述式

match 陳述式會拿取一個運算式,並將其值與多個連續的模式 (pattern) 進行比較,這些模式是以一個或多個 case 區塊來表示。表面上,這類似 C、Java 或 JavaScript(以及許多其他語言)中的 switch 陳述式,但它與 Rust 或 Haskell 等語言中的模式匹配 (pattern matching) 更為相近。只有第一個匹配成功的模式會被執行,而它也可以將成分(序列元素或物件屬性)從值中提取到變數中。如果沒有任何的 case 匹配成功,則不會執行任何的分支。

最簡單的形式,是將一個主題值 (subject value) 與一個或多個字面值 (literal) 進行比較:

def http_error(status):
    match status:
        case 400:
            return "Bad request"
        case 404:
            return "Not found"
        case 418:
            return "I'm a teapot"
        case _:
            return "Something's wrong with the internet"

請注意最後一段:「變數名稱」_ 是作為通用字元 (wildcard)的角色,且永遠不會匹配失敗。

你可以使用 |(「或」)來將多個字面值組合在單一模式中:

case 401 | 403 | 404:
    return "Not allowed"

模式可以看起來像是拆解賦值 (unpacking assignment),且可以用來連結變數:

# point 是一個 (x, y) 元組
match point:
    case (0, 0):
        print("Origin")
    case (0, y):
        print(f"Y={y}")
    case (x, 0):
        print(f"X={x}")
    case (x, y):
        print(f"X={x}, Y={y}")
    case _:
        raise ValueError("Not a point")

請仔細研究那個例子!第一個模式有兩個字面值,可以想作是之前所述的字面值模式的延伸。但是接下來的兩個模式結合了一個字面值和一個變數,且該變數繫結 (bind) 了來自主題 (point) 的一個值。第四個模式會擷取兩個值,這使得它在概念上類似於拆解賦值 (x, y) = point

如果你要用 class 來結構化你的資料,你可以使用該 class 的名稱加上一個引數列表,類似一個建構式 (constructor),但它能夠將屬性擷取到變數中:

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

def where_is(point):
    match point:
        case Point(x=0, y=0):
            print("Origin")
        case Point(x=0, y=y):
            print(f"Y={y}")
        case Point(x=x, y=0):
            print(f"X={x}")
        case Point():
            print("Somewhere else")
        case _:
            print("Not a point")

你可以將位置參數 (positional parameter) 與一些能夠排序其屬性的內建 class(例如 dataclasses)一起使用。你也可以透過在 class 中設定特殊屬性 __match_args__,來定義模式中屬性們的特定位置。如果它被設定為 ("x", "y"),則以下的模式都是等價的(且都會將屬性 y 連結到變數 var):

Point(1, var)
Point(1, y=var)
Point(x=1, y=var)
Point(y=var, x=1)

理解模式的一種推薦方法,是將它們看作是你會放在賦值 (assignment) 左側內容的一種延伸形式,這樣就可以了解哪些變數會被設為何值。只有獨立的名稱(像是上面的 var)能被 match 陳述式賦值。點分隔名稱(如 foo.bar)、屬性名稱(上面的 x=y=)或 class 名稱(由它們後面的 "(...)" 被辨識,如上面的 Point)則永遠無法被賦值。

模式可以任意地被巢套 (nested)。例如,如果我們有一個由某些點所組成的簡短 list,我們就可以像這樣加入 __match_args__ 來對它進行匹配:

class Point:
    __match_args__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

match points:
    case []:
        print("No points")
    case [Point(0, 0)]:
        print("The origin")
    case [Point(x, y)]:
        print(f"Single point {x}, {y}")
    case [Point(0, y1), Point(0, y2)]:
        print(f"Two on the Y axis at {y1}, {y2}")
    case _:
        print("Something else")

我們可以在模式中加入一個 if 子句,稱為「防護 (guard)」。如果該防護為假,則 match 會繼續嘗試下一個 case 區塊。請注意,值的擷取會發生在防護的評估之前:

match point:
    case Point(x, y) if x == y:
        print(f"Y=X at {x}")
    case Point(x, y):
        print(f"Not on the diagonal")

此種陳述式的其他幾個重要特色:

  • 與拆解賦值的情況類似,tuple(元組)和 list 模式具有完全相同的意義,而且實際上可以匹配任意的序列。一個重要的例外,是它們不能匹配疊代器 (iterator) 或字串。

  • 序列模式 (sequence pattern) 可支援擴充拆解 (extended unpacking):[x, y, *rest](x, y, *rest) 的作用類似於拆解賦值。* 後面的名稱也可以是 _,所以 (x, y, *_) 會匹配一個至少兩項的序列,且不會連結那兩項以外的其餘項。

  • 對映模式 (mapping pattern):{"bandwidth": b, "latency": l} 能從一個 dictionary(字典)中擷取 "bandwidth""latency" 的值。與序列模式不同,額外的鍵 (key) 會被忽略。一種像是 **rest 的拆解方式,也是可被支援的。(但 **_ 則是多餘的做法,所以它並不被允許。)

  • 使用關鍵字 as 可以擷取子模式 (subpattern):

    case (Point(x1, y1), Point(x2, y2) as p2): ...
    

    將會擷取輸入的第二個元素作為 p2(只要該輸入是一個由兩個點所組成的序列)。

  • 大部分的字面值是藉由相等性 (equality) 來比較,但是單例物件 (singleton) TrueFalseNone 是藉由標識值 (identity) 來比較。

  • 模式可以使用附名常數 (named constant)。這些模式必須是點分隔名稱,以免它們被解釋為擷取變數:

    from enum import Enum
    class Color(Enum):
        RED = 'red'
        GREEN = 'green'
        BLUE = 'blue'
    
    color = Color(input("Enter your choice of 'red', 'blue' or 'green': "))
    
    match color:
        case Color.RED:
            print("I see red!")
        case Color.GREEN:
            print("Grass is green")
        case Color.BLUE:
            print("I'm feeling the blues :(")
    

關於更詳細的解釋和其他範例,你可以閱讀 PEP 636,它是以教學的格式編寫而成。

4.8. 定義函式 (function)

我們可以建立一個函式來產生費式數列到任何一個上界:

>>> def fib(n):    # 寫出小於 n 的費氏數列
...     """印出小於 n 的費氏數列。"""
...     a, b = 0, 1
...     while a < n:
...         print(a, end=' ')
...         a, b = b, a+b
...     print()
...
>>> # 現在呼叫我們剛才定義的函式:
>>> fib(2000)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597

關鍵字 def 介紹一個函式的定義。它之後必須連著該函式的名稱和置於括號之中的一串參數。自下一行起,所有縮排的陳述式成為該函式的主體。

一個函式的第一個陳述式可以是一個字串文本;該字串文本被視為該函式的說明文件字串,即 docstring。(關於 docstring 的細節請參見說明文件字串 (Documentation Strings)段落。)有些工具可以使用 docstring 來自動產生線上或可列印的文件,或讓使用者能以互動的方式在原始碼中瀏覽文件。在原始碼中加入 docstring 是個好慣例,應該養成這樣的習慣。

函式執行時會建立一個新的符號表 (symbol table) 來儲存該函式內的區域變數 (local variable)。更精確地說,所有在函式內的變數賦值都會把該值儲存在一個區域符號表。然而,在引用一個變數時,會先從區域符號表開始搜尋,其次為外層函式的區域符號表,其次為全域符號表 (global symbol table),最後為所有內建的名稱。因此,在函式中,全域變數及外層函式變數雖然可以被引用,但無法被直接賦值(除非全域變數是在 global 陳述式中被定義,或外層函式變數在 nonlocal 陳述式中被定義)。

在一個函式被呼叫的時候,實際傳入的參數(引數)會被加入至該函式的區域符號表。因此,引數傳入的方式為傳值呼叫 (call by value)(這裡傳遞的永遠是一個物件的參照 (reference),而不是該物件的值)。 [1] 當一個函式呼叫別的函式或遞迴呼叫它自己時,在被呼叫的函式中會建立一個新的區域符號表。

函式定義時,會把該函式名稱加入至目前的符號表。函式名稱的值帶有一個型別,並被直譯器辨識為使用者自定函式 (user-defined function)。該值可以被指定給別的變數名,使該變數名也可以被當作函式使用。這是常見的重新命名方式:

>>> fib
<function fib at 10042ed0>
>>> f = fib
>>> f(100)
0 1 1 2 3 5 8 13 21 34 55 89

如果你是來自別的語言,你可能不同意 fib 是個函式,而是個程序 (procedure),因為它並沒有回傳值。實際上,即使一個函式缺少一個 return 陳述式,它亦有一個固定的回傳值。這個值稱為 None(它是一個內建名稱)。在直譯器中單獨使用 None 時,通常不會被顯示。你可以使用 print() 來看到它:

>>> fib(0)
>>> print(fib(0))
None

如果要寫一個函式回傳費式數列的 list 而不是直接印出它,這也很容易:

>>> def fib2(n):  # 回傳到 n 為止的費氏數列
...     """回傳包含到 n 為止的費氏數列的串列。"""
...     result = []
...     a, b = 0, 1
...     while a < n:
...         result.append(a)    # 見下方
...         a, b = b, a+b
...     return result
...
>>> f100 = fib2(100)    # 呼叫它
>>> f100                # 寫出結果
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

這個例子一樣示範了一些新的 Python 特性:

  • return 陳述式會讓一個函式回傳一個值。單獨使用 return 不外加一個運算式作為引數時會回傳 None。一個函式執行到結束也會回傳 None

  • result.append(a) 陳述式呼叫了一個 list 物件 resultmethod(方法)。method 為「屬於」一個物件的函式,命名規則為 obj.methodname,其中 obj 為某個物件(亦可為一運算式),而 methodname 為該 method 的名稱,並由該物件的型別所定義。不同的型別定義不同的 method。不同型別的 method 可以擁有一樣的名稱而不會讓 Python 混淆。(你可以使用 class(類別)定義自己的物件型別和 method,見 Class(類別))範例中的 append() method 定義在 list 物件中;它會在該 list 的末端加入一個新的元素。這個例子等同於 result = result + [a],但更有效率。

4.9. 深入了解函式定義

定義函式時使用的引數 (argument) 數量是可變的。總共有三種可以組合使用的形式。

4.9.1. 預設引數值

為一個或多個引數指定預設值是很有用的方式。函式建立後,可以用比定義時更少的引數呼叫該函式。例如:

def ask_ok(prompt, retries=4, reminder='Please try again!'):
    while True:
        reply = input(prompt)