Ana içeriğe geç
Kesit Python
İleri · Mimari

İleri OOP: dunder metotlar, ABC, Protocol ve descriptor

Sınıflarını Python'ın yerleşik türleri gibi davranacak şekilde tasarla: eşitlik, sıralama, uzunluk, indeksleme, çağrılabilirlik, soyut sınıflar, Protocol, descriptor, __slots__ ve metaclass'a kısa bakış.

Uygulamada aç, 23 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli?

Orta seviyede sınıf yazmayı, kalıtımı (inheritance) ve dataclass'ı öğrendin. Kurumsal kodda ise sınıfların "Python gibi" davranması beklenir: bir Para nesnesi == ile karşılaştırılabilmeli, bir kümeye konabilmeli, sorted ile sıralanabilmeli; bir Sepet nesnesi len() ve for ile kullanılabilmeli. Ilgaz Bank'ta bir işlem motoru yazarken "bu iki tutar eşit mi?" sorusunun cevabı id() karşılaştırması olursa raporlar yanlış çıkar. Bu dersin konusu, sınıflarını dil ile bütünleştiren özel metotlar (dunder methods, "double underscore") ve kodun sınırlarını tanımlayan soyutlama araçlarıdır.

Eşitlik, hash ve sıralama

Varsayılan olarak iki nesne yalnızca aynı nesneyse eşittir. __eq__ yazarak "değer eşitliği" tanımlarsın. Dikkat: __eq__ yazıp __hash__ yazmazsan Python nesneyi hashlenemez yapar; küme ve sözlük anahtarı olarak kullanamazsın. Değişmez (immutable) nesnelerde __hash__'i eşitlikte kullandığın alanlardan üret.

from functools import total_ordering

@total_ordering
class Para:
    """Ilgaz Bank: kuruş hassasiyetinde tutar."""
    __slots__ = ('kurus', 'birim')

    def __init__(self, tutar: float, birim: str = 'TRY'):
        self.kurus = round(tutar * 100)
        self.birim = birim

    def __repr__(self):
        return f'Para({self.kurus / 100:.2f}, {self.birim!r})'

    def __eq__(self, other):
        if not isinstance(other, Para):
            return NotImplemented
        return (self.kurus, self.birim) == (other.kurus, other.birim)

    def __hash__(self):
        return hash((self.kurus, self.birim))

    def __lt__(self, other):
        if not isinstance(other, Para) or self.birim != other.birim:
            return NotImplemented
        return self.kurus < other.kurus

    def __add__(self, other):
        if not isinstance(other, Para) or self.birim != other.birim:
            return NotImplemented
        return Para((self.kurus + other.kurus) / 100, self.birim)

a, b, c = Para(10.10), Para(10.10), Para(3.5)
print(a == b, a is b)          # True False
print(len({a, b, c}))          # 2  -> hash tutarlı
print(sorted([a, c, Para(7)])) # küçükten büyüğe
print(a + c, a > c)
total_orderingfunctools.total_ordering, yalnızca __eq__ ve bir karşılaştırma metodu (örneğin __lt__) tanımladığında geri kalan __le__, __gt__, __ge__ metotlarını üretir. NotImplemented döndürmek, "ben bilmiyorum, karşı taraf denesin" demektir; raise etmekten farklıdır.

Koleksiyon gibi davranmak

Dört metot yeter: __len__, __getitem__, __iter__, __contains__. Yalnızca __getitem__ yazarsan Python 0, 1, 2 diye indeksleyerek for döngüsünü ve in kontrolünü bile üretir; ama açıkça __iter__ yazmak daha hızlı ve nettir.

class Sepet:
    def __init__(self):
        self._kalemler: list[tuple[str, int, float]] = []

    def ekle(self, urun, adet, fiyat):
        self._kalemler.append((urun, adet, fiyat))
        return self            # zincirleme için

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

    def __getitem__(self, i):          # int ve dilim (slice) destekler
        return self._kalemler[i]

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

    def __contains__(self, urun):
        return any(k[0] == urun for k in self._kalemler)

    def __call__(self, kdv=0.20):      # sepet(0.2) -> toplam
        return round(sum(a * f for _, a, f in self._kalemler) * (1 + kdv), 2)

    def __bool__(self):
        return bool(self._kalemler)

s = Sepet().ekle('kahve', 2, 150.0).ekle('bardak', 4, 40.0)
print(len(s), 'kahve' in s, 'çay' in s)
print(s[0], s[-1:])
for urun, adet, fiyat in s:
    print(f'{urun:<8}{adet:>3} x {fiyat:>7.2f}')
print('KDV dahil:', s())
print('boş sepet:', bool(Sepet()))
__bool__ yoksa __len__ kullanılırBir nesnenin doğruluk değeri önce __bool__, o yoksa __len__ ile belirlenir. Koleksiyon sınıflarında __len__ yazdığında if sepet: otomatik olarak "sepet boş mu?" anlamına gelir.

ABC: zorunlu arayüz

abc.ABC ve @abstractmethod, alt sınıfın mutlaka gerçekleştirmesi gereken metotları tanımlar. Eksik bırakılan metot varsa sınıf örneklenemez; hata, nesne yaratılırken, yani erken çıkar. Ödeme sağlayıcıları, bildirim kanalları, depolama katmanları gibi "birden fazla gerçekleştirme, tek sözleşme" durumlarında kullan.

from abc import ABC, abstractmethod

class OdemeSaglayici(ABC):
    @abstractmethod
    def tahsil_et(self, tutar: float) -> str: ...

    def makbuz(self, tutar):            # somut metot: alt sınıflara miras
        return f'{self.__class__.__name__}: {tutar:.2f} TL tahsil edildi'

class KrediKarti(OdemeSaglayici):
    def tahsil_et(self, tutar):
        return self.makbuz(tutar)

class Havale(OdemeSaglayici):
    pass                                 # tahsil_et eksik!

print(KrediKarti().tahsil_et(250))
try:
    Havale()
except TypeError as e:
    print('Hata:', e)
print(issubclass(KrediKarti, OdemeSaglayici), isinstance(KrediKarti(), OdemeSaglayici))

Protocol: yapısal tipleme

ABC "kalıtımla sözleşme" kurar; typing.Protocol ise "bu metotlara sahip olan herkes uyar" der (ördek tipleme, duck typing). Alt sınıf olmak gerekmez; @runtime_checkable eklersen isinstance da çalışır. Dış kütüphanelerin sınıflarını değiştiremediğin, ama onları kendi fonksiyonlarına parametre olarak almak istediğin durumlarda Protocol tek çözümdür.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Raporlanabilir(Protocol):
    def satir(self) -> str: ...

class Siparis:
    def __init__(self, no, tutar): self.no, self.tutar = no, tutar
    def satir(self): return f'SIP-{self.no} {self.tutar:.2f}'

class Musteri:
    def __init__(self, ad): self.ad = ad
    def satir(self): return f'MUS {self.ad}'

def yazdir(kayitlar: list[Raporlanabilir]):
    for k in kayitlar:
        print(k.satir())

yazdir([Siparis(1, 99.9), Musteri('Ayşe')])
print(isinstance(Siparis(2, 1), Raporlanabilir), isinstance(3, Raporlanabilir))
ÖzellikABCProtocol
İlişkiNominal: alt sınıf olmalıYapısal: metotlar uyuşmalı
Eksik metotÖrnekleme anında TypeErrorStatik denetleyici (mypy) uyarır
Somut metot paylaşımıEvet, miras alınırHayır (yalnızca imza)
Dış kütüphane sınıfıUyarlamak gerekirOlduğu gibi uyar
Tipik kullanımKendi eklenti sisteminFonksiyon parametresi sözleşmesi

Descriptor ve property

property tek alana doğrulama eklemek için idealdir. Aynı doğrulamayı on sınıfta yirmi alana uygulayacaksan descriptor yaz: __set_name__, __get__, __set__ metotlarına sahip bir sınıf. Pydantic, Django ORM ve dataclass'ın field mekanizması bu altyapıya dayanır.

class Pozitif:
    """Alan değeri pozitif sayı olmalı; aksi halde ValueError."""
    def __set_name__(self, owner, name):
        self.ad = name
        self.gizli = '_' + name

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

    def __set__(self, obj, deger):
        if not isinstance(deger, (int, float)) or deger <= 0:
            raise ValueError(f'{self.ad} pozitif olmalı, gelen: {deger!r}')
        setattr(obj, self.gizli, deger)

class KrediBasvurusu:
    tutar = Pozitif()
    vade_ay = Pozitif()

    def __init__(self, tutar, vade_ay):
        self.tutar = tutar
        self.vade_ay = vade_ay

    @property
    def aylik_taksit(self):             # salt okunur hesaplanmış alan
        return round(self.tutar / self.vade_ay, 2)

k = KrediBasvurusu(120_000, 24)
print(k.tutar, k.vade_ay, k.aylik_taksit)
try:
    k.vade_ay = 0
except ValueError as e:
    print('Hata:', e)

__slots__, classmethod, staticmethod

__slots__ nesnenin __dict__ sözlüğünü kaldırır: bellek düşer, öznitelik erişimi hızlanır, yanlışlıkla self.tutr = 5 yazmak AttributeError verir. Milyonlarca küçük nesne ürettiğin (işlem kayıtları, log satırları) sınıflarda değerlidir; dinamik öznitelik eklemen gereken yerlerde kullanma.

@classmethod alternatif kurucu (constructor) yazmanın standart yoludur: Siparis.from_dict(...), datetime.fromisoformat(...). İlk parametresi sınıfın kendisidir; alt sınıftan çağrılınca alt sınıfı üretir. @staticmethod ise sınıfla ilgili ama ne örneğe ne sınıfa ihtiyaç duyan yardımcı fonksiyondur (ör. doğrulama).

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Islem:
    hesap: str
    tutar: float

    @staticmethod
    def hesap_gecerli(hesap: str) -> bool:
        return hesap.startswith('TR') and len(hesap) == 26

    @classmethod
    def from_dict(cls, d: dict):
        if not cls.hesap_gecerli(d['hesap']):
            raise ValueError('IBAN geçersiz')
        return cls(hesap=d['hesap'], tutar=float(d['tutar']))

i = Islem.from_dict({'hesap': 'TR' + '0' * 24, 'tutar': '150.75'})
print(i)
try:
    i.ekstra = 1
except (AttributeError, TypeError) as e:   # Python 3.12'de frozen+slots bazen TypeError verir
    print('slots engelledi:', type(e).__name__)
Kalıtım ve __slots__Alt sınıf da __slots__ tanımlamazsa __dict__ geri gelir ve kazanım kaybolur. Çoklu kalıtımda iki ebeveynin ikisi de boş olmayan __slots__ taşıyorsa "layout conflict" hatası alırsın.

Metaclass'a kısa bakış

Metaclass, sınıfın sınıfıdır: type. class X: ifadesi aslında type('X', (), {...}) çağrısıdır. Kendi metaclass'ını yazarak sınıf tanımlanırken müdahale edebilirsin (otomatik kayıt, alan doğrulama). Uygulama kodunda nadiren gerekir; çoğu ihtiyaç __init_subclass__ ile daha basit çözülür:

class Eklenti:
    kayit: dict[str, type] = {}

    def __init_subclass__(cls, anahtar=None, **kw):
        super().__init_subclass__(**kw)
        Eklenti.kayit[anahtar or cls.__name__.lower()] = cls

class CsvDisaAktar(Eklenti, anahtar='csv'): ...
class JsonDisaAktar(Eklenti, anahtar='json'): ...

print(sorted(Eklenti.kayit))
print(type(Eklenti), type(type))
Derinleş

Nesnenin yüzü: __repr__, __str__ ve __format__

Bir nesneyi ekrana üç farklı yol taşır. __repr__ geliştirici içindir: hata ayıklayıcıda, logda, liste içinde görünür; mümkünse nesneyi yeniden üretecek ifadeyi (Para(1250, 'TRY')) döndürmelidir. __str__ son kullanıcı içindir ve print/str() çağırır; tanımlı değilse __repr__ kullanılır. __format__ ise f-string'deki biçim belirtecini (f"{x:tl}") yorumlamanı sağlar.

class Para:
    def __init__(self, kurus, birim="TRY"):
        self.kurus, self.birim = kurus, birim

    def __repr__(self):
        return f"Para({self.kurus!r}, {self.birim!r})"

    def __str__(self):
        return f"{self.kurus / 100:.2f} {self.birim}"

    def __format__(self, spec):
        if spec == "tl":           # Türkçe biçim: 1.234,50 TL
            metin = f"{self.kurus / 100:,.2f}".replace(",", "_").replace(".", ",").replace("_", ".")
            return f"{metin} TL"
        return format(str(self), spec)

p = Para(123450)
print(p)                 # __str__
print([p])               # liste içinde __repr__
print(f"{p!r} | {p:tl} | {p:>14}|")

Aritmetik: yansıyan operatörler ve NotImplemented

a + b önce a.__add__(b)'yi dener. Bu NotImplemented döndürürse Python yansıyan (reflected) metodu, b.__radd__(a)'yı dener. sum() toplamaya 0 ile başladığı için 0 + Para(...) hesaplanır; sınıfın sum ile çalışması için __radd__ şarttır. Yerinde işlemler (+=) için __iadd__ tanımlamazsan Python __add__ ile yeni nesne üretir; değişmez değer nesnelerinde (value object) istenen de budur.

AmaçDunderNot
a + b, b + a__add__, __radd__Tanımadığın türde NotImplemented döndür, hata fırlatma
a += b__iadd__Yoksa __add__ kullanılır
-a, abs(a)__neg__, __abs__İade, ters kayıt
if a:__bool__Yoksa __len__, o da yoksa hep True
Sıralama__eq__ + __lt__ + @total_orderingDiğer dördünü dekoratör (decorator) üretir
with a:__enter__, __exit____exit__ True dönerse hata yutulur
a.x bulunamadı__getattr__Yalnızca normal arama başarısızsa çağrılır
Alt sınıf tanımlandı__init_subclass__Metaclass'sız eklenti kaydı
from functools import total_ordering

@total_ordering
class Tutar:
    def __init__(self, kurus):
        self.kurus = kurus
    def __repr__(self):
        return f"Tutar({self.kurus})"
    def __add__(self, other):
        if isinstance(other, Tutar):
            return Tutar(self.kurus + other.kurus)
        return NotImplemented
    def __radd__(self, other):
        if other == 0:                  # sum() başlangıç değeri
            return self
        return self.__add__(other)
    def __eq__(self, other):
        return isinstance(other, Tutar) and self.kurus == other.kurus
    def __lt__(self, other):
        if not isinstance(other, Tutar):
            return NotImplemented
        return self.kurus < other.kurus
    __hash__ = None                     # değişebilir: hash'lenmesin

islemler = [Tutar(500), Tutar(1200), Tutar(300)]
print(sum(islemler), max(islemler), sorted(islemler))
print(Tutar(5) >= Tutar(3), Tutar(5) != Tutar(5))

Temsil ve sarmalama: __getattr__

__getattr__ yalnızca nitelik normal yollarla (örnek sözlüğü, sınıf, üst sınıflar) bulunamadığında çağrılır. Bu yüzden bir nesneyi sarıp bilmediğin her isteği içteki nesneye devretmek (delegation, proxy) için idealdir. __getattribute__ ise her erişimde çağrılır; yanlış yazılırsa sonsuz özyinelemeye (recursion) girer, nadiren gerekir. __setattr__ yazarken atamayı super().__setattr__(ad, deger) ile yap; self.ad = deger yine __setattr__'ı çağırır.

class Loglu:
    def __init__(self, hedef):
        self._hedef = hedef
        self.cagrilar = []
    def __getattr__(self, ad):                 # yalnız bulunamayınca
        nitelik = getattr(self._hedef, ad)
        if callable(nitelik):
            def sarmal(*a, **k):
                self.cagrilar.append(ad)
                return nitelik(*a, **k)
            return sarmal
        return nitelik

stok = Loglu({"elma": 10})
stok.get("elma"); stok.setdefault("armut", 0)
print(stok.cagrilar, len(stok._hedef))

Alt sınıfları otomatik kaydet: __init_subclass__

Tasarım kalıpları dersinde kayıt (registry) fabrikasını dekoratörle kurdun. Taban sınıfa __init_subclass__ yazarsan her alt sınıf tanımlandığı anda taban sınıfa haber verir; dekoratörü unutmak mümkün olmaz. Sınıf satırındaki anahtar sözcükler (class X(Taban, kod="pdf")) bu metoda iletilir.

class Disa:
    turler = {}
    def __init_subclass__(cls, kod, **kw):
        super().__init_subclass__(**kw)
        Disa.turler[kod] = cls

class CSV(Disa, kod="csv"):
    def yaz(self, satirlar): return "\n".join(",".join(map(str, s)) for s in satirlar)

class JSONL(Disa, kod="jsonl"):
    def yaz(self, satirlar): return "\n".join(str(list(s)) for s in satirlar)

print(sorted(Disa.turler))
print(Disa.turler["csv"]().yaz([(1, "İzmir"), (2, "Bursa")]))

Bir kez hesapla: functools.cached_property

@cached_property ilk erişimde metodu çalıştırır ve sonucu örnek sözlüğüne yazar; sonraki erişimler sözlükten gelir. Pahalı ve değişmeyen türetilmiş değerler (rapor özeti, ayrıştırılmış yapılandırma) için idealdir. Nesnenin durumu değişebiliyorsa önbellek bayatlar: ya @property kullan ya da değişiklikte del self.ozellik ile önbelleği sil. __slots__ kullanan sınıfta __dict__ olmadığı için çalışmaz.

Dunder disiplini: kısa kontrol listesi

  • __eq__ yazdıysan __hash__'ı düşün. Değer nesnesi değişmezse hash((alanlar)), değişebiliyorsa __hash__ = None (sözlük anahtarı olmasın).
  • Tanımadığın türe NotImplemented. Karşılaştırma ve aritmetikte hata fırlatma; Python karşı tarafa sorsun.
  • Sözleşmeyi bütün kur. __lt__ yazdıysan total_ordering ekle; __len__ yazdıysan __bool__ davranışının uzunluktan türeyeceğini unutma.
  • Sürpriz yapma. + operatörü yan etki üretmemeli, girdiyi değiştirmemeli; okuyan kişi aritmetik bekler.
  • Önce dataclass'a bak. @dataclass(frozen=True, order=True) eşitlik, hash, sıralama ve repr'i tek satırda üretir; elle dunder yalnızca özel kural gerektiğinde.
Dunder metodu sınıfta araPython örtük çağrılarda (len(x), x + y, with x) dunder metodu sınıfta arar, örnekte değil. nesne.__len__ = lambda: 5 yazmak len(nesne)'yi değiştirmez.
Gerçek işteFinans ve e-ticaret kodunda en değerli dunder'lar sıkıcı olanlardır: anlamlı bir __repr__ (loglarda <Siparis object at 0x7f...> yerine Siparis(no=1042, tutar=Para(...))), doğru __eq__/__hash__ ve para birimi karışınca hata veren aritmetik. Kod incelemesinde (code review) "bu nesne logda nasıl görünecek?" sorusunu sor.
Uzmanlaş

Kurumsal kodda dunder disiplini

İş hayatında sık görülen hatalar: __eq__ yazıp __hash__ unutmak (nesne kümeye girince TypeError), değişebilir (mutable) nesneye __hash__ vermek (sözlükte kaybolan anahtarlar), __repr__ yazmamak (log dosyasında <Siparis object at 0x...>). Pratik kural: değer nesneleri için @dataclass(frozen=True) kullan, eşitlik ve hash hazır gelir; davranış sınıflarında yalnızca gerçekten gereken dunder'ları yaz.

ABC mi Protocol mü sorusunda ekip kuralı genelde şudur: kendi yazdığın eklenti sistemi ve paylaşılan somut metotlar varsa ABC; fonksiyon parametresi için "şu metoda sahip olsun" diyorsan Protocol. İkisini birlikte de kullanabilirsin: ABC gerçekleştirmeyi zorunlu kılar, Protocol test çiftlerinin (test double) kalıtım olmadan geçmesini sağlar.

Descriptor yazarken __set_name__ kullan; alan adını elle tekrar etmek hata kaynağıdır. Performans kritik sınıflarda __slots__ ile dataclass'ın slots=True seçeneğini birleştir. Metaclass'ı yalnızca bir çerçeve (framework) yazıyorsan düşün; uygulama kodunda görürsen önce __init_subclass__ ya da sınıf dekoratörü ile çözülüp çözülemeyeceğini sor.

Alıştırmalara geç (23)