2021年4月6日 星期二

Python 學習筆記 : 基本語法 (五) : 錯誤與例外

本篇繼續來複習整理 Python 基本語法之例外處理, 本系列之前的文章參考 :


參考書籍 :
  1. 人工智慧 Python 基礎課 (碁峰, 陳會安)
  2. 精通 Python (碁峰, 賴屹民譯)
  3. Python 程式設計入門與運算思維 (新陸, 曹祥雲)
  4. 一步到位! Python 程式設計 (旗標, 陳惠貞)
  5. Python 程式設計入門指南 (碁峰, 蔡明志譯)
  6. Python 也可以這樣學 (博碩, 董付國)

十一. 例外處理 :   

程式開發過程中會遇到下列三種錯誤 : 


 程式的三種錯誤 偵錯難易 說明
 語法錯誤 容易 違反語法規則, 例如縮排, 關鍵字拼寫等.
 執行時期錯誤 (例外) 容易 程式符合語法, 但執行時出現錯誤, 例如除以 0 等.
 邏輯錯誤 困難 程式符合語法, 執行時也無例外出現, 但結果不正確.


有語法錯誤的程式無法順利執行, 直譯器會顯示錯誤原因並指出錯誤之程式碼位置, 因此這種錯誤很容易即能除錯. 

有些程式語法並無問題, 但有時可順利執行, 有時卻突然終止執行 (閃退) 並顯示錯誤訊息, 通常是因為不同的輸入條件或中間運算結果所致, 此種錯誤稱為執行時期錯誤, 又特稱為例外 (exception), 其除錯因為有錯誤訊息因此也不難, 但能否偵錯卻要靠測試條件是否能充分觸發可能之例外而定. 

邏輯錯誤又稱語意錯誤 (semantic error), 存在邏輯錯誤的程式雖均可順利執行, 但執行結果卻不正確 (錯誤), 或者令程式陷入無窮迴圈, 此種錯誤來自程式設計師的思考邏輯不周全或演算法錯誤, 邏輯錯誤很難偵錯與除錯. 
 

1. 語法錯誤 :    

語法錯誤包括 SyntaxError, KeyError, IdentationError, IndexError, NameError, 與 AttributeError 等, 出現語法錯誤時必須修改程式碼使其符合 Python 語法規則後才能順利執行, 常見的語法錯誤列舉如下 : 
  • 使用關鍵字當識別字 (變數, 函式, 類別之名稱)
  • 關鍵字拼寫錯誤
  • 程式區塊忘記冒號直接跳行
  • 同一個區塊內之縮排不一致
  • 字串的括號不成對或不匹配 (例如前面用單引號, 後面用雙引號)
  • 元組 (), 串列 [], 字典 {} 內的元素或項目沒有用逗號隔開
  • 虛數使用了數學的 i, 在 Python 應該用 j 表示虛數
  • 存取字串, 元組, 串列時索引超出範圍 (IndexError)
  • 存取字典時所用之鍵不存在 (KeyError)
  • 存取不存在的物件屬性或方法 (AttributeError)
  • 存取目前命名空間中不存在的變數 (NameError)
參考 :


例如 :  

>>> else=123                          # else 是關鍵字, 不可以用作識別字
  File "<pyshell>", line 1
    else=123
       ^
SyntaxError: invalid syntax   
>>> Else=123                          # Else 不是關鍵字, 是合法識別字
>>> Else
123
>>> for i In range(1, 10):        # 關鍵字拼寫錯誤 (In 應為 in)
    print(i)
    
  File "<pyshell>", line 1
    for i In range(1, 10):
           ^
SyntaxError: invalid syntax
>>> a=10
>>> if a < 0:       
    print('negative')
elseif a == 0:                           # 關鍵字拼寫錯誤 (elseif 應為 elif)
    print('zero')
else:
    print('positive')
    
  File "<pyshell>", line 3
    elseif a == 0:
           ^
SyntaxError: invalid syntax
>>> def foo()                           # 函式標頭應以冒號結束, 開啟內縮區塊 (缺了冒號)
   print('bar')
   
  File "<pyshell>", line 1
    def foo()
            ^
SyntaxError: invalid syntax
>>> def foo(a):
    print(a)
        return a+1                        # 區塊內縮不一致 (return 應與 print 齊頭)
    
  File "<pyshell>", line 3
    return a+1
    ^
IndentationError: unexpected indent
>>> print("Hello World)         # 字串的括號不成對
  File "<pyshell>", line 1
    print("Hello World)
                      ^
SyntaxError: EOL while scanning string literal
>>> print('Hello World")       # 字串的括號前後不一致
  File "<pyshell>", line 1
    print('Hello World")
                       ^
SyntaxError: EOL while scanning string literal
>>> a=[1, 2, 3  4]
  File "<pyshell>", line 1
    a=[1, 2, 3  4]
                ^
SyntaxError: invalid syntax
>>> a=1+2i                             # 在 Python 中以 j 表示虛數, 不是數學中慣用的 i 
  File "<pyshell>", line 1
    a=1+2i
         ^
SyntaxError: invalid syntax
>>> "tony"[4]                         # 索引超出範圍 (此例索引 0~3)
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
IndexError: string index out of range
>>> {'foo':1, 'bar': 2}['tony']   # 字典的鍵不存在
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
KeyError: 'tony'
>>> "tony".length                   # 字串物件無 length 屬性
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
AttributeError: 'str' object has no attribute 'length'


2. 邏輯錯誤 : 

邏輯錯誤來自程式設計師本身的疏忽, 思考邏輯不正確, 或解決問題的演算法有誤, 此種錯誤很難偵錯, 因為程式都能順利執行不會有任何錯誤訊息, 只是結果不正確, 但有時也無法從結果看出正確與否, 只能一行一行檢視原始碼仔細推敲才可能發現錯誤之處. 

導致邏輯錯誤的可能原因如下 :
  • 使用了錯誤的變數導致運算式結果不正確
  • 誤用了整數除法使得小數部分被略去
  • 忽略了 0 起始或 1 起始差異導致差一錯誤 (off-by-one error), 例如迴圈終止值設定
  • 忽略了運算子的優先順序導致運算式得出錯誤結果
  • 搞錯了縮排的層次
例如計算 1+2+3+4+5 之和可用 for 迴圈搭配 range(from, to) 函式, 但要注意 range() 傳回的可迭代序列事實上只到 to-1 而非 to, range(1, 5) 傳回的序列為 1, 2, 3, 4, 所以得到的結果是錯的 :

>>> sum=0
>>> for i in range(1, 5):    # 這只加到 4 而已 : 1+2+3+4
    sum += i
    
>>> print(sum)                  # 1+2+3+4+5=15, 故計算結果錯誤
10


3. 執行時期錯誤 (例外) :    

程式語法正確, 但執行中因為無法順利處理資料而發生的異常錯誤稱為例外 (exception), 將導致程式崩潰而終止執行並輸出錯誤訊息, 但這些技術性資訊通常會使用者丈二金剛摸不著頭腦, 為了避免這種情況, 程式設計師必須捕捉這些可能的例外並做適當處理以增進程式的強固性. 

常見的執行時期錯誤例如 :
  • 除以零
  • 將字串與數值相加
  • 呼叫有必要參數之函式時未傳入引數
  • 欲開啟之檔案不存在
  • 取得網路文件時連線中斷
其中有些可能的錯誤只要例外處理最常用於程式需要與外部交換資料的時候, 例如讀寫外部檔案或從網路下載資料等, 這些操作都有環境不確定因素 (檔案不存在或網路連線異常), 其它可用因此必須程式設計師必須捕捉這些可能之例外並加以處理, 否則程式會因異常而突然終止, 使用者會對這些情況感到不知所措. 

除了開發時期程式員本身常遇到外, 軟體上線後也常因為以 input() 或 GUI 介面從使用者輸入取得之資料運算時造成例外, 故開發時需注意此類 Bug. 

語法錯誤與執行時期錯誤這兩種錯誤都有提示訊息可用做偵錯的參考, 而邏輯錯誤是最難被偵錯的, 因為問題出在對程式處理邏輯的想法不正確所致, 有時也出於程式員誤用運算子, 例如在條件式中使用了 = 運算子做比較而非 ==, 這不會出現語法錯誤, 因為在條件式中用 = 進行指派總是傳回 True. 


(1). 例外處理類別 :   

Python 提供了以 BaseException 為根類別的例外處理類別, 用來處理被拋出了各式例外, 常見的例外類別繼承關係如下圖所示 : 





其中比較常用的例外如下表 :


 常用的例外 說明
 IOError 輸出入錯誤
 NameError 與變數名稱有關的錯誤 (例如變數不存在)
 ValueError 數值錯誤
 ZeroDivisionError 除 0 之錯誤


BaseException 類別是 Python 的內建類別 (可匯入 builtins 後用 dir 檢視), 也是所有例外類別的最上層父類別. 程式設計師除了直接使用這些內建之例外類別來捕捉例外, 也可以自訂例外類別, 但自訂例外類別時並非直接繼承 BaseException, 而是應該繼承其子類別 Exception, 參考 :


Python 的例外處理語法為如下之 try except 結構 (try-and-error 模式) : 

try: 
    可能觸發例外之程序
except 例外類別名稱1 [as 別名1]: 
    例外處理程序1
except 例外類別名稱2 [as 別名2]: 
    例外處理程序2
.....
except:
    所有其他例外處理程序
[else:
    沒有觸發任何例外時之處理程序]
[finally:
    不論有無觸發例外必定執行之程序]

說明如下 : 
  • try 與至少一個 except 區塊是必須的, else 與 finally 則是可有可無的. 
  • 將可能發生例外的程式碼放在 try 區塊中, 一旦發生執行時期錯誤就會停止 try 區塊內程序的執行並拋出例外, 此例外會由底下的 except 區塊依序捕捉, 首先由排在前面的指名例外類別捕捉, 一旦捕捉到一個例外類別後, 其它的例外類別就不會再嘗試捕捉. 如果前面的指名例外類別都沒有捕捉到此例外, 就由最後面的 except 區塊概括承受全部捕捉. 
  • 若沒有發生例外, 則 try 區塊程序執行完畢後會執行 else 區塊, 不管有無發生例外, finally 區塊都會被執行, finally 通常用來處理資源清理工作, 釋放 try 區塊中所占用之記憶體資源, 例如關閉檔案等.
  • 如果 try 區塊發生之例外沒有被任何一個 except 區塊捕捉到, 或者連 else 區塊也發生例外, 則這些例外會等 finally 區塊執行完畢後才被拋出, 亦即發生例外並不會影響 finally 區塊的程序, 它一定會被執行. 因此若在函式中使用了異常處理結構, 切勿在 finally 區塊中使用 return 傳回結果, 因為這會讓該函式不管執行結果如何都傳回相同結果, 變成一個難以發現的邏輯錯誤. 
  • 例外類別名稱可以用 as 關鍵字取別名 (例如 e) 以節省打字長度, 在例外處理程序中可直接呼叫 print(e) 輸出例外物件內容, 也可以用 print(e.args) 檢視, args 屬性值為一個包含例外原因的 tuple.   
 此語法結構的演算法可用下圖表示 :




如果某些例外要共用相同的處理程序, 則可將這些例外放在 tuple 中, 結構如下 :

try: 
    可能觸發例外之程序
except (例外類別名稱a1,  例外類別名稱a2, ... ) [as 別名a]: 
    共用的例外處理程序a
except (例外類別名稱b1,  例外類別名稱b2, ... ) [as 別名b]: 
    共用的例外處理程序b
except:
    其它例外處理程序
else:
    沒有觸發任何例外時之處理程序
finally:
    不論有無觸發例外必定執行之程序

參考 : 


例如 : 

>>> try:
    a,b=input("請輸入被除數與除數(以空格隔開之整數)").split()
    a=int(a)
    b=int(b)
    print("被除數/除數=", a/b)
except ZeroDivisionError as e:
    print("發生除以零例外:", e)
    print(e.args)
else:
    print("沒有發生例外")

請輸入被除數與除數(以空格隔開)10 2
被除數/除數= 5.0
沒有發生例外

請輸入被除數與除數(以空格隔開之整數)10 0        # 除以零
發生除以零例外: division by zero
('division by zero',)

此例透過 input() 讓使用者輸入以空格隔開的兩個整數 a, b, 然後計算 a 除以 b, 當除數 b 不為零時不會發生例外, 當 b 為 0 時則拋出 ZeroDivisionError 的例外被 except 捕捉. 注意, 發生例外時的那行程式碼與以下的程式碼都會被終止, 故不會輸出 "被除數/除數=" 字串. 

但上面的程式只捕捉了 ZeroDivisionError 這個例外, 如果輸入資料中有一個為非數值將使程式於 try 區塊拋出另外一個例外 ValueError, 但因為程式沒有捕捉此例外因此會導致異常終止 : 

請輸入被除數與除數(以空格隔開)123 abc           # 輸入非數值觸發了未被捕捉之例外
Traceback (most recent call last):
  File "<pyshell>", line 4, in <module>
ValueError: invalid literal for int() with base 10: 'abc'

程式應捕捉所有可能出現的例外, 因此添加捕捉 ValueError 例外如下 : 

>>> try:
    a,b=input("請輸入被除數與除數(以空格隔開)").split()
    a=int(a)
    b=int(b)
    print("被除數/除數=", a/b)
except ZeroDivisionError as e:
    print("發生除以零例外:", e)
except ValueError as e:     
    print("發生值的例外:", e)
else:
    print("沒有發生例外")

請輸入被除數與除數(以空格隔開)123 abc       # 拋出的 ValueError 被捕捉
發生值的例外: invalid literal for int() with base 10: 'abc'

也可以將這兩個例外以 tuple 組合在一起合併處理, 例如 :

>>> try:
    a,b=input("請輸入被除數與除數(以空格隔開)").split()
    a=int(a)
    b=int(b)
    print("被除數/除數=", a/b)
except (ZeroDivisionError, ValueError):              # 合併處理兩個例外
    print("發生例外")
else:
    print("沒有發生例外")

請輸入被除數與除數(以空格隔開)10 0             # 發生除以零例外
  發生例外

請輸入被除數與除數(以空格隔開)123 abc        # 發生值的例外
  發生例外                                                            

若這樣攏統, 也可以用內建函式 isinstance() 來分辨例外類別, 例如 : 

>>> try:
    a,b=input("請輸入被除數與除數(以空格隔開)").split()
    a=int(a)
    b=int(b)
    print("被除數/除數=", a/b)
except (ZeroDivisionError, ValueError) as e:      # 合併處理兩個例外
    if isinstance(e, ZeroDivisionError):                # 用 isinstance() 分辨例外類別
        print("第二個整部不可為 0")
    else:
        print("請輸入兩個以空格隔開的整數")
else:
    print("沒有發生例外")

請輸入被除數與除數(以空格隔開)10 0   
第二個整部不可為 0

請輸入被除數與除數(以空格隔開)123 abc   
請輸入兩個以空格隔開的整數

以上範例均捕捉指定之例外, 如果只是要做例外處理, 不需要去做細緻的例外分辨,  則可以攏統地去捕捉 Exceptions 這個例外父類別, 然後利用列印其 args 屬性來顯示例外的原因, 例如上面的範例可以修改成如下 : 

>>> try:   
    a,b=input("請輸入被除數與除數(以空格隔開)").split()
    a=int(a)
    b=int(b)
    print("被除數/除數=", a/b)
except Exception as e:      # 合併處理兩個例外
    print(e.args)
else:
    print("沒有發生例外")
請輸入被除數與除數(以空格隔開)5 0
('division by zero',)
>>> try:   
    a,b=input("請輸入被除數與除數(以空格隔開)").split()
    a=int(a)
    b=int(b)
    print("被除數/除數=", a/b)
except Exception as e:      # 合併處理兩個例外
    print(e.args)
else:
    print("沒有發生例外")
請輸入被除數與除數(以空格隔開)5
('not enough values to unpack (expected 2, got 1)',)

可見雖然沒有客製化的錯誤訊息處理, 但至少還能知道例外原因. 

參考 :


沒有留言:

張貼留言