Laporan rekening bank yang kamu jalankan mencetak setoran Ani di dalam riwayat Budi. Saldonya benar dan tidak ada error yang muncul, jadi tidak ada yang menunjuk ke body class. Di situlah bug-nya: sebuah class attribute yang bisa diubah dan dipakai bersama semua akun.
Ini output dari skrip yang membuat Account("Ani", 100) dan Account("Budi", 0), lalu memanggil ani.deposit(50) dan budi.deposit(20):
ani.balance 150
budi.balance 20
ani.history ['Ani deposit 50', 'Budi deposit 20']
budi.history ['Ani deposit 50', 'Budi deposit 20']
same list: True
Account.history ['Ani deposit 50', 'Budi deposit 20']
ani.__dict__ {'owner': 'Ani', 'balance': 150}
Baca baris terakhir. __dict__ milik Ani hanya berisi owner dan balance, tanpa history. Atribut itu tidak pernah dipasang di objeknya, jadi pencariannya menemukan list milik class.
Membaca self.history mencari di objek dulu, baru di class. self.history.append(x) adalah pemanggilan method pada list hasil pencarian itu. Itu bukan assignment, jadi tidak ada yang ditulis ke objek. Tutorial class di Python membahas urutan pencarian ini.
Poin Penting
- List yang dipasang di body class adalah milik class, jadi setiap objek membaca list yang sama sampai objek itu punya list sendiri.
self.history.append(x)membaca atribut class lalu mengubahnya; tidak ada atribut yang ditulis ke objek, itu sebabnyahistorytidak muncul diani.__dict__.self.balance = ...kelihatan benar karena assignment selalu menulis di objek yang memegang namanya.- Buat state yang bisa berubah di
__init__, dan sisakan class attribute untuk nilai yang tidak boleh diubah objek mana pun.
Dua berkas, satu perintah
Mulai dari versi yang rusak. List-nya duduk di body class, di atas __init__:
# account.py
class Account:
history = []
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance = self.balance + amount
self.history.append(f"{self.owner} deposit {amount}")
Berikutnya, sebuah checker. Skrip ini membuat dua akun, melakukan satu setoran di masing-masing, lalu mengajukan empat pertanyaan:
# check_account.py
import account
passed = 0
total = 0
def check(name, ok, detail=""):
global passed, total
total += 1
if ok:
passed += 1
print(f"PASS {name}")
else:
print(f"FAIL {name} <- {detail}")
ani = account.Account("Ani", 100)
budi = account.Account("Budi", 0)
ani.deposit(50)
budi.deposit(20)
check("a deposit lands in the account that made it", ani.balance == 150, f"balance is {ani.balance}")
check("the other account's balance does not move", budi.balance == 20, f"balance is {budi.balance}")
check(
"each account has one line of history",
len(ani.history) == 1 and len(budi.history) == 1,
f"ani has {len(ani.history)}, budi has {len(budi.history)}",
)
check(
"the two history lists are different lists",
ani.history is not budi.history,
"both accounts point at the same list",
)
print()
print(f"{passed} of {total} checks passed")
raise SystemExit(0 if passed == total else 1)
Jalankan python3 check_account.py dengan versi rusak di atas. Exit code-nya 1:
PASS a deposit lands in the account that made it
PASS the other account's balance does not move
FAIL each account has one line of history <- ani has 2, budi has 2
FAIL the two history lists are different lists <- both accounts point at the same list
2 of 4 checks passed
Dua pemeriksaan sudah lolos, dan itu jebakannya. self.balance = ... adalah assignment, dan assignment selalu menulis di objeknya, jadi setiap akun punya balance sendiri. self.history.append(...) tidak pernah melakukan assignment. Saldo adalah hal pertama yang biasanya kamu uji, dan saldonya kelihatan benar.
Sekarang pindahkan list-nya ke __init__, supaya setiap objek membuat list sendiri:
# account.py
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.history = []
def deposit(self, amount):
self.balance = self.balance + amount
self.history.append(f"{self.owner} deposit {amount}")
Jalankan perintah yang sama lagi. Kali ini exit code-nya 0:
PASS a deposit lands in the account that made it
PASS the other account's balance does not move
PASS each account has one line of history
PASS the two history lists are different lists
4 of 4 checks passed
Semua ini dijalankan di Python 3.14.9 di Linux. Versi Python lain belum dicoba, jadi ini bukti dari satu mesin.
Yang tidak dilakukan checker ini
Class attribute tetap tempat yang benar untuk nilai yang tidak pernah diubah siapa pun. Checker tidak bisa membedakan konstanta bersama dari list bersama, dan memang tidak perlu. Berbagi bukan masalahnya; mengubah sesuatu yang dipakai bersama itulah masalahnya.
history yang kosong juga bukan bukti bahwa kamu sudah memperbaikinya. Kedua versi memulai setiap akun dengan list kosong. Bug-nya baru muncul begitu objek kedua ada, dan itulah yang dibangun oleh checker.
Rusak dengan sengaja
Taruh history = [] kembali di body class. Lalu tambahkan satu baris di __init__:
self.history = Account.history
Kelihatannya setiap akun sekarang dapat list sendiri, tapi jalankan checker-nya dan dua pemeriksaan gagal lagi:
PASS a deposit lands in the account that made it
PASS the other account's balance does not move
FAIL each account has one line of history <- ani has 2, budi has 2
FAIL the two history lists are different lists <- both accounts point at the same list
2 of 4 checks passed
Baris baru itu menyalin objek list yang sama ke kedua objek, jadi keduanya masih berbagi list itu.
Sebelum menaruh list, dict, atau set di body class, ajukan satu pertanyaan: apakah objek kedua seharusnya melihat perubahan ini? Kalau iya, bagikan dengan sengaja. Kalau tidak, buat di __init__.