Bab 31 — Decorator dan Closure: Membungkus Fungsi dengan Tambahan

Seri I4 — Bahasa Python Tingkat Lanjut

CatatanDi bab ini, Anda akan:
  • Memahami bahwa fungsi adalah objek yang bisa disimpan dan dikirim
  • Membuat closure: fungsi yang mengingat variabel di sekitarnya
  • Menulis decorator, termasuk yang memakai argumen dan functools.wraps
  • Mengenali jebakan umum dan decorator siap pakai (functools.cache)
  • Menambah log dan coba-ulang pada fungsi proyek tanpa mengubah isinya

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

Di Bab 30, proyek Anda belajar membaca berkas besar. Sekarang bayangkan Anda punya sepuluh fungsi yang mengambil data dari berbagai tempat, dan Anda ingin: (1) mencatat di log setiap kali fungsi dimulai dan selesai, (2) mengulang otomatis bila jaringan putus. Menyalin kode log dan ulang ke sepuluh fungsi itu membosankan dan rawan salah. Lebih baik “membungkus” setiap fungsi dengan lapisan tambahan, seperti membungkus kado: isinya sama, tetapi ada lapisan di luarnya. Itulah decorator.

Decorator = lapisan pembungkus di sekeliling fungsi: panggilan menembus lapisan, hasil kembali keluar.

Fungsi adalah objek

Pondasi decorator: di Python, fungsi hanyalah salah satu jenis objek, seperti angka atau teks. Ia bisa diberi nama lain, disimpan di dict, dan dikirim ke fungsi lain.

01_fungsi_objek.py
# Bab 31 - Fungsi adalah objek
def biomassa_kg(jumlah, bobot_g):
    return jumlah * bobot_g / 1000


alat = biomassa_kg            # menyalin "nama", bukan memanggil
print(alat(800, 540.5))
print(alat is biomassa_kg)
print(type(biomassa_kg).__name__)

# Fungsi boleh disimpan di dict dan dipilih lewat nama
rumus = {
    "biomassa": biomassa_kg,
    "luas": lambda panjang, lebar: panjang * lebar,
}
print(rumus["luas"](20, 15))


# Fungsi boleh dikirim ke fungsi lain
def terapkan(fungsi, *argumen):
    return fungsi(*argumen)


print(terapkan(biomassa_kg, 1000, 500))
432.4
True
function
300
500.0
  • alat = biomassa_kg (tanpa tanda kurung) tidak memanggil fungsi; ia hanya menambah satu nama untuk objek yang sama. Buktinya alat is biomassa_kg bernilai True.
  • Tipe objeknya function.
  • Di dict rumus, fungsi dipilih lewat nama: rumus["luas"](20, 15) menghasilkan 300.
  • terapkan(biomassa_kg, 1000, 500) mengirim fungsi sebagai bahan. *argumen mengumpulkan sisa argumen, dan fungsi(*argumen) membukanya kembali. Hasilnya 500,0.

Closure: fungsi yang mengingat

Fungsi boleh dibuat di dalam fungsi lain. Fungsi dalam itu bisa memakai variabel milik fungsi luar, bahkan setelah fungsi luar selesai. Gabungan “fungsi + variabel yang diingatnya” disebut closure.

02_closure.py
# Bab 31 - Closure: fungsi yang mengingat
def buat_penghitung(awal=0):
    total = awal

    def tambah(jumlah):
        nonlocal total          # tanpa ini, total dianggap variabel baru
        total += jumlah
        return total

    return tambah


panen_kebun = buat_penghitung()
panen_kolam = buat_penghitung(100)
print(panen_kebun(10))
print(panen_kebun(5))
print(panen_kolam(1))           # penghitung lain, ingatannya terpisah

isi = panen_kebun.__closure__[0].cell_contents
print("isi ingatan panen_kebun:", isi)
10
15
101
isi ingatan panen_kebun: 15
  • buat_penghitung() mengembalikan fungsi tambah. Fungsi itu “membawa” variabel total.
  • panen_kebun dan panen_kolam punya ingatan terpisah: 10, lalu 15 untuk kebun, sementara kolam mulai dari 100 dan menjadi 101.
  • nonlocal total wajib bila fungsi dalam ingin mengubah variabel luar. Tanpa itu, total += jumlah dianggap membuat variabel lokal baru dan memicu galat (ingat bahasan scope di Bab 10).
  • __closure__[0].cell_contents memperlihatkan “ingatan” itu secara langsung: 15.

Decorator pertama

Decorator adalah fungsi yang menerima fungsi dan mengembalikan fungsi baru (pembungkus). Tanda @ hanyalah singkatan.

03_decorator_dasar.py
# Bab 31 - Decorator pertama (belum sempurna)
def catat(fungsi):
    def pembungkus(*args, **kwargs):
        print(f"memanggil {fungsi.__name__}")
        hasil = fungsi(*args, **kwargs)
        print(f"selesai, hasil = {hasil}")
        return hasil
    return pembungkus


@catat
def biomassa_kg(jumlah, bobot_g):
    """Biomassa kolam dalam kg."""
    return jumlah * bobot_g / 1000


biomassa_kg(800, 540.5)

# @catat sama saja dengan: biomassa_kg = catat(biomassa_kg)
print(biomassa_kg.__name__)     # identitas aslinya hilang
print(biomassa_kg.__doc__)
memanggil biomassa_kg
selesai, hasil = 432.4
pembungkus
None
  • @catat di atas def biomassa_kg artinya sama dengan biomassa_kg = catat(biomassa_kg).
  • Setiap pemanggilan sekarang melewati pembungkus: mencetak “memanggil”, menjalankan fungsi asli, mencetak hasil, lalu mengembalikannya.
  • *args, **kwargs membuat pembungkus menerima argumen apa pun lalu meneruskannya apa adanya.
  • Ada cacat: biomassa_kg.__name__ menjadi pembungkus dan docstring-nya hilang (None). Fungsi kehilangan identitas.

functools.wraps: menjaga identitas

Obatnya satu baris: @wraps(fungsi) pada pembungkus.

04_wraps.py
# Bab 31 - functools.wraps menjaga identitas fungsi
from functools import wraps


def catat(fungsi):
    @wraps(fungsi)
    def pembungkus(*args, **kwargs):
        print(f"memanggil {fungsi.__name__}")
        return fungsi(*args, **kwargs)
    return pembungkus


@catat
def biomassa_kg(jumlah, bobot_g):
    """Biomassa kolam dalam kg."""
    return jumlah * bobot_g / 1000


print(biomassa_kg(800, 540.5))
print(biomassa_kg.__name__)
print(biomassa_kg.__doc__)
print(biomassa_kg.__wrapped__ is not None)
memanggil biomassa_kg
432.4
biomassa_kg
Biomassa kolam dalam kg.
True

Sekarang __name__ dan __doc__ kembali ke aslinya, dan __wrapped__ menyimpan rujukan ke fungsi sebelum dibungkus. Biasakan: setiap decorator selalu memakai @wraps. Ini penting untuk pesan galat, dokumentasi, dan alat seperti pytest.

Decorator yang punya argumen

Bila decorator perlu pengaturan (misalnya “ulangi 3 kali”), dibutuhkan satu lapisan lagi: fungsi luar menerima pengaturan dan mengembalikan decorator yang sesungguhnya.

05_decorator_argumen.py
# Bab 31 - Decorator yang punya argumen
from functools import wraps


def ulangi(kali):
    """Pabrik decorator: mengulang pemanggilan sebanyak `kali`."""
    def decorator(fungsi):
        @wraps(fungsi)
        def pembungkus(*args, **kwargs):
            hasil = None
            for i in range(kali):
                hasil = fungsi(*args, **kwargs)
            return hasil
        return pembungkus
    return decorator


@ulangi(3)
def sapa(nama):
    print("Halo,", nama)


sapa("Pak Tani")


# Menumpuk decorator: yang paling dekat dengan def dipasang lebih dulu
def tebal(f):
    return lambda: "<b>" + f() + "</b>"


def miring(f):
    return lambda: "<i>" + f() + "</i>"


@tebal
@miring
def judul():
    return "Panen"


print(judul())
Halo, Pak Tani
Halo, Pak Tani
Halo, Pak Tani
<b><i>Panen</i></b>

Tiga tingkat: ulangi(3) → mengembalikan decorator → yang menerima sapa dan mengembalikan pembungkus. @ulangi(3) menjalankan tingkat pertama langsung, hasilnya dipasang sebagai decorator.

Bagian bawah menunjukkan decorator bertumpuk: yang paling dekat dengan def dipasang lebih dulu. @miring membungkus judul lebih dulu, lalu @tebal membungkus hasilnya, jadi tag <b> berada di luar dan <i> di dalam: <b><i>Panen</i></b>.

Decorator siap pakai dan jebakan

Pustaka standar menyediakan banyak decorator. Salah satu yang paling berguna adalah functools.cache, yang mengingat hasil agar pemanggilan dengan argumen sama tidak dihitung ulang.

06_cache_bawaan.py
# Bab 31 - Decorator siap pakai: functools.cache
from functools import cache

panggilan = 0


@cache
def harga_pakan_per_kg(kode):
    global panggilan
    panggilan += 1             # menghitung berapa kali isi fungsi benar-benar jalan
    return {"P1": 8500, "P2": 9200}[kode]


print(harga_pakan_per_kg("P1"))
print(harga_pakan_per_kg("P1"))   # diambil dari ingatan
print(harga_pakan_per_kg("P2"))
print("isi fungsi berjalan:", panggilan, "kali")
8500
8500
9200
isi fungsi berjalan: 2 kali

Tiga kali dipanggil, tetapi isi fungsi hanya berjalan 2 kali: pemanggilan kedua untuk "P1" diambil dari ingatan. Cocok untuk fungsi yang lambat dan hasilnya selalu sama untuk masukan yang sama. Jangan dipakai untuk fungsi yang hasilnya berubah (misalnya membaca harga hari ini).

Sekarang jebakan paling umum: lupa tanda kurung pada decorator berargumen.

07_lupa_kurung.py
# Bab 31 - Jebakan: decorator berargumen dipasang tanpa tanda kurung
def ulangi(kali):
    def decorator(fungsi):
        def pembungkus(*args, **kwargs):
            for _ in range(kali):
                fungsi(*args, **kwargs)
        return pembungkus
    return decorator


@ulangi            # seharusnya @ulangi(3)
def sapa():
    print("Halo")


sapa()
Traceback (most recent call last):
  File "07_lupa_kurung.py", line 16, in <module>
    sapa()
    ~~~~^^
TypeError: ulangi.<locals>.decorator() missing 1 required positional argument: 'fungsi'

@ulangi tanpa (3) berarti sapa = ulangi(sapa). Argumen kali jadi berisi fungsi sapa, dan yang dikembalikan adalah decorator, bukan pembungkus. Saat sapa() dipanggil, sebenarnya decorator() yang jalan, dan ia kekurangan argumen fungsi. Pesan galatnya (ulangi.<locals>.decorator() missing 1 required positional argument: 'fungsi') adalah petunjuk khasnya.

PeringatanAWAS

Decorator berargumen selalu ditulis dengan kurung, walau argumennya kosong atau bawaan. Bila galatnya berbunyi <locals>.decorator() missing ... 'fungsi', hampir pasti Anda lupa kurung.

Proyek Bersambung I4: log dan coba-ulang

Sekarang gabungkan: dua decorator, catat (log) dan coba_ulang (mengulang bila ConnectionError), dipasang pada fungsi pengambil curah hujan. Jaringan tiruan: gagal dua kali, baru berhasil. Log memakai modul logging (Bab 19) dengan format tanpa jam, agar keluaran stabil. Jeda antar-percobaan sengaja kecil (0,01 detik).

08_proyek_log_coba_ulang.py
# Bab 31 - Proyek bersambung I4: decorator untuk log dan coba-ulang
import logging
import sys
import time
from functools import wraps

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("lapangan")


def catat(fungsi):
    @wraps(fungsi)
    def pembungkus(*args, **kwargs):
        log.info("mulai %s", fungsi.__name__)
        hasil = fungsi(*args, **kwargs)
        log.info("selesai %s", fungsi.__name__)
        return hasil
    return pembungkus


def coba_ulang(maksimal=3, jeda=0.01, galat=(ConnectionError,)):
    def decorator(fungsi):
        @wraps(fungsi)
        def pembungkus(*args, **kwargs):
            for percobaan in range(1, maksimal + 1):
                try:
                    return fungsi(*args, **kwargs)
                except galat as e:
                    log.warning("percobaan %d dari %d gagal: %s", percobaan, maksimal, e)
                    if percobaan == maksimal:
                        raise
                    time.sleep(jeda)
        return pembungkus
    return decorator


gangguan = {"sisa": 2}          # jaringan tiruan: gagal dua kali, lalu normal


@catat
@coba_ulang(maksimal=3)
def ambil_curah_hujan(stasiun):
    if gangguan["sisa"] > 0:
        gangguan["sisa"] -= 1
        raise ConnectionError("sambungan terputus")
    return {"stasiun": stasiun, "mm": 12.5}


print(ambil_curah_hujan("S1"))

gangguan["sisa"] = 5            # sekarang gangguan lebih panjang dari batas coba
try:
    ambil_curah_hujan("S2")
except ConnectionError as e:
    print("menyerah:", e)
INFO mulai ambil_curah_hujan
WARNING percobaan 1 dari 3 gagal: sambungan terputus
WARNING percobaan 2 dari 3 gagal: sambungan terputus
INFO selesai ambil_curah_hujan
{'stasiun': 'S1', 'mm': 12.5}
INFO mulai ambil_curah_hujan
WARNING percobaan 1 dari 3 gagal: sambungan terputus
WARNING percobaan 2 dari 3 gagal: sambungan terputus
WARNING percobaan 3 dari 3 gagal: sambungan terputus
menyerah: sambungan terputus

Telusuri:

  • Pemanggilan pertama: mulai dicatat, percobaan 1 dan 2 gagal (peringatan WARNING), percobaan 3 berhasil, lalu selesai dicatat. Hasilnya {'stasiun': 'S1', 'mm': 12.5}.
  • Urutan decorator penting: @catat di luar, @coba_ulang di dalam. Maka log mulai dan selesai hanya muncul sekali per pemanggilan, walau terjadi tiga percobaan di dalamnya.
  • Pemanggilan kedua: gangguan lebih panjang daripada tiga percobaan, sehingga galat dilempar ulang (raise). Perhatikan: tidak ada selesai, karena fungsi tidak selesai dengan baik. Program menangkapnya di luar dengan try/except dan mencetak “menyerah”.
TipTIPS

Coba-ulang hanya cocok untuk galat sementara (jaringan putus, server sibuk). Jangan mengulang galat yang pasti berulang (data salah): ia hanya membuang waktu. Itulah sebabnya coba_ulang hanya menangkap jenis galat yang disebutkan.

Latihan

  1. Tulis closure buat_pengali(faktor) yang mengembalikan fungsi mengalikan angka dengan faktor. Buat kali_tiga dan kali_sepuluh, lalu coba keduanya.
  2. Tulis decorator hitung_panggilan yang menyimpan jumlah panggilan di atribut fungsi (pembungkus.jumlah). Pasang pada satu fungsi, panggil empat kali, dan cetak jumlah.
  3. Ubah 05_decorator_argumen.py: tambahkan @ulangi(2) pada fungsi lain yang mencetak nama kolam, dan pastikan __name__-nya tetap benar.
  4. Tantangan: tulis decorator lebih_dari(batas) yang melempar ValueError bila hasil fungsi di bawah batas. Pasang pada fungsi biomassa, dan uji dengan satu masukan yang lolos dan satu yang gagal.

Ringkasan Bab

  • Fungsi adalah objek: bisa diberi nama lain, disimpan, dan dikirim ke fungsi lain.
  • Closure = fungsi dalam yang mengingat variabel fungsi luar; ubah dengan nonlocal.
  • Decorator = fungsi yang menerima fungsi dan mengembalikan pembungkusnya; @nama adalah singkatan.
  • Selalu pakai @wraps agar nama dan docstring fungsi asli terjaga.
  • Decorator berargumen butuh tiga tingkat dan wajib berkurung saat dipasang.
  • Proyek I4: fungsi pengambil data kini punya log dan coba-ulang tanpa mengubah isinya.

Selanjutnya, di Bab 32: context manager, cara memastikan sumber daya (berkas, koneksi) selalu ditutup rapi.