Dua kata yang sering disebut berpasangan ketika arsitektur software dibahas adalah abstraction dan encapsulation. Keduanya sudah Anda pakai sejak Section Pemrograman Berorientasi Objek: encapsulation menyembunyikan data member di belakang access specifier, abstraction menyajikan operasi tanpa membebankan detail cara kerjanya. Di level class, keduanya berhenti di private: — class yang sama tetap hidup di satu file, dan isi header ikut terbongkar ke setiap pemakai. Chapter ini menaikkan keduanya satu tingkat: memisahkan antarmuka (interface) dari implementasi secara fisik, dengan idiom klasik C++ bernama PIMPL — pointer to implementation.

Antarmuka vs Implementasi, dan Level Abstraction #

Antarmuka adalah janji: daftar function dan perilakunya yang boleh dipakai orang lain. Implementasi adalah dapurnya: data, struktur bantu, dan langkah-langkah persis yang mewujudkan janji itu. Class std::vector adalah contoh yang setiap hari kita nikmati — antarmukanya (push_back, size, operator[]) kita hafal, sementara hampir tidak ada dari kita yang tahu persis bagaimana realokasi internalnya bekerja. Itu bukan kebetulan, melainkan hasil desain: pembuat std::vector sengaja memisahkan dua dunia itu, sehingga implementasi boleh berganti tanpa satu baris kode pemakai pun berubah.

Abstraction bekerja berjenjang. Di level terendah, sebuah function menyembunyikan sepuluh baris langkah; di level class, antarmuka menyembunyikan data member; di level modul, satu class utuh disembunyikan di balik satu class antarmuka. Makin naik levelnya, makin sedikit detail yang harus dipikangkan pembaca — dan makin bebas kita mengganti isi tanpa mengganti bentuk. PIMPL adalah alat untuk jenjang teratas itu: class yang dilihat pemakai hanyalah cangkang tipis berisi deklarasi, sedangkan seluruh isi yang sesungguhnya tidur di sebuah struct terpisah.

Forward Declaration: Menyebut Tipe Tanpa Membukanya #

Satu-satunya bahasa yang kita butuhkan sudah Anda temui di Section Build System: forward declaration — menuliskan struct Impl; tanpa isi. Setelah baris itu, compiler tahu bahwa nama Impl adalah tipe yang akan datang, dan boleh dipakai untuk hal-hal yang tidak butuh ukurannya: mendeklarasikan pointer dan reference ke sana. Yang tetap dilarang sebelum definisi lengkapnya terlihat adalah hal yang butuh sizeof: me-dereference pointer, memanggil member, atau menyalin object — semua itu harus menunggu di file lain, setelah Impl didefinisikan.

Kombinasi std::unique_ptr dan forward declaration inilah mesin PIMPL. Class antarmuka hanya punya satu data member — std::unique_ptr<Impl> — dan satu-satunya tugasnya adalah memegang object implementasi yang benar-benar menyimpan data. Pemakai class tidak pernah menyentuh Impl langsung; semua akses lewat method yang kita pajang.

PIMPL dalam Kode yang Berjalan #

Program berikut memisahkan MesinKalkulasi menjadi dua lapis: class antarmuka di atas (yang akan menjadi isi header di proyek nyata) dan struct Impl di bawah (yang akan menjadi isi source file):

cpp
// pimpl-mesin.cpp — PIMPL: antarmuka kecil di depan, implementasi disembunyikan
// Kompilasi: g++ -std=c++23 -Wall -Wextra pimpl-mesin.cpp -o pimpl-mesin

#include <memory>
#include <print>
#include <string>
#include <vector>

// ===== Antarmuka publik: inilah satu-satunya yang perlu dibaca pemakai =====
class MesinKalkulasi {
public:
    explicit MesinKalkulasi(int nilaiAwal);
    ~MesinKalkulasi();

    // copy dilarang, move diizinkan — kepemilikan impl_ ikut berpindah
    MesinKalkulasi(const MesinKalkulasi&) = delete;
    MesinKalkulasi& operator=(const MesinKalkulasi&) = delete;
    MesinKalkulasi(MesinKalkulasi&&) noexcept;
    MesinKalkulasi& operator=(MesinKalkulasi&&) noexcept;

    void catat(int nilai);          // simpan satu nilai ke riwayat internal
    int total() const;              // nilai awal + seluruh riwayat
    std::size_t jumlahRiwayat() const;

private:
    struct Impl;                    // forward declaration: tipe diumumkan, isi belum
    std::unique_ptr<Impl> impl_;
};

// ===== Implementasi: boleh berisi apa pun, pemakai tidak pernah tahu =====
struct MesinKalkulasi::Impl {
    int nilaiAwal;
    std::vector<int> riwayat;       // besok bisa jadi map, cache, atau mutex
};

MesinKalkulasi::MesinKalkulasi(int nilaiAwal)
    : impl_(std::make_unique<Impl>(nilaiAwal, std::vector<int>{})) {}

// Kelima fungsi khusus DIDEFINISIKAN di sini, setelah Impl lengkap
MesinKalkulasi::~MesinKalkulasi() = default;
MesinKalkulasi::MesinKalkulasi(MesinKalkulasi&&) noexcept = default;
MesinKalkulasi& MesinKalkulasi::operator=(MesinKalkulasi&&) noexcept = default;

void MesinKalkulasi::catat(int nilai) {
    impl_->riwayat.push_back(nilai);
}

int MesinKalkulasi::total() const {
    int hasil = impl_->nilaiAwal;
    for (int nilai : impl_->riwayat) {
        hasil += nilai;
    }
    return hasil;
}

std::size_t MesinKalkulasi::jumlahRiwayat() const {
    return impl_->riwayat.size();
}

int main() {
    MesinKalkulasi mesin(100);
    mesin.catat(2);
    mesin.catat(3);
    mesin.catat(1);
    std::println("total          : {}", mesin.total());
    std::println("jumlah riwayat : {}", mesin.jumlahRiwayat());

    MesinKalkulasi mesinBaru = std::move(mesin);   // kepemilikan impl_ berpindah
    std::println("pemilik baru   : total {} ({} catatan)",
                 mesinBaru.total(), mesinBaru.jumlahRiwayat());
    return 0;
}

Output yang Dihasilkan:

text
total          : 106
jumlah riwayat : 3
pemilik baru   : total 106 (3 catatan)

Mari kita bedah mekanismenya:

  1. Baris 12-23 (deklarasi method tanpa isi): antarmuka tidak memuat satu pun body. Pemakai cukup membaca tujuh deklarasi ini untuk tahu segalanya tentang MesinKalkulasi — inilah abstraction pada level modul.
  2. Baris 26-27 (struct Impl; dan unique_ptr<Impl>): inti idiom. Impl hanya diumumkan, dan unique_ptr menahannya lewat pointer — dua-duanya tidak butuh ukuran tipe, sehingga class antarmuka lolos tanpa definisi lengkap.
  3. Baris 31-34 (definisi Impl): seluruh data member yang dulu mengotori class kini tinggal di sini. Menambah std::map untuk cache atau std::mutex untuk thread hanya menyentuh bagian ini.
  4. Baris 36-37 (constructor): std::make_unique<Impl>(...) dialokasikan di heap — satu alokasi kecil yang menjadi “biaya sewa” privasi ini.
  5. Baris 40-42 (kelima fungsi khusus di luar class): destructor dan kedua move didefinisikan di sini secara sadar, setelah Impl lengkap. Baris = default berarti kita menulis deklarasi tapi meminta compiler menghasilkan isinya — dan isinya (menghentikan object Impl, memindahkan pointer) hanya bisa dihasilkan bila Impl sudah utuh.
  6. Baris 44-58 (method lewat impl_->): setiap method mendelegasikan pekerjaan ke object internal. Dua panah kecil ini adalah satu-satunya indireksi yang dibayar untuk seluruh keuntungan di bawah.
  7. Baris 68 (std::move(mesin)): move memindahkan pointer impl_ — object pemilik baru mengoperasikan implementasi yang sama, sesuai jejak output yang tetap menghitung 106.

Mengapa Destructor Harus Keluar dari Class #

Satu jebakan PIMPL layak diperagakan sebelum Anda menabraknya sendiri. Coba tulis destructor sebagai = default langsung di dalam class, sebagaimana kebiasaan yang selama ini terasa wajar. Di proyek nyata — dengan header dan source terpisah — pemakai meng-include header dan mencoba memakai class; compiler langsung protes di file pemakai:

text Error nyata saat destructor di-default di header (GCC 14)
/usr/include/c++/14/bits/unique_ptr.h: In instantiation of 'constexpr void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = MesinKalkulasi::Impl]':
/usr/include/c++/14/bits/unique_ptr.h:399:17:   required from 'constexpr std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = MesinKalkulasi::Impl; ...]'
mesin.h:7:5:   required from here
    7 |     ~MesinKalkulasi() = default;   // coba-coba: didefinisikan inline di header
/usr/include/c++/14/bits/unique_ptr.h:91:23: error: invalid application of 'sizeof' to incomplete type 'MesinKalkulasi::Impl'
   91 |         static_assert(sizeof(_Tp)>0,

Alasannya masuk akal: menghentikan unique_ptr<Impl> berarti memanggil delete ke pointer itu, dan delete butuh tahu ukuran serta destructor Impl. Di titik pemakai meng-include header, Impl masih tipe yang belum lengkap — compiler baru melihat definisinya di file source yang tidak pernah disertakan. Karena itu aturan baku PIMPL: destructor, copy, dan move dideklarasikan di class, tetapi didefinisikan (= default atau manual) setelah Impl lengkap. Di contoh satu-file di atas posisi baris 40-42 terlihat seperti formalitas; di proyek nyata baris-baris itu yang menyelamatkan kompilasi.

Keuntungan Kompilasi yang Dibayar dari Indireksi Ini #

Dengan PIMPL, header MesinKalkulasi tidak lagi memuat <vector> — satu-satunya header yang dibutuhkan antarmukanya adalah <memory>:

cpp Wujud header sungguhan — mesin-kalkulasi.h (ilustrasi)
#pragma once
#include <memory>          // satu-satunya header yang dibutuhkan antarmuka

class MesinKalkulasi {
public:
    explicit MesinKalkulasi(int nilaiAwal);
    ~MesinKalkulasi();

    MesinKalkulasi(const MesinKalkulasi&) = delete;
    MesinKalkulasi& operator=(const MesinKalkulasi&) = delete;
    MesinKalkulasi(MesinKalkulasi&&) noexcept;
    MesinKalkulasi& operator=(MesinKalkulasi&&) noexcept;

    void catat(int nilai);
    int total() const;
    std::size_t jumlahRiwayat() const;

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

Untuk satu class, selisihnya terasa kecil. Untuk sebuah library yang class-nya dipakai dua ratus file, efeknya nyata: header yang ramping memutus rantai #include yang panjang (waktu kompilasi total turun), dan perubahan internal — misalnya std::vector diganti struktur data sendiri — hanya memicu kompilasi ulang satu file .cpp, bukan seluruh pemakai. Detail mekanisme build di balik pernyataan ini (dependency graph, incremental rebuild) sudah Anda pelajari di Section Build System. Ada pula bonus non-kompilasi yang sama pentingnya: ABI yang stabil — data member bisa berubah tanpa merusak binary yang sudah terkompilasi melawan header lama.

Antarmuka yang sudah terpisah dari implementasi ini adalah fondasi bagi pemecahan tingkat berikutnya. Ketika satu program punya banyak pekerjaan yang tidak seharusnya saling mengetahui — membaca input, menghitung, menampilkan — kita perlu alat yang lebih besar dari satu class: pemisahan tanggung jawab di seluruh lapisan program.

Referensi: std::unique_ptr (cppreference) , Pimpl idiom (cppreference) .