Dua chapter lalu kita membangun dua pola susunan object — decorator dan strategy — dan keduanya ternyata punya nama yang sama di mana-mana: developer Jepang, Brazil, dan Jerman menyebutnya dengan istilah yang kita pakai. Itulah nilai pertama design patterns: kosakata bersama. Nilai keduanya adalah resep yang sudah diuji puluhan tahun — katalog Design Patterns: Elements of Reusable Object-Oriented Software (Gang of Four, 1994) memetakan 23 pola beserta masalah yang masing-masing jawab. Chapter ini memilih pola-pola yang paling sering terbukti berguna di C++ modern: Strategy dan Observer, Factory Method, Adapter, serta Singleton — yang dibahas bersama kritiknya, karena pola yang salah tempat justru merusak desain.
Membaca Pola: Masalahnya Dulu, Bukan Namanya #
Kesalahan paling umum saat belajar pattern adalah menghafal nama lalu mencari tempat memakainya. Arah yang benar adalah sebaliknya: kenali masalah berulangnya, baru sadar bahwa solusinya sudah punya nama. Empat masalah yang paling sering berulang di kode aplikasi: “satu peristiwa harus sampai ke beberapa pihak yang jumlahnya bisa berubah” (Observer), “kerangka kerja sama di semua varian, hanya detail pembuatan objectnya yang beda” (Factory Method), “class lama yang tidak boleh disentuh harus bisa dipakai antarmuka baru” (Adapter), dan “kebijakan harus bisa ditukar” (Strategy — sudah kita bangun di chapter lalu). Tiga yang pertama kita buktikan sekarang dalam satu program teruji.
Observer: Satu Umumkan, Banyak Penerima #
Observer memutus ketergantungan dua arah antara pemberi berita dan penerimanya. Topik tidak tahu ada TampilanDasbor dan PencatatBerkas — ia hanya mengenal antarmuka Pengamat — sehingga penerima baru datang pergi tanpa Topik ikut disunting:
// pola-gof.cpp — Observer, Factory Method, dan Adapter dalam satu program
// Kompilasi: g++ -std=c++23 -Wall -Wextra pola-gof.cpp -o pola-gof
#include <memory>
#include <print>
#include <string>
#include <vector>
// ===== Observer: satu sumber peristiwa, banyak penerima yang tidak saling kenal =====
class Pengamat {
public:
virtual ~Pengamat() = default;
virtual void terima(const std::string& peristiwa) = 0;
};
class Topik {
public:
void daftar(Pengamat* p) { pengamat_.push_back(p); }
void umumkan(const std::string& peristiwa) const {
for (Pengamat* p : pengamat_) {
p->terima(peristiwa);
}
}
private:
std::vector<Pengamat*> pengamat_;
};
class TampilanDasbor : public Pengamat {
public:
void terima(const std::string& peristiwa) override {
std::println("[dasbor] {}", peristiwa);
}
};
class PencatatBerkas : public Pengamat {
public:
void terima(const std::string& peristiwa) override {
std::println("[berkas] {}", peristiwa);
}
};
// ===== Factory Method: kerangka tetap di class dasar, detail pembuatan di turunan =====
class Penulis {
public:
virtual ~Penulis() = default;
virtual std::string tulis(const std::string& judul, int total) const = 0;
};
class PenulisCsv : public Penulis {
public:
std::string tulis(const std::string& judul, int total) const override {
return std::format("{},{}", judul, total);
}
};
class PenulisJson : public Penulis {
public:
std::string tulis(const std::string& judul, int total) const override {
return std::format("{{\"judul\": \"{}\", \"total\": {}}}", judul, total);
}
};
class EksporLaporan { // creator
public:
virtual ~EksporLaporan() = default;
virtual std::unique_ptr<Penulis> buatPenulis() const = 0; // factory method
void jalankan(const std::string& judul, int total) const { // kerangka yang sama
auto penulis = buatPenulis();
std::println("{}: {}", label(), penulis->tulis(judul, total));
}
protected:
virtual std::string label() const = 0;
};
class EksporCsv : public EksporLaporan {
public:
std::unique_ptr<Penulis> buatPenulis() const override {
return std::make_unique<PenulisCsv>();
}
std::string label() const override { return "ekspor csv "; }
};
class EksporJson : public EksporLaporan {
public:
std::unique_ptr<Penulis> buatPenulis() const override {
return std::make_unique<PenulisJson>();
}
std::string label() const override { return "ekspor json"; }
};
// ===== Adapter: antarmuka lama yang tidak boleh diubah, dibungkus jadi antarmuka baru =====
class SumberData {
public:
virtual ~SumberData() = default;
virtual std::vector<int> angka() const = 0;
};
class SumberLegacy { // class lama: API-nya string terpisah koma
public:
std::string unduhBaris() const { return "12;7;2025"; }
};
class SumberDataAdapter : public SumberData {
public:
explicit SumberDataAdapter(const SumberLegacy& sumber) : sumber_(sumber) {}
std::vector<int> angka() const override {
std::vector<int> hasil;
std::string baris = sumber_.unduhBaris();
std::size_t awal = 0;
while (awal < baris.size()) {
std::size_t akhir = baris.find(';', awal);
if (akhir == std::string::npos) {
akhir = baris.size();
}
hasil.push_back(std::stoi(baris.substr(awal, akhir - awal)));
awal = akhir + 1;
}
return hasil;
}
private:
const SumberLegacy& sumber_;
};
int main() {
std::println("== observer ==");
TampilanDasbor dasbor;
PencatatBerkas berkas;
Topik topikSensor;
topikSensor.daftar(&dasbor);
topikSensor.daftar(&berkas);
topikSensor.umumkan("SENSOR A: ONLINE"); // satu umumkan, dua penerima
std::println("== factory method ==");
EksporCsv{}.jalankan("komisi-oktober", 310000);
EksporJson{}.jalankan("komisi-oktober", 310000);
std::println("== adapter ==");
SumberLegacy legacy;
SumberDataAdapter adapter(legacy);
int jumlah = 0;
for (int n : adapter.angka()) {
std::print("{} ", n);
jumlah += n;
}
std::println("-> jumlah {}", jumlah);
return 0;
}Output yang Dihasilkan:
== observer ==
[dasbor] SENSOR A: ONLINE
[berkas] SENSOR A: ONLINE
== factory method ==
ekspor csv : komisi-oktober,310000
ekspor json: {"judul": "komisi-oktober", "total": 310000}
== adapter ==
12 7 2025 -> jumlah 2044Mari bedah bagian observer-nya:
Topik::daftar()danstd::vector<Pengamat*>: penerima didaftarkan saat perakitan, bukan ditanam di kodeTopik. Menambah penerima berarti satu panggilandaftar()dimain()— bukan menyuntingTopik.Topik::umumkan(): loop sederhana di atas antarmuka.Topiktidak tahu berapa penerima, tidak tahu namanya, dan tidak peduli apa yang mereka lakukan — hanya kontrakterima()yang berlaku.- Arah panahnya:
TampilanDasbordanPencatatBerkasmenunjuk kePengamat;Topikjuga hanya menunjuk kePengamat. Kedua sisi bergantung pada abstraksi — benang DIP yang sama dari chapter SOLID Principles. std::vector<Pengamat*>(bukan unique_ptr): pilihan sadar untuk pola ini —Topiktidak memiliki penerimanya, ia hanya memanggil. Keputusan kepemilikan seperti ini ada di tangan perakit (main()), dan pola modern umumnya menyimpanstd::unique_ptr<Pengamat>bilaTopikmemang pemiliknya.
Factory Method: Kerangka di Dasar, Detail di Turunan #
Factory Method menjawab masalah: satu kerangka algoritma sama di semua varian, tetapi ada satu langkah — membuat object — yang harus berbeda per varian. Alih-alih melempar object jadi lewat if, class dasar menyatakan langkah itu sebagai method virtual yang tugasnya satu: membuat:
EksporLaporan::jalankan(): kerangka yang diwariskan utuh — ambil penulis, tulis, cetak. Barisauto penulis = buatPenulis();adalah jantung polanya: class dasar meminta object yang ia sendiri tidak tahu tipenya.buatPenulis()diEksporCsvdanEksporJson: setiap turunan menjawab denganstd::make_unique<PenulisCsv>()atauPenulisJson— detail pembuatan dikunci di tempatnya masing-masing. Konstruktorprotected-an danunique_ptrdi sini adalah wajah modern pola ini: kepemilikan hasil factory diserahkan ke pemanggil secara eksplisit lewat smart pointer, bukannewtelanjang.label()sebagai method virtual kedua: menunjukkan bahwa creator boleh punya lebih dari satu factory method; yang penting kerangkajalankan()tetap tak tersentuh saat varian baru datang.
Output bagian ini menunjukkan dua pemanggilan jalankan() yang identik kerangkanya — satu baris hasil ekspor — tetapi menghasilkan format berbeda. Beda hanya di satu keputusan: object apa yang buatPenulis() kembalikan.
Adapter: Jembatan ke Class Lama yang Tidak Boleh Disentuh #
Adapter masuk ketika dua bagian sudah benar secara desain tetapi salah secara bentuk: kode baru menginginkan SumberData::angka() yang mengembalikan std::vector<int>, sementara class lama — yang sudah teruji, terpakai, dan tidak boleh diubah — hanya mampu memberi std::string dipisah koma. SumberDataAdapter adalah class kecil yang mengimplementasikan antarmuka baru sambil membungkus class lama: ia menerima string, memecahnya di loop, dan mengembalikan vector. Kode pemakai di main() hanya mengenal SumberData&; class SumberLegacy sama sekali tidak tahu dirinya sedang diadaptasi.
Pola ini adalah alat migrasi yang sangat praktis di C++: bagian demi bagian kode lama bisa ditutup dengan adapter tanpa menyentuh aslinya, lalu aslinya diganti di belakang adapter ketika waktunya tiba — antarmuka di depan tidak berubah sama sekali.
Singleton: Satu Instance, Banyak Kritik #
Singleton menjanjikan satu instance global yang mudah diakses. Wujud modernnya yang layak dipakai adalah Meyers singleton — instance di dalam function local static, dibuat sekali dan thread-safe sejak C++11:
class Konfigurasi {
public:
static Konfigurasi& instan() {
static Konfigurasi satu; // dibuat sekali pada pemanggilan pertama
return satu;
}
Konfigurasi(const Konfigurasi&) = delete;
Konfigurasi& operator=(const Konfigurasi&) = delete;
private:
Konfigurasi() = default;
};
// pemakaian: Konfigurasi::instan().baca("timeout")
Kritiknya justru lebih penting dari resepnya. Singleton yang bisa diakses dari mana-mana adalah global state: fungsi apa pun bisa mengubahnya tanpa terlihat di antarmuka — kopling tersembunyi persis yang kita hindari sepanjang section ini. Ia juga melukai pengujian: test pertama mengubah state, test kedua mewarisi sisa itu, dan hasil test bergantung urutan eksekusi. Terakhir, ia merusak DIP: class yang memanggil Konfigurasi::instan() memilih sendiri detailnya, sehingga tidak bisa diberi implementasi lain saat diuji. Perbandingan yang sehat: Singleton hanya layak untuk resource yang benar-benar satu di seluruh sistem dan berat direplikasi (misalnya pool koneksi), dengan state seminimal mungkin; kebutuhan “nilai konfigurasi yang bisa diakses luas” lebih sehat dijawab dengan dependency injection — membawa object konfigurasi masuk lewat constructor, seperti yang akan kita lakukan di chapter berikutnya.
Pattern Overuse: Ketika Solusinya Menjadi Masalah #
Setiap pattern memperkenalkan indireksi — class tambahan, lapisan virtual, atau aturan pemakaian — dan indireksi berbayar. Gejala overuse yang paling sering: hierarki berlapis-lapis untuk satu operasi, antarmuka satu-method yang tak pernah punya implementasi kedua, atau Singleton yang ditempel “biar enak dipanggil”. Kaidah penyaring yang kita pakai sepanjang section ini tetap berlaku di sini: pattern dipakai ketika variabilitasnya nyata — ada varian kedua yang terlihat, ada kebutuhan menukar saat runtime, ada class lama yang benar-benar harus dibungkus. Satu implementasi yang hanya dipakai satu tempat belum butuh pattern; pattern bisa ditambahkan saat variabilitasnya datang, dan kode yang bersih jauh lebih mudah diberi pattern daripada kode yang sudah penuh indireksi.
Empat pola terakhir ini menuntaskan bekal strukturnya: modul yang terpisah, urusan yang berlapis, prinsip yang bernama, pola yang ada katalognya. Tinggal satu pertanyaan perakitan yang paling sering ditanya saat praktik: object yang saling bergantung itu disambung dari mana? Siapa yang membangun LayananPesanan beserta pengirimnya, gudangnya, dan konfigurasinya — dan bagaimana semua itu bisa diuji tanpa saling menarik? Teknik menjawabnya adalah bab berikutnya: dependency injection.
Referensi: std::unique_ptr (cppreference) , Design Patterns — Observer .