พื้นฐาน

Generator ใน Python: yield, iterator และ list/dict comprehension

อัปเดต: · Skillaria.Top

Generator ใน Python คืออ็อบเจกต์ที่ให้ค่าทีละตัวเมื่อถูกขอ โดยไม่เก็บทั้งลำดับไว้ในหน่วยความจำ สร้างได้ด้วยฟังก์ชันที่มีคำสั่ง yield หรือนิพจน์ generator ในวงเล็บ ส่วน list, dict และ set comprehension เป็นรูปแบบสั้นสำหรับสร้างคอลเลกชันสำเร็จรูปในบรรทัดเดียว เช่น [x * 2 for x in xs]

Generator ใน Python คืออ็อบเจกต์ที่ให้ค่าทีละตัวเมื่อมีการขอ และไม่เก็บลำดับทั้งหมดไว้ในหน่วยความจำ สร้างได้สองวิธี คือฟังก์ชันที่มีคำสั่ง yield หรือนิพจน์ generator ในวงเล็บ ส่วน list comprehension, dict comprehension และ set comprehension คือรูปแบบสั้น ๆ สำหรับสร้างคอลเลกชันสำเร็จรูปในบรรทัดเดียว บทความนี้จะอธิบายการทำงานของ python yield ความต่างระหว่าง generator กับ iterator และวิธีเขียน comprehension ให้ยังอ่านรู้เรื่อง

Iterator: หัวใจของลูป for

ก่อนจะเข้าใจ generator ต้องเข้าใจ iterator ก่อน Iterable คือสิ่งที่วนลูปได้ เช่น ลิสต์ สตริง ดิกชันนารี หรือไฟล์ ส่วน iterator คืออ็อบเจกต์ที่จำตำแหน่งปัจจุบันไว้ และเมื่อเรียก next() จะให้สมาชิกตัวถัดไป เมื่อสมาชิกหมดจะโยน StopIteration

nums = [10, 20, 30]
it = iter(nums)      # ขอ iterator จากลิสต์
print(next(it))      # 10
print(next(it))      # 20
print(next(it))      # 30
next(it)             # StopIteration

ลูป for ทำหน้าที่นี้เองทั้งหมด คือเรียก iter() แล้วเรียก next() ซ้ำจนเจอ StopIteration เราสามารถเขียน iterator ของตัวเองเป็นคลาสที่มีเมธอด __iter__ และ __next__ ได้:

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

print(list(Countdown(3)))  # [3, 2, 1]

วิธีนี้ใช้ได้ แต่มีโค้ดซ้ำซ้อนมาก generator ทำสิ่งเดียวกันได้ในสามบรรทัด

Generator ใน Python และการทำงานของ yield

ฟังก์ชัน generator คือฟังก์ชันปกติที่มี yield อยู่ในตัวฟังก์ชัน การเรียกใช้ไม่ได้รันโค้ดภายในทันที แต่คืนอ็อบเจกต์ generator กลับมา ทุกครั้งที่เรียก next() โค้ดจะรันไปจนถึง yield ตัวถัดไป ส่งค่าออกมา แล้วหยุดฟังก์ชันไว้พร้อมตัวแปรภายใน การเรียก next() ครั้งต่อไปจะทำงานต่อจากจุดเดิมพอดี

def countdown(start):
    while start > 0:
        yield start
        start -= 1

gen = countdown(3)
print(gen)          # <generator object countdown at 0x...>
print(next(gen))    # 3
print(list(gen))    # [2, 1] — ทำต่อจากจุดที่หยุดไว้

เมื่อฟังก์ชันทำงานจนจบ (หรือถึง return) generator จะโยน StopIteration เองโดยอัตโนมัติ และลูป for จะจบอย่างเรียบร้อย ความต่างสำคัญระหว่าง yield กับ return คือ return จบการทำงานของฟังก์ชันและคืนค่าเดียว ส่วน yield หยุดฟังก์ชันชั่วคราวและให้ค่าได้หลายครั้งตามต้องการ

ความต่างระหว่าง generator กับ iterator

Generator ทุกตัวเป็น iterator เพราะมีทั้ง __iter__ และ __next__ แต่ iterator ไม่ได้เป็น generator ทุกตัว ความต่างอยู่ที่วิธีสร้างและความสามารถ

Iterator (คลาส)Generator
วิธีสร้างคลาสที่มี __iter__ และ __next__ฟังก์ชันที่มี yield หรือ (x for x in ...)
สถานะต้องเก็บเองในแอตทริบิวต์เก็บอัตโนมัติในตัวแปรภายในฟังก์ชัน
StopIterationต้องโยนเองเกิดขึ้นเองเมื่อฟังก์ชันจบ
ความสามารถเพิ่มเติมขึ้นกับที่เขียนsend() throw() close()

ทั้งสองแบบมีคุณสมบัติสำคัญร่วมกันคือ วนได้เพียงครั้งเดียว การใช้ for ซ้ำกับ generator ที่วนหมดแล้วจะไม่ได้ค่าอะไรเลย ต้องสร้าง generator ใหม่

Generator ที่ไม่มีที่สิ้นสุดและการอ่านไฟล์ขนาดใหญ่

Generator ไม่จำเป็นต้องจบ ลำดับที่ไม่มีที่สิ้นสุดใช้ได้อย่างปลอดภัยตราบที่ดึงมาเฉพาะจำนวนจำกัด:

from itertools import islice

def ids():
    n = 1
    while True:
        yield f"ID-{n:04d}"
        n += 1

print(list(islice(ids(), 3)))  # ['ID-0001', 'ID-0002', 'ID-0003']

การใช้งานจริงที่พบบ่อยที่สุดคือประมวลผลไฟล์ขนาดใหญ่ทีละบรรทัด Generator สามารถต่อกันเป็นสายได้ ทำให้ในหน่วยความจำมีเพียงหนึ่งบรรทัดในแต่ละช่วงเวลา ไม่ใช่ทั้งไฟล์:

def read_lines(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            yield line.rstrip("\n")

def only_errors(lines):
    for line in lines:
        if "ERROR" in line:
            yield line

for line in only_errors(read_lines("app.log")):
    print(line)

yield from และ send

yield from ส่งต่อค่าทั้งหมดจากอ็อบเจกต์ที่วนซ้ำได้อีกตัว ช่วยให้ไม่ต้องเขียนลูปซ้อน:

def flatten(groups):
    for g in groups:
        yield from g

print(list(flatten([[1, 2], [3], [4, 5]])))  # [1, 2, 3, 4, 5]

เมธอด send() ส่งค่าเข้าไปยังภายใน generator ค่านั้นจะกลายเป็นผลของนิพจน์ yield ก่อนการเรียก send ครั้งแรกต้อง "สตาร์ต" generator ด้วย next() ก่อน ตัวอย่างต่อไปนี้เป็นตัวคำนวณค่าเฉลี่ยแบบสะสม:

def averager():
    total, count = 0, 0
    avg = None
    while True:
        value = yield avg
        total += value
        count += 1
        avg = total / count

a = averager()
next(a)            # ไปถึง yield แรก
print(a.send(10))  # 10.0
print(a.send(20))  # 15.0

ในโค้ดงานประจำ send แทบไม่ค่อยได้ใช้ แต่ถูกถามบ่อยในการสัมภาษณ์งาน

range เป็น generator หรือไม่

ไม่ใช่ range เป็นลำดับแบบขี้เกียจ (lazy) เช่นกัน คือไม่เก็บตัวเลขทั้งหมดไว้ แต่รองรับการเข้าถึงด้วยดัชนี len() การตรวจ in และการวนซ้ำได้หลายครั้ง ซึ่ง generator ทำไม่ได้

r = range(5)
print(r[2], len(r), 3 in r)  # 2 5 True
print(list(r), list(r))      # ได้ [0, 1, 2, 3, 4] ทั้งสองครั้ง

List comprehension

List comprehension มีรูปแบบ [นิพจน์ for x in คอลเลกชัน] ซึ่งสร้างลิสต์ใหม่โดยไม่แก้ไขลิสต์เดิม อ่านจากซ้ายไปขวา: ก่อนอื่นบอกว่าจะเก็บอะไร แล้วบอกว่าหยิบจากที่ไหน

amounts = [100, 250, 400]

# ลูปธรรมดา
with_markup = []
for a in amounts:
    with_markup.append(a * 1.2)

# เขียนใหม่ด้วย comprehension
with_markup = [a * 1.2 for a in amounts]
print(with_markup)  # [120.0, 300.0, 480.0]

แม้จะเรียกว่า "generator ของลิสต์" ผลลัพธ์เป็นลิสต์สำเร็จรูปธรรมดา ไม่ใช่ generator เพราะทุกสมาชิกถูกคำนวณทันที

List comprehension ที่มี if และ if else

จุดนี้เป็นสิ่งที่สับสนบ่อยที่สุด เงื่อนไขแบบกรองจะวางไว้ท้ายและทิ้งสมาชิกที่ไม่ตรง ส่วนเงื่อนไขแบบเลือก (ternary) จะวางไว้ต้นนิพจน์และเปลี่ยนสมาชิกทุกตัวให้เป็นค่าใดค่าหนึ่งจากสองค่า โดยไม่ทิ้งสมาชิกใด

amounts = [100, 250, 400]

big = [a for a in amounts if a > 200]                   # [250, 400]
labels = ["มาก" if a > 200 else "น้อย" for a in amounts]
# ['น้อย', 'มาก', 'มาก']

# กรองและเลือกค่าไปพร้อมกัน
odd_marks = ["คู่" if n % 2 == 0 else "คี่" for n in range(6) if n > 2]
# ['คี่', 'คู่', 'คี่']

การเขียน [a for a in amounts if a > 200 else 0] เป็นข้อผิดพลาดด้านไวยากรณ์ เพราะ else ใช้ได้เฉพาะเงื่อนไขที่อยู่ต้นนิพจน์เท่านั้น

comprehension ซ้อนกันและลิสต์สองมิติ

for ที่ซ้อนกันเขียนตามลำดับเดียวกับลูปซ้อน จากบนลงล่าง ช่วยไม่ให้สับสนลำดับเมื่อรวมลิสต์ของลิสต์:

groups = [[1, 2], [3], [4, 5]]
flat = [x for g in groups for x in g]
# เทียบเท่ากับ:
# for g in groups:
#     for x in g:
#         flat.append(x)
print(flat)  # [1, 2, 3, 4, 5]

ลิสต์สองมิติ (เมทริกซ์) สร้างได้ด้วย comprehension ซ้อนใน comprehension ข้อผิดพลาดคลาสสิกคือการคูณลิสต์ ซึ่งทำให้ทุกแถวกลายเป็นอ็อบเจกต์เดียวกัน

bad = [[0] * 3] * 2
bad[0][0] = 1
print(bad)   # [[1, 0, 0], [1, 0, 0]] — ทั้งสองแถวเปลี่ยนไปหมด

grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
print(grid)  # [[1, 0, 0], [0, 0, 0]]

# การสลับแถวกับคอลัมน์ (transpose)
m = [[1, 2, 3], [4, 5, 6]]
print([[row[i] for row in m] for i in range(3)])  # [[1, 4], [2, 5], [3, 6]]

Dict และ set comprehension

หลักการเดียวกันใช้ได้กับคอลเลกชันอื่น วงเล็บปีกกาที่มีเครื่องหมายโคลอนสร้างดิกชันนารี (dict comprehension) ส่วนที่ไม่มีโคลอนสร้างเซต (set comprehension)

spent = [("อาหาร", 500), ("แท็กซี่", 300), ("อาหาร", 200)]

categories = {cat for cat, _ in spent}
print(categories)  # {'อาหาร', 'แท็กซี่'} — เซตไม่รับประกันลำดับ

last = {cat: amount for cat, amount in spent}
print(last)        # {'อาหาร': 200, 'แท็กซี่': 300}

squares = {n: n ** 2 for n in range(4)}  # {0: 0, 1: 1, 2: 4, 3: 9}
swapped = {v: k for k, v in squares.items()}

สังเกต last: เมื่อคีย์ซ้ำ ค่าสุดท้ายจะชนะ ทำให้ยอด 500 หายไป หากต้องการรวมค่า comprehension ไม่เหมาะ ให้เขียนลูปหรือใช้ collections.Counter / defaultdict

แล้ว tuple comprehension ล่ะ

Python ไม่มี "tuple comprehension" แบบแยกต่างหาก วงเล็บกลมให้ generator expression แต่หากต้องการทูเพิล ให้ส่ง generator เข้า tuple():

t = tuple(n * 2 for n in range(3))  # (0, 2, 4)

Generator expression: ไม่เก็บ แต่ให้ทีละค่า

หากเปลี่ยนวงเล็บเหลี่ยมเป็นวงเล็บกลม จะได้ generator expression ซึ่งไม่คำนวณล่วงหน้า แต่ให้ค่าทีละตัว สำหรับข้อมูลหนึ่งล้านรายการ ความต่างด้านหน่วยความจำมหาศาล:

import sys

as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list))  # ราว 8 MB
print(sys.getsizeof(as_gen))   # ไม่กี่ร้อยไบต์

Generator expression มักใช้ร่วมกับฟังก์ชันที่รวมค่า หากมันเป็นอาร์กิวเมนต์เดียวของฟังก์ชัน ไม่ต้องใส่วงเล็บซ้อนเพิ่ม และ any() กับ all() จะหยุดทันทีที่ได้คำตอบ โดยไม่วนต่อ

spent = [("อาหาร", 500), ("แท็กซี่", 300), ("อาหาร", 200)]

total = sum(a for _, a in spent)            # 1000
has_big = any(a > 400 for _, a in spent)    # True และไม่ดูรายการต่อ
biggest = max(spent, key=lambda p: p[1])    # ('อาหาร', 500)

กฎในการเลือกง่ายมาก หากต้องใช้ผลลัพธ์หลายครั้ง หรือต้องการ indexing และ len() ให้ใช้ลิสต์ หากวนผ่านเพียงครั้งเดียวเพื่อคำนวณบางอย่าง ให้ใช้ generator

ขอบเขตของตัวแปร: ไม่รั่วออกนอก comprehension

คำถามที่ทำให้แม้แต่นักพัฒนาที่มีประสบการณ์สะดุดได้:

x = 10
squares = [x for x in range(3)]
print(x)  # 10

ตัวแปรลูปภายใน comprehension อยู่ในขอบเขตของตัวเอง จึงไม่ทับตัวแปรภายนอก (ใน Python 3 ซึ่งต่างจาก Python 2) ส่วนลูป for ธรรมดา หลังจากวนแล้ว x จะเท่ากับ 2 ตั้งแต่ Python 3.12 (PEP 709) comprehension ถูกรวมเข้ากับฟังก์ชันที่ล้อมอยู่เพื่อให้เร็วขึ้น แต่กฎเรื่องขอบเขตยังเหมือนเดิม

การแตกค่า * และ ** ใน comprehension: ของใหม่ใน Python 3.15 (PEP 798)

เครื่องหมายดอกจันสำหรับการแตกค่าใช้ได้มานานแล้วในการเรียกฟังก์ชันและลิเทอรัลลิสต์ แต่ภายใน comprehension เคยเป็น SyntaxError ใน Python 3.15 ข้อจำกัดนี้ถูกยกเลิก *element หมายถึง "ใส่สมาชิกทั้งหมดของมันเข้าที่นี่" ใช้ได้กับลิสต์ เซต และ generator รวมถึง generator แบบอะซิงโครนัส

# Python 3.15+
groups = [[1, 2], [3], [4, 5]]
flat = [*g for g in groups]        # [1, 2, 3, 4, 5]
uniq = {*g for g in groups}        # เซต
lazy = (*g for g in groups)        # generator แบบขี้เกียจ

เครื่องหมายดอกจันสองตัว ** ทำงานเช่นเดียวกันกับดิกชันนารี เหมาะกับการตั้งค่าแบบหลายชั้น "ค่าเริ่มต้น → ไฟล์ → แฟล็ก" โดยแต่ละชั้นถัดไปจะทับคีย์ของชั้นก่อนหน้า

# Python 3.15+
layers = [{"debug": False, "port": 80}, {"port": 8080}, {"debug": True}]
config = {**layer for layer in layers}
# {'debug': True, 'port': 8080}

มีสองข้อควรระวังจากคอร์ส ประการแรก ห้ามใช้เครื่องหมายดอกจันร่วมกับนิพจน์เงื่อนไขโดยไม่มีวงเล็บ เช่น [*w if w else 0 for w in weeks] เป็นข้อผิดพลาดด้านไวยากรณ์ ต้องใส่วงเล็บครอบเงื่อนไข ประการที่สอง โค้ดนี้จะรันไม่ได้บน Python 3.14 และเวอร์ชันก่อนหน้า หากไลบรารีของคุณรองรับเวอร์ชันเก่า ให้ใช้ for ซ้อนแบบเดิมหรือ itertools.chain.from_iterable

Generator แบบอะซิงโครนัส

หากฟังก์ชัน async def มี yield จะได้ generator แบบอะซิงโครนัส วนผ่านด้วย async for และภายในสามารถรอเครือข่ายหรือดิสก์ด้วย await ได้ มี async comprehension ด้วย

import asyncio

async def ticks(n):
    for i in range(n):
        await asyncio.sleep(0.1)
        yield i

async def main():
    result = [i async for i in ticks(3)]
    print(result)  # [0, 1, 2]

asyncio.run(main())

ข้อผิดพลาดที่พบบ่อย

  • comprehension ซ้อนสามชั้น มี for สองตัว if สองตัว และ ternary ในบรรทัดเดียว ไม่มีใครอ่านรู้เรื่องหลังผ่านไปหนึ่งสัปดาห์ หากเขียนในบรรทัดเดียวไม่ชัด ให้ใช้ลูปธรรมดา เพราะ PEP 8 ให้ความสำคัญกับความอ่านง่ายมากกว่าความกระชับ
  • วนซ้ำ generator ที่หมดแล้ว การเรียก sum(gen) ครั้งที่สองจะได้ 0 เพราะ generator หมดไปแล้ว
  • ใช้ comprehension เพื่อผลข้างเคียง [print(x) for x in xs] สร้างลิสต์ของ None ที่ไม่มีประโยชน์ สำหรับการกระทำให้ใช้ลูป for
  • คาดหวังว่า generator expression จะ indexing ได้ (x for x in xs)[0] เกิด TypeError เพราะ generator ไม่มีดัชนี

โจทย์ฝึกเกี่ยวกับ generator

  1. เขียน generator fib() ที่ให้เลขฟีโบนัชชีไม่สิ้นสุด แล้วดึง 10 ตัวแรกด้วย islice
  2. เขียนในบรรทัดเดียวเพื่อได้ดิกชันนารี "คำ → ความยาว" สำหรับคำที่ยาวกว่าสามตัวอักษร
  3. คำนวณผลรวมของกำลังสองของจำนวนคู่ถึงหนึ่งล้าน โดยไม่สร้างลิสต์
  4. เขียน generator chunks(seq, n) ที่ให้ลิสต์ย่อยทีละ n สมาชิก

สรุป

Iterator ให้สมาชิกทีละตัวผ่าน next() ส่วน generator คือวิธีที่ง่ายที่สุดในการเขียนมัน ไม่ว่าจะเป็นฟังก์ชันที่มี python yield หรือนิพจน์ในวงเล็บกลม list, dict และ set comprehension สร้างคอลเลกชันสำเร็จรูปในบรรทัดเดียว โดยเงื่อนไข if ที่กรองวางไว้ท้าย และ if else ที่เลือกค่าวางไว้ต้นนิพจน์ ต้องการประหยัดหน่วยความจำหรือประมวลผลข้อมูลแบบสตรีมครั้งเดียว ใช้ generator ต้องการผลลัพธ์หลายครั้ง ใช้ลิสต์ comprehension, generator และการแตกค่าใหม่จาก Python 3.15 ถูกสาธิตจริงในคอร์สผ่านตัวอย่างโปรแกรมติดตามค่าใช้จ่าย

คำถามที่พบบ่อย

ใน generator ใช้ return ได้หรือไม่?

ได้ return ที่ไม่มีค่าจะจบ generator ส่วน return ที่มีค่าจะเก็บค่านั้นไว้ในแอตทริบิวต์ value ของ StopIteration ซึ่งนิพจน์ yield from จะได้รับค่านั้น

จะแปลง generator เป็นลิสต์ได้อย่างไร?

ส่งเข้า list() เช่น list(gen) แต่หลังจากนั้น generator จะหมดแล้ว และทุกค่าจะอยู่ในหน่วยความจำ

list comprehension กับลูปที่ใช้ append อันไหนเร็วกว่า?

โดยทั่วไป comprehension เร็วกว่าเล็กน้อย เพราะไม่ต้องเรียกเมธอด append ในทุกรอบ แต่ควรเลือกตามความอ่านง่าย เพราะความต่างมักไม่ชี้ขาด

จะรู้ได้อย่างไรว่าอ็อบเจกต์เป็น generator?

ใช้ inspect.isgenerator(obj) สำหรับอ็อบเจกต์ หรือ inspect.isgeneratorfunction(f) สำหรับฟังก์ชัน ส่วนการตรวจว่าเป็น iterator ทำได้ด้วย isinstance(obj, collections.abc.Iterator)

generator expression ต่างจาก list comprehension อย่างไร?

วงเล็บเหลี่ยมสร้างลิสต์ทั้งก้อนในหน่วยความจำทันที ส่วนวงเล็บกลมสร้าง generator ที่คำนวณค่าเมื่อถูกขอ และวนได้เพียงรอบเดียว

เรียนหัวข้อนี้ผ่านวิดีโอ

หัวข้อนี้สอนอย่างละเอียดพร้อมฝึกปฏิบัติในคอร์ส “Python 3.15: от новичка до профи”

ไปที่คอร์ส