Setiap kali kita menyimpan data ke file, kita membuat keputusan format: bagaimana memisahkan field, bagaimana menandai akhir record, bagaimana mengetahui versi format. Tanpa format yang jelas, data yang kita tulis hari ini bisa tidak terbaca esok hari—terutama bila skema berubah. Pada chapter ini kita akan membandingkan tiga pendekatan umum: delimiter-based (CSV), fixed-width (kolom tetap), dan header + body (magic number). Masing-masing memiliki trade-off antara keterbacaan, efisiensi, dan kekakuan skema.

Tiga Pendekatan Format File #

Pilihan format file biasanya jatuh pada salah satu dari tiga pendekatan:

FormatKarakteristikKelebihanKekurangan
Delimiter (CSV/TSV)Field dipisahkan karakter (,, \t, ;)Mudah dibaca, fleksibelField tidak boleh mengandung delimiter (perlu escape)
Fixed-widthTiap field menempati jumlah byte tetapAkses acak mudah, parse sederhanaBoros ruang bila data bervariasi
Binary + headerMagic number + metadata + binary payloadEfisien, mendukung versiTidak bisa dibaca manusia, butuh parser biner

Tidak ada format “terbaik”—pilihan tergantung pada kebutuhan. File konfigurasi cenderung teks (CSV/JSON/INI). File database cenderung biner dengan header. File log sering fixed-width atau delimiter-based.

Format CSV: Delimiter dengan Quoting #

CSV (Comma-Separated Values) adalah format teks paling populer untuk data tabular. RFC 4180 mendefinisikan aturan dasar: field dipisahkan koma, record dipisahkan newline, field yang mengandung koma/double-quote/newline dibungkus dengan double-quote ("...").

c
#include <stdio.h>
#include <string.h>

int main(void) {
    printf("=== Format CSV (delimiter koma) ===\n");
    FILE *f = fopen("/tmp/test_format.csv", "w");
    if (!f) return 1;
    fprintf(f, "id,nama,umur,kota\n");
    fprintf(f, "1,Budi,20,Jakarta\n");
    fprintf(f, "2,Siti,21,Bandung\n");
    /* Field dengan koma harus di-quote */
    fprintf(f, "3,\"Dono Saputra\",22,Surabaya\n");
    fclose(f);

    f = fopen("/tmp/test_format.csv", "r");
    if (!f) return 1;
    char line[256];
    while (fgets(line, sizeof(line), f) != NULL) {
        printf("  %s", line);
    }
    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
=== Format CSV (delimiter koma) ===
  id,nama,umur,kota
  1,Budi,20,Jakarta
  2,Siti,21,Bandung
  3,"Dono Saputra",22,Surabaya

Baris keempat di output (3,"Dono Saputra",22,Surabaya) menunjukkan bahwa field "Dono Saputra" tidak mengandung koma, tetapi kita tetap membungkusnya dengan double-quote—ini konvensi yang baik bila kita tidak yakin apakah nama mengandung koma atau tidak. Pada implementasi nyata, kita perlu parser CSV yang menangani escape double-quote ("" untuk literal ").

Format Fixed-Width: Kolom Tetap #

Pada format fixed-width, setiap field menempati jumlah byte yang sama. Kita pakai format specifier printf seperti %-20s (rata kiri 20 karakter) atau %04d (angka 4 digit dengan leading zero). Format ini umum di sistem mainframe dan laporan keuangan.

c
#include <stdio.h>

int main(void) {
    printf("=== Format Fixed-Width (kolom tetap) ===\n");
    FILE *f = fopen("/tmp/test_format_fixed.txt", "w");
    if (!f) return 1;
    fprintf(f, "%-4s %-20s %3s %3s\n", "ID", "NAMA", "UMUR", "NILAI");
    fprintf(f, "%-4s %-20s %3d %3d\n", "001", "Budi Santoso", 20, 85);
    fprintf(f, "%-4s %-20s %3d %3d\n", "002", "Siti Aminah", 21, 92);
    fprintf(f, "%-4s %-20s %3d %3d\n", "003", "Andi", 19, 78);
    fclose(f);

    f = fopen("/tmp/test_format_fixed.txt", "r");
    if (!f) return 1;
    char line[256];
    while (fgets(line, sizeof(line), f) != NULL) {
        printf("  %s", line);
    }
    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
=== Format Fixed-Width (kolom tetap) ===
  ID   NAMA                 UMUR NILAI
  001  Budi Santoso          20  85
  002  Siti Aminah           21  92
  003  Andi                  19  78

Pada output di atas, kolom ID selalu 4 karakter (rata kiri), NAMA 20 karakter, UMUR 3 karakter (rata kanan), dan NILAI 3 karakter. Bila kita membaca baris ke-2 dengan fgets, kita bisa langsung mengakses ID sebagai substring line[0..3], NAMA sebagai line[5..24], dan seterusnya—tanpa parser kompleks.

Mari kita telaah baris-baris kode di atas:

  1. Baris 6 (fprintf(f, "%-4s %-20s %3s %3s\n", ...)): specifiers %-4s berarti string rata kiri dalam 4 karakter. %3d berarti angka rata kanan dalam 3 karakter. Spasi di antara specifier adalah pemisah visual (bukan data).
  2. Baris 7 (fprintf(f, "%-4s %-20s %3d %3d\n", "001", "Budi Santoso", 20, 85);): menulis satu record. "001" di-rata-kiri-kanan menjadi "001 " (4 karakter). "Budi Santoso" menjadi 20 karakter dengan spasi tambahan.
  3. Baris 13-15 (fgets + printf): membaca baris dan mencetak ulang. Karena data sudah ter-format dengan rapi, output yang dibaca sama persis dengan yang ditulis.

Format Biner dengan Header #

Untuk file yang efisien dan terdokumentasi, kita pakai pendekatan header + body. Header berisi:

  • Magic number — 4-8 byte identifikasi format (misalnya "MHS1" untuk “Mahasiswa versi 1”).
  • Version — angka versi untuk kompatibilitas naik.
  • Count — jumlah record atau metadata lain.

Setelah header, body berisi data biner (array struct).

c
#include <stdio.h>
#include <stdint.h>

typedef struct {
    uint32_t id;
    char     nama[20];
    uint16_t nilai;
} Record;

int main(void) {
    printf("=== Format dengan Header ===\n");
    FILE *f = fopen("/tmp/test_format_hdr.dat", "wb");
    if (!f) return 1;
    /* Header: magic (4 byte) + version (2 byte) + count (4 byte) */
    fwrite("MHS1", 1, 4, f);
    uint16_t version = 1;
    fwrite(&version, sizeof(version), 1, f);
    uint32_t count = 3;
    fwrite(&count, sizeof(count), 1, f);

    /* Body: array of Record */
    Record recs[] = {
        {1, "Budi", 85},
        {2, "Siti", 92},
        {3, "Andi", 78},
    };
    fwrite(recs, sizeof(Record), 3, f);
    fclose(f);

    /* Baca kembali dan verifikasi */
    f = fopen("/tmp/test_format_hdr.dat", "rb");
    if (!f) return 1;
    char magic[5] = {0};
    fread(magic, 1, 4, f);
    uint16_t ver;
    fread(&ver, sizeof(ver), 1, f);
    uint32_t cnt;
    fread(&cnt, sizeof(cnt), 1, f);
    printf("  magic=%s version=%u count=%u\n", magic, ver, cnt);

    for (uint32_t i = 0; i < cnt; i++) {
        Record r;
        fread(&r, sizeof(Record), 1, f);
        printf("  -> id=%u nama=%s nilai=%u\n", r.id, r.nama, r.nilai);
    }
    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
=== Format dengan Header ===
  magic=MHS1 version=1 count=3
  -> id=1 nama=Budi nilai=85
  -> id=2 nama=Siti nilai=92
  -> id=3 nama=Andi nilai=78

Saat membaca file, kita periksa magic number terlebih dahulu. Bila magic != "MHS1", file bukan format yang kita harapkan—kita bisa menolak dengan pesan error yang jelas. Selanjutnya kita baca version untuk menentukan layout body. Bila version > 1, kita bisa rute ke parser versi yang berbeda—ini adalah dasar backward compatibility.

Pola Magic Number dan Versioning #

Beberapa contoh magic number yang terkenal di dunia nyata:

FormatMagicHex
ELF executable\x7fELF7F 45 4C 46
PNG image\x89PNG\r\n\x1a\n89 50 4E 47 0D 0A 1A 0A
GIF imageGIF87a / GIF89a47 49 46 38 37 61
PDF document%PDF-25 50 44 46 2D
ZIP archivePK\x03\x0450 4B 03 04
Java class\xCA\xFE\xBA\xBECA FE BA BE

Konvensi yang baik: pakai 4-8 byte yang tidak mungkin muncul di awal file teks biasa. Magic number PNG bahkan sengaja memasukkan \r\n\x1a\n untuk mendeteksi transfer yang merusak newline (line-ending conversion) dan Ctrl-Z EOF (file transfer dari Windows).

Validasi Format Saat Membaca #

Saat membaca file dengan header, selalu validasi:

  1. Magic number — bila tidak cocok, file bukan format yang diharapkan.
  2. Version — bila lebih tinggi dari yang didukung, tolak dengan pesan jelas.
  3. Count — bila jumlah record tidak masuk akal (misalnya negatif atau sangat besar), tolak sebelum membaca.
  4. Ukuran file — bandingkan count * sizeof(record) dengan ukuran file aktual (fseek + ftell).
c
#include <stdio.h>
#include <stdint.h>
#include <string.h>

typedef struct {
    uint32_t id;
    char     nama[20];
    uint16_t nilai;
} Record;

int main(void) {
    FILE *f = fopen("/tmp/test_format_hdr.dat", "rb");
    if (!f) { perror("fopen"); return 1; }

    /* 1. Validasi magic number */
    char magic[5] = {0};
    if (fread(magic, 1, 4, f) != 4 || memcmp(magic, "MHS1", 4) != 0) {
        fprintf(stderr, "Magic number tidak cocok (dapat '%s')\n", magic);
        fclose(f);
        return 1;
    }

    /* 2. Cek version */
    uint16_t ver;
    fread(&ver, sizeof(ver), 1, f);
    if (ver != 1) {
        fprintf(stderr, "Versi %u tidak didukung (hanya versi 1)\n", ver);
        fclose(f);
        return 1;
    }

    /* 3. Cek count dengan ukuran file */
    uint32_t cnt;
    fread(&cnt, sizeof(cnt), 1, f);
    fseek(f, 0L, SEEK_END);
    long size = ftell(f);
    long expected = 10L + (long)cnt * sizeof(Record);   /* header 10 byte */
    if (size != expected) {
        fprintf(stderr, "Ukuran file %ld tidak sesuai (expected %ld)\n",
                size, expected);
        fclose(f);
        return 1;
    }
    fseek(f, 10L, SEEK_SET);   /* kembali ke posisi setelah header */

    printf("File valid. Membaca %u record...\n", cnt);
    for (uint32_t i = 0; i < cnt; i++) {
        Record r;
        fread(&r, sizeof(Record), 1, f);
        printf("  [%u] id=%u nama=%s nilai=%u\n", i, r.id, r.nama, r.nilai);
    }
    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
File valid. Membaca 3 record...
  [0] id=1 nama=Budi nilai=85
  [1] id=2 nama=Siti nilai=92
  [2] id=3 nama=Andi nilai=78