İçeriği atlamak için "Enter"'a basın

Effective Java Madde 52: Aşırı Yüklemeyi (Overloading) Dikkatli Kullanın

Aşağıdaki program koleksiyonlar (collection) üzerinde gruplama yapmaya çalışmaktadır:

// Bozuk! Bu programın ne sonuç üreteceğini biliyor musunuz?
public class CollectionClassifier {
    public static String classify(Set<?> s) {
        return "Set";
    }
    
    public static String classify(List<?> lst) {
        return "List";
    }

    public static String classify(Collection<?> c) {
       return "Unknown Collection";
    }

    public static void main(String[] args) { 
        Collection<?>[] collections = {
            new HashSet<String>(),
            new ArrayList<BigInteger>(),
            new HashMap<String, String>().values()
        };

        for (Collection<?> c : collections) { 
            System.out.println(classify(c));
        }
    } 
}

Bu programın önce Set sonra da List ve Unknown Collection değerlerini üreteceğini düşünebilirsiniz ancak böyle olmaz. Üç kere Unknown Collection çıktısı üretir. Peki bu neden olmaktadır? Çünkü classify metodu aşırı yüklenmiştir ve bu durumda hangi metodun çalıştırılacağına derleme anında karar verilir. Döngüye giren her c referansı için derleme anındaki tür Collection<?> olmaktadır. Çalışma zamanındaki (runtime) tür bilgisi farklı olsa da bu aşırı yüklenmiş metotlardan hangisinin çalıştırılacağını etkilemez. Yani derleme anındaki tür Collection<?> olduğu için her seferinde classify(Collection c) metodu çağrılacaktır.

Bu programın davranışı biraz kafa karıştırıcı çünkü aşırı yüklenen metotlar arasında yapılan seçim statik, geçersiz kılınan (override) metotlar arasındaki seçim ise dinamik olarak yapılıyor. Geçersiz kılınan metotlar söz konusu olduğunda, çalışma zamanındaki tür bilgisine göre hareket edilerek hangi metodun çalıştırılacağı belirleniyor. Hatırlatmak gerekirse, bir metodun geçersiz kılınması üst türde var olan bir metodun aynı metot imzası kullanılarak alt bir türde yeniden tanımlanmasıdır. Böyle bir durumda eğer bir metot alt türden yaratılmış bir nesne üzerinden çağırılıyorsa, derleme anındaki tür bilgisi üst türe işaret etse de alt türde tanımlanmış olan metot çağrılacaktır. Bunu somutlaştırmak için aşağıdaki örneğe bakalım:

class Wine {
    String name() { 
        return "wine"; 
    }
}
class SparklingWine extends Wine {
    @Override 
    String name() { 
        return "sparkling wine"; 
    }
}
class Champagne extends SparklingWine {
    @Override
    String name() { 
        return "champagne"; 
    }
}
public class Overriding {
    public static void main(String[] args) {
        List<Wine> wineList = List.of(
            new Wine(), new SparklingWine(), new Champagne());
        
        for (Wine wine : wineList) { 
            System.out.println(wine.name());
        }
    }
}

Yukarıdaki örnekte name metodu Wine sınıfında tanımlanmış, SparklingWine ve Champagne sınıflarında ise geçersiz kılınmıştır. Burada program beklediğiniz gibi, önce wine sonra da sparkling wine ve champagne çıktılarını üretecektir. Derleme anında, döngüye giren her değişkenin türü Wine olmasına rağmen hangi metodun çalıştırılacağına çalışma zamanında karar verildiği için beklediğimiz çıktıları alıyoruz. Bunu aşırı yüklenen metotlarla kıyasladığımızda tam tersi bir davranışın sergilendiğini görebiliriz. Aşırı yükleme yaptığımız önceki kodda metot seçimi derleme anındaki tür bilgisine göre, geçersiz kılınmış metotlar kullandığımızda ise çalışma zamanındaki tür bilgisine göre yapıldı.

CollectionClassifier örneğinde yapılmak istenen şey de koleksiyonları çalışma zamanındaki tür bilgisine göre gruplamaktı. Ancak aşırı yükleme mekanizması bu imkanı bize vermiyor. Bunu düzeltmenin en iyi yolu ise aşırı yükleme kullanmak yerine tek bir classify metodu yazmak ve tür bilgisini metot içinde instanceof ile test etmek olurdu:

public static String classify(Collection<?> c) {
    return c instanceof Set  ? "Set" :
        c instanceof List ? "List" : "Unknown Collection";
}

Geçersiz kılınan metotların nasıl davrandığına aşina olan programcılar aşırı yüklemenin de benzer biçimde çalışacağını düşünebilirler. Ancak örneğimizden anlaşıldığı üzere bu beklenti gerçeği yansıtmamaktadır. Programcıların kafasını karıştıracak kodlar yazmak pek tavsiye edilmez, özellikle de API yazıyorsanız daha dikkatli olmanız gerekir. Eğer API kullanıcısı aşırı yüklenmiş birkaç metottan hangisinin çalıştırılacağını bilemiyorsa, büyük ihtimalle hatalı kod yazacaktır. Bu hatalar derleme anında değil çalışma anında ortaya çıkacak, hatanın bulunmasını zorlaştıracaktır. Bu sebeple aşırı yüklemenin kafa karışıklığına sebep olabilecek kullanımlarından kaçınmalıyız.

Aşırı yüklemenin kafa karıştırıcı kullanımlarının hangileri olduğu tartışmaya açık bir konudur. Temkinli sayılabilecek yaklaşımlar, aşırı yüklenen metotların parametre sayısının birbirinden farklı olması gerektiğini savunur. Eğer metotlar değişken sayılı parametre (varargs) kullanıyorsa hiç aşırı yükleme yapmamak daha garanti bir yoldur. (Madde 53 buna bir istisnadır) Bu tavsiyelere uyulursa programcılar hiçbir zaman hangi aşırı yüklenmiş metodun çağrılacağı konusunda bir tereddüt yaşamazlar. Ayrıca, bu tavsiyelere uymak çok zahmetli bir iş de değildir, en nihayetinde metotları aşırı yüklemek yerine farklı isimler kullanmak mümkündür.

Örneğin, ObjectOutputStream sınıfına bakalım. Bu sınıfta write metodunun farklı varyasyonları bütün temel türler için ayrı ayrı tanımlanmıştır. write metodunu aşırı yüklemek yerine, bütün bu varyasyonlara farklı isimler verilmiştir, writeBoolean(boolean), writeInt(int), ve writeLong(long) gibi. Bu isimlendirme modelini kullanmanın bir diğer avantajı da okuma yapan metotlarda benzer isimler kullanarak API’ın daha kolay anlaşılmasını sağlamaktır. ObjectInputStream sınıfı okuma görevini yapan, readBoolean(), readInt()vereadLong() metotları da tanımlamıştır.

Yapıcı metotlar için farklı isimler kullanmak gibi bir şansımız olmadığı için, birden fazla yapıcı metot tanımlamak istiyorsak aşırı yükleme yapmak zorundayız. Alternatif olarak yapıcı metotlar yerine statik fabrika metotları yazabiliriz. (Madde 1) Ancak aynı parametre sayısına sahip birden fazla yapıcı metot tanımlamamız gereken durumlar da karşımıza çıkacaktır. Bu yüzden bunu nasıl güvenli bir biçimde yapabileceğimizi öğrenmek önemlidir.

Aynı sayıda parametreye sahip yapıcı metotlar, kullanılan parametre türleri eğer birbirine dönüştürülemeyen türlerse kafa karışıklığına sebep olmaz. Bu durumda, derleme zamanında türlerin karışma ihtimali olmadığı için programcıların hata yapması zorlaşır. Örneğin, ArrayList sınıfının yapıcı metotlarından bir tanesi tek bir int parametresi, diğer bir yapıcı metot ise tek bir Collection parametresi kabul etmektedir. Böyle bir durumda ArrayList kullanıcılarının hangi yapıcı metodun çağrılacağına dair kafa karışıklığı yaşaması düşünülemez.

Java 5’den önce, temel türlerin referans türleriyle karışması mümkün değildi ancak autoboxing mekanizması ile bu problem ortaya çıkmaya başladı. Aşağıdaki programı inceleyelim:

public class SetList {
    public static void main(String[] args) {
    
        Set<Integer> set = new TreeSet<>(); 
        List<Integer> list = new ArrayList<>();
    
        for (int i = -3; i < 3; i++) { 
            set.add(i);
            list.add(i); 
        }

        for (int i = 0; i < 3; i++) { 
            set.remove(i);
            list.remove(i); 
        }

        System.out.println(set + " " + list); }
}

Bu program önce -3’den 2’ye kadar olan tamsayı değerlerini hem bir Set hem de bir List koleksiyonuna ekliyor. Daha sonra 0, 1 ve 2 değerlerini kullanarak Set ve List üzerinde remove metodunu çağırıyor ve silme işlemi yapıyor. Çoğu programcı burada 0, 1 ve 2 değerlerinin her iki koleksiyondan da silinmesini ve sonuç olarak [-3, -2, -1] [-3, -2, -1] çıktısının üretilmesini bekler. Ancak gerçekte olan bu değildir, bu program [-3, -2, -1] [-2, 0, 2] sonucunu üretecektir. Peki bu anlam veremediğimiz sorun nasıl ortaya çıktı?

Olan şey şu: set.remove(i) çağrıldığında aşırı yüklenen remove(E) metodu seçilmektedir. Burada E, Set koleksiyonunun eleman türü olan Integer olacaktır ve int türündeki i değişkeni autoboxing ile Integer türüne çevrilip işletilecektir. Bu sebeple set üzerinde çalıştırılan remove metodu doğru elemanları silmekte ve doğru sonucu üretmektedir. Ancak list.remove(i)çağrıldığında seçilen aşırı yüklenmiş metot remove(int i) olacaktır, bu metot ise i pozisyonundaki liste elemanını silmektedir. Silme yapılmadan önce [-3, -2, -1, 0, 1, 2] değerlerine sahip olan listemiz, sırayla ve tek tek sıfırıncı, birinci ve ikinci elemanlar silindiğinde [-2, 0, 2] elemanlarına sahip olmaktadır. Böylece gizemi çözmüş olduk. Bu sorunu çözmek için list.remove çağırırken i değerini Integer türüne kendimiz çevirebiliriz veya Integer.valueOf kullanabiliriz. Her iki durumda da program beklenilen [-3, -2, -1] [-3, -2, -1] sonucunu üretecektir:

for (int i = 0; i < 3; i++) { 
    set.remove(i);
    list.remove((Integer) i); // veya remove(Integer.valueOf(i)) 
}

Bir önceki örnekte beklenmedik bir sonuç üretilmesinin sebebi List<E> arayüzünde remove metodunun remove(E) ve remove(int) ile aşırı yüklenmesinden kaynaklanmaktadır. Java 5’den önce, üreysellik (generics) mekanizması bulunmadığından dolayı remove(E) yerine remove(Object) vardı. int temel türüyle Object türünün birbiri yerine kullanılması söz konusu olmadığı için bir problem olmuyordu. Ancak sonradan dile eklenen üreysellik ve autoboxing mekanizmaları bu sorunu beraberinde getirdi. Başka bir deyişle, bu mekanizmaları eklemek List arayüzüne hasar verdi. Java kütüphaneleri içerisinde bu şekilde hasar alan çok sayıda sınıf veya arayüz olmasa da, bu mekanizmalar dile eklendikten sonra aşırı yükleme yaparken daha dikkatli olmamız gerektiği ortadadır.

Java 8’le dile eklenen lambda ve metot referansları aşırı yükleme konusundaki kafa karışıklıklarını iyice artırdı. Aşağıdaki kod örneklerine bakalım:

new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);

Thread yapıcı metodu ile submit metodu aynı argümanla çağrılmasına rağmen birinci kod başarılı bir şekilde derlenmekte, ikincisi ise derleme hatası vermektedir. Halbuki hem Thread sınıfının yapıcı metodu, hem de submit metodu Runnable kabul edecek şekilde aşırı yüklenmiştir. Peki o zaman neden submit metodu derleme hatası veriyor? Bunun sebebi ilginç bir biçimde submit için Callable<T> kabul eden başka bir aşırı yüklenmiş metodun tanımlı olmasıdır. Thread yapıcı metodu içinse böyle bu durum yoktur.

Burada derleyicinin yaşadığı kafa karışıklığına anlam veremiyor olabilirsiniz. Tam olarak neden hata aldığımızın teknik açıklaması burada mevcut. Ancak bunu tam olarak anlamamıza aslında gerek yok. Önemli olan nokta şu: yapıcı metotları veya sınıf metotlarını aşırı yüklerken aynı parametre pozisyonuna farklı fonksiyonel arayüzler koyarsanız derleyicinin kafası karışabilir. Farklı fonksiyonel arayüzler derleyici tarafından tamamen farklı türler olarak algılanmazlar. Bu şekilde problemli bir aşırı yükleme yaptığınızda -Xlint:overloads kullanıldığında derleyici sizi uyacaktır.

Bunun gibi örnekleri çoğaltmak mümkün. Maalesef, detaya indikçe aşırı yüklenen metotların hangisinin seçilip işletileceğine dair kurallar son derece karmaşık hale gelmektedir ve her yeni Java versiyonu ile birlikte daha da kötüye gitmektedir. Bunun bütün detaylarını bilen programcı sayısı son derece azdır.

Bazı durumlarda bu maddede anlatılan kuralları ihmal etmeniz gerektiğini hissedebilirsiniz, özellikle de var olan sınıflara ekleme yapmaya çalışırken. Örneğin, Java 4’den beri contentEquals(StringBuffer) metoduna sahip olan String sınıfını ele alalım. Java 5’de StringBuffer, StringBuilder, String, CharBuffer, ve başka benzer türlere ortak bir arayüz olması bakımından CharSequence eklenmiştir. Bu yapılırken aynı zamanda String sınıfına CharSequence kabul eden aşırı yüklenmiş yeni bir contentEquals metodu eklenmiştir.

Bu ekleme bu maddede anlatılan kuralları açıkça ihmal etmesine rağmen hiçbir soruna yol açmaz çünkü aynı referans üzerinden çağrıldığında yeni metot eskisiyle aynı şeyi yapmaktadır. Programcı aşırı yüklenmiş metotlardan hangisinin çalıştırılacağını bilemeyebilir ancak bunun bir önemi yoktur, çünkü her iki metot da aynı şekilde davranacaktır. Bunu yapmanın yolu da uygun biçimde bir metodu diğerine iletim yapacak biçimde yazmaktır:

// Metodun tek yaptığı gelen isteği diğerine iletmektir
public boolean contentEquals(StringBuffer sb) {
    return contentEquals((CharSequence) sb);
}

Java kütüphaneleri bu maddede anlatılan kurallara büyük ölçüde uysa da birkaç tane istisna bulunmaktadır. Örneğin, yine String sınıfı valueOf(char[]) ve valueOf(Object) olmak üzere iki tane statik fabrika metodu tanımlamıştır ve bunlar aynı referans üzerinden çağrıldıklarında çok farklı davranışlar sergilemektedir. Bunun mantıklı bir açıklaması yoktur ve ciddi kafa karışıklığına yol açabilecek anormal bir istisna olarak görülmelidir.

Özetlemek gerekirse, metotları aşırı yükleyebiliyor olmamız bunu gerçekten yapmamız gerektiği anlamına gelmez. Metot imzalarında aynı sayıda parametre olarak şekilde aşırı yükleme yapmaktan kaçınmalıyız. Bazı durumlarda, özellikle de yapıcı metotlarla çalışırken bu mümkün olmayabilir. Bu durumlarda en azından aşırı yüklenmiş farklı metotlarda birbirilerine dönüştürülebilen türler kullanmamalıyız. Eğer bunu da yapamıyorsak, karışıklığa yol açabilecek aşırı yüklenmiş metotları aynı davranışı gösterecek şekilde yazmalıyız. Bunlara uymadığımız taktirde programcılar bu aşırı yüklenmiş metotları kullanırken sorun yaşayacak ve neden istedikleri gibi çalışmadığı anlamakta zorlanacaklardır.

Share

Bir Cevap Yazın

%d blogcu bunu beğendi: