Menjumlahkan dua objek uang dengan + dan mengurutkan nomor versi dengan sorted() membuat kode domain terasa alami: total = harga + pajak terbaca lebih bersahabat daripada total = harga.tambah(pajak). Python mengizinkan class buatan kita mengambil alih operator bawaan melalui operator overloading — implementasi special method seperti __add__ dan __lt__ yang diperkenalkan pada Chapter Special Methods dan Data Model. Chapter ini membangun class Money lengkap dengan aritmetika, perbandingan, dan kontrak __eq__-__hash__ yang menyertainya.

Operator Adalah Special Method #

Setiap operator adalah gula sintaksis untuk pemanggilan special method pada tipe operand kiri. Ekspresi a + b dieksekusi sebagai type(a).__add__(a, b); jika method tersebut mengembalikan konstanta NotImplemented, interpreter Python mencoba versi reflected milik operand kanan, type(b).__radd__(b, a). Bila keduanya menolak, interpreter mengangkat TypeError.

Dua aturan praktis mengikuti mekanisme ini:

  • Kembalikan NotImplemented — bukan raise TypeError — untuk operand yang tidak dikenal, agar operand kanan masih diberi kesempatan merespons.
  • Method overload mengembalikan objek baru; memutasi operand sendiri di dalam __add__ mengejutkan pembaca kode.

Aritmetika pada Class Money #

Berikut class uang dengan penjumlahan, pengurangan, dan perkalian — nilai disimpan sebagai integer rupiah agar bebas dari jebakan presisi float:

python
class Money:
    def __init__(self, jumlah, mata_uang="IDR"):
        self.jumlah = jumlah
        self.mata_uang = mata_uang

    def __repr__(self):
        return f"Money({self.jumlah}, {self.mata_uang!r})"

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.mata_uang != other.mata_uang:
            raise ValueError("mata uang berbeda, konversi dulu")
        return Money(self.jumlah + other.jumlah, self.mata_uang)

    def __sub__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.mata_uang != other.mata_uang:
            raise ValueError("mata uang berbeda, konversi dulu")
        return Money(self.jumlah - other.jumlah, self.mata_uang)

    def __mul__(self, faktor):
        if not isinstance(faktor, int):
            return NotImplemented
        return Money(self.jumlah * faktor, self.mata_uang)

    def __rmul__(self, faktor):
        return self * faktor


dompet = Money(150_000)
bonus = Money(50_000)

print(dompet + bonus)
print(dompet - bonus)
print(dompet * 2)
print(3 * dompet)

try:
    print(dompet + Money(10, "USD"))
except ValueError as e:
    print("ValueError:", e)

try:
    print(dompet + 5000)
except TypeError as e:
    print("TypeError:", e)

Output yang Dihasilkan:

text
Money(200000, 'IDR')
Money(100000, 'IDR')
Money(300000, 'IDR')
Money(450000, 'IDR')
ValueError: mata uang berbeda, konversi dulu
TypeError: unsupported operand type(s) for +: 'Money' and 'int'

Mari kita bedah method demi method:

  1. Baris 9 (__add__): mengoperasikan dompet + bonus. Operand non-Money menghasilkan NotImplemented; operand Money berbeda mata uang ditolak dengan ValueError — penjumlahan uang beda denominasi memang bukan operasi yang sah.
  2. Baris 23 (__mul__): perkalian hanya diizinkan dengan bilangan bulat — dompet * 2 berarti dua kali lipat. Tipe lain mengembalikan NotImplemented.
  3. Baris 28 (__rmul__): menangani bentuk terbalik 3 * dompet. Interpreter memanggil Money.__rmul__(dompet, 3) karena int.__mul__(3, dompet) tidak tahu apa-apa tentang Money.
  4. Baris 46 (dompet + 5000): __add__ mengembalikan NotImplemented, int tidak punya __radd__ untuk Money, sehingga interpreter mengangkat TypeError dengan pesan yang menjelaskan kedua tipe.

Perbandingan dengan total_ordering #

Python menyediakan enam operator perbandingan (==, !=, <, <=, >, >=), tetapi menulis semuanya manual itu repetitif. Decorator total_ordering dari module functools cukup diberi __eq__ dan satu perbandingan urutan — __lt__ atau __le__ — lalu sisanya diturunkan otomatis:

python
from functools import total_ordering


@total_ordering
class VersiRilis:
    def __init__(self, utama, minor, tambal):
        self.utama = utama
        self.minor = minor
        self.tambal = tambal

    def __repr__(self):
        return f"VersiRilis({self.utama}.{self.minor}.{self.tambal})"

    def __eq__(self, other):
        if not isinstance(other, VersiRilis):
            return NotImplemented
        return (self.utama, self.minor, self.tambal) == (other.utama, other.minor, other.tambal)

    def __lt__(self, other):
        if not isinstance(other, VersiRilis):
            return NotImplemented
        return (self.utama, self.minor, self.tambal) < (other.utama, other.minor, other.tambal)


lama = VersiRilis(3, 11, 4)
baru = VersiRilis(3, 12, 0)

print(lama < baru)
print(lama <= baru)
print(baru > lama)
print(lama >= baru)
print(lama == VersiRilis(3, 11, 4))
print(sorted([VersiRilis(3, 12, 0), VersiRilis(3, 11, 9), VersiRilis(4, 0, 0)]))

Output yang Dihasilkan:

text
True
True
True
False
True
[VersiRilis(3.11.9), VersiRilis(3.12.0), VersiRilis(4.0.0)]

Trik intinya ada di Baris 17 dan 22: perbandingan tuple (utama, minor, tambal) memakai perbandingan leksikografis bawaan Python — bandingkan utama dulu, jika sama lanjut minor, lalu tambal — sehingga __lt__ dan __eq__ masing-masing cukup satu baris. Decorator @total_ordering mengisi __le__, __gt__, dan __ge__ dari keduanya, dan sorted() pada baris terakhir memanfaatkan seluruh rangkaiannya.

Kontrak eq dan hash #

Aturan penting yang sering dilupakan: mendefinisikan __eq__ pada class membuat instance-nya kehilangan perilaku hash bawaan. Interpreter Python menganggap objek yang bisa dibandingkan isinya tidak lagi cocok di-hash secara default, karena hash dictionary dan set menuntut janji berikut:

Jika a == b bernilai True, maka hash(a) harus sama dengan hash(b).

Mari kita lihat konsekuensinya:

python
class SimbolTanpaHash:
    def __init__(self, kode):
        self.kode = kode

    def __eq__(self, other):
        return isinstance(other, SimbolTanpaHash) and self.kode == other.kode


a = SimbolTanpaHash("GOTO")
b = SimbolTanpaHash("GOTO")

try:
    {a, b}
except TypeError as e:
    print("TypeError:", e)


class Simbol:
    def __init__(self, kode):
        self.kode = kode

    def __eq__(self, other):
        return isinstance(other, Simbol) and self.kode == other.kode

    def __hash__(self):
        return hash(self.kode)


x = Simbol("GOTO")
y = Simbol("GOTO")

print(len({x, y}))  # __eq__ sama -> dihitung satu
print(x in {y})     # __hash__ sama -> ditemukan

Output yang Dihasilkan:

text
TypeError: unhashable type: 'SimbolTanpaHash'
1
True

Alurnya sebagai berikut:

  1. Baris 13 ({a, b}): memasukkan SimbolTanpaHash ke set memicu TypeError: unhashable type — definisi __eq__ menonaktifkan __hash__ bawaan.
  2. Baris 25 (def __hash__): class Simbol memenuhi kontrak dengan meng-hash field yang sama dengan yang dibandingkan __eq__ — yaitu kode. Dua simbol "GOTO" kini punya hash yang identik.
  3. Baris 32 (len({x, y})): set menggunakan hash untuk menempatkan x dan y, lalu __eq__ untuk memastikan keduanya setara — hasilnya satu anggota.
  4. Baris 33 (x in {y}): pencarian berjalan cepat berkat hash yang cocok; True tercetak.

Kapan Tidak Perlu Overload #

Operator yang baik membuat kode lebih terbaca; operator yang buruk menyembunyikan kejutan. Beberapa batasan yang layak dipegang:

  • Operator harus terasa alami: + untuk penjumlahan/penggabungan, == untuk kesetaraan makna. Bila __add__ di class Pengguna menggabungkan dua akun, pembaca kode akan menebak salah.
  • Jangan menyerahkan operator ke domain yang berbeda: list + list menggabungkan, int + int menjumlahkan — dua makna yang sudah membeku sebagai konvensi. Class Tagihan yang +-nya mengurangkan adalah bencana keterbacaan.
  • Method bernama biasa lebih jelas untuk operasi niche: inv.laku() lebih ekspresif daripada ~inv atau inv << 3.
  • Pertahankan sifat yang diharapkan: __eq__ yang mengembalikan non-bool, atau __add__ yang memutasi operand kiri, melanggar ekspektasi setiap pembaca Python.