Python 常見錯誤與處理方式-Python 進階處理(二)

一、前言

Python 其實一直有一個相對弱的部分,就是廣大的生態系與眾多的套件中,因為寫法比較模糊,沒有很多靜態語言的風格,故在執行過程中,如果要知道問題出在哪,就將會是很大的問題。

而在常見的異常處理中,也很難針對錯誤的地方找到真正的錯誤,往往套件越複雜,或是導入的套件越多、層數越多,該功能能抓出準確問題的能力也就越困難。

故這篇文章會向大家介紹常見而且我個人覺得實用的做法,但在一開始,我希望先大概介紹一下(不會實作這件事)單元測試的邏輯與情境,以便讓各位在未來的環境中,對於程式碼 debug、異常處理、維護與修改中,有更深一層的理解,就讓我們開始吧!

(以下概念暫時不懂沒關係,但我還是希望讀者可以先至少知道這件事的存在,未來用到會至少有聽過?)

二、Unit Test

單元測試(Unit test)是什麼?

這邊稍微介紹一下單元測試的核心理念,通常我們在跑程式碼的時候,最擔心的就是程式碼出錯,而單元(Unit)這個概念更可以理解成一整包程式碼中的每個部件,小到變數的存取與呼叫,大到整個物件的呼叫與繼承(OOP),都是可以被測試的一環。

或許有人會問:「我一個簡單的邏輯,為何要寫兩份程式碼;2分鐘的任務,變成5分鐘的理由是啥呢?」

答案也很簡單,隨著程式碼的更迭、累積,後續維護的可能不是開發者,那系統越來越多功能時,如何保證程式碼的每個功能都能運作正常呢?

這時後平時所寫好的測試就會是一個非常適合迭代的行為,當然,測試分成很多種這邊不展開講,但可以基本理解的就是,測試的目標是為了維持程式過段時間仍然可以正常運作。即便加了新功能,只要一跑,就會知道過去的舊功能是否仍然可以執行,也更可以知道是否會有預期外的錯誤會發生。

好了,說了個大概後,就進入的今天的重點,錯誤一旦發生,程式該怎麼辦呢?

三、異常處理(try…except)

1. 捕獲異常狀況的處理原則

對於程式碼的錯誤處理,與其說是針對錯誤去做某些事,不如說是針對可以預期的幾種錯誤去做對應的解決方案,這類型問題在程式運算流程中。

只要資料來源、存取、處理、甚至於套件的使用與繼承,都不是自己可以完全控制的時候(有時候甚至連自己可以100%控制都得做),基本上都會需要做捕捉異常的事情。

這部分可以探討的很細,關於 Desgin pattern(設計原則)中有針對該議題提到的相關的細節,這邊不會特別展開說。

至於到底何時該做,何時不該做,大概的分水嶺可以分成:

(1)這部分功能、函式等等,是不能出錯的

def not_error_func(a ,b, c):
    return a + b + c

例如上述函式,知道回傳是三個函數的計算,所以我們可以知道丟進去的參數勢必是 int 或是 float等可計算數值。

那這時候這種功能如果非常的硬性,可以做以下兩種作法:

A. 直接在創建函式時就確認參數是數值,故如果丟進去的參數不是可計算數值,則會報錯

def not_error_func(a=int, b=int, c=float):
    return a + b + c

B. 在內部寫異常處理

def not_error_func(a, b, c):
    try:
        return a + b + c
    except Exception as e:
        print(e)
print(not_error_func(345, 34, "56"))

輸出結果:

unsupported operand type(s) for +: 'int' and 'str'
None

雖然用 Exception 捕捉到了錯誤,但這樣的捕捉其實是給人看的,程式很難針對其作法做出其他行為,這部分底下會一併解釋。

(2)可以出錯,但有一定的預期錯誤範圍

這部分的錯誤比較偏向於在 web 上使用別人的 API,或是呼叫別人的數據庫,因為你不能準確確定對方來源的資料格式是否會變,或是內容可以改版後型態會變,甚至變數名稱會變。

那自然就很難針對該情形去寫完整的異常處理。假設以下資料為API回傳的資料,我們希望可以將資料內部的 vol 相加:

test_data = {
    "code": 0,
    "status": 200,
    "data": [
        {
            "symbol": "BTCUSDT",
            "price": "30000",
            "amount": "3",
            "base": "USDT",
            "asset": "BTC",
            "vol": "32130",
            "status": True
        },
        {
            "symbol": "ETHUSDT",
            "price": "2000",
            "amount": "3",
            "base": "USDT",
            "asset": "BTC",
            "vol": "32130",
            "status": True
        },
    ]
}

想當然,因為vol是str,所以相加肯定是會報錯的,故函式可以將相加的數據強制轉成數值:

def get_data(data):
    sum_vol = []
    for d in data['data']:
         sum_vol.append(float(d['vol']))
    return sum(sum_vol)

print(get_data(api_back))

但這樣會有幾種問題:

  • 每次資料進來都要做一次型態轉換,那萬一他已經是 float 還要再做一次不是很浪費效能嗎?
  • 如果我們預期了 vol(volume) 的簡寫,有可能在未來變成 volume 呢?
def get_data_correct(data):
    try:
        sum_vol = []
        for d in data['data']:
            sum_vol.append(d['vol'])
        return sum(sum_vol)

    except KeyError:
        # 如果 vol 被換成了 volume
        sum_vol = []
        for d in data['data']:
            sum_vol.append(d['volume'])
        return sum(sum_vol)

    except TypeError:
        # 如果 vol 的數值 value 被換成了 字串 value
        # 例如   "vol": 123 -> "vol": "123"
        sum_vol = []
        for d in data['data']:
            sum_vol.append(float(d['vol']))
        return sum(sum_vol)

2. 幾種常見的捕獲錯誤的狀況

try:
    fh = open("ourfile_123", "r")
    print("read doc: {}".format(fh))
except IOError:
    print('file write failed: {}'.format(IOError))
else:
    print('file write done.')
    fh.close()

結果:

file write failed: <class 'OSError'>

3. 常見的使用情境

(1)SyntaxError 語法錯誤

這類很常見但是通常 pycharm 都幫你做了,原則上不需要特別寫。

(2)TypeError 對象類型與要求不符合

try:
    print(INPUT) # 使用者丟入的參數動作

except TypeError as e:
    # 如果使用 ("1"+1) 會得到:
    # can only concatenate str (not "int") to str
    # 因為python認為這是 字串要去拼接另一個字串

    # 如果使用 (1+"1") 會得到:
    # unsupported operand type(s) for +: 'int' and 'str'
    # python 認為這是數字要去加一個字串

    print(e)

except IndexError as e: # 超出 list 的序列範圍 
    # 例如 aaa = [1,2,3,4]  但跟程式要  aaa[7]
    print(e)

except KeyError as e:  # 字典裡不存在的key
    # kkk = {
    #     "a1": 1,
    #     "a2": 2,
    #     "a3": 3,
    #     "a4": 4
    # }
    # 但跟程式說要執行  kkk['b]
    print(e)

except ValueError as e:
    # 詳見"仔細說明1"
    # 這邊很常發生在 int('5.0') 這種結果下
    # 這種問題正確解法是 int(float('5.0'))
    print(e)

except ZeroDivisionError as e:
    # 3/0 時會發生(分母等於0時)
    print(e)

except Exception as e:
    # 詳見"仔細說明2"
    print(e)

4. 詳細說明

(1)int(‘5.0’):

這問題很常發生在各式型別轉來轉去的時候,雖然我們很習慣將 int(“30”)轉為 30 的數值,

但其實在 “5.0”時,python 對於轉型別的自動調整中,是必須先轉成浮點數 float,才能轉成整數 int 哦。

(2)except Exception as e:

這個雖然很常用,但因為這種做法往往只是捕捉錯誤,而且是很廣泛的捕捉,故比較常使用在無法精確控制的錯誤中,

而且這種做法在系統越來越大之後,反而會因為很不精確的捕獲,進而造成 debug 追溯問題的混輪情形。

5. 補充

或許有人會問說:「為何上面的 def get_data_correct(data): 做法要做三次一樣的?」

這邊可以給各位一個理想中的範例:

def get_data_failed(data):
    sum_vol = []
    for d in data['data']:
        try:
            sum_vol.append(d['vol'])

        except TypeError as e:
            print(e)
    return sum(sum_vol)

這樣就好了為何需要做三次一樣的 sum_vol = [] 以及底下的動作呢?

原因是因為發生錯誤的其實是在 sum_vol 這個資料儲存過程內,可是過程中又包含了for 迴圈,這時候如果中斷,那原本的資料則會卡在一半,for 迴圈也跑到一半。

所以如果要對整個行為進行錯誤處理,則會需要將全部的動作們都包到一個 try 內,好讓程式在出錯時,可以直接安全地跳出來哦!

四、小結

雖然這邊看似講了不少,但其實錯誤處理還有很多類型與處理方式,像是主動發起錯誤、自訂錯誤類型與回傳內容。

乃至於 assert 的用法,都各有其錯誤處理的使用情境,對於是否要放錯誤處理,以及怎麼處理,這個是個大議題,展開討論文章就寫不完啦!不過大致上可以理解成寫程式的重點在於追求正確,

錯誤處理就留給那些自己無法控制的情形就好囉!這章節大概就到這邊,如果有任何想要提問討論的,都歡迎下方留言哦!

延伸閱讀:


量化通粉絲社群,一起討論程式交易!

加入LINE匿名群組量化通QuantPass」無壓力討論與分享!

追蹤量化通的粉絲專頁量化通QuantPass」即時獲取實用的資源!

python_course_all_1920X400
RoWay
RoWay

多年投資經驗的兩岸三地操盤手,曾任海外資產管理公司交易平台的產品經理、與各外商投資公司合作開發各式交易策略與系統。

擅長用Python執行資料蒐集、整理、分析與交易;也善於用Multicharts、MetaTrader等系統建構並回測期貨、期權、區塊鏈策略進而完成投資組合管理。

文章: 26

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *