Eksekusi Tunda

Kapan query LINQ benar-benar berjalan dan jebakan enumerasi berulang.

Daftar isi

Eksekusi tunda (deferred execution) berarti query LINQ tidak berjalan pada saat dideklarasikan, melainkan pada saat hasilnya benar-benar ditelusuri, sehingga pembentukan query dan pengerjaannya adalah dua kejadian yang terpisah. Topik ini membahas titik ketika query mulai berjalan, pembuktian perilaku tersebut dengan penghitung samping, serta jebakan enumerasi berulang dan cara mengatasinya dengan materialisasi. Semua contoh dijalankan dan keluaran aslinya ditempel setelah kodenya.

Kapan query benar-benar berjalan#

Operator seperti Where dan Select hanya menyusun rencana query, yaitu objek IEnumerable<T> yang tahu cara menelusuri sumbernya, tanpa memproses satu elemen pun. Pengerjaan baru dimulai ketika penelusuran terjadi, misalnya pada pernyataan foreach, pada konversi seperti ToList() dan ToArray(), serta pada operator agregasi seperti Count() dan Max() yang memang harus membaca seluruh barisan.

csharp Contoh: pembentukan query belum memproses satu elemen pun
// Contoh: pembentukan query dan penelusurannya adalah dua kejadian berbeda
List<int> nilaiUjian = new() { 84, 60, 90, 55, 75 };
IEnumerable<int> lulus = nilaiUjian.Where(n => n >= 75);
Console.WriteLine("Query sudah dibentuk, penelusuran belum terjadi");
Console.WriteLine($"Isi barisan: {string.Join(", ", lulus)}");
Console.WriteLine($"Jumlah: {lulus.Count()}");
text Keluaran
Query sudah dibentuk, penelusuran belum terjadi
Isi barisan: 84, 90, 75
Jumlah: 3

Baris pertama keluaran muncul tanpa satu elemen pun diproses, sedangkan dua baris berikutnya masing-masing memicu satu penelusuran penuh atas sumber. Inilah alasan pencetakan Isi barisan dan Jumlah terasa mahal bila diterapkan pada sumber yang besar, karena setiap pembacaan mengulang seluruh pengerjaan query.

Rantai operator menunda pengerjaan lebih jauh lagi, karena setiap operator baru hanya membungkus rencana sebelumnya. Query yang disaring kemudian diurutkan tetap belum memproses apa pun, dan satu-satunya pemicu adalah pembacaan barisan terluarnya. Panjangnya rantai dengan demikian tidak menambah pekerjaan pada saat deklarasi, melainkan hanya pada saat penelusuran dilakukan.

Pembuktian dengan penghitung samping#

Perilaku tunda paling mudah dibuktikan dengan metode iterator yang menyimpan penghitung samping. Variabel jumlahEksekusi bertambah setiap kali badan iterator mulai berjalan, sehingga jumlah pencatatannya menunjukkan berapa kali query benar-benar dikerjakan. Satu query yang dienumerasi dua kali akan mencatat dua kali eksekusi.

csharp Contoh: penghitung samping mencatat dua enumerasi
// Contoh: penghitung samping membuktikan badan iterator berjalan tiap enumerasi
int jumlahEksekusi = 0;

IEnumerable<int> NilaiDikuadratkan(List<int> sumber)
{
    jumlahEksekusi++;
    Console.WriteLine($"Badan iterator berjalan kali ke-{jumlahEksekusi}");
    foreach (int nilai in sumber)
    {
        yield return nilai * nilai;
    }
}

List<int> data = new() { 1, 2, 3 };
IEnumerable<int> query = NilaiDikuadratkan(data);
Console.WriteLine("Query sudah dibentuk, badan iterator belum berjalan");
Console.WriteLine($"Enumerasi pertama: {string.Join(", ", query)}");
Console.WriteLine($"Enumerasi kedua: {string.Join(", ", query)}");
text Keluaran
Query sudah dibentuk, badan iterator belum berjalan
Badan iterator berjalan kali ke-1
Enumerasi pertama: 1, 4, 9
Badan iterator berjalan kali ke-2
Enumerasi kedua: 1, 4, 9

Keluaran menunjukkan bahwa baris Badan iterator berjalan tidak muncul pada saat query dibentuk, melainkan tepat sebelum setiap penelusuran berjalan. Satu variabel query yang sama diproses dua kali karena dua enumerasi, dan penghitung samping mencatat keduanya. Hasil kedua enumerasi tetap identik karena sumbernya tidak berubah di antara keduanya.

Jebakan enumerasi berulang#

Sifat tunda memiliki dua konsekuensi yang sering luput. Pertama, hasil query mengikuti keadaan sumber pada saat penelusuran, bukan pada saat deklarasi. Kedua, biaya pengerjaan query dibayar setiap enumerasi, sehingga query berat yang dienumerasi berulang kali memboroskan waktu proses. Contoh berikut menunjukkan konsekuensi pertama pada stok gudang yang bertambah di antara dua pembacaan.

csharp Contoh: hasil query ikut berubah karena sumbernya berubah
// Contoh: hasil query ikut berubah karena sumbernya berubah
List<int> stokGudang = new() { 3, 5, 8 };
IEnumerable<int> diAtasEmpat = stokGudang.Where(n => n > 4);
Console.WriteLine($"Sekarang: {string.Join(", ", diAtasEmpat)}");
stokGudang.Add(10);
Console.WriteLine($"Setelah penambahan: {string.Join(", ", diAtasEmpat)}");
text Keluaran
Sekarang: 5, 8
Setelah penambahan: 5, 8, 10

Variabel diAtasEmpat tidak pernah menyimpan salinan stok pada saat deklarasinya, tetapi hanya menyimpan rencana penyaringan. Ketika nilai 10 ditambahkan sebelum enumerasi kedua, barisan yang sama menghasilkan isi yang berbeda. Perilaku ini wajar menurut definisi eksekusi tunda, tetapi sering menjadi sumber bug ketika deklarasi query dan penelusurannya berjauhan dalam kode.

Konsekuensi biaya menempel pada pola yang sama, yaitu query yang dideklarasikan sekali lalu dibaca untuk laporan dan perhitungan yang berbeda-beda. Setiap pembacaan mengulang penyaringan dari nol, sehingga total kerjanya bertambah seiring banyaknya tempat yang membaca query tersebut. Perhitungan biaya semacam ini jarang terlihat pada data kecil, tetapi cepat terasa pada sumber yang berukuran ribuan elemen.

Materialisasi dengan ToList#

Jebakan di atas diatasi dengan materialisasi, yaitu mengubah query menjadi collection yang menyimpan hasilnya pada saat itu juga. Metode ToList() menelusuri query sekali, menyalin hasilnya ke List<T>, dan salinan itu tidak lagi bergantung pada keadaan sumber.

csharp Contoh: ToList menyalin hasil pada saat itu juga
// Contoh: ToList menyalin hasil pada saat itu juga
List<int> stokGudang = new() { 3, 5, 8 };
List<int> salinan = stokGudang.Where(n => n > 4).ToList();
stokGudang.Add(10);
Console.WriteLine($"Sumber: {string.Join(", ", stokGudang)}");
Console.WriteLine($"Salinan: {string.Join(", ", salinan)}");
text Keluaran
Sumber: 3, 5, 8, 10
Salinan: 5, 8

Nilai 10 yang ditambahkan setelah materialisasi tetap tidak termasuk dalam salinan, karena salinan itu sudah memuat isi barisan pada saat penelusuran berlangsung. Pilihan bentuk mengikuti kebutuhan, yaitu ToList() untuk kebutuhan indeks dan modifikasi, ToArray() untuk hasil yang benar-benar tidak berubah, dan ToDictionary() untuk akses berdasarkan kunci. Sifat tunda tetap berguna untuk barisan besar yang hanya dibaca sekali, karena elemen dapat diproses satu per satu tanpa menampung semuanya di memori.

Contoh program utuh#

Program berikut merangkum tiga sisi eksekusi tunda dalam satu berkas, yaitu penghitung samping, perubahan hasil mengikuti sumber, dan materialisasi.

csharp Program.cs
// Program contoh eksekusi tunda, enumerasi berulang, dan materialisasi

// Metode iterator dengan penghitung samping
int jumlahEksekusi = 0;

IEnumerable<int> NilaiDikuadratkan(List<int> sumber)
{
    jumlahEksekusi++;
    Console.WriteLine($"Badan iterator berjalan kali ke-{jumlahEksekusi}");
    foreach (int nilai in sumber)
    {
        yield return nilai * nilai;
    }
}

List<int> data = new() { 1, 2, 3 };
IEnumerable<int> query = NilaiDikuadratkan(data);
Console.WriteLine("Query sudah dibentuk, badan iterator belum berjalan");
Console.WriteLine($"Enumerasi pertama: {string.Join(", ", query)}");
Console.WriteLine($"Enumerasi kedua: {string.Join(", ", query)}");

// Hasil query mengikuti perubahan sumber
List<int> stokGudang = new() { 3, 5, 8 };
IEnumerable<int> diAtasEmpat = stokGudang.Where(n => n > 4);
Console.WriteLine($"Sekarang: {string.Join(", ", diAtasEmpat)}");
stokGudang.Add(10);
Console.WriteLine($"Setelah penambahan: {string.Join(", ", diAtasEmpat)}");

// Materialisasi dengan ToList
List<int> salinan = stokGudang.Where(n => n > 4).ToList();
stokGudang.Add(12);
Console.WriteLine($"Sumber: {string.Join(", ", stokGudang)}");
Console.WriteLine($"Salinan: {string.Join(", ", salinan)}");
text Keluaran
Query sudah dibentuk, badan iterator belum berjalan
Badan iterator berjalan kali ke-1
Enumerasi pertama: 1, 4, 9
Badan iterator berjalan kali ke-2
Enumerasi kedua: 1, 4, 9
Sekarang: 5, 8
Setelah penambahan: 5, 8, 10
Sumber: 3, 5, 8, 10, 12
Salinan: 5, 8, 10

Jalankan program tersebut dari folder topik dengan 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
Query sudah dibentuk, badan iterator belum berjalan
Badan iterator berjalan kali ke-1
Enumerasi pertama: 1, 4, 9
Badan iterator berjalan kali ke-2
Enumerasi kedua: 1, 4, 9
Sekarang: 5, 8
Setelah penambahan: 5, 8, 10
Sumber: 3, 5, 8, 10, 12
Salinan: 5, 8, 10

Kesalahan umum#

  • Mengira variabel query menyimpan hasil yang sudah jadi. Variabel itu hanya menyimpan rencana, sehingga hasilnya dihitung ulang pada setiap penelusuran.
  • Membaca query yang sama berkali-kali dalam perulangan atau pelaporan. Biayanya dibayar penuh setiap kali, dan materialisasi sekali di awal dengan ToList() mengatasinya.
  • Mencari data yang sudah dihapus dari sumber melalui variabel query lama. Enumerasi menemukan keadaan sumber terkini, sehingga hasil yang diharapkan tidak ada lagi dan logika program keliru.

Ringkasan#

  • Operator LINQ hanya menyusun rencana query, dan pengerjaan baru terjadi pada saat penelusuran, konversi, atau agregasi.
  • Penghitung samping pada metode iterator membuktikan bahwa satu query yang dienumerasi dua kali dikerjakan dua kali.
  • Hasil query mengikuti keadaan sumber pada saat penelusuran, sehingga perubahan sumber mengubah hasilnya.
  • ToList(), ToArray(), dan ToDictionary() mematerialisasi hasil pada saat itu juga, sehingga salinannya bebas dari perubahan sumber berikutnya.

Referensi#