Tipe Nilai vs Tipe Referensi

Stack dan heap, semantik penyalinan, serta identitas vs kesetaraan.

Daftar isi

Setiap nilai di dalam C# termasuk salah satu dari dua kategori besar, yaitu tipe nilai (value type) dan tipe referensi (reference type). Kategori tersebut menentukan cara variabel menyimpan datanya, cara penugasan menyalin data, dan cara dua nilai dibandingkan. Topik ini membahas pembagian keduanya, peran stack dan heap sebagai area penyimpanan, semantik penyalinan, serta perbedaan antara identitas dan kesetaraan. Setiap perilaku didemonstrasikan dengan program yang benar-benar dijalankan sehingga perbedaannya dapat diamati langsung dari keluarannya.

Dua kategori tipe di C##

Tipe nilai menyimpan data secara langsung di dalam variabelnya. Kelompok ini mencakup tipe angka seperti int, double, dan decimal, tipe logika bool, tipe karakter char, serta dua jenis tipe yang dapat didefinisikan sendiri dan membawa semantik tipe nilai, yaitu struct dan enum. Apabila sebuah variabel tipe nilai ditugaskan ke variabel lain, seluruh nilainya disalin, sehingga kedua variabel kemudian memegang data yang saling bebas. Mengubah salah satunya tidak pernah memengaruhi yang lain.

Tipe referensi menyimpan bukan datanya, melainkan reference, yaitu alamat menuju lokasi data di memori. Kelompok ini mencakup string, array, kelas yang dideklarasikan dengan class, record, yaitu tipe referensi ringkas yang datanya ditentukan lewat parameternya, dan delegate. Penugasan antarvariabel tipe referensi hanya menyalin alamatnya, sehingga kedua variabel menunjuk object yang sama. Perubahan yang dilakukan melalui salah satu variabel terlihat melalui variabel lainnya karena object-nya hanya ada satu di memori.

AspekTipe nilaiTipe referensi
Contoh tipeint, double, bool, char, struct, enumstring, array, class, record, delegate
Isi variabeldata itu sendirireference menuju data
Hasil penugasansalinan utuh yang bebasalamat yang sama
== secara defaultperbandingan isiperbandingan identitas

Stack dan heap#

Runtime, yaitu lingkungan yang mengeksekusi program .NET, menyimpan data di dua area memori utama. Stack, yaitu tumpukan yang bekerja dengan urutan masuk terakhir keluar pertama, menjadi tempat variabel lokal dan parameter metode bertipe nilai. Alokasi di stack cepat karena hanya memajukan penunjuk, dan datanya dibebaskan secara otomatis ketika metode yang memuatnya selesai dijalankan. Karena itu, tipe nilai yang hidupnya seumur pemanggilan metode tidak menambah beban pengelolaan memori.

Heap, yaitu area memori untuk data yang hidup lebih lama dari satu pemanggilan metode, menjadi tempat object tipe referensi dialokasikan. Objek di heap tidak dibebaskan begitu metode kembali, melainkan dikelola oleh garbage collector, bagian runtime yang membebaskan memori object yang tidak lagi dirujuk. Variabel tipe referensi menyimpan alamat, sedangkan datanya berada di heap, sehingga pembersihan memori menunggu hingga tidak ada variabel yang menunjuk object tersebut.

Semantik penyalinan#

Program berikut membandingkan hasil penugasan pada tipe nilai dan tipe referensi. Separuh pertama menyalin sebuah int dan sebuah array, sedangkan separuh kedua menyiapkan kelas Keranjang untuk pengujian identitas pada bagian berikutnya.

csharp Program.cs
// Program perbandingan semantik penyalinan serta identitas dan kesetaraan
int angkaAsal = 10;
int angkaSalin = angkaAsal;   // salinan nilai dibuat
angkaSalin = 99;

Console.WriteLine($"Tipe nilai: angkaAsal = {angkaAsal}, angkaSalin = {angkaSalin}");

int[] daftarAsal = { 1, 2, 3 };
int[] daftarSalin = daftarAsal;   // reference yang disalin, bukan isinya
daftarSalin[0] = 500;

Console.WriteLine($"Tipe referensi: daftarAsal[0] = {daftarAsal[0]}, daftarSalin[0] = {daftarSalin[0]}");
Console.WriteLine($"ReferenceEquals(daftarAsal, daftarSalin) = {ReferenceEquals(daftarAsal, daftarSalin)}");

Keranjang k1 = new Keranjang { Isi = 5 };
Keranjang k2 = new Keranjang { Isi = 5 };
Keranjang k3 = k1;

Console.WriteLine($"k1 == k2 -> {k1 == k2}");
Console.WriteLine($"k1.Equals(k2) -> {k1.Equals(k2)}");
Console.WriteLine($"ReferenceEquals(k1, k2) -> {ReferenceEquals(k1, k2)}");
Console.WriteLine($"k1 == k3 -> {k1 == k3}");

class Keranjang
{
    public int Isi;
}
text Keluaran
Tipe nilai: angkaAsal = 10, angkaSalin = 99
Tipe referensi: daftarAsal[0] = 500, daftarSalin[0] = 500
ReferenceEquals(daftarAsal, daftarSalin) = True
k1 == k2 -> False
k1.Equals(k2) -> False
ReferenceEquals(k1, k2) -> False
k1 == k3 -> True

Untuk menjalankan program tersebut dari folder topik, gunakan perintah berikut.

bash
# aktifkan .NET yang terpasang di sandbox
export DOTNET_ROOT=/home/kal/.dotnet PATH=/home/kal/.dotnet:$PATH DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
# jalankan program contoh dari folder topik
dotnet run --file Program.cs
text Keluaran
Tipe nilai: angkaAsal = 10, angkaSalin = 99
Tipe referensi: daftarAsal[0] = 500, daftarSalin[0] = 500
ReferenceEquals(daftarAsal, daftarSalin) = True
k1 == k2 -> False
k1.Equals(k2) -> False
ReferenceEquals(k1, k2) -> False
k1 == k3 -> True

Keluaran menunjukkan dua perilaku yang berlawanan. Variabel angkaSalin diubah menjadi 99, tetapi angkaAsal tetap 10 karena penugasan menyalin seluruh nilai int, sehingga kedua variabel benar-benar terpisah. Sebaliknya, daftarSalin hanyalah reference kedua menuju array yang sama. Mengubah elemen melalui daftarSalin langsung mengubah array yang dilihat oleh daftarAsal, dan metode ReferenceEquals mengonfirmasi bahwa keduanya menunjuk satu object dengan nilai True.

Semantik penyalinan seperti ini menjadi alasan utama mengapa pembagian kategori tipe perlu dipahami sejak awal. Salinan utuh membuat aliran data mudah dinalar, karena perubahan pada satu variabel tidak pernah menyusup ke variabel lain tanpa sengaja, dan sifat inilah yang membuat tipe nilai nyaman untuk data kecil. Sebaliknya, kebebasan dua variabel yang menunjuk object yang sama adalah fitur yang dibutuhkan, misalnya ketika satu daftar diserahkan ke banyak bagian program, tetapi fitur yang sama menjadi sumber kesalahan apabila salinan yang benar-benar bebas diharapkan. Ukuran data menentukan biayanya, karena menyalin array berisi ribuan angka memiliki biaya yang jauh berbeda dengan menyalin alamatnya, dan pertimbangan ini kembali muncul pada topik struct.

Identitas dan kesetaraan#

Identitas dan kesetaraan adalah dua pertanyaan yang berbeda. Identitas bertanya apakah dua variabel menunjuk object yang persis sama, sedangkan kesetaraan bertanya apakah isinya sama. Pada tipe nilai, operator == membandingkan isi, sehingga dua variabel int bernilai 7 saling setara tanpa perlu langkah tambahan. Pada kelas, operator == secara default membandingkan identitas, dan keluaran program di atas membuktikannya: k1 == k2 bernilai False meskipun kedua keranjang sama-sama berisi 5, karena keduanya dibuat dari dua object yang berbeda.

Metode Equals pada kelas juga membandingkan identitas secara default, sehingga k1.Equals(k2) bernilai False. Perilaku identitas itu tegas terlihat pada k3, yang ditugaskan dari k1 tanpa membuat object baru. Operator ==, metode Equals, dan ReferenceEquals semuanya mengembalikan True untuk pasangan k1 dan k3 karena keduanya adalah satu object yang sama.

string merupakan tipe referensi yang berperilaku berbeda pada pemeriksaan kesetaraan. Operator == pada string didefinisikan ulang sehingga membandingkan isinya, bukan alamatnya.

csharp Contoh: string membandingkan isi, bukan identitas object
// Contoh: string membandingkan isi, bukan identitas object
string a = new string('a', 3);
string b = "aaa";

Console.WriteLine($"a == b -> {a == b}");
Console.WriteLine($"ReferenceEquals(a, b) -> {ReferenceEquals(a, b)}");
text Keluaran
a == b -> True
ReferenceEquals(a, b) -> False

Kedua variabel berisi urutan karakter yang sama, sehingga a == b bernilai True, sedangkan ReferenceEquals mengembalikan False karena keduanya dibuat sebagai dua object terpisah di heap. Metode Equals pada string juga membandingkan isi, sehingga pola pemeriksaan teks tidak menuntut langkah tambahan. Apabila kelas buatan sendiri memerlukan kesetaraan berbasis isi, metode Equals dan operator == harus didefinisikan ulang, atau gunakan record yang menyediakannya secara otomatis.

Kesalahan umum#

  • Mengira penugasan array menyalin isinya. Penugasan hanya menyalin reference-nya, sehingga perubahan elemen melalui variabel salin tetap mengubah array asalnya. Gunakan metode Clone() atau salin elemen satu per satu apabila salinan yang benar-benar bebas diperlukan.
  • Membandingkan object kelas dengan == dan mengharapkan isinya yang dibandingkan. Tanpa definisi ulang operator, == menguji identitas dan mengembalikan False untuk dua object berisi sama.
  • Menyimpulkan bahwa tipe nilai selalu berada di stack. Penyimpanan mengikuti konteksnya, misalnya field bertipe nilai di dalam kelas tersimpan di heap bersama object yang memuatnya.

Ringkasan#

  • Tipe nilai menyimpan data secara langsung dan disalin utuh saat penugasan, sedangkan tipe referensi menyimpan reference sehingga penugasan membuat dua variabel menunjuk object yang sama.
  • Variabel lokal bertipe nilai umumnya berada di stack, sedangkan object tipe referensi berada di heap dan dibersihkan oleh garbage collector ketika tidak lagi dirujuk.
  • Operator == membandingkan isi pada tipe nilai dan identitas pada kelas secara default, sedangkan string didefinisikan ulang agar == membandingkan isi.

Referensi#