Property pada chapter sebelumnya mampu menghitung dan memvalidasi satu atribut — tetapi bagaimana bila satu class membutuhkan lima atribut string yang semuanya harus tervalidasi dengan aturan yang sama? Menulis lima pasang @property dan @x.setter adalah duplikasi yang menyiksa, dan logikanya tetap terikat pada satu class. Descriptor adalah mekanisme di balik layar yang menyelesaikan masalah ini: satu class terpisah yang mengelola perilaku sebuah atribut, dapat dipakai ulang di atribut dan class mana pun. Property, method, dan field ORM pada framework seperti Django semuanya dibangun di atasnya.

Protokol Descriptor #

Protokol descriptor terdiri dari tiga method opsional yang diimplementasikan pada sebuah class:

MethodDipicu olehPeran
__set_name__(self, owner, name)pembuatan class pemilikmencatat nama atribut yang dikelola
__get__(self, obj, objtype)pembacaan obj.attrmengembalikan nilai
__set__(self, obj, value)penugasan obj.attr = valuemenyimpan/memvalidasi nilai

Mekanismenya bekerja pada level class: ketika interpreter Python mengevaluasi obj.attr dan menemukan bahwa attr pada class adalah objek yang punya __get__, interpreter memanggil __get__ objek tersebut alih-alih mengambil nilai mentah. Aturan yang sama berlaku untuk penugasan bila __set__ ada. Class yang mengimplementasikan __get__ dan __set__ disebut data descriptor — versi paling kuat, karena mengalahkan atribut instance biasa.

ValidString — Descriptor Validasi Reusable #

Mari kita bangun descriptor yang memvalidasi string, lalu memakainya untuk dua atribut sekaligus:

python
class ValidString:
    """Descriptor: validasi string yang dipakai ulang di banyak atribut."""

    def __set_name__(self, owner, name):
        self.internal = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # akses lewat class, bukan instance
        return getattr(obj, self.internal)

    def __set__(self, obj, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.internal[1:]} harus string")
        if not value.strip():
            raise ValueError(f"{self.internal[1:]} tidak boleh kosong")
        setattr(obj, self.internal, value)


class Pelanggan:
    nama = ValidString()
    email = ValidString()

    def __init__(self, nama, email):
        self.nama = nama   # memicu ValidString.__set__
        self.email = email # memicu ValidString.__set__


p = Pelanggan("Budi", "[email protected]")
print(p.nama, p.email)

p.nama = "Budi Santoso"
print(p.nama)

try:
    Pelanggan("Siti", 42)
except TypeError as e:
    print("TypeError:", e)

try:
    p.email = "   "
except ValueError as e:
    print("ValueError:", e)

Output yang Dihasilkan:

text
Budi [email protected]
Budi Santoso
TypeError: email harus string
ValueError: email tidak boleh kosong

Mari kita bedah bagian inti descriptor-nya:

  1. Baris 4 (def __set_name__(self, owner, name)): interpreter memanggil method ini sekali saat class Pelanggan dibuat. Parameter name berisi nama atribut yang sedang dikelola — "nama" untuk instance pertama ValidString(), "email" untuk yang kedua. Dari nama tersebut, descriptor menurunkan nama penyimpanan internal: "_nama" dan "_email".
  2. Baris 7 (def __get__(self, obj, objtype=None)): membaca p.nama memicu method ini. Nilai sebenarnya diambil kembali dari self._nama pada instance. Jika diakses lewat class (Pelanggan.nama), parameter obj bernilai None dan descriptor mengembalikan dirinya sendiri.
  3. Baris 12 (def __set__(self, obj, value)): setiap p.nama = ... — termasuk dua baris di dalam __init__ — berhenti di method ini sebelum nilai disimpan. Dua pemeriksaan menolak tipe selain string dan string kosong.
  4. Baris 21 (nama = ValidString()): dua atribut class — nama di sini dan email di baris berikutnya — berbagi class descriptor yang sama dengan instance berbeda. Berkat __set_name__, masing-masing tahu atribut mana yang dikelolanya.

Hasilnya: satu definisi validasi, dua atribut tervalidasi. Menambah atribut alamat = ValidString() hanya membutuhkan satu baris — dan class lain seperti Pemasok bisa memakai ValidString yang sama tanpa menyalin logika.

Property dan Function adalah Descriptor #

Protokol ini bukan fitur pinggiran; dua mekanisme yang sudah kita pakai sebelumnya dibangun di atasnya:

python
class Contoh:
    @property
    def harga(self):
        return 100


properti = Contoh.__dict__["harga"]
print(type(properti).__name__)
print(hasattr(properti, "__get__"), hasattr(properti, "__set__"))


class Halo:
    def sapa(self):
        return "Halo!"


objek = Halo()
terikat = Halo.__dict__["sapa"].__get__(objek, Halo)
print(terikat())
print(terikat.__self__ is objek)

Output yang Dihasilkan:

text
property
True True
Halo!
True

Objek property yang diambil dari Contoh.__dict__ terbukti memiliki __get__ dan __set__ — @property adalah data descriptor siap pakai yang tinggal kita konfigurasi dengan method getter dan setter. Function pun ternyata descriptor: Halo.__dict__["sapa"].__get__(objek, Halo) mengembalikan bound method, dan terikat.__self__ is objek bernilai True. Inilah cara objek.sapa() bekerja sejak Chapter Memahami Class dan Object — pengikatan method ke instance hanyalah __get__ milik function.

Kapan Perlu Descriptor #

Descriptor adalah alat framework, bukan kebutuhan harian:

  • Property sudah cukup untuk satu atribut dengan logika sederhana — lebih ringkas dibaca dan ditulis.
  • Descriptor layak dipakai ketika logika atribut yang sama berulang di banyak field, perlu menerima konfigurasi (nilai minimal, panjang maksimum), atau ingin perilaku yang bisa diwariskan dan diuji terpisah.
  • Framework memakainya secara besar-besaran: setiap field models.CharField(...) pada Django dan kolom pada SQLAlchemy adalah descriptor yang mengelola pembacaan dan penulisan atribut model, dari validasi sampai sinkronisasi ke database.