Preprocessor adalah tahap pertama yang dijalankan compiler C sebelum kode sumber benar-benar diparsing menjadi syntax tree. Pada tahap ini semua baris yang diawali tanda # (seperti #include, #define, #ifdef) diproses terlebih dahulu, dan hasilnya adalah kode C murni tanpa directive yang kemudian masuk ke tahap compile. Memahami tahap ini penting karena sebagian besar struktur proyek C berskala menengah ke atas—mulai dari include guards, conditional compilation, sampai macro—sepenuhnya diatur oleh preprocessor. Tanpa gambaran yang benar tentang apa yang dilakukan preprocessor, kita akan kesulitan membaca pesan error verbose dari GCC/Clang atau memprediksi kapan sebuah macro dipakai berulang.

Tahap Preprocessing dalam Pipeline Compiler #

Pipeline compiler C secara garis besar terdiri dari empat tahap berurutan. Pertama, preprocessor memanipulasi teks sumber dengan mengganti macro, menyertakan isi header, dan membuang blok yang tidak terpilih oleh conditional. Kedua, compiler menerjemahkan translation unit hasil preprocessing menjadi assembly. Ketiga, assembler mengubah assembly menjadi object file (.o). Keempat, linker menggabungkan satu atau lebih object file menjadi executable. Yang akan kita fokuskan di chapter ini adalah tahap pertama saja—tahap yang dipicu secara eksplisit oleh flag gcc -E.

Mekanisme yang berjalan di balik layar adalah manipulasi teks baris demi baris. Preprocessor tidak memahami tipe data, scope variabel, atau aturan sintaks C; preprocessor hanya mengenali directive yang dimulai dengan # dan melakukan substitusi leksikal. Inilah sebabnya banyak jebakan macro—seperti evaluasi argumen berulang—tidak bisa dideteksi oleh preprocessor sendiri karena preprocessor tidak tahu bahwa sebuah ekspresi bisa berdampak samping (side effect).

Melihat Hasil Preprocessor dengan gcc -E #

Cara paling efektif untuk memahami preprocessor adalah dengan melihat langsung hasil kerjanya. GCC dan Clang menyediakan flag -E yang menghentikan pipeline setelah tahap preprocessing dan mencetak translation unit hasil substitusi ke stdout.

c
#include <stdio.h>

#define VERSI 2
#define NAMA  "Aplikasi Demo"

int main(void) {
    printf("%s versi %d\n", NAMA, VERSI);
    return 0;
}

Output yang Dihasilkan (menjalankan gcc -std=c11 -E -P /tmp/test_preprocessor/demo.c, lalu menampilkan bagian akhir):

text
int main(void) {
    printf("%s versi %d\n", "Aplikasi Demo", 2);
    return 0;
}

Output yang Dihasilkan (menjalankan gcc -std=c11 -Wall -Wextra -o demo demo.c && ./demo):

text
Aplikasi Demo versi 2

Perhatikan bahwa identifier NAMA dan VERSI telah diganti dengan literal "Aplikasi Demo" dan 2 pada hasil gcc -E. Inilah esensi dari macro object-like: substitusi teks sederhana sebelum compiler melihat kode. Flag -P di atas sekadar membuang line marker seperti # 5 "demo.c" agar output lebih ringkas dan mudah dibaca.

Macro Object-Like dan Function-Like #

Preprocessor mengenal dua keluarga macro: object-like (tanpa parameter, mirip konstanta) dan function-like (menerima parameter dalam tanda kurung). Mari kita lihat keduanya dalam satu program yang juga memperlihatkan bagaimana hasil akhirnya setelah preprocessing.

c
#include <stdio.h>

/* Macro object-like: diganti dengan nilai literal */
#define MAX_DATA 100
#define FORMAT   "Data ke-%d = %d\n"

/* Macro function-like: diganti dengan ekspresi */
#define KUADRAT(x) ((x) * (x))

int main(void) {
    int data[MAX_DATA] = {0};
    data[0] = 7;

    printf(FORMAT, 0, data[0]);
    printf("Kuadrat dari 7 = %d\n", KUADRAT(7));

    return 0;
}

Output yang Dihasilkan:

text
Data ke-0 = 7
Kuadrat dari 7 = 49

Jika kita menjalankan gcc -std=c11 -E -P /tmp/test_cara_kerja.c dan melihat bagian akhir, hasilnya kira-kira sebagai berikut:

text
int main(void) {
    int data[100] = {0};
    data[0] = 7;
    printf("Data ke-%d = %d\n", 0, data[0]);
    printf("Kuadrat dari 7 = %d\n", ((7) * (7)));
    return 0;
}

Mari kita telaah baris-baris kode di atas:

  1. Baris 4 (#define MAX_DATA 100): mendefinisikan macro object-like bernama MAX_DATA dengan nilai 100. Setiap kemunculan MAX_DATA pada kode berikutnya akan diganti dengan teks 100 oleh preprocessor—termasuk di dalam deklarasi array int data[MAX_DATA].
  2. Baris 5 (#define FORMAT "Data ke-%d = %d\n"): macro object-like dapat juga berisi string literal. Trik ini berguna untuk memusatkan format printf agar tidak tersebar di banyak tempat.
  3. Baris 8 (#define KUADRAT(x) ((x) * (x))): macro function-like. Tanda kurung (...) tepat setelah nama macro menandakan bahwa KUADRAT menerima satu parameter bernama x. Tanda kurung ekstra di sekitar (x) pada badan macro adalah praktik wajib untuk mencegah precedence bug saat argumen adalah ekspresi seperti 1 + 2.
  4. Baris 11 (int data[MAX_DATA] = {0};): setelah preprocessing baris ini berubah menjadi int data[100] = {0};. Inilah alasan kita bisa memakai macro sebagai ukuran array tanpa VLA.
  5. Baris 14 (printf("Kuadrat dari 7 = %d\n", KUADRAT(7));): pemanggilan macro function-like dengan argumen 7. Preprocessor mengganti KUADRAT(7) menjadi ((7) * (7)), yang kemudian dievaluasi compiler menjadi 49.

Directive yang Diproses Preprocessor #

Preprocessor tidak hanya mengganti macro. Berikut ringkasan directive utama yang akan kita dalami di chapter-chapter berikutnya pada section ini:

  • #include — menyisipkan isi file lain (header sistem atau header lokal).
  • #define dan #undef — mendefinisikan dan menghapus macro.
  • #if, #ifdef, #ifndef, #elif, #else, #endif — conditional compilation.
  • #pragma — instruksi implementasi spesifik (mis. #pragma once).
  • #line — mengubah nomor baris dan nama file yang dilaporkan compiler.
  • #error — memicu compile error dengan pesan kustom.

Masing-masing directive memiliki aturan sendiri, tetapi semuanya berbagi satu prinsip dasar: dijalankan sebelum compiler benar-benar memahami kode sebagai bahasa C. Akibatnya, kesalahan macro sering muncul sebagai pesan error yang merujuk ke kode yang sudah di-expand—bukan kode sumber yang kita tulis.