Program sering menghitung hal yang sama berkali-kali: function yang memvalidasi konfigurasi sama, query database yang identik, atau rekursi yang memanggil subsoal yang sama. Caching adalah strategi menyimpan hasil perhitungan agar pemanggilan berikutnya cukup membaca hasil lama. Python menyediakan perangkatnya di module functools — tetapi caching juga punya harga: memori, data basi, dan jebakan argument yang mutable. Pada Chapter ini kita pelajari kapan caching menyelamatkan performa dan kapan justru melukai.

functools.lru_cache: Memoization Siap Pakai #

Rekursi Fibonacci tanpa cache adalah contoh pemborosan paling terkenal — fib(30) menghitung fib(2) ratusan ribu kali. Decorator @functools.lru_cache menyimpan hasil setiap pemanggilan berdasarkan argument-nya:

python
import functools
import time


def fib_tanpa_cache(n):
    if n < 2:
        return n
    return fib_tanpa_cache(n - 1) + fib_tanpa_cache(n - 2)


@functools.lru_cache(maxsize=None)
def fib_dengan_cache(n):
    if n < 2:
        return n
    return fib_dengan_cache(n - 1) + fib_dengan_cache(n - 2)


mulai = time.perf_counter()
hasil_1 = fib_tanpa_cache(30)
waktu_1 = time.perf_counter() - mulai

mulai = time.perf_counter()
hasil_2 = fib_dengan_cache(30)
waktu_2 = time.perf_counter() - mulai

print(f"tanpa cache : fib(30) = {hasil_1} dalam {waktu_1:.4f} detik")
print(f"dengan cache: fib(30) = {hasil_2} dalam {waktu_2:.8f} detik")
print(f"cache_info  : {fib_dengan_cache.cache_info()}")

Output yang Dihasilkan (waktu bergantung pada mesin):

text
tanpa cache : fib(30) = 832040 dalam 0.1109 detik
dengan cache: fib(30) = 832040 dalam 0.00002307 detik
cache_info  : CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)

Mari kita telaah apa yang terjadi di balik layar:

  1. Baris @functools.lru_cache(maxsize=None): mendekorasi fib_dengan_cache sehingga setiap pemanggilan memeriksa dictionary internal berisi argument → hasil sebelum function asli dijalankan.
  2. Baris misses=31: dari 59 pemanggilan total (rekursi fib(30) hanya butuh 31 nilai unik), 31 pemanggilan pertama benar-benar menghitung.
  3. Baris hits=28: 28 pemanggilan sisanya langsung dijawab dari cache — inilah selisih antara 0,11 detik dan 23 mikrosekon.

Parameter maxsize membatasi jumlah hasil yang disimpan; saat penuh, entri yang paling jarang dipakai dibuang (LRU = least recently used). maxsize=None berarti tanpa batas — untuk fungsi kecil seperti ini aman, untuk data besar berbahaya karena memori terus tumbuh.

Membaca cache_info dan Mereset dengan cache_clear #

Function terdekorasi membawa dua alat diagnosis. cache_info() melaporkan hits, misses, dan ukuran cache; cache_clear() mengosongkan seluruh cache:

python
import functools

@functools.lru_cache(maxsize=128)
def ambil_harga(nama_barang):
    print(f"  menghitung harga untuk {nama_barang}...")
    return len(nama_barang) * 1000


print(ambil_harga("beras"))
print(ambil_harga("gula"))
print(ambil_harga("beras"))  # cache hit
print(ambil_harga.cache_info())
ambil_harga.cache_clear()
print(ambil_harga.cache_info())

Output yang Dihasilkan:

text
  menghitung harga untuk beras...
5000
  menghitung harga untuk gula...
4000
5000
CacheInfo(hits=1, misses=2, maxsize=128, currsize=2)
CacheInfo(hits=0, misses=0, maxsize=128, currsize=0)

Pemanggilan ambil_harga("beras") kedua tidak mencetak baris “menghitung” — function aslinya tidak pernah berjalan. Setelah cache_clear(), seluruh pencacah kembali nol. Angka hits yang selalu rendah adalah sinyal cache tidak efektif dan hanya menambah overhead.

Sejak Python 3.9 tersedia juga functools.cache — versi sederhana lru_cache(maxsize=None) tanpa fitur LRU, cocok untuk memoization murni yang ukurannya pasti terkendali.

Kapan Caching Justru Menyakiti #

python
import functools

@functools.lru_cache(maxsize=128)
def total_pesanan(nama_barang, jumlah):
    return jumlah * 1000


try:
    total_pesanan(["beras", "gula"], 2)
except TypeError as galat:
    print("TypeError:", galat)

Output yang Dihasilkan:

text
TypeError: unhashable type: 'list'

Solusinya sederhana: ubah list menjadi tuple sebelum masuk ke function ter-cache, atau terima elemen individual sebagai argument terpisah.

Jebakan kedua lebih halus: caching bekerja pada argument dan hasil, bukan pada state luar. Function yang membaca file, isi database, atau waktu sekarang akan mengembalikan data basi setelah dunia berubah — cache bisa membuat program salah, bukan sekadar boros. Terkait erat, ingat kembali anti-pola parameter mutable dari Chapter Default Parameters:

python
def catat_pesanan(nama_barang, keranjang=[]):
    # ANTI-POLA: parameter mutable dipakai sebagai penampung status
    keranjang.append(nama_barang)
    return keranjang


print(catat_pesanan("beras"))
print(catat_pesanan("gula"))
print(catat_pesanan("teh"))

Output yang Dihasilkan:

text
['beras']
['beras', 'gula']
['beras', 'gula', 'teh']

List default yang sama dipakai bersama semua pemanggilan — state “menempel” persis seperti cache yang tidak pernah dibersihkan. Bayangkan benda seperti ini disimpan berbulan-bulan di server: memori tumbuh tanpa henti dan isinya semakin tidak relevan.

Memoization Manual dengan Dictionary #

Sebelum decorator ada, memoization ditulis tangan dengan dictionary — dan pola ini tetap berguna saat kita butuh kendali penuh, misalnya memilih sendiri kunci penyimpanannya:

python
def fib_memo(n, memo=None):
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]
    if n < 2:
        return n
    memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
    return memo[n]


print("fib(10) :", fib_memo(10))
print("fib(60) :", fib_memo(60))
print("fib(100):", fib_memo(100))

Output yang Dihasilkan:

text
fib(10) : 55
fib(60) : 1548008755920
fib(100): 354224848179261915075

Perhatikan pola memo=None lalu if memo is None: memo = {} — membuat dictionary baru per pemanggilan teratas, menghindari jebakan parameter mutable di atas, sekaligus membuat dictionary bisa diteruskan turun di antara panggilan rekursif.

Masalah Invalidasi dan Cache Terdistribusi #

Dunia nyata menambahkan satu masalah tersulit: kapan cache harus ditinggalkan? File konfigurasi bisa berubah, harga barang bisa naik, cache di memori bisa hilang saat server restart. Prinsip yang sehat: beri cache masa kedaluwarsa (lru_cache punya cache_clear() untuk itu, dipanggil berkala atau saat data sumber berubah), dan simpan versi/timestamp data sumber sebagai bagian dari logika pembanding.

Konsep ini sama dengan HTTP caching yang kita jumpai sehari-hari di browser — header seperti Cache-Control dan ETag adalah cara server memberi tahu klien berapa lama boleh menyimpan dan bagaimana memvalidasi ulang. Ketika satu mesin tidak cukup, cache pindah ke memori terdistribusi seperti Redis yang dishare antar banyak process; Python cukup berbicara dengannya lewat client seperti redis-py. Skalanya berbeda, tetapi pertanyaannya tetap sama: apa kuncinya, berapa lama sah, dan siapa yang membatalkannya.