Yang menarik dari lab ini bukan satu jawabannya, tapi kenapa ada tiga. Tiga gejala — nota yang isinya sama, total Rp0, kas Rp0 — kelihatan seperti satu penyakit. Padahal tiga lapisnya beda, dan tiap lapis punya obat yang beda.
Temanya cuma satu, dan ini yang paling layak dibawa pulang: mana yang kamu ubah, dan benda apa itu sebenarnya. Tiap lapis rusak karena penulisnya mengira dia mengubah satu benda, padahal yang dia ubah benda lain — atau nggak mengubah apa pun.
Poin Penting
- Tiga lapis beda, satu tema: mana yang kamu ubah, dan benda apa itu sebenarnya.
- Nilai default sebuah fungsi dihitung sekali, jadi list di nilai default-nya dipakai bersama selamanya.
- Mengubah nama lokal nggak mengubah isi dict yang dipegang pemanggil.
- Nama yang diubah di dalam fungsi dianggap lokal, kecuali kamu bilang terus terang bahwa itu milik modul.
- Memperbaiki lapis 1 saja masih menyisakan 4 dari 6 merah.
Hasil pengukuran
Pemeriksaannya enam. Angka di kolom kanan: berapa yang masih merah.
| varian | merah |
|---|---|
| kondisi awal | 6 dari 6 |
| cuma memperbaiki lapis 1 | 4 dari 6 |
| ketiga lapis sekaligus | 0 dari 6 (hijau) |
Baris tengah itu yang paling sering kelewat. Memperbaiki lapis yang paling terkenal saja masih menyisakan 4 dari 6 merah, sebab dua masalah lain ada di tempat yang beda.
Lapis 1 — nilai default yang dihitung sekali
Ini lapis yang paling dikenal orang.
def pesanan_baru(meja, item=[]): # salah
Kelihatannya wajar: "kalau pemanggil nggak mengirim item, bikin list kosong baru." Yang nggak kelihatan: nilai default sebuah fungsi dihitung sekali, waktu fungsinya baru didefinisikan di memori, bukan tiap kali dipanggil. Jadi [] di situ bukan list kosong baru setiap panggilan — dia satu list yang sama, dipakai ulang selamanya. Semua nota berbagi list itu.
def pesanan_baru(meja, item=None): # benar
if item is None:
item = []
return {"meja": meja, "item": item, "total": 0}
Kamu nggak perlu percaya kata orang; cek sendiri:
kasir.pesanan_baru(1)["item"] is kasir.pesanan_baru(2)["item"] # harus False
Bahkan buktinya nggak perlu ditakar: pesanan_baru.__defaults__ menyimpan list itu secara harfiah. Isi satu nota, lalu lihat __defaults__ — item yang baru kamu masukkan muncul di situ juga, karena pesanan["item"] dan nilai default itu benda yang sama. Perhatikan juga: pesanan_baru sendiri nggak pernah menambah apa pun ke list itu. Yang menambah adalah fungsi lain, dan tetap saja list itu bertambah — karena cuma ada satu.
Lapis 2 — mengubah nama lokal, bukan nilai di dalam dict
total = pesanan["total"] # menyalin NILAI
total = total + harga * qty # cuma nama lokal yang berubah
return pesanan # nota nggak pernah tahu
total = pesanan["total"] bikin nama baru total yang menunjuk ke angka yang sama. Baris berikutnya mengubah angka di nama itu — tapi pesanan masih memegang angka lama. Nama lokal mati begitu fungsinya selesai.
pesanan["total"] = pesanan["total"] + harga * qty # benar
Bedanya halus, tapi ini intinya: yang diubah adalah isi dict yang dipegang pemanggil. Mengubah nama variabel belum tentu mengubah benda yang kamu maksud — nama yang sama di dua tempat bukan jaminan benda yang sama.
Lapis 3 — nama milik modul, atau nama milik fungsi
TOTAL_KAS = 0
def catat_penjualan(jumlah):
total = TOTAL_KAS # bikin nama lokal baru
total = total + jumlah # cuma lokalnya yang berubah
return total # TOTAL_KAS nggak pernah tersentuh
Penulis aslinya berhenti di tengah jalan: niatnya menambah ke TOTAL_KAS, tapi menulisnya lewat variabel lain. Akibatnya TOTAL_KAS tetap nol sampai kapan pun. Kalau dia menulisnya langsung tanpa bilang ke Python bahwa nama itu milik modul:
def catat_penjualan(jumlah):
TOTAL_KAS = TOTAL_KAS + jumlah # UnboundLocalError
Python malah melempar UnboundLocalError. Sebabnya: begitu ada penugasan ke sebuah nama di dalam fungsi, Python menganggap nama itu lokal untuk seluruh fungsi tersebut. Waktu dia membaca TOTAL_KAS di sebelah kanan, Python belum menganggapnya ada. Ironisnya error itu justru lebih baik, karena keliatan. Penulis aslinya menghindarinya, dan itu yang bikin bugnya senyap.
def catat_penjualan(jumlah):
global TOTAL_KAS # benar
TOTAL_KAS = TOTAL_KAS + jumlah
return TOTAL_KAS
global bilang ke Python: nama TOTAL_KAS di sini menunjuk yang di tingkat modul, bukan bikin yang baru di sini.
Jebakan yang gampang kejeblos
item or [] sebagai ganti if item is None. Kelihatan lebih ringkas dan di demo ini tetap benar — tapi salah begitu ada pemanggil yang sengaja mengirim list kosong sebagai argumen, karena item or [] menggantinya dengan list baru, padahal pemanggil mau mengisi list yang dia kirim.
Nambah .copy() di mana-mana biar nggak ada yang saling nimpa. .copy() bikin wadah baru, bukan isi baru. Kalau isinya benda yang bisa berubah juga, salinannya masih berbagi isi dengan aslinya. Sebelum menambah salinan, tanya dulu: yang mau kamu pisahkan wadahnya atau isinya?
Ganti nama variabel biar "beda". Kalau total diganti jadi total_nota, bugnya nggak hilang — cuma pindah nama. Yang salah bukan namanya, tapi fakta bahwa yang berubah adalah nama lokal dan bukan nilai di dalam dict.
Setelah ketiganya beres
make check # harus 6 dari 6 LULUS
make demo # tiga meja, tiga nota, sesuai pesanannya
Di situ make demo berhenti mengeluh, dan tiga notanya jadi tiga nota yang beda: meja 1 kopi x2 dengan total Rp30.000, meja 3 teh x1 Rp8.000, meja 5 roti x1 Rp12.000, kas harian Rp50.000.
Dan pertanyaan yang paling berguna buat dibawa keluar lab ini bukan "di mana bugnya", tapi yang satu ini: waktu kamu menulis x = ..., benda mana yang sebenarnya kamu ubah? Jawab itu dengan benar, dan ketiga lapis di atas berhenti jadi misteri.
Pertanyaan buat kamu
- Kalau
pesanan_barumenerima list dari pemanggilnya, siapa yang seharusnya bertanggung jawab waktu list itu berubah setelahnya — yang mengirim, atau yang menerima? - Lapis 2 dan lapis 3 kelihatan seperti dua bug yang beda. Tapi keduanya bisa diringkas jadi satu pertanyaan yang sama persis. Pertanyaan apa itu?
- Kalau
TOTAL_KASbukan angka melainkan dict, misalnya{"kas": 0}, apakah lapis 3 masih perlu bilang ke Python bahwa nama itu milik modul? Coba di kertas dulu, baru di terminal.