Effective Java Madde 47: Dönüş Türü Olarak Stream Yerine Collection Tercih Edin

Bir dizi eleman döndüren metotlarla sıkça karşılaşırız. Java 8’den önce bu tür metotlar için dönüş türü olarak ya Collection, Set, List gibi koleksiyon türleri, ya Iterable ya da dizi türleri kullanılırdı. Çoğu zaman da bunlardan birisini seçmek zor olmazdı. Metodun amacı dönüş değerinin for-each döngüsünde kullanılmasını mümkün kılmaksa veya döndürülen nesnelerin Collection arayüzündeki bazı metotları geçersiz kılması mümkün olmuyorsa, Iterable dönüş türü tercih edilirdi. Döndürülen elemanlar temel türlerde ise veya çok ciddi performans gereksinimleri varsa dizi kullanılırdı. Java 8’le streamler dile eklendikten sonra bu tür metotlarda dönüş türünü belirlemek zorlaştı.

Bir dizi eleman döndüren metotlar için dönüş türü olarak stream kullanmanın en doğru seçenek olduğunu söyleyenleri duyabilirsiniz. Ancak Madde 45’de anlattığımız gibi, iyi kod yazabilmek için stream ile yinelemeyi (iteration) beraber kullanmalıyız. Bir API sadece stream döndürecek şekilde tasarlanırsa, bunu for-each döngüsünde kullanmak isteyen bir istemci hayal kırıklığına uğrayacaktır. Stream türünün for-each döngülerinde kullanılmasını engelleyen tek şey Stream arayüzünün iterator metoduyla bu işlevselliği sağlamasına rağmen Iterable arayüzünü kalıtmamasıdır.

Maalesef bu problemin basit bir çözümü de yoktur. İlk bakışta, Stream içindeki iterator için bir metot referansı geçersek problem ortadan kalkacakmış gibi görünüyor:

// Java'da tür çıkarsama mekanizmasındaki kısıtlar nedeniyle derlenmez
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) { 
    // döngü kodu
}

Bu kodu derlemeye çalışınca aşağıdaki gibi bir hata ile karşılaşırız:

Test.java:6: error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
                        ^

Bu hatayı düzeltmek için bir metot referansını parametreli Iterable türüne dönüştürebiliriz:

// çalışan ama çirkin bir çözüm 
for (ProcessHandle ph : (Iterable<ProcessHandle>)
                         ProcessHandle.allProcesses()::iterator)

Bu kod çalışır ama pratikte kullanmak için biraz karmaşık ve anlaşılması güç. Daha mantıklı bir seçenek olarak adaptör kullanabiliriz. JDK böyle bir adaptör sunmasa da yazması zor değildir. Dikkat ederseniz bu yöntemde tür dönüşümü yapmak zorunda değiliz çünkü tür çıkarsama mekanizması burada doğru çalışacaktır:

// Stream<E>'den Iterable<E>'ye dönüşüm yapan adaptör
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}

Bu adaptörü kullanarak stream döndüren metotları for-each döngülerinde kullanabiliriz:

for(ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) { 
    // döngü kodu 
}

Madde 45’de Anagrams programının stream versiyonunda Files.lines, yinelemeli versiyonunda ise scanner kullanmıştık. Files.lines aslında scanner kullanmaktan daha mantıklıdır, yinelemeli versiyonda da bunu kullanmak daha doğru olurdu. Ancak yinelemeli versiyonda elemanları tek tek taramak gerektiği için stream döndüren Files.lines pek kullanışlı değildir. Stream döndüren metotlar yazmak istemcileri bu gibi tavizler vermeye zorlayabilir.

Benzer şekilde, bir grup elemanı stream kullanarak işlemek isteyen bir istemci, Iterable döndüren bir metotla karşılaşınca hayal kırıklığına uğrayacaktır. Bu problem için de yine bir adaptör kullanabiliriz:

// Iterable<E>'yi Stream<E>'ye dönüştüren adaptör
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

Eğer döndürdüğünüz elemanların bir stream kullanarak işleneceğinden eminseniz tabii ki stream döndürmekte bir sıkıntı yoktur. Aynı şekilde, for-each döngüsünde kullanılacağını bildiğiniz elemanları da Iterable türünde döndürmekte sakınca yoktur. Ancak dışarıya açık (public) bir API yazıyorsanız ve döndürülen elemanların ne şekilde işleneceğinden emin değilseniz, bu iki kullanımı da desteklemek yararlı olacaktır.

Collection arayüzü Iterable arayüzünü kalıtır ve stream metodu vardır, dolayısıyla hem yinelemeli hem de stream kullanımını destekler. Bu yüzden bir grup eleman döndüren dışa açık metotlar için dönüş türünün Collection veya bunun bir alt türü olması en doğrusudur. Diziler de yineleme ve stream kullanımlarını Arrays.asList ve Stream.of metotları sayesinde desteklerler. Döndürdüğünüz elemanlar belleğe kolayca sığacak kadar küçükse ArrayList veya HashSet gibi standart bir koleksiyon döndürmek mantıklı olacaktır. Ancak sırf koleksiyon türü döndürebilmek için büyük miktarlardaki veriyi belleğe sıkıştırmaya çalışmayın.

Bu gibi durumlarda kendi koleksiyon türünüzü yazmayı düşünebilirsiniz. Diyelim ki verilen bir kümenin kuvvet kümesini döndürmek istiyoruz. Kuvvet kümesi bir kümenin bütün alt kümelerini içerir. Örneğin {a, b, c} kümesinin kuvvet kümesi {{}, {a}, {b}, {c}, {a, b}, {a, c}, {b, c}, {a, b, c}} elemanlarından oluşur. Eğer kümenin n tane elemanı varsa, kuvvet kümesinde 2n eleman bulunur. Bu sebeple kuvvet kümesini standart bir koleksiyonda saklamayı düşünmeyin! AbstractList yardımıyla kendi koleksiyonumuzu yazarak bunun üstesinden gelebiliriz.

Bunu gerçekleştirebilmek için bit vektörü kullanabiliriz. Yukarıdaki gibi bir {a, b, c} kümesini örnek alırsak küme üç elemanlı olduğu için kuvvet kümesinin 23 = 8 tane elemanı olur. Yani kuvvet kümesinin bütün elemanlarını üç tane bitle ifade edebiliriz. Üç elemanlı bir bit vektöründe sıfırıncı indis “a”, birinci indis “b”, ikinci indis ise “c” elemanını temsil ederse 000 değeri boş kümeyi, 001 değeri {a} kümesini, 010 değeri {b} kümesini, 101 değeri {a, c} kümesini temsil edecektir. Başka bir deyişle bit vektörünün alabileceği sekiz değerin her biri kuvvet kümesinin bir elemanını temsil edecektir. Bir int değişkeni en fazla 231 – 1 değerini alabildiği için tek bir int ile 30 elemanlı bir kümenin kuvvet kümelerini üretebiliriz. Şimdi koda bakalım:

// Girdi olarak verilen bir kümenin kuvvet kümesini temsil eden
// bir Collection döndürür 
public class PowerSet {
    public static final <E> Collection<Set<E>> of(Set<E> s) {
        List<E> src = new ArrayList<>(s);
        if (src.size() > 30) {
            throw new IllegalArgumentException("Set too big " + s); 
        }

        return new AbstractList<Set<E>>() {
            @Override 
            public int size() {
                return 1 << src.size(); // 2 üzeri src.size()
            }
            @Override 
            public boolean contains(Object o) {
                return o instanceof Set &amp;&amp; src.containsAll((Set)o);
            }
            @Override 
            public Set<E> get(int index) {
                Set<E> result = new HashSet<>();
                for (int i = 0; index != 0; i++, index >>= 1) {
                    if ((index &amp; 1) == 1) {
                        result.add(src.get(i));
                    }
                }
                return result;
            }
        }; 
    }
}

Dikkat ederseniz PowerSet.of metodu 30’dan fazla elemanlı bir küme verildiğinde aykırı durum fırlatmaktadır. Bunun sebebi size metodunun int türünde değer döndürmesidir. Stream veya Iterable yerine Collection dönüş türü kullanmanın bir dezavantajı budur.

AbstractCollection üzerinden bir Collection gerçekleştirimi yazmak istersek Iterable arayüzünün gerektirdiği bir metot haricinde contains ve size metotlarını geçersiz kılmamız gerekir. Çoğu zaman bu metotlar kolayca geçersiz kılınabilir ancak bu mümkün olmuyorsa Stream veya Iterable döndürmek kabul edilebilir. Hatta iki ayrı metot kullanarak her ikisini de döndürebilirsiniz.

Bazen de dönüş türünü gerçekleştirim kolaylılığına göre seçebilirsiniz. Diyelim ki verilen bir listenin bütün alt listelerini (elemanların ard arda geliş sırasını bozmadan) döndüren bir metot yazmak istiyoruz. Bu alt listeleri üretip standart bir koleksiyonda tutmak için sadece üç satır kod yeterli olacaktır, ancak burada da bellek kullanımı çok fazla olacaktır. Kuvvet kümesi kadar kötü olmasa da kabul edilecek bir durum değildir. JDK bizlere iskelet bir Iterator gerçekleştirimi de sunmadığı için kuvvet kümesinde yaptığımız gibi yeni bir koleksiyon yazmak da uğraştırıcı olacaktır.

Bunu stream kullanarak yapmak ise ufak bir ipucu sayesinde nispeten kolaydır. Listenin ilk elemanını içeren alt listelere önek (prefix), son elemanını içeren listelere de sonek (suffix) adını verelim. Örneğin (a, b, c) listesi için (a), (a, b), and (a, b, c) alt listeleri önek, (a, b, c), (b, c), ve (c) alt listeleri ise sonek olacaktır. Bütün önek alt listelerinin soneklerini hesaplayıp birleştirirsek listenin bütün alt listelerini bulmuş oluruz. Bunu tersinden, yani sonek listelerinin öneklerini hesaplayarak da yapabiliriz. Tabi buna bir de boş listeyi eklemek gerekir.

NOT: Burada biraz kafanız karışmış olabilir o yüzden biraz daha detay vermek istedim. Yapılmak istenen şey bir listenin elemanlarının sırasını bozmadan bütün alt listelerini bulmak. Yani (a, b, c) listesi için alt listeler (), (a), (b), (c), (a, b), (b, c) ve (a, b, c) olacaktır, dikkat ederseniz eleman sırasını bozduğu için (a, c) alt liste olarak kabul edilmiyor. Verilen ipucu da şunu söylüyor: önek alt listleri olan (a), (a, b), and (a, b, c) listelerini alıp bunların her biri için sonekleri üretirsek bütün alt listelere erişmiş oluruz. (a) alt listesi için sonek yine (a) olacaktır. (a, b) için (b) ve (a, b) gelecektir. (a, b, c) içinse (c), (b, c) ve (a, b, c) üretilecektir. Bütün bu sonekleri birleştirip üzerinde bir de boş listeyi () eklerseniz (a, b, c) listesinin bütün alt listelerini bulmuş olursunuz.

Şimdi bunun kodu nasıl yazılıyor buna bakalım:

public class SubLists
{
    // verilen listenin bütün alt listelerini stream olarak döndürür
    public static <E> Stream<List<E>> of(List<E> list) {
        return Stream.concat(Stream.of( Collections.emptyList()),
                         prefixes(list).flatMap(SubLists::suffixes));
    }
    
    private static <E> Stream<List<E>> prefixes(List<E> list) {
        return IntStream.rangeClosed(1, list.size())
               .mapToObj(end -> list.subList(0, end));
    }

    private static <E> Stream<List<E>> suffixes(List<E> list) {
        return IntStream.range(0, list.size())
               .mapToObj(start -> list.subList(start, list.size()));
    }
}

Dikkat ederseniz Stream.concat metodu boş liste değerini de döndürülen streame eklemektedir. Ayrıca flatMap metodu (Madde 45) bütün öneklerin soneklerini içeren tek bir stream döndürmek için kullanılmaktadır. Son olarak IntStream.range ve IntStream.rangeClosed metotları önek ve sonek alt listelerini üretmek için kullanılmaktadır. Bu metotlar standart for döngülerinde int türünde indislerle yapılan tarama işlevini görmektedir. Dolayısıyla aslında yazdığımız stream tabanlı alt liste üretme kodu aşağıda iç içe for döngüleri ile yazılmış versiyonla kısmen benzerdir:

for (int start = 0; start < src.size(); start++) {
    for (int end = start + 1; end <= src.size(); end++) {
        System.out.println(src.subList(start, end));
    }
}

Bu for döngüsünü direk olarak bir streame dönüştürmek de mümkündür. Sonuçta önceki stream kodundan kısa ama belki biraz daha az anlaşılır bir kod ortaya çıkacaktır:

// verilen listenin bütün alt listelerini stream olarak döndürür
public static <E> Stream<List<E>> of(List<E> list) {
    return IntStream.range(0, list.size())
        .mapToObj(start ->
           IntStream.rangeClosed(start + 1, list.size())
              .mapToObj(end -> list.subList(start, end)))
        .flatMap(x -> x);
}

Önceki for döngüsü kodu gibi bu kod da boş listeyi dönüş değerine eklemiyor. Bunu çözmek için yine Stream.concat kullanabiliriz.

Her iki stream gerçekleştirimi de geçerlidir ve kullanılmasında bir sakınca yoktur. Ancak kullanıcılar dönüş değerini Iterable türüne dönüştürmek için adaptör kullanmak zorunda kalabilirler. Bu sadece kodu kirletmekle kalmaz aynı zamanda daha yavaş çalışmasına da sebep olur.

Özetle, bir grup eleman döndüren metotlar yazarken bazı kullanıcıların bunları stream kullanarak, bazılarının da tarama yaparak yinelemeli biçimde işlemek isteyebileceğini unutmayın. Metodun nasıl kullanılacağından emin değilseniz bu iki kullanımı da desteklemeye çalışın. Bunun için bir koleksiyon döndürebiliyorsanız döndürün. Elemanlar zaten bir koleksiyon içindeyse veya sayıları yeterince azsa ArrayList gibi standart bir koleksiyon döndürün, değilse kuvvet kümesi örneğinde yaptığımız gibi kendiniz bir koleksiyon türü tanımlayın. Collection döndürmek pek makul değilse Stream veye Iterable döndürebilirsiniz. Eğer sonraki Java versiyonlarında Stream arayüzü Iterable arayüzünü kalıtırsa stream döndürmek bir problem yaratmayacaktır çünkü bu durumda hem stream hem de yinelemeli işletimi desteklemiş olacaktır.

Share

Effective Java Madde 31: API Esnekliğini Artırmak İçin Sınırlandırılmış Joker (bounded wildcard) Kullanın

Madde 28’de anlatıldığı üzere parametreli türler arasında hiçbir koşulda alt tür/üst tür ilişkisi bulunmaz. Örneğin String türü Object‘in bir alt türü olmasına rağmen, List<String> ile List<Object> arasında böyle bir ilişki bulunmaz. List<Object> içerisine istediğiniz türden nesneleri koyabilirsiniz ama List<String> sadece String türünden nesneler içerebilir. Bu durumda List<String> türü, List<Object> türünün yaptığı her işi yapamadığına göre, alt türü de değildir.

Bu her ne kadar mantıklı olsa da bazı durumlarda bizi kısıtlayabilir. Madde 29’daki üreysel yığıt olarak tasarladığımız Stack sınıfını düşünelim. Hafızanızı tazelemek için API hatırlatması yapalım:

public class Stack<E> {
    public Stack();
    public void push(E e);
    public E pop();
    public boolean isEmpty();
}

Farzedelim ki bu sınıfa bir dizi nesne alıp hepsini yığıta ekleyen yeni bir metot yazmak istiyoruz. İlk denememiz aşağıdaki gibi olsun:

// joker tür kullanmayan pushAll metodu - kusurlu!
public void pushAll(Iterable<E> src) {
    for (E e : src) {
        push(e);
    }
}

Bu metot tertemiz derlenir ama tam tatmin edici değildir. src içerisindeki elemanların türü ile yığıtın eleman türü tıpatıp aynı ise sorunsuz çalışır. Elinizde bir Stack<Number> varsa ve siz yığıta tek bir eleman eklemek için push metodunu bir Integer nesnesi ile çağırırsanız sorunsuz çalışacaktır çünkü Integer sınıfı Number‘ın bir alt türüdür. Mantık olarak aşağıdakinin de çalışmasını beklersiniz:

Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ... ;
numberStack.pushAll(integers);

Ancak denediğinizde aşağıdaki gibi bir hata alırsınız:

StackTest.java:7: error: incompatible types: Iterable<Integer>
   cannot be converted to Iterable<Number>
           numberStack.pushAll(integers);
                               ^

Burada hata almamızın sebebi, yazının başında belirtildiği gibi Integer ile Number arasında bulunan alt/üst tür ilişkisinin Iterable<Integer> ile Iterable<Number> arasında bulunmamasıdır.

Şanslıyız ki bunun bir çözümü var. Java bizlere sınırlandırılmış joker tür (bounded wildcard type) adında özel bir tür parametresi belirleme imkanı sunmaktadır. Bizim pushAll metodundan istediğimiz aslında sadece E türünden değil, E‘nin alt türlerinden nesneler içeren bir Iterator geçtiğimizde de doğru çalışarak bunları yığıta eklemesi. İşte joker tür tam burada devreye giriyor. pushAll metodunun parametresini Iterable<? extends E> olarak değiştirdiğimizde amacımıza ulaşmış oluyoruz. <? extends E> ifadesi E‘nin kendisi ve onu kalıtan türler anlamına gelmektedir. (Burada extends ifadesi hafif bir kafa karışıklığına sebep olabilir. Madde 29’dan hatırlayın, Java’da her tür kendisinin alt türü sayıldığı için bu ifade E türünü de kapsamaktadır.) Şimdi pushAll metodunu buna göre güncelleyelim:

// sınırlandırılmış joker tür ile esnekleştirilmiş pushAll metodu
public void pushAll(Iterable<? extends E> src) {
    for (E e : src) {
        push(e);
    }
}

Metodu yukarıdaki gibi yazdığımızda hem Stack sınıfı hem de daha önce hata veren istemci sınıfı sorunsuz derlenecek ve çalışacaktır. Derleyiciden uyarı almadığımız için tür güvenliğini de sağladığımızdan emin olabiliriz.

Şimdi de pushAll metoduna eşlik etmek üzere bir de popAll metodu yazmaya çalışalım. Bu metot yine bir koleksiyon parametresi alacak ama bu sefer yığıttaki elemanları çıkartıp bu koleksiyona ekleyecektir. Bu şekildeki bir popAll metodunu ilk denemede şöyle yazabiliriz:

// joker tür kullanılmayan popAll metodu, kusurlu!
public void popAll(Collection<E> dst) {
    while (!isEmpty()) {
        dst.add(pop());
    }
}

Önceki durumda olduğu gibi, eğer istemcinin geçtiği dst koleksiyonunun tür parametresi E ile yığıttaki elemanların türü tıpatıp aynı ise kod sorunsuz çalışacaktır. Ancak bu yine tatmin edici olmaz. Elimizde Stack<Number> türünde bir Stack varsa ve biz pop metodunu çağırıp sonucu bir Object içinde saklamak istesek hiçbir sorunla karşılaşmayız. O zaman aşağıdakini de yapabilmemiz gerekmez mi?

Stack<Number> numberStack = new Stack<Number>();
Collection<Object> objects = ... ;
numberStack.popAll(objects);

Bu istemci kodunu popAll metodunu da içeren Stack ile beraber derlersek, pushAll yazmaya çalışırken ilk başta aldığımıza çok benzer bir hatayla karşılaşırız. Bunun sebebi yine Collection<Object> ile Collection<Number> arasında bir alt/üst tür ilişkisi olmamasıdır. Bu sorunu da yine sınırlandırılmış joker tür ile çözebiliriz ama arada ufak bir fark var. Bu sefer yığıta yazma degil de okuma yaptığımız için, geçtiğimiz koleksiyon türünün E‘nin alt türlerini değil üst türlerini ifade etmesi gerekir. Bunu ifade etmenin yolu da tür parametresini Collection<? super E> olarak değiştirmektir. Dikkat ederseniz burada extends ifadesi üst türleri ifade edebilmek için super olarak değişti. Java’da her tür kendisinin üst türü olarak kabul edildiği için bu ifade hem E türünün kendisini de kapsamaktadır. Şimdi popAll metodunu yeniden yazalım:

// joker türünün üst türler için kullanımı  
public void popAll(Collection<? super E> dst) {
    while (!isEmpty()) {
        dst.add(pop());
    }
}

Bu kod kullanıldığı zaman hem Stack sınıfı hem de istemci uyarısız ve hatasız derlenip çalışacaktır.

Buradaki ders bellidir. Esnekliği artırmak için üretici (producer) ve tüketicileri (consumer) temsil eden girdi parametrelerini (input parameter) joker tür kullanarak tanımlayın. Farklı bir biçimde ifade edecek olursak, metoda geçilen parametreli tür T nesneleri üretmek için kullanılıyorsa <? extends T>, T nesnelerini tüketmek için kullanılıyorsa <? super T> kullanın.

Bizim pushAll örneğinde parametreli koleksiyon türü yığıt için yeni eleman üretmek için kullanıldığı için (üretici) extends, popAll metodunda ise benzer parametre yığıttan eleman çıkarmak için kullanıldığı için (tüketici) super kullanılmıştır. Şunu da belirmek gerekir ki, bir parametre hem üretici hem de tüketici görevi görüyorsa o zaman joker türü kullanmanın bir anlamı yoktur. Joker tür kullanmadan normal tür parametresi geçmek daha doğru olacaktır.

Bu kuralı unutmamak için PECS olarak aklımızda tutabiliriz. Bunun açılımı yukarıdaki anlatıma uygun olarak producer-extends, consumer-super olarak düşünülebilir. (producer=üretici, consumer=tüketici)

Bu ipucunu aklımızda tutarak, kitabın Üreyseller bölümünün daha önceki maddelerinde gördüğümüz örneklere tekrar bakalım. Madde 28‘deki Chooser sınıfının yapıcı metodundan başlayalım:

public Chooser(Collection<T> choices)

Bu yapıcı metot choices koleksiyonunu kullanarak T türünde nesneler üretmekte ve bunları sonradan kullanmak için saklamaktadır. Bu durumda üretici olduğundan dolayı joker türü extends ile kullanılmalıdır. Şimdi bu şekilde tekrar yazalım:

// T üreticisi görevi gören parametre için joker tür kullanımı
public Chooser(Collection<? extends T> choices)

Peki bu yeni tanım pratikte ne işimize yarayacak? Diyelim ki elimizde Chooser<Number> var ve biz yapıcı metoda List<Integer> geçmek istiyoruz. Orijinal tanımda bu mümkün olmazdı ama yukarıdaki gibi joker tür kullandığımızda sorunsuz çalışacaktır.

Şimdi de Madde 30’daki union metoduna bakalım. Bu metot parametre aldığı iki kümeyi birleştirip tek bir küme olarak döndürüyor:

public static <E> Set<E> union(Set<E> s1, Set<E> s2)

Hem s1 hem de s2 parametreleri üreticidir. PECS kuralına göre ikisini de extends ifadesi ile joker türe dönüştürebiliriz:

public static <E> Set<E> union(Set<? extends E> s1, 
                               Set<? extends E> s2)

Dikkat ederseniz, parametre olan Set türlerini joker türe çevirdik ama dönüş türünü (return type) ellemedik. Bu esneklik sağlamanın tersine, istemcileri joker türü kullanmaya zorlardı, bu istenen bir durum değildir. Yukarıdaki gibi güncellenen bir union metodunu kullanan aşağıdaki gibi bir istemci yazabiliriz (Java 8 ve sonrası):

Set<Integer> integers = Set.of(1, 3, 5);
Set<Double>  doubles  = Set.of(2.0, 4.0, 6.0);
Set<Number>  numbers  = union(integers, doubles);

Doğru kullanıldıklarında joker türler istemciler için görünmez olurlar, yani istemciler bunları fark etmeden dahi kodlarını yazabilirler. Kabul edilmesi gereken metot parametrelerini kabul eder, reddedilmesi gerekenleri reddelerler. Eğer bir sınıfın kullanıcısı kod yazarken sınıfta kullanılan joker türleri düşünüyorsa o sınıfın API’ında bir sorun var demektir.

Yine Madde 30’daki max metoduna bakalım. Tanımı şu şekildeydi:

public static <T extends Comparable<T>> T max(List<T> list)

Bunu PECS kuralına göre yeniden düzenlersek aşağıdaki gibi bir sonuç çıkar:

public static <T extends Comparable<? super T>> T max( 
                                List<? extends T> list)

Bu sonuca ulaşmak için iki kere PECS kuralını uyguladık. İlk olarak list parametresini T nesneleri ürettiği için List<T> iken List<? extends T> olarak değiştirdik. Kafa karıştırıcı kısım ise T extends Comparable<T> ifadesinin T extends Comparable<? super T> olarak değiştirilmiş olması. Bunun sebebi de Comparable<T> arayüzünün karşılaştırma yaparken T türündeki nesneleri tüketmesidir, bu sebeple de extends yerine super kullanılmıştır. (Burada tüketmek derken illa ki bir koleksiyondan çıkartılması, yok edilmesi manası çıkartılmamalıdır. Bir nesnenin parametre alınıp okunarak bir iş için kullanılması ”tüketilmesi” demektir)

Comparable ve Comparator her zaman tüketicidir. Bu sebeple Comparable<T> yerine Comparable<? super T>, Comparator<T> yerine de Comparator<? super T> kullanmanız önerilir.

Yukarıda yeniden yazdığımız max metodu bu kitapta göreceğiniz en karmaşık metot tanımıdır diyebiliriz. Peki bu karmaşıklık bize ne kazandırıyor? Örneğin aşağıdaki liste orijinal max metoduna parametre geçilemez, ama joker türlerle geliştirdiğimiz max metoduna bu listeyi geçebiliriz:

List<ScheduledFuture<?>> scheduledFutures = ... ;

Hatırlayacağınız üzere orijinal tanımda T extends Comparable<T> şunu söylemektedir: T türü sadece kendisiyle direk olarak karşılaştırılabilen türlerden seçilebilir. Bu listede kullanılan ScheduledFuture türü Comparable<ScheduledFuture> arayüzünü uygulamadığı için orijinal max metoduna geçilemez. Ancak ScheduledFuture arayüzü Delayed arayüzünü ve Delayed arayüzü de Comparable<Delayed> arayüzünü kalıtmaktadır. Bunun anlamı şudur: ScheduledFuture kendi türünden nesneler ile karşılaştırılamasa da, üst türü olan Delayed nesneleri ile karşılaştırılabilir. Bu durumda super kullanılarak eklenen joker tür parametresi problemi çözmektedir. Comparable (veya Comparator) arayüzünü direk uygulamayan ama uygulayan bir üst türü kalıtan türleri desteklemek istiyorsak yukarıdaki gibi bir joker tür parametresi kullanmamız gerekir

Joker türlerle alakalı tartışmaya değer bir konu daha var. Aşağıdaki gibi bir swap metodunu üreysel mekanizmalar kullanarak tanımlamanın iki yolu vardır. Birincisi sınırlandırılmamış tür parametresi (Madde 30) ikincisi ise sınırlandırılmamış joker türü kullanmaktadır:

// swap metodunun iki farklı tanımı
public static <E> void swap(List<E> list, int i, int j); 
public static void swap(List<?> list, int i, int j);

Bunlardan hangisini kullanmak daha mantıklıdır ve neden? Açık (public) bir API için ikinci kullanım daha mantıklıdır çünkü daha basittir. İstediğiniz bir listeyi geçebilirsiniz ve metot i ve j indislerindeki (index) elemanları yer değiştirecektir. Tür parametresi ile ilgilenmeye gerek yoktur. Bir kural olarak, eğer tür parametresi metot tanımında sadece bir kere görülüyorsa bunu joker türüne çevirebilirsiniz. Tür parametresi sınırlandırılmamış ise sınırlandırılmamış joker, sınırlandırılmış ise de sınırlandırılmış jokere çevirebilirsiniz.

Ancak ikinci swap tanımıyla ilgili bir problem vardır, aşağıdaki gibi yazıldığında derlenmeyecektir:

public static void swap(List<?> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));
}

Bu kod aşağıdaki gibi çok da anlaşılmayan bir hata vermektedir:

Swap.java:5: error: incompatible types: Object cannot be
   converted to CAP#1
           list.set(i, list.set(j, list.get(i)));
                                           ^
     where CAP#1 is a fresh type-variable:
       CAP#1 extends Object from capture of ?

Bir elemanı listeden okuyup sonra aynı listede başka bir indise yazarken hata almak pek de mantıklı değil. Buradaki problem şudur: List<?> türünden listelere null haricinde bir değer yazmak yasaktır. Ancak bunu tür güvenliğini tehlikeye atmadan çözmek mümkündür. Yardımcı bir private metot yazarak ? ile temsil edilen türü yakalayabilir, sonra da elemanları yer değiştirebiliriz:

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}

// joker türünü yakalamak için yazılmış yardımcı metot
private static <E> void swapHelper(List<E> list, int i, int j) { 
    list.set(i, list.set(j, list.get(i)));
}

swap metodunda ? ile temsil edilen tür bilgisi swapHelper içerisinde artık E ile ifade edilmektedir. İçinde E türünden nesneler olan bir listenin elemanlarının yerlerini değiştirmek tür güvenliğini tehlikeye atmayacağı için bu kod tertemiz derlenecektir. Biraz dolambaçlı bir yöntem gibi görünse de, bu sayede istemciler daha karmaşık görünen swapHelper metodunun imzasını görmeyecek ama bundan faydalanabilecektir.

Özetle, API tasarlarken joker türler kullanmak biraz alengirli olsa da esnekliği çok artıracaktır. Eğer çok kullanılan bir kütüphane yazacak olursanız, joker tür kullanımı çok daha önemli hale gelmektedir. Temel PECS (producer-extends consumer-super) kuralımızı hatırlayın. Comparable ve Comparator her zaman tüketicidir, bunu da aklınızdan çıkarmayın.

Share