Python dict 預設值技巧,key 找不到也不用擔心

by 好豪
Published: Updated:

相信每個在 Python 使用 dict 的時候都有遇過:對 dict 查詢了沒有出現過key,就產生了 KeyError 造成程式錯誤。

# 範例:計算字串內每個字母出現次數
>>> my_string = "aabbbbc"
>>> char_count = {}
>>> for s in my_string:
...     if s not in char_count.keys():
...         char_count[s] = 0
...     char_count[s] += 1

>>> char_count
{'a': 2, 'b': 4, 'c': 1}

>>> char_count['g']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'g'

上述計算字母出現次數的範例,看起來簡單,卻有個小問題:就算 dict 找不到 key 邏輯也很簡單—就是計數為 0!因此,範例中的寫法稍嫌麻煩:

  1. 建立 dict 時,要不斷用 if 檢查該 key 有沒有出現過
  2. dict 建立完成後,要是查詢到沒出現過的 key 會出現 KeyError
    • 但是,沒出現過的字母不是 Error,而是出現 0 次

這些麻煩,使用 dict 的預設值就可以解決!Python 的 dict 有多種方法可以設定預設值,這則筆記將與各位分享其中 3 種使用 dict 查詢不到 key 時、採用預設值的方法:

  • get() 函式
  • setdefault() 函式
  • defaultdict 物件

我會用上述計算字母出現次數範例介紹該如何活用這些 dict 預設值技巧,也會比較三種方法的功能與效能、告訴你何時該用哪一招。相信讀者學會這些 dict 操作,Python code 寫起來會更流暢!

你是進階級的 Python 使用者嗎?筆者推薦你直接來嘗試本文最後的 挑戰題,看看自己是否各種方法都熟悉。

dict.get() 函式

get() 同樣是用來查詢 dict,它與 dict[key] 的差別是:

get() 不會出現 KeyError

get(key[, default]) 是 Python dict 物件的內建函式:

  • 如果 dict 內 key 存在,回傳查詢到的 value
  • 如果 dict 內 key 不存在,回傳 default 設置的值
  • default 是用來設定預設值的可選引數,若未指定則為 None

範例改寫

# 範例:計算字串內每個字母出現次數
#   使用 dict.get(key, 0) 
#   -> 當 key 找不到就回傳 0
>>> my_string = "aabbbbc"
>>> char_count = {}
>>> for s in my_string:
...     char_count[s] = char_count.get(s, 0) + 1
>>> char_count
{'a': 2, 'b': 4, 'c': 1}

## 設定預設值為 0,查詢沒出現過的字母,也不會 KeyError
>>> char_count.get('g', 0)
0

## 沒設定預設值就回傳 None 囉
>>> char_count.get('g')

dict.setdefault() 函式

setdefault()get() 的作用幾乎相同、請參考上方 get() 說明,setdefault() 只多了一項功能:

dict 內 key 不存在時,setdefault() 不只回傳 default,還會更改 dict、將該 key 的值設定為 default

範例改寫

# 範例:計算字串內每個字母出現次數
#   使用 dict.setdefault(key, 0) 
#   -> 當 key 找不到就回傳 0
#   -> 並且更新 dict 內容
>>> my_string = "aabbbbc"
>>> char_count = {}
>>> for s in my_string:
...     # 若找不到 key,則修改 dict
...     char_count.setdefault(s, 0) 
...     # 因為先執行 setdefault
...     # 所以 dict[key] 必不會出現 KeyError
...     char_count[s] = char_count[s] + 1 
>>> char_count
{'a': 2, 'b': 4, 'c': 1}

# setdefault 不只回傳預設值、還會更改 dict 內容
>>> char_count.setdefault('g', 42)
42
>>> char_count
{'a': 2, 'b': 4, 'c': 1, 'g': 42}

collections.defaultdict 物件

defaultdict 是 Python 內建 library collections 內的類別之一,它是繼承自 dict 的子類別。

建構 defaultdict 物件時,需要傳入 default_factory 引數、用來宣告「怎麼設定預設值」,特別的是,此引數需要是一個可被呼叫物件(callable,大多時候就是 function 物件)。

defaultdict 的行為與 setdefault() 相似:

若是以 defaultdict[key] 查詢不存在的 key
不只會回傳預設值、還會修改 defaultdict 的內容

用文字說明有點抽象,直接用程式碼學習 defaultdict 宣告預設值的方式:

>>> from collections import defaultdict
# 使用 list 預設值
>>> d = defaultdict(list)
>>> d
defaultdict(<class 'list'>, {})
>>> d["hello"]
[]
>>> d
defaultdict(<class 'list'>, {'hello': []})

# 使用 string 常數預設值
# 注意引數要是 callable,所以使用 lambda
>>> d = defaultdict(lambda: "我是預設值")
>>> d["hello"]
'我是預設值'

# 錯誤示範:引數不是 callable
>>> d = defaultdict("我是預設值")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: first argument must be callable or None

# 如果沒設置 default_factory 引數
# 還是會 KeyError
>>> d = defaultdict()
>>> d["hello"]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'hello'

範例改寫

# 範例:計算字串內每個字母出現次數
#   使用 collections.defaultdict
#   -> 當 key 找不到就回傳 0
>>> from collections import defaultdict
>>> my_string = "aabbbbc"
>>> char_count = defaultdict(int)
>>> ## 等同於:defaultdict(lambda: 0)
>>> for s in my_string:
...     char_count[s] = char_count[s] + 1
>>> char_count
defaultdict(<class 'int'>, {'a': 2, 'b': 4, 'c': 1})
>>> dict(char_count)
{'a': 2, 'b': 4, 'c': 1}

# 注意:
#   就算是不存在的 key
#   在 defaultdict 只要查詢過
#   就會更新 defaultdict 物件本身內容
>>> char_count['g']
0
>>> char_count
defaultdict(<class 'int'>, {'a': 2, 'b': 4, 'c': 1, 'g': 0})

三種方法比較

get()setdefault()defaultdict,何時該用哪一個?我們可以依照使用情境,考慮功能與效能兩個面向決定。

功能差別

乍看之下三個方法都在設置預設值,實際上有個關鍵性的 使用情境差異

建構完 dict 之後,再次用 dict[key] 查詢不存在的 key 時,你希望程式如何反應?
KeyError 或是預設值?

  1. 查詢不存在的 key 產生 KeyError:使用 get()setdefault() 建立 dict
  2. 查詢不存在的 key,回傳並設置預設值:建立 defaultdict 物件
# 1. char_count = {}
...
>>> char_count['???']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: '???'

# 2. char_count = defaultdict(int)
...
>>> char_count['???']
0

效能差別

效能差異我們用 timeit.timeit() 函式直接測試,觀察這 3 種方法在 dict 查找 key 的花費時間差別:(以下用 i5 的 Macbook Pro 測試)

>>> def test_dict_access(d):
...     for i in range(100):
...         d[i]
>>> def test_get(d):
...     for i in range(100):
...         d.get(i, -1)
>>> def test_setdefault(d):
...     for i in range(100):
...         d.setdefault(i, -1)
>>> def test_defaultdict(d):
...     for i in range(100):
...         d[i]

>>> import timeit
>>> print(timeit.timeit(
...     "test_dict_access({i:i for i in range(100)})",
...     "from __main__ import test_dict_access"))
11.30299491599726

>>> print(timeit.timeit(
...     "test_get({i:i for i in range(100)})",
...     "from __main__ import test_get"))
16.070979757001624

>>> print(timeit.timeit(
...     "test_setdefault({i:i for i in range(100)})",
...     "from __main__ import test_setdefault"))
17.516931142999965

>>> print(timeit.timeit(
...     "test_defaultdict(defaultdict(lambda: -1, {i:i for i in range(100)}))",
...     "from __main__ import test_defaultdict; from collections import defaultdict"))
14.216421057000844

設定 dict 預設值之後,速度都會比不設定預設值的單純 dict[key] 慢。而比起 get()setdefault() 預設值是每次查找 key 時都要設定,defaultdict 只在物件宣告時設定一次預設值而已,所以 defaultdict 速度比其他兩個方法快一點。筆者好豪也在 StackOverFlow 上看到有網友使用 dis 函式庫,用反組譯碼來分析速度差異,有興趣的讀者可以參考 這篇問答

總言之,是否要為 dict 的查找設定預設值,效能與功能的權衡要綜合考慮。



挑戰題

動筆寫出這篇筆記的契機、也是下列挑戰題的來源,是先前在 Python Taiwan Facebook 社群 分享 zip() 技巧 的貼文,當時網友提出的問題如下,有興趣的讀者可以先自行挑戰,看看用這篇筆記介紹的 dict 預設值方法可以怎麼解題:

請問以下陣列(list of lists)
[
  ['a', 1],['a', 2],
  ['b', 1],
  ['c', 1],['c', 6],['c', 8]
]
該怎麼轉換成
{
  'a':[1, 2],
  'b':[1],
  'c':[1, 6, 8]
}

以下我將分享 5 種解題方式。

### 以下範例程式碼,arr 都是同樣內容
>>> arr
[['a', 1], ['a', 2], ['b', 1], ['c', 1], ['c', 6], ['c', 8]]

dictget()

>>> d = {}
>>> for k, v in arr:
...     l = d.get(k, [])
...     l.append(v)
...     d[k] = l
>>> d
{'a': [1, 2], 'b': [1], 'c': [1, 6, 8]}

dictsetdefault()

>>> d = {}
>>> for k, v in arr: 
...     d.setdefault(k, []).append(v)
>>> d
{'a': [1, 2], 'b': [1], 'c': [1, 6, 8]}

collectionsdefaultdict

>>> from collections import defaultdict
>>> d = defaultdict(list)
>>> for k, v in arr:
...     d[k].append(v)
>>> d
defaultdict(<class 'list'>, {'a': [1, 2], 'b': [1], 'c': [1, 6, 8]})
>>> dict(d)
{'a': [1, 2], 'b': [1], 'c': [1, 6, 8]}

pandasgroupby()

>>> import pandas as pd
>>> df = pd.DataFrame(arr, columns=['key', 'value'])
>>> df.groupby('key')['value'].apply(list).to_dict()
{'a': [1, 2], 'b': [1], 'c': [1, 6, 8]}

itertoolsgroupby()

>>> from itertools import groupby
>>> grp_obj = groupby(arr, lambda x: x[0])
>>> {k: [x[1] for x in grp] for k, grp in grp_obj}
{'a': [1, 2], 'b': [1], 'c': [1, 6, 8]}

同樣一個任務,有那麼多種做法,你是否跟我一樣更覺得 Python 有趣了呢!

結語

Python 為 dict 查找 key 行為設置預設值的方式有許多種,要依照使用情況所需功能判斷該用哪個。從功能面來看,「預設值是否需要隨情況改變」以及「後續查找是否要讓 dict 出現 KeyError」,我們可以決定到底要用 get()setdefault()、還是 defaultdict

不過,get() 設定預設值後雖然讓你查找 key 永遠免於面對 KeyError,卻會讓你的查找 key 效能遜於單純的 dict[key]

究竟需不需要為 dict 查找 key 設置預設值,以及該用哪種方法來設置預設值,相信各位詳讀並練習這篇教學之後就會更加熟練了!


參考資料


學了 dict 的預設值設定,你知道 Python 函式設定預設值時也有些小陷阱嗎?推薦你閱讀好豪的另一篇筆記:Python 函式的可變物件預設引數陷阱,繼續學習!

此外,如果你想一次學會更多 Python 技巧,也推薦你閱讀好豪蒐集的《Python 神乎其技》免費教學文章,幫助你寫出 Pythonic Code!

如果你喜歡這篇教學,歡迎追蹤 好豪的粉絲專頁,我會持續撰寫 Python 技巧、資料科學等知識,與你分享!

也歡迎點選下方按鈕將本文加入書籤隨時複習、或者分享給更多正在學 Python 的朋友。

推薦閱讀