Bab 32 — Context Manager: Membuka, Memakai, dan Pasti Menutup

Seri I4 — Bahasa Python Tingkat Lanjut

CatatanDi bab ini, Anda akan:
  • Memahami mengapa with lebih aman daripada open() lalu close()
  • Membuat context manager dengan kelas (__enter__ dan __exit__)
  • Membuat context manager lebih singkat dengan @contextmanager
  • Memakai alat contextlib (suppress, redirect_stdout, ExitStack)
  • Menulis laporan dengan aman: berkas lama tidak rusak bila penulisan gagal

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

Pernahkah Anda lupa mematikan pompa kolam setelah selesai mengisi air? Pompa terus menyala, listrik terbuang, atau kolam meluap. Program punya masalah serupa: berkas yang dibuka dan lupa ditutup, koneksi yang dibiarkan menggantung. Lebih parah lagi bila di tengah pekerjaan terjadi galat: baris close() di ujung tidak pernah tercapai.

Python punya solusi: pernyataan with. Ia berjanji bahwa apa pun yang terjadi di dalam blok, urusan “tutup dan bersihkan” pasti dikerjakan.

with: pintu dibuka, pekerjaan dilakukan, lalu pintu dipastikan ditutup, baik berhasil maupun gagal.

with pada berkas

Anda sudah memakai with open(...) sejak Bab 13. Bandingkan dengan cara lama:

01_with_berkas.py
# Bab 32 - with menutup berkas otomatis
from pathlib import Path

folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
jalur = folder / "catatan.txt"

# Cara lama: harus ingat close()
f = open(jalur, "w", encoding="utf-8")
f.write("panen kebun A: 245.5 kg\n")
print("sebelum close, tertutup?", f.closed)
f.close()
print("sesudah close, tertutup?", f.closed)

# Cara with: tertutup sendiri, bahkan bila terjadi galat
with open(jalur, encoding="utf-8") as f:
    isi = f.read()
    print("di dalam with, tertutup?", f.closed)
print("di luar with, tertutup?", f.closed)
print(isi.strip())
sebelum close, tertutup? False
sesudah close, tertutup? True
di dalam with, tertutup? False
di luar with, tertutup? True
panen kebun A: 245.5 kg
  • Cara lama: f.closed bernilai False sampai Anda sendiri memanggil f.close(). Kalau ada galat sebelum close(), berkasnya tetap terbuka.
  • Cara with: di dalam blok, berkas terbuka (False). Begitu blok selesai, ia tertutup (True), tanpa Anda menulis close().
  • Perhatikan bahwa variabel f masih ada setelah blok, tetapi berkasnya sudah tertutup.

Di balik layar: __enter__ dan __exit__

with bisa dipakai pada objek apa pun yang punya dua metode khusus: __enter__ (dijalankan saat masuk blok; nilainya masuk ke variabel setelah as) dan __exit__ (dijalankan saat keluar blok, selalu, termasuk bila terjadi galat). Objek seperti itu disebut context manager.

02_kelas_enter_exit.py
# Bab 32 - Context manager dengan kelas: __enter__ dan __exit__
class Pintu:
    def __init__(self, nama):
        self.nama = nama

    def __enter__(self):
        print(f"buka pintu {self.nama}")
        return self                      # nilai setelah kata "as"

    def __exit__(self, jenis_galat, galat, jejak):
        print(f"tutup pintu {self.nama}")
        return False                     # False: galat (bila ada) tetap diteruskan


with Pintu("kandang") as p:
    print("bekerja di dalam", p.nama)

print("--- sekarang dengan galat ---")
try:
    with Pintu("kolam"):
        print("bekerja...")
        raise ValueError("pompa mati")
except ValueError as e:
    print("galat ditangkap di luar:", e)
buka pintu kandang
bekerja di dalam kandang
tutup pintu kandang
--- sekarang dengan galat ---
buka pintu kolam
bekerja...
tutup pintu kolam
galat ditangkap di luar: pompa mati
  • Pada percobaan pertama urutannya: buka, bekerja, tutup.
  • Pada percobaan kedua, galat ValueError("pompa mati") terjadi di dalam blok. Pintu tetap ditutup (“tutup pintu kolam” tercetak) sebelum galatnya muncul di luar dan ditangkap except.
  • __exit__ menerima tiga informasi galat (jenis, isi, jejak; nilainya None bila tidak ada galat). Mengembalikan False berarti “galatnya silakan diteruskan”.

Cara singkat: @contextmanager

Menulis kelas dengan dua metode itu panjang. contextlib.contextmanager mengubah fungsi generator (Bab 30) menjadi context manager: kode sebelum yield menjadi __enter__, kode sesudahnya menjadi __exit__.

03_contextmanager.py
# Bab 32 - @contextmanager: context manager dari fungsi generator
from contextlib import contextmanager


@contextmanager
def sesi_kerja(nama):
    print(f"[{nama}] mulai")
    try:
        yield nama.upper()               # isi blok with dijalankan di sini
    finally:
        print(f"[{nama}] selesai dan dibersihkan")


with sesi_kerja("pemupukan") as kode:
    print("bekerja dengan kode", kode)

try:
    with sesi_kerja("pemanenan"):
        print("bekerja...")
        1 / 0
except ZeroDivisionError:
    print("galat tertangkap, tetapi pembersihan tetap jalan")
[pemupukan] mulai
bekerja dengan kode PEMUPUKAN
[pemupukan] selesai dan dibersihkan
[pemanenan] mulai
bekerja...
[pemanenan] selesai dan dibersihkan
galat tertangkap, tetapi pembersihan tetap jalan
  • yield nama.upper() melepaskan kendali ke blok with dan memberikan nilai PEMUPUKAN ke variabel kode.
  • try ... finally di sekeliling yield menjamin pembersihan. Pada percobaan kedua terjadi 1 / 0, tetapi “selesai dan dibersihkan” tetap tercetak, lalu galatnya tertangkap di luar.
TipTIPS

Selalu bungkus yield di @contextmanager dengan try ... finally bila ada pembersihan. Tanpa finally, bila blok with terjadi galat, baris pembersihan sesudah yield tidak dijalankan.

__exit__ boleh menelan galat

Bila __exit__ mengembalikan True, galatnya dianggap sudah ditangani dan program lanjut seolah tidak ada apa-apa. Ini kuat tetapi berbahaya.

04_menelan_galat.py
# Bab 32 - __exit__ boleh menelan galat (dengan hati-hati)
from contextlib import suppress


class AbaikanKosong:
    def __enter__(self):
        return self

    def __exit__(self, jenis_galat, galat, jejak):
        if jenis_galat is ZeroDivisionError:
            print("dibagi nol diabaikan")
            return True                  # True: galat dianggap sudah ditangani
        return False


with AbaikanKosong():
    print(10 / 0)
print("program lanjut")

# Pustaka standar sudah menyediakan yang serupa
with suppress(FileNotFoundError):
    open("tidak_ada.txt", encoding="utf-8")
print("lanjut lagi")
dibagi nol diabaikan
program lanjut
lanjut lagi

Pada blok pertama, 10 / 0 melempar ZeroDivisionError, tetapi __exit__ mengembalikan True, jadi “program lanjut” tetap tampil. Pustaka standar menyediakan versi yang lebih rapi: contextlib.suppress(FileNotFoundError), yang menelan hanya jenis galat yang Anda sebut.

PeringatanAWAS

Menelan galat bisa menyembunyikan bug. Telan hanya jenis galat yang Anda harapkan dan memang tidak berbahaya (misalnya berkas yang memang boleh tidak ada). Jangan menelan semua galat.

Alat siap pakai di contextlib

05_alat_contextlib.py
# Bab 32 - Alat siap pakai di contextlib dan modul lain
import io
from contextlib import ExitStack, redirect_stdout
from pathlib import Path

# redirect_stdout: tangkap apa yang dicetak
tangkap = io.StringIO()
with redirect_stdout(tangkap):
    print("ini tidak tampil di layar")
print("tertangkap:", repr(tangkap.getvalue()))

# ExitStack: jumlah berkas baru diketahui saat program jalan
folder = Path(__file__).parent / "_tmp"
folder.mkdir(exist_ok=True)
nama = ["kolam_a.txt", "kolam_b.txt", "kolam_c.txt"]
with ExitStack() as tumpukan:
    berkas = [tumpukan.enter_context(open(folder / n, "w", encoding="utf-8")) for n in nama]
    for i, f in enumerate(berkas):
        f.write(f"data kolam nomor {i + 1}\n")
    print("terbuka bersamaan:", len(berkas))
print("semua tertutup:", all(f.closed for f in berkas))
tertangkap: 'ini tidak tampil di layar\n'
terbuka bersamaan: 3
semua tertutup: True
  • redirect_stdout mengalihkan apa yang dicetak print ke penampung io.StringIO. Layar tidak menampilkan apa-apa, tetapi isinya bisa dibaca lagi ('ini tidak tampil di layar\n'). Berguna untuk menguji fungsi yang mencetak.
  • ExitStack dipakai bila jumlah sumber daya baru diketahui saat program berjalan. Di sini tiga berkas dibuka lewat enter_context, dan ketiganya ditutup bersama saat blok selesai.

Jebakan: with pada objek yang bukan context manager

06_bukan_context_manager.py
# Bab 32 - Salah: with pada objek yang bukan context manager
class Gudang:
    pass


with Gudang() as g:
    print("tidak akan sampai sini")
Traceback (most recent call last):
  File "06_bukan_context_manager.py", line 6, in <module>
    with Gudang() as g:
         ~~~~~~^^
TypeError: 'Gudang' object does not support the context manager protocol (missed __exit__ method)

Pesan galatnya jelas: kelas Gudang tidak punya __exit__ (dan __enter__), jadi tidak mendukung “protokol context manager”. Solusinya: tambahkan kedua metode itu, atau pakai @contextmanager.

Proyek Bersambung I4: menulis laporan dengan aman

Skenario nyata: Anda menulis laporan harian ke berkas. Di tengah penulisan, listrik padam atau program error. Berkas yang tertulis setengah itu menimpa laporan lama yang utuh. Solusi umum: tulis ke berkas sementara, dan hanya bila seluruh penulisan berhasil, tukar dengan berkas asli.

%%{init: {"flowchart": {"diagramPadding": 40}}}%%
flowchart TD
  A["Tulis ke laporan.txt.tmp"] --> B{"Berhasil sampai akhir?"}
  B -- "ya" --> C["os.replace: tukar dengan laporan.txt"]
  B -- "tidak (galat)" --> D["Buang berkas sementara,<br/>laporan lama tetap utuh"]

Penulisan aman: berkas sementara baru menggantikan laporan asli bila semuanya berhasil.

07_proyek_tulis_aman.py
# Bab 32 - Proyek bersambung I4: menulis laporan dengan aman
import os
from contextlib import contextmanager
from pathlib import Path

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


@contextmanager
def tulis_aman(tujuan):
    """Menulis ke berkas sementara; hanya bila sukses, menggantikan berkas asli."""
    sementara = Path(str(tujuan) + ".tmp")
    f = open(sementara, "w", encoding="utf-8")
    try:
        yield f
    except BaseException:
        f.close()
        sementara.unlink()               # gagal: buang yang setengah jadi
        print("gagal, berkas sementara dibuang")
        raise
    else:
        f.close()
        os.replace(sementara, tujuan)    # sukses: tukar sekaligus
        print("sukses, laporan disimpan")


laporan = folder / "laporan.txt"

with tulis_aman(laporan) as f:
    f.write("Panen kebun: 1030.0 kg\n")

try:
    with tulis_aman(laporan) as f:
        f.write("Data setengah jadi...\n")
        raise RuntimeError("listrik padam")
except RuntimeError as e:
    print("galat:", e)

print("isi laporan tetap utuh:", laporan.read_text(encoding="utf-8").strip())
print("sisa berkas sementara:", Path(str(laporan) + ".tmp").exists())
sukses, laporan disimpan
gagal, berkas sementara dibuang
galat: listrik padam
isi laporan tetap utuh: Panen kebun: 1030.0 kg
sisa berkas sementara: False

Telusuri:

  • Penulisan pertama sukses: “sukses, laporan disimpan”, dan isi Panen kebun: 1030.0 kg menggantikan berkas.
  • Penulisan kedua mensimulasikan listrik padam (RuntimeError) setelah menulis setengah isi. Pada blok except BaseException, berkas sementara ditutup dan dibuang, lalu galat dilempar ulang (raise) sehingga tertangkap di luar.
  • Hasil akhir: isi laporan tetap utuh seperti sebelum penulisan kedua, dan tidak ada berkas .tmp yang tertinggal (False).
  • os.replace menggantikan berkas tujuan. Menurut dokumentasi Python, bila berhasil operasi ini bersifat atomik (tidak terbelah di tengah), tetapi bisa gagal bila sumber dan tujuan berada di sistem berkas yang berbeda. Itu sebabnya berkas sementara ditaruh di folder yang sama dengan tujuan.

BaseException lebih luas daripada Exception: ia juga mencakup KeyboardInterrupt (Anda menekan Ctrl+C) dan SystemExit. Untuk pembersihan, kita ingin ikut tertangkap pada semua kasus itu, lalu melempar ulang agar galat tetap sampai ke pemanggil. Untuk menangani galat biasa, cukup Exception.

Latihan

  1. Tulis context manager kelas Pencatat yang mencetak “mulai” di __enter__ dan “selesai” di __exit__. Pakai dengan with dan tambahkan satu print di dalamnya.
  2. Ubah 03_contextmanager.py: tambahkan context manager sesi_kerja("penyiraman") ketiga yang mengalami ValueError, dan tangkap galat itu di luar. Apakah “dibersihkan” tetap tercetak?
  3. Gunakan contextlib.suppress(FileNotFoundError) untuk menghapus berkas _tmp/tidak_ada.txt dengan Path.unlink() tanpa error bila berkasnya memang tidak ada.
  4. Tantangan: tulis context manager catat_langkah(nama) memakai @contextmanager yang mencetak “[nama] mulai” dan “[nama] selesai”; bila terjadi galat, cetak “[nama] GAGAL” lalu lempar ulang galatnya. Uji dengan satu blok sukses dan satu blok gagal.

Ringkasan Bab

  • with menjamin pembersihan (menutup berkas, melepas sumber daya) walau terjadi galat.
  • Context manager = objek dengan __enter__ dan __exit__; __exit__ selalu jalan.
  • @contextmanager mengubah fungsi generator menjadi context manager; bungkus yield dengan try ... finally.
  • __exit__ yang mengembalikan True menelan galat; pakai suppress hanya untuk jenis galat yang diharapkan.
  • contextlib menyediakan suppress, redirect_stdout, dan ExitStack (jumlah sumber daya dinamis).
  • Proyek I4: laporan ditulis lewat berkas sementara, sehingga laporan lama tetap utuh bila penulisan gagal.

Selanjutnya, di Bab 33: type hint, cara memberi tahu manusia dan alat pemeriksa tentang jenis data yang diharapkan.