Sering kali yang membuat program berhenti bukan kekurangan kecepatan, melainkan kehabisan memori — loading seluruh file log ke RAM sekaligus, atau membangun list jutaan elemen hanya untuk menjumlahkannya. Generator yang sudah kita kenal dari Chapter yield dan Generator Functions dan Lazy Evaluation menawarkan jalan keluar: menghasilkan satu item pada satu waktu sehingga memori yang dipakai tidak lagi sebanding dengan ukuran data. Pada Chapter ini kita ukur sendiri penghematannya dan melihat pola pemakaian yang tepat.

List vs Generator pada Range Besar #

Mari kita buktikan dengan sys.getsizeof(). List menyimpan setiap elemen sekaligus; generator hanya menyimpan kondisi perulangannya:

python
import sys

daftar = list(range(1_000_000))
generator = (i for i in range(1_000_000))

print("getsizeof(list(range(1_000_000)))   :", f"{sys.getsizeof(daftar):,} byte")
print("getsizeof((i for i in range(...))) :", f"{sys.getsizeof(generator):,} byte")
print("elemen pertama generator           :", next(generator))

Output yang Dihasilkan:

text
getsizeof(list(range(1_000_000)))   : 8,000,056 byte
getsizeof((i for i in range(...))) : 192 byte
elemen pertama generator           : 0

Selisihnya lebih dari 40.000x: 8 MB untuk array pointer list, versus 192 byte untuk generator. Baris ketiga menegaskan bahwa generator memang benar-benar menghasilkan data — next() hanya mengeluarkan satu elemen, sisanya belum pernah dibuat. Dan angka 8 MB pun masih dangkal: ingat peringatan dari Chapter Memahami Memory Management bahwa getsizeof tidak menghitung object yang dirujuk, dan sejuta object integer di dalam list menambah sekitar 28 MB lagi. Total nyatanya bisa kita lihat dengan tracemalloc, yang mengukur seluruh alokasi selama program berjalan:

python
import tracemalloc

tracemalloc.start()
total = sum([i * 2 for i in range(1_000_000)])
_, puncak_list = tracemalloc.get_traced_memory()
print(f"list comprehension : total = {total:,}, puncak memori = {puncak_list:,} byte")
tracemalloc.stop()

tracemalloc.start()
total = sum(i * 2 for i in range(1_000_000))
_, puncak_gen = tracemalloc.get_traced_memory()
print(f"generator expression: total = {total:,}, puncak memori = {puncak_gen:,} byte")
tracemalloc.stop()

Output yang Dihasilkan:

text
list comprehension : total = 999,999,000,000, puncak memori = 40,444,616 byte
generator expression: total = 999,999,000,000, puncak memori = 464 byte

Total penjumlahan identik, tetapi puncak memori 40 MB versus 464 byte. Perhatikan bahwa sum() sendiri tidak butuh seluruh data sekaligus — generator memberi angka satu-satu dan sum mengakumulasinya, jadi tidak pernah ada “seluruh data” di memori.

Memproses File Baris demi Baris #

Bentuk paling praktis dari prinsip yang sama adalah membaca file. Pertama kita siapkan file contoh penjualan berisi 200.000 baris:

python
# Membuat file contoh: penjualan.log (1 baris = 1 transaksi)
with open("penjualan.log", "w", encoding="utf-8") as f:
    for i in range(200_000):
        f.write(f"TRX-{i:06d};toko-{i % 20:02d};Rp{(i * 1500) % 500_000};lunas\n")

import os
print(f"ukuran file penjualan.log: {os.path.getsize('penjualan.log'):,} byte")

Output yang Dihasilkan:

text
ukuran file penjualan.log: 6,755,200 byte

Sekarang kita bandingkan dua cara menghitung: f.read() yang memuat seluruh isi ke satu string, versus iterasi file object yang menghasilkan satu baris dalam satu waktu:

python
import tracemalloc

NAMA_FILE = "penjualan.log"


def hitung_baris_read():
    "Membaca seluruh file ke satu string besar."
    with open(NAMA_FILE, encoding="utf-8") as f:
        isi = f.read()
    return isi.count("\n")


def hitung_baris_iterasi():
    "Membaca satu baris demi satu baris."
    jumlah = 0
    with open(NAMA_FILE, encoding="utf-8") as f:
        for baris in f:
            jumlah += 1
    return jumlah


tracemalloc.start()
hasil_1 = hitung_baris_read()
_, puncak_1 = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"read()    : {hasil_1:,} baris, puncak memori = {puncak_1:,} byte")

tracemalloc.start()
hasil_2 = hitung_baris_iterasi()
_, puncak_2 = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"iterasi   : {hasil_2:,} baris, puncak memori = {puncak_2:,} byte")

Output yang Dihasilkan:

text
read()    : 200,000 baris, puncak memori = 13,515,658 byte
iterasi   : 200,000 baris, puncak memori = 21,847 byte

File 6,7 MB menjadi string 13,5 MB di memori saat didecode (sekitar dua kali lipat ukuran file), sementara iterasi baris stabil di 21 KB — kurang lebih satu baris buffer dipegang pada satu waktu. Untuk file log server berukuran gigabyte, read() akan langsung membunuh proses; iterasi berjalan dengan memori yang datar-datar saja.

Pipeline Generator: Matematika Memorinya #

Kekuatan generator penuh muncul saat beberapa tahap dirangkai — masing-masing function generator mengambil satu item, mengubahnya, lalu mengoperkannya ke tahap berikutnya:

python
def baca_baris(nama_file):
    "Tahap 1: hasilkan satu baris dalam satu waktu."
    with open(nama_file, encoding="utf-8") as f:
        for baris in f:
            yield baris.rstrip("\n")


def ambil_toko_lunas(baris_generator):
    "Tahap 2: ubah tiap baris menjadi pasangan (toko, nilai) untuk transaksi lunas."
    for baris in baris_generator:
        kode, toko, nilai, status = baris.split(";")
        if status == "lunas":
            yield toko, int(nilai.removeprefix("Rp"))


def jumlah_per_toko(pasangan_generator):
    "Tahap 3: agregasi -- satu-satunya tahap yang menyimpan hasil."
    total = {}
    for toko, nilai in pasangan_generator:
        total[toko] = total.get(toko, 0) + nilai
    return total


alur = jumlah_per_toko(ambil_toko_lunas(baca_baris("penjualan.log")))
hasil_tiga = dict(sorted(alur.items())[:3])
print("3 toko pertama :", hasil_tiga)
print("jumlah toko    :", len(alur))

Output yang Dihasilkan:

text
3 toko pertama : {'toko-00': 2450000000, 'toko-01': 2465000000, 'toko-02': 2480000000}
jumlah toko    : 20

Matematika memorinya sederhana: 200.000 baris × 3 tahap list bersarang akan menyimpan jutaan item antara sekaligus. Pada pipeline generator, setiap saat hanya ada satu baris dan satu pasangan di perjalanan — satu-satunya struktur yang tumbuh adalah dictionary agregasi berisi 20 toko, yang memang hasil akhir yang kita inginkan. Memori total pipeline ini mendekati konstan, O(ukuran output), bukan O(ukuran input).

Kapan List Tetap Lebih Baik #

Generator bukan kemenangan mutlak — sifat sekali pakai dan tanpa panjang membuatnya canggung untuk beberapa kebutuhan:

python
bilangan = (i for i in range(5))
print("konsumsi sekali :", list(bilangan))
print("konsumsi kedua  :", list(bilangan))  # generator sudah habis

bilangan_baru = (i for i in range(5))
try:
    len(bilangan_baru)
except TypeError as galat:
    print("TypeError       :", galat)

Output yang Dihasilkan:

text
konsumsi sekali : [0, 1, 2, 3, 4]
konsumsi kedua  : []
TypeError       : object of type 'generator' has no len()

Generator yang sudah dikonsumsi menghasilkan list kosong saat dipakai lagi, dan len() menolak bekerja karena jumlah elemen memang belum diketahui sebelum dihitung. Gunakan list ketika: data perlu diiterasi lebih dari sekali (misalnya diverifikasi lalu diproses), kita butuh len(), slicing, atau indexing acak, atau total ukurannya kecil sehingga kenyamanan list gratis. Untuk semua skenario lain — stream data besar, transformasi berantai, konsumsi satu arah — generator adalah struktur default yang paling hemat.