跳至主要内容

字典

字典

簡介

上個章節介紹了串列,接下來這個章節我們看看在 Python 中使用率也很高的資料類型:字典(Dictionary)。

在開始介紹字典之前,先想一下,如果是要用來裝資料的「容器」,像藥盒子一樣的串列不夠用嗎?為什麼需要其他的資料結構?舉個例子:

data = ["弗利沙", [10, 20], (1, 23, 530000)]

你光看這些資訊,有辦法一眼就看出來 [10, 20] 或是第三個元素裡的 23 分別代表什麼意思嗎?也許這是你設計的所以你現在看的懂,但對其他人或是幾個月之後的自己,可能就不是這麼一回事了。如果能幫這些資料加上「標籤」,識別度會更好一些,這時候字典就能派上用場了。

創建字典

Python 的字典可以幫資料加上更多說明,讓資料的意義更明確。字典的最外層使用大括號 { } 包起來,裡面則是由「鍵(Key)」以及「值(Value)」所組成。建立字典不需要使用任何函數,直接用大括號就可以開始寫,像這樣:

data = {"name": "弗利沙", "level": [10, 20], "power": (1, 23, 530000)}

這裡的 "name""level" 以及 "power" 就是 Key,後面對應的就是 Value。Key 等於是在幫 Value 加上標籤,這樣可以更清楚的知道每個值所代表的意義,這也是它被稱為「字典」的原因。不使用大括號的話,也可以使用 Python 的內建函數 dict() 來建立字典:

data = dict(name="弗利沙", level=[10, 20], power=(1, 23, 530000))

這樣也能建立字典。

dict() 函數還能使用其他像是串列或 Tuple 等巢狀資料結構來建立字典:

>>> user1 = [["name", "悟空"], ["hp", 80], ["power", 100]]
>>> user2 = (("username", "魯夫"), ("hp", 100), ("mp", 70))

# 把巢狀陣列轉換成字典
>>> hero1 = dict(user1)
>>> hero1
{'name': '悟空', 'hp': 80, 'power': 100}

# 把巢狀 Tuple 轉換成字典
>>> hero2 = dict(user2)
>>> hero2
{'username': '魯夫', 'hp': 100, 'mp': 70}

只要給它成對的資料,不管是串列或是 Tuple,或甚至混著用也沒關係,dict() 函數都能把它們轉換成字典。

不過,如果沒特別需求的話,我自己比較喜歡直接用字面值(Literal),也就是大括號 { } 來建立字典,程式碼看起來比較清楚。事實上,使用 dict() 函數建立字典物件的過程中會經歷的步驟比用字面值 { } 多了一點點,因此使用 dict() 的執行速度也會比 { } 慢一點點。

是說,dict 的發音,最後一個 t 的發音要唸清楚,不然聽起來就會像「dick」,這個字在英文...嗯,有別的意思,如果怕唸錯的話可以唸做 Dictionary,或是直接叫它「字典」就好。

雖然之前有稍微提過但我猜大家可能沒放心上,就是 str()int()float()bool()list() 看起來像是類別的函數,為了簡化或是避免造成學習上的困惑,在這本書裡我大部份時候都會稱它們為「函數」,但實際上在 Python 裡它們都是「類別(Class)」,dict() 本身也是類別。關於類別的詳細內容會在物件導向章節介紹,

dict 類別身上有一個 .fromkeys() 方法,也可以用來建立字典:

>>> hero = dict.fromkeys(["name", "age", "power"])
>>> hero
{'name': None, 'age': None, 'power': None}

.fromkeys() 方法的第一個參數是可迭代物件,所以要用字串、串列或 Tuple 都可以,我就先給它一個串列。串列裡的每個元素會被當作 Key 並且建立一個全新的字典,然後所有的 Value 預設都會被設定成 None。如果想要改變預設值,可以多加一個參數:

>>> hero = dict.fromkeys(["name", "age", "power"], 0)
>>> hero
{'name': 0, 'age': 0, 'power': 0}

這樣就會建立一個所有值都是 0 的新字典。要稍微注意的是,因為字串也是可迭代物件,所以如果這裡把一個字串放進第一個參數:

>>> user = dict.fromkeys("hello", "kitty")
>>> user
{'h': 'kitty', 'e': 'kitty', 'l': 'kitty', 'o': 'kitty'}

"hello" 字串的每個字元會被當作 Key,Value 會被設定成 kitty,又因為剛好 hello 裡面有一個重複的 l,所以有一組就被蓋掉了。我不確定這是不是你預期的結果,使用的時候需要稍微留意一下。

取用字典

存取字典的方式跟串列有點類似,串列是連續的記憶體空間,所以是使用索引值來存取裡面的元素,但字典本身並沒有順序,所以沒有提供類似索引值的方式來存取資料,取而代之的是使用 Key 來存取資料:

>>> user = {"name": "地球人", "age": 20, "power": 5}

# 使用 Key 來存取
>>> user['name']
'地球人'
>>> user['age']
20
>>> user['power']
5

字典裡查的到的 Key 都很正常,但如果用一個不存在的 Key 來查資料,有些程式語言只是安靜的給你一個 undefinednil 然後裝做什麼事都沒發生,但 Python 又會再次給你很明確的錯誤訊息:

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

恭喜你得到了一個 KeyError 的錯誤訊息。雖然萬一跳出這個錯誤訊息也是可以用 try...except... 救回來,但如果每次要查個 Value 就要做錯誤處理也未免有點太辛苦了,錯誤處理相關的介紹會在後面的章節再詳述。要是擔心會遇到不存在的 Key 的情況,可以用 in 關鍵字判斷一下:

user = {"name": "地球人", "age": 20, "power": 5}

if "hello" in user:
print(user["hello"])
else:
print("是在哈囉?")

這樣就能避免這個情況。不過 Python 的字典物件本身就有提供一些方法,可以在查詢的時候遇到沒有 Key 的情況也不會出錯,還可以給個預設值:

>>> user = {"name": "地球人", "age": 20, "power": 5}

# 使用 get() 方法
>>> user.get("hello")
>>> user.get("hello", "kitty")
'kitty'

字典物件的 .get() 方法可以接收兩個參數,第一個參數是要查找的 Key,第二個參數是萬一找不到 Key 的時候的預設值,假設沒有特別給預設值的話,找不到 Key 的時候就會得到預設值 None

要從字典裡取值的話還有另一個做法,在字典物件有一個叫做 .setdefault() 的方法,這名字乍看之下跟取值沒什麼關連,畢竟它的名字就是 set 開頭的,從方法的名字看起來就像是要設定預設值用的,不過這個方法在設定預設值的過程中順便也可以幫你取值。.get() 方法會在找不到 Key 的時候回傳 None 或是你指定的預設值,而 .setdefault() 方法則是會在找不到 Key 的時候直接幫你新增並且回傳這組 Key / Value 組合:

>>> hero = {"name": "悟空", "power": 100}

# 設定一組不存在的 Key
>>> age = hero.setdefault("age", 20)
>>> age
20
>>> hero
{'name': '悟空', 'power': 100, 'age': 20}

但如果這個 Key 已經存在的話,.setdefault() 方法就不會有動作:

>>> hero = {"name": "悟空", "power": 100}

# Key 已經存在
>>> name = hero.setdefault("name", "魯夫")
>>> name
'悟空'

# 沒有改變
>>> hero
{'name': '悟空', 'power': 100}

《冷知識》字典沒有順序?

剛剛提到在 Python 的字典是沒有順序的,要存取資料的時候只能使用 Key 來存取,而不是使用索引值。嗯...這樣的說法沒有錯,在 Python 3.6 版本之前,字典的確是沒有順序,但從 Python 3.6 之後就有點變化了。

在 Python 3.6 之前,字典是使用「雜湊表(Hash Table)」來實作的,關於雜湊在本章的最後面會再補充說明。雜湊表本身是一種沒有順序的資料結構,實際上在字典裡的 Key 會先經過雜湊函數計算,然後在雜湊表根據計算出來的雜湊值來決定它對應到的值。

在 Python 3.6 之後,字典改用元素加入字典的順序來做為 Key 的順序,也就是說字典的 Key 會依照當時建立或新增的時候的順序排列。

# 注意:這是 Python 3.5
>>> data = {'a': 123, 'b': 456, 'c': 789}
>>> data.keys()
dict_keys(['b', 'c', 'a'])]

# 這是 Python 3.12
>>> data = {'a': 123, 'b': 456, 'c': 789}
>>> data.keys()
dict_keys(['a', 'b', 'c'])

就算現在的 Python 版本的字典是有順序的,要存取字典的值還是需要使用 Key 才行。如果你真的這麼想要有順序的資料結構,就改用串列或 Tuple 吧。

新增、更新字典

更新字典的方式也很直覺,就跟讀取差不多,也是使用 Key,只是在後面再加上等號而已:

>>> user = {"name": "地球人", "age": 20, "power": 5}

# 把 name 改成火星人
>>> user["name"] = "火星人"
>>> user
{'name': '火星人', 'age': 20, 'power': 5}

如果 Key 不存在的話呢?別擔心,Python 會認為這個行為是想要設定一組新的 Key / Value 組合:

>>> user = {"name": "地球人"}

# "age" Key 不存在
>>> user["age"] = 10
>>> user
{'name': '地球人', 'age': 10}

所以,像 user["age"] = 10 這樣的寫法,就等於是在新增一組 Key / Value 組合。

除了一個一個的指定更新,字典物件身上有個 .update() 方法可以一次更新一個或多個值:

>>> hero = {"name": "悟空", "age": 30, "power": 100}
>>> new_data = {"age": 31, "power": 250, "speed": 50}
>>> hero.update(new_data)
>>> hero
{'name': '悟空', 'age': 31, 'power': 250, 'speed': 50}

.update() 方法可以把要更新的字典的 Key / Value 組合新增或更新到原本的字典裡面,如果 Key 不存在就新增一組,存在的話就進行更新。但,這個方法的名稱有點容易誤會,它的名字叫做 .update(),但實際上在做的事情更像是「合併(merge)」而不是「更新」,所以雖然可以一次更新多個很方便,使用這個方法的時候要多想一下這是不是你想要的效果。

有這個 Key 嗎?

如果想要知道字典裡有沒有某個 Key,早期 Python 的字典物件有個 .has_key() 方法可以用,不過到了 Python 3 之後就被移掉了,取而代之的是 in 關鍵字:

>>> hero = {"name": "悟空", "power": 100}
>>> "name" in hero
True
>>> "age" in hero
False

透過 in 關鍵字問看看在不在,這個語法寫起來還滿直覺的。除了 in 之外,有個比較沒那麼直覺而且也不建議的寫法,就是呼叫物件身上的 .__contains__() 方法:

# 檢查字典有沒有指定的 Key
>>> hero = {"name": "悟空", "power": 100}
>>> hero.__contains__("name")
True
>>> hero.__contains__("age")
False

# 串列也有這個方法
>>> [1, 2, 3].__contains__(2)
True
>>> [1, 2, 3].__contains__(100)
False

# 字串也行
>>> "hello".__contains__('h')
True
>>> "hello".__contains__('x')
False

在 Python 你可能會時不時的看到這種頭尾有帶兩個底線的方法,正因為有兩個底線,所以通常會稱它們為 Double Underscore 方法,常會唸成 dunder 方法,有時也會稱它「魔術方法(Magic Method)」,在第 15 章的物件導向程式設計 - 進階篇還會看到更多的例子。

雖然 .__contains__() 方法在字面上看起來還算容易理解,但在 Python 的設計裡,這些魔術方法大部份都不是給我們這些麻瓜用的,這通常是 Python 內部使用的,所以特別在設計的時候在頭尾加上兩個底線,避免不小心跟我們自己定義的方法重複。

說是這樣說,Python 並沒有不讓你用它,就算加了兩個底線還是可以呼叫的。事實上,當你使用 in 關鍵字的時候,Python 內部就是在呼叫 .__contains__() 方法,所以沒必要自己手動呼叫這種魔術方法。

Key 的限制?

你可能會看到我在前面的範例都是使用字串來當 Key,雖然 Key 不一定要是字串,但也不是什麼東西都能拿來當做 Key。要拿來當做 Key 有一些條件,首先它必須是「不可變(Immutable)」的資料型態。大家還記得有哪些是不可變的嗎?除了字串之外,數字、布林值也都是不可變的:

>>> options = {True: "真的", False: "假的"}
>>> options[True]
'真的'
>>> options[1]
'真的'

這裡我用布林值當做 Key,在取用的時候自然也得用布林值來拿,不過因為在 Python 布林值其實就是 0 跟 1 的別名,所以用數字 0 跟 1 也能拿到對應的值。如果你喜歡,你也可以直接用數字來當 Key,以結果來看,它最後用起來的樣子甚至會有點像串列:

>>> not_list = {0: "a", 1: "b", 2: "c"}
>>> not_list[0]
'a'
>>> not_list[2]
'c'

但這就只是看起來像,本質上還是字典,所以串列常用的方法不一定適用在它身上。另外,在下個章節會提到的 Tuple,因為它也是不可變的,所以也能拿來當 Key:

>>> cities = {(10, 20): "台北", (23, 42): "台中", (37, 19): "高雄"}
>>> cities[(10, 20)]
'台北'

這樣也可以正常運作。在下個章節介紹 Tuple 的時候也會提到,雖然 Tuple 這個容器本身是不可變的,但裡面的值就不一定了,所以如果你真的要用 Tuple 來當 Key 的話,需要確認裡面的每個元素也都必須是不可變的,不然一樣會出錯。

如果不信邪,就是拿可變動的東西來當 Key 會發生什麼事?我就直接用可變動的串列來當 Key 給大家看看:

>>> not_working = {[1, 2]: "hi", [3, 4]: "hey"}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

Python 會給我們 TypeError 錯誤訊息,說串列不是一種可以被雜湊(unhashable type)的型別,所以不能拿來當 Key,也就是說,串列、字典或集合這種可以被修改的資料型態通通都不能當做字典的 Key。不過這裡提到的無法被雜湊是什麼意思?先不急,這個我們放在這個章節的最後再來跟大家補充。

雖然不一定只能用字串當 Key,但大部份都還是會使用字串,偶爾可能會用數字或布林值來做一些值的對照或翻譯,那就是真的把它當「字典」來用。至於用 Tuple 來當 Key...嗯,我是想不太到什麼場合需要這樣做,可以是把經緯度座標組合成 Tuple 當做 Key 來取得對應的資料吧。

字典的 Key 有以上這些限制,但相對的 Value 就沒什麼限制了,數字、文字、串列...只要你想的到的都可以放上去。

喔,有個不算限制的限制是,Key 之間必須是唯一的,也就是說,你不能用同樣的 Key 來存放不同的值...嗯,也不是說不能這樣做:

>>> user = {"name": "地球人", "age": 20, "name": "火星人"}
>>> user
{'name': '火星人', 'age': 20}

這麼做並不會出錯,但比較晚出現的 Key 的值會蓋掉前面出現過的值,就看這是不是你要的結果了。

《冷知識》為什麼叫字典?

在 Python 稱做「字典」的資料結構,在其他程式語言可能會被叫做「雜湊(Hash)」或「關聯陣列(Associative Array)」,在 JavaScript 則稱這樣的資料結構為「物件(Object)」。本章節最後會再跟大家介紹什麼是雜湊,但我們先來看為什麼 Python 要稱它做「字典」?

不用我特別介紹,大家應該知道一般傳統的字典就是一本厚厚的書,裡面會有很多單字以及每個單字所對應的意思,這就像我們在這個章節學到的 Key 跟 Value 的對應關係,雖然在傳統的字典裡的可以有很多不同的字用來表示相同的意思,但每個單字都不會重複。Python 的字典內部是使用雜湊表(Hash Table)實作,但叫它「Hash」可能會讓人誤會它是個雜湊函數或雜湊計算之後的結果。簡單的說,「字典」是一個比較泛用而且容易想像的名稱,用來表示 Key 跟 Value 的對應關係。而雜湊表是 Python 內部用來實作字典的方法,所以 Python 最後選擇了一個更為直觀易懂的方式來命名。

為什麼不叫它「物件」...這個嘛,我認為在一個什麼東西都是物件的程式語言裡,如果把字典也叫做物件容易讓人有點混淆。畢竟在 Python 裡面很多東西都是物件,字典只是眾多物件的其中一種而已。在 JavaScript 裡用大括號把 Key 與 Value 包一包組合在一起的東西就叫做「物件」,我反而對這樣的命名方式有點意見,如果這樣,陣列是物件嗎?如果陣列是物件,為什麼不要叫它物件就好,還要叫它陣列呢?

字典常見操作

計算個數

如果把字典的 Key 跟 Value 當做一組資料,要計算字典裡面有多少組元素,可以用我們在串列章節介紹過的內建函數 len()

>>> hero = {"name": "悟空", "power": 100}
>>> len(hero)
2

內建的 len() 函數不只可以用在一般的序列型的資料,例如字串、串列、Tuple 上,字典也能用,只是算出來的不是多少顆元素,而是多少組。

清除內容

字典物件身上有個 .clear() 方法可以用來清除字典內的所有元素:

>>> hero = {"name": "悟空", "power": 100}
>>> hero
{'name': '悟空', 'power': 100}

# 清除所有元素
>>> hero.clear()
>>> hero
{}

如果只想要刪除某一組 Key 的話,可以使用 del 關鍵字:

>>> hero = {"name": "悟空", "power": 100}
>>> del hero['name']
>>> hero
{'power': 100}

del 是 Python 內建的關鍵字,不是函數,使用的時候不需要加小括號。使用 del 關鍵字能把 name 這組 Key 以及對應的值從字典裡面刪除掉,但要注意的是 del 能刪的東西不是只有字典裡面的元素,基本上它可以刪除任何東西,包括整個字典,所以如果是這樣寫的話:

>>> hero = {"name": "悟空", "power": 100}
>>> del hero
>>> hero
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'hero' is not defined

剛才的 .clear() 方法只是把原本的字典清空,但 del 是把整個變數刪掉連一點渣都沒剩,所以小心使用 del 這個關鍵字,不要刪到不該刪的東西。

取出

要把值從字典裡取出來,我指的取出不是只有讀取,而是真的要把它從字典裡面拿出來的那種取出。要做到這個效果可使用字典物件的 .pop() 方法:

>>> hero = {"name": "悟空", "skill": "龜派氣功"}
>>> special_move = hero.pop("skill")
>>> special_move
'龜派氣功'
>>> hero
{'name': '悟空'}

有些程式語言的 .pop() 方法會直接取出最後一個元素,但因為字典沒有順序,所以沒有所謂的最後一個,.pop() 需要指定想要取出的 Key,不指定的話會出錯。要是指定的 Key 不存在的話,會得到 KeyError 的錯誤訊息:

>>> hero.pop("food")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'food'

這應該不是第一次遇到這種情況了,前面也有介紹過怎麼處理類似的錯誤,如果想要在找不到 Key 的時候給個預設值的話,可以在呼叫 .pop() 方法的時候加上第二個參數:

>>> hero = {"name": "悟空", "skill": "龜派氣功"}
>>> hero.pop("food", "仙豆")
'仙豆'

這樣就不會出現錯誤訊息了。同樣是從字典把東西取出來,另一個 .popitem() 方法可以連 Key 一起拿出來:

>>> hero = {"name": "悟空", "skill": "龜派氣功", "power": 1000}
>>> hero.popitem()
('power', 1000)
>>> hero
{'name': '悟空', 'skill': '龜派氣功'}

.popitem() 方法會把最後一組 Key / Value 取出,這個方法就跟其他程式語言的 .pop() 的操作比較像了,但是要注意的是前面有說過字典本身並沒有順序,但在 Python 3.6 之後的 Key 會依照加入字典的順序,所以 .popitem() 方法的時候就會依照 LIFO(Last In, First Out)原則,最後加進來的會最先被拿出來。

因為它會把最後一組 Key / Value 取出來,所以如果你想要把字典裡面的所有元素都取出來的話,可以用 while 迴圈搭配 .popitem() 方法:

hero = {"name": "悟空", "skill": "龜派氣功", "power": 100}

while hero:
k, v = hero.popitem()
print(f"{k}: {v}")

while 迴圈裡執行 .popitem() 方法會不斷的字典裡的最後一組 Key 拿出來,拿到沒東西拿的時候字典就會變成空的,而空的字典在 Python 會被判定成 False,所以這時 while 迴圈的判斷條件就會變成 False,迴圈停止。

如果整個字典裡都沒東西還是想再繼續執行 .popitem() 方法了,Python 會再次給你 KeyError 的錯誤訊息:

>>> hero.popitem()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'popitem(): dictionary is empty'

Python 明確的跟你抱怨這個字典已經沒東西了,不要再拿了!

不過使用 .popitem() 方法的時候要注意 Python 版本,早期版本的 Python 的字典本身並沒有順序...嗯,其實也不是沒順序,只是順序不是你想像的樣子,而且我們也看不出排序的規則,所以 .popitem() 方法取出的資料也不一定是最後一個,以結果來看比較像是隨機拿一個出來一樣。

合併字典

要合併字典可使用前面介紹的 .update() 方法,另外還有其他幾個手法也能做到一樣的效果。首先,還記得上個章節提到的開箱運算子(Unpacking Operator)嗎?一樣也能用在字典身上,只是用在串列用的是一個 *,要開箱字典的話則是使用兩個 **

>>> city = {"name": "台北", "population": 2600000}
>>> location = {"lat": 25.04, "lng": 121.51}

# 使用 ** 開箱字典再組合
>>> info = {**city, **location}

>>> info
{'name': '台北', 'population': 2600000, 'lat': 25.04, 'lng': 121.51}

這個用起來跟在串列使用開箱運算子的手法有點像,但因為合併字典最後還是會得到一個字典,所以外面記得用 {} 包起來。

一開始提到可以用來建立字典的 dict() 函數也可以做到類似的事:

info = dict(city, **location)

dict() 函數可以接好幾種參數,其中一種用法的第二個參數是一個名為 Keyword Arguments 的東西,這在後面介紹到函數的時候會再詳細說明,它是一個字典,所以你可以開箱運算子把另一個字典的內容加進去。但老實說我沒有覺得很好用,主要是第二個參數的格式不太直覺,而且都要用開箱運算子了,不如就兩個就都開一開再組合就好,所以我自己是知道但不太會用這個方法。

另外還有一個用起來更方便但可能需要稍微想一下的 | 運算子,它也能做到合併字典的效果:

# 使用 | 運算子合併字典
>>> info = city | location

>>> info
{'name': '台北', 'population': 2600000, 'lat': 25.04, 'lng': 121.51}

在很前面的章節曾經介紹過的 a = a + b 可以改成 a += b 這樣的寫法,這裡的 | 運算子也可以有一樣的操作,如果只是要更新字典的話,用起來會更簡單:

>>> city = {"name": "台北", "population": 2600000}
>>> location = {"lat": 25.04, "lng": 121.51}

# a = a | b 的概念
>>> city |= location

>>> city
{'name': '台北', 'population': 2600000, 'lat': 25.04, 'lng': 121.51}

雖然 ||= 的寫法很方便,不過這個語法是在 Python 3.9 版之後才加進來的功能,使用的時候需要稍微留意 Python 的版本。

在合併字典的時候,不管是哪種合併方式,都需要注意如果有重複的 Key 的話,後面的值會蓋掉前面的值,也就是說 A 合併 B 跟 B 合併 A,結局可能是不一樣的。

複製字典

在串列章節也有提過,在變數指定的時候,如果指定是同一個串列或字典的時候,如果改變其中一個變數的值,另一個變數的值也會跟著改變:

>>> cat = {"name": "kitty", "age": 18}
>>> new_cat = cat

# 把 age 改成 20
>>> new_cat["age"] = 20

>>> cat
{'name': 'kitty', 'age': 20}
>>> new_cat
{'name': 'kitty', 'age': 20}

如果這種改一個全部跟著改的效果不是你要的,要避免這種情況只要把字典的內容複製一份給另一個變數就行了,在前面介紹串列的章節也做過類似的事。串列可以利用「切片」複製一份串列,但字典本身不是有順序的資料,所以沒有切片的設計,只能透過其他方式來做這件事。Python 的字典物件有個 .copy() 方法,看名字就知道它是什麼用途:

>>> cat = {"name": "kitty", "age": 18}

# 複製一份給 copy_cat
>>> copy_cat = cat.copy()

# 把 age 改成 100
>>> copy_cat["age"] = 100
>>> cat
{'name': 'kitty', 'age': 18}
>>> copy_cat
{'name': 'kitty', 'age': 100}

這樣就可以避免原本的字典被改掉了。不過,.copy() 方法有個限制,就是它只能複製第一層的內容,如果字典裡面還有更深一層的字典,那麼內層的字典還是共用的:

>>> cat = {"name": "kitty", "profile": {"age": 18, "color": "white"}}

# 複製一份
>>> copy_cat = cat.copy()

# 把 color 改成紅色
>>> copy_cat["profile"]["color"] = "red"

# 結果兩個都變紅色了
>>> cat
{'name': 'kitty', 'profile': {'age': 18, 'color': 'red'}}
>>> copy_cat
{'name': 'kitty', 'profile': {'age': 18, 'color': 'red'}}

在串列章節曾經出場過的 copy 模組,裡面的 deepcopy() 函數也能對字典做深度的複製:

>>> from copy import deepcopy
>>> cat = {"name": "kitty", "profile": {"age": 18, "color": "white"}}

# 深度複製
>>> copy_cat = deepcopy(cat)

# 把 color 改成紅色
>>> copy_cat["profile"]["color"] = "red"
>>> cat
{'name': 'kitty', 'profile': {'age': 18, 'color': 'white'}}
>>> copy_cat
{'name': 'kitty', 'profile': {'age': 18, 'color': 'red'}}

除了字典物件的 .copy() 方法以及 copy 模組的 deepcopy() 函數外,我們在最一開始講到建立字典的時候也提過 dict() 函數,它不只能用來建立新的字典,還能用來複製一份字典:

>>> hero = {"name": "悟空", "power": 100}
>>> new_hero = dict(hero)
>>> new_hero
{'name': '悟空', 'power': 100}

把一整個字典當作參數傳給 dict() 函數,它會回傳一個新的字典,內容跟原本的一樣。不過這個方法跟字典物件的 .copy() 方法一樣,只能淺淺的複製第一層的內容而已。

還有還有,剛剛講到的字典用的開箱運算子 ** 也能做到淺層的複製:

>>> hero = {"name": "悟空", "power": 100}
>>> clone_hero = {**hero}
>>> clone_hero
{'name': '悟空', 'power': 100}

複製字典的手法有好幾種,大家就選一個自己看起來最順眼的方式來做就行了。

印出所有 Key 跟 Value

Python 的字典物件有 .keys().values() 方法,分別可以取出字典物件裡所有的 Key 跟 Value:

>>> hero = {"name": "悟空", "skill": "龜派氣功", "power": 100}

# 列出所有的 Key
>>> hero.keys()
dict_keys(['name', 'skill', 'power'])

# 列出所有的 Value
>>> hero.values()
dict_values(['悟空', '龜派氣功', 100])

如果想要印出所有的 Key 跟 Value 的話,用個 for 迴圈就能搞定:

hero = {"name": "悟空", "skill": "龜派氣功", "power": 100}

# 印出所有的 Key
for k in hero.keys():
print(k)

# 印出所有的 Value
for v in hero.values():
print(v)

小孩子才做選擇,有個 .items() 方法可以把 Key 跟 Value 一起列出來:

>>> hero = {"name": "悟空", "skill": "龜派氣功", "power": 100}
>>> hero.items()
dict_items([('name', '悟空'), ('skill', '龜派氣功'), ('power', 100)])

一樣用 for 迴圈就能搞定:

hero = {"name": "悟空", "skill": "龜派氣功", "power": 100}

for k, v in hero.items():
print(f"{k}: {v}")

這應該比前面介紹的 popitem() 簡單多了,而且還不會改變字典物件本身的內容。

《冷知識》為什麼不是串列?

是說,不知道你剛才是否有注意到 .keys().values() 以及 .items() 這三個方法執行之後的結果有些不太一樣?為什麼這幾個方法不直接給個串列就好,反而是 dict_keysdict_valuesdict_items 這種看起來有點奇怪的東西?

想像一下,如果你有一個數百萬筆的大字典,假設 keys() 方法直接回傳一個串列的話,迴圈還沒開始轉就要先用掉一大塊記憶體來放這個串列。所以 Python 的做法是回傳一個「產生器(Generator)」的東西,我們在第 11 章「函數進階篇」會再詳細介紹它。產生器可以在每次迭代的時候產生所需要的資料,這樣就不需要一口氣把所有的值都展開放進記憶體裡。有興趣的話不妨做個簡單的實驗,比較一下差異:

from sys import getsizeof

# 做一個一百萬筆資料的字典
big_dict = {i: i for i in range(1000000)}

keys1 = big_dict.keys()
keys2 = list(big_dict.keys())

print(getsizeof(keys1)) # 40
print(getsizeof(keys2)) # 8000056

getsizeof() 是 Python 的內建函數,可以用來看一個物件佔用了多少記憶體,實際跑出來的數字可能會因為不同環境而有所不同,但應該很明顯可以看的出來轉換成串列的 keys2 所用的記憶體比 keys1 大很多。

不只這樣,不管是 .keys().values() 或是 .items() 方法,它們都有個很有趣的特性,就是它們都是一個「視圖(View)」。什麼意思?View 的概念有點像是一扇窗戶,透過窗戶你可以看到某些景色。事實上你拿到的並不是這些 Key 或 Value 而是窗戶本身,所以當 Key 或 Value 有變動的時候,你從窗戶看出去的景色也會變。來看個例子會更容易理解一些:

>>> hero = {"name": "悟空", "skill": "龜派氣功", "power": 100}

# 取得所有的 key 跟 value
>>> hero_keys = hero.keys()
>>> hero_values = hero.values()
>>> hero_keys
dict_keys(['name', 'skill', 'power'])
>>> hero_values
dict_values(['悟空', '龜派氣功', 100])

# 新增一組 Key
>>> hero["age"] = 20
>>> hero
{'name': '悟空', 'skill': '龜派氣功', 'power': 100, 'age': 20}
>>> hero_keys
dict_keys(['name', 'skill', 'power', 'age'])
>>> hero_values
dict_values(['悟空', '龜派氣功', 100, 20])

你會發現,當新增、修改或刪除原本字典物件裡的資料之後,即使沒有再次呼叫 .keys().values() 方法,之前拿到的 hero_keyshero_values 對應到的值也會跟著變, 就是因為透過這些方法拿到的並不是那些 Key 或 Value 本身。

另外還有個小細節,就是 .keys().values() 拿到的東西雖然感覺型態有點類似,名字都是 dict_ 開頭的,但它們的本質上不太一樣。想一下,在字典裡的 Key 應該是唯一的、不會重複的,所以透過 .keys() 方法拿到的 dict_keys 物件,裡面的內容也不會是重複的。dict_keys 物件的行為跟我們下個章節會介紹到的「集合(Set)」比較接近,可以做一些聯集、交集、差集等等的運算。但 Value 就不一定了,字典裡的 Value 可以重複,所以透過 .values() 方法拿到的 dict_values 物件,裡面的內容也可能是重複的,所以就沒有像 dict_keys 的特性。

字典推導式

串列有串列推導式,字典也有字典推導式(Dictionary Comprenhension)。字典推導式的寫法跟串列推導式很像,只是把中括號換成大括號。而且因為字典是由 Key 跟 Value 組合而成,所以在推導式裡需要在中間加上冒號:

>>> numbers = [1, 2, 3, 4, 5]
>>> square_dict = {x: x**2 for x in numbers}
>>> square_dict
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

在前面學過的串列推導式是什麼用途,字典推導式就差不多的用途。推導式本身就是在做資料轉換,以上面這個例子來說,就是把一個串列轉換成字典,Key 是原本的值,Value 是原本的值的平方。

我們來做個簡單的練習,假設我手邊有兩個串列,分別表示水果的種類以及價錢:

fruits = ["蘋果", "香蕉", "鳳梨", "芭樂"]
prices = [20, 10, 15, 30]

如果我想要得到一個字典,Key 是水果的名稱,Value 是價錢,像這樣:

{"蘋果": 20, "香蕉": 10, "鳳梨": 15, "芭樂": 30}

這該怎麼做?有幾種做法,最陽春的就是用迴圈:

fruits = ["蘋果", "香蕉", "鳳梨", "芭樂"]
prices = [20, 10, 15, 30]

fruit_price = {}
for i in range(len(fruits)):
fruit_price[fruits[i]] = prices[i]

print(fruit_price)

雖然寫起來有點囉嗦,但基本上沒什麼問題。Python 有個內建函數 zip(),可以更簡單的幫我們做到這件事:

fruit_price = zip(fruits, prices)

zip() 函數,如它字面上「拉鏈」的意思,可以把兩個或多個可迭代物件像拉鏈一樣的拉在一起變成一個新的 Tuple,然後再搭配字典推導式來簡化剛才的 for...in...

fruit_price = {fruit: price for fruit, price in zip(fruits, prices)}

寫起來簡單又容易閱讀,這也正是推導式的好處之一。

《冷知識》什麼是「雜湊」?

雜湊(Hash),或說雜湊函數(Hash Function),可以將任意長度的資料,也許是字串、數字,或是位元組,轉換成固定長度的字串,經過雜湊函數計算之後所產生的字串又稱「雜湊值」。雜湊值通常會由隨機英文字母跟數字組合而成,而且除非原始資料就只有幾個字,不然雜湊值通常算出來都會比原始資料要來的短。

雜湊函數可以將輸入值對映到一個固定的輸出值,也就是說只要輸入值一樣,計算出來的雜湊值也會是一樣,但如果輸入值不一樣,就算只差一個字或標點符號,算出來的雜湊值就會完全不一樣,在密碼學中,這樣的特性又稱之「雪崩效應(Avalanche Effect)」。因為這個特性,雜湊函數常被用來確保檔案或資訊的完整性或一致性,例如確保下載的檔案沒有被有心人士動過手腳。

雖然不同的輸入值就會算出不同的雜湊值,但也是有可能兩個看起來差很多的輸入值經過雜湊計算之後卻巧合的得到相同的結果,這種情況稱為「碰撞(Collision)」。碰撞發生的機率非常非常非常低但不是 0,只是這機率低到不知道可以連續中幾張大樂透頭獎了,所以在大部份的情況可以當做不會遇到。

Python 有個內建的函數 hash(),可以用來計算某一顆物件的雜湊值,計算之後會得到一個整數:

>>> hash("hello")
5846906938926706108
>>> hash("kitty")
5627778489144586221

# 在同一個情境下再算第二次會得到一樣的結果
>>> hash("hello")
5846906938926706108

hash() 函數算出來的雜湊值可能會因為作業系統不同或是 Python 版本不同而有所不同(不同版本的 Python 用的演算法可能不一樣),而且在執行 hash() 函數的時候 Python 還會偷偷的塞一顆「隨機種子(Random Seed)」在裡面,讓每次算出來的結果不會一樣,所以如果大家跟著我敲打上面的程式碼,在各位的電腦算出來的答案跟我的不一樣也是很正常的。

在 Python 的設計中,如果兩顆物件使用 == 比較的結果是「相等」的話,表示待會丟進 hash() 函數時候的「輸入值」就是一樣的,因此這兩個物件算出來的雜湊值就會是一樣的。我這裡先用下個章節會介紹到的 Tuple 為例:

# 建立兩個看起來一樣的 Tuple
>>> t1 = (1, 2, 3)
>>> t2 = (1, 2, 3)

# 它們相等,但不是同一個物件
>>> t1 == t2
True
>>> t1 is t2
False

# 計算雜湊值
>>> hash(t1)
529344067295497451
>>> hash(t2)
529344067295497451

除了我們在前面數字章節介紹過的 NaN 之外,只要是同一顆物件一定會相等於自己,但相等的物件卻不一定是同一顆物件,這也是我們前面學過 ==is 的差別。然而,並不是所有的東西都能被 hash() 函數計算出結果,只有「可雜湊物件」才能被計算雜湊值,如果把無法計算雜湊的物件丟進來,例如串列或字典:

# 串列
>>> hash([1, 2, 3])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

# 字典
>>> hash({"name": "kitty"})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'

你就會看到前面出現過 unhashable type 的錯誤訊息。所以,什麼叫做「可雜湊物件」?

可雜湊物件

可雜湊物件(Hashable Object)從字面上看起來就是可以被雜湊函數所計算的物件,在 Python 只要是不可變物件,例如整數、浮點數、字串、位元組,這些都是可以進行雜湊計算的;相對的,如果是可變物件,像是串列、字典、集合,都是不可雜湊的。那麼 Tuple 呢?這得看情況,Tuple 本身雖然是不可變物件,但還得看看裡面裝的元素是不是全部也都是可雜湊物件,如果裡面的元素都是可雜湊物件,那麼這個 Tuple 就是可雜湊的。

在字典裡的 Key 有個規定,就是 Key 必須是可雜湊的物件,所以在字典裡的鍵不能是串列、字典、集合,但可以是 Tuple,所以這樣寫是沒問題的:

cities = {
(40.7128, -74.0060): "New York City",
(34.0522, -118.2437): "Los Angeles",
(25.0330, 121.5654): "Taipei",
(35.6895, 139.6917): "Tokyo"
}

不只字典的 Key,同樣在下個章節也會介紹到的資料結構「集合(Set)」,裡面的元素也規定必須全部都是可雜湊物件。

是說,如果兩個物件不是同一個東西,但只要它們的雜湊值一樣,從字典的角度來說就會被當成是同樣的 Key:

import math

vol1 = (1, 2)
vol2 = (round(0.9), math.sqrt(4))

books = {vol1: "航海王", vol2: "七龍珠"}

print(books) # 會印出什麼?

雖然 vol1vol2 是不同的 Tuple,我還故意用不同的方式計算,但因為它們計算之後的雜湊值是一樣的,所以最終會被當成是同樣的 Key,然後後面的 Key 會把前面的 Key 蓋掉,所以這段程式碼會印出:

{(1, 2): '七龍珠'}

雖然我們在後面的章節才會介紹到函數,不過可以先劇透一下,Python 的函數本身也是不可變的,所以它也是一種可雜湊物件,也就是說如果你想要把函數當作字典的 Key 的話,也是可以的:

def hi():
pass

def hey():
pass

greetings = {hi: "嗨", hey: "嘿"}

print(greetings)

你可以實際執行看看,看會印出什麼結果。技術上來說,一個物件必須是不可變的才能當作字典的 Key 的說法並不夠精準,應該說必須是「可雜湊物件」才能拿來當做字典裡的 Key,而不可變物件剛好都是可雜湊的。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!