Python 詭譎的 default parameter value ,由踩坑來學習!

有時候錯誤訊息只是表象,root cause 並非如字面上的提示如此簡單!今天分享一個跑測試時遇到的坑,使用 django 內建的 test 框架跑測試,所以建立測試 db 由 django 負責,在跑測試前會建立 test db 並把 table 開好。結果這次在跑開表前就看到這個錯誤

django.core.exceptions.ImproperlyConfigured: settings.DATABASES is improperly configured. Please supply the NAME value.

起初懷疑是 database 相關設定被改,於是檢查 settings 部分,發現 code 最近並沒有人做過修改。切到其他 branch 可以跑,但就在目前這隻 branch 會發生問題,所以看來和這隻的修改有關係。

與同事討論後,他說他之前也遇過類似的問題,那時的原因是他在 test 裡把一些 access db 的 code 寫在某些地方,導致 db 還沒建立就去 access db 而發生類似的錯誤。

仔細檢查這次的修改,發現在某些 class 的 __init__ 裡面增加了 default 設定

class AdvanceInvoiceUpdateHandler():
    def __init__(self, user, voucher_service=VoucherService()):  # set default value
        self.user = user
        self.voucher_service = voucher_service

而在 VoucherService 的 __init__ 裡面我們有 access db

class VoucherService():
    def __init__(self):
        company = Company.get_current_company()  # access db

但直覺上來說塞在 __init__ 裡面的 default VoucherService() 不應該在還沒建立 AdvanceInvoiceUpdateHandler 的時候 init 啊!

為了確認是否他是兇手,做以下修改

class VoucherService():
    def __init__(self):
	print(‘I am the ghost.’’)
        ...

再跑一次測試,果然看到 「I am the ghost.」出現在錯誤訊息之前

做以下修改解決錯誤,把 default VoucherService 往內放即可

class AdvanceInvoiceCreateHandler():
    def __init__(self, user, voucher_service=None):
        self.user = user
        self.voucher_service = voucher_service or VoucherService()

這讓我好奇 python 中 function default arguments 的規則,於是上網研究了下,得到以下結論

  1. python 的函數是物件
  2. python 的 def 是「可執行陳述式」
  3. default arguments 的值會在「跑 def 陳述」時被建立
  4. mutable object 做 default value 的時候,不能寫在 arguments 旁邊,要到 function 裡面去處理

首先要知道 python 中的東西都是 object,再來要區分出 mutable object 和 immutable object

常見可變物件(指對物件「本身」的值可以改變):
list, dict, set

常見不可變物件(指對物件「本身」的值不可改變):
int, float, string, tuple

對可變物件的 value 做修改,會發現他的記憶體位置並不改變

a = [1, 2]
print(id(a))
>>> 4320093280

a[0] = 2
print(a)
>>> [2, 2]
print(id(a))
>>> 4320093280

這部分很好理解。
那對不可變物件的 value 修改

b = 1000
print(id(b))
>>> 4320093280

b += 10
print(b)
>>> 1010
print(id(b))
>>> 4320093102

原來 b 指到 1000 這個 int object,因為 int object  不可變,所以 python 會先建立一個新的 int object 1010,再把 b 指過去(很像 C 的指標)

回到我們的要討論的初始值問題
如果我們有這個函數:

def test(data=[]):
    data.append(1)
    return data

會發現這個現象:

>>> test()
[1]
>>> test()
[1, 1]
>>> test()
[1, 1, 1]

這是因為我們設定的 default argment 「data」 所屬的空陣列 [] 在 python 初始化 test 函數時就建立在特殊 class attribute

>>> test.__defaults__
([], )

每次呼叫函數,python 都會從 __defaults__ 裡面去拿預設值,所以

>>> test()
[1]
>>> test.__defaults__
([1], )
>>> test()
[1, 1]
>>> test.__defaults__
([1, 1], )

從這個實驗可以看出

  1. default data 值是從 class attribute 拿過來設定進去
  2. 每次跑時對 data 的修改是「可變物件」的修改,因此都會去修改同一個物件

可以看成以下程式

#  呼叫第一次
data = test.__defaults__[0]
data.append(1)
print(data)
>>> [1]

# 呼叫第二次
data = test.__defaults__[0]
data.append(1)
print(data)
>>> [1, 1]

# 後面雷同

如果要設定的 default value 是可變物件,要使用以下方式

def test(data=None):
    if data is None:
        data = []

把初始值放在函數裡面去給定,這樣就能解決問題!

最後再回頭看一開始遇到的問題

class AdvanceInvoiceUpdateHandler():
    def __init__(self, user, voucher_service=VoucherService()):
        self.user = user
        self.voucher_service = voucher_service

因為我把 voucher_service 的預設值設定為 VoucherService(),所以 python 最初 initial 整份 code 的時候,就先把 VoucherService 初始化放到 __init__ 函數裡的 __default__,那時 django 還沒把 db 建立起來,而 VoucherService 初始化要 access db,程式就發生錯誤了!

真是一個背景知識很高的採坑經驗!

延伸閱讀:
如何解決 Python Decimal.quantize() 發生 InvalidOperation
被新創公司裁員後,我學到的五件事
macOS 中 Python 版本太多如何管理?試試 pyenv 吧!

參考資料

Default Parameter Values in Python
Python constructor and default value [duplicate]
“Least Astonishment” and the Mutable Default Argument
[Python 基礎教學] 什麼是 Immutable & Mutable objects

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。