Ders 12 / 15
Tanımsız Davranış
Sarkan işaretçi okumak, bırakılmış belleği kullanmak, bir bloğu iki kez bırakmak ve dizinin sınırı dışına erişmek dördü de tanımsız davranıştır; hiçbiri koşturulmaz, ders her birini düzyazıda tarif eder ve yanına tanımlı karşılığını koşturarak gösterir.
Yayın Güncelleme
Bu derste
- Tanımsız davranışın dört kaynağını (sarkan işaretçi, bırakılmış bellek, çift bırakma, sınır dışı erişim) sözleşme ihlali olarak tanır
- Tanımsız davranışın neden var olduğunu derleyicinin varsayım yapma hakkıyla açıklar
- Her tanımsız kaynağın yerine tanımlı bir karşılığı yazar ve koşturarak doğrular
İçindekiler
Önceki ders dört bölgeyi tek eksende topladı: yığıt, statik alan, dizgi değişmezi ve öbek — hepsinin ortak noktası, ömrünü kimin belirlediğinin kaynakta yazılı olmasıydı. Bu ders o sözleşmenin kırıldığı anı ölçüyor. Sözleşme “bu bloğu kim bırakacak” sorusuna bir yanıt veriyordu; sözleşme kırılınca ortaya çıkan şey bir hata mesajı değil, tanımsız davranıştır (undefined behavior) — ve tanımsız davranışın en önemli özelliği, ölçülemeyecek olmasıdır.
Bu son cümle bir uyarı değil, dersin sınırını çizen bir tanımdır. Aşağıdaki dört bölüm tanımsız davranışın dört ayrı kaynağını anlatıyor — sarkan işaretçi, bırakılmış bellek, çift bırakma, sınır dışı erişim — ama hiçbiri kaynağın kendisini koşturmuyor. Her bölümün yanında koşan kod, o kaynağın yerine geçen tanımlı karşılıktır: sözleşme kırılmadan önce araya giren bir denetim satırı. Bu ayrım kurs boyunca hep aynıdır — ölçülen şey her zaman tanımlı davranıştır — ama bu derste özellikle önemlidir, çünkü ölçülmeyen tarafın adı da düzyazıda açıkça geçiyor.
Tanımsız Davranış Neden Var
C standardı her ifadenin anlamını tanımlamaz. Bir kısım ifade tanımlıdır: int toplam = a + b; her zaman aynı kuralla çalışır. Bir kısmı uygulamaya bağlıdır
(implementation-defined): derleyici bir seçim yapar ve o seçimi belgeler. Ama bir kısım
ifade tanımsızdır: standart onun ne yapacağını hiç söylemez — derleyici istediği gibi
davranabilir, hatta hiç davranmayabilir.
Bu boşluk bir eksiklik değil, bir tasarım kararıdır. Derleyici, bir programın tanımsız davranışa hiç girmediğini varsayarak optimizasyon yapar. Örneğin bir işaretçinin her zaman geçerli bir bloğu gösterdiğini varsayabilir, çünkü geçersiz bir bloğu göstermesi zaten tanımsızdır ve standart bu durumda hiçbir garanti vermez. Bu varsayım sayesinde derleyici gereksiz denetimleri silebilir, sırayı değiştirebilir, bir değeri yeniden hesaplamak yerine önbelleğe alabilir. Varsayım yanlış çıkarsa — yani program gerçekten tanımsız davranışa girerse — üretilen kod hâlâ derlenir ve hâlâ bir şey yapar, ama o şeyin ne olduğu standardın konusu değildir.
Bu varsayımın etkisi derleyici sürümüyle, optimizasyon düzeyiyle ve hatta çevredeki kodun şekliyle değişir; aynı tanımsız ifade bir derlemede fark edilmeden geçebilir, bir başkasında programın tamamen farklı bir yol izlemesine yol açabilir. Standart bu iki sonucu da eşit derecede geçerli sayar, çünkü ikisi de “tanımsız” sözünün kapsamındadır. Bu yüzden tanımsız davranışı bulmanın yolu programı çalıştırıp “hata verdi mi” diye bakmak değildir — hata vermemesi hiçbir şey kanıtlamaz, yalnız o belirli koşumda, o belirli derleyicide bir şey gözlenmediğini gösterir.
Ölçüm çekirdeğinin dört sınıfı arasında bu dersin konusu sahipsiz bellektir: sözleşme kırıldığında ne dil, ne çağıran, ne de çağrılan artık o bloğa tanımlı bir yoldan erişemiyordur — blok hâlâ orada durabilir, ama onu güvenle kullanacak hiçbir taraf kalmaz. Bu yüzden aşağıdaki dört kaynağın hiçbiri “kim sahipleniyor” sorusuna doğrudan bir yanıt vermez; hepsi, sahiplik sözleşmesi zaten çiğnendikten sonraki anı gösterir.
Bu yüzden “çalışıyor görünmesi” hiçbir kanıt taşımaz. Bir sonraki bölüm, bu depoda ölçüm çekirdeğinin dört bilerek koşturmadığı kaynağı tek tek ele alıyor; her birinde önce sözleşme neden kırıldığı düzyazıyla anlatılıyor, sonra yanına tanımlı bir karşılığı koşturuluyor.
Sarkan İşaretçiyi Okumak
Bir blok bırakıldıktan sonra ona işaret eden işaretçi sarkar (dangling): işaretçinin
kendisi hâlâ eski adresi tutar, ama o adresteki bellek artık ayırıcıya geri verilmiştir.
Sarkan bir işaretçiyi dolaylamak (*p) — okumak amacıyla bile olsa — tanımsız davranıştır.
Bellek fiilen hâlâ aynı içeriği taşıyor gibi görünebilir, çünkü ayırıcı o bloğu henüz başka
bir isteğe vermemiş olabilir; bu rastlantıdır, garanti değildir. Aynı program bir sonraki
koşumda, ya da aynı koşumda bir sonraki ayırma isteğinden sonra, o adresi tamamen farklı bir
bloğa verebilir.
Ders bu okumayı hiç koşturmuyor. Yerine, sarkan işaretçinin oluşmasını engelleyen tanımlı
kalıbı koşturuyor: bir blok bırakıldığı anda işaretçiye NULL atamak ve her kullanımdan önce
bunu denetlemek.
// koru.c — sarkan isaretcinin olusmasini engelleyen tanimli karsilik
#include <stdlib.h>
const char *sarkan_karsiligi(void) {
int *blok = malloc(sizeof *blok);
if (blok != NULL) {
*blok = 9;
}
free(blok);
blok = NULL;
return (blok == NULL) ? "isaretci bos, dolaylama yapilmadi" : "erisildi";
}
Bu karşılığın ölçümü, dersin tek main’ini içeren son kod bloğunda, öbür üç karşılıkla
birlikte gösteriliyor.
Dikkat edilmesi gereken nokta şu: blok’u NULL’a çekmek, işaretçinin kendisini
değiştirir, işaret ettiği belleği değil — o bellek free çağrısıyla zaten ayırıcıya geri
döndü. Denetim yalnız aynı işaretçi değişkeninin bir daha kullanılmasını engelliyor; aynı
adresi tutan başka bir işaretçi varsa (örneğin aynı bloğa işaret eden ikinci bir
değişken), o ikinci işaretçi hâlâ sarkar ve bu kalıp onu korumaz. Kalıbın kapsamı, tam
olarak denetlediği işaretçi değişkeniyle sınırlıdır.
Bırakılmış Belleği Kullanmak
Bırakılmış belleği kullanmak (use-after-free), sarkan işaretçiyi okumaktan biraz daha
geniştir: bloğa yazmayı da kapsar. free çağrısından sonra aynı bloğa *p = değer;
yazmak, ayırıcının o bellek bölgesini yeniden kullanıma açmış olabileceği anlamına gelir —
yazma, artık ayırıcının kendi defterine ait olan baytların üzerine gidebilir ve ayırıcının
iç tutarlılığını bozabilir. Bu da tanımsızdır ve bir hata iletisi vermeyebilir; program bir
süre daha “normal” çalışabilir.
Tanımlı karşılığı, blokla ilişkilendirilmiş bir geçerlilik bayrağı taşımaktır: blok bırakıldığında bayrak düşürülür ve her yazma bu bayrağa bakar.
// gecerlilik.c — birakma sonrasi yazmayi bayrakla engelleyen kalip
#include <stdlib.h>
const char *kullanim_karsiligi(void) {
int *ikinci = malloc(sizeof *ikinci);
int gecerli = (ikinci != NULL);
if (gecerli) {
*ikinci = 4;
}
free(ikinci);
ikinci = NULL;
gecerli = 0;
if (gecerli) {
*ikinci = 7;
}
return gecerli ? "yazildi" : "yazilmadi, bayrak dustu";
}
Bu karşılığın ölçümü de aynı son kod bloğunda gösteriliyor. Bayrağın yanında işaretçinin
kendisi de free’den hemen sonra NULL’a çekiliyor. Bu satır süs değildir: o olmadan, uyarıları
açık bir derleme ikinci’nin free sonrasında hâlâ metinde geçtiğini görüp uyarı basar — dal
hiç çalışmasa bile, çünkü derleyici dalın çalışmayacağını her zaman kanıtlayamaz. Derleyicinin
kendisi bile bırakılmış bir işaretçiyi gözünde tutmaya devam eder; bayrak dalı kapatır,
NULL’a çekme adın bırakılmış bir adres taşımasını önler ve ikisi birlikte bloğu
uyarısız derletir.
Bayrağın yeri de önemlidir: bayrak, koruduğu işaretçiyle aynı ömre sahip olmalıdır. Bayrak yerel bir değişkense ve işaretçi bir işlevden diğerine geçiyorsa, bayrak işaretçiyle birlikte taşınmadığı sürece çağrılan taraf işaretçinin hâlâ geçerli olup olmadığını bilemez — bu da geçerlilik denetiminin kendisinin, denetlediği bloktan ayrı bir sahiplik taşıdığı anlamına gelir; bayrağı kim güncelleyecek, kimin sorumluluğundadır.
Çift Bırakma
Aynı bloğu iki kez free’ye vermek de tanımsızdır. Ayırıcı, bir bloğu bıraktıktan sonra o
adresi kendi defterine (serbest liste) ekler; ikinci free çağrısı aynı adresi bu deftere
ikinci kez eklemeye çalışır ve deftere ait iç yapıyı bozabilir. Sonuç, ayırıcının bir sonraki
malloc çağrısında görünür ya da hiç görünmez — ikisi de standardın söylediği bir şey değil.
Tanımlı karşılığı, bir bloğun bırakıldığını da bir bayrakla işaretlemek ve ikinci bırakma isteğini bu bayrakla atlamaktır.
// tekbirak.c — cift birakmayi bayrakla engelleyen kalip
#include <stdlib.h>
const char *tek_birakma(void) {
int *ucuncu = malloc(sizeof *ucuncu);
int birakildi = 0;
if (!birakildi) {
free(ucuncu);
birakildi = 1;
}
if (!birakildi) {
free(ucuncu);
birakildi = 1;
}
return birakildi ? "bir kez birakildi" : "birakilmadi";
}
İkinci birakildi denetimi burada hiç çalışmayacak — kod satırı olarak var, ama
birakildi bayrağı o noktada zaten doğru olduğu için koşulu hiç sağlamayacak. Bu, dersin
başında kurulan kuralın somut hâlidir: tanımsız yolu gösteren satır kaynakta durabilir, yeter
ki hiç yürütülmesin. Ölçümü yine son kod bloğunda.
Çift bırakma, sahiplik açısından bakıldığında ilginç bir durumdur: blok bırakıldığı anda
sahipliği zaten hiç kimseye devretmiştir — ne dil, ne çağıran, ne çağrılan onu artık
sahiplenmez. İkinci free çağrısı bu boşluğa yapılan bir çağrıdır; ayırıcı o adresi
tanımaz ya da yanlış tanır, çünkü elinde o adresle ilgili artık geçerli bir kayıt
yoktur. Tek-bırakma bayrağı bu boşluğu doldurmuyor, yalnız oraya bir daha girilmesini
engelliyor.
Sınır Dışı Erişim
Bir dizinin [0, uzunluk) aralığının dışına erişmek — dizi[uzunluk] ya da daha ötesi —
tanımsızdır. Bellek fiziksel olarak orada bir şey içerebilir, ama o bayt dizinin bir parçası
değildir; derleyici, bir dizi erişiminin her zaman sınırlar içinde kaldığını varsayarak sınır
denetimlerini silebilir. Sınır dışı bir okuma rastgele bir değer, sınır dışı bir yazma başka
bir değişkenin verisini bozabilir.
Tanımlı karşılığı, erişimden önce istenen indeksi bilinen uzunlukla karşılaştırmaktır.
// sinirli.c — indeksi uzunlukla karsilastirip erisimi tanimli tutan kalip
const char *sinirli_erisim(int istenen) {
int dizi[4] = {10, 20, 30, 40};
int uzunluk = 4;
if (istenen >= 0 && istenen < uzunluk) {
(void)dizi[istenen];
return "erisildi";
}
return "reddedildi, sinir disi";
}
istenen < uzunluk denetimi C’nin kendisinde yoktur — dizi erişimi (dizi[i]) dilde her
zaman sessizce izin verir, ne çalışma zamanında ne derleme zamanında bir denetim yapılır.
Denetim tamamen programcının eklediği bir satırdır; eklenmezse dizinin ötesine geçen bir
indeks derlenir ve tanımsız davranışa girer.
Bu, öbür üç kaynaktan farklı bir noktayı gösteriyor: sarkan işaretçi ve bırakılmış bellek bir zamanlama sorunudur — blok bir noktada geçerliyken sonra geçersiz olur. Sınır dışı erişim ise zamanlamadan bağımsızdır; dizi hiç bırakılmamış, hâlâ tamamen geçerli olsa bile yanlış indeks tanımsız davranışa girer. Ortak nokta, ikisinde de derleyicinin hiçbir çalışma zamanı denetimi eklememesidir — ne zamanlamayı, ne indeksi C’nin kendisi izler.
Dört Karşılığın Birlikte Koşturulması
Dördü de tanımsız değil, tanımsızlığı önleyen kalıplardır; hepsi tek bir main’de,
sırayla koşturuluyor.
// olcum.c — dordunun de tanimli karsiligi tek main'de olculuyor
#include <stdio.h>
const char *sarkan_karsiligi(void);
const char *kullanim_karsiligi(void);
const char *tek_birakma(void);
const char *sinirli_erisim(int istenen);
static void gozle(const char *ad, const char *sonuc) {
printf("%-28s%s\n", ad, sonuc);
}
int main(void) {
printf("%-28s%s\n", "gozlem", "sonuc");
gozle("sarkan isaretci", sarkan_karsiligi());
gozle("birakilmis bellek", kullanim_karsiligi());
gozle("cift birakma denemesi", tek_birakma());
gozle("gecerli indeks", sinirli_erisim(2));
gozle("sinir disi indeks", sinirli_erisim(5));
return 0;
}
gozlem sonuc sarkan isaretci isaretci bos, dolaylama yapilmadi birakilmis bellek yazilmadi, bayrak dustu cift birakma denemesi bir kez birakildi gecerli indeks erisildi sinir disi indeks reddedildi, sinir disi
SL1. blok == NULL denetimi burada sarkan işaretçiyi önlüyor, sarkan işaretçinin
kendisini ölçmüyor. Tanımsız davranışın ölçülebilir bir “sonucu” yoktur; ölçülebilen tek şey,
ona hiç girilmediğinin kanıtıdır — bu yüzden çıktı satırı “dolaylama yapılmadı” diyor, “değer
şu çıktı” demiyor.
SL2. Bayrağın kendisi sıradan bir int‘tir; C’nin ayrı bir “geçerlilik” tipi yoktur.
Güvence, tipten değil, her yazma noktasının bayrağı denetlemesi disiplininden gelir —
derleyici bu disiplini zorlamaz, denetlemeyi unutan bir satır sessizce derlenir.
SL3. tek_birakma içindeki ikinci birakildi denetimi koşumda hiç çalışmadı — çıktı
tek bir “bir kez birakildi” satırı verdi, ikinci bir bırakmanın izi yok.
SL4. Son iki satır aynı işlevin aynı diziye iki farklı istenen değeriyle çağrılışıdır:
geçerli indeks dizinin içinde kalıp erişiliyor, sınır dışı indeks erişilmeden reddediliyor —
ikisi de tanımlı, ikisi de dizinin ötesine hiç dokunmuyor.
Dört satırın da ortak noktası, hiçbirinin bir dilin ya da bir çalışma zamanının garantisine
dayanmamasıdır. sarkan_karsiligi işaretçiyi kendi elleriyle NULL’a çeker, kullanim_karsiligi
bayrağı kendi elleriyle düşürür, tek_birakma ikinci bırakmayı kendi elleriyle atlar,
sinirli_erisim sınırı kendi elleriyle karşılaştırır — dördü de aynı bloğu sahiplenen bir
taraf olmadığı için, kalan tek güvence kaynakta yazılı denetimdir. Bu, önceki dersin dört
sınıfından (dil, çağıran, çağrılan, sahipsiz) hiçbirinin burada devrede olmadığı anlamına
gelmez; tam tersine, sahipsiz sınıfının ne anlama geldiğini tam da bu dört örnek
somutlaştırıyor: blok bir noktada geçerliliğini kaybettiğinde, dil de çağıran da çağrılan da
onu artık kendiliğinden korumaz. Koruma varsa, onu koyan kod satırıdır — derleyici değil,
çalışma zamanı kütüphanesi değil, tip sistemi hiç değil. Bu yüzden sahipsiz sınıfının bu
dersteki karşılığı, bir önceki dersteki dil/çağıran/çağrılan sınıflarının yerini almaz;
onların hepsinin ortak bir zayıf noktasını, sözleşme kırıldığı andaki durumu, ayrıca ele alır.
Sınırlayıcı Ölçüm
Tanımsız davranış bir hata mesajı değildir. Yukarıdaki dört karşılık her koşumda birebir aynı çıktıyı veriyor — ama bu tutarlılık, karşılıklarının tanımlı olmasından geliyor, ilgili tanımsız işlemlerin “aslında zararsız olduğunu” göstermiyor. Bu dersin hiçbir kod bloğu bir sarkan işaretçiyi dolaylamıyor, bırakılmış bir bloğa yazmıyor, bir bloğu iki kez bırakmıyor ya da bir dizinin ötesine erişmiyor; hepsi bunları önleyen kalıbı koşturuyor. Bir programın “çalışıyor görünmesi”, içinde tanımsız davranış olmadığının kanıtı değildir — derleyici sürümü, optimizasyon düzeyi ya da bir sonraki ayırma isteği değiştiğinde aynı kaynak farklı davranabilir.
Bu, dersin ölçüm sınırını da belirliyor: burada koşturulan hiçbir program, tanımsız davranışın “ne yaptığını” göstermiyor, çünkü öyle bir şey göstermek zaten kavramın tanımıyla çelişir. Ders yalnız iki şeyi ölçebiliyor — sözleşmenin nerede kırıldığını düzyazıyla anlatmayı ve kırılmanın yerine geçen tanımlı yolu koşturmayı. Aradaki fark, bir laboratuvar deneyiyle bir tarif arasındaki farka benzer: deney sonucu gözlemlenebilir, tarif yalnız izlenirse sonucu garanti eder — izlenmezse ne olacağı tarifin konusu değildir.
Özet
- Tanımsız davranış, derleyicinin hiç girilmediğini varsayarak optimizasyon yaptığı bir boşluktur; “çalışıyor görünmek” onun tanımlı olduğunu göstermez.
- Sarkan işaretçiyi okumak, bırakılmış belleği kullanmak, bir bloğu iki kez bırakmak ve dizinin sınırı dışına erişmek — dördü de tanımsızdır ve bu derste hiçbiri koşturulmadı.
- Her kaynağın tanımlı bir karşılığı vardır: geçerlilik bayrağı, tek-bırakma bayrağı ve indeks-uzunluk denetimi — hepsi programcının eklediği, dilin zorlamadığı satırlardır.
- Bu dersin ölçtüğü bellek sahipsiz sınıfındadır: sözleşme kırıldığında blok bırakılmakla yükümlü hiçbir tarafça artık tanımlı yoldan erişilemez.
Sonraki Adım
Sözleşmenin kırılması yalnız bellek erişiminde değil, bir işlevin hata döndürdüğü yolda da görülür — orada da bir şeyin temizlenmesi gerekir, ama bu kez tanımsız davranışa girmeden. Sonraki ders bu temizliği çağrılan sınıfının işlevi üzerinden ölçüyor: dönüş kodu, hata numarası ve tek çıkış noktalı temizlik kalıpları.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.