Bab 31 — Decorator dan Closure: Membungkus Fungsi dengan Tambahan
Seri I4 — Bahasa Python Tingkat Lanjut
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.

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. Buktinyaalat is biomassa_kgbernilaiTrue.- 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.*argumenmengumpulkan sisa argumen, danfungsi(*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 fungsitambah. Fungsi itu “membawa” variabeltotal.panen_kebundanpanen_kolampunya ingatan terpisah: 10, lalu 15 untuk kebun, sementara kolam mulai dari 100 dan menjadi 101.nonlocal totalwajib bila fungsi dalam ingin mengubah variabel luar. Tanpa itu,total += jumlahdianggap membuat variabel lokal baru dan memicu galat (ingat bahasan scope di Bab 10).__closure__[0].cell_contentsmemperlihatkan “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
@catatdi atasdef biomassa_kgartinya sama denganbiomassa_kg = catat(biomassa_kg).- Setiap pemanggilan sekarang melewati
pembungkus: mencetak “memanggil”, menjalankan fungsi asli, mencetak hasil, lalu mengembalikannya. *args, **kwargsmembuat pembungkus menerima argumen apa pun lalu meneruskannya apa adanya.- Ada cacat:
biomassa_kg.__name__menjadipembungkusdan 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.
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:
mulaidicatat, percobaan 1 dan 2 gagal (peringatanWARNING), percobaan 3 berhasil, laluselesaidicatat. Hasilnya{'stasiun': 'S1', 'mm': 12.5}. - Urutan decorator penting:
@catatdi luar,@coba_ulangdi dalam. Maka logmulaidanselesaihanya 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 adaselesai, karena fungsi tidak selesai dengan baik. Program menangkapnya di luar dengantry/exceptdan mencetak “menyerah”.
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
- Tulis closure
buat_pengali(faktor)yang mengembalikan fungsi mengalikan angka denganfaktor. Buatkali_tigadankali_sepuluh, lalu coba keduanya. - Tulis decorator
hitung_panggilanyang menyimpan jumlah panggilan di atribut fungsi (pembungkus.jumlah). Pasang pada satu fungsi, panggil empat kali, dan cetakjumlah. - Ubah
05_decorator_argumen.py: tambahkan@ulangi(2)pada fungsi lain yang mencetak nama kolam, dan pastikan__name__-nya tetap benar. - Tantangan: tulis decorator
lebih_dari(batas)yang melemparValueErrorbila hasil fungsi di bawahbatas. 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;
@namaadalah singkatan. - Selalu pakai
@wrapsagar 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.