Menu

C++ Tanımsız Davranış: Nedir ve Nasıl Önlenir

Tanımsız davranış (UB), C++ standardının hiçbir kural koymadığı koddur; çökebilir, veriyi bozabilir veya çalışıyormuş gibi görünebilir. Yaygın nedenleri, "sorunsuz çalıştı" demenin neden hiçbir şey kanıtlamadığını ve UB'yi yakalayan araçları öğrenin.

Bu sayfada çalıştırılabilir editörler var - düzenle, çalıştır ve sonucu anında gör.

"Tanımsız Davranış" Aslında Ne Demektir

Önceki sayfa, try/catch'in programınızın tanımladığı ve bilerek fırlattığı hataları nasıl ele aldığını gösterdi. Tanımsız davranış bunun tam tersidir: C++ standardının hiçbir anlam vermeyi reddettiği işlemler kümesidir. Yakalanacak bir istisna, bir hata kodu ya da çökme garantisi yoktur. Derleyici, UB'nin asla gerçekleşmediğini varsaymakta ve gerçekleştiğinde dilediğini yapmakta serbesttir.

İşte UB'yi bu kadar tehlikeli yapan da bu özgürlüktür. Aynı hatalı satır dizüstünüzde "doğru" yanıtı yazdırabilir, bir sunucuda çöp döndürebilir ve -O2'de optimize edici tarafından tamamen silinebilir. UB "belgelemediğimiz davranış" değil, "dilin hakkında hiçbir söz vermediği davranıştır". Senin görevin onu en baştan hiç yazmamaktır.

int arr[3] = {1, 2, 3};
int x = arr[5];   // tanimsiz davranis: dizinin sonunun otesini okumak

Burada derleyici hatası yoktur ve birçok çalıştırmada sana sessizce başıboş bir tamsayı verir. Bu görünürdeki başarı bir tuzaktır.

Sınır Dışı Okuma veya Yazma

UB'nin en yaygın biçimi, sahibi olmadığın belleğe dokunmaktır. Yerleşik diziler ve std::vector::operator[] hiçbir sınır denetimi yapmaz; sonun ötesindeki bir indeks (ya da negatif bir indeks) okusan da yazsan da anında UB'dir.

Dikkat edilmesi gereken hata, < demek istediğin yerde <= kullanmaktır: i == v.size() olduğunda son elemanın bir ötesini indekslersin ki bu UB'dir. İndekse ihtiyacın olmadığında aralık tabanlı for (daha önce ele alındı) tercih et, çünkü o sonun dışına taşamaz. Gerçekten elle indeksliyor ve bir güvenlik ağı istiyorsan, v.at(i) belleği sessizce bozmak yerine std::out_of_range fırlatır:

Bir hatayı avlarken at() kullan; indekslerin geçerli olduğunu kanıtladıktan sonra sıcak döngülerde []'ye geri dön.

Sarkan İşaretçiler ve Use-After-Free

İşaret ettiği nesneden daha uzun yaşayan bir işaretçi veya referans sarkar (dangling). Onu kullanmak UB'dir; bellek yeniden kullanılmış, serbest bırakılmış ya da hiç var olmamış olabilir. Bu, akıllı işaretçilerin (önceki bölümden) kaçınmana yardımcı olduğu tuzaktır, ama ham işaretçiler hâlâ buna düşmene izin verir.

En keskin biçimi, yerel bir değişkenin adresini döndürmektir. Yerel değişken, fonksiyon dönünce ölür; böylece çağıran taraf hiçliğe işaret eden bir işaretçiyle baş başa kalır:

int* makeNumber() {
    int n = 42;
    return &n;   // yerel bir degiskenin adresini dondurur - return'den sonra yok olur
}
// Sonucu dereference etmek tanimsiz davranistir.

Aynı şey bir delete'ten sonra ya da bir vector yeniden tahsis edip içine işaret eden iteratörleri veya işaretçileri geçersiz kıldığında olur:

int* p = new int(5);
delete p;
cout << *p;   // use-after-free: tanimsiz davranis

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // yeniden tahsis edebilir - 'first' artik sarkar
cout << *first;   // tanimsiz davranis

Savunmalar zaten bildiğin şeylerdir: herhangi bir işaretçinin ihtiyaç duyduğu sürece nesneleri yaşat, sahiplik içeren ham işaretçiler yerine referansları ve akıllı işaretçileri tercih et ve bir kabı yeniden boyutlandırabilecek herhangi bir işlemden sonra işaretçileri/iteratörleri yeniden al.

İlklendirilmemiş Değişkenler ve İşaretli Taşma

Bir değişkene değer vermeden onu okumak yerleşik türler için UB'dir; varsayılan bir 0 yoktur. Değişken, o bellekte zaten bulunan bitleri tutar ve optimize edici onu hiçbir zaman ilklendirilmemiş halde okumadığını varsayabilir.

sum çıplak bir int sum; olarak bildirilseydi, her sum += i önce belirsiz bir değer okurdu: UB ve genellikle çalışıyor gibi göründüğü için kötü şöhretli derecede zor bir hata. İlklendirmeyi bir alışkanlık hâline getir: int x = 0; veya int x{};.

Bir başka sessiz suçlu da işaretli tamsayı taşmasıdır. İşaretli bir int'i azamisinin ötesine itmek UB'dir (işaretsiz türler öngörülebilir biçimde başa döner; işaretli türler dönmez):

int big = 2147483647;   // 32-bit int'te INT_MAX
int oops = big + 1;     // isaretli tasma: tanimsiz davranis

"Negatif bir sayıya döner" diye buna güvenme; derleyici taşmanın gerçekleşemeyeceğini varsayıp buna göre optimize edebilir. Tanımlı bir başa dönmeye ihtiyacın varsa işaretsiz bir tür kullan ya da toplamadan önce sınırları denetle.

UB'yi Sanitizer'lar ve Uyarılarla Yakalamak

UB konusunda test ederek güvene ulaşamazsın, çünkü başarılı bir çalışma hiçbir şey garanti etmez. İşe yarayan şey, UB'yi derleyicinin sanitizer'larıyla (GCC ve Clang'da mevcuttur) çalışma zamanında sesli hâle getirmektir.

// AddressSanitizer: sinir disi, use-after-free, sizintilar
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: isaretli tasma, null deref, hatali donusumler
g++ -fsanitize=undefined -g main.cpp -o app && ./app

Mevcut testlerini bu bayraklarla çalıştır; "sorunsuz çalışan" sınır dışı okuma, use-after-free ya da işaretli taşma, dosyayı ve satırı adlandıran kesin bir rapora dönüşür. Derleyicinin sen daha çalıştırmadan şüpheli kodu (olası bir ilklendirilmemiş okuma gibi) da işaretlemesi için bunları -Wall -Wextra ile birleştir.

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

Herhangi bir sanitizer raporunu, görmezden gelinecek bir uyarı değil, mutlaka düzeltilmesi gereken bir hata olarak değerlendir; sana standardın o satır hakkında hiçbir söz vermediğini söylüyor.

Özet

Tanımsız davranış, C++'ın güvenlik bariyerlerinin kalktığı kısmıdır: sınır dışı erişim, sarkan işaretçiler, use-after-free, ilklendirilmemiş okumalar ve işaretli taşma, hiçbir tanımlı anlamı olmayan kod üretir ve "sorunsuz çalıştı" asla doğru olduğunun kanıtı değildir. Güvende kalmanın yolu savunmacı yazmaktır (her değişkeni ilklendir, kap sınırlarına saygı göster, heap belleğine akıllı işaretçilerin sahip olmasına izin ver) ve ardından sessiz UB'nin sesli, düzeltilebilir bir rapora dönüşmesi için -fsanitize=address, -fsanitize=undefined ve -Wall -Wextra ile doğrulamaktır.

Bu, Hatalar ve Hata Ayıklama bölümünü kapatır. İstisnalar, try/catch ve UB'ye karşı sağlıklı bir korku sayesinde artık sessizce ve kazara değil, sesli ve bilerek başarısız olan C++ yazmak için gerekli araçlara sahipsin.

Sıkça Sorulan Sorular

C++'ta tanımsız davranış nedir?

Tanımsız davranış (UB), C++ standardının açıkça hiçbir tanımlı sonuç bırakmadığı her işlemdir; örneğin bir dizinin sonunun ötesini okumak ya da sarkan bir işaretçiyi dereference etmek. Derleyici her şeyi yapabilir: çökebilir, çöp değer döndürebilir, kodu optimize edip silebilir ya da bugün çalışıyor görünüp yeniden derlemeden sonra bozulabilir. Bu, programınızdaki bir hatadır, dilin bir özelliği değil.

C++ programım tanımsız davranış içermesine rağmen neden çalışıyor?

"Sorunsuz çalıştı" UB hakkında hiçbir şey kanıtlamaz. Standart her iki yönde de hiçbir garanti vermez; dolayısıyla bir UB hatası bugün sizin makinenizde, sizin derleyicinizle beklediğiniz sonucu üretip ardından farklı bir optimizasyon seviyesinde, platformda veya derleyici sürümünde çökebilir. Başarılı bir çalışmayı asla UB'nin zararsız olduğunun kanıtı sayma; onu gerçekten yakalamak için bir sanitizer kullan.

C++'ta tanımsız davranışı nasıl yakalarsınız?

Sanitizer'larla derleyin: -fsanitize=address (AddressSanitizer) sınır dışı okuma/yazmaları ve use-after-free'i bulur, -fsanitize=undefined (UndefinedBehaviorSanitizer) ise işaretli taşmayı, null dereference'leri ve hatalı dönüşümleri işaretler. Uyarıları açın (-Wall -Wextra) ve testlerinizi bu bayraklarla çalıştırın; bunlar sessiz UB'yi açık bir çalışma zamanı raporuna dönüştürür.

Coddy programming languages illustration

Coddy ile kodlamayı öğren

BAŞLA