TL;DR:列表推導式
[n*n for n in range(5)]其實就跟一個 for 迴圈做一樣的事,只是把「結果」寫在最前面、「來源」丟到後面,順序剛好跟念中文相反。會看不懂多半不是因為它難,比較像是這個順序要花點時間習慣。能把它翻回 for 迴圈來看的話,大概就沒那麼可怕了。
第一次看到 [x for x in data if x > 0] 這種東西會愣一下,我覺得滿正常的。它長得不太像一般的句子,沒冒號、沒縮排,for 還跑到中間去。很多地方會直接說「這叫列表推導式(list comprehension),很 Pythonic」就帶過,可是那句話其實對看懂它沒什麼幫助,看完還是一樣霧。
所以這篇就不背語法了,從大家應該都會的 for 迴圈開始慢慢聊好了,不趕時間。
同一件事,兩種寫法
假設要做一個 0 到 4 的平方數清單。用 for 迴圈大概像這樣寫:
squares = []
for n in range(5):
squares.append(n * n)
## [0, 1, 4, 9, 16]
三行,開個空 list、跑迴圈、一個一個 append 進去。很普通,沒什麼問題,能跑就好。
換成列表推導式的話,就變這樣:
squares = [n * n for n in range(5)]
## [0, 1, 4, 9, 16]
一行,結果一樣。這倒不是我隨口說的,文末附的測試檔就是把這兩種寫法的結果丟去 assertEqual 對,跑出來是相等的。所以大概可以放心把它當成上面那段 for 迴圈的縮寫,因為它字面上差不多就是那個意思,只是擠成一行而已。
怎麼讀它:翻回 for 迴圈來看
我覺得重點在閱讀順序。推導式長這樣:
[ 運算式 for 變數 in 來源 ]
n * n for n in range(5)
拆成三塊來看的話:
for n in range(5):跟一般迴圈的開頭一樣,「從 range(5) 一個一個拿出來叫 n」n * n:每一輪要產出什麼,差不多就是append()括號裡那個東西- 外面的
[ ]:最後裝成一個 list
我猜會卡住,大概是因為眼睛習慣「先 for 再做事」,但推導式是反過來的,先寫結果、再講它從哪來。讀的時候在心裡把順序倒過來看可能會好一點:先瞄中間的 for ... in ... 知道資料哪來的,再回頭看最前面那塊。多看幾次好像就習慣了,我自己現在是不太需要停下來想。
其實如果有看過之前那篇 Python Chunks,裡面切 list 的時候就偷用過推導式([input_list[i:i+n] for i in range(0, len(input_list), n)]),只是那時候沒特別解釋。現在回去看那行,搞不好會順眼一點。
想過濾的話,if 放尾巴
推導式最後面可以接一個 if 當過濾器。比如說只想留偶數:
evens = [n for n in range(10) if n % 2 == 0]
## [0, 2, 4, 6, 8]
一樣翻回 for 迴圈看就懂了,它大概等於:
evens = []
for n in range(10):
if n % 2 == 0:
evens.append(n)
尾巴的 if 比較像一個閘門,條件成立才產出,不成立就跳過,所以結果長度會比來源短一點。這個用法我自己滿常用的,像是想從一堆東西裡撈出符合條件的那幾個,寫一行就清掉了。
比較容易搞混的:尾巴的 if 跟前面的 if/else
這個我自己覺得是最容易混的地方,分開講一下好了。上面那個 if 在尾巴,是在問「這筆要不要」。可是只要出現 if/else,位置會跑到最前面,意思也跟著不一樣了:
labels = ["fizz" if n % 2 == 0 else "buzz" for n in range(6)]
## ['fizz', 'buzz', 'fizz', 'buzz', 'fizz', 'buzz']
結果有六個、一個都沒少。因為 "fizz" if ... else "buzz" 是一個三元運算式,它本身就是「運算式」那一塊,一定會吐一個值出來,只是吐哪個看條件。所以它不是在篩選,比較像是「每筆都會產,只是長相不同」。
大概可以這樣分:
[x for x in xs if 條件],if在尾巴,是篩選,結果可能變短[a if 條件 else b for x in xs],if/else在前面,每筆都產,結果一樣長
這兩個搞混好像滿常見的,我自己偶爾也要停下來想一下到底是哪個。真的記不起來的話,就回去翻成 for 迴圈,一翻就現形了。
順帶提兩個小地方:變數不外洩,還有……其實沒快多少
有件事可能不少人沒注意到:推導式裡的迴圈變數跑完不會留在外面。跟普通 for 迴圈對照一下就看得出來:
for m in range(3):
pass
print(m) ## 2,m 還在,留在外層
_ = [n * n for n in range(3)]
print(n) ## NameError: name 'n' is not defined
普通迴圈跑完 m 會殘留在當前作用域(Python 3 一直都這樣),推導式的 n 跑完就被收掉了。少一個可能會誤用到的變數,算是個小小的好處吧,雖然平常大概也不太會去注意。
至於效能,這裡想順便講一下,因為好像有點以訛傳訛。很多舊文章會說推導式「快兩倍」,但那大概是滿多年前的數字了。我在 Python 3.14.3 上用 timeit 試了一下(range(1000)、跑兩萬次),推導式對上 append 迴圈差不多是這樣:
comprehension: 0.52s
append loop : 0.58s
loop / comp : 大概 1.1 倍
只快一成左右,比傳說中小很多。我猜是因為新版 CPython 那個 adaptive specializing interpreter(3.11 開始有的)把 append 迴圈也順便優化了。所以與其說「為了快」用推導式,不如說是「因為這樣比較好讀」才用它,那點差距在真的程式裡大概也量不太出來。
不只是 list,dict 跟 set 也可以
把外面的括號換掉,同一套順序就搬到字典跟集合上了。dict 推導式是 PEP 274 帶進來的;set 推導式的語法則是 Python 3.0 / 2.7 那時候才補上,兩個來源不太一樣,不過寫起來感覺是一致的。
word = "mississippi"
## set 推導式,順便去重
unique = {ch for ch in word}
## {'m', 'i', 's', 'p'}
## dict 推導式,key: value
counts = {ch: word.count(ch) for ch in set(word)}
## {'m': 1, 'i': 4, 's': 4, 'p': 2}
{ } 裡只放一個值就是 set,有 key: value 就是 dict。讀法跟 list 一樣,沒什麼新東西要學,就是括號換一下而已。我自己最常用的是 dict 推導式,拿來把兩個 list 兜成一個對照表很順手。
再進階一點:攤平巢狀、還有海象運算子
兩個還算常用、但有點容易寫歪的,順便講講。
攤平二維 list。多個 for 從左排到右,順序跟巢狀 for 迴圈一樣:
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]
## [1, 2, 3, 4, 5, 6, 7, 8, 9]
讀法是 for row in matrix(外層)、for x in row(內層)、然後 x(產出)。寫的順序跟巢狀迴圈由外到內一樣,被它擠在一行嚇到的話,拆開來想就還好。這個我建議真的搞不清楚就先寫普通迴圈,沒必要硬擠一行。
海象運算子 := 是 PEP 572 帶進來的,Python 3.8 之後才有。如果你又想過濾、又想用「過濾時順便算出來的那個值」,它可以讓你只算一次:
data = [" 10 ", "x", " 20", "", "30 "]
def parse(s):
s = s.strip()
return int(s) if s.isdigit() else None
cleaned = [v for s in data if (v := parse(s)) is not None]
## [10, 20, 30]
(v := parse(s)) 把結果存進 v,順便讓尾巴的 if 拿去判斷,這樣就不用 parse() 跑兩遍。滿方便的,不過老實說這也差不多是可讀性開始往下掉的訊號了,要不要用自己感覺一下,我自己是會稍微猶豫。
什麼時候可能不太適合用
推導式我覺得不是越多越好,有時候硬要用反而把事情弄複雜。下面幾種狀況,我自己會傾向退回普通 for 迴圈:
- 巢狀超過兩層、或夾好幾個 if:一行塞太滿,可能過陣子連自己都讀不太懂。讀不懂的話,用它好像就有點失去意義了。
- 每一輪有副作用:像寫檔、
print、發 request 那種。推導式本來比較像是拿來「生一個新集合」用的,如果只是為了做一串動作而寫成[do(x) for x in xs],還會順手做出一個你根本不要的 list,有點浪費。 - 邏輯複雜到要中間變數、try/except:這些推導式裡塞不太進去,硬塞通常只會更難看。
大概的判斷方式:這行寫出來,旁邊的人掃一眼讀得懂嗎?讀不懂的話拆開可能比較舒服。Python 之禪那句「Readability counts」,在這種地方我覺得是比效能值錢一點的。
附:剛剛說的那個測試檔
前面講「推導式跟 for 迴圈結果一樣」的時候,說有丟去對過。就是這個,放這邊給有興趣的人看一下,其實也沒幾行。在 Python 3.14.3 上 python -m unittest 跑是全綠的。
import unittest
def by_loop():
out = []
for n in range(5):
out.append(n * n)
return out
def by_comprehension():
return [n * n for n in range(5)]
class TestSame(unittest.TestCase):
def test_two_ways_match(self):
# 同一件事的兩種寫法, 結果應該一模一樣
self.assertEqual(by_loop(), by_comprehension())
self.assertEqual(by_comprehension(), [0, 1, 4, 9, 16])
def test_tail_if_filters(self):
# 尾巴的 if 是篩選, 偶數留下來
self.assertEqual([n for n in range(10) if n % 2 == 0], [0, 2, 4, 6, 8])
def test_if_else_keeps_length(self):
# if/else 在前面, 每筆都產, 長度不變
labels = ["fizz" if n % 2 == 0 else "buzz" for n in range(6)]
self.assertEqual(len(labels), 6)
if __name__ == "__main__":
unittest.main()
沒什麼特別的,就是把「兩種寫法等價」「尾巴 if 會篩短」「前面 if/else 不改長度」這三件前面講過的事,用 assertEqual 釘住而已。哪天 Python 改版改壞了,這個會先跳給你看。
小結
列表推導式好像也沒那麼玄,大致上就是 for 迴圈的縮寫,差別主要在閱讀順序,結果在前、來源在後。先看中間的 for ... in ... 找來源,再看最前面那塊看每筆怎麼變,然後留意一下 if 在尾巴是過濾、if/else 在前面是每筆都產。這幾個搞清楚之後,dict、set、巢狀、海象大概都是同一套順序的延伸而已,不算另外的東西。
想再翻翻 Python 其他語法小品的話,這幾篇可以順手看看:Python lambda(推導式裡常一起出現的匿名函式)、Python Iterable(推導式的「來源」到底能放哪些東西)、Python f-string(另一個讓程式變短的小工具),還有把它拿去切資料的 Python Chunks。
想看英文版的話,這裡也有一篇 English version of this post。
本文範例都在 Python 3.14.3 上跑過;效能數字是用 timeit 量的,換機器應該會有出入。想看源頭的話:PEP 202 — List Comprehensions、Python 官方教學 5.1.3。