İçeriğe geç
academia.sh

Ders 04 / 15

Yineleyiciler ve Eşzamanlı Değişiklik

Yineleyicinin temel sözü — tükendiğinde NoSuchElementException — üç ayrı gerçekleştirimde birebir aynı çıkıyor. Ama gezinme sırasında kap değişirse üç ayrı yanıt görülüyor: erken hata veren, anlık kopya üzerinden gezen, hiçbir şey söylemeyen. Erken hatanın kendisi de bir garanti değil bir en iyi çaba çıkıyor: aynı silme işlemi bir konumda istisna verirken sondan bir önceki ögede sessizce ve bir eksik biten bir döngü bırakıyor. Sınırlayıcı ölçüm, yineleyicinin kendi remove'unun bu kuralın dışında kaldığını gösteriyor.

İçindekiler

Önceki ders bir sözlüğün anahtarı bulup bulamayacağını ölçtü ve kusurun kaynağını üçe ayırdı: arayüz, gerçekleştirim, çağıranın kuralı. Bu ders aynı üçlüyü, kabın durağan haliyle değil, üzerinde gezinirken sınıyor. Bir listeyi, kümeyi ya da sözlüğü for ile dolaşırken aynı kaba yeni bir öge eklemek ya da bir ögeyi silmek — üç ailenin de paylaştığı bir durumdur. Kütüphane buna tek bir yanıt vermiyor.

Gezinme her zaman bir sıra varsayar: yineleyici bir sonraki ögenin nerede olduğunu, bir önceki çağrının bıraktığı iç durumdan çıkarır. Kap gezinme ortasında değişirse bu iç durum ile kabın gerçek hali arasında bir uyuşmazlık doğar. Sorunun kendisi bütün gerçekleştirimler için aynı; ölçülecek olan, her birinin bu uyuşmazlığı nasıl ele aldığı — sessizce mi görmezden geldiği, adıyla mı durdurduğu, yoksa hiç oluşmayacak biçimde mi tasarlandığı.

Yineleyicinin Arayüz Sözü

Değişiklik konusuna girmeden önce yineleyicinin bozulmayan tarafı ölçülür. Iterator arayüzü hasNext ve next çiftini tanımlar ve next’in tükenmiş bir yineleyicide ne yapacağını da tanımlar.

  • KO16 — Üç kap da aynı üç ögeyle doldurulur ve tükenene kadar gezilir; ardından bir next çağrısı daha yapılır. Ölçülen tek şey bu son çağrının sonucudur.
// TemelSoz.java — next tukendiginde uc gerceklestirim de ayni mi davraniyor
import java.util.*;

public class TemelSoz {
    static String dene(Iterable<String> kap) {
        Iterator<String> it = kap.iterator();
        while (it.hasNext()) it.next();
        try {
            it.next();
            return "next basarili (beklenmiyordu)";
        } catch (NoSuchElementException e) {
            return e.getClass().getSimpleName();
        }
    }

    public static void main(String[] args) {
        List<String> kaynak = List.of("a", "b", "c");
        System.out.println("ArrayList  : " + dene(new ArrayList<>(kaynak)));
        System.out.println("HashSet    : " + dene(new HashSet<>(kaynak)));
        System.out.println("ArrayDeque : " + dene(new ArrayDeque<>(kaynak)));
    }
}
ArrayList  : NoSuchElementException
HashSet    : NoSuchElementException
ArrayDeque : NoSuchElementException

Üç aile de aynı istisnayı, aynı adla veriyor. Bu, Iterator arayüzünün doğrudan tanımladığı bir sözdür: next sözleşmesi tükenmiş bir yineleyicide bu istisnayı fırlatmayı şart koşar. Kabın kendisi değişmediği sürece — yalnız gezinildiği sürece — üç gerçekleştirim de birbirinden ayırt edilemez. Ayrım, gezinme sırasında kabın kendisi değiştiğinde başlıyor.

Bu ölçüm önceki derslerdeki “yok anahtarın yanıtı” ölçümüyle aynı kalıptadır: kabın hiç değişmediği, yalnız sorgulandığı durumda üç gerçekleştirim tek bir davranış etrafında toplanıyor. Ayrım her zaman kabın durumu değiştiğinde ortaya çıkıyor — önceki derste anahtar yazılırken, bu derste ise kap gezinirken değiştiğinde.

Üç Yanıt: Erken Hata, Anlık Kopya, Sessizlik

  • KO17 — Dört kap da aynı dört ögeyle doldurulur ve for-each ile gezilirken, ikinci öge işlendiği anda kaba beşinci bir öge eklenir. Ölçülen, bu eklemenin gezintiyi nasıl etkilediğidir: istisna mı, sessiz devam mı, ve devam ediyorsa kaç öge görülüyor. Değişiklik dört kapta da aynı adımda yapılır; değişen yalnız kaptır.
// UcYanit.java — gezinme sirasinda kap degisirse uc ayri yanit
import java.util.*;
import java.util.concurrent.*;

public class UcYanit {
    static String dene(Iterable<String> kap, Runnable degisiklik) {
        int sayilan = 0;
        try {
            for (String s : kap) {
                sayilan++;
                if (sayilan == 2) degisiklik.run();
            }
            return "istisna yok, sayilan=" + sayilan;
        } catch (ConcurrentModificationException e) {
            return e.getClass().getSimpleName() + ", sayilan=" + sayilan;
        }
    }

    public static void main(String[] args) {
        List<String> hataVerenListe = new ArrayList<>(List.of("a", "b", "c", "d"));
        System.out.println("ArrayList (erken hata)                    : "
                + dene(hataVerenListe, () -> hataVerenListe.add("ekstra")));

        Set<String> hataVerenKume = new HashSet<>(List.of("a", "b", "c", "d"));
        System.out.println("HashSet (erken hata)                      : "
                + dene(hataVerenKume, () -> hataVerenKume.add("ekstra")));

        List<String> kopyaUzerinden = new CopyOnWriteArrayList<>(List.of("a", "b", "c", "d"));
        System.out.println("CopyOnWriteArrayList (anlik kopya)        : "
                + dene(kopyaUzerinden, () -> kopyaUzerinden.add("ekstra")));

        Map<String, Integer> sessizKalan = new ConcurrentHashMap<>();
        sessizKalan.put("a", 1); sessizKalan.put("b", 2);
        sessizKalan.put("c", 3); sessizKalan.put("d", 4);
        System.out.println("ConcurrentHashMap (hicbir sey soylemeyen) : "
                + dene(sessizKalan.keySet(), () -> sessizKalan.put("ekstra", 5)));
    }
}
ArrayList (erken hata)                    : ConcurrentModificationException, sayilan=2
HashSet (erken hata)                      : ConcurrentModificationException, sayilan=2
CopyOnWriteArrayList (anlik kopya)        : istisna yok, sayilan=4
ConcurrentHashMap (hicbir sey soylemeyen) : istisna yok, sayilan=5

Dört satır üç ayrı sözü gösteriyor ve üçü de Iterator arayüzünün kendisinde yazmıyor — Iterator gezinme sırasında değişikliğe ne olacağını hiç tanımlamıyor, tanımı seçilen sınıfa bırakıyor. ArrayList’in ve HashSet’in yineleyicileri her next çağrısında kabın yapısal olarak değişip değişmediğini denetliyor ve değiştiyse hemen, adıyla, duruyor: ikinci ögeden sonra ekleme yapıldığı için üçüncü next çağrısı ikisinde de düşüyor. İki ayrı veri yapısının (dizi tabanlı liste, karma tabanlı küme) aynı sayacı tutması rastlantı değil — AbstractList ve HashMap (ki HashSet içten içe bir HashMap sarar) aynı modCount düzeneğini paylaşıyor; erken hata tek bir sınıfın değil, çerçevenin tasarım deseninin bir parçası.

CopyOnWriteArrayList ise gezinmeye başladığı anda dizinin bir kopyasını almış durumda; kaba ne eklenirse eklensin, o kopyada dört öge var ve yineleyici dördünü de görüyor, hiçbir şeyi fark etmeden. Dördüncü satır öncekilerin ikisinden de farklı: ConcurrentHashMap ne istisna fırlatıyor ne sabit bir kopyada donuyor — bu koşumda eklenen beşinci öge de gezintiye giriyor ve toplam beş öge sayılıyor, ama bu API’nin verdiği bir söz değil, yalnız bu koşumun sonucu; kütüphane yalnızca istisna fırlatmayacağını garanti ediyor, göreceği ögelerin hangileri olacağını garanti etmiyor.

Erken Hata Bir En İyi Çaba, Bir Garanti Değil

ArrayList’in “erken hata veren” davranışı bir sonraki ölçümde kendi sınırını gösteriyor. Yineleyici her değişikliği yakalamıyor; yalnızca bir sayaç tutuyor ve bu sayacı yalnızca next çağrıldığında denetliyor. Silinen öge listenin sondan bir öncekiyse, döngü zaten bir daha next çağırmadan bitiyor ve denetim hiç çalışmıyor.

  • KO18 — Beş ögelik aynı liste iki kez gezilir; birinde ortadaki (2. indeks), ötekinde sondan bir önceki (3. indeks) öge, gezinti o ögeye ulaştığında silinir. Değişen tek şey silinen konumdur.
// SessizBitis.java — erken hata her zaman calismiyor
import java.util.*;

public class SessizBitis {
    static void dene(String etiket, int silinecekIndeks) {
        List<String> liste = new ArrayList<>(List.of("a", "b", "c", "d", "e"));
        List<String> gezilen = new ArrayList<>();
        try {
            for (String s : liste) {
                gezilen.add(s);
                if (gezilen.size() - 1 == silinecekIndeks) {
                    liste.remove(silinecekIndeks);
                }
            }
            System.out.println(etiket + " -> istisna yok, gezilen=" + gezilen);
        } catch (ConcurrentModificationException e) {
            System.out.println(etiket + " -> " + e.getClass().getSimpleName()
                    + ", gezilen=" + gezilen);
        }
    }

    public static void main(String[] args) {
        dene("indeks 2 (ortadaki) silinirse", 2);
        dene("indeks 3 (sondan bir onceki) silinirse", 3);
    }
}
indeks 2 (ortadaki) silinirse -> ConcurrentModificationException, gezilen=[a, b, c]
indeks 3 (sondan bir onceki) silinirse -> istisna yok, gezilen=[a, b, c, d]

Aynı sınıf, aynı for-each, aynı tek silme — sonuç iki ayrı şey söylüyor. Ortadaki öge silindiğinde beklenen olur: bir sonraki next çağrısı sayaç uyuşmazlığını yakalar ve istisna düşer. Sondan bir önceki öge silindiğinde ise listenin boyutu beşten dörde iner ve döngünün iç sayacı (cursor), bir sonraki ögeye gitmek yerine, artık listenin yeni boyutuna eşitlenmiş olur; hasNext bunu “gezinti bitti” diye okur ve döngü hiç bir next çağırmadan sona erer — son öge ("e") hiç görülmeden. Hiçbir istisna yok, ama gezinti de eksik: dört öge görüldü, beşincisi sessizce atlandı.

Bu, “erken hata” adının yanıltıcı yanı. ArrayList‘in yineleyicisi her değişikliği yakalayan bir gözlemci değildir; yalnızca next çağrıldığında bir sayaç karşılaştırması yapan, en iyi çaba (best-effort) bir denetimdir. Java’nın kendi belgeleri bu davranışı garanti olarak değil, hataları erken yakalamaya yardımcı bir araç olarak tanımlar — ve bu ölçüm, aracın kendisinin de atlayabileceği bir durumu gösteriyor.

Bunun pratik sonucu, bir programın doğruluğunu “istisna düşmedi” cümlesine dayandıramayacağı olur. İki koşumun ikisi de aynı sınıfla, aynı döngüyle, tek bir silme işlemiyle çalıştı; biri hatayı adıyla bildirdi, öteki hiçbir şey söylemeden bir ögeyi kaybetti. Denetimin çalışıp çalışmayacağı silinen konumun listenin neresinde olduğuna bağlı — bu da çoğu zaman çalışma zamanı verisine bağlı bir bilgi olduğundan, aynı kaynak kod bazı girdilerde hatasız görünüp bazı girdilerde eksik gezinti üretebilir.

Denetimin nerede yapıldığı bu en iyi çabanın sınırını daha da netleştiriyor. hasNext hiçbir zaman bir tutarlılık denetimi yapmaz; yalnız next ve ona benzer birkaç yöntem denetler.

  • KO19 — Bir öge okunduktan sonra kaba bir öge eklenir; hemen ardından önce hasNext, sonra next çağrılır. İkisinin de aynı anda mı yoksa yalnız birinin mi istisna verdiği ölçülür.
// HasNextCheck.java — denetim sadece next() icinde calisiyor
import java.util.*;

public class HasNextCheck {
    public static void main(String[] args) {
        List<String> liste = new ArrayList<>(List.of("a", "b", "c"));
        Iterator<String> it = liste.iterator();
        it.next();
        liste.add("ekstra");
        System.out.println("degisiklikten sonra hasNext() : " + it.hasNext());
        try {
            it.next();
            System.out.println("next() basarili (beklenmiyordu)");
        } catch (ConcurrentModificationException e) {
            System.out.println("next()    -> " + e.getClass().getSimpleName());
        }
    }
}
degisiklikten sonra hasNext() : true
next()    -> ConcurrentModificationException

hasNext değişiklikten sonra bile sessizce true döndürüyor; denetim yalnız next çağrıldığında devreye giriyor. Bu, en iyi çabanın nerede durduğunu somutlaştırıyor: sayaç karşılaştırması kabın her işleminde değil, yalnız yineleyicinin ilerlediği anlarda çalışıyor. hasNext’in “evet, daha var” demesi bir garanti değil; bir sonraki adımın başarılı olacağının garantisi hiç yok.

Sınırlayıcı Ölçüm: Kural Kabın Dışından Değiştirmeyi Yasaklar

Buraya kadarki ölçümler “gezinirken kabı değiştirme” kuralının kırılmasıydı. Ama bu kural tam olarak ne demek? Yineleyicinin kendi remove yöntemi de kabı değiştiriyor — peki o neden aynı istisnayı vermiyor?

  • KO20 — Aynı beş ögelik liste açık bir Iterator ile gezilir ve iki öge, doğrudan listenin remove’uyla değil, yineleyicinin kendi remove’uyla silinir.
// YineleyiciRemove.java — kendi remove'u kurali kirmiyor
import java.util.*;

public class YineleyiciRemove {
    public static void main(String[] args) {
        List<String> liste = new ArrayList<>(List.of("a", "b", "c", "d", "e"));
        Iterator<String> it = liste.iterator();
        while (it.hasNext()) {
            String s = it.next();
            if (s.equals("b") || s.equals("d")) {
                it.remove();
            }
        }
        System.out.println("yineleyicinin kendi remove'uyla sonuc : " + liste);
    }
}
yineleyicinin kendi remove'uyla sonuc : [a, c, e]

Hiçbir istisna düşmüyor ve silme işlemi eksiksiz tamamlanıyor: iki öge de kalıcı olarak çıkarılmış. Bu, önceki iki ölçümle çelişmiyor; kuralı daha doğru okumayı gerektiriyor. Kural “koleksiyonu değiştirme” değildir — “koleksiyonu yineleyicinin dışından değiştirme”dir. Iterator.remove() çağrıldığında, yineleyici kabın iç sayacını (modCount) kendisi günceller ve kendi tuttuğu beklenen değeri de aynı anda ileri alır; bir sonraki next çağrısında ikisi hâlâ eşittir ve denetim sessizce geçer. Listenin kendi remove’u ise bu ikinci sayacı bilmiyor ve yalnız birinciyi değiştiriyor; aradaki fark bir sonraki next çağrısında ortaya çıkıyor. Erken hata mekanizması “kap değişti mi” diye sormuyor, “kap yineleyicinin bilmediği bir yoldan değişti mi” diye soruyor.

Bu ayrım, kuralın neden var olduğunu da açıklıyor. Bir yineleyici, kabın hangi ögesinde durduğunu bir iç konumla (dizi tabanlı kaplarda bir indeks, bağlı yapılarda bir düğüm referansı) izliyor. Kap dışarıdan değiştiğinde bu iç konum geçersizleşebilir — bir öge silinmişse bir sonraki next çağrısı ya yanlış bir ögeye ya da hiç var olmayan bir konuma gidebilir. Iterator.remove() kendi kaydını kendi güncellediği için bu tehlike hiç doğmuyor; dışarıdan gelen bir remove ise yineleyicinin haberi olmadan konumu kaydırıp onu tutarsız bırakıyor. Erken hata, bu tutarsızlığı sessizce yanlış bir ögeye gitmektense adıyla durmayı tercih eden bir tasarım kararı.

Bu, üç dersin ortak sonucunu bir kez daha doğruluyor. Kural bir davranışı yasaklamıyor — “koleksiyonu değiştirme” gibi kaba bir yasak koysaydı Iterator.remove()’un kendisi de her zaman istisna verirdi, oysa vermiyor. Kural, kimin bilgilendirildiğini ayırıyor: yineleyicinin kendi yolundan gelen bir değişiklik bilgilendirilmiş bir değişikliktir, kabın herhangi bir başka yolundan gelen bir değişiklik değildir. Aynı sonucu (bir ögenin kalıcı olarak çıkarılması) üreten iki çağrı, yineleyicinin bakış açısından birbirinden tamamen farklı iki olaydır.

Üç yanıt da kendi içinde tutarlıdır, ama hiçbiri ötekinin verdiği garantiyi vermez. Erken hata “yanlış çalışmaktansa dur” der; anlık kopya “gezinti ne görürse görsün, tutarlı bir görüntü sunulur” der; sessiz devam “hiçbir zaman durmam, ama gördüklerimin listesi için söz vermem” der. Üçü de meşru bir sözdür ve üçü de Iterator arayüzünün dışında, seçilen sınıfın kendi kararıdır — arayüz yalnız hasNext ve next çiftini garanti eder, gezinme sırasında kabın değişmesine ne olacağını hiç konuşmaz.

Özet

  • Yineleyicinin temel sözü — next tükendiğinde NoSuchElementException — üç gerçekleştirimde de birebir aynı çıktı; bu, Iterator arayüzünün doğrudan sözü ve kap hiç değişmediği sürece bozulmuyor.
  • Gezinme sırasında kap değiştiğinde dört gerçekleştirimde üç ayrı yanıt ölçüldü: ArrayList ve HashSet erken ve adıyla hata veriyor, CopyOnWriteArrayList gezinmeye başladığı anki kopyada kalıp değişikliği hiç görmüyor, ConcurrentHashMap istisna vermeden devam ediyor ama hangi ögeleri göreceğini garanti etmiyor.
  • Erken hata bir garanti değil, en iyi çaba: aynı silme işlemi ortadaki bir ögede istisna verirken, sondan bir önceki ögede döngüyü hiçbir uyarı vermeden bir öge eksik bitiriyor — sonuç, silinen konumun listede nerede olduğuna bağlı.
  • Sınırlayıcı ölçüm: yineleyicinin kendi remove’u aynı kuralı kırmıyor, çünkü kural “değiştirme” değil “yineleyicinin dışından değiştirme”; Iterator.remove() kabın iç sayacını ve yineleyicinin beklediği değeri aynı anda güncelliyor.
  • Üç yanıtın hiçbiri Iterator arayüzünde yazmıyor; hangisinin seçileceğine yalnızca seçilen sınıf karar veriyor ve üçü de kendi içinde tutarlı, birbirinden bağımsız sözlerdir.

Sonraki Adım

Bu derste gezinme sırasında kabın değişmesi ölçüldü; peki kabın hiç değişmediği, yalnızca gezinme sırasının sorgulandığı durumda ne oluyor? Şimdiye kadarki her ölçümde bir kabın kendi ögelerini hangi sırayla vereceğine kendi gerçekleştirimi karar verdi. Sıradaki ders bu kararı kabın dışına taşıyor: sıra kuralı artık çağıranın yazdığı, kaptan bağımsız bir nesne oluyor ve o nesnenin tutarlı olup olmadığı, kabın kendisinden önce sınanması gereken ayrı bir soru haline geliyor. Bu derste görülen kalıp orada da sürüyor: bir kural yazılabilir, derlenebilir, çoğu girdide doğru sonuç verebilir ve yine de belirli bir girdide sessizce yanlış bir davranışa düşebilir.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat