ข้ามไปยังเนื้อหา

Comprehensions

comprehension คือนิพจน์ที่กระชับและอ่านง่าย ใช้สร้าง collection ใหม่โดยการแปลงหรือกรอง iterable Python มีสามรูปแบบ: list, dict และ set comprehension บวกกับนิพจน์ที่คล้ายกันอีกแบบที่สร้าง lazy iterator แทน collection — generator expression

รูปแบบทั่วไปคือ:

[expression for item in iterable]
[expression for item in iterable if condition]
squares = [x**2 for x in range(1, 8)]
# [1, 4, 9, 16, 25, 36, 49]
evens = [x for x in range(20) if x % 2 == 0]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

ทั้งสองตัวอย่างเทียบเท่ากับ for loop ที่ append ลง list แต่กระชับกว่าและมักเร็วกว่า ทั้งสองแบบ compile เป็น bytecode ที่ interpreter ตัวเดียวกันรัน (comprehension ไม่ใช่ “โค้ด C”) ที่เร็วกว่ามาจากสองเรื่อง: (1) comprehension ใช้ opcode LIST_APPEND โดยตรง แทนที่จะต้อง LOAD_ATTR + CALL เพื่อค้นหาและเรียก .append ทุกรอบแบบ loop และ (2) ตั้งแต่ Python 3.12 (PEP 709) comprehension ถูก inline เข้า frame เดียวกันกับที่ล้อมรอบ ไม่ต้อง compile เป็น function call แยกต่างหาก

import dis
def comp():
return [x**2 for x in range(5)]
def loop():
result = []
for x in range(5):
result.append(x**2)
return result

opcode ของ inner loop จาก dis.dis(comp) และ dis.dis(loop) บน 3.14 ที่รันในเครื่องจริง ยืนยันเรื่องนี้:

comp(): FOR_ITER -> STORE_FAST_LOAD_FAST (x, x) -> LOAD_SMALL_INT 2 -> BINARY_OP 8 (**) -> LIST_APPEND 2 -> JUMP_BACKWARD
loop(): FOR_ITER -> STORE_FAST (x) -> LOAD_FAST_BORROW (result) -> LOAD_ATTR (append + NULL|self) -> LOAD_FAST_BORROW (x) -> LOAD_SMALL_INT 2 -> BINARY_OP 8 (**) -> CALL 1 -> POP_TOP -> JUMP_BACKWARD

ตั้งแต่ PEP 709 (Python 3.12) ได้ทำให้เป็นทางการในสิ่งที่ถูกต้องอยู่แล้วสำหรับพฤติกรรม runtime ตั้งแต่ Python 3: ตัวแปร for ของ comprehension อยู่ใน scope ที่ซ่อนของตัวเอง มันไม่เคยเขียนทับตัวแปรชื่อเดียวกันใน function หรือ module ที่ล้อมรอบเลย:

x = 'outer'
squares = [x for x in range(5)]
print(squares) # [0, 1, 2, 3, 4]
print(x) # 'outer' — completely unaffected by the comprehension's own x

นี่คือการเปลี่ยนพฤติกรรมจริงจาก Python 2 มาเป็น 3: ใน Python 2 ตัวแปร loop ของ list comprehension รั่ว เข้า scope ที่ล้อมรอบได้ ถ้าเคยอ่านโค้ดเก่าที่พึ่งพาค่าสุดท้ายของตัวแปร loop ให้รอดหลัง comprehension โค้ดนั้นเขียนไว้สำหรับ Python 2 และต้องใช้ for loop แบบชัดเจน (หรือ walrus :=) ภายใต้ Python 3

สามารถซ้อน for หลายตัวเพื่อวนซ้ำข้าม combination ประโยค for อ่านจากซ้ายไปขวา — for ซ้ายสุดเป็น outer loop:

pairs = [
(x, y)
for x in range(1, 4)
for y in range(1, 4)
if x != y
]
# [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]

การอ่านมันเป็น for statement ที่ซ้อนกันเขียนบนบรรทัดเดียว ทำให้ลำดับชัดเจนไม่คลุมเครือ: ประโยค for แรกกลายเป็น loop ชั้นนอกสุด เหมือนกับ for x in range(1, 4): for y in range(1, 4): ... เป๊ะ ๆ

เปลี่ยน [] เป็น () นิพจน์คล้าย comprehension จะกลายเป็น generator expression — มันสร้างค่าทีละตัวตามที่ถูกขอ แทนที่จะสร้าง collection ทั้งหมดในหน่วยความจำล่วงหน้า:

import sys
list_version = [n * n for n in range(100_000)]
gen_version = (n * n for n in range(100_000))
print(sys.getsizeof(list_version)) # a large number of bytes — every element is materialised
print(sys.getsizeof(gen_version)) # a small, constant number of bytes — no elements exist yet

ต้นทุนของความ lazy นี้: generator จะหมดสภาพหลังวนครบหนึ่งรอบ เรียก sum() บนมันสองครั้งจะไม่ได้คำตอบเดิมสองครั้ง:

gen = (n for n in range(5))
print(sum(gen)) # 10
print(sum(gen)) # 0 — the generator is already exhausted, nothing left to sum

ใช้ generator expression เมื่อต้องการวนซ้ำแค่ครั้งเดียวกับ sequence ขนาดใหญ่หรือไม่จำกัด ใช้ list (หรือ set/dict) comprehension เมื่อต้องวนซ้ำมากกว่าหนึ่งครั้ง, index เข้าไปในผลลัพธ์, หรือตรวจสอบความยาวของมัน

สองอย่างนี้หน้าตาคล้ายกันแต่ทำงานต่างกัน if clause หลัง for กรองว่า item ไหนจะถูกเก็บไว้เลย:

nums = [1, 2, 3, 4, 5, 6]
filtered = [n for n in nums if n % 2 == 0]
# [2, 4, 6] — odd numbers are dropped entirely

conditional expression (a if condition else b) ก่อน for เก็บทุก item ไว้ แต่แปลงค่าต่างกันไปตามเงื่อนไข:

transformed = [n if n % 2 == 0 else -n for n in nums]
# [-1, 2, -3, 4, -5, 6] — every element survives, some are negated

ทั้งสองแบบใช้ร่วมกันได้: กรองด้วย if หลัง for แปลงด้วย if/else ในนิพจน์ก่อน for:

both = [n * 10 for n in nums if n % 2 == 0]
# [20, 40, 60] — filtered AND transformed

ใช้ {} และ syntax key: value

word_len = {word: len(word) for word in ['apple', 'banana', 'cherry']}
# {'apple': 5, 'banana': 6, 'cherry': 6}

Dict comprehension เป็นวิธีที่สะอาดสำหรับการกลับ mapping, กรอง entry หรือแปลง value:

prices = {'apple': 1.5, 'banana': 0.75, 'cherry': 3.0}
expensive = {k: v for k, v in prices.items() if v >= 1.5}
# {'apple': 1.5, 'cherry': 3.0}

Syntax เหมือน list comprehension แต่ใช้ {} ค่าซ้ำถูกลบออกอัตโนมัติ

unique_lens = {len(word) for word in ['apple', 'banana', 'cherry', 'date']}
# {4, 5, 6} (order not guaranteed)

หน้าที่ทั้งหมดของ comprehension คือสร้าง collection ถ้า “ค่า” ของแต่ละนิพจน์ถูกทิ้งไป และเหตุผลเดียวที่เขียนมันคือ side effect (print, append ลง structure อื่น, เขียนไฟล์) comprehension ก็ยังคงสร้าง — แล้วทิ้งทันที — list ของสิ่งที่ side effect นั้นคืนค่ามา:

wasted = [print(f'item {n}') for n in range(3)]
# item 0
# item 1
# item 2
print(wasted) # [None, None, None] — a throwaway list nobody asked for

print() คืนค่า None ดังนั้น wasted คือ list ของ None สามตัวที่ไม่มีเหตุผลจะมีอยู่ for loop ธรรมดาสื่อ intent ตรงไปตรงมาและไม่ allocate อะไรเพิ่มเลย:

for n in range(3):
print(f'item {n}')
# --- list comprehension ---
squares = [x**2 for x in range(1, 8)]
print('squares:', squares)
evens = [x for x in range(20) if x % 2 == 0]
print('evens:', evens)
# --- nested comprehension ---
pairs = [(x, y) for x in range(1, 4) for y in range(1, 4) if x != y]
print('pairs:', pairs)
# --- dict comprehension ---
word_len = {word: len(word) for word in ['apple', 'banana', 'cherry']}
print('word lengths:', word_len)
# --- set comprehension ---
unique_lens = {len(word) for word in ['apple', 'banana', 'cherry', 'date']}
print('unique lengths:', sorted(unique_lens))

playground ด้านล่างคือหัวข้อความลึกด้านบนที่รันจริง — scope ไม่รั่ว, หน่วยความจำของ generator เทียบ list, การวนรอบที่สองของ generator ที่หมดสภาพแล้ว, filter เทียบ transform, ลำดับ nested, และ comprehension ที่ถูกใช้ผิดเพื่อ side effect:

import sys
# --- comprehension scope does not leak (Python 3) ---
x = 'outer'
squares = [x for x in range(5)]
print('squares:', squares)
print('x after list comprehension:', x)
# --- generator expression: lazy, tiny memory footprint ---
list_version = [n * n for n in range(100_000)]
gen_version = (n * n for n in range(100_000))
print('list size (bytes):', sys.getsizeof(list_version))
print('generator size (bytes):', sys.getsizeof(gen_version))
print('sum from generator:', sum(gen_version))
print('sum from exhausted generator (again):', sum(gen_version))
# --- filter (if clause) vs conditional expression (if/else) ---
nums = [1, 2, 3, 4, 5, 6]
filtered = [n for n in nums if n % 2 == 0]
print('filter only (keeps some):', filtered)
transformed = [n if n % 2 == 0 else -n for n in nums]
print('conditional expr (keeps all, transforms):', transformed)
# combining both: filter AND transform
both = [n * 10 for n in nums if n % 2 == 0]
print('filter + transform:', both)
# --- nested comprehension evaluation order ---
pairs = [(x, y) for x in range(1, 3) for y in ['a', 'b']]
print('nested order (x outer, y inner):', pairs)
# --- when not to use a comprehension: side effects ---
# This "works" but builds and immediately discards a list of Nones just
# to run print() as a side effect -- wasteful and confusing to read.
wasted = [print(f'item {n}') for n in range(3)]
print('comprehension used for side effects returns:', wasted)
flowchart LR
  L["list comprehension: n*n for n in range(N)"] --> LM["builds every element now<br/>memory grows with N"]
  G["generator expression: n*n for n in range(N)"] --> GM["builds nothing yet<br/>constant, tiny memory"]
  GM --> Next["next value computed only when pulled<br/>by for, sum, next, etc."]
  Next --> Done["exhausted after one full pass<br/>a second pass yields nothing"]
list comprehension จ่ายต้นทุนหน่วยความจำล่วงหน้าทันที ส่วน generator expression เลื่อนทุก element ออกไปจนกว่าจะมีอะไรมาขอจริง ๆ แล้วก็เล่นซ้ำไม่ได้อีก
สิ่งที่ได้ประโยชน์ต้นทุน
list/dict/set comprehensionนิพจน์เดียวบอกว่า “สร้างสิ่งนี้จากสิ่งนั้น”; มักเร็วกว่า loop ที่ใช้ .append()สร้าง collection ทั้งหมดในหน่วยความจำทันที
generator expression (...)หน่วยความจำแทบเป็นศูนย์ สร้างค่าตามที่ถูกขอผ่านได้ครั้งเดียว — หมดสภาพหลังวนครบหนึ่งรอบ index หรือ len() ไม่ได้
if clause (filter)ตัด item ที่ไม่ต้องการออกทั้งหมดเปลี่ยนความยาวของผลลัพธ์ — สับสนกับ transform ได้ง่าย
if/else expression (transform)เก็บทุก item ไว้ เปลี่ยนแค่ค่าไม่กรอง — ทุก item ที่ป้อนเข้ายังปรากฏในผลลัพธ์เสมอ
  • “ตัวแปร loop ของ comprehension รั่วเข้า scope ที่ล้อมรอบ” — ความจริง: ใน Python 3 ตัวแปร for ของ comprehension เป็น local ของ comprehension เอง ตัวแปรชื่อเดียวกันข้างนอกไม่ถูกแตะต้อง
  • “(x for x in ...) กับ [x for x in ...] เหมือนกันแค่วงเล็บต่างกัน” — ความจริง: รูปแบบวงเล็บกลมคือ generator expression — lazy, ผ่านได้ครั้งเดียว และใช้หน่วยความจำเกือบคงที่ ต่างจาก list ที่มันดูคล้ายกันผิวเผิน
  • “comprehension เร็วเพราะรันด้วย C ต่างจาก for loop” — ความจริง: ทั้งสองแบบ compile เป็น Python bytecode ที่ interpreter ตัวเดียวกันรัน ความเร็วที่เพิ่มมาจาก opcode LIST_APPEND เฉพาะทางและ (3.12+) การ inline frame ไม่ใช่จาก execution engine ที่ต่างกัน
  • “[x for x in items if cond] กับ [x if cond else default for x in items] ทำงานเหมือนกัน” — ความจริง: อันแรกกรอง (ผลลัพธ์สั้นกว่า items ได้) อันที่สองแปลงทุก item และไม่เคยเปลี่ยนความยาวของผลลัพธ์
  • “sum() (หรือวนซ้ำ) generator ได้มากกว่าหนึ่งครั้งเหมือน list” — ความจริง: generator เป็น iterator แบบใช้ครั้งเดียว พอหมดสภาพแล้ว การผ่านครั้งต่อไปจะไม่ได้อะไรเลย
  • โค้ดสร้าง generator expression แล้วส่งให้มากกว่าหนึ่ง function ที่แต่ละตัวบริโภคมันจนหมด (เช่น sum(gen) ตามด้วย max(gen), หรือสอง for loop แยกกันบน gen เดียวกัน) หรือเปล่า? มีแค่ผู้บริโภครายแรกเท่านั้นที่เห็นค่าใด ๆ
  • comprehension มีจุดประสงค์เดียวคือ side effect หรือเปล่า — [fn(x) for x in items] ที่ผลลัพธ์ไม่เคยถูก assign หรือ assign แล้วไม่เคยถูกใช้? นั่นควรเป็น for loop ธรรมดา
  • range() ขนาดใหญ่หรือการอ่านไฟล์ถูกครอบด้วย [...] (list comprehension) ทั้งที่ต้องการแค่ผ่านเชิงเส้นครั้งเดียวหรือเปล่า? generator expression (หรือ generator เปล่า ๆ ไม่ต้องมี comprehension เลยสำหรับ sum(x for x in ...)) จะใช้หน่วยความจำน้อยกว่ามาก
  • conditional expression แบบ if/else ถูกใช้ในที่ที่โค้ดต้องการกรอง (ตัด) item ออก หรือ if filter clause ถูกใช้ในที่ที่โค้ดต้องการแปลงทุก item หรือเปล่า? ตรวจว่าความยาวของผลลัพธ์ควรตรงกับความยาวของ input หรือไม่
  • ลำดับของ nested comprehension (for x ... for y ...) ตรงกับ outer/inner loop ที่ผู้เขียนตั้งใจไว้ หรือผู้เขียนสมมติว่ามันอ่านจากขวาไปซ้ายหรือเปล่า?

🐛 หา bug ในโค้ดนี้

def process(data):
filtered = (x for x in data if x > 0)
total = sum(filtered)
count = sum(1 for _ in filtered)
return total, count
print(process([1, -2, 3, -4, 5]))
เฉลย

filtered คือ generator expression ไม่ใช่ list — มันวนซ้ำได้แค่ครั้งเดียว sum(filtered) บริโภคมันจนหมดตอนคำนวณ total ดังนั้นพอถึงเวลา sum(1 for _ in filtered) รัน filtered ก็หมดสภาพไปแล้วและให้ item เป็นศูนย์ ตรวจสอบแล้วบน Python 3.14: โค้ดนี้พิมพ์ (9, 0) ไม่ใช่ (9, 3) — ไม่มี exception เกิดขึ้นเลย ซึ่งเป็นสิ่งที่ทำให้ bug นี้อันตราย มันล้มเหลวแบบเงียบ ๆ วิธีแก้คือทำให้ค่าที่กรองแล้วเป็นรูปธรรมครั้งเดียว — filtered = [x for x in data if x > 0] — เพื่อให้ทั้ง sum(filtered) และ len(filtered) (ซึ่งแทนที่ sum(1 for _ in ...) ตัวที่สองไปเลย) วนซ้ำมันได้อย่างอิสระต่อกัน

[x**2 for x in range(4)] ได้ผลลัพธ์อะไร?
โค้ดนี้พิมพ์อะไร? x = 'outer' result = [x * 2 for x in [1, 2, 3]] print(x)
โค้ดนี้พิมพ์อะไร? gen = (n for n in range(3)) print(sum(gen)) print(sum(gen))
นิพจน์ใด FILTER item (ให้ผลลัพธ์สั้นลงได้) แทนที่จะแปลงทุก item?
ใน [(x, y) for x in range(2) for y in range(2)] loop ไหนรันอยู่ชั้นนอก?
function หนึ่งต้องวนซ้ำ sequence ที่กรองแล้วสองครั้งพอดี (ครั้งหนึ่งเพื่อรวม อีกครั้งเพื่อนับ) แบบไหนถูกต้อง?