💡 先搞懂問題
學完串列(list,一排有編號的格子)和字典(dict,用鍵查值)之後,開始讀別人寫的程式,很快會撞上三種看不懂的寫法。第一種是 low, high = min_max(weights):等號左邊怎麼有兩個名字?第 9 課說函式用 return 交回結果,難道一次可以交回兩個?第二種是 set(scans):一串掃描紀錄丟進去,出來的東西變短了,印出來的順序還和放進去的不一樣。第三種是 data["boxes"][0]["items"][1]:一行裡接了四組方括號,看起來像密碼。
這三件事分別對應這一課的三個主角:元組(tuple)、集合(set)和巢狀資料(nested data)。元組在第 5 課出現過,就是「用圓括號建立、建立後不能修改的串列」;這一課要補上它最常見、卻最少被說破的用法:打包與拆包。集合是一個新的容器,專門處理「不要重複」和「有沒有」這兩種問題。巢狀資料則不是新型別,而是把前面學過的容器一層套一層放在一起,從網路服務拿到的資料幾乎都長這個樣子。
想像青松物流的倉庫。出貨前,理貨員把「貨號、件數、重量」三樣東西依序放進一個紙箱、封上膠帶,這個動作叫打包;收貨端拆開箱子,照順序把三樣東西放到三個位置,這叫拆包。倉庫門口有一台掃描器,記下「今天有哪些貨號進來過」:同一個貨號掃兩次,名單上還是只有一筆,名單也不管誰先誰後,只負責回答「有沒有」。倉庫裡的東西則是一層套一層的:一張出貨單裡有好幾個紙箱,每個紙箱裡又有好幾件商品,要找某一件,就得說「第 0 箱裡的第 1 件」,從外往內一層一層指定。
換成程式,封箱就是寫一串用逗號隔開的值:box = "A12", 3, 4.5。Python 看到逗號,會自動把它們裝進一個元組,括號寫不寫都可以。反過來,等號左邊放好幾個名字、右邊放一個元組,Python 就會依序拆開、一個名字貼一格。所以開頭那一行 low, high = min_max(weights) 的真相是:函式其實只交回一個元組,左邊的兩個名字把它拆開了。
集合就是那份掃描名單:set(scans) 把串列裡的貨號倒進集合,重複的自動只留一個,所以變短了。集合不記先後,也沒有「第幾個」,它擅長的是回答「某個貨號在不在裡面」,以及「兩份名單有哪些共同的貨號」。
巢狀資料就是出貨單。字典的值可以是一個串列,串列的格子裡又可以放字典,像下圖這樣一層包一層。讀取時從最外層開始,每接一組方括號就往裡走一層:外層是字典就用 ["鍵"],是串列或元組就用 [索引]。開頭那串看起來像密碼的寫法,其實是四個很普通的動作接在一起。
🎮 互動實驗室一:元組的打包與拆包
按「下一步 ▶」讓 Python 執行一行,看右邊的記憶體怎麼變:左欄是名字,右欄是物件,箭頭是「名字貼在哪個物件上」。遇到 🤔 問題時先選答案再往下走(自動播放也會在這裡停下來)。右上角的選單有四段程式:① 打包與拆包、② 函式交回兩個值、③ 元組當字典的鍵、④ 拆包的個數對不上。
🎮 互動實驗室二:集合去掉重複、比對兩份清單
集合用大括號建立,或用 set(...) 把一個串列轉成集合。播放器把集合畫成一排沒有索引的格子:因為集合沒有「第幾個」。另外要先說好一件事:播放器為了方便對照,會把集合的元素排序後再畫出來,真正印出來的順序不一定是這樣,第三段程式會讓你親眼看到差別。右上角選單可以切換:① 去掉重複與 in、② 交集、聯集、差集、③ 沒有順序也沒有索引。
sorted() 排成串列;字串集合直接 print,每次重新執行程式時順序都可能不同。「③ 沒有順序」用的是整數,輸出畫面印出 {12, 30, 15},既不是放進去的順序,也不是由小到大。🎮 互動實驗室三:一層一層往下取,箭頭從格子指出去
下面這段程式一步一步組出一張出貨單:外層是字典,"items" 的值是一個串列,串列的每一格又是一個字典。請注意右邊的畫法:外層的格子裡只有一個小圓點,箭頭從圓點伸出去,指向內層的物件。接著用 first = order["items"][0] 替內層的字典另外貼一張標籤,看看透過 first 修改時,從 order 那邊看過去會不會變。
order["items"] 指向的那個串列(它在閃黃),外層字典 order 的格子沒有變:它的 items 格一直指著同一個串列。第 5 行之後,first 的箭頭和串列第 0 格的箭頭指向同一個字典,所以第 7 行透過 first 改成 5,第 8 行從 order 一路走進去,看到的也是 5。複製巢狀資料:淺複製又出現了
第 5 課看過:copy() 只會做出新的外層,格子裡的箭頭照抄,叫做淺複製(shallow copy)。字典的 copy() 也一樣。出貨單想留一份備份再修改時,這件事就會咬人。右上角選單可以切換兩種寫法:① 只用 order.copy()、② 再把裡面的串列也各自複製一份。下方的提示框會一直告訴你,兩邊的 skus 是不是同一個串列。
backup["id"] 只換掉 backup 自己的格子,order 不受影響,第 4 行 append 卻改到兩邊共用的串列,所以最後印出的 order 也多了 D01。選「② 兩層都複製」,第 3 行讓 backup 的 skus 格子改指向一個新串列,之後的 append 就只有 backup 看得到。🎮 互動實驗室四:巢狀資料尋寶
青松物流的一張出貨單 shipment,四種容器都用上了:字典、串列、元組,還有一個集合。下方的「資料樹」把它一層一層攤開,每一列就是一格。有兩種玩法:「🔍 自由探索」點樹上的任何一格,右邊會組出取到這一格的 Python 寫法,並逐層解釋每一組方括號做了什麼;「🎯 尋寶挑戰」會給你一行取值的寫法,請在樹上點出它會拿到哪一格,或者判斷它會出錯。所有結果都和 Python 3.13 實際執行一致。
[0]、[1] 是串列或元組的索引;每一列右邊的小標籤是那一格的型別。集合底下的元素沒有索引,點了也組不出 [...] 的寫法,只能用 in 檢查或用 for 一個一個拿出來。挑戰卡答錯時,綠框會標出正確的那一格,或告訴你 Python 實際丟出的錯誤。📘 原理補完
1. 正式名稱
Python 官方文件把元組(tuple)歸類為不可變序列(immutable sequence):「序列」代表有順序、能用索引取值,「不可變」代表建立後不能修改。官方教學把 t = 12345, 54321, "hi" 這種寫法叫做元組打包(tuple packing),反過來 x, y, z = t 叫做序列拆包(sequence unpacking)。注意後者的名稱是「序列」拆包:右邊不一定要是元組,串列、字串也能拆,例如 a, b = [1, 2]、x, y = "OK"。
集合(set)在官方文件裡的定義是「沒有順序、元素不重複的一群物件」(an unordered collection with no duplicate elements),常見用途是檢查有沒有某個元素、去掉重複,以及做交集、聯集、差集這類運算。集合是可變物件,可以 add、remove;它的元素則和字典的鍵一樣,必須是不可變的值。
巢狀資料(nested data)不是一種新的型別,而是「容器裡放容器」的統稱:串列裡放字典、字典的值是串列、元組裡放元組……要套幾層都可以,英文常叫 nested data 或 nested data structures。
2. 四種容器放在一起比
到這一課為止,Python 內建的四種容器都見過了。選哪一種,看三個問題就夠了:要不要用位置取值、建立後會不會改、允不允許重複。
| 容器 | 寫法 | 空的怎麼寫 | 順序 | 可以修改? | 重複 | 怎麼取值 | 常見用途 |
|---|---|---|---|---|---|---|---|
| 串列 list | [1, 2, 2] | [] | 有,照放入順序 | 可以 | 允許 | a[i] | 會增減、要排序的一串資料 |
| 元組 tuple | (1, 2) 或 1, 2 | ();一個元素寫 (5,) | 有 | 不行 | 允許 | t[i]、拆包 | 固定的一組值、函式交回多個值、字典的鍵 |
| 字典 dict | {"a": 1} | {} | 保持放入順序(3.7 起保證) | 可以 | 鍵不重複 | d["鍵"] | 用名稱查資料、一筆記錄的各個欄位 |
| 集合 set | {1, 2} | set() | 不保證 | 可以 | 不重複 | 沒有索引;用 in 或 for | 去掉重複、快速檢查有沒有、比對兩份名單 |
最容易搞混的是大括號:{} 是空的字典,不是空集合,空集合只能寫 set()。大括號裡有冒號就是字典,只有值就是集合。另一個是一個元素的元組:(5) 只是加了括號的整數 5,(5,) 才是元組,因為做出元組的是逗號。
3. 打包與拆包的規則
打包很單純:一串用逗號隔開的值,就會被裝成一個元組。括號通常可以省略,但有時候必須加,免得 Python 誤會,例如 print(1, 2) 是把兩個值分別交給 print,print((1, 2)) 才是交給 print 一個元組。
拆包只有一條硬規則:左邊的名字數量,要和右邊的元素數量剛好一樣,多了少了都是 ValueError。只想要其中幾格時,習慣用底線 _ 當成「這一格我不需要」的名字。拆包也會出現在 for 迴圈裡:第 6 課的 for name, ext in phone.items():,items() 每一圈交出一個 (鍵, 值) 元組,迴圈把它拆給 name 和 ext 兩個名字。
交換兩個變數 a, b = b, a 是打包和拆包連在一起:Python 一定先把等號右邊整個算完、打包成一個元組,才開始把左邊的名字貼上去。下圖把這三個階段拆開來看。
left, right = right, left 的三個階段。因為第 ② 步先把右邊的值裝進元組,第 ③ 步改貼 left 時,right 原本指的「台北」已經安全地放在元組裡,不會被弄丟。函式「回傳多個值」也是同一回事。return low, high 交回的是一個元組,呼叫的地方可以用一個名字整個接住(result = min_max(...),之後用 result[0] 取值),也可以直接拆包(low, high = min_max(...))。兩種寫法都對,拆包通常比較好讀,因為每個值一出來就有自己的名字。
4. 集合:去掉重複、檢查有沒有、比對兩份名單
集合為什麼能自動去掉重複,而且 in 特別快?原因和第 6 課的字典相同:集合會根據元素的內容算出一個號碼(雜湊值,hash),用這個號碼決定元素放在哪一格。加入元素時,先算號碼去看那一格,已經有相同的值就不再放;查詢 x in s 時,也是算一次號碼直接過去看。串列的 in 則要從第 0 格開始一格一格比,資料越多比越久。
元素的位置由雜湊值決定,所以走訪或印出集合時的順序,和你放進去的順序沒有關係。字串的雜湊值在每次啟動 Python 時還會重新隨機(為了防範特定的攻擊手法),所以同一個字串集合,這次執行印出 {'A12', 'B07'},下次可能變成 {'B07', 'A12'}。需要固定順序時,先用 sorted() 排成串列。同樣的道理,元素必須是不可變的值:把串列加進集合會得到 TypeError: unhashable type: 'list',要放一組值就改用元組。
集合最實用的是下面這幾種運算。符號寫法與方法寫法效果相同,方法寫法讀起來比較像英文句子。
| 寫法 | 方法寫法 | 意思 | north = {A12, B07, D01}、south = {B07, C33} 的結果 |
|---|---|---|---|
a & b | a.intersection(b) | 交集:兩邊都有 | {'B07'} |
a | b | a.union(b) | 聯集:任一邊有 | {'A12', 'B07', 'C33', 'D01'}(印出順序不固定) |
a - b | a.difference(b) | 差集:a 有、b 沒有 | {'A12', 'D01'} |
a ^ b | a.symmetric_difference(b) | 對稱差:只出現在其中一邊 | {'A12', 'C33', 'D01'} |
s.add(x) | — | 加入一個元素(已存在就不變) | 就地修改,回傳 None |
s.remove(x) | — | 刪除元素,不存在就 KeyError | 就地修改 |
s.discard(x) | — | 刪除元素,不存在也不出錯 | 就地修改 |
x in s、len(s) | — | 有沒有、有幾個 | True/False、整數 |
5. 巢狀資料:一組方括號往裡走一層
讀巢狀取值的寫法,只要記住一件事:每一組方括號,都作用在前一步拿到的那個物件上。所以要從左到右一步一步問「現在手上是什麼型別」:是字典,下一步就用 ["鍵"];是串列或元組,就用 [整數];是集合,就沒有方括號可以用了。用錯了 Python 不會猜你的意思,而是直接報錯,最常見的是對串列用字串去查,得到 TypeError: list indices must be integers or slices, not str。
很長的取值可以拆成好幾行,每一步替中間結果取個名字,例如 box0 = shipment["boxes"][0],下一行再寫 box0["items"][1]。這樣不會多花記憶體,因為 = 只是多貼一張標籤(實驗室三的 first 就是這樣);但也因為是同一個物件,透過 box0 修改,shipment 裡面也會跟著變。
從網路服務拿到的 JSON 資料,在 Python 裡就是這種字典和串列組成的巢狀資料(第 6 課提過)。JSON 格式本身只有「物件」與「陣列」兩種容器,分別對應字典與串列,沒有元組和集合:元組轉成 JSON 會變成串列,集合則不能直接轉換。怎麼在文字和 Python 物件之間轉換,要用標準函式庫的 json 模組,地圖上的「json 與 csv」節點有說明。
6. 複製巢狀資料:要分開幾層,就複製幾層
第 5 課的結論在字典上完全適用:d.copy()、dict(d)、a.copy()、a[:] 都是淺複製,只做出新的外層,格子裡的箭頭照抄。巢狀資料越深,這個陷阱越容易被忽略,因為外層看起來確實是新的一份。想讓某一層完全分開,就要把那一層也複製一次,像實驗室三的第二種寫法;以後學到 import(匯入模組)之後,標準函式庫的 copy.deepcopy() 可以一次把每一層都複製出來。
7. 常見錯誤長什麼樣子
以下是 Python 3.13 實際執行的錯誤畫面(在你的電腦上,File 後面會是完整的檔案路徑)。讀法和第 10 課一樣,從最後一行往上看。
拆包的個數對不上:名字比元素多,訊息是 not enough values;名字比元素少,是 too many values。括號裡寫的 expected 是左邊名字的數量,got 是右邊實際的元素數量。
Traceback (most recent call last):
File "main.py", line 2, in <module>
code, qty, weight, note = box
^^^^^^^^^^^^^^^^^^^^^^^
ValueError: not enough values to unpack (expected 4, got 3)
Traceback (most recent call last):
File "main.py", line 2, in <module>
code, qty = box
^^^^^^^^^
ValueError: too many values to unpack (expected 2)
對集合用索引:subscriptable 的意思是「可以用方括號取值」,集合不行。
Traceback (most recent call last):
File "main.py", line 2, in <module>
print(tags[0])
~~~~^^^
TypeError: 'set' object is not subscriptable
把串列放進集合:集合的元素和字典的鍵一樣,必須是不可變的值。改成元組 ("A12", "B07") 就可以。
Traceback (most recent call last):
File "main.py", line 2, in <module>
seen.add(["A12", "B07"])
~~~~~~~~^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'list'
remove 不存在的元素:集合的 remove 找不到時丟出的是 KeyError(不是串列的 ValueError)。不確定有沒有時改用 discard(),它找不到也不會出錯。
Traceback (most recent call last):
File "main.py", line 2, in <module>
tags.remove("冷藏")
~~~~~~~~~~~^^^^^^^^
KeyError: '冷藏'
以為 {} 是空集合:scans = {} 做出的是空字典,字典沒有 add 方法。
Traceback (most recent call last):
File "main.py", line 2, in <module>
scans.add("A12")
^^^^^^^^^
AttributeError: 'dict' object has no attribute 'add'
巢狀取值少了一層:order["items"] 是串列,還沒選第幾筆就直接用鍵查。~ 標出的是已經算好的那一段(串列),^ 標出出問題的那一組方括號。
Traceback (most recent call last):
File "main.py", line 2, in <module>
print(order["items"]["sku"])
~~~~~~~~~~~~~~^^^^^^^
TypeError: list indices must be integers or slices, not str
8. 兩段完整範例
第一段把元組與集合串起來;註解裡的輸出都是 Python 3.13 實際執行的結果。
def summarize(weights): # 一次交回三個值
return len(weights), min(weights), max(weights) # 逗號打包成一個元組
count, light, heavy = summarize([2.5, 1.2, 4.0]) # 拆包:三格貼給三個名字
print(count, light, heavy) # 3 1.2 4.0
start, end = "台北", "台中" # 右邊打包、左邊拆包
start, end = end, start # 交換:右邊先算完才貼
print(start, end) # 台中 台北
rates = {("台北", "台中"): 120} # 元組不能修改,可以當字典的鍵
print(rates[(end, start)]) # 120:內容相同就查得到
scans = ["A12", "B07", "A12", "C33"] # 有重複的掃描紀錄
unique = set(scans) # 轉成集合:重複的只留一個
print(len(unique), "B07" in unique) # 3 True
north = {"A12", "B07"} # 另一份貨號集合
print(sorted(unique - north)) # ['C33']:差集,先排序再印
第二段是一張巢狀的出貨單:一層一層取值、透過另一個名字修改內層,最後確認淺複製時哪一層是共用的。for 迴圈每一圈讓 box 貼到一個箱子字典(第 8 課)。
shipment = { # 外層:字典
"id": "QS-1024",
"boxes": [ # boxes 的值:串列
{"code": "A12", "items": ["筆記本", "書籤"]}, # 串列的每一格:字典
{"code": "B07", "items": ["桌曆"]},
],
}
print(shipment["boxes"][0]["items"][1]) # 書籤:四組方括號,一層一層往下
print(len(shipment["boxes"])) # 2:箱子串列有兩格
second = shipment["boxes"][1] # 替內層字典再貼一張標籤(沒有複製)
second["items"].append("月曆") # 透過 second 修改最內層的串列
print(shipment["boxes"][1]["items"]) # ['桌曆', '月曆']:從外層看也變了
for box in shipment["boxes"]: # 每一圈,box 貼到一個箱子字典
print(box["code"], len(box["items"])) # A12 2,下一圈 B07 2
backup = shipment.copy() # 淺複製:只有最外層是新的
print(backup["boxes"] is shipment["boxes"]) # True:箱子串列兩邊共用
9. 以後會在哪裡遇到
讀 AI 與資料分析的範例程式時,這一課的三樣東西幾乎每天出現。拆包:scikit-learn 切分訓練與測試資料的 X_train, X_test, y_train, y_test = train_test_split(X, y),函式交回四份資料,左邊用四個名字一次拆開;NumPy 陣列的 arr.shape 是一個元組,常寫成 rows, cols = arr.shape。巢狀資料:呼叫大型語言模型的 API,交回來的 JSON 常要寫成 reply["choices"][0]["message"]["content"] 才拿得到文字,現在你知道它就是「字典 → 串列 → 字典 → 字典」一層一層往下走。集合:整理文字時建立不重複的詞彙表、比對兩份名單差了哪些項目。
✅ 自我檢測
6 題,每題的答案都用 Python 3.13 實際執行確認過。選完立刻顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6
Q1.下列程式印出什麼?
point = 3, 8
x, y = point
x = x + 10
print(point, x)
Q2.下列程式印出什麼?
tags = {"冷藏", "急件", "冷藏"}
tags.add("急件")
print(len(tags))
Q3.下列程式印出什麼?
def split_name(full):
return full[0], full[1:]
result = split_name("林小美")
print(result)
last, first = split_name("林小美"),才會拆成兩個名字。Q4.下列程式印出什麼?
a = {1, 2, 3, 4}
b = {3, 4, 5}
print(sorted(a - b), sorted(a & b))
a - b 是差集:在 a 裡、不在 b 裡的 1、2;a & b 是交集:兩邊都有的 3、4。sorted() 把結果排成串列,所以印出方括號。選 A 的人把差集和對稱差(^)搞混了。Q5.執行下列程式,結果是什麼?
shop = {"name": "晴空咖啡", "menu": ["拿鐵", "美式"]}
print(shop["menu"][0])
print(shop["menu"]["拿鐵"])
print(shop["name"][0])
shop["menu"] 拿到的是串列,第 2 行用 [0] 取第一格沒問題。第 3 行對串列用字串去查,串列只接受整數索引,所以是 TypeError(不是 KeyError,KeyError 是字典查不到鍵);程式在這裡停下,第 4 行不會執行。Q6.下列程式印出什麼?
inv = {"A": [1, 2]}
inv2 = inv.copy()
inv2["A"].append(3)
inv2["B"] = [9]
print(inv)