Menulis context manager

Menulis context manager sendiri melalui __enter__/__exit__ dan @contextmanager.

Daftar isi

Context manager adalah objek yang menetapkan pekerjaan persiapan dan pembersihan di sekitar sebuah blok kode, dan pernyataan with memastikan keduanya dijalankan. Pustaka standar sudah memakai pola ini untuk berkas, kunci (lock), dan koneksi, tetapi Python juga menyediakan protokol untuk membuat context manager sendiri, baik berupa kelas dengan metode __enter__ dan __exit__ maupun fungsi generator dengan decorator @contextmanager. Topik ini membahas keduanya beserta aturan penanganan exception di dalamnya.

Kelas dengan enter dan exit#

Sebuah kelas menjadi context manager dengan menyediakan dua metode khusus. Metode __enter__ dipanggil saat masuk blok with, dan nilai kembaliannya ditugaskan ke variabel setelah kata as. Metode __exit__ dipanggil saat blok selesai, baik selesai normal maupun karena exception, sehingga pekerjaan pembersihan tidak pernah terlewat.

python Contoh: context manager pelacak tahap
class Pelacak:
    def __init__(self, nama):
        self.nama = nama

    def __enter__(self):
        print("masuk", self.nama)
        return self

    def __exit__(self, tipe, nilai, traceback):
        print("keluar", self.nama)
        return False

with Pelacak("tahap-1") as p:
    print("badan with berjalan")

print("selesai")
text Keluaran
masuk tahap-1
badan with berjalan
keluar tahap-1
selesai

Keluaran menegaskan urutannya, yaitu __enter__ terlebih dahulu, badan with, lalu __exit__ tanpa syarat. Parameter __exit__ berjumlah tiga dan berisi informasi exception apabila badan blok gagal, atau None untuk ketiganya bila blok selesai normal. Diagram berikut merangkum cabang keputusan yang terjadi di sekitar blok with.

with kelola() as xmemanggil __enter__()badan with dijalankantanpa errorterjadi error__exit__(None, None, None)nilai kembalian diabaikan__exit__(tipe, nilai, traceback)True: ditelan, False: dilempar
Alur pernyataan with dari pemanggilan __enter__ sampai __exit__

Menelan atau meneruskan exception#

Nilai kembalian __exit__ menentukan nasib exception yang terjadi di badan blok. Nilai True menandakan exception sudah ditangani, sehingga with menelannya dan program berlanjut, sedangkan nilai False atau None membiarkan exception menyebar ke luar. Pembersihan di dalam __exit__ tetap berjalan pada kedua keadaan tersebut.

python Contoh: __exit__ yang menelan exception
class TangkapSemua:
    def __enter__(self):
        return self

    def __exit__(self, tipe, nilai, traceback):
        if tipe is not None:
            print("menangkap", tipe.__name__)
        return True

with TangkapSemua():
    raise ValueError("contoh")

print("program berlanjut")
text Keluaran
menangkap ValueError
program berlanjut

Pemanggilan raise di badan blok tidak menghentikan program karena __exit__ mengembalikan True setelah mencatat nama exceptionnya. Bandingkan dengan versi yang mengembalikan False pada contoh berikut, yang tetap membersihkan namun membiarkan exception naik.

python Contoh: __exit__ yang meneruskan exception
class JanganTangkap:
    def __enter__(self):
        return self

    def __exit__(self, tipe, nilai, traceback):
        print("bersih tetap dijalankan meski error")
        return False

try:
    with JanganTangkap():
        raise ValueError("contoh")
except ValueError as e:
    print("tertangkap di luar:", e)
text Keluaran
bersih tetap dijalankan meski error
tertangkap di luar: contoh

Program utuh: transaksi dictionary#

Contoh berikut merangkai protokol di atas menjadi context manager transaksi, yaitu context manager yang mencadangkan isi dictionary saat masuk dan memulihkannya bila badan blok berakhir dengan exception. Pola semacam ini berguna untuk pembaruan data yang harus bersifat utuh, berhasil semua atau tidak sama sekali.

python transaksi.py
class Transaksi:
    """Context manager yang memulihkan isi dictionary bila terjadi error."""

    def __init__(self, data):
        self.data = data

    def __enter__(self):
        self.cadangan = dict(self.data)
        return self.data

    def __exit__(self, tipe, nilai, traceback):
        if tipe is not None:
            self.data.clear()
            self.data.update(self.cadangan)
            print("transaksi dibatalkan, data dipulihkan")
            return True
        print("transaksi berhasil, data tersimpan")
        return False

stok = {"apel": 10, "jeruk": 5}

with Transaksi(stok) as data:
    data["apel"] = 8
    data["mangga"] = 4

print(dict(sorted(stok.items())))

with Transaksi(stok) as data:
    data["apel"] = 0
    raise RuntimeError("gudang tutup")

print(dict(sorted(stok.items())))
text Keluaran
transaksi berhasil, data tersimpan
{'apel': 8, 'jeruk': 5, 'mangga': 4}
transaksi dibatalkan, data dipulihkan
{'apel': 8, 'jeruk': 5, 'mangga': 4}

Simpan berkas di atas di direktori kerja, lalu jalankan dengan perintah berikut.

bash
# menjalankan program context manager transaksi
python3 transaksi.py
text Keluaran
transaksi berhasil, data tersimpan
{'apel': 8, 'jeruk': 5, 'mangga': 4}
transaksi dibatalkan, data dipulihkan
{'apel': 8, 'jeruk': 5, 'mangga': 4}

Transaksi pertama selesai normal, sehingga perubahan apel dan penambahan mangga bertahan. Transaksi kedua mengubah apel menjadi 0 sebelum menimbulkan RuntimeError, dan __exit__ memulihkan isi dictionary dari cadangannya, sehingga cetakan terakhir kembali menampilkan apel bernilai 8. Metode __enter__ mengembalikan dictionary aslinya, sehingga badan blok bekerja langsung pada data yang dipantau, tanpa salinan tambahan di luar cadangan internal.

@contextmanager dari contextlib#

Untuk context manager sederhana, menulis kelas lengkap menjadi berlebihan. Modul contextlib menyediakan decorator @contextmanager yang mengubah fungsi generator menjadi context manager, dengan pekerjaan sebelum yield sebagai pengganti __enter__ dan pekerjaan sesudahnya sebagai pengganti __exit__.

python Contoh: context manager dari generator
from contextlib import contextmanager

@contextmanager
def indikator(nama):
    print("mulai", nama)
    try:
        yield
    finally:
        print("selesai", nama)

with indikator("unduh"):
    print("proses berjalan")
text Keluaran
mulai unduh
proses berjalan
selesai unduh

Kode sebelum yield berjalan saat masuk blok, sedangkan kode pada klausa finally berjalan saat keluar blok, termasuk ketika badan with menimbulkan exception. Dengan demikian, membungkus yield dengan try/finally merupakan padanan langsung dari logika pembersihan pada __exit__, dan bentuk generator ini memang dirancang untuk kasus persiapan beserta pembersihannya saja.

Satu pernyataan with juga dapat membuka beberapa context manager sekaligus yang dipisahkan koma, dan __enter__ dipanggil menurut urutan penulisannya. Bentuk ini menggantikan dua blok with yang bersarang ketika kedua sumber daya dibuka bersama, misalnya berkas masukan dan berkas keluaran pada penyalinan teks, sehingga urutan pembukaan dan penutupannya tetap rapi pada satu baris.

Kesalahan umum#

Kesalahan pertama adalah menaruh kode pembersihan setelah yield tanpa try/finally. Bila badan with menimbulkan exception, eksekusi generator berhenti di yield, sehingga baris pembersihan tidak pernah tercapai.

python Contoh: pembersihan tanpa finally
from contextlib import contextmanager

@contextmanager
def rapuh(nama):
    print("mulai", nama)
    yield
    print("selesai", nama)

try:
    with rapuh("unduh"):
        raise RuntimeError("terputus")
except RuntimeError as e:
    print("tertangkap:", e)
text Keluaran
mulai unduh
tertangkap: terputus

Cetakan selesai unduh yang semestinya muncul tidak ada, dan sumber daya yang seharusnya dilepas tetap tertahan. Perbaikannya mengikuti pola indikator, yaitu membungkus yield dengan try/finally agar pembersihan berjalan pada setiap jalur keluar.

Kesalahan kedua adalah mendefinisikan __exit__ tanpa tiga parameternya. Protokol memanggil __exit__ dengan tiga argumen, sehingga definisi yang lebih pendek menimbulkan error tepat saat blok selesai.

python Contoh: tanda tangan __exit__ yang keliru
class Keliru:
    def __enter__(self):
        return self

    def __exit__(self):
        pass

try:
    with Keliru():
        pass
except TypeError as e:
    print(type(e).__name__, str(e))
text Keluaran
TypeError Keliru.__exit__() takes 1 positional argument but 4 were given

Pesan errornya menyebut empat argumen, yaitu objek self ditambah tiga informasi exception dari protokol. Perbaikannya adalah mendeklarasikan __exit__(self, tipe, nilai, traceback) meskipun ketiga parameternya tidak semuanya dipakai, karena kontraknya ditetapkan oleh protokol, bukan oleh kebutuhan badan metode.

Ringkasan#

  • Kelas menjadi context manager dengan metode __enter__, yang nilainya ditugaskan ke as, dan metode __exit__ yang selalu dipanggil saat blok berakhir.
  • Nilai kembalian __exit__ menentukan nasib exception, yaitu True menelannya, sedangkan False atau None membiarkannya menyebar.
  • Decorator @contextmanager mengubah generator ber-satu-yield menjadi context manager, dengan try/finally di sekitar yield sebagai pembersihnya.
  • Pembersihan pada __exit__ maupun pada klausa finally berjalan baik blok selesai normal maupun gagal, sehingga perilakunya setara dan pemilihannya bergantung pada keringkasan yang dibutuhkan.

Referensi#