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, sonranextç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
Iteratorile gezilir ve iki öge, doğrudan listeninremove’uyla değil, yineleyicinin kendiremove’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ü —
nexttükendiğindeNoSuchElementException— üç gerçekleştirimde de birebir aynı çıktı; bu,Iteratorarayü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ü:
ArrayListveHashSeterken ve adıyla hata veriyor,CopyOnWriteArrayListgezinmeye başladığı anki kopyada kalıp değişikliği hiç görmüyor,ConcurrentHashMapistisna 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
Iteratorarayü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.