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

Bir Cevap Yazın