Evaluasi comptime

Menjalankan kode selama kompilasi dan memahami semantik comptime_int.

Daftar isi

Zig dapat mengeksekusi sebagian kode pada waktu kompilasi (compile time), yaitu sebelum biner program dihasilkan. Kemampuan ini disebut comptime dan menjadi fondasi pemrograman generik, refleksi tipe, serta pembentukan tabel konstanta pada jalur Zig. Topik ini membahas deklarasi mana yang dievaluasi oleh kompilator, semantik tipe comptime_int, dan cara membuktikan bahwa sebuah nilai sudah selesai dihitung sebelum program mulai berjalan. Hasil akhirnya adalah kemampuan memindahkan perhitungan tertentu ke waktu kompilasi secara sengaja, bukan secara kebetulan.

Konsep#

Deklarasi tingkat container dievaluasi saat kompilasi#

Aturan dasarnya terletak pada lokasi deklarasi. Semua konstanta yang ditulis di tingkat container (container), yaitu di luar fungsi mana pun, dievaluasi sepenuhnya oleh kompilator. Panggilan fungsi di dalam deklarasi tersebut ikut berlangsung di dalam kompilator selama seluruh argumennya diketahui pada waktu kompilasi. Blok comptime di tubuh fungsi memberikan perlakuan yang sama pada pernyataan yang ditempatinya.

Program berikut menghitung faktorial 6 pada waktu kompilasi. Konstanta f6 berada di tingkat container, sehingga perulangan di dalam fungsi faktorial dijalankan oleh kompilator, bukan oleh CPU. Blok comptime kemudian memeriksa nilai faktorial 5 dengan std.debug.assert, dan pemeriksaan itu juga terjadi sebelum program ada.

zig faktorial-comptime.zig
const std = @import("std");

fn faktorial(n: u64) u64 {
    var hasil: u64 = 1;
    var i: u64 = 2;
    while (i <= n) : (i += 1) {
        hasil *= i;
    }
    return hasil;
}

// deklarasi tingkat container selalu dievaluasi saat kompilasi
const f6 = faktorial(6);

pub fn main() void {
    std.debug.print("faktorial 6 = {d}\n", .{f6});

    comptime {
        // asersi ini diperiksa oleh kompilator, bukan oleh program
        std.debug.assert(faktorial(5) == 120);
    }
    std.debug.print("tipe literal 720 = {s}\n", .{@typeName(@TypeOf(720))});
}
text Keluaran
faktorial 6 = 720
tipe literal 720 = comptime_int

Baris const f6 = faktorial(6) adalah bukti evaluasi waktu kompilasi yang paling mudah dilihat. Ubah panggilannya menjadi faktorial(21), lalu kompilasi berhenti dengan pesan error: overflow of integer type 'u64' beserta catatan called at comptime here. Kegagalan itu tidak mungkin terjadi pada program yang berjalan, karena nilai tidak muat pada u64 sudah tertangkap sebelum biner dibuat. Baris terakhir melengkapi bukti tersebut dari sisi tipe, yaitu @TypeOf(720) melaporkan bahwa literal 720 bertipe comptime_int, sebuah tipe yang hanya ada pada waktu kompilasi.

bash
# jalankan program dari direktori topik
zig run faktorial-comptime.zig
text Keluaran
faktorial 6 = 720
tipe literal 720 = comptime_int

Tipe comptime_int dan presisi arbitrer#

Setiap literal integer tanpa anotasi tipe bertipe comptime_int. Tipe ini hanya hidup pada waktu kompilasi, lebarnya tidak dibatasi, dan aritmetikanya tidak pernah meluap. Sifat itu sudah tersentuh pada pembahasan operator pembungkusan untuk menjelaskan mengapa literal tidak pernah membungkus. Topik ini memandang sifat yang sama dari sisi lain, yaitu presisi arbitrer (arbitrary precision) yang memungkinkan perhitungan berukuran besar tetap akurat.

Fungsi pada program berikut bekerja sepenuhnya dengan comptime_int. Parameternya bertipe comptime_int, sehingga hanya menerima konstanta waktu kompilasi, dan hasil perhitungannya juga comptime_int. Faktorial 25 dihitung tanpa kehilangan satu digit pun, meskipun nilainya jauh melampaui kapasitas u64 yang hanya sekitar 1,8 × 10^19.

zig presisi-comptime.zig
const std = @import("std");

// comptime_int hanya menerima konstanta waktu kompilasi
fn faktorialBesar(n: comptime_int) comptime_int {
    var hasil: comptime_int = 1;
    var i: comptime_int = 2;
    while (i <= n) : (i += 1) {
        hasil *= i;
    }
    return hasil;
}

pub fn main() void {
    const f25 = faktorialBesar(25);
    std.debug.print("25! = {d}\n", .{f25});
    std.debug.print("tipe hasil = {s}\n", .{@typeName(@TypeOf(f25))});

    const muat: u128 = f25;
    std.debug.print("25! pada u128 = {d}\n", .{muat});
}
text Keluaran
25! = 15511210043330985984000000
tipe hasil = comptime_int
25! pada u128 = 15511210043330985984000000

Konversi comptime_int ke tipe konkret berlangsung implisit selama nilainya muat, seperti terlihat pada const muat: u128 = f25. Apabila nilai tidak muat, kompilator menolaknya dengan pesan yang menyebut angka lengkapnya.

zig Contoh: konversi comptime_int yang tidak muat
// tidak-dijalankan
const tidak_muat: u64 = f25; // 25! melebihi kapasitas u64

Pesan errornya adalah type 'u64' cannot represent integer value '15511210043330985984000000'. Penolakan semacam ini terjadi saat kompilasi, sehingga kekeliruan lebar tipe tidak pernah sampai ke program yang berjalan. Turunkan nilai perhitungan, perbesar tipe sasarannya, atau pertahankan hasil sebagai comptime_int selama nilai itu masih dipakai pada waktu kompilasi.

Contoh Lengkap#

Menentukan kapan kode berjalan#

Kompilator mengeksekusi kode pada tiga tempat, yaitu deklarasi tingkat container, blok comptime, dan ekspresi yang diberi kata kunci comptime. Selain ketiganya, kode berjalan pada waktu berjalan (runtime) dan dieksekusi oleh CPU. Karena lokasi panggilan menentukan jalannya eksekusi, panggilan fungsi yang sama dapat menghasilkan kegagalan pada waktu yang berbeda.

zig Contoh: lokasi panggilan menentukan waktu eksekusi
// tidak-dijalankan
// panggilan pada deklarasi tingkat container dievaluasi kompilator
const f21 = faktorial(21); // error kompilasi, overflow terdeteksi lebih dulu

pub fn main() void {
    // panggilan lokal dieksekusi CPU saat program berjalan
    const g21 = faktorial(21); // panic integer overflow saat program berjalan
}

Kedua baris pada fragmen menghitung nilai yang sama, tetapi kegagalannya muncul di tempat yang berbeda. Baris pertama menghentikan kompilasi dengan pesan error, sedangkan baris kedua lolos kompilasi kemudian menghentikan program dengan panic integer overflow pada mode Debug. Diagram berikut merangkum dua jalur eksekusi tersebut untuk pemanggilan fungsi yang sama.

Waktu kompilasi (dijalankan oleh kompilator)deklarasi containerconst f6 = faktorial(6)kompilator mengeksekusiperulangan faktorialnilai 720 tertanamdi dalam binerWaktu berjalan (dijalankan oleh CPU)panggilan di dalam mainconst g21 = faktorial(21)CPU mengeksekusiinstruksi perulangannilai dihitungsetiap kali program berjalan
Dua jalur eksekusi untuk pemanggilan fungsi yang sama

Pemindahan perhitungan ke waktu kompilasi dilakukan dengan tiga kriteria, yaitu seluruh masukan berupa konstanta, hasilnya diketahui tidak berubah, dan pemrosesannya tidak menyentuh I/O. Kriteria ketiga penting karena kode pada waktu kompilasi tidak memiliki akses ke terminal, berkas, maupun memori runtime. Perhitungan yang lolos ketiga kriteria, misalnya konversi satuan dengan faktor tetap, layak diikat sebagai konstanta tingkat container.

Kesalahan umum#

Kesalahan pada topik ini berpusat pada asumsi tentang kode mana yang benar-benar dieksekusi kompilator. Tiga kasus berikut paling sering muncul.

  1. Menganggap pengikatan const lokal sebagai pemicu evaluasi waktu kompilasi. Deklarasi const g21 = faktorial(21) di dalam main tetap berjalan pada CPU, sehingga overflow baru muncul sebagai panic saat program berjalan. Pindahkan deklarasi ke tingkat container atau tambahkan kata kunci comptime pada ekspresinya agar evaluasi berpindah ke kompilator.
  2. Meletakkan operasi I/O di dalam blok comptime. Pemanggilan std.debug.print di dalam blok ditolak dengan pesan unable to resolve comptime value. Struktur yang benar adalah menghitung nilai di dalam blok, menyimpannya pada konstanta, kemudian mencetak konstanta tersebut di luar blok.
  3. Memaksa konversi comptime_int ke tipe yang tidak muat. Kompilator menolak dengan pesan yang menyebut nilai lengkapnya, misalnya type 'u64' cannot represent integer value .... Pilih lebar tipe yang lebih besar atau tulis ulang perhitungan agar hasilnya berada di dalam rentang tipe sasaran.

Ringkasan#

  • Konstanta di tingkat container selalu dievaluasi saat kompilasi, dan blok comptime di tubuh fungsi memberikan perlakuan yang sama pada pernyataannya.
  • Literal integer bertipe comptime_int dengan lebar tak terbatas, sehingga aritmetikanya tidak pernah meluap dan konversinya implisit selama nilai muat pada tipe sasaran.
  • Lokasi panggilan menentukan waktu eksekusi, yaitu deklarasi container berjalan di dalam kompilator, sedangkan panggilan lokal berjalan pada CPU.
  • Kode waktu kompilasi tidak boleh menyentuh I/O, sehingga pemeriksaan nilai dilakukan dengan std.debug.assert di dalam blok comptime.

Referensi#