Bab 12 — Penanganan Error: Program yang Tidak Gampang Runtuh

Seri B3 — Program Kecil yang Berguna

CatatanDi bab ini, Anda akan:
  • Menangkap error dengan try, except, else, dan finally
  • Mengenali jenis error yang paling sering muncul
  • Memunculkan error sendiri dengan raise dan membuat jenis error sendiri
  • Membaca traceback (jejak error) dari bawah ke atas
  • Mencari kutu dengan print, assert, logging, dan mengenal debugger pdb

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

Bayangkan petugas lapangan mengetik “tiga puluh” padahal komputer menunggu angka 30. Tanpa persiapan, program berhenti mendadak dengan pesan merah yang menakutkan. Di bab-bab sebelumnya, error datang sebagai pelajaran. Sekarang Anda belajar bersiap menghadapinya, seperti petani memasang pagar di tepi kolam: bukan berharap tidak ada yang tersandung, melainkan memastikan tidak ada yang tercebur.

Dalam Python, error disebut exception (pengecualian). Saat ia terjadi dan tidak ada yang menangkapnya, program berhenti. Tugas kita: menangkap yang bisa diperbaiki, dan membiarkan yang memang fatal tampil jelas.

Alur try: bagian try dicoba; bila ada error masuk except, bila tidak masuk else; finally selalu dijalankan.

try dan except

01_try_dasar.py
# Bab 12 - try dan except: menangkap error agar program tidak runtuh
teks_diameter = "tiga puluh"          # petugas mengetik kata, bukan angka

try:
    diameter = float(teks_diameter)
    print("Diameter:", diameter)
except ValueError:
    print("Diameter harus berupa angka, bukan:", teks_diameter)

print("Program tetap berjalan sampai baris terakhir")
Diameter harus berupa angka, bukan: tiga puluh
Program tetap berjalan sampai baris terakhir

Python mencoba isi try. Saat float("tiga puluh") gagal dengan ValueError, loncat ke except ValueError. Sisa isi try dilewati, tetapi program berlanjut: baris terakhir tetap tercetak.

PeringatanAWAS

Jangan menulis except: kosong atau menangkap semua dengan except Exception: lalu diam saja. Error nyata akan tersembunyi dan Anda tidak tahu mengapa hasilnya aneh. Tangkap jenis error yang Anda perkirakan, dan lakukan sesuatu yang berarti (beri pesan, catat, atau pakai nilai cadangan).

Kenali jenis error

Tiap error punya jenis dan pesan. Skrip berikut sengaja membuat delapan error umum dan menampilkannya:

02_jenis_error.py
# Bab 12 - Jenis error yang paling sering muncul
def coba(nama, tindakan):
    try:
        tindakan()
    except Exception as e:
        print(f"{nama:14} -> {type(e).__name__}: {e}")


daftar = [10, 20, 30]
data = {"jenis": "Jati"}

coba("ValueError", lambda: int("tiga"))
coba("ZeroDivision", lambda: 10 / 0)
coba("IndexError", lambda: daftar[5])
coba("KeyError", lambda: data["diameter"])
coba("TypeError", lambda: "umur: " + 5)
coba("NameError", lambda: nama_tak_ada)
coba("AttributeError", lambda: daftar.tambah(40))
coba("FileNotFound", lambda: open("tidak_ada.txt"))
ValueError     -> ValueError: invalid literal for int() with base 10: 'tiga'
ZeroDivision   -> ZeroDivisionError: division by zero
IndexError     -> IndexError: list index out of range
KeyError       -> KeyError: 'diameter'
TypeError      -> TypeError: can only concatenate str (not "int") to str
NameError      -> NameError: name 'nama_tak_ada' is not defined
AttributeError -> AttributeError: 'list' object has no attribute 'tambah'
FileNotFound   -> FileNotFoundError: [Errno 2] No such file or directory: 'tidak_ada.txt'
Jenis Artinya Contoh penyebab di lapangan
ValueError nilai salah untuk tipe itu mengubah “tiga” jadi angka
ZeroDivisionError dibagi nol rata-rata dari daftar kosong
IndexError nomor urut di luar daftar mengambil plot ke-6 padahal ada 3
KeyError kunci dict tidak ada meminta kolom “diameter” yang tidak ada
TypeError tipe tidak cocok menggabung teks dengan angka
NameError nama variabel belum ada salah ketik nama variabel
AttributeError objek tidak punya fitur itu memanggil .tambah() pada list
FileNotFoundError berkas tidak ditemukan salah nama berkas (lihat Bab 13)

Pada skrip itu, except Exception as e dipakai hanya sebagai alat peraga, supaya semua jenis bisa ditampilkan dengan type(e).__name__ (nama jenisnya) dan e (pesannya).

else dan finally

03_else_finally.py
# Bab 12 - try / except / else / finally
def bagi(a, b):
    try:
        hasil = a / b
    except ZeroDivisionError:
        print("  [except] pembagi nol")
        return None
    else:
        print("  [else] tidak ada error")
        return hasil
    finally:
        print("  [finally] selalu dijalankan")


print("bagi(10, 4):")
print("  hasil =", bagi(10, 4))
print("bagi(10, 0):")
print("  hasil =", bagi(10, 0))
bagi(10, 4):
  [else] tidak ada error
  [finally] selalu dijalankan
  hasil = 2.5
bagi(10, 0):
  [except] pembagi nol
  [finally] selalu dijalankan
  hasil = None
  • else dijalankan hanya bila tidak ada error di try.
  • finally dijalankan selalu, ada error atau tidak, bahkan saat ada return. Cocok untuk “bersih-bersih” seperti menutup berkas.

Perhatikan bagi(10, 4): urutannya else lalu finally. Pada bagi(10, 0): except lalu finally.

raise: memunculkan error sendiri

Kadang tidak ada error bawaan, tetapi data jelas tidak masuk akal. pH air hanya mungkin 0 sampai 14. Kita sendiri yang memunculkannya dengan raise:

04_raise.py
# Bab 12 - raise: kita sendiri yang memunculkan error
def cek_ph(ph):
    if not 0 <= ph <= 14:
        raise ValueError(f"pH {ph} di luar rentang 0-14")
    return ph


for nilai in [7.2, 15, -1]:
    try:
        print("pH sah:", cek_ph(nilai))
    except ValueError as e:
        print("Ditolak:", e)
pH sah: 7.2
Ditolak: pH 15 di luar rentang 0-14
Ditolak: pH -1 di luar rentang 0-14

pH 7,2 lolos; 15 dan -1 ditolak dengan pesan yang kita tulis. Pesan error yang jelas menghemat banyak waktu: sebut apa yang salah dan nilainya.

Jenis error buatan sendiri

Untuk proyek besar, buatlah jenis error sendiri dengan mewarisi Exception (kelas dan pewarisan dibahas penuh di Bab 16; di sini cukup ikuti polanya):

05_error_sendiri.py
# Bab 12 - Membuat jenis error sendiri
class DataLapanganError(Exception):
    """Induk semua error data lapangan kita."""


class DiameterTidakWajar(DataLapanganError):
    def __init__(self, nilai):
        super().__init__(f"diameter {nilai} cm tidak wajar (harus 1 sampai 300)")
        self.nilai = nilai


def periksa_diameter(cm):
    if not 1 <= cm <= 300:
        raise DiameterTidakWajar(cm)
    return cm


for cm in [32.5, 0.2, 450]:
    try:
        print("OK:", periksa_diameter(cm))
    except DataLapanganError as e:           # menangkap semua keturunan DataLapanganError
        print(f"{type(e).__name__}: {e} (nilai={e.nilai})")
OK: 32.5
DiameterTidakWajar: diameter 0.2 cm tidak wajar (harus 1 sampai 300) (nilai=0.2)
DiameterTidakWajar: diameter 450 cm tidak wajar (harus 1 sampai 300) (nilai=450)

DiameterTidakWajar adalah turunan dari DataLapanganError, sehingga except DataLapanganError menangkap keduanya. Berguna: satu except bisa menangani seluruh keluarga error proyek kita, sementara error bawaan lain tetap lewat.

Membaca traceback

Bila error tidak tertangkap, Python mencetak traceback: jejak urutan pemanggilan fungsi sampai ke titik error.

06_baca_traceback.py
# Bab 12 - Membaca traceback: error tiga lapis fungsi
def rata_rata(angka):
    return sum(angka) / len(angka)


def ringkas_plot(nama, diameter):
    rata = rata_rata(diameter)
    print(f"Plot {nama}: rata-rata {rata:.1f} cm")


def laporan():
    ringkas_plot("A", [32.5, 45.2])
    ringkas_plot("B", [])           # plot B belum ada datanya


laporan()
Plot A: rata-rata 38.9 cm
Traceback (most recent call last):
  File "06_baca_traceback.py", line 16, in <module>
    laporan()
    ~~~~~~~^^
  File "06_baca_traceback.py", line 13, in laporan
    ringkas_plot("B", [])           # plot B belum ada datanya
    ~~~~~~~~~~~~^^^^^^^^^
  File "06_baca_traceback.py", line 7, in ringkas_plot
    rata = rata_rata(diameter)
  File "06_baca_traceback.py", line 3, in rata_rata
    return sum(angka) / len(angka)
           ~~~~~~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero

Bacalah dari bawah ke atas:

  1. Baris terakhir adalah inti: ZeroDivisionError: division by zero, yaitu jenis dan pesan error.
  2. Baris File ... in rata_rata tepat di atasnya menunjukkan tempat kejadian: pembagian sum(angka) / len(angka) dengan len(angka) bernilai 0.
  3. Ke atas lagi, ringkas_plot yang memanggilnya, lalu laporan, lalu <module> (kode tingkat paling luar). Itulah rantai pemanggilan.
  4. Tanda ~~~^^^ di bawah baris kode menunjuk bagian ekspresi yang bermasalah.
  5. Sebab sebenarnya ada di data: plot B daftarnya kosong. Plot A tercetak sebelum error, jadi jejaknya juga memberi tahu sampai mana program sempat jalan.
TipTIPS

Nomor baris pada traceback dihitung dari baris pertama berkas, termasuk baris komentar di paling atas. Karena itu nomor yang tampil di atas cocok persis dengan baris pada kode di buku dan pada berkas unduhan: hitung baris komentar pembuka sebagai baris 1.

Mencari kutu: print, assert, logging

Kesalahan program disebut bug (kutu). Mencarinya disebut debugging. Tiga alat dasar:

1. print debugging: cetak isi variabel di tiap langkah. Pakai repr() agar tanda kutip dan spasi tersembunyi tampak.

07_print_debug.py
# Bab 12 - Debugging dengan print: lihat isi variabel di tiap langkah
def bobot_total_kg(kolam):
    total = 0
    for k in kolam:
        bobot_kg = k["jumlah"] * k["bobot_g"] / 1000
        print("  [debug]", repr(k["nama"]), "->", bobot_kg)     # repr menampakkan tanda kutip
        total += bobot_kg
    return total


kolam = [
    {"nama": "A", "jumlah": 800, "bobot_g": 540.5},
    {"nama": "B", "jumlah": 1250, "bobot_g": 300.0},
]
print("Total:", bobot_total_kg(kolam), "kg")
  [debug] 'A' -> 432.4
  [debug] 'B' -> 375.0
Total: 807.4 kg

Kolam A 432,4 kg dan kolam B 375,0 kg, jumlahnya 807,4 kg. Dengan baris [debug] Anda melihat tiap suku penjumlahannya. Hapus baris itu setelah bug ketemu.

2. assert: pagar pengaman yang menyatakan “ini pasti benar”. Bila salah, program berhenti dengan AssertionError.

08_assert_lolos.py
# Bab 12 - assert: pagar pengaman "ini PASTI benar" (versi yang lolos)
def persen_hidup(awal, hidup):
    assert awal > 0, "jumlah awal harus lebih dari 0"
    assert 0 <= hidup <= awal, "ikan hidup tidak boleh lebih banyak dari awal"
    return hidup / awal * 100


print(f"{persen_hidup(1000, 940):.1f}%")
94.0%
09_assert_gagal.py
# Bab 12 - assert yang gagal: asumsi kita ternyata keliru
def persen_hidup(awal, hidup):
    assert awal > 0, "jumlah awal harus lebih dari 0"
    assert 0 <= hidup <= awal, "ikan hidup tidak boleh lebih banyak dari awal"
    return hidup / awal * 100


print(f"{persen_hidup(1000, 1040):.1f}%")
Traceback (most recent call last):
  File "09_assert_gagal.py", line 8, in <module>
    print(f"{persen_hidup(1000, 1040):.1f}%")
             ~~~~~~~~~~~~^^^^^^^^^^^^
  File "09_assert_gagal.py", line 4, in persen_hidup
    assert 0 <= hidup <= awal, "ikan hidup tidak boleh lebih banyak dari awal"
           ^^^^^^^^^^^^^^^^^^
AssertionError: ikan hidup tidak boleh lebih banyak dari awal

Ikan hidup (1040) lebih banyak daripada ikan awal (1000), tidak masuk akal, maka assert kedua menghentikan program dengan pesan yang kita tulis. Gunakan assert untuk menangkap kesalahan programmer, bukan kesalahan input pengguna.

PeringatanAWAS

Python dapat dijalankan dengan opsi -O yang membuang semua assert. Kami mencobanya pada skrip di atas: dengan python -O, hasilnya tercetak 104.0% tanpa error. Jadi jangan memakai assert sebagai satu-satunya pemeriksa input dari pengguna; pakai if dan raise.

3. logging: print yang lebih rapi, dengan tingkat kepentingan (DEBUG, INFO, WARNING, ERROR, CRITICAL) yang bisa disaring.

10_logging_awal.py
# Bab 12 - logging: catatan kerja program dengan tingkat kepentingan
import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.DEBUG,
                    format="%(levelname)-8s %(message)s")

logging.debug("memeriksa 3 baris data")
logging.info("data kolam A dibaca")
logging.warning("pH kolam B = 9.8, terlalu tinggi")
logging.error("data kolam C tidak ditemukan")

logging.getLogger().setLevel(logging.WARNING)       # sekarang hanya WARNING ke atas
logging.info("pesan ini tidak muncul")
logging.warning("pesan ini muncul")
DEBUG    memeriksa 3 baris data
INFO     data kolam A dibaca
WARNING  pH kolam B = 9.8, terlalu tinggi
ERROR    data kolam C tidak ditemukan
WARNING  pesan ini muncul

Setelah tingkat dinaikkan ke WARNING, pesan info tidak muncul lagi, tetapi warning tetap. Dengan logging, Anda tidak perlu menghapus pesan debug saat program selesai; cukup naikkan tingkatnya. (Pembahasan logging lebih jauh ada di Bab 19.)

Alat keempat adalah debugger pdb bawaan Python: fungsi breakpoint() menghentikan program di satu baris dan membuka prompt (Pdb) tempat Anda bisa mengetik p variabel (lihat isi), n (baris berikut), c (lanjut), dan q (keluar).

11_pdb_debugger.py
# Bab 12 - Debugger interaktif pdb: berhenti di satu baris dan mengintip isi variabel
def rata_rata(angka):
    total = sum(angka)
    breakpoint()                  # program berhenti di sini dan membuka prompt (Pdb)
    return total / len(angka)


print(rata_rata([32.5, 45.2, 28.0]))

Kode ini tidak dijalankan saat buku dibuat karena pdb menunggu ketikan pengguna, jadi keluarannya tidak ditampilkan. Coba sendiri di terminal: jalankan berkasnya, lalu ketik p total dan c.

Proyek Bersambung B3: tahan terhadap input salah

Dua skrip proyek. Pertama, meminta input sampai benar. Masukan tiruan (disediakan berkas .input) berisi lima jawaban: abc, -5, baris kosong, 450, dan akhirnya 32,5.

12_input_aman.py
# Bab 12 - Proyek B3: meminta angka sampai benar (tahan terhadap input salah)
def minta_diameter():
    while True:
        teks = input("Diameter pohon (cm): ")
        try:
            nilai = float(teks.replace(",", "."))      # terima "32,5" dan "32.5"
        except ValueError:
            print("  Bukan angka, coba lagi.")
            continue
        if not 1 <= nilai <= 300:
            print("  Harus antara 1 dan 300 cm, coba lagi.")
            continue
        return nilai


hasil = minta_diameter()
print("Diameter tercatat:", hasil)
Diameter pohon (cm): abc
  Bukan angka, coba lagi.
Diameter pohon (cm): -5
  Harus antara 1 dan 300 cm, coba lagi.
Diameter pohon (cm): 
  Bukan angka, coba lagi.
Diameter pohon (cm): 450
  Harus antara 1 dan 300 cm, coba lagi.
Diameter pohon (cm): 32,5
Diameter tercatat: 32.5

Empat jawaban pertama ditolak dengan alasan berbeda (bukan angka, di luar rentang), dan jawaban 32,5 diterima setelah koma diganti titik. Pola while True + try/except + continue + return adalah resep standar “ulangi sampai benar”.

Kedua, membersihkan baris data campur aduk yang kelak datang dari berkas (Bab 13 dan 14). Ada enam baris; tiga di antaranya rusak:

13_proyek_tahan_salah.py
# Bab 12 - Proyek B3: membersihkan baris data yang campur aduk tanpa runtuh
import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.WARNING, format="%(levelname)s: %(message)s")


class BarisSalah(Exception):
    pass


def baca_baris(teks):
    """Mengubah 'plot,jenis,diameter' menjadi dict, atau munculkan BarisSalah."""
    bagian = [b.strip() for b in teks.split(",")]
    if len(bagian) != 3:
        raise BarisSalah(f"harus 3 kolom, ada {len(bagian)}: {teks!r}")
    plot, jenis, diameter = bagian
    try:
        diameter = float(diameter.replace(",", "."))
    except ValueError:
        raise BarisSalah(f"diameter bukan angka: {diameter!r}") from None
    if not 1 <= diameter <= 300:
        raise BarisSalah(f"diameter di luar 1-300: {diameter}")
    return {"plot": plot, "jenis": jenis, "diameter_cm": diameter}


mentah = ["A,Jati,32.5", "A,Meranti,45.2", "B,Mahoni,dua puluh", "B,Sengon", "C,Jati,-3", "C,Jati,36.0"]
bersih, ditolak = [], 0
for baris in mentah:
    try:
        bersih.append(baca_baris(baris))
    except BarisSalah as e:
        logging.warning("baris ditolak: %s", e)
        ditolak += 1

print(f"Diterima {len(bersih)} baris, ditolak {ditolak}")
for b in bersih:
    print(b)
WARNING: baris ditolak: diameter bukan angka: 'dua puluh'
WARNING: baris ditolak: harus 3 kolom, ada 2: 'B,Sengon'
WARNING: baris ditolak: diameter di luar 1-300: -3.0
Diterima 3 baris, ditolak 3
{'plot': 'A', 'jenis': 'Jati', 'diameter_cm': 32.5}
{'plot': 'A', 'jenis': 'Meranti', 'diameter_cm': 45.2}
{'plot': 'C', 'jenis': 'Jati', 'diameter_cm': 36.0}

Alurnya: baca_baris memunculkan BarisSalah untuk tiap jenis kerusakan; perulangan menangkapnya, mencatat peringatan dengan logging, dan lanjut ke baris berikutnya. Hasil akhir: 3 baris diterima, 3 ditolak, dan program tidak pernah runtuh. Bagian from None menyembunyikan error asli (ValueError) supaya pesan kita yang tampil; tanpa itu, traceback akan memuat dua error yang membingungkan pembaca.

Latihan

  1. Tulis fungsi ke_angka(teks) yang mengembalikan float dari teks, atau None bila teksnya bukan angka. Uji dengan "12,5", "abc", dan "".
  2. Ubah 13_proyek_tahan_salah.py supaya diameter di atas 150 cm tidak ditolak, tetapi hanya menghasilkan logging.warning("pohon sangat besar").
  3. Buat assert pada fungsi rata_rata(angka) yang memastikan daftar tidak kosong. Bandingkan pesan errornya dengan ZeroDivisionError di traceback tadi.
  4. Tantangan: buat kelas error PhTidakWajar(DataLapanganError) dan fungsi periksa_kolam(daftar) yang mengumpulkan semua kolam bermasalah (bukan berhenti di yang pertama), lalu mencetak ringkasan.

Ringkasan Bab

  • try / except menangkap error; else jalan bila tidak ada error; finally selalu jalan.
  • Tangkap jenis error yang spesifik; hindari except: kosong.
  • raise memunculkan error sendiri; jenis error baru dibuat dengan mewarisi Exception.
  • Traceback dibaca dari bawah: jenis dan pesan, tempat kejadian, lalu rantai pemanggilan.
  • Alat debugging: print (dengan repr), assert (bisa dimatikan dengan -O), logging, dan pdb/breakpoint().
  • Proyek B3: program meminta input sampai benar dan menolak baris data rusak tanpa runtuh.

Selanjutnya, di Bab 13: file dan I/O dasar, cara program menyimpan dan membaca data agar tidak hilang saat ditutup.