Bug paling licik di kode Python sering berbentuk kalimat sederhana: “saya hanya mengubah list Siti, kenapa list Budi ikut berubah?”. Akar masalahnya adalah dua variabel yang menunjuk ke satu objek list yang sama — objek yang bisa diubah (mutable) kapan saja oleh siapa pun. Immutability menawarkan jalan keluarnya: data yang tidak bisa diubah setelah dibuat, sehingga tidak ada pihak yang bisa mengubahnya diam-diam.

Rekap Tipe Immutable di Python #

Python membagi tipe dasar menjadi dua kubu. Kubu mutable mencakup list, dict, dan set. Kubu immutable mencakup int, float, bool, str, tuple, dan frozenset — tipe terakhir ini sudah kita kenal pada Chapter Set.

Pada tipe immutable, “mengubah nilai” sebenarnya berarti membuat objek baru. Method upper() tidak mengubah string asli, melainkan mengembalikan string baru:

python
nama = "budi"
nama_baru = nama.upper()
print(nama, "|", nama_baru)

koordinat = (-6.2146, 106.8451)
try:
    koordinat[0] = -7.0
except Exception as galat:
    print(type(galat).__name__, "-", galat)

Output yang Dihasilkan:

text
budi | BUDI
TypeError - 'tuple' object does not support item assignment

Setelah baris nama_baru = nama.upper(), variabel nama masih berisi budi — string lama tidak tersentuh. Upaya menulis elemen tuple langsung ditolak interpreter Python dengan TypeError.

Tuple sebagai Record dan frozenset #

Tuple bukan sekadar list yang terkunci. Tuple yang sudah kita pelajari pada Chapter Tuple paling pas dipakai sebagai record — kumpulan nilai yang posisinya bermakna:

python
kantor = (-6.2146, 106.8451)
item = ("kopi", 45000)

lat, lon = kantor
print(f"Lat: {lat}, Lon: {lon}")
print(f"{item[0]}: Rp{item[1]:,}")

Output yang Dihasilkan:

text
Lat: -6.2146, Lon: 106.8451
kopi: Rp45,000

Tuple pertama memetakan posisi latitude, longitude; tuple kedua memetakan nama, harga. Pola pembacaan lat, lon = kantor (unpacking) menegaskan bahwa urutan elemen adalah bagian dari makna data.

Untuk kumpulan nilai unik yang tidak boleh berubah, Python menyediakan frozenset. Operasi yang membentuk set baru tetap sah; operasi yang memutasi ditolak:

python
stok = frozenset({"kopi", "gula", "teh"})
tambahan = frozenset({"susu"})

stok_baru = stok | tambahan
print(sorted(stok_baru))

try:
    stok.add("susu")
except Exception as galat:
    print(type(galat).__name__, "-", galat)

Output yang Dihasilkan:

text
['gula', 'kopi', 'susu', 'teh']
AttributeError - 'frozenset' object has no attribute 'add'

Operator | menggabungkan dua frozenset menjadi frozenset baru, sementara stok asli tidak berubah. Method add memang tidak pernah ada pada frozenset, sehingga upalan mutasi langsung terlihat sebagai AttributeError — kegagalan yang terjadi saat menulis kode, bukan bug senyap saat produksi.

Bug Klasik: Shared Mutable State #

Sekarang mari kita reproduksi bug di awal chapter:

python
keranjang_budi = ["kopi", "gula"]
keranjang_siti = keranjang_budi

def tambah_item(keranjang, item):
    keranjang.append(item)
    return keranjang

tambah_item(keranjang_siti, "teh")
print("keranjang_siti:", keranjang_siti)
print("keranjang_budi:", keranjang_budi)

Output yang Dihasilkan:

text
keranjang_siti: ['kopi', 'gula', 'teh']
keranjang_budi: ['kopi', 'gula', 'teh']

Pada baris keranjang_siti = keranjang_budi, operator = tidak menyalin list — keduanya menjadi dua nama untuk satu objek. Ketika tambah_item memanggil append lewat nama keranjang_siti, satu-satunya list yang ada berubah, dan keranjang_budi ikut melapor perubahan tersebut. Inilah aliasing: bug yang muncul karena mutable data dipakai bersama.

Disiplin Copy-on-Write #

Jalan keluarnya bukan melarang semua perubahan, melainkan mengubah kebiasaan: function yang perlu “mengubah” koleksi justru mengembalikan koleksi baru dan membiarkan input asli utuh:

python
def tambah_item_menimpa(keranjang, item):
    keranjang.append(item)
    return keranjang

def tambah_item_baru(keranjang, item):
    return [*keranjang, item]

daftar_a = ["kopi", "gula"]
hasil_a = tambah_item_menimpa(daftar_a, "teh")
print("daftar_a:", daftar_a)
print("objek sama:", hasil_a is daftar_a)

daftar_b = ["kopi", "gula"]
hasil_b = tambah_item_baru(daftar_b, "teh")
print("daftar_b:", daftar_b)
print("objek sama:", hasil_b is daftar_b)

Output yang Dihasilkan:

text
daftar_a: ['kopi', 'gula', 'teh']
objek sama: True
daftar_b: ['kopi', 'gula']
objek sama: False

Versi tambah_item_menimpa memodifikasi list milik pemanggil — daftar_a berubah dan hasil balikan masih objek yang sama. Versi tambah_item_baru membangun list baru lewat ekspresi [*keranjang, item], sehingga daftar_b tetap bersih dan hasil balikan adalah objek berbeda. Pemanggil yang menginginkan perubahan harus menulis daftar_b = tambah_item_baru(daftar_b, "teh") secara eksplisit — perubahan state selalu terlihat di kode.

Data Terstruktur yang Terkunci: frozen dataclass #

Untuk data terstruktur, kita bisa memakai dataclass dengan opsi frozen=True — fitur yang dibahas tuntas pada Chapter Dataclasses:

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Titik:
    latitude: float
    longitude: float

kantor = Titik(-6.2146, 106.8451)
print(kantor)

try:
    kantor.latitude = 0.0
except Exception as galat:
    print(type(galat).__name__, "-", galat)

Output yang Dihasilkan:

text
Titik(latitude=-6.2146, longitude=106.8451)
FrozenInstanceError - cannot assign to field 'latitude'

frozen=True menambah atribut dan method kenyamanan dataclass (repr rapi, __eq__) sekaligus mengunci semua field. Bonus praktisnya: objek frozen yang seluruh isinya hashable bisa dipakai sebagai dictionary key — sesuatu yang tidak bisa dilakukan instance dataclass biasa.

Manfaat immutability akhirnya kembali ke tiga hal: penalaran yang mudah (data tidak berubah di belakang layar), keamanan saat data dibagikan antar bagian program atau thread, dan kegagalan yang cepat terlihat ketika ada kode mencoba memutasi data yang seharusnya beku.