Effective Java Madde 38: Enumlarda Kalıtımı Arayüz Kullanarak Taklit Edin

Madde 34’de enumlardan bahsederken enum türlerinin kalıtılamayan (final) sınıflara dönüştürüldüğü söylemiştik. Dolayısıyla bir enum türü başka bir enum türünü kalıtamaz. Enum türleri zaten dolaylı olarak java.lang.Enum sınıfından türetildikleri için başka sınıfları kalıtması da mümkün değildir.

Bu durum çoğu zaman bir sorun teşkil etmese de bazı enumları kalıtma ihtiyacı duyabiliriz veya kullanıcılara bu imkanı vermek isteyebiliriz. Buna örnek olarak işlem kodlarını (operation code) temsil eden enumları verebiliriz. İşlem kodları bir makina üzerinde yapılabilecek işlemleri temsil ederler, tıpkı Madde 34’de yazdığımız dört işlem yapabilen bir hesap makinasını tanımlayan Operation gibi. Bu tür enumlar için kullanıcılara esneklik sağlayarak yeni işlem kodları eklemelerine izin vermek isteyebiliriz.

Enumlar kalıtımı desteklemese de arayüzleri kullanarak böyle bir etki yaratabiliriz. Aşağıdaki gibi tek metotlu bir arayüz tanımlayıp enum türünde bu arayüzü uygulayabiliriz. Şimdi BasicOperation enum türünü bu şekilde yazalım:

// Arayüz uygulayarak kalıtılabilirliği taklit eden enum
public interface Operation {
    double apply(double x, double y);
}

public enum BasicOperation implements Operation {
    PLUS("+") {
        public double apply(double x, double y) { 
            return x + y; } 
        },
    MINUS("-") {
        public double apply(double x, double y) { 
            return x - y; 
        }
    },
    TIMES("*") {
        public double apply(double x, double y) { 
            return x * y; } 
        },
    DIVIDE("/") {
        public double apply(double x, double y) { 
            return x / y; 
        }
    };

    private final String symbol;
    BasicOperation(String symbol) {
        this.symbol = symbol;
    }
    @Override 
    public String toString() {
        return symbol;
    } 
}

Eğer bir kullanıcı BasicOperation türünde tanımlı aritmetik işlemlere eklemeler yapmak isterse bu enumu kalıtamaz ancak Operation arayüzünü uygulayan ikinci bir enum yazabilir. Örneğin, bu programa üst alma ve bölme işleminde kalan hesaplama işlemlerini de eklemek isteyelim. Bunun için yapmamız gereken bu iki işlemi gerçekleştiren yeni bir enum türünü Operation arayüzünü uygulayarak yazmak olacaktır:

// Kalıtımı taklit eden enum
public enum ExtendedOperation implements Operation {
    EXP("^") {
        public double apply(double x, double y) {
            return Math.pow(x, y);
        } 
    },
    REMAINDER("%") {
        public double apply(double x, double y) {
            return x % y; 
        }
    };
    
    private final String symbol;
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
    @Override 
    public String toString() {
        return symbol;
    } 
}

Böylece tür olarak Operation bekleyen metotlara hem BasicOperation nesnelerini hem de yeni yazdığımız ExtendedOperation nesnelerini geçebiliriz. Hatta sadece nesneleri değil ExtendedOperation türünün kendisini de BasicOperation yerine metotlara geçip elemanlarını kullanabiliriz. Aşağıdaki program komut satırından verilen iki argümanı kullanarak ExtendedOperation enumunda tanımlı bütün aritmetik işlemleri gerçekleştiriyor ve sonuçları ekrana yazdırıyor:

public static void main(String[] args) { 
    double x = Double.parseDouble(args[0]); 
    double y = Double.parseDouble(args[1]); 
    test(ExtendedOperation.class, x, y);
}

private static <T extends Enum<T> &amp; Operation> void test( 
    Class<T> opEnumType, double x, double y) {
    
    for (Operation op : opEnumType.getEnumConstants()) {
        System.out.printf("%f %s %f = %f%n",
                             x, op, y, op.apply(x, y));
    }
}

Burada main metodunun test metodunu çağırırken ExtendedOperation.class geçmesi sayesinde EXP ve REMAINDER işlemleri gerçekleşmektedir. Bu kullanım sınırlandırılmış tür belirtecine bir örnektir. (Madde 33) Metottaki opEnumType parametresinin <T extends Enum<T> & Operation> Class<T> biçimindeki tür tanımı da geçilebilecek Class nesnesinin hem bir enum olması hem de Operation arayüzünü uygulaması gerektiğini belirliyor. Bize lazım olan da tam olarak budur.

Burada ikinci bir seçenek olarak metoda Class nesnesi yerine Collection<? extends Operation> geçebiliriz. Bu kullanım sınırlandırılmış joker tür olarak bilinir. (Madde 31)

public static void main(String[] args) {
    double x = Double.parseDouble(args[0]);
    double y = Double.parseDouble(args[1]); 
    test(Arrays.asList(ExtendedOperation.values()), x, y);
}

private static void test(Collection<? extends Operation> opSet, 
        double x, double y) {

    for (Operation op : opSet) {
        System.out.printf("%f %s %f = %f%n",
                          x, op, y, op.apply(x, y));
    }
}

Bu kod biraz daha anlaşılır durumdadır ve test metodu biraz daha esnektir çünkü metodun istemcisi Operation türünü uygulayan farklı enum türlerinden nesneleri birleştirip gönderebilir. Diğer taraftan ise enum nesneleri aynı türden olmadığı için EnumSet (Madde 36) ve EnumMap (Madde 37) ile kullanılması mümkün olmaz. Buradaki iki yaklaşım da API tanımlarken gerçek enum türlerini değil bunların ortak olarak uyguladığı arayüz türünü kullandığı için bize bu esnekliği sağlayabilmektedir.

Bu iki program da komut satırından 2 ve 4 değerleri geçildiğinde aşağıdaki sonucu üretecektir:

4.000000 ^ 2.000000 = 16.000000 
4.000000 % 2.000000 = 0.000000

Arayüz kullanarak kalıtılabilirliği taklit etme yönteminin bir dezavantajı, bir enumda tanımlı metotların diğer metot tarafından kullanılamamasıdır. Bu metodun davranışı nesnenin durumuna bağımlı değilse arayüz içinde varsayılan metot (default method) olarak yazılabilir. (Madde 20) Bizim örneğimizde BasicOperation ve ExtendedOperation enumları symbol alanının saklanması ve okunması amacıyla yazılmış bölümleri tekrar etmektedirler. Bu örnekte tekrar edilen kod miktarı çok az olduğu için farketmez ama artarsa ortak kısımları bir yardımcı sınıf veya statik bir yardımcı metot içine taşıyabilirsiniz.

Bu maddede anlatılan teknik Java kütüphanelerinde de kullanılmaktadır. Örneğin java.nio.file.LinkOption enum türü CopyOption ve OpenOption arayüzlerini uygulamaktadır.

Özetle, kalıtılabilen enum türleri yazamasak da bu etkiyi verebilmek için bir arayüz tanımlayıp yazdığımız enumda bunu uygulayabiliriz. Bu istemcilere kendi enum türlerini (veya sınıflarını) aynı arayüzü uygulayarak yazmalarına izin verir. Bu farklı türlerin nesneleri de referans olarak arayüz türünü kabul eden her yerde kullanılabilir.

Share

Effective Java Madde 37: Ordinal İndisler Yerine EnumMap Kullanın

Madde 35’de çoğu programcının enum türleri için tanımlanan ordinal metoduna ihtiyacı olmayacağını ve bu sebeple de çok nadir durumlar haricinde kullanılmaması gerektiğini söylemiştik. Bu maddede ise bazı programcıların ordinal kullanmayı tercih ettiği bir iki özel durum üzerinde duracağız.

Bazen ordinal metodunun bir dizi veya liste için indis (index) olarak kullanıldığını görebilirsiniz. Örneğin bitkileri temsil etmek için tasarlanmış aşağıdaki sınıfa bakalım:

class Plant {
    enum LifeCycle { ANNUAL, BIENNIAL, PERENNIAL }
    
    final String name;
    final LifeCycle lifeCycle;
    Plant(String name, LifeCycle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }
    
    @Override 
    public String toString() {
        return name;
    } 
}   

Bu sınıfta bitkilerin yaşam döngülerini temsil eden bir de LifeCycle isimli enum türü bulunmaktadır. ANNUAL yaşam döngüsüne sahip bitkiler tek yıl yaşayıp ölürken BIENNIAL iki yıl, PERENNIAL ise daha uzun yıllar yaşayabilen bitki türlerini temsil etmektedir.

Şimdi de elemanları bu bitki türünün nesneleri olan garden isimli bir dizimiz olsun ve biz bu dizideki bitkileri yaşam döngülerine (life cycle) göre gruplayıp yazdırmak isteyelim. Bunu yapabilmek adına her bir yaşam döngüsü için bir tane olacak şekilde toplam 3 küme (Set) oluşturup garden dizisindeki bitki nesnelerini yaşam döngüsü değerlerine göre bu kümelere ekleyebiliriz. Bazı programcılar bunun için oluşturdukları Set nesnelerini bir diziye koyup LifeCycle enum sabitlerinin ordinal değerleri ile erişme yoluna giderler.

// ordinal değerini diziye indis olarak kullanmak - YAPMAYIN!
Set<Plant>[] plantsByLifeCycle =
        (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];

for (int i = 0; i < plantsByLifeCycle.length; i++) {
    plantsByLifeCycle[i] = new HashSet<>();
}

for (Plant p : garden) {
    plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
}

// sonuçları yazdır
for (int i = 0; i < plantsByLifeCycle.length; i++) {
    System.out.printf("%s: %s%n",
        Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}

Burada plantsByLifeCycle dizisinin 0. elemanı yaşam döngüsü ANNUAL olan bitkileri, 1. elemanı BIENNIAL, 2. elemanı ise PERENNIAL bitkileri tutan Set nesnelerini referans etmektedir.

Bu kod çalışacaktır ama problemlerle doludur. Diziler ile üreysel türler (generics) uyumlu olmadığı için (Madde 28) program kontrolsüz bir tür dönüşümü yapmak zorundadır. Bu sebeple de derleyici uyarı verecektir. Ancak daha ciddi bir problem vardır o da dizi indisleri için bir int değeri üreten ordinal metodunu kullandığımız için, programın her yerinde doğru int değerilerini kullanmak sizin sorumluluğunuzdadır. Temel int türü enumlar gibi tür güvenliği sağlayamaz. Yanlış bir değer kullanırsanız program sessiz bir biçimde yanlış sonuç üretebilir ama şanslıysanız ArrayIndexOutOfBoundsException fırlatır.

Aynı amaca ulaşmak için kullanabileceğimiz çok daha iyi bir yol var. Buradaki dizi aslında enum sabitlerini başka verilerle eşleştirmek için kullanılmaktadır. Bunun için bir Map kullanabiliriz. Hatta bu iş için özel yazılmış, anahtar olarak enum sabitleri kullanan java.util.EnumMap adında bir sınıf vardır. EnumMap kullanarak bu programı yeniden yazarsak aşağıdaki gibi olacaktır:

// EnumSet kullanarak enum sabitlerini başka verilerle eşleştirme
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
          new EnumMap<>(Plant.LifeCycle.class);

for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
    plantsByLifeCycle.put(lc, new HashSet<>());
}

for (Plant p : garden) {
    plantsByLifeCycle.get(p.lifeCycle).add(p);
}

System.out.println(plantsByLifeCycle);

Bu program daha kısa, daha anlaşılır ve daha güvenlidir. Performans olarak da önceki versiyonla benzerdir. Güvensiz bir tür dönüşümü içermez. Yazdırmak için özel bir çaba sarfetmemize gerek yoktur çünkü plantsByLifeCycle anahtarları enum sabitleri olduğu için birer String olarak ifade edilebilirler. En önemlisi de ortada bir dizi olmadığı için yanlış indis kullanımı yüzünden hatalar oluşmayacaktır. Bu gerçekleştirimin öncekiyle benzer performans sergilemesinin sebebi de EnumMap sınıfının kendisinin arka planda önceki örnekteki gibi bir dizi kullanmasıdır. Tabi bu bir gerçekleştirim detayı olduğu için sınıfın kullanıcılarına yansıtılmaz. Böylece EnumMap bize kullanım kolaylığı ve tür güvenliği vermesinin yanında kendi içinde kullandığı dizi sayesinde yüksek performans da sağlamaktadır. Ayrıca, EnumMap türünün yapıcı metodunda anahtar olarak kullanılmak amacıyla bir Class nesnesi kabul edilmektedir. Bu Madde 33’de gördüğümüz sınırlandırılmış tür belirtecine bir örnektir.

Aynı programı çok daha kısa bir şekilde yazmak stream kullanarak mümkündür. (Madde 45) Aşağıdaki program büyük ölçüde aynı işlevselliği taşımaktadır ve stream kullanarak yazılmıştır:

// EnumMap üretmeyen stream tabanlı yaklaşım
System.out.println(Arrays.stream(garden)
           .collect(groupingBy(p -> p.lifeCycle)));

Bu koddaki problem kendi Map gerçekleştirim türünü seçmesidir ve pratikte bu tür EnumMap olmayacaktır. Bu sebeple de EnumMap ile yakaladığımız yüksek performans ve düşük bellek kullanımı gibi avantajlar kaybolmaktadır. Bu problemi düzeltmek içinse aşağıdaki gibi Collectors.groupingBy metodunun 3 parametreli halini kullanarak istediğimiz Map türünü belirtebiliriz:

// Stream ve EnumMap kullanarak enum ile veri ilişkilendirme
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle,
             () -> new EnumMap<>(LifeCycle.class), toSet())));

Böyle bir optimizasyonu yapmak bizimki gibi basit bir örnek için çok önemli olmasa da bu veri yapılarını çok sık kullanan bir program için faydalı olabilir.

Bazen de ordinal değerinin iki boyutlu dizilerin indislerini ifade etmek için kullanıldığını görebilirsiniz. Örneğin, aşağıdaki program maddenin halleri arasındaki geçişleri ifade etmek için iki boyutlu bir dizi kullanmaktadır:

// iki boyutlu dizilerin indisleri için ordinal kullanımı - YAPMAYIN!
public enum Phase {
    SOLID, LIQUID, GAS;

    public enum Transition {
        MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;

        private static final Transition[][] TRANSITIONS = {
            { null,    MELT,     SUBLIME },
            { FREEZE,  null,     BOIL    },
            { DEPOSIT, CONDENSE, null    }
        };
        
        // Maddenin halleri arasındaki geçişleri temsil eden 
        // Transition enum sabitlerinden birini döndürüyor 
        public static Transition from(Phase from, Phase to) { 
            return TRANSITIONS[from.ordinal()][to.ordinal()];
        } 
    }
}

Burada maddenin 3 halini temsil eden Phase enum türü ve onun içinde de maddenin halleri arasındaki geçişleri temsil etmek üzere Transition enum türü yazılmıştır. from metoduna maddenin ilk ve son halini verdiğimizde bize geçişi temsil eden Transition sabitlerinden birini döndürecektir. Örneğin SOLID (katı) ve LIQUID (sıvı) geçildiği zaman bu elemanların ordinal değerlerinden TRANSITIONS iki boyutlu dizisi içerisinde katıdan sıvıya geçişi temsil eden MELT (erime) bulunup döndürülecektir. Burada dikkat ederseniz iki enum sabiti anahtar olarak kullanılmakta ve başka bir enum sabiti değer olarak döndürülmektedir.

Bu program çalışır hatta gözünüze hoş bile gelebilir ancak görünüşler aldatıcı olabilir. Önceki bitki örneğinde olduğu gibi burada da int türünden dizi indisleri kullanıldığı için, Phase veya Phase.Transition enumlarında bir değişiklik yapıp iki boyutlu diziyi olması gerektiği gibi güncellemezseniz programınız çalışma zamanında çökecektir. Bu noktada ArrayIndexOutOfBoundsException veya NullPointerException alabilirsiniz veya program hiç çökmeden yanlış sonuçlar da üretebilir.

Burada da EnumMap kullanarak çok daha iyisini yapabiliriz. Her bir geçiş maddenin iki hali ile ilişkili olduğu için, bunu iç içe iki EnumMap ile ifade edebiliriz:

// EnumMap kullanarak enum çiftleri ile veri ilişkilendirmek
public enum Phase {
    SOLID, LIQUID, GAS;

    public enum Transition {
        MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
        BOIL(LIQUID, GAS),   CONDENSE(GAS, LIQUID),
        SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);
      
        private final Phase from;
        private final Phase to;
      
        Transition(Phase from, Phase to) {
            this.from = from;
            this.to = to;
        }   
      
        // geçişleri temsil eden map nesnesinin yaratılması
        private static final Map<Phase, Map<Phase, Transition>> m = 
            Stream.of(values()).collect(
                groupingBy(t -> t.from,() -> 
                new EnumMap<>(Phase.class),
                toMap(t -> t.to, t -> t,
                (x, y) -> y, () -> new EnumMap<>(Phase.class))));
        
        public static Transition from(Phase from, Phase to) {
            return m.get(from).get(to);
        } 
    }
}

Burada ilk dikkati çeken şey Transition enum sabitlerinin yapıcı metotlarına maddenin hangi iki hali arasındaki geçişi temsil ettiğini göstermek üzere from ve to isimli Phase sabitleri geçmesidir.

Geçişleri temsil eden veri yapısının yaratılması biraz karmaşık. Buradaki m nesnesinin türü Map<Phase, Map<Phase, Transition>> olarak belirlenmiştir. Bu veri yapısında dıştaki Phase anahtarı from (maddenin ilk hali), içteki Phase anahtarı to (maddenin son hali), Transition ise geçiş sabitini temsil etmektedir. Bu iç içe Map yapısını kurmak için stream ve kademeli toplayıcılar (collector) kullanılmıştır. Stream kullanmak istemiyorsanız veya karmaşık geliyorsa aynı veri yapısını aşağıdaki gibi döngüler kullanarak da oluşturabilirsiniz:

// geçişleri temsil eden veri yapısını yaratmanın alternatif yolu
private static final Map<Phase, Map<Phase, Transition>> m =
    new EnumMap<>(Phase.class);

static {
    for (Phase p : Phase.values()) {
        m.put(p, new EnumMap<>(Phase.class));
    }
    for (Transition trans : Transition.values()) {
        m.get(trans.src).put(trans.dst, trans);
    }
}

Bu yöntem her ne kadar daha çok kod yazmayı gerektirse de bazı kişiler tarafından daha anlaşılır bulunabilir.

Şimdi varsayalım ki sisteme maddenin bir başka hali olan plazmayı eklemek istiyoruz. Bu yeni hal için iki tane daha geçiş tanımlamak gerekecektir. Gaz galden plazmaya geçiş iyonlaşma, plazmadan gaz hale geçişse iyonsuzlaşma adını alır. Bu eklemeyi iki boyutlu dizi kullandığımız örnekte yapıyor olsaydık Phase enumuna bir tane, Transition enumuna ise iki tane ekleme yapardık ancak 9 elemanlı diziyi (3×3) 16 elemana (4×4) çıkarmamız gerekirdi. Burada yapılacak en ufak bir hatada programımız yanlış sonuçlar üretebilir veya çalışma zamanında çökebilirdi. EnumMap kullandığımız versiyonda ise tek yapmamız gereken Phase enum türüne PLASMA adında bir yeni değer, Transition içinse IONIZE(GAS, PLASMA) ve DEIONIZE(PLASMA, GAS) olacak şekilde iki yeni değer eklemek olacaktır.

// EnumMap gerçekleştiriminde yeni bir Phase eklemek
public enum Phase {
    SOLID, LIQUID, GAS, PLASMA;
    
    public enum Transition {
        MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), 
        BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID), 
        SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID), 
        IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS); 

       ... // Geri kalan kısımlarda değişiklik yok
    } 
}

Programın geri kalanı hiçbir değişikliğe ihtiyaç duymadan doğru olarak çalışmaya devam edecektir ve böylece size hata yapmak için neredeyse hiç imkan vermemektedir.

Yukarıdaki örneklerde Transition döndüren from metodu eğer maddenin iki hali arasında bir geçiş bulamazsa null döndürecektir (örneğin from ve to parametreleri aynı olursa). Bu çok doğru bir yaklaşım olmasa da kodu basit tutmak için yapılmıştır. Gerçek sistemlerde bu gibi metotlar yüzünden sıklıkla NullPointerException ile karşılaşabilirsiniz.

Özetle, ordinal değerlerini dizi indisi olarak kullanmak çok nadir durumlar haricinde önerilmez, bunun yerine EnumMap kullanın. Eğer ifade etmek istediğiniz ilişkiler çok boyutlu ise iç içe EnumMap<..., EnumMap<...>> kullanın.

Share