Ders 06 / 11
Yineleyiciler ve Geçerlilik
erase sonrası bir yineleyicinin okunmasının tanımsız davranışa girdiğinin ve geçerli kalan yineleyicilerin aynen kaldığının ölçümü.
Yayın Güncelleme
Bu derste
- erase edilen bir elemana ait yineleyicinin neden bir daha okunamadığını gösterir
- Hangi kapsayıcı işlemlerinin yineleyicileri geçersizleştirdiğini, hangilerinin geçersizleştirmediğini ayırır
- Geçersizleşmeyen bir yineleyicinin işlemden sonra aynen kaldığını ölçer
- Yineleyici kategorileri arasındaki (girdi, ileri, çift yönlü, rastgele erişim) farkı tanır
İçindekiler
Önceki ders stok["armut"] çağrısının haritaya sıfırla kurulmuş bir eleman
eklediğini, bunun kaynağı değiştirdiğini ölçtü. Orada değişen şey
kapsayıcının bütünüydü: eleman sayısı ve haritanın içeriği. Bu ders sınırı
daha küçük bir birime indiriyor: kapsayıcının içindeki tek bir konuma
işaret eden bir yineleyici. Bir eleman silindiğinde, o elemana işaret eden
yineleyici elde tutulmaya devam edilirse ne olur? Bu sorunun cevabı
kapsayıcıdan kapsayıcıya değişir — aynı erase çağrısı bir vector’da
bütün sonraki yineleyicileri etkilerken bir list’te yalnız silinen
elemanın kendisini etkiler — bu yüzden “yineleyici hâlâ geçerli mi” sorusu
yalnız işlemin adına bakılarak değil, hangi kapsayıcı üzerinde çağrıldığına
bakılarak yanıtlanabilir.
erase Sonrası Bir Yineleyiciyi Okumak
std::vector::erase, verilen konumdaki elemanı kaldırır ve ondan sonraki
elemanları bir konum kaydırır. Kaldırılan elemana ait yineleyici bu işlemden
sonra geçersizdir — artık var olmayan bir belleğe işaret eder ya da
üzerine başka bir elemanın taşındığı bir konuma işaret eder. Bu yineleyiciyi
okumak tanımsız davranıştır ve bu ders onu hiç çalıştırmaz; yalnız
erase’in döndürdüğü yeni, geçerli yineleyiciyi kullanır.
// erase-donen-yineleyici.cpp — erase hangi yineleyiciyi geçerli birakiyor
#include <cstdio>
#include <vector>
int main() {
std::vector<int> sayilar{10, 20, 30, 40};
auto it = sayilar.begin() + 1; // 20'yi gosteriyor
auto sonraki = sayilar.erase(it);
std::printf("erase sonrasi eleman sayisi %zu\n", sayilar.size());
std::printf("erase'in dondurdugu konumdaki deger %d\n", *sonraki);
return 0;
}
erase sonrasi eleman sayisi 3 erase'in dondurdugu konumdaki deger 30
KT7. erase(it) çağrısından sonra it değişkeni artık geçerli değil —
bu ders it‘i erase’den sonra bir daha hiç okumuyor, çünkü okumak
tanımsız davranışa girer ve tanımsız davranış bu kursta hiç çalıştırılmaz.
Onun yerine erase’in döndürdüğü sonraki yineleyicisi kullanılıyor:
standart, erase’in kaldırılan elemandan sonraki konuma işaret eden yeni ve
geçerli bir yineleyici döndüreceğini garanti eder. Bu işlemin sınıfı
belirsiz: it’in kendisinin işlemden sonraki durumu standart tarafından
tanımlanmamıştır, tek güvence onu okumamak gerektiğidir.
Geçersizleştirmeyen İşlemler: Aynen Kalan Yineleyiciler
Her değiştirici işlem yineleyicileri geçersizleştirmez. std::list::erase,
yalnız silinen elemana ait yineleyiciyi geçersizleştirir — diğer tüm
yineleyiciler geçerliliğini korur, çünkü bağlı bir liste elemanları
bitişik bellekte tutmaz ve bir düğümün silinmesi diğer düğümleri taşımaz.
// list-erase-diger-yineleyiciler.cpp — silinmeyen elemanin yineleyicisi ne oluyor
#include <cstdio>
#include <list>
int main() {
std::list<int> degerler{10, 20, 30};
auto orta = std::next(degerler.begin()); // 20
auto son = std::next(degerler.begin(), 2); // 30
degerler.erase(orta);
std::printf("silinmeyen yineleyicinin degeri %d\n", *son);
std::printf("kalan eleman sayisi %zu\n", degerler.size());
return 0;
}
silinmeyen yineleyicinin degeri 30 kalan eleman sayisi 2
KT8. son yineleyicisi, orta silinirken hiç dokunulmayan bir düğüme
işaret ediyordu ve erase(orta) çağrısından sonra da aynı düğüme işaret
etmeye devam ediyor — değeri hâlâ 30. Bu yineleyicinin durumu aynen:
silinen eleman kendisi olmadığı için standart onun geçerliliğini garanti
eder. std::vector için bu garanti yoktur — bir vector’de bir eleman
silindiğinde ondan sonraki tüm elemanlar bir konum kaydığından, silinen
konumdan sonraki her yineleyici de geçersizleşir. Bu fark, hangi
kapsayıcının seçildiğinin yineleyici kullanımını doğrudan etkilediğini
gösterir.
Yineleyici Kategorileri
Standart kütüphane yineleyicileri işlevsel kapasitelerine göre kategorilere
ayırır. Girdi yineleyicisi yalnız ileri doğru, tek geçişlik okuma
yapabilir. İleri yineleyici aynı yineleyiciyle birden çok geçiş yapmayı
destekler. Çift yönlü yineleyici (std::list’in yineleyicisi gibi) hem
ileri hem geri gidebilir ama sabit zamanlı atlama yapamaz. Rastgele erişim
yineleyicisi (std::vector’ın yineleyicisi gibi) it + n gibi sabit
zamanlı atlamayı destekler.
// yineleyici-kategorisi.cpp — rastgele erisim ve cift yonlu farki
#include <cstdio>
#include <iterator>
#include <list>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
std::list<int> l{1, 2, 3, 4, 5};
auto vit = v.begin() + 3; // sabit zamanli atlama
auto lit = std::next(l.begin(), 3); // dogrusal zamanda ilerler
std::printf("vector uzerinden atlanan deger %d\n", *vit);
std::printf("list uzerinden ilerlenen deger %d\n", *lit);
return 0;
}
vector uzerinden atlanan deger 4 list uzerinden ilerlenen deger 4
KT9. İki yineleyici de aynı sonuca ulaştı ama farklı karmaşıklıkla:
v.begin() + 3 vector’ın rastgele erişim yineleyicisi olduğu için sabit
zamanda hesaplanır; std::next(l.begin(), 3) ise list’in çift yönlü
yineleyicisi üzerinde üç kez ++ çağrısına eşdeğerdir ve doğrusal zamanda
ilerler. Bu ders hız farkını ölçmüyor — süre bu kursta hiç ölçülmüyor —
yalnız iki kategorinin farklı işlemleri desteklediğini gösteriyor: list
yineleyicisi üzerinde it + 3 yazmak derlenmez, çünkü çift yönlü yineleyici
operator+ sağlamaz.
Aralık Tabanlı for ve Örtük Yineleyiciler
Aralık tabanlı for döngüsü, arka planda begin() ve end() çağırarak
yineleyicileri örtük kullanır. Döngü gövdesinde kapsayıcıya eleman
eklenirse ya da elemanlar silinirse, örtük yineleyiciler de aynı geçerlilik
kurallarına tabidir — bu yüzden bir vector üzerinde aralık tabanlı for
gövdesinde push_back çağırmak, döngünün geri kalanını tanımsız davranışa
sokabilir. Bu ders böyle bir değişikliği hiç yapmıyor.
// araligbdan-for-salt-okuma.cpp — araligb tabanlı for elemanlari nasil gezer
#include <cstdio>
#include <vector>
int main() {
std::vector<int> sayilar{5, 10, 15};
int toplam = 0;
for (int deger : sayilar) {
toplam += deger;
}
std::printf("toplam %d\n", toplam);
std::printf("eleman sayisi degisti mi %s\n",
(sayilar.size() == 3) ? "hayir" : "evet");
return 0;
}
toplam 30 eleman sayisi degisti mi hayir
KT10. Döngü yalnız her elemanı okudu, kapsayıcıya hiç yazmadı; sayilar
işlem sonunda aynen kaldı. Örtük begin()/end() çağrıları da bu
yüzden döngü boyunca geçerli kaldı — kapsayıcının boyutu ya da içeriği
değişmediği için yineleyicilerin geçersizleşeceği bir durum hiç oluşmadı.
Boş ile Belirsiz Arasındaki Fark
end() yineleyicisi ile geçersizleşmiş bir yineleyici bazen aynı şeymiş
gibi karıştırılıyor; ikisi de “okunmaması gereken” bir konumu gösteriyor
gibi görünüyor. Ama standart bu ikisine tamamen farklı garantiler bağlıyor.
end(), kapsayıcının son elemanından bir sonraki konumu gösteren, her
zaman tanımlı ve karşılaştırmaya açık bir değerdir — it == kapsayici.end()
yazmak her koşulda geçerlidir, çünkü end()’in kendisi hiçbir zaman
geçersiz değildir; yalnız *end() ile onu dereferanslamak tanımsızdır.
Bir kapsayıcı boş olduğunda begin() == end() olur — bu, “aranan eleman
yok” demenin tanımlı bir yoludur, find’ın bulamama durumunda döndürdüğü
değer de budur. Geçersizleşmiş bir yineleyici ise belirsizdir: değeri
karşılaştırmaya bile açık değildir, it == baska_it yazmak da tanımsız
davranışa girebilir, çünkü standart geçersizleşmiş bir yineleyicinin hangi
işlemleri desteklediğini hiç garanti etmez. Bu fark önemlidir çünkü bir
kodun “boş” durumunu denetlemesi (it == end()) her zaman güvenlidir, ama
“acaba bu yineleyici hâlâ geçerli mi” sorusunu yineleyicinin kendisine
sorarak yanıtlamak mümkün değildir — geçerlilik yineleyicide taşınan bir
bilgi değildir, kaynağın geçirdiği işlemlerden çıkarılması gereken bir
bilgidir.
push_back ve Yeniden Ayırma: Bütün Yineleyicileri Etkileyen İşlem
erase yalnız silinen konumdan sonrasını etkiler; std::vector::push_back
ise çok daha geniş bir hasar bırakabilir. vector’ın ayrılmış kapasitesi
dolmuşsa push_back yeni, daha büyük bir bellek bloğu ayırır, tüm mevcut
elemanları oraya taşır ve eski bloğu serbest bırakır — bu durumda eklemeden
önce alınmış her yineleyici, işaretçi ve referans geçersizleşir, yalnız
eklenen elemana ait olanlar değil. Kapasite yeterliyse taşıma hiç olmaz ve
mevcut yineleyiciler aynen kalır; ama çağıran taraf bunu size() ve
capacity()’yi karşılaştırmadan önceden bilemez.
// push-back-yeniden-ayirma.cpp — kapasite dolunca push_back yineleyicileri nasil etkiler
#include <cstdio>
#include <vector>
int main() {
std::vector<int> sayilar;
sayilar.reserve(2);
sayilar.push_back(1);
sayilar.push_back(2);
bool kapasite_doldu = (sayilar.size() == sayilar.capacity());
sayilar.push_back(3);
std::printf("ekleme oncesi kapasite dolmus muydu %s\n",
kapasite_doldu ? "evet" : "hayir");
std::printf("eleman sayisi %zu\n", sayilar.size());
std::printf("ilk eleman %d\n", sayilar.front());
return 0;
}
ekleme oncesi kapasite dolmus muydu evet eleman sayisi 3 ilk eleman 1
KT11. reserve(2) ile ayrılan kapasite iki eleman eklendiğinde tam
doldu; üçüncü push_back çağrısı bu yüzden vector’ı yeni bir bloğa
taşıdı. Taşıma tamamlandıktan sonra kapasiteden önce alınmış olabilecek
her yineleyici belirsiz duruma girerdi — bu ders böyle bir yineleyiciyi
saklayıp taşımadan sonra okumuyor, yalnız front() ile kapsayıcının
kendisinden yeniden okuyor. front()’un 1 döndürmesi, elemanların
değerinin taşımadan etkilenmediğini gösteriyor; etkilenen şey elemanların
değeri değil, onlara önceden alınmış yineleyicilerin geçerliliği.
std::map’te Yineleyici Kararlılığı
İlişkisel kapsayıcılar bu konuda vector’dan çok list’e benzer.
std::map bir dengeli ikili arama ağacıyla gerçeklenir ve elemanlar
bitişik bellekte durmaz; bir düğüm eklendiğinde ya da silindiğinde diğer
düğümler yer değiştirmez, yalnız ağacın bağlantıları güncellenir. Bu yüzden
std::map::erase de tıpkı std::list::erase gibi yalnız silinen elemana
ait yineleyiciyi geçersizleştirir; kalan her yineleyici, işaretçi ve
referans aynen geçerli kalır. std::map::insert da benzer bir garanti
taşır: yeni bir düğüm eklemek var olan düğümleri hiç taşımaz, dolayısıyla
eklemeden önce alınmış hiçbir yineleyici geçersizleşmez.
// map-erase-diger-yineleyiciler.cpp — map erase diger yineleyicileri etkiler mi
#include <cstdio>
#include <map>
int main() {
std::map<int, int> tablo{{1, 10}, {2, 20}, {3, 30}};
auto uc = tablo.find(3);
tablo.erase(tablo.find(1));
std::printf("silinmeyen anahtarin degeri %d\n", uc->second);
std::printf("kalan eleman sayisi %zu\n", tablo.size());
return 0;
}
silinmeyen anahtarin degeri 30 kalan eleman sayisi 2
KT12. tablo.find(1)’e ait yineleyici silindi, ama uc başka bir
anahtara işaret ediyordu ve erase çağrısından etkilenmedi — uc->second
hâlâ 30. Bu durumun sınıfı std::list’teki karşılığıyla aynı: aynen.
std::vector’da aynı senaryo kurulsaydı — üç elemandan ilkini silmek —
kalan elemanlar kaydığı için ikinci ve üçüncü elemana ait yineleyiciler
geçersizleşirdi; burada geçersizleşmiyor olması map’in bellek düzeninin
vector’dan temelden farklı olmasından kaynaklanıyor, silme işleminin
“daha güvenli” yazılmış olmasından değil.
Geçersiz Yineleyici Neden Okunmaz
erase’in döndürdüğü yineleyiciyi kullanmak yerine, silinen elemana ait
eski yineleyiciyi doğrudan okumak isteyen bir kod satırı derlenir ama
çalıştırılamaz — bu, derleyicinin yakalayamayacağı bir tanımsız
davranıştır. Aşağıdaki blok o satırı yalnız yorum olarak taşır ve neden hiç
çalıştırılmadığını açıklar:
// gecersiz-yineleyici-yorumu.cpp — gecersiz yineleyiciyi okumamanin gerekcesi
#include <cstdio>
#include <vector>
int main() {
std::vector<int> sayilar{10, 20, 30, 40};
auto it = sayilar.begin() + 1;
sayilar.erase(it);
// Asagidaki satir tanimsiz davranistir ve bu yuzden hic calistirilmaz:
// std::printf("%d\n", *it);
std::printf("erase sonrasi eleman sayisi %zu\n", sayilar.size());
return 0;
}
erase sonrasi eleman sayisi 3
Derleyici *it satırını reddetmez, çünkü it’in geçerliliği bir tip
denetimi değil, bir çalışma zamanı sözleşmesidir. Standart bu sözleşmeyi
ihlal eden kodun davranışını tanımlamadığı için, güvenli olan tek yol o
satırı hiç yazmamak ya da hiç çalıştırmamaktır — bu kursun her dersinde
uygulanan kural budur. Bu, statik analiz araçlarının bazılarının neden bu
sınıf hataları tam yakalayamadığını da açıklar: it’in geçerliliği
it’in tipinde durmaz, it’i üreten kapsayıcının geçirdiği işlem
geçmişinde durur — bir araç bu geçmişi izlemedikçe, geçerli görünen bir
yineleyici çağrısının aslında geçersizleşmiş bir konuma eriştiğini
göremez.
Sınırlayıcı Ölçüm
Aynı erase çağrısı, hedef aldığı yineleyiciyle sınırlıdır — kalan
yineleyicilere ne olduğu kapsayıcının türüne, işlemin kendisine değil,
bağlıdır. Dört elemanlı bir std::list üzerinde ortadaki iki elemanı
silip uçlardaki yineleyicileri yeniden okuyalım:
#include <cstdio>
#include <iterator>
#include <list>
int main() {
std::list<int> sayilar{1, 2, 3, 4};
auto ilk = sayilar.begin();
auto son = std::prev(sayilar.end());
auto orta_bir = std::next(sayilar.begin());
auto orta_iki = std::next(sayilar.begin(), 2);
sayilar.erase(orta_bir);
sayilar.erase(orta_iki);
std::printf("%d %d\n", *ilk, *son);
return 0;
}
1 4
erase(orta_bir) yalnız orta_bir’in gösterdiği düğümü geçersizleştirir;
ilk ve son başka düğümlere bağlıdır ve etkilenmez. İkinci erase
çağrısı da aynı kuralı izler: orta_iki kendi düğümüyle birlikte geçersiz
olur, ama bu, zaten silinmiş orta_bir’e bir şey katmaz — ilk ve son
işlemden önceki değerlerini aynen göstermeye devam eder. Aynı senaryoyu
bir std::vector ile kursaydık sonuç başka olurdu: eleman silindiğinde
ardından gelen her eleman bir konum kaydırılır, dolayısıyla son gibi
sondan alınmış bir yineleyici de geçersizleşirdi. Ölçümün sınıfını
belirleyen şey silinen eleman sayısı ya da yineleyicinin nereden alındığı
değil, kapsayıcının bellek düzenidir: düğüm tabanlı bir yapıda yalnız
dokunulan düğüm geçersizleşirken, bitişik bellekte bir silme kendisinden
sonraki her konumu etkiler.
Özet
std::vector::erase, silinen elemana ait yineleyiciyi geçersizleştirir; onu erase’den sonra okumak belirsiz davranıştır ve hiç çalıştırılmaz. Çağrının döndürdüğü yeni yineleyici ise geçerlidir ve kaldırılan elemandan sonraki konumu gösterir. Sınırlayıcı ölçüm: aynı işlem birstd::list’te yalnız hedef aldığı düğümü geçersizleştirir; dokunulmayan yineleyiciler aynen kalır — geçerliliği belirleyen işlem değil kapsayıcının bellek düzenidir.std::list::erasevestd::map::erase/insert, düğüm tabanlı yapılarında yalnız dokunulan düğümün yineleyicisini geçersizleştirir; diğer tüm yineleyiciler aynen kalır — bellek bitişik olmadığı için eleman kaydırma hiç gerçekleşmez.- Yineleyiciler girdi, ileri, çift yönlü ve rastgele erişim kategorilerine ayrılır; kategori, hangi işlemlerin desteklendiğini belirler.
- Bu dersin merkezi ölçümünün sınıfı belirsiz: erase edilen bir elemana ait yineleyicinin durumu standart tarafından tanımlanmaz, tek güvence onu bir daha okumamaktır.
end()her zaman tanımlı ve karşılaştırılabilir bir değerdir; bir kapsayıcının boş olmasıbegin() == end()ile ifade edilir. Bu, geçersizleşmiş bir yineleyicinin belirsiz durumundan ayrı bir şeydir — biri “burada eleman yok” der, öbürü hiçbir garanti taşımaz.std::vector::push_back, kapasite dolmuşsa yalnız eklenen elemanı değil, önceden alınmış her yineleyiciyi geçersizleştirir; kapasite yeterliyse hiçbir yineleyici etkilenmez. Bu yüzden birvectorüzerinde döngü içindepush_backçağırırken önceden alınmış yineleyicilere güvenmemek gerekir.
Sonraki Adım
Bu ders tek bir yineleyicinin geçerliliğine odaklandı. Sonraki ders bir
kapsayıcının tüm elemanları üzerinde çalışan standart algoritmalara geçiyor:
std::sort bir kapsayıcıyı yerinde nasıl değiştiriyor ve bu değişiklik
hangi yineleyicileri etkiliyor?
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.