Java · Bölüm 1

OOP ve nesne tasarımı: polymorphism, interface, composition, immutability, equals/hashCode

Java'nın metot seçim modelini, abstract class/interface/sealed tasarım kararlarını, composition'ın neden kalıtımdan güvenli olduğunu, gerçek immutability'yi ve equals/hashCode sözleşmesinin hash tabanlı koleksiyonlardaki karşılığını mekanizmasıyla öğretir.

~40 dk okumaQuiz: j1, j2, j3, j4, q2
Cevaplar:

Bu bölümde

Java'da bir metot çağrısının hangi koda gittiğine iki ayrı aşamada karar verilir: derleme anında imza, çalışma anında gövde seçilir. Bu ayrımı bilen biri overloading/overriding sorularını ezberlemeden çözer, Spring proxy'lerinin neden final ve private metotlarda çalışmadığını da açıklar. Bölümün devamında abstract class ile interface arasındaki tasarım kararını, Java 21'in sealed + record + pattern matching üçlüsünü, kalıtımın encapsulation'ı nasıl deldiğini, "immutable" denen sınıfların neden çoğu zaman immutable olmadığını ve equals/hashCode sözleşmesinin HashMap içinde ne anlama geldiğini göreceksin.

Mülakatta bu konular çoğunlukla kod okuma sorusu olarak gelir ("ekrana ne yazar?"). Bunun dışında "abstract class mı interface mi?" gibi bir tasarım sorusu ya da live coding'de bir domain modelinin (sepet, sipariş, ödeme) nasıl kurulduğu üzerinden karşına çıkar.

Konu anlatımı

Polymorphism: iki aşamalı metot seçimi

p.pay(arg) gibi bir çağrıda iki soru vardır:

  1. Hangi imza? pay(Object) mu, pay(String) mı? Bu soruyu derleyici cevaplar. Bakacağı tek bilgi, alıcının (p) ve argümanların (arg) derleme anındaki (statik) tipleridir. Bu aşamanın adı overload resolution ve kuralları JLS §15.12'de tanımlıdır.
  2. Hangi gövde? Seçilen imzanın hangi sınıftaki implementasyonu çalışacak? Bu soruyu JVM cevaplar ve nesnenin çalışma anındaki gerçek sınıfına bakar. Bu aşamanın adı dynamic dispatch'tir.

Derleyici seçtiği imzayı bytecode'a sabit olarak yazar. Örneğin invokevirtual Payment.pay:(Ljava/lang/Object;)V. Çalışma anında JVM imzayı değiştirmez. Sadece bu imzanın, nesnenin gerçek sınıfında en alttaki override'ını bulur. HotSpot bunu sınıf başına tutulan bir sanal metot tablosuyla (vtable) yapar: her override edilebilir metodun tabloda bir slotu vardır, alt sınıf override ettiği metodun slotuna kendi gövdesini yazar. Interface çağrıları (invokeinterface) için benzer bir itable kullanılır.

Bu iki aşamalı modelin önemli bir sonucu var: Java single dispatch yapar. Çalışma anında sadece alıcı nesnenin (p) tipine bakılır. Argümanların çalışma anı tipi hiçbir zaman metot seçimini etkilemez. Object arg = "TR123" dediğinde arg çalışma anında bir String'dir, ama overload seçimi derleme anında Object tipine göre yapılmıştır ve bu karar bir daha açılmaz.

Overload resolution'ın üç fazı

Derleyici uygun metotları üç fazda arar ve bir fazda aday bulursa sonraki faza geçmez:

  1. Faz 1: Boxing/unboxing ve varargs olmadan, sadece primitive genişletme (widening) ve alt tip ilişkisiyle uyan metotlar.
  2. Faz 2: Boxing/unboxing'e izin verilir.
  3. Faz 3: Varargs'a izin verilir.

Bir fazda birden fazla aday varsa en spesifik olanı seçilir. String, Object'in alt tipi olduğu için pay(String), pay(Object)'ten daha spesifiktir. Hiçbiri diğerinden daha spesifik değilse derleme hatası alırsın (ambiguous method call).

java
class Overloads {
    static void log(long x)     { System.out.println("long"); }
    static void log(Integer x)  { System.out.println("Integer"); }
    static void log(int... xs)  { System.out.println("varargs"); }

    static void find(String s)  { System.out.println("String"); }
    static void find(Object o)  { System.out.println("Object"); }

    public static void main(String[] args) {
        log(5);        // "long": Faz 1'de int -> long genişletmesi bulunur, boxing'e hiç bakılmaz
        find(null);    // "String": null her iki tipe uyar, String daha spesifik
    }
}

find(String) ve find(Integer) diye iki overload olsaydı find(null) derlenmezdi: String ile Integer arasında alt tip ilişkisi yok, ikisi de eşit derecede spesifik.

Overload seçimi derleme anında yapıldığı için, aynı isimde ve aynı sayıda parametresi olan overload'lar okuyanı yanıltır. Effective Java bu yüzden "overload'u dikkatli kullan" der. Mümkünse metotlara farklı isimler ver: findById(long), findBySku(String).

JIT'in bakış açısı: dispatch'in maliyeti

Dynamic dispatch "yavaştır" diye bir inanış vardır. HotSpot'ta pratikte öyle değildir. JIT, bir çağrı noktasında (call site) gerçekte hangi sınıfların görüldüğünü profiller:

  • Monomorphic (tek sınıf görülmüş): Çağrı doğrudan o gövdeye bağlanır, hatta inline edilir. Yanında ucuz bir tip kontrolü (guard) kalır.
  • Bimorphic (iki sınıf): İki guard ile yine inline edilebilir.
  • Megamorphic (üç ve daha fazla): vtable/itable üzerinden gerçek sanal çağrı yapılır, inline edilemez.

Ayrıca Class Hierarchy Analysis sayesinde, yüklenmiş tek bir implementasyonu olan metotlar sanal çağrı olmaktan çıkarılır. Yeni bir alt sınıf yüklenirse JIT bu optimizasyonu geri alır (deoptimization). Yani mülakatta "polymorphism performansı düşürür" demek yanlış olur. Doğru cümle şu: "Sıcak bir çağrı noktası megamorphic hale gelirse inline edilemez. Bu nadiren darboğazdır ve profil olmadan optimize edilmez."

Overloading ve overriding: kurallar

OverloadingOverriding
TanımAynı isim, farklı parametre listesiAlt sınıfta aynı imza (isim + parametre tipleri)
Karar anıDerleme anı, argümanların statik tipine göreÇalışma anı, alıcı nesnenin gerçek sınıfına göre
Dönüş tipiSerbest (imzanın parçası değil)Aynı ya da alt tip (covariant return, Java 5+)
Erişim belirleyiciSerbestDaraltılamaz (public metot protected yapılamaz)
Checked exceptionSerbestÜst metottan daha geniş checked exception bildirilemez (daha dar ya da hiç olabilir; unchecked serbest)
static metotOverload edilebilirOverride edilemez, sadece gizlenir (hiding)

Bu kuralların hepsinin ortak gerekçesi Liskov yerine geçebilirliğidir. Çağıran kod Payment referansı üzerinden konuşur ve Payment'ın sözleşmesine göre yazılmıştır. Alt sınıf erişimi daraltsaydı, daha geniş checked exception fırlatsaydı ya da alakasız bir tip döndürseydi bu kod derlendiği halde bozulurdu.

Covariant return derleyicinin ürettiği bir bridge method ile çalışır. Payment copy() metodunu CardPayment copy() ile override ettiğinde, derleyici CardPayment içine Payment copy() imzasında sentetik bir metot ekler. Bu metot gerçek metodu çağırır. Böylece eski imzayla gelen invokevirtual çağrıları da doğru gövdeyi bulur.

@Override neden zorunlu sayılmalı

java
public class Money {
    private final BigDecimal amount;
    private final Currency currency;

    // HATA: Bu equals'ı override ETMİYOR, overload ediyor.
    public boolean equals(Money other) {
        return amount.equals(other.amount) && currency.equals(other.currency);
    }
}

Set<Money> set = new HashSet<>();
set.add(new Money(...));
set.contains(new Money(...));   // HashSet, equals(Object)'i çağırır: Object'in == karşılaştırması çalışır

HashSet içeride equals(Object) imzasıyla çağırır. Bizim equals(Money) metodumuz farklı bir imza olduğu için hiç çağrılmaz. Metodun üstüne @Override yazılsaydı derleyici "method does not override or implement a method from a supertype" hatası verirdi. Bu, @Override'ın en değerli olduğu senaryodur.

Static binding'e giren her şey: static, private, final ve field'lar

Override sadece instance metotları için geçerlidir ve sadece alt sınıftan erişilebilen, final olmayan metotlar override edilebilir. Geri kalan her şey derleme anında bağlanır.

Static metotlar gizlenir (method hiding). Alt sınıfta aynı imzalı bir static metot tanımlarsan üsttekini override etmezsin, gizlersin. Hangisinin çağrılacağına referansın derleme tipi karar verir:

java
class Payment     { static String type() { return "Payment"; } }
class CardPayment extends Payment { static String type() { return "Card"; } }

Payment p = new CardPayment();
p.type();             // "Payment": derleyici bunu Payment.type() olarak yazar
((CardPayment) p).type();   // "Card"

p.type() yazmak yasal ama yanıltıcıdır. Derleyici bu çağrıyı invokestatic Payment.type olarak üretir ve p'nin değerine hiç bakmaz. p null olsa bile NullPointerException almazsın. IDE'ler bu yüzden "static metoda sınıf adıyla eriş" uyarısı verir. Static metotlara @Override yazarsan derleme hatası alırsın.

Private metotlar alt sınıftan görünmez, dolayısıyla override edilemez. Alt sınıfta aynı isimde bir metot yazarsan bu tamamen bağımsız yeni bir metottur.

Final metotlar override edilemez. Bu bir derleme hatasıdır.

Field'lar polymorphic değildir. Alt sınıfta aynı isimde bir alan tanımlarsan üst sınıfınkini gizler. Erişim, referansın derleme tipine göre yapılır:

java
class Base    { String name = "base";    String name() { return name; } }
class Derived extends Base { String name = "derived"; @Override String name() { return name; } }

Base b = new Derived();
b.name;     // "base"   : field, derleme tipine göre
b.name();   // "derived": metot, dynamic dispatch

Constructor'dan override edilebilir metot çağırmak

Dynamic dispatch constructor içinde de çalışır. Üst sınıfın constructor'ı, alt sınıfın alanları henüz ilklendirilmeden çalışır. Bu yüzden constructor'dan çağrılan bir override, alt sınıfın alanlarını varsayılan değerleriyle (null, 0) görür:

java
abstract class Notifier {
    protected Notifier() {
        registerTemplates();          // dynamic dispatch: SmsNotifier.registerTemplates çalışır
    }
    protected abstract void registerTemplates();
}

class SmsNotifier extends Notifier {
    private final List<String> templates = new ArrayList<>();   // super() döndükten SONRA atanır

    @Override
    protected void registerTemplates() {
        templates.add("ORDER_SHIPPED");   // NullPointerException: templates henüz null
    }
}

İlklendirme sırası şöyledir: new SmsNotifier() önce Notifier() constructor'ını çalıştırır, o bitince SmsNotifier'ın alan ilklendiricileri çalışır, en son SmsNotifier constructor gövdesi çalışır. Kural basit: constructor'dan override edilebilir metot çağırma. Ya metodu private/final yap ya da ilklendirmeyi bir factory metoduna taşı.

Spring proxy'leri ve static binding

Spring AOP (@Transactional, @Cacheable, @Async), Spring Boot'un varsayılan ayarıyla hedef sınıfın CGLIB ile üretilmiş bir alt sınıfını kullanır. Proxy, metotları override eder, araya girer ve sonra super'e (aslında hedef nesneye) delege eder. Bu tasarımın doğrudan sonuçları:

  • final metot override edilemez: proxy araya giremez. Annotation sessizce etkisiz kalır.
  • private metot override edilemez: aynı sonuç.
  • static metot hiç dispatch edilmez: aynı sonuç.
  • Aynı sınıf içinden this.save() çağrısı proxy'ye uğramaz (self-invocation): this gerçek nesnedir.

Kotlin sınıfları varsayılan olarak final olduğu için Spring projelerinde kotlin-spring (all-open) eklentisi kullanılır. Sebep tamamen bu mekanizmadır. Proxy mekanizması ve @Transactional tuzaklarının ayrıntısı için bkz. Bölüm 6 ve Bölüm 8.

Abstract class mı, interface mi?

İkisi de "soyutlama" aracıdır ama farklı soruları cevaplar. Interface "bu nesne ne yapabilir?" sorusunu, yani bir sözleşmeyi tanımlar. Abstract class "bu nesneler ortak olarak ne?" sorusunu, yani ortak state'i ve ortak akışı tanımlar.

InterfaceAbstract class
Instance stateYok. Alanlar örtük olarak public static final (sabit)Var
ConstructorYokVar (alt sınıflar super(...) ile çağırır)
Çoklu kullanımBir sınıf N interface implemente ederBir sınıf sadece bir sınıfı extend eder
Metot gövdesidefault ve static (Java 8+), private (Java 9+)Her türlü
ErişimAbstract ve default metotlar her zaman publicHer erişim seviyesi
Tipik kullanımSözleşme, DI, test'te mock, strategyTemplate Method, ortak state ve yardımcılar

default, static ve private metotlar neden geldi

Java 8 lambda ve stream'leri getirirken Collection interface'ine stream(), removeIf(), forEach() gibi metotlar eklemek gerekti. Interface'e yeni bir abstract metot eklemek, dünyadaki tüm implementasyonları derleme hatasına sokardı. Default metot, interface'e gövdesiyle birlikte metot eklemeyi ve mevcut implementasyonları kırmamayı sağlar. Yani asıl amacı geriye uyumlu API evrimidir, çoklu kalıtım değil.

  • Static metotlar (Java 8+) interface'e ait yardımcılardır: Comparator.comparing(...), List.of(...). Önemli bir ayrıntı: interface'teki static metotlar miras alınmaz. List.of() çalışır, ama ArrayList.of() diye bir şey yoktur ve implementasyon üzerinden çağrılamaz.
  • Private metotlar (Java 9+) birden fazla default metodun paylaştığı kodu dışarıya açmadan yazmak içindir.

Default metotların sınırları da bilinmeli:

  • Default metot Object'in equals, hashCode veya toString metotlarını override edemez. Bu bir derleme hatasıdır. Gerekçe "class wins" kuralı: bu metotların her sınıfta Object'ten gelen bir implementasyonu zaten vardır, interface'teki default hiçbir zaman seçilemezdi.
  • Default metot state tutamaz; sadece diğer interface metotlarını çağırabilir.

Diamond problem: aynı default metot iki yerden gelirse

Derleyici çakışmayı şu sırayla çözer:

  1. Class wins: Üst sınıf zincirinde (abstract dahil) somut bir metot varsa o kazanır.
  2. Daha spesifik interface kazanır: B extends A ise ve ikisi de aynı default metodu tanımlıyorsa B'ninki seçilir.
  3. Hâlâ belirsizlik varsa sınıf metodu override etmek zorundadır. İstediği üst implementasyonu X.super.m() ile çağırabilir.
java
interface Auditable  { default String describe() { return "auditable"; } }
interface Refundable { default String describe() { return "refundable"; } }

final class CardPayment implements Auditable, Refundable {
    @Override
    public String describe() {                // override etmezsen derleme hatası
        return Auditable.super.describe() + "+" + Refundable.super.describe();
    }
}

Template Method ile abstract class

Abstract class'ın gerçek değeri, akışı sabitleyip adımları açık bırakmasıdır:

java
public abstract class AbstractPaymentProcessor implements PaymentProcessor {

    private final AuditLogger audit;

    protected AbstractPaymentProcessor(AuditLogger audit) {
        this.audit = audit;
    }

    @Override
    public final PaymentResult pay(PaymentRequest request) {   // final: akış değiştirilemez
        validate(request);
        PaymentResult result = doPay(request);
        audit.log(request, result);
        return result;
    }

    protected void validate(PaymentRequest request) {           // hook: istenirse genişletilir
        if (request.amount().signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
    }

    protected abstract PaymentResult doPay(PaymentRequest request);   // değişen adım
}

Burada pay() metodunun final olması bilinçli bir karardır: alt sınıflar validasyonu veya audit'i atlayamaz. Bunun bedeli de bilinmeli: pay() final olduğu için CGLIB proxy bu metodun önüne geçemez. Bu sınıfa @Transactional gerekiyorsa transaction sınırı başka bir bean'e konmalıdır.

Pratik kural: Önce interface tasarla (PaymentProcessor). Bean'ler bu interface üzerinden enjekte edilsin. İmplementasyonlar arasında gerçekten ortak state veya akış ortaya çıkarsa, interface'i implemente eden bir abstract class ekle. Abstract class'ı public API olarak dışarıya verme. Çağıranlar her zaman interface'i görsün.

Sealed interface, record ve pattern matching (Java 21)

Klasik OOP, "tip sayısı açık, işlem sayısı kapalı" varsayımına dayanır. Yeni bir ödeme tipi eklemek kolaydır (yeni sınıf yaz, metodu override et), ama yeni bir işlem eklemek (ör. komisyon hesabı) her sınıfa dokunmayı gerektirir. Bazı domain'lerde durum tam tersidir: tip kümesi kapalıdır ve bilinir (ödeme sonucu: onaylandı, reddedildi, 3DS bekliyor), işlemler ise sürekli artar (raporlama, bildirim, muhasebe kaydı). Java 17 ve 21 bu ikinci modeli birinci sınıf hale getirdi.

Sealed tipler (Java 17, JEP 409): Bir sınıf ya da interface, kimlerin onu extend/implement edebileceğini permits ile sınırlar. İzin verilen her alt tip şunlardan biri olmak zorundadır:

  • final (record'lar ve enum'lar örtük olarak final'dır),
  • sealed (hiyerarşi kontrollü şekilde devam eder),
  • non-sealed (hiyerarşi bu noktadan sonra bilinçli olarak açılır).

İzin verilen alt tipler aynı modülde, modül yoksa aynı pakette olmalıdır. Yani "yeni ödeme tiplerini sadece ödeme ekibi ekleyebilsin" kuralı derleyici tarafından uygulanır.

Record'lar (Java 16, JEP 395): Değer taşıyan, sığ immutable veri sınıfları. Bileşenlerden private final alanlar, canonical constructor, accessor'lar (amount()), equals, hashCode ve toString üretilir.

Pattern matching for switch (JEP 441) ve record patterns (JEP 440), Java 21: switch artık tip üzerinden eşleşir, record'ları bileşenlerine ayırabilir (deconstruction) ve when ile koşul (guard) alabilir.

java
public sealed interface PaymentResult permits Approved, Declined, PendingThreeDS {}

public record Approved(String authCode, BigDecimal amount) implements PaymentResult {}
public record Declined(String reasonCode, boolean retryable) implements PaymentResult {}
public record PendingThreeDS(URI redirectUrl) implements PaymentResult {}

public final class PaymentResultMapper {

    public CheckoutResponse toResponse(PaymentResult result) {
        return switch (result) {
            case Approved(String authCode, BigDecimal amount) ->
                    CheckoutResponse.success(authCode, amount);
            case Declined d when d.retryable() ->
                    CheckoutResponse.retry(d.reasonCode());
            case Declined d ->
                    CheckoutResponse.failed(d.reasonCode());
            case PendingThreeDS(URI redirectUrl) ->
                    CheckoutResponse.redirect(redirectUrl);
        };   // default YOK: derleyici tüm durumların ele alındığını kontrol eder
    }
}

Bu kodun mülakatta söylenmesi gereken özellikleri:

  • Exhaustiveness: Switch bir ifade (expression) olarak ya da pattern kullanan bir statement olarak yazıldığında ve seçici sealed bir tip olduğunda, derleyici tüm izinli alt tiplerin ele alındığını kontrol eder. Yarın Refunded diye yeni bir tip eklendiğinde bu switch derleme hatası verir. Hatayı production'da değil, derleme anında yakalarsın.
  • default yazmak bu güvenceyi öldürür. default -> throw ... yazarsan yeni tip sessizce default'a düşer. Sealed hiyerarşide default yazmamak bilinçli bir tercihtir.
  • Guard'lı case tek başına kapsamaz. case Declined d when d.retryable() tüm Declined'ları kapsamadığı için altında koşulsuz bir case Declined d gerekir.
  • Dominance: Daha genel bir case, daha özel bir case'in üstüne yazılırsa (koşulsuz case Declined d guard'lı olanın üstünde) derleme hatası alırsın, çünkü alttaki case'e asla ulaşılamaz.
  • case null: Pattern switch'te null için açık bir case yazabilirsin. Yazmazsan seçici null olduğunda eskisi gibi NullPointerException fırlar.
  • Ayrı derleme: Switch derlendikten sonra hiyerarşiye yeni bir tip eklenip sadece o sınıf yeniden derlenirse, çalışma anında eşleşmeyen değer için MatchException fırlar.

Ne zaman polymorphic metot, ne zaman sealed + switch? Davranış nesnenin kendi sorumluluğundaysa ve tip sayısı büyüyecekse (yeni kargo firmaları sürekli ekleniyor) interface + override doğrudur. Tip kümesi kapalıysa, veri sadece veri ise (event'ler, API sonuçları, komutlar) ve üzerinde çok sayıda bağımsız işlem yapılacaksa sealed + record + switch daha temizdir. Bu ikinci yaklaşıma fonksiyonel dillerde algebraic data type denir. Visitor pattern'in yaptığı işi (double dispatch) çok daha az törenle yapar.

Composition ve inheritance: kırılgan üst sınıf

Kalıtım iki şeyi birden miras alır: tipi (alt sınıf, üst sınıfın yerine geçebilir) ve implementasyonu (alt sınıf, üst sınıfın kodunu yeniden kullanır). Sorun ikinci kısımdadır. Alt sınıf, üst sınıfın dokümante edilmemiş iç davranışına bağımlı hale gelir.

Klasik örnek (Effective Java, Item 18): HashSet'ten türeyip add ve addAll'u sayan bir sınıf. HashSet.addAll, AbstractCollection'dan miras alınmıştır ve her eleman için add(e) çağırır. Bu çağrı dynamic dispatch ile alt sınıfın add'ine gider ve her eleman iki kez sayılır. Üst sınıfın kendi metodunu kendi içinden çağırmasına self-use denir. Self-use bir implementasyon detayıdır, sözleşme değildir. JDK'nın bir sonraki sürümünde değişebilir ve alt sınıf hiçbir kod değişikliği olmadan bozulur ya da düzelir. Bu duruma fragile base class (kırılgan üst sınıf) problemi denir.

Kalıtımın riskleri sadece self-use ile sınırlı değil:

  • Üst sınıfa yeni metot eklenmesi: Alt sınıf "her eklemede kural kontrol et" diye add'i override etmiş olsun. Üst sınıfa yeni bir ekleme yolu (ör. Java 8'deki removeIf gibi yeni bir metot) gelirse, bu yol alt sınıfın kontrolünü atlar. Effective Java'nın verdiği gerçek örnekte, Collection'a eklenen default removeIf, senkronize bir koleksiyon sarmalayıcısının kilit garantisini deldi.
  • İmza çakışması: Alt sınıfa eklediğin bir metot, üst sınıfın sonraki sürümünde aynı imzayla ama farklı dönüş tipiyle eklenirse kodun derlenmez. Aynı dönüş tipiyle eklenirse farkında olmadan override etmiş olursun.

JDK'nın kendisinde de bu hatanın örnekleri var: Stack extends Vector (bir yığının ortasına insertElementAt ile eleman eklenebilir) ve Properties extends Hashtable (String olmayan anahtar konabilir, getProperty onları görmez). İkisinde de doğru ilişki "has-a" olmalıydı.

Çözüm: forwarding (delegation) ve decorator

java
// Yeniden kullanılabilir forwarding sınıfı: sadece delege eder, kendi içinde self-use yok
public class ForwardingSet<E> implements Set<E> {

    private final Set<E> delegate;

    public ForwardingSet(Set<E> delegate) {
        this.delegate = Objects.requireNonNull(delegate);
    }

    @Override public int size()                                { return delegate.size(); }
    @Override public boolean isEmpty()                         { return delegate.isEmpty(); }
    @Override public boolean contains(Object o)                { return delegate.contains(o); }
    @Override public Iterator<E> iterator()                    { return delegate.iterator(); }
    @Override public Object[] toArray()                        { return delegate.toArray(); }
    @Override public <T> T[] toArray(T[] a)                    { return delegate.toArray(a); }
    @Override public boolean add(E e)                          { return delegate.add(e); }
    @Override public boolean remove(Object o)                  { return delegate.remove(o); }
    @Override public boolean containsAll(Collection<?> c)      { return delegate.containsAll(c); }
    @Override public boolean addAll(Collection<? extends E> c) { return delegate.addAll(c); }
    @Override public boolean retainAll(Collection<?> c)        { return delegate.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c)        { return delegate.removeAll(c); }
    @Override public void clear()                              { delegate.clear(); }
    @Override public boolean equals(Object o)                  { return delegate.equals(o); }
    @Override public int hashCode()                            { return delegate.hashCode(); }
    @Override public String toString()                         { return delegate.toString(); }
}

// Asıl iş: decorator
public class CountingSet<E> extends ForwardingSet<E> {

    private int addCount;   // tek thread varsayımı; paylaşılacaksa LongAdder

    public CountingSet(Set<E> delegate) {
        super(delegate);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);   // delegate.addAll, delegate'in KENDİ add'ini çağırır; bizimkine dönemez
    }

    public int getAddCount() {
        return addCount;
    }
}

CountingSet hâlâ bir sınıftan türüyor ama bu sefer kalıtım için tasarlanmış ve kontrolümüzdeki bir sınıftan: ForwardingSet'in hiçbir metodu başka bir metodunu çağırmaz. delegate.addAll içeride delegate.add'i çağırsa bile bu çağrı HashSet'in kendi add'idir, bizim sayacımıza dönemez. Ek kazanç: artık herhangi bir Set'i sarabiliriz (TreeSet, ConcurrentHashMap.newKeySet()).

Composition'ın da bilinmesi gereken bir zayıflığı var: SELF problemi. Sarılan nesne kendini bir callback olarak başka bir yere kaydederse (listener.register(this)), dışarıya sarmalayıcıyı değil kendisini verir ve decorator atlanır. Pratikte nadir görülür ama "composition her derde deva" demek yerine bunu bilmek iyi bir işarettir.

Kalıtım ne zaman doğru?

  • Gerçek ve kalıcı bir is-a ilişkisi varsa, ve
  • Üst sınıf kalıtım için tasarlanmış ve self-use'unu dokümante etmişse (Effective Java, Item 19: "Design and document for inheritance or else prohibit it"), ya da
  • İki taraf da aynı paket/modül içindeyse ve aynı ekip tarafından değiştiriliyorsa.

Bu şartlar yoksa sınıfı final yap ya da constructor'ını private yapıp static factory ver. Kalıtıma kapalı bir sınıfı sonradan açmak kolaydır, açık bir sınıfı kapatmak kırıcı bir değişikliktir.

Liskov Substitution Principle (LSP) kalıtımın davranış testidir: alt tip, üst tipin yerine konduğunda üst tipe göre yazılmış kodun doğruluğu bozulmamalıdır. "Her ReadOnlyCart bir Cart'tır" cümlesi dilbilgisi olarak doğrudur ama addItem çağrısında exception fırlatan bir ReadOnlyCart, Cart sözleşmesine güvenen kodu bozar. Doğru model, okuma ve yazma yeteneklerini ayrı interface'lere bölmektir (CartView ve MutableCart).

Immutability

Immutable nesne, oluşturulduktan sonra gözlemlenebilir durumu hiçbir şekilde değişmeyen nesnedir. "Alanlar final" bundan çok daha zayıf bir garantidir.

Tarif

  1. Durumu değiştiren hiçbir metot (setter, add, increase) yok.
  2. Sınıf genişletilemez: final sınıf, ya da private constructor + static factory. Aksi halde bir alt sınıf mutable alan ekleyebilir veya getter'ı override edip başka bir şey döndürebilir.
  3. Tüm alanlar private final.
  4. Mutable bileşenler için girişte ve çıkışta savunmacı kopya (defensive copy).
  5. Bileşenlerin kendisi de immutable (derin immutability).

final ne garanti eder, ne etmez?

final sadece referansın bir kez atanacağını garanti eder. Referansın gösterdiği List veya Date nesnesinin içi değişebilir. Ama final'ın ikinci, daha az bilinen bir garantisi var: Java Memory Model'deki final field semantiği (JLS §17.5). Constructor sırasında this dışarı sızmadığı sürece, nesneyi bir data race ile (senkronizasyonsuz) gören başka bir thread bile final alanların constructor'da atanmış değerlerini görmek zorundadır. Bu yüzden immutable nesneler volatile veya kilit olmadan güvenle paylaşılabilir. final olmayan alanlar için böyle bir garanti yoktur: başka bir thread alanı varsayılan değeriyle görebilir.

Savunmacı kopya: önce kopyala, sonra doğrula

java
public final class Campaign {

    private final List<String> skus;
    private final Instant startsAt;
    private final Instant endsAt;

    public Campaign(List<String> skus, Instant startsAt, Instant endsAt) {
        this.skus = List.copyOf(skus);            // 1) önce kopyala (null eleman da reddedilir)
        this.startsAt = Objects.requireNonNull(startsAt);
        this.endsAt = Objects.requireNonNull(endsAt);
        if (!this.endsAt.isAfter(this.startsAt)) { // 2) sonra KOPYAYI doğrula
            throw new IllegalArgumentException("endsAt must be after startsAt");
        }
    }

    public List<String> skus() { return skus; }   // zaten değiştirilemez, kopya gerekmez
}

Doğrulamayı kopyadan önce, orijinal parametre üzerinde yaparsan, doğrulama ile kopyalama arasındaki pencerede başka bir thread listeyi değiştirebilir (time-of-check to time-of-use, TOCTOU). Bu yüzden Effective Java (Item 50) sırayı "önce kopya, sonra doğrulama" olarak verir. Instant immutable olduğu için kopyalanmaz. Eski java.util.Date kullanılsaydı hem girişte hem çıkışta new Date(d.getTime()) gerekirdi. Yeni kodda Date yerine her zaman java.time tipleri kullanılır.

Değiştirilemez koleksiyonlar arasındaki fark mülakatta sık sorulur:

YöntemKopya mı?Altta değişiklik yansır mı?null eleman
List.of(...)Yeni listeHayırReddeder (NPE)
List.copyOf(c)Kopya (kaynak zaten unmodifiable List.of türündense aynı nesneyi dönebilir)HayırReddeder
stream().toList() (Java 16+)Yeni listeHayırKabul eder
Collections.unmodifiableList(x)Hayır, görünümEvet, x değişirse görünüm de değişirKabul eder

Collections.unmodifiableList tek başına immutability sağlamaz. Sadece "bu referans üzerinden yazılamaz" der. Altındaki listeyi tutan biri hâlâ değiştirebilir.

Bir ayrıntı daha: BigDecimal ve BigInteger immutable'dır ama final değildir. Teoride güvenilmeyen bir kaynaktan gelen BigDecimal mutable bir alt sınıf olabilir. Bankacılık gibi güvenlik hassas kodda, dışarıdan gelen değer new BigDecimal(value.unscaledValue(), value.scale()) ile normalleştirilebilir. Günlük servis kodunda bu kadar ileri gidilmez ama "immutable tipin final olması neden önemli?" sorusuna iyi bir cevaptır.

Record'lar sığ immutable'dır

Record bileşenleri private final'dır ama record hiçbir şeyi kopyalamaz. record Order(List<OrderLine> lines) içine verdiğin liste, dışarıdaki listeyle aynı nesnedir. Kopyayı compact constructor'da sen yaparsın:

java
public record Order(UUID id, List<OrderLine> lines, Instant createdAt) {

    public Order {                                  // compact canonical constructor
        Objects.requireNonNull(id);
        lines = List.copyOf(lines);                 // parametreyi yeniden atıyoruz; alan atamasını derleyici yapar
        Objects.requireNonNull(createdAt);
        if (lines.isEmpty()) {
            throw new IllegalArgumentException("order must have at least one line");
        }
    }

    public Order withLine(OrderLine line) {         // "wither": değişiklik = yeni nesne
        var copy = new ArrayList<>(lines);
        copy.add(line);
        return new Order(id, copy, createdAt);
    }
}

public record OrderLine(long productId, int quantity, BigDecimal unitPrice) {}

Record'ların iki tuzağı daha var:

  • Dizi bileşenleri: Record'un ürettiği equals, referans tipli bileşenler için Objects.equals mantığıyla çalışır. Diziler için bu referans eşitliğidir. record Hash(byte[] value) iki ayrı ama içeriği aynı dizi için false döner. Ya List kullan ya da equals/hashCode'u Arrays.equals ile kendin yaz.
  • toString her bileşeni yazar. record CardInfo(String pan, String cvv) log'a düştüğünde kart numarası da düşer. Fintech kodunda hassas bileşen taşıyan record'larda toString'i override et ve maskele.

Immutability neden önemli, bedeli ne?

  • Thread-safety bedavadır. Paylaşılan immutable nesne için kilit gerekmez. Cache'e konan konfigürasyon, kampanya kuralları, döviz kuru tablosu gibi yapılar böyle tasarlanmalıdır.
  • Hash tabanlı koleksiyonlarda güvenlidir. hashCode değişmez (bkz. aşağıdaki equals/hashCode bölümü).
  • Akıl yürütmeyi kolaylaştırır. Bir metoda verdiğin nesnenin dönüşte aynı kalacağını bilirsin. Pass-by-value'nun yan etki sürprizlerini ortadan kaldırır (bkz. Bölüm 2).
  • Bedeli: Her değişiklik yeni nesne demektir. Kısa ömürlü nesneler modern GC'ler için çok ucuzdur (bkz. Bölüm 3), ama sıcak döngülerde milyonlarca ara nesne üretmek yerine bir builder ya da mutable yerel değişken kullanılır. String ile StringBuilder ilişkisi bunun en bilinen örneğidir.

JPA entity'leri bu tarifin dışındadır: Hibernate'in no-arg constructor'a, proxy için final olmayan sınıfa ve dirty checking için değişebilir alanlara ihtiyacı vardır. Entity'ler bu yüzden record olamaz. Ama DTO'lar, projection'lar, komutlar ve event'ler record olmalıdır. Hibernate 6.2+ record'ları @Embeddable (ör. Money, Address) olarak da destekler.

equals ve hashCode sözleşmesi

Object.equals için Javadoc'taki sözleşme beş maddedir:

  • Yansıma (reflexive): x.equals(x) her zaman true.
  • Simetri: x.equals(y) ise y.equals(x).
  • Geçişlilik: x.equals(y) ve y.equals(z) ise x.equals(z).
  • Tutarlılık: Nesneler değişmediği sürece tekrar tekrar çağrı aynı sonucu verir.
  • Null: x.equals(null) her zaman false (exception değil).

hashCode sözleşmesi:

  • equals'a göre eşit iki nesnenin hashCode'u aynı olmak zorundadır.
  • Tersi gerekmez. Eşit olmayan nesneler aynı hash'i verebilir (çakışma), sadece performansı düşürür.
  • Nesne değişmediği sürece aynı değeri döndürmelidir.

equals'ı override edip hashCode'u etmezsen, iki eşit nesne Object.hashCode'dan farklı hash'ler alır. HashSet ikisini farklı bucket'lara koyar ve aynı ürünü iki kez tutar.

HashMap içeride ne yapıyor?

Mutable key tuzağını anlamak için HashMap'in mekanizmasını bilmek gerekir:

  1. put(key, value) çağrısında key.hashCode() hesaplanır ve yüksek bitler alçak bitlere karıştırılır: h ^ (h >>> 16).
  2. Bucket indeksi (n - 1) & hash ile bulunur (n tablo boyutu, her zaman 2'nin kuvveti).
  3. Bucket'a bir Node(hash, key, value, next) eklenir. Hash değeri node'un içinde saklanır.
  4. get(key)/contains(key) aynı hesaplamayı yapar, ilgili bucket'a gider ve her node için önce node.hash == hash, sonra node.key == key || key.equals(node.key) kontrol eder.
  5. Tablo büyüdüğünde (varsayılan load factor 0.75) node'lar saklanan hash'e göre yeni tabloya dağıtılır.
  6. Bir bucket'taki node sayısı 8'i aşarsa (TREEIFY_THRESHOLD = 8) ve tablo en az 64 boyutundaysa bucket bağlı listeden kırmızı-siyah ağaca dönüşür (treeify). Kötü dağılan hashCode'larda en kötü durum O(n) yerine O(log n) olur.

Bu yüzden hash tabanlı bir koleksiyonun içindeyken nesnenin hashCode'u değişirse, nesne eski hash'iyle eski bucket'ta kalır. Yeni hash'le yapılan her arama ya başka bir bucket'a gider ya da aynı bucket'a düşse bile saklanan hash eşleşmediği için equals'a hiç gelinmez. Nesne koleksiyonun içindedir ama bulunamaz. Iterasyonda görünür, size()'a sayılır, ama contains ve remove onu bulamaz. Uzun ömürlü bir koleksiyonda bu aynı zamanda bir bellek sızıntısıdır.

Kurtarmanın tek yolu iterator üzerinden silmektir: Iterator.remove() (ve onu kullanan removeIf), node'un saklanan hash'ini kullandığı için kayıp elemanı silebilir. Ama bu bir kurtarma yöntemidir, tasarım değil.

Doğru tasarım

  • Map key'i ya da Set elemanı olacak nesneler immutable olsun. Value object'ler için record kullan.
  • Nesne mutable olmak zorundaysa equals/hashCode sadece değişmeyen kimlik alanlarını kullansın (productId, sku, orderNumber).
  • Bir sepet satırını Set'te tutmak yerine, kimliği key yapan bir Map<Long, CartLine> çoğu zaman daha doğru modeldir.

Kalıtım ve simetri

java
public class Money {
    protected final BigDecimal amount;
    protected final Currency currency;
    // ... constructor

    @Override
    public boolean equals(Object o) {
        return o instanceof Money m
                && amount.compareTo(m.amount) == 0
                && currency.equals(m.currency);
    }
    @Override
    public int hashCode() {
        return Objects.hash(amount.stripTrailingZeros(), currency);
    }
}

public class TaggedMoney extends Money {
    private final String tag;
    // ... constructor

    @Override
    public boolean equals(Object o) {
        return o instanceof TaggedMoney t && super.equals(t) && tag.equals(t.tag);
    }
    // hashCode: super'inki yeterli (eşit TaggedMoney'ler eşit Money'dir)
}

Money m = new Money(new BigDecimal("10"), TRY);
TaggedMoney t = new TaggedMoney(new BigDecimal("10"), TRY, "cashback");
m.equals(t);   // true  : t bir Money
t.equals(m);   // false : m bir TaggedMoney değil -> SİMETRİ BOZUK

Instantiable bir sınıfı extend edip equals'a yeni bir değer alanı ekleyerek sözleşmeyi koruyan bir çözüm yoktur (Effective Java, Item 10). getClass() != o.getClass() kontrolü simetriyi kurtarır ama LSP'yi bozar: bir Money alt sınıfı (ör. Hibernate proxy'si) hiçbir zaman bir Money'e eşit olmaz. Çıkış yolu yine composition'dır: TaggedMoney, bir Money alanı tutsun ve Money'i extend etmesin. Value object sınıflarını final (ya da record) yapmak bu problemi baştan önler.

Money örneğindeki compareTo ve stripTrailingZeros bilinçli seçimlerdir: new BigDecimal("1.0").equals(new BigDecimal("1.00")) false döner, çünkü BigDecimal.equals hem değeri hem scale'i karşılaştırır. Bu yüzden HashSet<BigDecimal> içinde 1.0 ve 1.00 iki ayrı eleman olur, TreeSet<BigDecimal> ise (compareTo kullandığı için) tek eleman tutar. Para tutarlarını karşılaştırırken compareTo(...) == 0 kullanılır. Hash'te de aynı normalizasyon yapılmazsa equals true dönen iki nesne farklı hash alır ve sözleşme bozulur.

JPA entity'lerinde equals/hashCode

Entity'lerde DB'nin ürettiği id (@GeneratedValue) üzerinden hashCode yazmak tehlikelidir: persist öncesi id null, sonrası doludur. Entity'yi bir HashSet'e koyup sonra persist edersen hash'i değişir ve yukarıdaki tuzağa düşersin. Yaygın iki çözüm var:

  1. Değişmeyen bir iş anahtarı varsa (sku, orderNumber, Hibernate'te @NaturalId) equals ve hashCode doğrudan ona dayanır. En temiz çözüm budur.
  2. İş anahtarı yoksa equals id üzerinden yazılır ama id null iken hiçbir nesne başkasına eşit sayılmaz, hashCode ise sabit döner:
java
@Entity
public class Shipment {

    @Id @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Shipment other)) return false;   // instanceof: Hibernate proxy alt sınıfıyla da çalışır
        return id != null && id.equals(other.getId());      // persist edilmemiş nesne sadece kendisine eşit
    }

    @Override
    public int hashCode() {
        return Shipment.class.hashCode();                   // sabit: persist öncesi ve sonrası aynı
    }

    public Long getId() { return id; }
}

Sabit hashCode bir entity tipi için tüm nesneleri aynı bucket'a koyar. Bu, binlerce entity'yi tek bir HashSet'te tutmuyorsan sorun değildir ve entity koleksiyonları zaten büyük olmamalıdır. getClass() yerine instanceof ve alan yerine getter (other.getId()) kullanılmasının nedeni, Hibernate'in lazy proxy'lerinin entity'nin alt sınıfı olması ve alanlarının proxy üzerinde boş olmasıdır.

Id'yi uygulama tarafında üreten entity'lerde (ör. UUID.randomUUID() ile factory metodunda) bu problem yoktur. Id oluşturma anında atanır, hiç değişmez ve equals/hashCode doğrudan id'ye dayanabilir. flash-sale-outbox-demo projesindeki Order entity'si id'yi Order.create(...) içinde atar. Bu yüzden id üzerinden yazılacak bir equals/hashCode orada güvenli olurdu.

Lombok'un @Data ve @EqualsAndHashCode annotation'ları varsayılan olarak tüm alanları kullanır. Entity'de bu, mutable alanlar ve lazy ilişkiler demektir: hash değişir, toString/hashCode lazy koleksiyonları yükleyip LazyInitializationException ya da çift yönlü ilişkilerde StackOverflowError üretir. Entity'lerde @Data kullanma.

Quiz'deki sorular: çözüm ve nedenleri

j1: Overloading, overriding ve static: ekrana ne yazar?

Senaryo: Payment sınıfında pay(Object), pay(String) ve static type() var. CardPayment sadece pay(Object)'i override ediyor ve kendi static type()'ını tanımlıyor.

java
Payment p = new CardPayment();
Object arg = "TR123";
p.pay(arg);                    // ?
p.pay("TR123");                // ?
System.out.println(p.type());  // ?

Doğru cevap: C-Object P-String Payment.

Her satırı iki aşamalı modelle çöz:

  1. p.pay(arg): Derleme anı. p'nin statik tipi Payment, arg'ın statik tipi Object. Payment'ta Object alan tek uygun imza pay(Object). Derleyici bunu yazar. Çalışma anı: nesne CardPayment, pay(Object)'in en alttaki override'ı CardPayment'ta. Sonuç: C-Object. arg'ın çalışma anında bir String olması önemsizdir, çünkü argümanlar dispatch'e katılmaz (single dispatch).
  2. p.pay("TR123"): Derleme anı. Argümanın statik tipi String. İki aday var, pay(String) daha spesifik. Çalışma anı: CardPayment pay(String)'i override etmemiş, yani Payment'taki gövde çalışır. Sonuç: P-String.
  3. p.type(): Static metot. Derleyici çağrıyı referansın statik tipine göre Payment.type() olarak yazar. CardPayment.type() onu gizler, override etmez. Sonuç: Payment.

Yanlış seçenekler:

  • C-Object C-Object Card: İkinci çağrıda pay(String) seçilir ve ezilmemiştir. Static metot da polymorphic değildir.
  • P-String P-String Payment: Overload seçimi argümanın çalışma anı tipine göre yapılsaydı doğru olurdu. Yapılmaz, arg'ın statik tipi Object.
  • C-Object P-String Card: İlk iki kısım doğru, ama static metotlar dynamic dispatch'e girmez.

Arkadaki kavram ve production karşılığı: Static binding ile dynamic dispatch ayrımı. Production'da en sık karşılığı Spring proxy'leridir: proxy bir alt sınıf olduğu için sadece dynamic dispatch'e giren metotlarda (public/protected, final olmayan instance metotları) araya girebilir. İkinci karşılığı, bir Object parametresi alan genel bir API'ye (eventPublisher.publish(Object event)) özel bir overload eklemenin, çağıranın statik tipine göre sessizce farklı davranmasıdır. Overload yerine farklı metot isimleri ya da pattern matching tercih edilmelidir.

j2: Ödeme yöntemlerini modelliyoruz: abstract class mı, interface mi?

Senaryo: Kart, havale ve cüzdan ödemeleri. Ortak pay() davranışı, bazılarında ortak doğrulama/loglama kodu var. Yeni ödeme tiplerini sadece ödeme ekibi ekleyebilmeli.

Doğru seçenekler: a, c, d, f.

  • (a) Doğru: Interface'lerde default ve static metot Java 8+, private metot Java 9+. Default metotların asıl amacı mevcut implementasyonları kırmadan interface'i evrimleştirmektir (Collection.stream()).
  • (c) Doğru: Abstract class constructor ve state içerebilir. Java'da sınıf seviyesinde tekli, tip (interface) seviyesinde çoklu kalıtım vardır. Durum (state) çoklu kalıtımı yasak, davranış çoklu kalıtımı (default metotlar) kontrollü olarak serbesttir.
  • (d) Doğru, bir nüansla: İki interface aynı imzalı default metot getirirse sınıf metodu override etmek zorundadır ve A.super.validate() ile seçim yapabilir. Nüans: Çakışma her zaman override gerektirmez. Üst sınıfta somut bir metot varsa o kazanır (class wins). Interface'lerden biri diğerini extend ediyorsa daha spesifik olanın default'u kazanır. Override zorunluluğu sadece gerçek belirsizlikte doğar.
  • (f) Doğru: sealed interface PaymentMethod permits Card, Transfer, Wallet (Java 17) implementasyonları sınırlar. "Sadece ödeme ekibi ekleyebilsin" gereksiniminin derleyici tarafından uygulanan karşılığı budur. Java 21'de pattern switch, sealed bir tip üzerinde tüm durumları ele almazsa (ve default yoksa) derleme hatası verir.

Yanlış seçenekler:

  • (b) Yanlış: Interface alanları örtük olarak public static final'dır. Implementasyon başına kopya yoktur. State gerekiyorsa abstract class veya composition gerekir.
  • (e) Yanlış: Abstract class hem abstract hem somut metot içerebilir. Hiç abstract metodu olmayan bir abstract class bile yazılabilir (sadece doğrudan new edilmesini engellemek için).

Bu senaryo için önerilen model: PaymentMethod sealed bir interface olsun, her tip bir record olsun. Ödemeyi yürüten taraf ayrı bir PaymentProcessor strategy interface'i olsun ve ortak doğrulama/audit akışı AbstractPaymentProcessor içinde Template Method olarak dursun. Veri (ödeme yöntemi) ile davranışı (ödemeyi işleyen servis) ayırmak, hem kapalı hiyerarşinin exhaustiveness avantajını hem de Spring'de DI ile strategy seçimini birlikte verir:

java
@Service
public class PaymentService {

    private final Map<Class<? extends PaymentMethod>, PaymentProcessor> processors;

    public PaymentService(List<PaymentProcessor> all) {          // Spring tüm implementasyonları enjekte eder
        this.processors = all.stream()
                .collect(Collectors.toUnmodifiableMap(PaymentProcessor::supports, p -> p));
    }

    public PaymentResult pay(PaymentMethod method, BigDecimal amount) {
        var processor = processors.get(method.getClass());
        if (processor == null) {
            throw new UnsupportedPaymentMethodException(method.getClass().getSimpleName());
        }
        return processor.pay(new PaymentRequest(method, amount));
    }
}

toUnmodifiableMap, aynı tipi destekleyen iki processor varsa açılışta IllegalStateException fırlatır. Bu, hatanın runtime'da değil uygulama başlarken yakalanması demektir.

j3: Sayaç neden 6 diyor? Kırılgan üst sınıf

Senaryo: CountingSet<E> extends HashSet<E>, add'de sayacı 1, addAll'da c.size() kadar artırıyor. addAll(List.of("a","b","c")) sonrası sayaç 6.

Neden 6: addAll sayaca 3 ekler ve super.addAll(c)'yi çağırır. HashSet, addAll'u kendisi tanımlamaz, AbstractCollection'dan miras alır. O implementasyon her eleman için add(e) çağırır. Bu çağrı this.add(e) olduğu ve this bir CountingSet olduğu için dynamic dispatch bizim override ettiğimiz add'e gider ve her eleman bir kez daha sayılır: 3 + 3 = 6.

Kök problem: Alt sınıf, üst sınıfın self-use davranışına (addAll'un add'i kullanması) bağımlı. Bu davranış HashSet'in sözleşmesinin parçası değil, bir implementasyon detayı. Sayacı düzeltmek için addAll override'ını silmek de bir çözüm gibi görünür ama bu sefer doğruluğun, HashSet'in gelecekte de addAll'u add üzerinden yapacağına bağlı olur. Kalıtım, encapsulation'ı deler: alt sınıfın doğruluğu, üst sınıfın gizli olması gereken implementasyonuna bağlanır.

Çözüm: Composition + forwarding. Yukarıdaki ForwardingSet + CountingSet kodu doğrudan bu sorunun düzeltmesidir. Wrapper herhangi bir Set'i sarar, iç detayına bağımlı değildir ve final olmayan, kalıtım için tasarlanmamış bir sınıftan türemez.

Değerlendirmede beklenen noktalar: self-use mekanizması (dynamic dispatch'in üst sınıf içinden alt sınıfa geri dönmesi), fragile base class, composition over inheritance (Effective Java Item 18), is-a/has-a ayrımı, ve kalıtımın ne zaman kabul edilebilir olduğu (kalıtım için tasarlanmış ve dokümante edilmiş sınıflar, Item 19).

Production karşılığı: Framework sınıflarından türeyip bir metodunu override etmek (ör. bir HTTP client'ın, bir Kafka listener container'ının veya bir Jackson serializer'ın iç metodunu) kütüphane sürüm yükseltmelerinde sessizce bozulan kodun en yaygın kaynağıdır. Framework'ün sunduğu genişleme noktaları (interceptor, customizer, decorator, callback interface) kullanılmalıdır. Bunlar kütüphane yazarının sözleşme olarak taahhüt ettiği yerlerdir.

j4: "Immutable" sipariş nesnesi gerçekten immutable mı?

Senaryo: final class Order içinde private final List<OrderLine> lines ve private final Date createdAt. Constructor parametreleri doğrudan atıyor, getter'lar iç referansları döndürüyor. Nesne thread'ler arasında paylaşılıyor ve cache'leniyor.

Doğru seçenekler: b, c, d, e.

  • (b) Doğru: Constructor'da List.copyOf(lines) alınmazsa çağıran taraf elindeki listeyi değiştirerek siparişi değiştirir. List.copyOf hem kopyalar hem değiştirilemez bir liste döner, null elemanları da reddeder.
  • (c) Doğru: getLines().add(...) iç listeyi dışarıdan değiştirir. Getter'dan iç referans sızıyor.
  • (d) Doğru: Date mutable'dır, getCreatedAt().setTime(0) siparişin tarihini değiştirir. Instant immutable'dır.
  • (e) Doğru: Immutability derindir (transitive). Liste değişmese bile OrderLine mutable ise fiyatı değişebilir.

Yanlış seçenekler:

  • (a) Yanlış: final sadece referansın yeniden atanmasını engeller, gösterdiği nesnenin içini korumaz.
  • (f) Yanlış: Record'lar bileşenleri kopyalamaz. Kopya compact constructor'da elle yapılmalıdır.

Bu soruda production'da neden kritik: Nesne cache'leniyor ve thread'ler arasında paylaşılıyor. Bir thread'in getLines().add(...) çağrısı, cache'ten aynı nesneyi okuyan tüm thread'lerin gördüğü siparişi değiştirir. Üstelik ArrayList senkronize olmadığı için eşzamanlı okuyan bir thread ConcurrentModificationException alabilir ya da tutarsız bir durum görebilir. "Cache'teki nesne immutable olmalı" kuralı bu yüzden vardır. Local cache'te (Caffeine) mutable nesne tutmak, iki isteğin birbirinin verisini bozmasına yol açar. Doğru hal, yukarıdaki Order record'u ve immutable OrderLine'dır.

q2: Sepetteki ürün kayboldu: equals/hashCode tuzağı

Senaryo: CartItem'ın equals/hashCode'u hem productId hem quantity alanını kullanıyor. Ürün HashSet'e eklendikten sonra increase() ile quantity artırılıyor.

java
cart.add(item);          // hash, quantity=1'e göre hesaplanıp node'a yazıldı
item.increase();         // quantity=2, hashCode artık farklı
cart.contains(item);     // (1)
cart.size();             // (2)
cart.remove(item);
cart.size();             // (3)

Doğru cevap: false, 1, 1.

  • (1) contains, yeni hash'i hesaplar. Bu hash büyük olasılıkla başka bir bucket'ı gösterir. Aynı bucket'a düşse bile HashMap önce node'da saklanan hash ile yeni hash'i karşılaştırır, eşleşmediği için equals'a gelmez. Nesne, kendisiyle == eşit olduğu halde bulunamaz.
  • (2) Nesne fiziksel olarak hâlâ tablodadır. size() 1'dir.
  • (3) remove da aynı arama yolunu kullanır, bulamaz, hiçbir şey silmez. size() hâlâ 1.

Yanlış seçenekler:

  • true, 1, 0: Nesne set'in içindedir ama arama hash üzerinden yapılır, eski bucket'a gidilmez.
  • true, 1, 1: contains da remove ile aynı hash aramasını yapar, true dönemez.
  • Derleme hatası: Java, hashCode'da hangi alanların kullanılacağı konusunda hiçbir derleme kuralı uygulamaz. Hata sessizdir ve çalışma anında ortaya çıkar. En tehlikeli yanı da budur.

Arkadaki kavram: Sözleşmenin yazılı kısmı "eşit nesnelerin hash'i eşit olmalı" der. Hash tabanlı koleksiyonların bir de yazılı olmayan (ama Javadoc'ta Map için uyarı olarak geçen) şartı var: key koleksiyonun içindeyken equals karşılaştırmasını etkileyecek şekilde değişmemelidir.

Düzeltme seçenekleri, iyiden en iyiye:

  1. equals/hashCode'u sadece productId'ye dayandır. Ürün kimliği değişmez, miktar değişebilir.
  2. CartItem'ı immutable record yap: record CartItem(long productId, int quantity) ve değişiklikte eskisini çıkar, yenisini ekle. Record'un ürettiği equals her iki alanı da kullandığı için mutasyon yolu zaten kapanır.
  3. Modeli değiştir: Sepet bir Map<Long, CartItem> olsun, key ürün id'si. Miktar değişikliği cart.merge(productId, ...) ile yapılır. Hem kimlik hem değer ayrımı açıkça modellenmiş olur.

Production karşılığı: Bu hata uzun ömürlü bir in-memory yapı içinde olursa (bir session cache'i, bir ConcurrentHashMap tabanlı kayıt defteri) hem mantıksal hata (ürün "kaybolur") hem de bellek sızıntısı (silinemeyen elemanlar birikir) üretir. Heap dump'ta beklenenden büyük bir HashMap$Node[] olarak görünür (bkz. Bölüm 3).

Üç seviyede cevap

Soru: "Overloading ile overriding arasındaki fark nedir?"

Overloading aynı isimde farklı parametreli metotlar yazmak, overriding ise alt sınıfta üst sınıfın metodunu yeniden yazmak. Overloading compile-time polymorphism, overriding runtime polymorphism. Override ederken @Override annotation'ı kullanırım.

Overload seçimi derleme anında, argümanların statik tiplerine göre yapılır. Derleyici en spesifik imzayı seçer, boxing ve varargs'a sadece gerekirse bakar. Override ise çalışma anında, nesnenin gerçek sınıfına göre seçilir. Override'da imza aynı olmalı, dönüş tipi covariant olabilir, erişim daraltılamaz ve daha geniş checked exception fırlatılamaz. Static, private ve final metotlar override edilmez. Static'te aynı imza method hiding olur ve referansın derleme tipine göre çağrılır. Field'lar da polymorphic değildir.

İki aşamalı bir seçim var. Derleyici imzayı seçip bytecode'a sabitler, JVM o imzanın alıcı nesnedeki en alt override'ını çalıştırır. Java single dispatch yaptığı için argümanın runtime tipi seçime hiç girmez. Object tipli bir değişkenle çağırırsan pay(String) overload'u hiç seçilmez. Argüman tipine göre davranış istiyorsam overload'a değil, sealed tip + pattern switch'e ya da visitor'a giderim.

Pratikte bunun iki etkisini görüyorum. Birincisi Spring proxy'leri: CGLIB alt sınıf ürettiği için @Transactional sadece dynamic dispatch'e giren metotlarda çalışır; final, private, static metotlarda ve self-invocation'da sessizce devre dışı kalır. İkincisi API tasarımı: aynı parametre sayısıyla overload etmek, çağıranın değişken tipine göre farklı kodun çalışmasına yol açar. Kod review'da overload yerine farklı isim isterim. Performans tarafında dynamic dispatch'in maliyeti JIT'in inline cache'leri sayesinde monomorphic çağrılarda pratikte sıfırdır. Bunu profil görmeden optimize etmem. Son olarak @Override'ı zorunlu tutarım. equals(Money) gibi yanlışlıkla overload edilmiş bir equals, HashSet'te sessizce çalışmayan bir bug'dır.

Junior cevap tanımı doğru veriyor ama "compile-time polymorphism" etiketini ezberden söylüyor. "Hangi bilgiye göre seçilir?" sorusuna geçmiyor. Object arg = "x"; p.pay(arg) sorulduğunda büyük olasılıkla yanılır. Mid cevap kuralları ve static hiding'i biliyor, kod okuma sorularını çözer. Senior cevap mekanizmayı (imza derleme anında sabitlenir, gövde runtime'da seçilir, single dispatch) bir kez anlatıp oradan production sonuçlarına geçiyor: Spring proxy'lerinin sınırları, overload'un API tasarımındaki riski ve alternatif olarak pattern matching. Performans konusunda yaygın bir mit'i doğru şekilde düzeltiyor.

Takip sorusu: "find(null) çağrısı find(String) ve find(Integer) overload'ları varken ne yapar? Peki find(String) ve find(Object) varken?"

Soru: "Abstract class ile interface arasında nasıl seçim yaparsın?"

Interface'te sadece abstract metotlar olur, abstract class'ta normal metotlar da olabilir. Bir sınıf birden fazla interface implemente edebilir ama tek bir sınıfı extend edebilir. Çoklu kalıtım gerekiyorsa interface kullanırım.

Java 8'den beri interface'lerde default ve static, Java 9'dan beri private metot var. Yani fark artık "gövde var mı" değil. Asıl fark state ve constructor: interface state tutamaz, abstract class tutar. Sözleşmeyi interface ile tanımlarım, çünkü DI, mock'lama ve birden fazla implementasyon için esnektir. Implementasyonlar arasında ortak state veya ortak akış varsa abstract class ile Template Method kurarım. İki interface aynı default metodu getirirse override edip X.super.m() ile çözerim.

Önce "çağıran neye bağımlı olacak?" diye sorarım. Çağıranlar her zaman bir interface görmeli. Abstract class bir implementasyon detayıdır ve dışarıya API olarak açılmaz. Ortak akış ve state varsa interface'in arkasına bir abstract class koyarım, ama akışı tanımlayan metodu final yaparım ki alt sınıflar validasyonu veya audit'i atlayamasın. Bunun Spring tarafında bir bedeli var: final metoda proxy giremez, transaction sınırını başka bir bean'e koymam gerekir.

Üçüncü bir seçenek de var: tip kümesi kapalıysa ve veriyse (ödeme sonucu, domain event, komut) sealed interface + record kullanırım. Java 21'de pattern switch exhaustiveness kontrolü yapıyor. Yeni bir sonuç tipi eklendiğinde ele alınmayan her yer derleme hatası veriyor ve bu, default dalına düşen sessiz bir bug'dan çok daha iyi. Davranışın tipe göre çoğaldığı yerde (kargo firmaları, kampanya kuralları) ise interface + strategy + Spring'in List<Strategy> enjeksiyonunu tercih ederim. Default metotları da sınırlı kullanırım. Amaçları API evrimi. Bir interface'e iş mantığı taşıyan default'lar eklemek, abstract class'ın sorunlarını interface'e taşır.

Junior cevap Java 8 öncesinin cevabı ve "interface'te sadece abstract metot olur" ifadesi artık yanlış. Mid cevap güncel ve doğru, pratik kuralı da veriyor. Senior cevabı ayıran üç şey var: kararı çağıranın bağımlılığı üzerinden vermesi, Template Method'u final ile korurken bunun Spring proxy'leriyle çeliştiğini bilmesi ve üçüncü seçenek olarak sealed hiyerarşiyi getirip ne zaman strategy, ne zaman sealed + switch kullanılacağını ayırması.

Takip sorusu: "Interface'e bir default metot eklemek mevcut implementasyonları hangi durumda yine de bozabilir?"

Soru: "Composition'ı neden inheritance'a tercih ederiz? Somut bir örnek ver."

Composition daha esnek olduğu için tercih edilir. Inheritance'da sınıflar birbirine sıkı bağlanır. "Has-a" ilişkisi varsa composition, "is-a" ilişkisi varsa inheritance kullanırım.

Inheritance alt sınıfı üst sınıfın implementasyonuna bağlar. Klasik örnek HashSet'ten türeyip add ve addAll'u sayan sınıf: addAll içeride add'i çağırdığı için her eleman iki kez sayılır. Çözüm, Set'i içeride tutup arayüzü implemente eden bir forwarding wrapper yazmak, yani decorator. Spring'de de servisler birbirini constructor'dan alır. Bu composition'dır.

Kalıtım hem tipi hem implementasyonu miras alır. Sorun implementasyon kısmında: alt sınıfın doğruluğu, üst sınıfın dokümante edilmemiş self-use davranışına bağlanır. HashSet örneğinde sayaç 6 çıkıyor ve daha kötüsü, JDK'nın bir sonraki sürümünde addAll'un içi değişirse kod değişmeden doğru ya da yanlış hale gelebiliyor. Üst sınıfa yeni bir metot eklenmesi de alt sınıfın invariant'ını atlatabilir. Java 8'de gelen default removeIf bunu Apache Commons'ın senkronize koleksiyon sarmalayıcısında yaptı.

Kalıtımı sadece üç durumda kullanırım: gerçek ve kalıcı bir is-a ilişkisi varsa ve LSP'yi bozmuyorsa, üst sınıf kalıtım için tasarlanmış ve self-use'unu dokümante etmişse, ya da iki taraf da benim kontrolümdeyse. Diğer durumlarda sınıfı final yaparım. Framework sınıflarından türeyip iç metot override etmek, sürüm yükseltmelerinde bozulan kodun en sık kaynağı. Framework'ün customizer, interceptor gibi genişleme noktalarını kullanırım. Composition'ın da bir zayıflığı var: sarılan nesne kendini callback olarak kaydederse decorator atlanır. Bunu bilirim ama nadiren pratik bir sorun olur.

Junior cevap sloganı tekrarlıyor ama "neden daha esnek" sorusuna mekanizma veremiyor. Mid cevap klasik örneği ve çözümü biliyor. Senior cevap problemi "self-use bir implementasyon detayıdır, sözleşme değildir" diye doğru yerden tanımlıyor, zaman boyutunu (sürüm değişince bozulma) ekliyor, kalıtımın kabul edilebilir olduğu şartları net sayıyor ve composition'ın da sınırını (SELF problemi) biliyor. Ezber bir "composition iyidir" yerine dengeli bir karar çerçevesi var.

Takip sorusu: "Stack extends Vector neden kötü bir tasarım? LSP açısından değerlendir."

Soru: "equals ve hashCode'u neden birlikte override ederiz? Mutable bir nesneyi HashMap key'i yaparsak ne olur?"

equals'ı override edersem hashCode'u da override etmeliyim, çünkü eşit nesnelerin hashCode'u eşit olmalı. IDE ile ikisini birlikte üretirim. Mutable nesneyi key yapmak iyi değildir.

HashMap önce hashCode ile bucket'ı bulur, sonra equals ile karşılaştırır. hashCode override edilmezse eşit iki nesne farklı bucket'lara düşer ve map onları farklı key sayar. Mutable key'in değeri değişip hashCode değişirse, nesne eski bucket'ta kalır ve get/contains/remove onu bulamaz. Key'leri immutable yaparım ya da equals/hashCode'u sadece değişmeyen alanlara dayandırırım. Record kullanırsam ikisi otomatik üretilir.

HashMap her node'da ekleme anındaki hash'i saklar ve aramada önce saklanan hash'i, sonra equals'ı karşılaştırır. Key'in hash'i koleksiyondayken değişirse nesne eski bucket'ta kalır, aramada bulunamaz ama iterasyonda görünür ve size'a sayılır. Uzun ömürlü bir map'te bu hem mantık hatası hem bellek sızıntısıdır. Sadece iterator üzerinden silinebilir, çünkü iterator saklanan hash'i kullanır.

Kurallarım şunlar: value object'ler record olur. Mutable nesnelerde equals/hashCode sadece kimlik alanına dayanır. JPA entity'lerinde DB'nin ürettiği id hash'te kullanılmaz, çünkü persist öncesi null. İş anahtarı yoksa id ile equals yazıp sabit hashCode dönerim, ya da id'yi uygulama tarafında UUID olarak üretirim. Entity'lerde Lombok @Data kullanmam. Bir de para tuzağı var: BigDecimal.equals scale'i de karşılaştırır, 1.0 ile 1.00 eşit değildir. Para karşılaştırmasında compareTo kullanırım, hash'te de stripTrailingZeros ile normalize ederim. Aksi halde HashSet<BigDecimal> ile TreeSet<BigDecimal> aynı veriye farklı cevap verir.

Junior cevap kuralı biliyor ama nedenini (HashMap'in arama algoritması) anlatmıyor ve "mutable key iyi değildir" diyip ne olacağını açıklamıyor. Mid cevap mekanizmayı ve çözümü doğru veriyor. Senior cevap saklanan hash ayrıntısıyla "neden bulunamıyor ama size'a sayılıyor" sorusunu tam cevaplıyor, sızıntı boyutunu ekliyor ve sözleşmeyi gerçek hayattaki en sık iki bağlama (JPA entity'leri ve BigDecimal) taşıyor. Fintech geçmişi olan bir adaydan BigDecimal scale tuzağını duymak güçlü bir sinyal.

Takip sorusu: "Bir alt sınıf equals'a yeni bir alan eklerse simetri neden bozulur? getClass() ile instanceof arasındaki seçim neyi değiştirir?"

Sorulmadı ama sorulabilir

Static nested class ve inner class

Bir sınıfın içinde static olmadan tanımlanan sınıf (inner class), dış sınıfın instance'ına örtük bir referans (this$0) taşır. Anonim sınıflar ve instance bağlamında tanımlanan lokal sınıflar da öyledir. JDK 18+ javac, dış instance'ı hiç kullanmayan (ve Serializable olmayan) inner class'larda bu alanı artık üretmez; ama dış sınıfın bir üyesine erişen her inner class onu taşır. Bu referans iki soruna yol açar: dış nesne gereğinden uzun yaşar (inner nesne bir cache'e veya başka bir thread'e verilirse dış nesne ve tuttuğu her şey de yaşar) ve serileştirmede dış nesne de serileştirilmeye çalışılır. Lambda'lar bu açıdan farklıdır: sadece gerçekten kullandıkları değişkenleri yakalarlar, this'i sadece instance üyelerine eriştiklerinde yakalarlar.

java
public class OrderService {
    private final byte[] bigBuffer = new byte[10_000_000];

    class Task implements Runnable {                                    // OrderService'i tutar (this$0)
        public void run() { System.out.println(bigBuffer.length); }
    }
    static class SafeTask implements Runnable { public void run() { } } // tutmaz
}

Mülakat cümlesi: "Dış instance'a ihtiyacım yoksa nested class'ı her zaman static yaparım. Inner class görünmez bir referans taşır ve sızıntı kaynağıdır."

Static factory ve Builder

Constructor yerine static factory metodu (Order.create(...), List.of(...), BigDecimal.valueOf(...)) isim taşıyabilir, her çağrıda yeni nesne üretmek zorunda değildir (cache'leyebilir), alt tip döndürebilir ve yarım kurulmuş nesnenin dışarı sızmasını engeller. flash-sale-outbox-demo'daki Order.create(command, now) bunun örneğidir: public setter yoktur, Order her zaman geçerli bir durumla doğar.

Çok sayıda opsiyonel parametre varsa Builder kullanılır. Record'larla birlikte builder gerekiyorsa genellikle küçük bir static builder ya da Lombok @Builder tercih edilir. Builder'ın build() metodu invariant kontrolünü yapmalıdır, aksi halde builder geçersiz nesne üretmenin yeni yolu olur.

Mülakat cümlesi: "Nesne her zaman geçerli doğmalı. Setter'lı JavaBean yerine factory veya builder ile kurar, invariant'ı tek bir yerde kontrol ederim."

Anemic ve rich domain model

Anemic model, entity'lerin sadece getter/setter taşıdığı, tüm kuralların servislerde olduğu modeldir. order.setStatus(SHIPPED) her yerden çağrılabilir ve "sadece ödenmiş sipariş kargolanabilir" kuralı servislere dağılır. Rich model'de durum geçişleri entity'nin metotlarıdır ve kuralları kendisi korur:

java
public void ship(TrackingNumber tracking) {
    if (status != OrderStatus.PAID) {
        throw new IllegalOrderStateException(id, status, OrderStatus.SHIPPED);
    }
    this.status = OrderStatus.SHIPPED;
    this.trackingNumber = tracking;
}

Bu, encapsulation'ın gerçek anlamıdır: alanları private yapmak değil, invariant'ları nesnenin içinde tutmak (tell, don't ask). Spring/JPA projelerinde anemic model yaygındır ve basit CRUD için kabul edilebilir. Kuralların yoğun olduğu domain'lerde (ödeme, sipariş durum makinesi) rich model hataları azaltır.

Mülakat cümlesi: "Encapsulation getter/setter yazmak değil, nesnenin kendi invariant'ını korumasıdır. Durum geçişlerini entity'nin metotlarına koyarım."

Value object: Money

Para, bir BigDecimal'dan fazlasıdır: tutar + para birimi + yuvarlama kuralı. Bunu ayrı bir immutable tip yapmak, TL ile EUR'nun toplanması gibi hataları derleme veya oluşturma anında yakalar:

java
public record Money(BigDecimal amount, Currency currency) {

    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
    }

    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch: " + currency + " vs " + other.currency);
        }
        return new Money(amount.add(other.amount), currency);
    }
}

Compact constructor'da scale'in normalize edilmesi, record'un ürettiği equals'ın 10 ile 10.00'ı eşit saymasını sağlar. Ayrıca yuvarlama kuralının tek bir yerde olmasını garantiler.

Mülakat cümlesi: "Parayı BigDecimal + Currency taşıyan immutable bir value object olarak modellerim. Scale'i constructor'da normalize ederim ki equals ve hash tutarlı olsun."

Double dispatch: Visitor ve pattern matching

Java single dispatch yaptığı için "iki nesnenin çalışma anı tiplerine göre davranış" (ör. hangi ödeme yöntemi ile hangi kampanya tipi birleşiyor) doğrudan ifade edilemez. Klasik çözüm Visitor pattern'dir: nesne accept(visitor) içinde visitor.visit(this) çağırır, this'in statik tipi alt sınıfta bilindiği için doğru overload seçilir. Bu, overload'un derleme anı seçimini dynamic dispatch ile birleştirmektir. Java 21'de sealed hiyerarşi üzerinde pattern switch aynı işi çok daha az kodla yapar, hatta iki değeri birlikte eşleştirmek için bir record içinde birleştirilebilir: switch (new Pair(method, campaign)).

Mülakat cümlesi: "Visitor, single dispatch'in eksiğini overload + override ile kapatır. Java 21'de sealed tip + pattern switch bunu daha okunur şekilde çözer."

Liskov ve "optional operation"lar

List.of(...) bir List döner ama add çağrısında UnsupportedOperationException fırlatır. List interface'i bu metotları Javadoc'ta "optional operation" olarak işaretlediği için bu teknik olarak sözleşme ihlali değildir. Ama List alan ve içine ekleme yapan bir metoda değiştirilemez liste verirsen hatayı ancak çalışma anında görürsün. Kendi API'lerinde bu deseni kopyalamamak gerekir: bir tipin yapamayacağı bir işlem, o tipin sözleşmesinde olmamalı. Okuma ve yazma yeteneklerini ayrı interface'lere ayırmak (ör. OrderReader ve OrderWriter) bu yüzden daha sağlıklıdır.

Mülakat cümlesi: "Bir alt tip, üst tipin sözleşmesindeki bir işlemi exception ile reddediyorsa LSP'yi bozuyordur. Çözüm yetenekleri ayrı interface'lere bölmektir."

Sık yapılan hatalar

  • Overload seçiminin argümanın çalışma anı tipine göre yapıldığını sanmak. Seçim derleme anında, statik tipe göre yapılır. Davranışın runtime tipine göre değişmesi gerekiyorsa override ya da pattern matching kullan.
  • equals(MyType o) yazmak. Bu overload'dur, koleksiyonlar onu çağırmaz. İmza equals(Object o) olmalı ve üstünde @Override olmalı.
  • Static metodu instance üzerinden çağırıp polymorphic sanmak. Static metotlar gizlenir, referansın derleme tipine göre çağrılır. Her zaman sınıf adıyla çağır.
  • Constructor'dan override edilebilir metot çağırmak. Alt sınıf alanları henüz ilklendirilmemiştir. Metodu private/final yap ya da factory kullan.
  • final alanların sınıfı immutable yaptığını sanmak. final referansı sabitler, içeriği değil. Girişte ve çıkışta kopya al, bileşenleri immutable tipler (record, Instant, List.copyOf) seç.
  • Önce doğrulayıp sonra kopyalamak. Arada parametre değişebilir. Önce kopyala, sonra kopyayı doğrula.
  • Collections.unmodifiableList'i kopya sanmak. Bir görünümdür, altındaki liste değişirse o da değişir. Gerçek kopya için List.copyOf.
  • Record'un bileşenleri kopyaladığını sanmak. Record'lar sığ immutable'dır. Mutable bileşeni compact constructor'da kopyala.
  • Record'a hassas veri koyup toString'i bırakmak. Kart numarası log'a düşer. toString'i override edip maskele.
  • Mutable alanları hashCode'a katmak (IDE'nin "tüm alanlar" varsayılanı). Nesne koleksiyondayken değişirse kaybolur. Sadece değişmeyen kimlik alanlarını kullan.
  • Entity'de DB üretimli id'yi hashCode'da kullanmak veya @Data koymak. Persist öncesi ve sonrası hash değişir, lazy ilişkiler yüklenir. Sabit hash + id ile equals ya da iş anahtarı kullan.
  • Para tutarlarını BigDecimal.equals ile karşılaştırmak. Scale farkı yüzünden 1.0 != 1.00. compareTo(...) == 0 kullan veya scale'i normalize et.
  • Sealed hiyerarşide default dalı yazmak. Exhaustiveness kontrolünü kapatır, yeni tip sessizce default'a düşer. Tüm tipleri açıkça ele al.
  • Framework sınıflarından türeyip iç metot override etmek. Sürüm yükseltmesinde self-use değişir ve kod sessizce bozulur. Customizer, interceptor veya decorator kullan.

Hızlı tekrar

S: Overload ve override hangi anda ve hangi bilgiye göre seçilir?

C: Overload derleme anında, alıcının ve argümanların statik tiplerine göre seçilir ve bytecode'a sabitlenir. Override çalışma anında, alıcı nesnenin gerçek sınıfına göre seçilir. Argümanların runtime tipi hiçbir zaman seçime girmez (single dispatch).

S: Payment p = new CardPayment(); p.type() static bir metotsa hangisi çalışır?

C: Payment.type(). Static metotlar override edilmez, gizlenir (method hiding). Çağrı referansın derleme tipine göre invokestatic olarak üretilir.

S: Spring @Transactional hangi metotlarda sessizce çalışmaz ve neden?

C: final, private ve static metotlarda ve aynı sınıf içinden yapılan self-invocation'da. CGLIB proxy bir alt sınıftır, sadece override edilebilen ve proxy üzerinden çağrılan metotlarda araya girebilir.

S: Interface'e default metot neden eklendi?

C: Mevcut implementasyonları kırmadan interface'i evrimleştirmek için (ör. Collection.stream()). Default metot state tutamaz ve Object'in equals/hashCode/toString metotlarını override edemez.

S: İki interface aynı default metodu getirirse derleyici çakışmayı nasıl çözer?

C: Önce üst sınıftaki somut metot kazanır (class wins), sonra daha spesifik interface kazanır. Hâlâ belirsizse sınıf metodu override etmek zorundadır ve X.super.m() ile seçim yapabilir.

S: Sealed tip üzerinde pattern switch'te default yazmamanın faydası nedir?

C: Derleyici tüm izinli alt tiplerin ele alındığını kontrol eder. Yeni bir alt tip eklendiğinde ele alınmayan her switch derleme hatası verir. default yazmak bu kontrolü kapatır.

S: HashSet'ten türeyen sayaç sınıfı addAll(3 eleman) sonrası neden 6 der?

C: addAll, AbstractCollection'dan miras alınır ve her eleman için add çağırır. Dynamic dispatch bu çağrıyı alt sınıfın sayan add'ine gönderir. Self-use bir implementasyon detayıdır; çözüm composition + forwarding'dir.

S: final alan neyi garanti eder?

C: Referansın bir kez atanacağını ve (constructor'da this sızmadıysa) başka thread'lerin constructor'da atanan değeri göreceğini (JLS 17.5). Gösterilen nesnenin içinin değişmeyeceğini garanti etmez.

S: List.copyOf ile Collections.unmodifiableList arasındaki fark nedir?

C: List.copyOf değiştirilemez bir kopya döner ve null elemanı reddeder. unmodifiableList sadece yazmayı engelleyen bir görünümdür; altındaki liste değişirse görünüm de değişir.

S: Record'lar bileşenlerini kopyalar mı?

C: Hayır, record'lar sığ immutable'dır. Mutable bir bileşen (List) compact constructor'da elle kopyalanmalıdır. Dizi bileşenlerinde üretilen equals referans karşılaştırması yapar.

S: HashSet'teki nesnenin hashCode'u değişirse ne olur?

C: Nesne eski hash'le eski bucket'ta kalır. contains ve remove onu bulamaz ama size'a sayılır ve iterasyonda görünür. Sadece iterator üzerinden silinebilir. Uzun ömürlü koleksiyonda bellek sızıntısıdır.

S: new BigDecimal("1.0").equals(new BigDecimal("1.00")) ne döner?

C: false, çünkü equals scale'i de karşılaştırır. Değer karşılaştırması için compareTo(...) == 0 kullanılır. HashSet bu ikisini iki eleman, TreeSet tek eleman olarak tutar.

S: JPA entity'sinde equals/hashCode nasıl yazılır?

C: Değişmeyen bir iş anahtarı varsa ona göre. Yoksa equals id üzerinden (id null ise eşit değil) ve hashCode sabit yazılır. instanceof ve getter kullanılır ki Hibernate proxy'leriyle çalışsın. Lombok @Data kullanılmaz.

Kaynaklar