C memberi programmer kebebasan penuh atas memori—tetapi dengan kebebasan itu datang tanggung jawab. Setiap memory leak, double free, use-after-free, dan buffer overflow yang pernah kita bahas di chapter sebelumnya berasal dari satu akar: tidak ada otomatisasi yang menjamin kebenaran manajemen memori. Strategi memory safety adalah kumpulan pola desain dan praktik yang membuat bug memori kurang mungkin terjadi, lebih cepat ditemukan bila terjadi, dan lebih mudah diperbaiki. Pada chapter terakhir ini kita akan merangkum pola yang sudah kita pelajari sepanjang section, menambahkan beberapa strategi baru, dan melihat implementasi konkret dari sebuah container sederhana dengan manajemen memori yang aman.

Tujuh Strategi Utama #

Ringkasan tujuh strategi yang harus menjadi habit setiap programmer C:

  1. Selalu cek NULL setelah malloc/calloc/realloc — alokasi bisa gagal. Bila tidak dicek, dereference pointer NULL menyebabkan segfault atau undefined behavior.
  2. Set pointer ke NULL setelah free — mencegah dangling pointer dan double free. Karena free(NULL) aman, ini membuat kode lebih robust tanpa overhead.
  3. Satu pemilik, satu free — gunakan pola factory function yang mengalokasikan dan mengembalikan pointer. Pemanggil menjadi pemilik dan satu-satunya yang boleh memanggil free. Lihat chapter Ownership dan Lifetime.
  4. Gunakan calloc bila butuh memori berisi nol — lebih aman daripada malloc + memset. Untuk struct berisi pointer, calloc otomatis mengisi pointer dengan NULL.
  5. Gunakan snprintf, strncpy, fgets alih-alih sprintf, strcpy, gets — fungsi bounded mencegah buffer overflow.
  6. Aktifkan -Wall -Wextra -Wconversion dan -fsanitize=address,undefined di CI — compiler warning dan sanitizer menangkap bug memori yang tidak terlihat dalam testing fungsional.
  7. Untuk struktur kompleks, gunakan opaque struct + fungsi akses — sembunyikan detail internal struct dari pengguna. Pengguna hanya bisa berinteraksi lewat fungsi init/destroy/push/pop—tidak dapat mengakses field langsung dan menyebabkan invariant violation.

Contoh 1: Container Aman dengan Init/Destroy #

Kita rancang struct Vector dengan opaque akses. Fungsi vector_init mengalokasikan array awal, vector_push memperbesar bila perlu, dan vector_destroy membebaskan. Karena vector_destroy adalah satu-satunya yang boleh memanggil free, ownership sangat jelas.

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

/* Pola: typedef struct dengan inisialisasi & destroy */
typedef struct {
    size_t kapasitas;
    size_t ukuran;
    int *data;
} Vector;

int vector_init(Vector *v, size_t kapasitas_awal) {
    v->data = malloc(kapasitas_awal * sizeof(int));
    if (v->data == NULL) return -1;
    v->kapasitas = kapasitas_awal;
    v->ukuran = 0;
    return 0;
}

void vector_destroy(Vector *v) {
    if (v && v->data) {
        free(v->data);
        v->data = NULL;
        v->kapasitas = 0;
        v->ukuran = 0;
    }
}

int vector_push(Vector *v, int nilai) {
    if (v->ukuran == v->kapasitas) {
        size_t baru = v->kapasitas * 2;
        int *p = realloc(v->data, baru * sizeof(int));
        if (p == NULL) return -1;
        v->data = p;
        v->kapasitas = baru;
    }
    v->data[v->ukuran++] = nilai;
    return 0;
}

int main(void) {
    Vector v;
    if (vector_init(&v, 4) != 0) {
        fprintf(stderr, "init gagal\n");
        return 1;
    }

    for (int i = 1; i <= 10; i++) {
        vector_push(&v, i * 10);
    }

    printf("Vector: ukuran=%zu, kapasitas=%zu\n", v.ukuran, v.kapasitas);
    printf("Isi: ");
    for (size_t i = 0; i < v.ukuran; i++) {
        printf("%d ", v.data[i]);
    }
    printf("\n");

    vector_destroy(&v);
    printf("Setelah destroy: ukuran=%zu, kapasitas=%zu, data=%p\n",
           v.ukuran, v.kapasitas, (void*)v.data);
    return 0;
}

Output yang Dihasilkan:

text
Vector: ukuran=10, kapasitas=16
Isi: 10 20 30 40 50 60 70 80 90 100 
Setelah destroy: ukuran=0, kapasitas=0, data=(nil)

Setelah vector_destroy(&v), semua field di-set ke nol/NULL. Pemanggil yang lupa memanggil vector_destroy menyebabkan memory leak—tetapi bila pemanggil memanggil vector_destroy dua kali, vector_destroy aman karena pengecekan if (v && v->data) mencegah double free.

Mari kita telaah baris-baris kode di atas:

  1. Baris 6-11 (typedef struct { ... } Vector;): deklarasi struct publik dengan field terbuka. Pada implementasi opaque yang lebih ketat, struct ini dideklarasikan di file .c dan hanya dideklarasikan maju di header—pengguna tidak dapat mengakses field langsung.
  2. Baris 13 (int vector_init(Vector *v, size_t kapasitas_awal)): fungsi inisialisasi. Mengembalikan kode error (-1 bila gagal) alih-alih pointer—lebih mudah dipakai dengan idiom if (vector_init(...) != 0).
  3. Baris 21 (void vector_destroy(Vector *v)): fungsi pembersih. Cek if (v && v->data) membuat fungsi ini idempoten—pemanggil dua kali tidak menyebabkan double free.
  4. Baris 22 (free(v->data);): hanya v->data yang di-free, bukan v sendiri. v adalah struct yang dimiliki pemanggil (di stack atau di struct lain); v->data adalah blok heap yang dimiliki Vector.
  5. Baris 31 (int vector_push(Vector *v, int nilai)): fungsi mutator. Bila kapasitas penuh, panggil realloc untuk memperbesar. Idioma int *p = realloc(...); if (p) v->data = p; menghindari kehilangan pointer lama bila realloc gagal.
  6. Baris 49-51 (for (int i = 1; i <= 10; i++) vector_push(&v, i * 10);): memasukkan 10 nilai. Kapasitas awal 4, sehingga realloc dipanggil dua kali (4 -> 8 -> 16). Hasil akhir: ukuran 10, kapasitas 16.

Contoh 2: Cleanup Path Konsisten #

Fungsi yang mengalokasikan beberapa blok harus punya cleanup path yang konsisten. Pola goto cleanup dianggap acceptable di C untuk error handling—meskipun goto sering dihindari, di sini pola ini membuat kode lebih jelas daripada nested if bersarang.

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

/* Pola aman: selalu cek NULL + kembalikan kode error */
char *salin_string(const char *src) {
    if (src == NULL) return NULL;
    size_t panjang = strlen(src);
    char *dst = malloc(panjang + 1);
    if (dst == NULL) return NULL;
    memcpy(dst, src, panjang + 1);
    return dst;
}

/* Fungsi dengan cleanup path yang konsisten */
int proses_file(const char *nama) {
    char *salinan = NULL;
    char *header = NULL;
    int rc = 0;

    salinan = salin_string(nama);
    if (salinan == NULL) { rc = 1; goto cleanup; }

    header = salin_string("HEADER:");
    if (header == NULL) { rc = 2; goto cleanup; }

    printf("Memproses: %s %s\n", header, salinan);

cleanup:
    /* Selalu bebaskan semua yang sudah dialokasikan */
    free(salinan);
    salinan = NULL;
    free(header);
    header = NULL;
    return rc;
}

int main(void) {
    int rc = proses_file("data.txt");
    printf("Status: %d\n", rc);

    rc = proses_file(NULL);   /* uji path error */
    printf("Status dengan NULL: %d\n", rc);

    return 0;
}

Output yang Dihasilkan:

text
Memproses: HEADER: data.txt
Status: 0
Status dengan NULL: 1

Saat proses_file(NULL) dipanggil, salin_string(NULL) mengembalikan NULL, dan goto cleanup melompat ke label cleanup. Di sana, free(salinan) (yang NULL) adalah no-op yang aman, dan free(header) (juga NULL) juga aman. Tidak ada leak dan tidak ada double free. Pola ini sangat berguna untuk fungsi yang mengalokasikan banyak blok dengan urutan dependensial.

Toolchain dan CI Recommendations #

Untuk mengaktifkan deteksi bug memori sejak awal, tambahkan ke Makefile atau CMakeLists.txt:

  • -Wall -Wextra -Wpedantic — peringatan compiler standar.
  • -Wconversion -Wsign-conversion — peringatan konversi tipe (sering tangkap bug halus).
  • -Werror (opsional) — anggap peringatan sebagai error untuk build CI.
  • -fsanitize=address,undefined untuk build tes—menangkap leak, double-free, UAF, overflow, dan UB lainnya.
  • -fsanitize=memory (Clang saja) — menangkap baca memori tidak terinisialisasi.
  • -D_FORTIFY_SOURCE=2 -fstack-protector-strong untuk build produksi—hardening tanpa overhead sanitizer.
  • -g — simbol debug untuk stack trace ASan/Valgrind yang berguna.
  • Valgrind (valgrind --leak-check=full ./program) untuk binary produksi yang tidak dikompilasi dengan ASan.

Checklist Akhir #

Sebelum menyatakan kode C “memory-safe”, verifikasi:

  • Setiap malloc/calloc/realloc diikuti pengecekan NULL.
  • Setiap free diikuti pointer di-set NULL.
  • Setiap factory function terdokumentasi sebagai “caller must free”.
  • Tidak ada gets, sprintf, strcpy tanpa batas; gunakan fgets, snprintf, strncpy/strlcpy.
  • Loop array menggunakan < n bukan <= n (cegah off-by-one).
  • #pragma pack digunakan hanya bila binary compatibility wajib.
  • Program tes dikompilasi dengan -fsanitize=address,undefined dan lulus tanpa error.
  • Valgrind atau ASan melaporkan “0 byte(s) leaked”.