Bab 30 — Iterator dan Generator: Membaca Satu per Satu, Tanpa Menumpuk

Seri I4 — Bahasa Python Tingkat Lanjut

CatatanDi bab ini, Anda akan:
  • Memahami apa yang sebenarnya dilakukan perulangan for (protokol iterasi)
  • Membuat iterator sendiri dan generator dengan yield
  • Memakai ekspresi generator dan itertools untuk data “malas” (lazy)
  • Membuktikan bahwa generator menghemat memori
  • Membaca berkas data besar baris demi baris: proyek Seri I4 dimulai

Semua skrip bab ini ada di folder kode/bab30/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Seri I1 sampai I3 membuat kode Anda rapi dan hasilnya bisa dipertanggungjawabkan. Di Seri I4, kita memperkuat bahasa Python itu sendiri: fitur-fitur yang membuat kode lebih hemat, lebih ringkas, dan lebih tahan banting. Kita mulai dari yang paling sering dipakai diam-diam: perulangan.

Bayangkan seorang mandor yang mencatat panen. Cara pertama: ia menunggu semua karung selesai diangkut, menumpuknya di gudang, baru mulai mencatat. Cara kedua: ia mencatat satu karung setiap kali ada yang datang, lalu karung itu dilepas. Cara pertama butuh gudang besar. Cara kedua hanya butuh meja kecil. Bab ini tentang cara kedua.

List menyimpan semua isi sekaligus; generator hanya menyimpan satu isi pada satu saat.

Apa yang dikerjakan for: protokol iterasi

Perulangan for bekerja pada apa pun yang bisa diiterasi (iterable): list, tuple, dict, string, berkas. Di baliknya ada dua langkah: Python meminta iterator (semacam penanda bacaan) lewat iter(), lalu terus meminta isi berikutnya lewat next() sampai muncul sinyal StopIteration.

01_protokol_iterasi.py
# Bab 30 - Apa yang sebenarnya dikerjakan perulangan for
kolam = ["A", "B", "C"]

pengulang = iter(kolam)       # minta "penunjuk bacaan" dari list
print(type(pengulang).__name__)
print(next(pengulang))
print(next(pengulang))
print(next(pengulang))

try:
    next(pengulang)           # sudah habis
except StopIteration:
    print("StopIteration: tidak ada isi lagi")

# for melakukan hal yang sama, dan menangkap StopIteration untuk Anda
for nama in kolam:
    print("Kolam", nama)
list_iterator
A
B
C
StopIteration: tidak ada isi lagi
Kolam A
Kolam B
Kolam C
  • iter(kolam) memberi objek bertipe list_iterator, penanda bacaan atas list itu.
  • Tiga panggilan next() memberi A, B, C. Panggilan keempat melempar StopIteration, artinya “habis”.
  • Perulangan for melakukan persis itu, dan menangkap StopIteration untuk Anda, sehingga Anda tidak pernah melihatnya.
CatatanINGAT

Iterable = benda yang bisa diminta iterator-nya (iter()). Iterator = benda yang bisa diminta isi berikutnya (next()). Iterator sekali jalan: setelah habis, ia tidak mundur.

Iterator buatan sendiri dengan kelas

Agar objek buatan Anda bisa dipakai di for, kelasnya cukup punya dua metode: __iter__ (mengembalikan iterator) dan __next__ (memberi isi berikutnya, atau melempar StopIteration).

02_kelas_iterator.py
# Bab 30 - Membuat iterator sendiri dengan kelas
class HitungMundur:
    """Menghitung mundur hari sampai panen."""

    def __init__(self, hari):
        self.hari = hari

    def __iter__(self):
        return self

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


for sisa in HitungMundur(3):
    print("Sisa", sisa, "hari")

hitung = HitungMundur(2)
print(list(hitung))
print(list(hitung))   # sudah habis: kosong
Sisa 3 hari
Sisa 2 hari
Sisa 1 hari
[2, 1]
[]

Perhatikan bagian akhirnya: list(hitung) pertama menghasilkan [2, 1], tetapi yang kedua []. Iterator-nya sudah habis dibaca. Ini sifat dasar iterator, dan akan muncul lagi sebagai jebakan di bawah.

Generator: cara pendek membuat iterator

Menulis kelas hanya untuk menghitung mundur itu repot. Python punya jalan pintas: fungsi biasa yang memakai yield menjadi fungsi generator. yield berarti “berikan nilai ini, lalu jeda di sini sampai diminta lagi”.

03_generator_yield.py
# Bab 30 - Fungsi generator dengan yield
def hitung_mundur(hari):
    print("  (mulai)")
    while hari > 0:
        yield hari
        print("  (lanjut setelah yield)")
        hari -= 1
    print("  (selesai)")


gen = hitung_mundur(2)
print("Generator dibuat, belum ada yang jalan")
print("nilai pertama:", next(gen))
print("nilai kedua:", next(gen))
try:
    next(gen)
except StopIteration:
    print("StopIteration")
Generator dibuat, belum ada yang jalan
  (mulai)
nilai pertama: 2
  (lanjut setelah yield)
nilai kedua: 1
  (lanjut setelah yield)
  (selesai)
StopIteration

Telusuri urutannya, karena di sinilah letak sihirnya:

  1. hitung_mundur(2) belum menjalankan apa-apa. Hanya terbentuk objek generator. Itu sebabnya “(mulai)” belum tercetak saat “Generator dibuat”.
  2. next(gen) pertama menjalankan fungsi dari awal sampai yield 2, lalu berhenti. Tercetak “(mulai)” lalu nilainya, 2.
  3. next(gen) kedua melanjutkan tepat setelah yield tadi: “(lanjut setelah yield)”, hari turun menjadi 1, lalu yield 1.
  4. next(gen) ketiga melanjutkan, while selesai, tercetak “(selesai)”, dan fungsi berakhir, sehingga muncul StopIteration.

Generator adalah fungsi yang mengingat posisinya. Semua variabel lokalnya tetap hidup di antara dua yield.

Ekspresi generator dan bukti hemat memori

Seperti list comprehension (Bab 9), ada versi generatornya: ganti kurung siku dengan kurung biasa. Hasilnya bukan list, melainkan generator yang menghitung isi saat diminta.

04_ekspresi_generator.py
# Bab 30 - Ekspresi generator dan penghematan memori
import sys
import tracemalloc

kuadrat_list = [n * n for n in range(1_000_000)]   # kurung siku: semua dibuat sekarang
kuadrat_gen = (n * n for n in range(1_000_000))    # kurung biasa: dibuat satu per satu

print("jumlah list     :", sum(kuadrat_list))
print("jumlah generator:", sum(kuadrat_gen))
del kuadrat_list

# Ukuran objek generator tetap kecil, berapa pun banyaknya isi
kecil = (n for n in range(10))
besar = (n for n in range(10_000_000))
print("ukuran sama untuk 10 dan 10 juta isi:", sys.getsizeof(kecil) == sys.getsizeof(besar))


def puncak_memori(pembuat):
    tracemalloc.start()
    hasil = sum(pembuat())
    _, puncak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return hasil, puncak


_, puncak_list = puncak_memori(lambda: [n for n in range(500_000)])
_, puncak_gen = puncak_memori(lambda: (n for n in range(500_000)))
print("list memakai memori puncak lebih dari 100x generator:", puncak_list > 100 * puncak_gen)
jumlah list     : 333332833333500000
jumlah generator: 333332833333500000
ukuran sama untuk 10 dan 10 juta isi: True
list memakai memori puncak lebih dari 100x generator: True
  • Kedua cara menghasilkan jumlah yang sama, 333332833333500000: hasilnya tidak berubah, hanya cara kerjanya.
  • Objek generator berukuran sama (sys.getsizeof sama) baik untuk 10 isi maupun 10 juta isi. Ia tidak menyimpan isinya, hanya “resep” cara membuatnya.
  • tracemalloc (alat bawaan untuk mencatat pemakaian memori) menunjukkan bahwa memori puncak list lebih dari 100 kali memori puncak generator untuk 500.000 angka.

Perhatikan bahwa program ini tidak mencetak angka byte, hanya perbandingan (True). Ukuran persisnya berubah antar versi Python dan antar komputer. Perbandingan “jauh lebih besar” tetap benar di mana pun.

TipTIPS

Kalau Anda hanya perlu melewati data sekali (menjumlahkan, mencari yang terbesar, menyaring), jangan buat list dulu. sum(x * x for x in data) lebih hemat dan tidak butuh kurung siku.

Generator lebih jauh: itertools, yield from, dan jebakan

05_generator_lanjut.py
# Bab 30 - itertools, yield from, dan jebakan generator habis
from itertools import islice


def bilangan_tak_hingga():
    n = 1
    while True:          # tidak pernah berhenti, tetapi aman karena malas
        yield n
        n += 1


print(list(islice(bilangan_tak_hingga(), 5)))   # ambil 5 saja


def semua_kolam():
    yield from ["A1", "A2"]
    yield from ["B1"]


print(list(semua_kolam()))

# Jebakan: generator hanya bisa dibaca SEKALI
panen = (kg for kg in [245.5, 310.0, 198.5])
print("pertama:", sum(panen))
print("kedua  :", sum(panen))   # kosong, bukan error
[1, 2, 3, 4, 5]
['A1', 'A2', 'B1']
pertama: 754.0
kedua  : 0
  • Generator bilangan_tak_hingga() tidak pernah berhenti, tetapi aman: ia hanya bekerja bila diminta. itertools.islice(..., 5) mengambil lima isi pertama lalu berhenti meminta.
  • yield from meneruskan semua isi dari iterable lain, jadi semua_kolam() menggabungkan dua daftar.
  • Jebakan: sum(panen) pertama memberi 754,0, tetapi yang kedua memberi 0. Generator hanya bisa dibaca sekali. Tidak ada galat, hanya hasil kosong, jadi bug ini licin. Bila data perlu dibaca dua kali, buat generator baru atau simpan ke list.

Merangkai generator menjadi pipa

Kekuatan terbesar generator: beberapa generator bisa disambung seperti pipa air. Setiap tahap mengerjakan satu tugas kecil, dan data mengalir satu isi per satu isi.

06_pipa_generator.py
# Bab 30 - Merangkai generator menjadi pipa
catatan = ["kolam,A,540.5", "kolam,B,rusak", "kolam,C,498.0", "", "kolam,D,610.0"]


def tanpa_kosong(baris):
    for b in baris:
        if b.strip():
            yield b


def pecah(baris):
    for b in baris:
        yield b.split(",")


def hanya_angka(rekaman):
    for sektor, unit, nilai in rekaman:
        try:
            yield unit, float(nilai)
        except ValueError:
            print("  dilewati:", unit, nilai)


pipa = hanya_angka(pecah(tanpa_kosong(catatan)))
print("pipa dibuat, belum ada yang diproses")
for unit, bobot in pipa:
    print(unit, bobot)
pipa dibuat, belum ada yang diproses
A 540.5
  dilewati: B rusak
C 498.0
D 610.0

Saat pipa dibuat, belum ada yang diproses (itu sebabnya “pipa dibuat, belum ada yang diproses” tampil lebih dulu). Pekerjaan baru terjadi di for. Baris kosong disaring oleh tanpa_kosong, baris kolam,B,rusak dilewati oleh hanya_angka (dengan pesan “dilewati”), dan sisanya mengalir sampai ke ujung. Tidak ada list perantara.

Proyek Bersambung I4: membaca berkas data besar

Proyek Seri I4 menguatkan kode lapangan Anda, bab demi bab. Langkah pertama: membaca berkas catatan lapangan yang besar. Skrip di bawah membuat berkas tiruan (angka contoh, fiktif) lima sektor (hutan, kebun, sawah, kolam, kandang) berisi 300.000 baris, besarnya lebih dari 5 MB, lalu membacanya dengan generator.

%%{init: {"flowchart": {"diagramPadding": 40}}}%%
flowchart LR
  A["Berkas CSV<br/>300.000 baris"] --> B["Generator<br/>baca_rekaman()"]
  B --> C["Hitung jumlah dan<br/>cacah per sektor"]
  C --> D["Rata-rata<br/>per sektor"]

Alur proyek: berkas besar dibaca satu baris per satu, dirangkum, tanpa menyimpan semuanya.

07_proyek_baca_besar.py
# Bab 30 - Proyek bersambung I4: membaca berkas besar dengan generator
import random
import tracemalloc
from pathlib import Path

folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
berkas = folder / "lapangan_besar.csv"

# 1) Buat berkas tiruan (angka contoh, fiktif)
rentang = {"hutan": (20, 60), "kebun": (150, 350), "sawah": (3000, 7000),
           "kolam": (400, 700), "kandang": (300, 450)}
acak = random.Random(42)
with open(berkas, "w", encoding="utf-8") as f:
    f.write("sektor,unit,hari,nilai\n")
    for i in range(300_000):
        sektor = list(rentang)[i % 5]
        bawah, atas = rentang[sektor]
        f.write(f"{sektor},{sektor[0].upper()}{i % 7 + 1},{i // 5 + 1},{acak.uniform(bawah, atas):.1f}\n")
print("ukuran berkas lebih dari 5 MB:", berkas.stat().st_size > 5_000_000)


# 2) Generator membaca baris demi baris
def baca_rekaman(jalur):
    with open(jalur, encoding="utf-8") as f:
        next(f)                      # lewati judul kolom
        for baris in f:
            sektor, unit, hari, nilai = baris.strip().split(",")
            yield sektor, unit, int(hari), float(nilai)


def rata_rata_per_sektor(jalur):
    jumlah, cacah = {}, {}
    for sektor, _, _, nilai in baca_rekaman(jalur):
        jumlah[sektor] = jumlah.get(sektor, 0) + nilai
        cacah[sektor] = cacah.get(sektor, 0) + 1
    return {s: jumlah[s] / cacah[s] for s in sorted(jumlah)}, sum(cacah.values())


tracemalloc.start()
rata, banyak = rata_rata_per_sektor(berkas)
_, puncak_gen = tracemalloc.get_traced_memory()
tracemalloc.stop()
print("rekaman dibaca:", banyak)
for sektor, nilai in rata.items():
    print(f"{sektor:8} rata-rata {nilai:8.1f}")

# 3) Pembanding: baca semua ke list dulu
tracemalloc.start()
semua = list(baca_rekaman(berkas))
_, puncak_list = tracemalloc.get_traced_memory()
tracemalloc.stop()
print("jumlah rekaman di list:", len(semua))
print("cara list memakai memori puncak lebih dari 20x generator:", puncak_list > 20 * puncak_gen)
ukuran berkas lebih dari 5 MB: True
rekaman dibaca: 300000
hutan    rata-rata     40.0
kandang  rata-rata    375.2
kebun    rata-rata    250.1
kolam    rata-rata    550.2
sawah    rata-rata   4996.2
jumlah rekaman di list: 300000
cara list memakai memori puncak lebih dari 20x generator: True

Telusuri hasilnya:

  • Berkas dibuat dengan benih acak tetap (random.Random(42)), sehingga angkanya sama setiap dijalankan. Berkasnya ditaruh di _tmp/, bukan di folder skrip.
  • Tiap sektor mendapat 60.000 baris (300.000 dibagi 5). Rata-rata per sektor mengikuti rentang yang kita tetapkan: sawah sekitar 5.000, kolam sekitar 550, dan seterusnya.
  • Generator baca_rekaman memakai with open(...) (Bab 13): berkas ditutup otomatis. Perulangan for baris in f sendiri sudah membaca berkas baris demi baris, tanpa memuat semuanya.
  • Pembanding: list(baca_rekaman(berkas)) menyimpan semua 300.000 rekaman, dan memori puncaknya lebih dari 20 kali lipat. Karena hanya perbandingan yang dicetak, keluarannya stabil.

sys.getsizeof hanya mengukur objek itu sendiri, bukan isi yang dirujuknya. Untuk list, ukurannya ikut bertambah karena list memang menyimpan penunjuk ke setiap isi. Untuk generator, ukurannya tetap karena ia hanya menyimpan keadaan terakhirnya (posisi di fungsi dan variabel lokal). Karena itu bab ini memakai tracemalloc untuk membandingkan pemakaian memori sungguhan, dan hanya mencetak perbandingannya.

Latihan

  1. Tulis fungsi generator genap_sampai(n) yang memberikan bilangan genap dari 2 sampai n. Cetak list(genap_sampai(10)).
  2. Ubah ekspresi generator pada 04_ekspresi_generator.py supaya menjumlahkan hanya kuadrat bilangan ganjil. Apakah jumlahnya berubah? Mengapa?
  3. Tambahkan satu tahap pada pipa di 06_pipa_generator.py: generator di_atas(rekaman, batas) yang hanya meloloskan bobot di atas 500. Sambungkan di ujung pipa.
  4. Tantangan: ubah 07_proyek_baca_besar.py supaya juga menghitung nilai terbesar tiap sektor dalam sekali baca, tanpa menyimpan seluruh rekaman.

Ringkasan Bab

  • for memakai protokol iterasi: iter() untuk meminta iterator, next() untuk isi berikutnya, StopIteration bila habis.
  • Generator dibuat dengan yield; fungsinya berjeda dan melanjutkan dari posisi terakhir.
  • Ekspresi generator (x for x in ...) menghitung saat diminta, ukurannya tetap kecil, dan menghemat memori.
  • Generator hanya bisa dibaca sekali; hasil kosong pada pembacaan kedua adalah jebakan klasik.
  • Beberapa generator bisa dirangkai menjadi pipa tanpa list perantara.
  • Proyek I4: berkas besar dibaca dengan generator, 300.000 rekaman dirangkum tanpa menumpuk di memori.

Selanjutnya, di Bab 31: decorator dan closure, cara “membungkus” fungsi dengan tambahan (log, coba-ulang) tanpa mengubah isinya.