Design pattern dan gaya Pythonic

Design pattern dengan gaya Pythonic, dari singleton lewat modul hingga dependency injection.

Daftar isi

Gaya Pythonic menempatkan fitur bahasa di atas formalitas struktur, dan prinsip itu juga berlaku pada design pattern, yaitu solusi baku untuk masalah desain yang berulang. Banyak pola klasik lahir pada bahasa yang tidak memiliki fungsi kelas satu, yaitu fungsi yang dapat diperlakukan sebagaimana nilai biasa, serta kelas yang bersifat statis. Karena keterbatasan semacam itu, versi asli pola-pola tersebut terasa berat ketika dipindahkan mentah-mentah ke Python.

Topik ini membahas tiga pola yang paling sering ditemui pada proyek nyata, yaitu singleton, strategy, dan dependency injection, beserta bentuk Pythonic-nya. Setiap pola disertai kode yang dijalankan sehingga perilakunya dapat diperiksa. Setelah membacanya, sebuah pola dapat dikenali dari masalah yang dipecahkannya, dan bentuk penerapannya dapat dipilih sesuai fitur bahasa yang tersedia.

Modul sebagai singleton#

Singleton menjamin satu instans tunggal dari sebuah objek beserta titik akses global kepadanya. Kebutuhan semacam ini paling sering muncul untuk konfigurasi, koneksi bersama, dan pencatat kejadian. Pada Python, kebutuhan tersebut sudah dijawab oleh mekanisme modul. Sebuah modul dieksekusi hanya satu kali saat pertama kali diimpor, kemudian hasilnya disimpan di sys.modules, sehingga setiap impor berikutnya menerima objek modul yang sama. Perubahan pada atribut modul langsung terlihat oleh seluruh kode yang mengimpornya, tanpa kelas tambahan dan tanpa metode pembungkus.

Contoh berikut membangun dua modul, lalu membuktikan bahwa keduanya berbagi objek konfigurasi yang sama. Modul konfigurasi.py menyimpan pengaturan, sedangkan laporan.py membaca pengaturan itu saat mencetak nilai.

python Contoh: modul sebagai singleton
import sys
import tempfile
from pathlib import Path

akar = Path(tempfile.mkdtemp(prefix="contoh-singleton-"))
sys.path.insert(0, str(akar))

(akar / "konfigurasi.py").write_text(
    "OPSIM = {'desimal': 2, 'pemisah': ','}\n",
    encoding="utf-8",
)
(akar / "laporan.py").write_text(
    "import konfigurasi\n"
    "\n"
    "def cetak(nilai):\n"
    "    angka = round(nilai, konfigurasi.OPSIM['desimal'])\n"
    "    print('nilai:', angka)\n",
    encoding="utf-8",
)

import konfigurasi
import laporan

konfigurasi.OPSIM["desimal"] = 4
laporan.cetak(3.14159)
print("objek yang sama:", sys.modules["konfigurasi"] is konfigurasi)
text Keluaran
nilai: 3.1416
objek yang sama: True

Keluaran menunjukkan dua bukti sekaligus. Nilai desimal yang diubah melalui objek konfigurasi ikut terbaca oleh laporan.cetak(), sehingga hasilnya empat angka di belakang koma, bukan dua. Ekspresi sys.modules["konfigurasi"] is konfigurasi bernilai True, yang berarti impor ulang tidak pernah membuat objek baru. Pada proyek nyata, konfigurasi.py adalah berkas sungguhan di dalam package, dan seluruh modul lain mengimpornya sebagaimana biasa.

Singleton berbasis kelas dengan new#

Apabila objek tunggal membutuhkan perilaku kelas, misalnya metode sendiri atau warisan, pola singleton dapat dibangun lewat metode __new__. Metode ini dipanggil sebelum __init__ pada setiap pembuatan instans, sehingga menjadi tempat yang tepat untuk mengembalikan instans yang sudah ada alih-alih membuat yang baru. Instans lama yang dikembalikan itu tetap melewati __init__ pada setiap pemanggilan, sehingga penyetelan awal di dalamnya perlu ditulis dengan hati-hati. Implementasinya berupa satu atribut kelas yang menyimpan instans tunggal tersebut, dan setiap pemanggilan PencatatKejadian() berikutnya hanya mengembalikan nilai atribut itu.

python Contoh: singleton berbasis kelas dengan __new__
class PencatatKejadian:
    """Kelas yang selalu mengembalikan instans yang sama."""
    _instans = None

    def __new__(cls):
        if cls._instans is None:
            cls._instans = super().__new__(cls)
        return cls._instans

a = PencatatKejadian()
b = PencatatKejadian()
print("objek yang sama:", a is b)
b.kejadian = []
b.kejadian.append("mulai")
print("isi a:", a.kejadian)
text Keluaran
objek yang sama: True
isi a: ['mulai']

Perubahan yang dilakukan melalui b terbaca dari a karena keduanya memang objek yang sama. Pola ini sah dan umum digunakan, tetapi pada Python bentuk berbasis modul biasanya lebih disukai karena tidak menambah kelas sama sekali. Kelas tunggal dipilih apabila instansnya harus mewarisi kelas lain, atau apabila objeknya harus dikenali oleh pemeriksa tipe sebagai instans kelas tertentu. Selain itu, pengujian atas kelas semacam ini perlu mengosongkan atribut _instans antar kasus, sebab keadaan sisa dari pengujian sebelumnya masih tertinggal.

Strategy dengan fungsi kelas satu#

Pola strategy menyimpan beberapa cara melakukan satu pekerjaan, lalu memilih salah satunya saat program berjalan. Versi klasiknya memakai satu antarmuka bersama, beberapa kelas strategi, dan satu kelas konteks yang menampung strategi terpilih. Pada Python, fungsi adalah objek kelas satu, yaitu nilai yang dapat dikirim sebagai argumen sebagaimana angka atau string, sehingga seluruh hierarki itu cukup digantikan oleh beberapa fungsi dan satu parameter. Pola ini tepat dipakai ketika perilaku yang sama ditulis dengan beberapa cabang if, misalnya memilih cara merangkum data sesuai permintaan pemanggil.

python strategi.py
"""Strategi agregasi sebagai fungsi kelas satu."""
import statistics

def rata_rata(nilai):
    """Strategi pertama: nilai rata-rata."""
    return statistics.mean(nilai)

def median(nilai):
    """Strategi kedua: nilai tengah."""
    return statistics.median(nilai)

def rentang(nilai):
    """Strategi ketiga: selisih nilai tertinggi dan terendah."""
    return max(nilai) - min(nilai)

def ringkas(nilai, strategi):
    """Menerapkan fungsi strategi terhadap daftar nilai."""
    return strategi(nilai)

NILAI_UJIAN = [70, 85, 90, 60, 78]

for nama, fungsi in (("rata-rata", rata_rata), ("median", median), ("rentang", rentang)):
    print(f"{nama}: {ringkas(NILAI_UJIAN, fungsi)}")
text Keluaran
rata-rata: 76.6
median: 78
rentang: 30

Fungsi ringkas() menerima daftar nilai beserta sebuah fungsi strategi, lalu menerapkannya. Tiga strategi didefinisikan sebagai fungsi biasa tanpa kelas, dan pemilihan strategi terjadi pada tuple yang menghubungkan nama dengan fungsinya. Apabila strategi baru diperlukan, satu fungsi ditambahkan tanpa menyentuh ringkas(). Ketiga fungsi strategi juga dapat diuji satu per satu karena tidak bergantung pada kelas konteks apa pun. Kelas menjadi pilihan yang tepat ketika strategi membawa keadaan (state) sendiri, misalnya penimbang yang berubah antar pemanggilan, sebab fungsi polos tidak menyimpan keadaan apa pun.

Dependency injection lewat parameter konstruktor#

Dependency injection adalah teknik menyediakan ketergantungan sebuah kelas dari luar, bukan membuatnya di dalam kelas. Kelas yang menerima ketergantungan lewat parameter konstruktor tidak mengetahui detail penyimpanan maupun sumber datanya, sehingga implementasinya dapat diganti tanpa menyentuh kode kelas tersebut. Contoh berikut memakai kelas ArsipCadangan yang menerima objek penyimpan, lengkap dengan dua penyimpan yang saling dipertukarkan. Penyimpan berbasis folder membuat folder arsip-cadangan pada direktori kerja ketika contohnya dijalankan.

python injeksi.py
"""Ketergantungan disuntikkan lewat parameter konstruktor."""
from pathlib import Path

class PenyimpanMemori:
    """Penyimpan berkas di memori, cocok untuk pengujian cepat."""

    def __init__(self):
        self.data = {}

    def simpan(self, nama, teks):
        self.data[nama] = teks

    def baca(self, nama):
        return self.data[nama]

class PenyimpanFolder:
    """Penyimpan berkas pada folder, cocok untuk penggunaan nyata."""

    def __init__(self, folder):
        self.folder = Path(folder)
        self.folder.mkdir(exist_ok=True)

    def simpan(self, nama, teks):
        (self.folder / nama).write_text(teks, encoding="utf-8")

    def baca(self, nama):
        return (self.folder / nama).read_text(encoding="utf-8")

class ArsipCadangan:
    """Mengarsipkan teks ke penyimpan apa pun yang diterimanya."""

    def __init__(self, penyimpan):
        self.penyimpan = penyimpan

    def cadangkan(self, nama, teks):
        self.penyimpan.simpan(nama, teks)
        print("terarsip:", nama)

    def pulihkan(self, nama):
        return self.penyimpan.baca(nama)

TEKS = "Senin: Ali, Selasa: Budi, Rabu: Citra"

arsip_uji = ArsipCadangan(PenyimpanMemori())
arsip_uji.cadangkan("jadwal-piket.txt", TEKS)
print("memori :", arsip_uji.pulihkan("jadwal-piket.txt"))

arsip_folder = ArsipCadangan(PenyimpanFolder("arsip-cadangan"))
arsip_folder.cadangkan("jadwal-piket.txt", TEKS)
print("folder :", arsip_folder.pulihkan("jadwal-piket.txt"))
text Keluaran
terarsip: jadwal-piket.txt
memori : Senin: Ali, Selasa: Budi, Rabu: Citra
terarsip: jadwal-piket.txt
folder : Senin: Ali, Selasa: Budi, Rabu: Citra

Kedua penyimpan berjalan tanpa perubahan apa pun pada ArsipCadangan, karena kelas itu hanya bergantung pada dua metode yang benar-benar dipakainya, yaitu simpan() dan baca(). Cara kerja semacam ini disebut duck typing, yaitu kesesuaian dinilai dari metode yang dimiliki objek, bukan dari kelasnya. Manfaat terbesarnya muncul saat pengujian, sebab kelas dapat diuji dengan penyimpan tiruan yang ringan tanpa menyentuh folder maupun basis data sungguhan.

Kesalahan umum#

Kesalahan pertama adalah menerjemahkan pola klasik secara harfiah. Hierarki kelas strategi dan pembungkus akses global ditulis penuh padahal fungsi dan modul sudah menjawab kebutuhan yang sama, sehingga kode membengkak tanpa manfaat perilaku. Kebiasaan yang sehat adalah memulai dari bentuk paling sederhana, lalu naik ke kelas ketika keadaan atau warisan benar-benar dibutuhkan. Penandanya sederhana, yaitu apabila perilaku mulai menyimpan keadaan sendiri atau perlu diwariskan.

Kesalahan kedua adalah memperlakukan singleton modul sebagai tempat menulis keadaan yang boleh diubah dari mana saja. Keadaan global yang berubah diam-diam membuat alur program sulit dilacak, dan pengujian menjadi saling memengaruhi karena nilai sisa dari pengujian sebelumnya masih tertinggal. Batasi isi modul tunggal pada pengaturan yang memang bersifat global, dan teruskan nilai lainnya lewat parameter. Pembacaan tanpa mengubah isi tidak termasuk kebiasaan yang perlu dihindari.

Kesalahan ketiga adalah mengganti dependency injection lewat parameter konstruktor dengan impor langsung di dalam kelas. Kelas yang memanggil PenyimpanFolder() sendiri di dalam badannya terikat permanen pada implementasi itu, sehingga tidak ada celah untuk menggantinya. Perbaikannya mengikuti pola pada bagian dependency injection, yaitu menerima ketergantungan sebagai parameter dan memilih implementasinya di luar kelas, biasanya pada titik masuk program. Dengan demikian, versi tiruan maupun versi nyata dipilih di satu tempat yang sama.

Ringkasan#

  • Modul Python adalah singleton bawaan karena dieksekusi satu kali dan disimpan di sys.modules, sehingga impor berikutnya menerima objek yang sama.
  • Singleton berbasis kelas dibangun lewat __new__ dan dipilih ketika instans tunggal harus memakai perilaku kelas.
  • Strategy cukup ditulis sebagai fungsi kelas satu yang dikirim sebagai argumen, sedangkan kelas diperlukan hanya ketika strategi membawa keadaan sendiri.
  • Dependency injection lewat parameter konstruktor memisahkan kelas dari implementasi ketergantungannya dan membuat pengujian dengan versi tiruan menjadi mudah.

Referensi#