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.
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__)nilai dari descriptor
nilai dari descriptor
strPembacaan 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.
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))buku 12 35000
10
TypeError harga harus intSaat 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.
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)property
30
25Cetakan 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.
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))314.0
AttributeError atribut ini hanya bisa dibacaPemakaian @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.
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)999
1Dictionary 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.
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.
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)")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.
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__)PendengarCetakan 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, denganobjbernilaiNoneketika akses dilakukan lewat kelas. - Descriptor data menyediakan
__set__, memakai__set_name__untuk mengetahui namanya, dan menyimpan nilai per instans melaluivars(obj)agar tidak terbagi. - Objek
propertyadalah 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#
- Descriptor HowTo Guide — Python Software Foundation — Panduan resmi protokol descriptor beserta urutan pencarian atributnya.
- Implementing descriptors — Python Software Foundation — Kontrak get, set, dan set_name pada referensi bahasa.
- property() — Python Software Foundation — Dokumentasi property sebagai fasad descriptor data.