Pernah lihat class User: di proyek Django atau FastAPI, terus bingung dari mana asal kata self yang nempel di mana-mana? Banyak tutorial Python buru-buru ngajak kamu terjun ke framework, padahal fondasinya — object-oriented programming — jarang dijelaskan dengan benar. Akibatnya, begitu kamu buka model Django atau serializer FastAPI, semua terasa seperti sihir. Kita bongkar sihir itu satu per satu: class, __init__, inheritance, sampai magic methods, lewat kode yang langsung bisa kamu jalankan.

Kenapa Repot-repot Pakai Class?

Ambil contoh sederhana: kamu mau menyimpan data beberapa karyawan. Tanpa class, kebanyakan orang pakai list berisi dictionary, lalu menulis fungsi terpisah untuk mengubah gaji.

karyawan = [
    {"nama": "Rina", "gaji": 8_000_000},
    {"nama": "Bima", "gaji": 6_500_000},
]

def naikkan_gaji(data, nama, persen):
    for k in data:
        if k["nama"] == nama:
            k["gaji"] = int(k["gaji"] * (1 + persen / 100))
            return k
    return None

naikkan_gaji(karyawan, "Rina", 10)
print(karyawan)

Jalan sih jalan, tapi makin banyak fungsi dan field, makin gampang tersesat. Data dan perilaku terpencar di banyak tempat. Class menyatukan keduanya jadi satu kesatuan yang jelas.

python
class Karyawan:
    def __init__(self, nama, gaji):
        self.nama = nama
        self.gaji = gaji

    def naikkan_gaji(self, persen):
        self.gaji = int(self.gaji * (1 + persen / 100))

rina = Karyawan("Rina", 8_000_000)
bima = Karyawan("Bima", 6_500_000)

rina.naikkan_gaji(10)
print(rina.nama, rina.gaji)  # Rina 8800000

Sekarang nama, gaji, dan naikkan_gaji duduk di satu tempat. Mau nambah karyawan baru? Tinggal Karyawan("Rina", 8_000_000) lagi, tanpa menyentuh fungsi global.

Membedah Class: __init__ dan self

class itu cetakan, objek adalah hasil cetakannya. Karyawan di atas cuma blueprint; rina dan bima adalah objek sungguhan yang punya datanya sendiri-sendiri. Dua hal yang paling sering bikin bingung pemula: self dan __init__.

python
class Karyawan:
    def __init__(self, nama, gaji):
        # dipanggil otomatis saat Karyawan(...) dijalankan
        self.nama = nama   # self.nama = atribut instance
        self.gaji = gaji

k = Karyawan("Rina", 8_000_000)
print(k.nama)   # Rina
print(k.gaji)   # 8000000

__init__ dipanggil otomatis setiap kali kamu menulis Karyawan(...) — itu konstruktornya. Sementara self adalah referensi ke objek yang sedang dibuat, jadi self.nama = nama artinya “simpan nama ke objek ini”. Nama self sebenarnya cuma konvensi; kamu boleh pakai nama lain, tapi jangan. Seluruh ekosistem Python memakai self, dan linter bakal menegurmu kalau menyimpang.

Atribut Kelas vs Atribut Instance

Atribut yang kamu buat lewat self di dalam __init__ adalah milik tiap objek, nilainya bisa beda-beda. Ada juga atribut yang ditulis langsung di badan class — itu milik class dan dibagikan ke semua objek.

python
class Karyawan:
    perusahaan = "BengkelKode"  # atribut kelas, dibagi bersama

    def __init__(self, nama, gaji):
        self.nama = nama   # atribut instance, beda tiap objek
        self.gaji = gaji

a = Karyawan("Rina", 8_000_000)
b = Karyawan("Bima", 6_500_000)

print(a.perusahaan, b.perusahaan)  # BengkelKode BengkelKode

Karyawan.perusahaan = "Nusantara Tech"
print(a.perusahaan)  # Nusantara Tech (ikut berubah)

Perhatikan: saat perusahaan diubah lewat class, semua objek ikut berubah. Itu karena atribut kelas hanya disimpan satu kali di class, bukan di tiap objek.

Atribut kelas berbahaya kalau nilainya mutable seperti list atau dict — semua objek bakal berbagi list yang sama dan saling menimpa. Untuk nilai bawaan yang mutable, selalu buat di dalam __init__, bukan di badan class.

Inheritance: Menurunkan Sifat ke Class Anak

Dua class sering mirip tapi tidak sama persis. Manager tetaplah Karyawan, cuma punya tambahan divisi dan cara tampil yang beda. Daripada menyalin kode, class anak bisa mewarisi semua method dan atribut class induk, lalu menimpa atau menambah seperlunya.

python
class Karyawan:
    def __init__(self, nama, gaji):
        self.nama = nama
        self.gaji = gaji

    def info(self):
        return f"{self.nama} — Rp{self.gaji:,}"

class Manager(Karyawan):
    def __init__(self, nama, gaji, divisi):
        super().__init__(nama, gaji)  # panggil __init__ induk
        self.divisi = divisi

    def info(self):  # menimpa method induk
        return f"{self.nama} (Manager {self.divisi}) — Rp{self.gaji:,}"

m = Manager("Rina", 15_000_000, "Engineering")
print(m.info())  # Rina (Manager Engineering) — Rp15,000,000
print(isinstance(m, Karyawan))  # True

super().__init__(nama, gaji) memanggil konstruktor induk supaya nama dan gaji tetap terisi tanpa menulis ulang. isinstance berguna buat ngecek apakah sebuah objek termasuk turunan class tertentu — sering muncul saat memvalidasi input.

Polymorphism: Satu Panggilan, Perilaku Berbeda

Karena info() ditimpa di Manager, fungsi yang sama bisa menghasilkan keluaran berbeda tergantung tipe objek yang diterima. Ini inti polymorphism: kamu tidak perlu if isinstance(...) bertumpuk di sana-sini.

python
def cetak_laporan(karyawan):
    print(karyawan.info())

cetak_laporan(Karyawan("Bima", 6_500_000))
cetak_laporan(Manager("Rina", 15_000_000, "Engineering"))
# Bima — Rp6,500,000
# Rina (Manager Engineering) — Rp15,000,000

cetak_laporan tidak peduli dia menerima Karyawan atau Manager. Dia tinggal panggil info(), dan Python memutuskan sendiri method mana yang dijalankan. Pola ini yang bikin kode framework terasa fleksibel tanpa ratusan cabang if.

Magic Methods: Bikin Objek Berperilaku seperti Tipe Bawaan

Method yang diawali dan diakhiri double underscore — __repr__, __eq__, dan kawan-kawan — disebut magic methods atau dunder methods. Python memanggilnya otomatis di situasi tertentu: print(obj) memanggil __str__, a == b memanggil __eq__. Tanpa method ini, hasilnya sering tidak masuk akal.

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p)                    # <__main__.Point object at 0x7f...>
print(p == Point(1, 2))     # False (padahal isinya sama)

Tampilan <__main__.Point object at 0x7f...> itu alamat memori, bukan yang kamu mau. Dan dua Point dengan koordinat sama dianggap beda karena Python membandingkan identitas objek, bukan isinya. Magic methods memperbaikinya.

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)

p = Point(1, 2)
q = Point(3, 4)
print(p)                    # Point(1, 2)
print(p == Point(1, 2))     # True
print(p + q)                # Point(4, 6)

__repr__ menentukan tampilan saat objek di-print atau muncul di REPL; __eq__ membuat == membandingkan isi; __add__ membuat operator + bisa dipakai antar Point. Magic methods inilah yang bikin class buatanmu terasa seperti tipe bawaan Python.

@property: Atribut yang Dihitung, Bukan Disimpan

@property menyulap method jadi atribut yang bisa diakses tanpa tanda kurung. Cocok buat nilai turunan — semisal gaji tahunan dari gaji bulanan — yang tidak perlu disimpan terpisah dan rawan tidak sinkron. Kalau tanda @ masih asing buatmu, mampir dulu ke tutorial Python Decorators, sebab @property, @classmethod, dan @staticmethod semuanya decorator.

python
class Karyawan:
    def __init__(self, nama, gaji_bulanan):
        self.nama = nama
        self._gaji_bulanan = gaji_bulanan

    @property
    def gaji_tahunan(self):
        return self._gaji_bulanan * 12

    @property
    def gaji_bulanan(self):
        return self._gaji_bulanan

    @gaji_bulanan.setter
    def gaji_bulanan(self, nilai):
        if nilai < 0:
            raise ValueError("Gaji tidak boleh negatif")
        self._gaji_bulanan = nilai

k = Karyawan("Rina", 8_000_000)
print(k.gaji_tahunan)       # 96000000

k.gaji_bulanan = 9_000_000
print(k.gaji_tahunan)       # 108000000

Atribut berawalan underscore (_gaji_bulanan) adalah konvensi untuk menandai “internal, jangan disentuh langsung”. @property memberi kamu satu pintu kontrol untuk validasi tanpa memaksa pemanggil menulis method. Perhatikan setter-nya: nilai negatif ditolak sebelum sempat tersimpan.

classmethod dan staticmethod: Method Tanpa Instance

@classmethod menerima class (cls) sebagai argumen pertama, bukan instance. Berguna untuk constructor alternatif, misalnya membangun Karyawan langsung dari sebaris data CSV. @staticmethod sebaliknya tidak menerima self maupun cls — dia cuma fungsi biasa yang ditaruh di dalam class karena topiknya nyambung.

python
class Karyawan:
    def __init__(self, nama, gaji):
        self.nama = nama
        self.gaji = gaji

    @classmethod
    def dari_csv(cls, baris):
        nama, gaji = baris.split(",")
        return cls(nama, int(gaji))

    @staticmethod
    def hitung_pph(gaji_bulanan):
        return gaji_bulanan * 0.05

k = Karyawan.dari_csv("Rina,8000000")
print(k.nama, k.gaji)               # Rina 8000000
print(Karyawan.hitung_pph(k.gaji))  # 400000

Pakai @classmethod saat kamu butuh akses ke class (misalnya buat memanggil konstruktor), dan @staticmethod hanya kalau fungsinya benar-benar tidak butuh data dari instance maupun class. Kalau ragu, fungsi module-level biasa sering lebih jujur daripada staticmethod yang dipaksakan.

Studi Kasus: Sistem Kasir Mini

Gabungkan semua yang barusan dipelajari jadi satu program utuh: produk sebagai class, diskon lewat inheritance, dan total belanja lewat magic methods. Simpan sebagai kasir.py lalu jalankan.

python
class Produk:
    def __init__(self, nama, harga):
        self.nama = nama
        self.harga = harga

    def __repr__(self):
        return f"{self.nama} (Rp{self.harga:,})"

class ProdukDiskon(Produk):
    def __init__(self, nama, harga, diskon_persen):
        super().__init__(nama, harga)
        self.diskon_persen = diskon_persen

    @property
    def harga_bersih(self):
        return int(self.harga * (1 - self.diskon_persen / 100))

class Keranjang:
    def __init__(self):
        self.items = []

    def tambah(self, produk):
        self.items.append(produk)

    def __len__(self):
        return len(self.items)

    def __iter__(self):
        return iter(self.items)

    @property
    def total(self):
        return sum(
            p.harga_bersih if isinstance(p, ProdukDiskon) else p.harga
            for p in self.items
        )

keranjang = Keranjang()
keranjang.tambah(Produk("Mouse", 150_000))
keranjang.tambah(ProdukDiskon("Keyboard", 600_000, 25))

print(len(keranjang))   # 2
for item in keranjang:
    print(item)         # Mouse (Rp150,000) / Keyboard (Rp600,000)
print(f"Total: Rp{keranjang.total:,}")  # Total: Rp600,000

Dengan __iter__, Keranjang bisa langsung di-for; dengan __len__, dia bisa di-len(). Kalau class-mu mayoritas cuma nyimpen data plus sedikit method, cek Python Dataclasses — dia menghapus boilerplate __init__ manual buat kasus semacam itu.

Kalau class-mu cuma buat bawa data plus validasi — misalnya body request sebuah API — pertimbangkan Pydantic. Dia menumpuk validasi tipe otomatis di atas konsep class yang barusan kamu pelajari, jadi tidak perlu tulis __init__ dan pengecekan manual.

Latihan paling cepat: tulis ulang contoh Karyawan tanpa melirik, lalu tambah class Magang yang menurunkan Karyawan tapi mengubah info()-nya sendiri. Kalau output-nya sesuai dugaan, kamu sudah menguasai sebagian besar OOP yang dipakai framework sehari-hari.