Descriptor

Descriptor sebagai mesin di balik @property beserta demo praktisnya.

Daftar isi

Descriptor adalah objek kelas yang menyisipkan dirinya pada akses atribut melalui protokol __get__, __set__, dan __delete__, sehingga pembacaan dan penugasan atribut dapat dialihkan ke logika khusus. Mekanisme ini adalah mesin yang bekerja di balik @property, metode kelas, dan sebagian besar pustaka ORM, dan memahaminya berarti memahami bagaimana akses obj.atribut benar-benar diputuskan. Topik ini membahas protokolnya, descriptor data dengan __set_name__, @property sebagai fasad descriptor, urutan pencarian atribut, beserta dua kesalahan yang lazim.

Protokol descriptor#

Sebuah kelas menjadi descriptor sejak menyediakan __get__, dan objeknya cukup diletakkan sebagai atribut kelas. Metode __get__ menerima tiga argumen, yaitu descriptor itu sendiri, objek yang diakses, dan kelas pemilik objek tersebut. Ketika atribut dibaca lewat kelas dan bukan lewat instans, argumen objeknya berupa None.

python Contoh: descriptor paling sederhana
class Tampil:
    def __get__(self, obj, objtype=None):
        return "nilai dari descriptor"

class Kelas:
    atribut = Tampil()

k = Kelas()
print(k.atribut)
print(Kelas.atribut)
print(type(Kelas.atribut).__name__)
text Keluaran
nilai dari descriptor
nilai dari descriptor
str

Pembacaan k.atribut tidak mengembalikan objek Tampil itu, melainkan hasil pemanggilan __get__-nya, yaitu string "nilai dari descriptor". Pemanggilan lewat kelas berjalan sama pada contoh ini karena __get__ tidak membedakan obj dan objtype, dan cetakan ketiga menegaskan hasilnya memang string, bukan objek descriptor. Descriptor tanpa __set__ disebut non-data descriptor, sedangkan yang menyediakan __set__ disebut data descriptor, dan perbedaan itu menentukan urutan pencarian atribut pada bagian berikutnya.

Descriptor data dengan set_name#

Descriptor data memperluas protokol dengan __set__, yang dipanggil pada setiap penugasan obj.atribut = nilai. Kombinasi __get__, __set__, dan __set_name__ menghasilkan atribut tervalidasi yang dapat dipasang di banyak kelas tanpa mengulang kodenya.

python Contoh: descriptor validasi angka
class ValidasiAngka:
    def __set_name__(self, owner, name):
        self.nama_atribut = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return vars(obj)[self.nama_atribut]

    def __set__(self, obj, nilai):
        if not isinstance(nilai, int):
            raise TypeError(f"{self.nama_atribut} harus int")
        vars(obj)[self.nama_atribut] = nilai

class Produk:
    stok = ValidasiAngka()
    harga = ValidasiAngka()

    def __init__(self, nama, stok, harga):
        self.nama = nama
        self.stok = stok
        self.harga = harga

p = Produk("buku", 12, 35000)
print(p.nama, p.stok, p.harga)
p.stok = 10
print(p.stok)
try:
    p.harga = "murah"
except TypeError as e:
    print(type(e).__name__, str(e))
text Keluaran
buku 12 35000
10
TypeError harga harus int

Saat kelas Produk dibuat, __set_name__ mengisi nama_atribut dengan "stok" dan "harga" sesuai tempat pemasangannya, sehingga satu descriptor melayani dua atribut dengan pesan error yang akurat. Nilai sebenarnya disimpan di __dict__ instans melalui vars(obj), dan pembacaan __get__ mengambilnya kembali. Penugasan p.harga = "murah" ditolak karena melanggar tipe, sementara penugasan p.stok = 10 yang sah berjalan tanpa error.

@property sebagai descriptor#

Decorator @property menghasilkan objek property, dan objek itu sendiri adalah descriptor data dengan __get__, __set__, dan __delete__ yang dibangun dari getter dan setter-nya. Membaca property dari kelasnya memperlihatkan identitas itu secara langsung.

python Contoh: property adalah descriptor
class Suhu:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, nilai):
        self._celsius = nilai

print(type(Suhu.celsius).__name__)
s = Suhu(30)
print(s.celsius)
s.celsius = 25
print(s.celsius)
text Keluaran
property
30
25

Cetakan pertama menegaskan bahwa Suhu.celsius berupa objek property, bukan fungsi biasa. Descriptor berikut mereplikasi perilaku @property versi hanya-baca dengan protokol yang sama, sekaligus memperlihatkan bahwa descriptor kelas dapat dipakai sebagai decorator.

python Contoh: replika property hanya-baca
class BacaSaja:
    def __init__(self, getter):
        self.getter = getter

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

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return self.getter(obj)

    def __set__(self, obj, nilai):
        raise AttributeError("atribut ini hanya bisa dibaca")

class Lingkaran:
    def __init__(self, radius):
        self.radius = radius

    @BacaSaja
    def luas(self):
        return 3.14 * self.radius ** 2

l = Lingkaran(10)
print(l.luas)
try:
    l.luas = 100
except AttributeError as e:
    print(type(e).__name__, str(e))
text Keluaran
314.0
AttributeError atribut ini hanya bisa dibaca

Pemakaian @BacaSaja pada luas memanggil konstruktor descriptor dengan fungsi luas sebagai argumennya, persis seperti pemanggilan property tanpa setter. Nilai luas selalu dihitung dari radius saat dibaca, dan setiap usaha penugasan ditolak oleh __set__, sehingga atribut terjaga konsisten dengan sumber datanya.

Urutan pencarian atribut#

Ketika obj.atribut dievaluasi, Python memeriksa kelas objeknya terlebih dahulu, bukan __dict__ instansnya. Apabila kelas memiliki data descriptor dengan nama itu, hasil __get__ itulah yang dipakai, sedangkan __dict__ instans tidak diperiksa. Karena itu, atribut tervalidasi seperti pada bagian sebelumnya tidak dapat dilompati dengan menulis langsung ke dictionary instans.

python Contoh: data descriptor mengungguli dict instans
class SelaluSatu:
    def __get__(self, obj, objtype=None):
        return 1

    def __set__(self, obj, nilai):
        raise AttributeError("atribut ini hanya bisa dibaca")

class Wadah:
    atribut = SelaluSatu()

w = Wadah()
w.__dict__["atribut"] = 999
print(w.__dict__["atribut"])
print(w.atribut)
text Keluaran
999
1

Dictionary instans memang berisi 999, tetapi pembacaan lewat titik tetap menghasilkan 1 karena data descriptor pada kelas diperiksa lebih dahulu. Apabila descriptor yang ada hanya non-data, urutannya berbalik, yaitu __dict__ instans diperiksa lebih dahulu, baru kemudian __get__ descriptor. Rantai lengkapnya berakhir pada atribut kelas biasa dan akhirnya pada __getattr__, sebagaimana dirangkum diagram berikut.

obj.atributdiaksesdatadescriptor kelasdictinstansnon-datadescriptoratribut kelaslalu __getattr__urutan dicari dari kiri ke kanan, tahap pertama yang menjawab menangmenang di atas dict instans
Urutan pencarian atribut dari data descriptor sampai __getattr__

Kesalahan umum#

Kesalahan pertama adalah menyimpan nilai pada descriptor itu sendiri, misalnya melalui self.nilai = nilai di dalam __set__. Descriptor berada di kelas dan hanya ada satu untuk semua instans, sehingga nilai yang disimpan terbagi ke seluruh instans kelas pemiliknya.

python Contoh: nilai descriptor terbagi ke semua instans
class Keliru:
    def __get__(self, obj, objtype=None):
        return self.nilai

    def __set__(self, obj, nilai):
        self.nilai = nilai

class Rekam:
    skor = Keliru()

a = Rekam()
b = Rekam()
a.skor = 90
print("a:", a.skor)
print("b:", b.skor, "(ikut berubah)")
text Keluaran
a: 90
b: 90 (ikut berubah)

Instans b tidak pernah diisi, tetapi tetap membaca 90 karena self.nilai milik objek descriptor yang sama. Perbaikannya adalah menyimpan nilai per instans, misalnya di vars(obj) dengan nama yang disediakan __set_name__ sebagaimana contoh ValidasiAngka.

Kesalahan kedua adalah memasang descriptor sebagai atribut instans. Protokol hanya bekerja pada atribut kelas, sehingga objek descriptor yang disimpan lewat self dibaca sebagaimana adanya, tanpa __get__ yang dipanggil.

python Contoh: descriptor di instans tidak bekerja
class Pendengar:
    def __get__(self, obj, objtype=None):
        return "dipanggil"

class Keliru:
    def __init__(self):
        self.atribut = Pendengar()

k = Keliru()
print(type(k.atribut).__name__)
text Keluaran
Pendengar

Cetakan Pendengar, bukan "dipanggil", menandakan pembacaan k.atribut mengembalikan objeknya mentah. Perbaikannya adalah memindahkan pemasangan ke badan kelas, sebab mekanisme pencarian descriptor terjadi pada kelas pemilik, bukan pada dictionary instans.

Ringkasan#

  • Descriptor mendefinisikan __get__ untuk mengalihkan pembacaan atribut, dengan obj bernilai None ketika akses dilakukan lewat kelas.
  • Descriptor data menyediakan __set__, memakai __set_name__ untuk mengetahui namanya, dan menyimpan nilai per instans melalui vars(obj) agar tidak terbagi.
  • Objek property adalah descriptor data, dan versi hanya-bacanya dapat direplikasi dengan protokol yang sama.
  • Pencarian atribut memprioritaskan data descriptor pada kelas di atas __dict__ instans, sedangkan non-data descriptor berada setelahnya.

Referensi#