Skip to main content
Yazılım Tasarımı•12 dk okuma

OOP: Nesne Yönelimli Programlama: Abstraction (Soyutlama) Rehberi

Mustafa Kürşad BaşerMustafa Kürşad BAŞER
••

Seri: Nesne Yönelimli Programlama · Bölüm 5/5

OOP: Nesne Yönelimli Programlama: Abstraction (Soyutlama) Rehberi

Abstraction (Soyutlama) Nedir?

Özet (TL;DR)

  • Soyutlama, bir şeyin ne yaptığını anlatıp nasıl yaptığını saklamaktır. Kapsülleme durumu korur, soyutlama karmaşıklığı gizler.
  • Abstract sınıf ortak durum ve ortak kod taşıyabilir; arayüz sadece sözleşme sunar. Java’da bir sınıf tek bir sınıftan türer ama istediği kadar arayüz uygulayabilir.
  • Java 8 ile gelen default metotlar arayüzlere davranış ekleyebilme imkanı verdi ve iki yapı arasındaki farkı daralttı.
  • Aralarında ilişki olmayan iki arayüzden aynı imzalı default metot gelirse Java derleme hatası verir; ilişkili olanları ise kendi çözer. Çözüm, açıkça override edip Arayuz.super.metot() ile hangisini istediğinizi söylemektir.
  • Her soyutlama bir miktar sızdırır. Erken kurulan, tek uygulaması olan soyutlama ise çözdüğünden fazla karmaşıklık üretir.

Bir çağrının ne yaptığını anlamak için üç dosya gezdiğiniz oldu mu? siparisiOlustur metodundan SiparisServisine, oradan SiparisIsleyici arayüzüne, oradan da tek uygulamasına. Üç durak sonra ortaya çıkan şey, en baştaki metodun doğrudan yapabileceği bir iş.

Soyutlama işte bu iki uç arasında bir yerde duruyor. Az yaparsanız kodunuz somut sınıflara çakılı kalır, fazla yaparsanız yukarıdaki gezintiye çıkarsınız. Bu yazı o dengenin nerede olduğuna dair.

Serinin son yazısındayız. Sınıf, nesne, kalıtım, kapsülleme ve çok biçimlilik konularını daha önce ele almıştım; bu yazıda hem soyutlamayı hem de altı kavramın birlikte nasıl çalıştığını göreceğiz.

Soyutlama ile kapsülleme aynı şey değil

İki kavram sürekli birbirine karıştırılıyor, çünkü ikisi de "gizlemek" fiiliyle anlatılıyor. Gizledikleri şey farklı.

Kapsülleme durumu gizler. Amacı, nesnenin geçersiz bir duruma düşmesini engellemek. Bir banka hesabının bakiyesini private yapıp değişikliği paraCek metoduna bağlamanız kapsüllemedir; konuya serinin kapsülleme yazısında girmiştim.

Soyutlamanın derdi ise karmaşıklık. List arayüzünü kullanırken elemanların dizide mi bağlı listede mi tutulduğunu bilmezsiniz, bilmeniz de gerekmez; sizi ilgilendiren tek şey add ve get metotlarının verdiği söz.

Kısaca: kapsülleme "bunu bozamazsın" der, soyutlama "bunu bilmene gerek yok" der. İkisi birlikte çalışır ama farklı problemleri çözer.

Abstract Class

Abstract sınıf, doğrudan nesnesi üretilemeyen ve yalnızca türetilmek için var olan sınıftır. İçinde hem gövdesi olan metotlar hem de gövdesiz bırakılıp alt sınıfa devredilen metotlar bulunabilir.

java
public abstract class Rapor {    private final String baslik;    private final LocalDateTime olusturulma;     protected Rapor(String baslik) {        if (baslik == null || baslik.isBlank()) {            throw new IllegalArgumentException("Başlık zorunlu");        }        this.baslik = baslik;        this.olusturulma = LocalDateTime.now();    }     // Her rapor tipi kendi biçimini bilir.    public abstract String uret();     // Ortak davranış burada bir kez yazılır.    protected String basligiBicimlendir() {        return "[" + olusturulma.toLocalDate() + "] " + baslik;    }}

Bu sınıf iki iş yapıyor. uret metodunu gövdesiz bırakarak alt sınıfları onu yazmaya zorluyor. basligiBicimlendir metodunu ve baslik alanını da bir kez tanımlayıp bütün alt sınıflara devrediyor. Yani hem sözleşme hem ortak kod taşıyor; arayüzün yapamadığı da tam olarak bu ikincisi.

Kurucunun protected olmasına dikkat edin. Abstract bir sınıfın nesnesi zaten üretilemez, dolayısıyla bu teknik bir zorunluluk değil. Niyeti belli etmek için: bu kurucu yalnızca super(...) çağrısıyla, alt sınıftan kullanılacak.

Interface

Arayüz ise ne yapılabileceğini söyleyip nasıl yapılacağına hiç karışmayan saf bir sözleşmedir.

java
public interface Gonderilebilir {    void gonder(String mesaj);} public class Epostaci implements Gonderilebilir {    @Override    public void gonder(String mesaj) {        // SMTP üzerinden gönderim    }}

Aradaki en belirleyici fark Java’nın kalıtım kuralında saklı. Bir sınıf yalnızca tek bir sınıftan türeyebilir, ama istediği kadar arayüz uygulayabilir. Kalıtım kotanız bir tane; arayüz kotanız sınırsız.

Arayüzler nesneye ait durum tutamaz; içlerine yazdığınız alanlar örtük olarak public static final olur, yani sabit sayılır. Bir de sık denenen ama olmayan şey: equals, hashCode ve toString metotlarını arayüzde default olarak yazamazsınız, çünkü her sınıf onları zaten Objectten alır.

Hangisini seçmeli

Karar tek bir soruya bakarak verilebiliyor: paylaşmak istediğiniz şey ortak bir durum mu, yoksa yalnızca bir yetenek mi?

ÖlçütAbstract ClassInterface
Ortak alan (durum) taşır mıEvetHayır, sadece sabit
Kurucu metodu var mıVarYok
Aynı anda kaç tanesi kullanılabilirBir taneSınırsız
Erişim belirleyicileriprivate dahil hepsiVarsayılan public, Java 9’dan beri private metot da yazılabilir
Uygun olduğu ilişki"bir çeşididir""şunu yapabilir"
Tipik örnekAbstractListComparable, Runnable
Abstract class ile interface arasındaki pratik ayrımlar

İlişki adlarındaki fark yön gösterici. Kartal bir Kus çeşididir, bu kalıtımdır. Ama Kartal aynı zamanda Ucabilir yeteneğine sahiptir ve aralarında hiçbir akrabalık olmamasına rağmen Ucak sınıfı da o yeteneği taşır; işte bu ikincisi arayüzün alanına girer.

Kararsız kaldığınızda arayüzle başlayın; sonradan ortak koda ihtiyaç duyarsanız o arayüzü uygulayan bir abstract sınıf eklemek kolaydır, oysa kurulmuş bir kalıtım hiyerarşisini sökmek çok daha pahalıya patlar. Bir de üçüncü bir eksen var: uygulama kümesi kapalıysa, yani hangi sınıfların o tipi karşılayacağını siz belirliyorsanız, Java 17’den beri sealed arayüz kullanıp derleyiciye bu kapalılığı denetletebilirsiniz.

Java 8 denklemi değiştirdi

Yukarıdaki ayrım 2014’e kadar keskindi. Java 8, arayüzlere gövdeli metot yazabilme imkanı getirdi.

java
public interface Gonderilebilir {     void gonder(String mesaj);     // Uygulayan sınıflar yazmak zorunda değil.    default void topluGonder(List<String> mesajlar) {        mesajlar.forEach(this::gonder);    }}

Bunun asıl amacı tasarımı zenginleştirmek değildi, geriye dönük uyumu korumaktı. Collection arayüzüne stream() metodunu eklemek gerekiyordu ve o arayüzü uygulayan milyonlarca satır kod vardı. default olmasaydı Java 8’e geçen her proje derlenmeyi bırakırdı.

Yan etkisi de oldu: çoklu kalıtımın klasik problemi arayüzler üzerinden geri döndü. Java çoğu durumu kendi çözüyor. Biri diğerini genişleten iki arayüzden daha özel olan kazanır, bir üst sınıftan gelen somut metot da her zaman arayüz default’unu geçer. Ama aralarında hiçbir ilişki olmayan iki arayüzden aynı imzalı birer default metot gelirse ortada sıralama yoktur ve derleyici hata verir. Java bunu sessizce çözmek yerine kararı size bırakır:

java
interface A {    default String selam() { return "A"; }} interface B {    default String selam() { return "B"; }} class C implements A, B {    @Override    public String selam() {        return A.super.selam();   // hangisini istediğinizi açıkça yazarsınız    }}

Java 9 ile arayüzlere private metot da eklendi, böylece birden fazla default metodun paylaştığı yardımcı kod dışarıya sızmadan yazılabiliyor. Arayüzler yıllar içinde sözleşme olmaktan çıkıp davranış da taşıyan yapılara dönüştü. Yine de tek bir şeyi hala yapamıyorlar: nesneye ait durum tutmak. Karar verirken bakılacak asıl ölçüt bugün de bu.

Soyutlamanın bedeli

Soyutlama bedava değil ve bu, tanıtım yazılarında pek geçmez.

Joel Spolsky’nin 2002’de yazdığı bir kural var: bütün önemsiz olmayan soyutlamalar, bir dereceye kadar sızdırır. Detayı gizlersiniz ama detay bir gün kendini hatırlatır. Ağ üzerindeki bir dosyayı yerel dosya gibi okuyabilirsiniz, ta ki bağlantı kopana kadar. List arayüzü size tekdüze bir yüzey sunar, ama LinkedList üzerinde indeksle dolaşırsanız performans çöker. Soyutlama arkasındaki gerçeklik yok olmaz, sadece görünmez olur.

İkinci bedel daha yaygın: erken soyutlama. Tek uygulaması olan bir arayüz, kod tabanına gezinilecek fazladan bir dosya ekler ve karşılığında hiçbir esneklik vermez. "İleride başka bir sağlayıcı gelirse" diye kurulan katmanların çoğunda o sağlayıcı hiç gelmez.

Kullanışlı bir eşik var: ikinci gerçek uygulama ortaya çıkana kadar bekleyin. İki somut örneği yan yana gördüğünüzde aralarındaki ortak yüzeyin ne olduğunu tahmin etmeniz gerekmez, okuyabilirsiniz.

Altı kavram tek örnekte

Seriyi kapatırken hepsini bir arada görelim. Bildirim gönderen küçük bir tasarım, altı kavramın da yerini gösteriyor:

java
// SOYUTLAMA: çağıran taraf yalnızca bunu bilir.public interface Bildirim {    void gonder(String mesaj);} // SINIF: bildirim şablonu. KAPSÜLLEME: alanlar private, doğrulama kurucuda.public abstract class TemelBildirim implements Bildirim {    private final String alici;     protected TemelBildirim(String alici) {        if (alici == null || alici.isBlank()) {            throw new IllegalArgumentException("Alıcı zorunlu");        }        this.alici = alici;    }     protected String getAlici() {        return alici;    }} // KALITIM: ortak doğrulama ve alan buradan gelir.public class EpostaBildirimi extends TemelBildirim {     public EpostaBildirimi(String eposta) {        super(eposta);    }     @Override    public void gonder(String mesaj) {        System.out.println("E-posta -> " + getAlici() + ": " + mesaj);    }} public class SmsBildirimi extends TemelBildirim {     public SmsBildirimi(String telefon) {        super(telefon);    }     @Override    public void gonder(String mesaj) {        System.out.println("SMS -> " + getAlici() + ": " + mesaj);    }} // NESNE ve ÇOK BİÇİMLİLİK: aynı çağrı, farklı davranış.public class Uygulama {    public static void main(String[] args) {        List<Bildirim> kanallar = List.of(            new EpostaBildirimi("kullanici@ornek.com"),            new SmsBildirimi("+90 555 000 00 00")        );         kanallar.forEach(kanal -> kanal.gonder("Siparişiniz kargoya verildi."));    }}

main metodunda hangi kanalın kullanıldığına dair tek bir if yok. Yeni bir kanal eklemek, Bildirim arayüzünü uygulayan bir sınıf yazmaktan ibaret; mevcut hiçbir dosyaya dokunulmuyor. Altı kavramın birlikte ürettiği sonuç bu.

Sonuç

Soyutlama, nesne yönelimli programlamanın en kolay yanlış kullanılan kavramı. Az yaparsanız kod tabanınız somut sınıflara çakılı kalır; fazla yaparsanız üç dosya gezmeden tek bir satırın ne yaptığını anlayamazsınız.

Dengeyi bulmanın kestirme yolu yok, ama iki soru yardımcı oluyor. Bu detayı kullanan tarafın gerçekten bilmesi gerekiyor mu? Ve bu soyutlamanın bugün kaç uygulaması var?

Seri burada tamamlanıyor. Sınıf ve nesneyle başladık, kalıtımla devam ettik, kapsülleme ve çok biçimlilikle sürdürdük, soyutlamayla kapattık. Bu altı kavram tek başına iyi tasarım üretmiyor; onları ne zaman kullanmayacağınızı bilmek de gerekiyor. Bir sonraki adım için S-O-L-I-D Prensipleri yazısına göz atmanızı öneririm, orada bu kavramların hangi kurallar çerçevesinde bir araya geldiğini ele aldım.

Sıkça Sorulan Sorular

Soyutlama ile kapsülleme arasındaki fark nedir?
İkisi de gizleme yapar ama farklı şeyleri. Kapsülleme bir güvenlik önlemidir; nesnenin kurallarını dışarıdan çiğnenemez hale getirir. Soyutlama bir sadeleştirmedir; ilgilenilmesi gerekmeyen detayı kullanıcının önünden kaldırır. Bir sınıf kapsüllenmiş olup hiç soyutlanmamış olabilir, tersi de mümkün.
Abstract class mı interface mi kullanmalıyım?
Paylaşmak istediğiniz şey ortak alan ve ortak kodsa abstract class, yalnızca bir yetenekse interface. Java’da bir sınıf tek bir sınıftan türeyebildiği için kalıtımı gerçekten gerektiren durumlara saklayın. Kararsızsanız arayüzle başlayın; sonradan abstract sınıf eklemek, kurulmuş bir hiyerarşiyi sökmekten kolaydır.
Java 8 default metotlar geldikten sonra abstract class gereksiz mi kaldı?
Kalmadı. Arayüzler artık davranış taşıyabiliyor ama hala nesneye ait durum tutamıyor; alanları örtük olarak public static final oluyor. Ortak bir alanı ve onu doğrulayan bir kurucuyu paylaşmanız gerekiyorsa abstract class hala tek seçenek.
İki arayüzden aynı default metot gelirse ne olur?
Derleyici hata verir ve sizi seçim yapmaya zorlar. Metodu uygulayan sınıfta açıkça override edip Arayuz.super.metot() söz dizimiyle hangi sürümü istediğinizi belirtirsiniz. Java bu belirsizliği sessizce çözmeyi bilinçli olarak reddeder.
Her sınıf için arayüz yazmalı mıyım?
Hayır. Tek uygulaması olan bir arayüz esneklik değil, gezilecek fazladan bir dosya üretir. İkinci gerçek uygulama ortaya çıkana kadar bekleyin; o noktada soyutlamanın nasıl görünmesi gerektiğini tahmin etmek yerine bilerek tasarlarsınız.

Referanslar

Bloch, J. (2018). Effective Java (3rd ed.). Addison-Wesley. Madde 20 abstract sınıf yerine arayüzü tercih etmeyi savunuyor. Madde 21 bir uyarı: default metotlar geriye dönük uyum uğruna eklendi ve mevcut bir arayüze sonradan default metot eklemek, o arayüzü uygulayan her sınıfın değişmezlerini koruyacağını garanti edemediğiniz için risklidir. Madde 22 ise arayüzlerin yalnızca tip tanımlamak için kullanılması gerektiğini anlatıyor.

Ousterhout, J. (2018). A Philosophy of Software Design. Yaknyam Press. Derin ve sığ modül ayrımı üzerine kurulu; bu yazıdaki "tek uygulaması olan arayüz" eleştirisinin kaynağı da orası.

Gamma, E., Helm, R., Johnson, R. ve Vlissides, J. (1994). Design Patterns. Addison-Wesley. Template Method abstract sınıfın, Strategy ise arayüzün kanonik kullanımını gösterir.

Martin, R. C. (2017). Clean Architecture. Prentice Hall. Soyutlama sınırlarının bağımlılık yönünü nasıl belirlediği ve bir sınırın ne zaman çizilmeye değdiği üzerine.

Abstract Methods and Classes, Java Tutorials

Default Methods, Java Tutorials

Spolsky, J. (2002). The Law of Leaky Abstractions

Bu yazıyı paylaş

Mustafa Kürşad Başer
Yazar

Mustafa Kürşad Başer

Kıdemli Yazılım Mühendisi

Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.