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.
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:
- 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. - 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:
- Faz 1: Boxing/unboxing ve varargs olmadan, sadece primitive genişletme (widening) ve alt tip ilişkisiyle uyan metotlar.
- Faz 2: Boxing/unboxing'e izin verilir.
- 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).
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
| Overloading | Overriding | |
|---|---|---|
| Tanım | Aynı isim, farklı parametre listesi | Alt 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üş tipi | Serbest (imzanın parçası değil) | Aynı ya da alt tip (covariant return, Java 5+) |
| Erişim belirleyici | Serbest | Daraltılamaz (public metot protected yapılamaz) |
| Checked exception | Serbest | Üst metottan daha geniş checked exception bildirilemez (daha dar ya da hiç olabilir; unchecked serbest) |
static metot | Overload edilebilir | Override 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ı
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:
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:
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:
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ı:
finalmetot override edilemez: proxy araya giremez. Annotation sessizce etkisiz kalır.privatemetot override edilemez: aynı sonuç.staticmetot hiç dispatch edilmez: aynı sonuç.- Aynı sınıf içinden
this.save()çağrısı proxy'ye uğramaz (self-invocation):thisgerç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.
| Interface | Abstract class | |
|---|---|---|
| Instance state | Yok. Alanlar örtük olarak public static final (sabit) | Var |
| Constructor | Yok | Var (alt sınıflar super(...) ile çağırır) |
| Çoklu kullanım | Bir sınıf N interface implemente eder | Bir sınıf sadece bir sınıfı extend eder |
| Metot gövdesi | default ve static (Java 8+), private (Java 9+) | Her türlü |
| Erişim | Abstract ve default metotlar her zaman public | Her erişim seviyesi |
| Tipik kullanım | Sözleşme, DI, test'te mock, strategy | Template 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, amaArrayList.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'inequals,hashCodeveyatoStringmetotlarını override edemez. Bu bir derleme hatasıdır. Gerekçe "class wins" kuralı: bu metotların her sınıftaObject'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:
- Class wins: Üst sınıf zincirinde (abstract dahil) somut bir metot varsa o kazanır.
- Daha spesifik interface kazanır:
B extends Aise ve ikisi de aynı default metodu tanımlıyorsaB'ninki seçilir. - Hâlâ belirsizlik varsa sınıf metodu override etmek zorundadır. İstediği üst implementasyonu
X.super.m()ile çağırabilir.
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:
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.
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
Refundeddiye yeni bir tip eklendiğinde bu switch derleme hatası verir. Hatayı production'da değil, derleme anında yakalarsın. defaultyazmak bu güvenceyi öldürür.default -> throw ...yazarsan yeni tip sessizce default'a düşer. Sealed hiyerarşidedefaultyazmamak bilinçli bir tercihtir.- Guard'lı case tek başına kapsamaz.
case Declined d when d.retryable()tümDeclined'ları kapsamadığı için altında koşulsuz bircase Declined dgerekir. - Dominance: Daha genel bir case, daha özel bir case'in üstüne yazılırsa (koşulsuz
case Declined dguard'lı olanın üstünde) derleme hatası alırsın, çünkü alttaki case'e asla ulaşılamaz. case null: Pattern switch'tenulliç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
MatchExceptionfı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'dekiremoveIfgibi 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 defaultremoveIf, 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
// 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
- Durumu değiştiren hiçbir metot (setter,
add,increase) yok. - Sınıf genişletilemez:
finalsı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. - Tüm alanlar
private final. - Mutable bileşenler için girişte ve çıkışta savunmacı kopya (defensive copy).
- 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
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öntem | Kopya mı? | Altta değişiklik yansır mı? | null eleman |
|---|---|---|---|
List.of(...) | Yeni liste | Hayır | Reddeder (NPE) |
List.copyOf(c) | Kopya (kaynak zaten unmodifiable List.of türündense aynı nesneyi dönebilir) | Hayır | Reddeder |
stream().toList() (Java 16+) | Yeni liste | Hayır | Kabul eder |
Collections.unmodifiableList(x) | Hayır, görünüm | Evet, x değişirse görünüm de değişir | Kabul 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:
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çinObjects.equalsmantığıyla çalışır. Diziler için bu referans eşitliğidir.record Hash(byte[] value)iki ayrı ama içeriği aynı dizi içinfalsedöner. YaListkullan ya daequals/hashCode'uArrays.equalsile kendin yaz. toStringher 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'lardatoString'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.
hashCodedeğ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.
StringileStringBuilderiliş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)isey.equals(x). - Geçişlilik:
x.equals(y)vey.equals(z)isex.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 nesneninhashCode'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:
put(key, value)çağrısındakey.hashCode()hesaplanır ve yüksek bitler alçak bitlere karıştırılır:h ^ (h >>> 16).- Bucket indeksi
(n - 1) & hashile bulunur (ntablo boyutu, her zaman 2'nin kuvveti). - Bucket'a bir
Node(hash, key, value, next)eklenir. Hash değeri node'un içinde saklanır. get(key)/contains(key)aynı hesaplamayı yapar, ilgili bucket'a gider ve her node için öncenode.hash == hash, sonranode.key == key || key.equals(node.key)kontrol eder.- Tablo büyüdüğünde (varsayılan load factor 0.75) node'lar saklanan hash'e göre yeni tabloya dağıtılır.
- 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ğılanhashCode'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/hashCodesadece 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
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:
- Değişmeyen bir iş anahtarı varsa (
sku,orderNumber, Hibernate'te@NaturalId)equalsvehashCodedoğrudan ona dayanır. En temiz çözüm budur. - İş anahtarı yoksa
equalsid üzerinden yazılır ama id null iken hiçbir nesne başkasına eşit sayılmaz,hashCodeise sabit döner:
@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.
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:
p.pay(arg): Derleme anı.p'nin statik tipiPayment,arg'ın statik tipiObject.Payment'taObjectalan tek uygun imzapay(Object). Derleyici bunu yazar. Çalışma anı: nesneCardPayment,pay(Object)'in en alttaki override'ıCardPayment'ta. Sonuç:C-Object.arg'ın çalışma anında birStringolması önemsizdir, çünkü argümanlar dispatch'e katılmaz (single dispatch).p.pay("TR123"): Derleme anı. Argümanın statik tipiString. İki aday var,pay(String)daha spesifik. Çalışma anı:CardPaymentpay(String)'i override etmemiş, yaniPayment'taki gövde çalışır. Sonuç:P-String.p.type(): Static metot. Derleyici çağrıyı referansın statik tipine görePayment.type()olarak yazar.CardPayment.type()onu gizler, override etmez. Sonuç:Payment.
Yanlış seçenekler:
C-Object C-Object Card: İkinci çağrıdapay(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 tipiObject.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
defaultvestaticmetot Java 8+,privatemetot 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 (vedefaultyoksa) 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
newedilmesini 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:
@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.copyOfhem 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:
Datemutable'dır,getCreatedAt().setTime(0)siparişin tarihini değiştirir.Instantimmutable'dır. - (e) Doğru: Immutability derindir (transitive). Liste değişmese bile
OrderLinemutable ise fiyatı değişebilir.
Yanlış seçenekler:
- (a) Yanlış:
finalsadece 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.
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 bileHashMapönce node'da saklanan hash ile yeni hash'i karşılaştırır, eşleşmediği içinequals'a gelmez. Nesne, kendisiyle==eşit olduğu halde bulunamaz. - (2) Nesne fiziksel olarak hâlâ tablodadır.
size()1'dir. - (3)
removeda 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:containsdaremoveile aynı hash aramasını yapar,truedö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:
equals/hashCode'u sadeceproductId'ye dayandır. Ürün kimliği değişmez, miktar değişebilir.CartItem'ı immutable record yap:record CartItem(long productId, int quantity)ve değişiklikte eskisini çıkar, yenisini ekle. Record'un ürettiğiequalsher iki alanı da kullandığı için mutasyon yolu zaten kapanır.- Modeli değiştir: Sepet bir
Map<Long, CartItem>olsun, key ürün id'si. Miktar değişikliğicart.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?"
seviye: Başlangıç etiketler: [oop, polymorphism] kaynak: Tahmini
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?"
seviye: Başlangıç etiketler: [oop, interface, abstract-class] kaynak: Tahmini
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."
seviye: Orta etiketler: [oop, composition] kaynak: Tahmini
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?"
seviye: Orta etiketler: [equals-hashcode, hashmap] kaynak: Tahmini
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.
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:
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:
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. İmzaequals(Object o)olmalı ve üstünde@Overrideolmalı.- 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.
finalalanların sınıfı immutable yaptığını sanmak.finalreferansı 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çinList.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@Datakoymak. 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.equalsile karşılaştırmak. Scale farkı yüzünden1.0 != 1.00.compareTo(...) == 0kullan veya scale'i normalize et. - Sealed hiyerarşide
defaultdalı 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
- JLS 21 §15.12: Method Invocation Expressions: Overload seçiminin üç fazı ve "en spesifik metot" kuralının birincil kaynağı.
- JLS 21 §8.4.8: Inheritance, Overriding, and Hiding: Override, hiding ve interface default metot kurallarının tam tanımı.
- JLS 21 §17.5: final Field Semantics: Immutable nesnelerin neden senkronizasyonsuz paylaşılabildiğinin dayanağı.
- JEP 409: Sealed Classes: Sealed tiplerin kuralları ve tasarım gerekçesi.
- JEP 441: Pattern Matching for switch: Exhaustiveness, guard'lar, dominance ve
MatchException. - JEP 440: Record Patterns: Record'ların switch ve instanceof içinde bileşenlerine ayrılması.
- JEP 395: Records: Record'ların neyi ürettiği, neyi üretmediği (sığ immutability).
- Object (Java 21 API): equals ve hashCode sözleşmesinin resmi metni.
- Effective Java, 3rd Edition (Joshua Bloch): Item 10-11 (equals/hashCode), 17 (immutability), 18-19 (composition, kalıtım için tasarım), 50 (savunmacı kopya) bu bölümün temelidir.
- Hibernate ORM 6.2 Final: Record'ların embeddable olarak desteklenmesi.