Model objek dan jebakan mutabilitas

Model objek Python, jebakan mutabilitas, aliasing, dan identitas versus kesetaraan.

Daftar isi

Setiap nilai pada Python adalah objek yang membawa tiga hal, yaitu identitas, tipe, dan nilai, sedangkan nama variabel hanyalah referensi yang menunjuk objek itu. Topik ini membahas konsekuensi model tersebut, mulai dari mutabilitas, aliasing, hingga perbedaan operator is dengan ==. Pemahaman ini mencegah bug yang halus, sekaligus menjelaskan mengapa data dapat berubah tanpa sepengetahuan bagian kode lain yang ikut menunjuknya, sesuatu yang berpengaruh langsung terhadap kebenaran dan konsumsi memori program.

Nama adalah referensi, bukan kotak#

Penugasan pada Python tidak menyalin nilai, melainkan menempelkan nama baru pada objek yang sudah ada. Objeknya sendiri tersimpan di heap, yaitu area memori yang dikelola CPython, dan setiap objek membawa penghitung referensi (reference count) yang mencatat berapa nama dan struktur data yang menunjuknya. Dua nama yang menunjuk objek yang sama disebut aliasing, dan kondisi ini menjadi akar dari sebagian besar jebakan mutabilitas.

python Contoh: penghitung referensi saat nama ditambahkan
import sys

data = [10, 20, 30]
print(sys.getrefcount(data))
salinan_nama = data
print(sys.getrefcount(data))
del salinan_nama
print(sys.getrefcount(data))
text Keluaran
2
3
2

Fungsi sys.getrefcount() mengembalikan jumlah referensi ditambah satu, karena pemanggilan fungsi itu sendiri membuat satu referensi sementara. Keluaran di atas menunjukkan angka bertambah ketika salinan_nama ditempelkan pada objek yang sama dan turun kembali setelah nama itu dihapus. Sifat ini mengonfirmasi bahwa del hanya melepas nama, bukan memusnahkan objeknya.

python Contoh: aliasing pada list
a = [1, 2, 3]
b = a
c = [1, 2, 3]
print(a is b)
print(a is c)
b.append(4)
print(a)
text Keluaran
True
False
[1, 2, 3, 4]

Variabel b adalah alias dari a, sehingga a is b bernilai True dan penambahan lewat b terlihat oleh a. Sebaliknya c adalah objek lain yang isinya kebetulan sama, sehingga a is c bernilai False dan c sepenuhnya terpisah dari a.

Mutable dan immutable#

Objek Python terbagi dua kelompok besar. Objek mutable seperti list, dictionary, dan set dapat diubah isinya tanpa mengganti identitasnya, sedangkan objek immutable seperti int, float, str, tuple, dan frozenset tidak pernah berubah. Apabila isi objek immutable perlu berbeda, Python membangun objek baru, sebagaimana perintah teks = teks + "!" yang menghasilkan string baru alih-alih memodifikasi string lama.

Kelompok immutable menawarkan satu keuntungan yang dipakai CPython secara langsung, yaitu aman dibagikan. Apabila dua nama menunjuk objek yang tidak mungkin berubah, keduanya boleh menunjuk satu objek yang sama tanpa risiko saling mengganggu, dan penghematan memori diperoleh tanpa konsekuensi kebenaran apa pun.

python Contoh: string yang dibagikan dan yang dibangun
a = "satu"
b = "satu"
c = "".join(["sa", "tu"])
print(a is b)
print(a is c)
print(a == c)
text Keluaran
True
False
True

Konstanta string yang sama di dalam satu kode sumber menjadi satu objek yang dibagikan, sehingga a is b bernilai True. String c dirakit saat program berjalan dan selalu berupa objek baru, sehingga a is c bernilai False meskipun a == c tetap True. Diagram berikut merangkum keadaan semacam itu, yaitu dua nama yang berbagi satu objek.

namaablist [1, 2, 3]refcount = 2
Dua nama menunjuk satu objek list, sehingga penghitung referensinya bernilai dua

Identitas versus kesetaraan#

Operator is membandingkan identitas, yaitu apakah keduanya menunjuk objek yang sama atau tidak, sedangkan operator == membandingkan nilai melalui metode __eq__. Kedua pertanyaan itu berbeda, dan kebingungan di antara keduanya adalah sumber bug yang halus. Kelas yang tidak mendefinisikan __eq__ mewarisi perilaku bawaan, yaitu kesetaraan dihitung dari identitas, sehingga dua instans yang isinya sama pun tidak pernah ==.

python Contoh: kesetaraan bawaan instans kelas
class Tugas:
    def __init__(self, judul):
        self.judul = judul

t1 = Tugas("kirim laporan")
t2 = Tugas("kirim laporan")
print(t1 == t2, t1 is t2)
text Keluaran
False False

Kedua instans berjudul sama, tetapi tanpa definisi __eq__ kesetaraannya jatuh ke identitas dan hasilnya False. Panduan praktisnya, gunakan == untuk membandingkan nilai dan simpan is untuk uji identitas, terutama pola x is None yang tidak dapat disalahartikan. Bedanya juga terasa pada struktur data, karena dictionary dan set mencari entrinya dengan kesetaraan, sehingga dua kunci yang == dianggap satu entri meskipun objeknya berbeda, sedangkan is tidak terlibat sama sekali dalam pencarian itu.

Mutabilitas pada argumen fungsi#

Fungsi menerima referensi, bukan salinan, sehingga mutasi pada parameter langsung terlihat oleh pemanggil. Perilaku ini berguna untuk operasi yang memang dimaksudkan mengubah data, tetapi mengejutkan apabila pemanggil tidak menyadari bahwa datanya dibagikan.

python Contoh: mutasi lewat alias di dalam fungsi
def tambah_tugas(daftar, tugas):
    daftar.append(tugas)

tugas_hari_ini = ["rapat"]
tugas_terpantau = tugas_hari_ini
tambah_tugas(tugas_hari_ini, "ulas kode")
print(tugas_hari_ini)
print(tugas_terpantau)
text Keluaran
['rapat', 'ulas kode']
['rapat', 'ulas kode']

Variabel tugas_terpantau tidak pernah disentuh fungsi, tetapi isinya ikut bertambah karena menunjuk objek yang sama. Fungsi yang memutasi argumennya sebaiknya menyatakan hal itu pada namanya atau pada docstring. Apabila data asli harus terjaga, pemanggil dapat mengirim salinan lewat tambah_tugas(list(tugas), ...).

Pola aman yang lazim adalah membagi fungsi menjadi dua kelompok yang jelas, yaitu fungsi yang memutasi argumennya dan fungsi yang mengembalikan hasil baru. Kelompok pertama lazim menamai dirinya dengan kata kerja yang tegas seperti tambah atau urutkan, sedangkan kelompok kedua mengembalikan list baru dan membiarkan argumennya utuh. Konvensi sederhana ini membuat pembaca kode tidak perlu membaca isinya untuk mengetahui apakah datanya berubah.

Demo aliasing#

Demo berikut memperagakan hubungan nama dan objek secara langsung. Tombol b = a menjadikan b alias dari a, tombol b.append(4) memutasi objek yang ditunjuk b, dan tombol b = [1, 2, 3] memberi objek baru kepada b. Perhatikan isyarat a is b pada setiap langkah, kemudian cocokkan hasilnya dengan perilaku list pada contoh-contoh sebelumnya.

Kode sumber demo
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>Demo aliasing list</title>
<style>
  body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 12px 14px; background: #f8fafc; color: #0f172a; }
  h1 { font-size: 15px; margin: 0 0 6px; }
  p#status { font-size: 13px; line-height: 1.5; min-height: 40px; margin: 0 0 8px; }
  .tombol { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
  button { font: inherit; font-size: 13px; padding: 6px 12px; border: 1px solid #6366f1; border-radius: 6px; background: #eef2ff; color: #3730a3; cursor: pointer; }
  button:hover { background: #e0e7ff; }
  svg { display: block; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; max-width: 100%; }
  .badge { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 999px; background: #fef3c7; }
</style>
</head>
<body>
<h1>Demo aliasing: nama versus objek</h1>
<p id="status">Keadaan awal: a dan b menunjuk dua objek list yang berbeda, sehingga identitasnya terpisah.</p>
<div class="tombol">
  <button id="b-alias" type="button">b = a</button>
  <button id="b-append" type="button">b.append(4)</button>
  <button id="b-baru" type="button">b = [1, 2, 3]</button>
  <button id="b-reset" type="button">Ulangi</button>
</div>
<svg id="kanvas" viewBox="0 0 520 190" width="520" height="190"></svg>
<p id="isi">a is b: <span class="badge" id="hasil">False</span></p>
<script>
(function () {
  var NS = "http://www.w3.org/2000/svg";
  var kanvas = document.getElementById("kanvas");
  var status = document.getElementById("status");
  var hasil = document.getElementById("hasil");
  var hitung = 0;
  var objekA, objekB;

  function objekBaru() {
    hitung += 1;
    return { id: hitung, isi: [1, 2, 3] };
  }

  function teksIsi(o) {
    return "list " + JSON.stringify(o.isi);
  }

  function teks(x, y, t, ukuran, warna) {
    var el = document.createElementNS(NS, "text");
    el.setAttribute("x", x);
    el.setAttribute("y", y);
    el.setAttribute("text-anchor", "middle");
    el.setAttribute("font-size", ukuran || 14);
    el.setAttribute("fill", warna || "#0f172a");
    el.textContent = t;
    kanvas.appendChild(el);
  }

  function kotak(x, y, w, h, isi) {
    var r = document.createElementNS(NS, "rect");
    r.setAttribute("x", x); r.setAttribute("y", y);
    r.setAttribute("width", w); r.setAttribute("height", h);
    r.setAttribute("rx", 8);
    r.setAttribute("fill", "#eef2ff");
    r.setAttribute("stroke", "#6366f1");
    r.setAttribute("stroke-width", 1.5);
    kanvas.appendChild(r);
    teks(x + w / 2, y + h / 2 - 3, isi, 14);
  }

  function nama(x, y, label) {
    var r = document.createElementNS(NS, "rect");
    r.setAttribute("x", x); r.setAttribute("y", y);
    r.setAttribute("width", 64); r.setAttribute("height", 32);
    r.setAttribute("rx", 8);
    r.setAttribute("fill", "#f1f5f9");
    r.setAttribute("stroke", "#64748b");
    r.setAttribute("stroke-width", 1.5);
    kanvas.appendChild(r);
    teks(x + 32, y + 21, label, 14);
  }

  function panah(x1, y1, x2, y2) {
    var l = document.createElementNS(NS, "line");
    l.setAttribute("x1", x1); l.setAttribute("y1", y1);
    l.setAttribute("x2", x2); l.setAttribute("y2", y2);
    l.setAttribute("stroke", "#475569");
    l.setAttribute("stroke-width", 1.5);
    l.setAttribute("marker-end", "url(#panah)");
    kanvas.appendChild(l);
  }

  function gambar() {
    while (kanvas.firstChild) {
      kanvas.removeChild(kanvas.firstChild);
    }
    var defs = document.createElementNS(NS, "defs");
    var m = document.createElementNS(NS, "marker");
    m.setAttribute("id", "panah");
    m.setAttribute("viewBox", "0 0 10 10");
    m.setAttribute("refX", "9"); m.setAttribute("refY", "5");
    m.setAttribute("markerWidth", "7"); m.setAttribute("markerHeight", "7");
    m.setAttribute("orient", "auto-start-reverse");
    var p = document.createElementNS(NS, "path");
    p.setAttribute("d", "M0 0L10 5L0 10z");
    p.setAttribute("fill", "#475569");
    m.appendChild(p); defs.appendChild(m); kanvas.appendChild(defs);

    teks(52, 16, "nama", 12, "#64748b");
    teks(345, 16, "objek", 12, "#64748b");

    if (objekA === objekB) {
      kotak(280, 78, 130, 44, teksIsi(objekA));
      nama(20, 40, "a");
      nama(20, 128, "b");
      panah(86, 56, 276, 94);
      panah(86, 144, 276, 106);
      teks(345, 142, "refcount = 2", 12, "#64748b");
    } else {
      kotak(280, 26, 130, 44, teksIsi(objekA));
      kotak(280, 126, 130, 44, teksIsi(objekB));
      nama(20, 32, "a");
      nama(20, 132, "b");
      panah(86, 48, 276, 48);
      panah(86, 148, 276, 148);
      teks(345, 90, "refcount = 1", 12, "#64748b");
    }
    hasil.textContent = objekA === objekB ? "True" : "False";
  }

  function reset() {
    objekA = objekBaru();
    objekB = objekBaru();
    status.textContent = "Keadaan awal: a dan b menunjuk dua objek list yang berbeda, sehingga identitasnya terpisah.";
    gambar();
  }

  document.getElementById("b-alias").addEventListener("click", function () {
    objekB = objekA;
    status.textContent = "b kini menunjuk objek yang sama dengan a. Mutasi lewat salah satu nama terlihat oleh keduanya, dan a is b menjadi True.";
    gambar();
  });

  document.getElementById("b-append").addEventListener("click", function () {
    objekB.isi.push(4);
    status.textContent = objekA === objekB
      ? "Objek bersama berubah isinya, sehingga a juga melihat 4. Inilah jebakan aliasing."
      : "Hanya objek milik b yang berubah, sedangkan a tidak terdampak karena berbeda objek.";
    gambar();
  });

  document.getElementById("b-baru").addEventListener("click", function () {
    objekB = objekBaru();
    status.textContent = "b diberi objek baru. a tetap menunjuk objek lamanya, sehingga keduanya terpisah kembali.";
    gambar();
  });

  document.getElementById("b-reset").addEventListener("click", reset);

  reset();
})();
</script>
</body>
</html>

Pada keadaan awal, a dan b menunjuk dua objek yang berbeda, sehingga a is b bernilai False. Setelah tombol b = a, kedua nama menunjuk satu kotak dan mutasi lewat salah satunya terlihat oleh yang lain, sama seperti keluaran ['rapat', 'ulas kode'] di atas. Tombol b = [1, 2, 3] memutus keterkaitan itu, dan identitasnya kembali terpisah tanpa mengubah isi objek milik a. Setiap pergantian keadaan pada demo adalah padanan langsung dari penugasan Python, sehingga kejadian yang sama dapat diuji ulang di REPL dengan tiga baris yang sama.

Kesalahan umum#

Kesalahan pertama adalah memakai is untuk membandingkan isi. Pada beberapa kasus hasilnya tampak benar karena CPython menyimpan ulang (interning) bilangan kecil dan konstanta string yang sama, sehingga dua nilai identik kebetulan objeknya sama. Keputusan seperti ini milik implementasi, dapat berubah antar versi, dan tidak menjamin apa pun untuk nilai yang dibangun saat program berjalan.

Kesalahan kedua adalah menyiapkan struktur data bersama tanpa memikirkan siapa yang berhak memutasi. Perbaikannya sederhana, yaitu membuat salinan pada batas antarmodul, misalnya list(data) untuk lapisan terluar atau copy.deepcopy(data) untuk struktur bersarang, kemudian menjadikan ketidakberubahan sebagai janji antarmuka yang tertulis.

Ringkasan#

  • Nama hanyalah referensi, dan penugasan menempelkan nama pada objek yang sama, sebagaimana dibuktikan sys.getrefcount() dan operator is.
  • Objek mutable berubah tanpa mengganti identitasnya, sedangkan objek immutable aman dibagikan karena isinya tidak mungkin berubah.
  • is membandingkan identitas, == membandingkan nilai melalui __eq__, dan kelas tanpa __eq__ menjatuhkan kesetaraan ke identitas.
  • Fungsi menerima referensi, sehingga mutasi parameter terlihat oleh pemanggil dan salinan eksplisit diperlukan bila data aslinya harus terjaga.

Referensi#