Kısım I · Makine · Bölüm 10
C ile Belleğe Dokunmak
Dizi, adres, pointer (Arrays, addresses, pointers)
Neden burada: Bölüm 5'te RAM'i, numaralı hücrelerden oluşan dev bir raf olarak tanıdın. O zaman raf donanımın gözüyle görünüyordu. Bu bölüm aynı rafı programcının gözünden gösterir: C'de bir değişkenin nerede durduğunu sorabilir, o yerin numarasını elinde tutabilir ve rafın hangi kısmını kullandığını kendin yönetebilirsin. Modern dillerin neden "bellek güvenliği" ile övündüğü ancak burada anlaşılır: övündükleri şey, C'de senin sırtında olan yükü senin yerine taşımalarıdır.
Neye dayanıyor: Bölüm 5.4: RAM, adres ve hücre; burada "adres"i programda bir değer olarak göreceksin. Bölüm 9: tipler (int 4 byte, char 1 byte), fonksiyonlar ve özellikle "argüman kopyalanarak geçer" cümlesi; bu bölümde o cümlenin sonucunu çözeceğiz. Küçük destekler: Bölüm 2.4 (onaltılık sayı ve 0x öneki), Bölüm 2.5 (ASCII), Bölüm 8.2 (yerellik).
- Kat 6–10Gömülü yazılım, işletim sistemi, ağ, web, uygulamaBu bölümdeki bellek fikirleri hepsinin altında çalışır.
- Kat 5Programlama dilleri ve derleyiciC, belleği doğrudan gösteren en yalın dildir. Bölüm 9–11.
- Kat 4Makine dili ve assemblyAdresler burada komutların işlenenleridir. Bölüm 7.
- Kat 3İşlemci ve bellek (CPU, RAM)Numaralı hücreler: adresler buradadır. Bölüm 5, 6, 8.
- Kat 0–2Elektrik, kapılar, devrelerBölüm 2–5.
Bu bölümün sonunda bir değişkenin belleğe nasıl yerleştiğini, bir pointer'ın aslında yalnızca bir adres (bir sayı) olduğunu, malloc ile ayrılan bellekten kimin sorumlu olduğunu ve bunu yanlış yapınca neden "segmentation fault" gibi hatalar çıktığını anlatabileceksin. Sonra bakışı yukarı çevireceğiz: JavaScript'te bir nesneyi kopyalayıp orijinalinin değiştiğini gördüğünde, bunun aynı adres fikrinin görünmeyen halinden doğduğunu fark edeceksin. Her örnek bu makinede (Linux, x86-64, gcc 15.2.0) gerçekten çalıştırılmıştır.
10.1 Dizi: yan yana hücreler, sıfırdan başlayan indeks
Bölüm 9'da int dogru = 7; yazdığında derleyici belleğin 4 byte'ını ayırıp ona dogru adını vermişti. Peki üç quiz puanını saklamak için üç ayrı değişken mi yazacaksın: puan1, puan2, puan3? Soru bankasında 500 soru olsa ne olurdu? Bu sorunun cevabı dizidir (array): aynı tipten birden çok değeri, bellekte art arda, yan yana yerleştirilmiş hücrelerde saklayan yapı. CS50'nin anlatımıyla int scores[3], derleyiciye "bana art arda üç adet int büyüklüğünde yer ver" demektir.1
int skor[3] = {80, 90, 100};
printf("%i\n", skor[0]); // 80: ilk hücre
printf("%i\n", skor[2]); // 100: üçüncü hücre
Köşeli parantez içindeki sayı indekstir (index): kaçıncı hücreye bakılacağı. İndeks sıfırdan başlar: ilk hücre skor[0], son hücre skor[2]. Bunun nedeni keyfî bir adetten çok mesafedir. Dizinin kendisi, ilk hücrenin adresiyle tanımlanır; skor[2], "ilk hücreden iki hücre ileri" demektir ve ilk hücreye uzaklık sıfırdır. Bu makinede üç hücrenin adreslerini yazdırdım (adreslerin kendisi her çalıştırmada değişir; farklarına bak):
&skor[0] = 0x7fffb4a048c0
&skor[1] = 0x7fffb4a048c4
&skor[2] = 0x7fffb4a048c8
Her adres bir öncekinden tam 4 fazla; çünkü bir int 4 byte yer kaplar (Bölüm 9.3). Dizinin toplam boyutu da sizeof skor ile sorulduğunda 12 çıktı (3 × 4). Yani skor[i] bilgisayarın içinde şu hesaptır: dizinin başlangıç adresi + i × (bir hücrenin boyutu). İşlemci bu toplamayı tek bir adım olarak yapabilir (Bölüm 4'teki toplayıcı ve Bölüm 7'deki "yükle" komutu), bu yüzden dizideki herhangi bir hücreye gitmek her zaman aynı hızdadır; hücre sayısı 3 de olsa 3 milyon da olsa. "Rastgele erişim" (random access) adındaki RAM'in (Bölüm 5.4) özelliğiyle dizi böyle bir araya gelir.
Art arda durmanın bir faydası daha var: bir hücreyi okuduğunda işlemci komşularını da önbelleğe getirir; bu da Bölüm 8.2'de gördüğün yerellik fikrinin bir sonucudur. Dizi üzerinde döngü kurmak bu yüzden hızlı olur. Dizinin bedeli ise esnemezliğidir: bellekte yan yana yer ayrıldığı için, boyutu sonradan büyütmek zordur (11. bölümde bağlı listeyle bunun çözümüne bakacağız).
3 hücrelik bir diziyle skor[100000000]'a bakarsan C seni durdurmaz: derleyici itiraz etmedi. Hesap, "başlangıç + yüz milyon × 4 byte" adresine gider ve orası senin değilse işletim sistemi programı keser. Bunu denedim: program Segmentation fault mesajıyla sonlandı (çıkış durumu 139). Aynı hata i <= 3 yazan bir döngüde de olur: CS50, 6 elemanlı bir diziyi dolaşırken i < 6 yerine i < 7 yazınca yaşanan segmentation fault'a dikkat çeker.2 Bu hatayı 10.5'te ayrıntılı ele alacağız.
10.2 Karakter dizisi ve sonlandırıcı \0
Bölüm 2.5'te "HI!" yazısının 72, 73, 33 sayıları olduğunu görmüştün. C'de yazı (karakter dizisi (string)), char'lardan oluşan bir dizidir. CS50 bunu şöyle gösterir: bir string, ilk karakterle başlayan ve NUL denilen özel bir karakterle biten karakter dizisidir; NUL, \0 olarak yazılır ve değeri 0'dır (tek L'li NUL ile iki L'li NULL farklı şeylerdir).2 Bunu doğruladım: char s[] = "HI!" için sizeof s 4, strlen(s) 3 çıktı ve dört byte'ın değerleri 72 73 33 0 idi.
| s[0] | s[1] | s[2] | s[3] |
|---|---|---|---|
'H' = 72 | 'I' = 73 | '!' = 33 | '\0' = 0 |
Sonlandırıcı neden var? Dizi, belleğin düz bir bölgesidir; hücrelerden birine "burada yazı bitti" yazmazsan, yazıyı okuyan fonksiyon nerede duracağını bilemez. strlen (string uzunluğunu veren kütüphane fonksiyonu) bu yüzden \0'a kadar saymak zorundadır ve onu saymaz.2 Yani strlen("HI!") 3'tür ama dizi 4 byte kaplar. İleride yer ayırırken "uzunluk + 1" yazılmasının nedeni budur.
Bu mimarinin ince bir sonucu, Bölüm 2.5'teki UTF-8 bilginle birleşince ortaya çıkar. strlen karakter değil byte sayar: strlen("sik") 3, strlen("şık") 5 verdi (ş ve ı ikişer byte). Türkçe metinlerde "kaç karakter" sorusu C'de bir kütüphane fonksiyonuyla cevaplanamaz; kodlamanın (Bölüm 2.5) bilinmesi gerekir.
CS50 bir tuzağa da dikkat çeker: iki yazıyı karşılaştırmak için == kullanamazsın, çünkü == yazıların içeriğini değil bellekteki adreslerini karşılaştırır. İçerik için strcmp kullanılır ve aynıysa 0 döner.1 Bu tuzak, adres kavramını anladığında şaşırtıcı olmaktan çıkar. Şimdi ona geçelim.
10.3 Adres ve işaretçi: & ve *
Bölüm 5'ten beri biliyorsun: RAM'deki her hücrenin bir adresi var. Bölüm 2.4'te onaltılık sayıların neden kullanıldığını da gördün; adresler genellikle 0x önekiyle onaltılık yazılır, çünkü 0x10 adresiyle 10 değerini ayırmak gerekir.3 C'de iki işleç, adresle değer arasında gidip gelmeni sağlar:
&n: "n'in adresi" (CS50'nin çevirisiyle "the address of n").3*p: "p'nin gösterdiği adresteki değer" (adresten değere gitmek, dereference).3
Bir adresi saklayan değişkene işaretçi (pointer) denir. CS50'nin tanımıyla pointer, bir şeyin adresini saklayan değişkendir; en yalın hâliyle, bellekteki bir adrestir.3 Küçük bir program:
int n = 50;
int *p = &n; // p, n'in adresini saklar
printf("n=%d *p=%d\n", n, *p); // ikisi de 50
printf("%zu\n", sizeof p); // 8
Satır satır: int *p, "p, bir int'in adresini tutan değişkendir" der (* burada tanımda geçiyor). &n, n'in adresini verir ve p'ye atanır. *p ise gidip o adresteki değeri okur: 50. Aynı yıldız işareti iki işe yarar: tanımda "pointer" demektir, ifadede "şu adrese git" demektir. Bu yüzden başlangıçta kafa karıştırır. sizeof p 8 çıktı: CS50'nin dediği gibi pointer genellikle 8 byte'lık bir değerdir.3 Sebep, bu tür makinelerde adresin kendisinin de 64 bit (8 byte) olmasıdır: 64 bitle 264 farklı adres yazılabilir (Bölüm 2.2'deki 2n fikri; Bölüm 6'daki x86-64 adı da buradan gelir).
Hukuk benzetmesi burada gerçekten açıklayıcı. Mahkemenin dosyasına bir esas numarası verilir: 2025/412. Numara dosyanın kendisi değildir; dosyanın nerede bulunacağını söyler. Bir meslektaşına "şu esas numaralı dosyaya bak" diyerek numarayı vermek ve dosyanın fotokopisini vermek iki farklı eylemdir. Numarayı vermek, onun aynı dosyayı açıp değiştirebilmesi demektir. İşte pointer, numaradır; *p, dosyayı açıp içine bakmaktır; &n ise dosyanın numarasını öğrenmektir. Bu ayrımı bir sonraki örnek keskinleştirir.
Dizi adı ve pointer aritmetiği
Bir dizinin adı, çoğu bağlamda, ilk hücresinin adresi gibi davranır. CS50'nin anlatımıyla bir string aslında char *'dır, yani ilk karakterin adresidir; cs50.h yalnızca bu pointer'ı string adıyla saklayan bir yardımcıdır.3 Bu yüzden s'ye 1 eklemek ikinci karaktere götürür.3 Bir int pointer'ına 1 eklemek ise 1 byte değil bir int boyu ilerlemek demektir. Bu makinede: int a[3] = {10, 20, 30}; int *p = a; için *p, *(p + 1) ve a[2] 10, 20, 30 verdi ve p + 1 ile p arasındaki fark 4 byte çıktı. Dolayısıyla a[i] ile *(a + i) aynı işin iki yazılışıdır; 10.1'deki "başlangıç + i × boy" hesabının C'deki yazılışı budur.
Neden gerekli: kopya yerine adres vermek
Bölüm 9.4'te "argümanlar kopyalanır" demiştik. Şimdi sonucu görelim: iki değeri takas eden bir fonksiyon. İlk sürüm değer alır, ikinci sürüm adres alır.
void swap(int a, int b) { int t = a; a = b; b = t; }
void swap2(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int x = 1, y = 2;
swap(x, y); // çıktı: 1 2 (hiçbir şey değişmedi)
swap2(&x, &y); // çıktı: 2 1 (değişti)
Bu makinede ilk çağrıdan sonra x y yine 1 2 yazdı, ikinciden sonra 2 1. Nedeni CS50'nin açıklamasıdır: değerle verildiğinde fonksiyon kopyalarla çalışır; adresle verildiğinde fonksiyon asıl değişkenin nerede durduğunu bilir ve orada değişiklik yapabilir.3 Esas numarası benzetmesine dönersek: swap'a fotokopi verdin, swap2'ye numara. Fotokopiyle oynanan dosya aslını etkilemez.
Bir kullanıcının puanını "güncelle" diye bir fonksiyona verdiğini düşün. Fonksiyona kullanıcının kopyası giderse, fonksiyon puanı artırır ama asıl kullanıcı değişmez ve puan kaybolur. Asıl kullanıcının adresi giderse değişiklik kalıcıdır. Go ile yazacağımız arka uçta (Bölüm 37) bu ayrım Kullanici ile *Kullanici tipleri arasındaki farktır; ilk bakışta bir yıldız işareti, gerçekte bir kopya–adres kararıdır. Agent'ın yazdığı bir fonksiyonun imzasında yıldız görünce artık şunu söyleyebilirsin: "bu, kopya değil adres alıyor; yani çağıranın verisini değiştirebilir."
10.4 Yığın ve öbek: malloc, free, bellek sızıntısı
Bir programın belleği tek bir düz alan değildir; kabaca üç yere ayrılır. Bunu, main'in, bir global değişkenin, malloc ile ayrılan bir bloğun ve bir yerel değişkenin adreslerini yazdıran bir programla görmek mümkündür. Bu makinede şu çıktı geldi:
kod (main) = 0x6261809531a9
global = 0x626180956010
öbek (heap) = 0x6261a1d3d010
yığın (stack) = 0x7ffe5248c02c
Dört adres dört farklı bölgeden: kod ile global değişken birbirine yakın, öbek biraz ötede, yığın çok daha yüksek bir adreste. (Adresler her çalıştırmada değişir; ben ./u programını iki kez çalıştırınca farklı adresler gördüm. Önemli olan bölgelerin ayrı olması.) İki bölgeye yakından bakalım.
Yığın (stack): fonksiyon çağrılarının defteri
Yığın (stack), bir kâğıt yığını gibi çalışır: son konan ilk alınır. Bottom-Up CS kitabının anlatımıyla her fonksiyon çağrısı yığında yeni bir çerçeve (stack frame) alır; çerçevede en az, fonksiyon bitince dönülecek adres, girdi argümanları ve yerel değişkenler için yer vardır.4 Fonksiyon bitince çerçeve atılır. İşte Bölüm 9'daki "her fonksiyon kendi kopyasıyla çalışır" cümlesinin fiziksel nedeni: her çerçeve ayrı bir bellek bölgesidir. CS50 de aynı şeyi çizer: main ve swap'ın ayrı çerçeveleri vardır, bu yüzden değerler bir çerçeveden ötekine doğrudan değiştirilemez.3
Yığın otomatik yönetilir; sen hiçbir şey yapmadan ayrılır ve geri verilir. Bedeli, ömrünün fonksiyon süresiyle sınırlı olmasıdır: fonksiyon bitince yerel değişkenlerin ortadan kalkar. Ayrıca yığın sınırlıdır; CS50, çok fazla fonksiyon çağrısının yığını taşırdığını söyler (stack overflow).3
Öbek (heap) ve malloc
Peki program çalışırken, ne kadar yer gerekeceğini ancak o zaman öğreniyorsan? Kullanıcının kaç soruluk bir sınav oluşturacağını bilmeden dizi tanımlayamazsın. Çözüm öbektir (heap): istek üzerine bellek ayrılan, ömrünü senin belirlediğin bölge. İki fonksiyon kullanılır: malloc verdiğin kadar byte'lık bir blok ayırır ve onun adresini döndürür; free o bloğu geri verir.3 CS50'nin örneği, bir yazıyı kopyalamaktır:
char *s = "HI!";
char *t = malloc(strlen(s) + 1); // uzunluk + 1: \0 için de yer
if (t == NULL) return 1; // malloc başarısız olduysa NULL döner
strcpy(t, s); // s'nin içeriğini t'ye kopyala
t[0] = 'h';
printf("%s %s\n", s, t); // HI! hI!
free(t); // işin bitince geri ver
Çıktı HI! hI!: t gerçek bir kopya; t[0]'ı değiştirmek s'yi etkilemedi. CS50'nin ilk denemesi ise string t = s; yazmaktı ve bu yalnızca adresi kopyalar: iki pointer aynı yazıyı gösterir.3 Satırlar: strlen(s) + 1, yazının \0'ı da dahil boyudur (10.2). NULL kontrolü önemlidir: CS50'nin notu, malloc bir sorun olursa NULL döndürdüğünü ve bunun kontrol edilmesi gerektiğini söyler.3 free, "bu bloğu artık kullanmıyorum" demektir.
- Çöp değer (garbage value):
malloc'un verdiği bellek boş olmak zorunda değildir; CS50'nin deyişiyle daha önce başka bir amaçla kullanılmış olabilir ve içinde rastgele artıklar kalır. Başlatmadığın belleğin değerine güvenme.3 - Bellek sızıntısı (memory leak):
mallocettiğin bloğufreeetmezsen, program çalıştığı sürece o bellek ayrılmış kalır ve kimse onu kullanamaz. Hata mesajı vermez; program yalnızca zamanla şişer. CS50'nin Valgrind örneği tam bunu gösterir: program hata vermeden çalışır ama ayrılan bellek geri verilmemiştir ve araç bunu raporlar.3 - Boyut hatası: 3
intiçin yer ayırıp dördüncüyü yazmak. Aşağıda göreceğiz.
Bağlı liste: öbeğin ilk gerçek ürünü
Öbek ve pointer birleşince yeni bir veri düzeni mümkün olur: her hücrenin bir değeri ve bir sonraki hücrenin adresi olsun. Bu düzen Bölüm 11'in konusu olduğundan yalnızca şunu söylüyorum: pointer'lar, belleğin farklı yerlerinde duran parçaları birbirine iplikle bağlamaya yarar. Üç düğümlü bir bağlı listeyi (1 2 3) kurup dolaştım ve AddressSanitizer açıkken hiçbir hata çıkmadı; sizeof ile bir düğümün 16 byte olduğunu da gördüm (4 byte sayı, 4 byte dolgu, 8 byte pointer). Derinine Bölüm 11.5'te gireceğiz.
Kullanıcıların leaderboard'unu düşün: kaç kullanıcı olacağı önceden bilinmez. Sabit boyutlu bir dizi (int puanlar[100]) 101. kullanıcıda yetmez; 1.000.000 yer ayırmak da şimdiden bellek israfıdır. "Öbekten, gerektiği kadar" fikri her büyüyen veri için geçerlidir. Ama ileride kullanacağımız dillerde (JavaScript, Go) bu ayırma ve geri verme senin yerine yapılır: bir dizi push ile büyür, kimse malloc yazmaz. Bu kolaylığın nasıl sağlandığı bir sonraki bölümün konusu.
10.5 Bellek güvenliği: taşma, segmentation fault, çöp toplayıcı, sahiplik
Şimdi bu bölümün asıl sorusuna geliyoruz. C sana belleğin doğrudan anahtarını verir, ama o belleğin hangi kısmının senin olduğunu denetlemez. Bu yüzden iki tür hata ortaya çıkar. CS50 bunlara arabellek taşması (buffer overflow) der: ayrılan bloğun ötesine yazmak. Öbekte olursa heap overflow, yığında fazla çerçeve açıp yığın sınırını aşarsa stack overflow.3 Bunun somut bir örneği:
int *x = malloc(3 * sizeof(int)); // 3 int: x[0], x[1], x[2]
x[3] = 33; // dördüncüsü: bize ait değil!
Bu program sorunsuz derlenir. Ben bunu sıradan biçimde (-Wall uyarısı bile çıkmadı) derleyip çalıştırdım: program bitti yazdı ve çıkış durumu 0 oldu; hata hiç görünmedi. CS50'nin Valgrind örneğinde de aynı hata (3 elemanlık diziye 33 değerini x[3]'e yazmak) program hata vermese de araç tarafından yakalanır.3 Ben aynı programı gcc'nin AddressSanitizer seçeneğiyle (-fsanitize=address) derleyip çalıştırdım ve şu çıktıyı aldım (ilgili satırlar):
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x781feefe001c
WRITE of size 4 at 0x781feefe001c thread T0
0x781feefe001c is located 0 bytes after 12-byte region [0x781feefe0010,0x781feefe001c)
Okunuşu: "4 byte'lık bir yazma yaptın; yazdığın yer, 12 byte'lık bölgenin hemen bittiği yer." Araç olmadan program sessizce komşu belleğin üstüne yazar. Bu sessizlik tehlikelidir, çünkü hata ortaya çıktığında (başka bir yerde, başka bir zamanda bozuk bir değer olarak) sebebinden uzaktadır. CS50 aynı noktaya scanf ile yazı okurken de işaret eder: kullanıcının ne kadar uzun bir yazı gireceğini bilemediğin için önceden ayırdığın alan yetmeyebilir ve bu kod "güvensizdir".3 Dışarıdan gelen veriyle taşıma, güvenlik tartışmalarının (Bölüm 41) konusudur; burada yalnızca kaynağını bil: C belleğin sınırını kontrol etmez.
Segmentation fault ise, işletim sisteminin bu ihlalin en çok göze batan türünü yakalamasıdır: programın kendisine ait olmayan bir bölgeye dokunması. CS50 bunu "programın dokunmaması gereken bir belleğe dokunması" diye tanımlar.2 Neden yalnızca bazen çıkar, neden bazen sessiz geçer? Çünkü işletim sistemi bellek bölgelerini kaba sınırlarla korur (Bölüm 13 ve 15); sınır içinde kalan bir taşma fark edilmez.
Sorunu çözmenin üç yolu
Modern diller bu yükü farklı yollarla kaldırır. Kerem'in karşılaşacağı üç yol:
- Çöp toplayıcı (garbage collector): Çalışma zamanı, artık kullanılmayan belleği kendisi bulup geri verir. MDN'nin anlatımıyla JavaScript, nesneler oluşturulurken belleği otomatik ayırır ve artık kullanılmadıklarında serbest bırakır; C gibi diller ise
malloc()vefree()gibi elle bellek yönetimi araçlarına sahiptir.5 Bellek yaşam döngüsü her dilde aynıdır: ayır, kullan, serbest bırak; yüksek seviyeli dillerde birinci ve üçüncü adım örtüktür.5 Bunun bir tuzağı var: MDN, bu otomatikliğin geliştiricide "belleği düşünmeme" yanılgısı yaratabileceğini söyler.5 JavaScript motorlarının kullandığı ana yöntem "erişilebilirlik"tir: köklerden (global nesne) başlayıp erişilebilen nesneleri bulur, erişilemeyenleri toplar.5 Go'nun da tasarım gerekçelerinde çöp toplama vardır: Go SSS'si, çok çekirdekli işlemcilerin dile eşzamanlılık desteği gerektirdiğini ve büyük eşzamanlı programlarda kaynak yönetimini yönetilebilir kılmak için çöp toplamanın ya da en azından güvenli bir otomatik bellek yönetiminin gerektiğini söyler.6 - Sahiplik (ownership): Rust'ın yolu. Rust Kitabı'nın ifadesiyle bazı diller çöp toplama kullanır; bazılarında programcı belleği elle ayırıp serbest bırakır; Rust ise "üçüncü bir yaklaşım" izler: bellek, derleyicinin denetlediği sahiplik kurallarıyla yönetilir. Kurallar ihlal edilirse program derlenmez.7 Kurallar kısadır: her değerin bir sahibi vardır; aynı anda tek sahip olabilir; sahip kapsamın dışına çıkınca değer atılır.7 Bu yöntemin ayırt edici yanı, bedelin çalışma zamanında değil derleme zamanında ödenmesidir: Rust Kitabı, sahiplik özelliklerinin programı çalışırken yavaşlatmadığını söyler.7 Rust'ı Bölüm 21'de dil haritasında göreceğiz.
- Dilden pointer aritmetiğini çıkarmak: Go SSS'sinde "Neden pointer aritmetiği yok?" sorusunun cevabı tek kelime: güvenlik. Pointer aritmetiği olmayınca, yanlışlıkla geçerli sayılan hatalı bir adres türetmek imkânsızlaşır; ayrıca bu durum çöp toplayıcının gerçeklenmesini de kolaylaştırır.6 Yani 10.3'te gördüğün
p + 1işlemi Go'da yok; bu bilinçli bir kısıtlama.
Bunların hepsi aynı terimle özetlenir: bellek güvenliği (memory safety), bir programın yalnızca kendisine ayrılmış belleğe dokunmasının dil tarafından güvence altına alınması. Go SSS'sinin bir cümlesi bunu ters yönden doğrular: Go'yu C koduyla birleştirmek, Go'nun sağladığı bellek güvenliği ve yığın yönetimi özelliklerinden vazgeçmek anlamına gelir.6 Neden C hâlâ kullanılıyor? Çünkü bu denetim bir maliyettir ve işletim sistemi çekirdeği, sürücüler ve gömülü yazılım (Bölüm 12–13) gibi yerlerde programcı o maliyeti ödemek ya da denetimi bırakmak istemez. Bu bölümde sana düşen: bir dilin "bellek güvenli" olmasının ne anlama geldiğini ve neyi senin yerine yaptığını bilmek.
10.6 Değer ve referans: JavaScript'te nesne kopyalarken sürprizler
JavaScript'te pointer yoktur; ama aynı fikir görünmeden çalışır. MDN'nin tanımıyla ilkel değer (primitive), nesne olmayan ve metodu bulunmayan veridir; yedi türü vardır (string, number, bigint, boolean, undefined, symbol, null) ve hepsi değiştirilemezdir.8 Nesneler, diziler ve fonksiyonlar ise ilkel değildir ve değiştirilebilir. Fark, kopyalarken ortaya çıkar. Aşağıdaki kodu Node ile çalıştırdım:
let n = 5; let m = n; m = 6;
console.log(n, m); // 5 6 → ilkel değer kopyalandı
const a = { ad: "Kerem", puan: 10 };
const b = a; // b = a: nesnenin KENDİSİ değil, ona giden "yol" kopyalandı
b.puan = 99;
console.log(a.puan); // 99 → a da değişti!
const c = { ...a }; // yeni bir nesne: "yüzeysel kopya"
c.puan = 5;
console.log(a.puan, c.puan); // 99 5 → bu kez ayrı
const d = { ad: "K", rozetler: ["ilk"] };
const e = { ...d }; // yüzeysel kopya
e.rozetler.push("seri");
console.log(d.rozetler); // [ 'ilk', 'seri' ] → iç içe dizi ORTAK!
const f = structuredClone(d); // derin kopya
f.rozetler.push("x");
console.log(d.rozetler.length, f.rozetler.length); // 2 3
Satır satır: İlk blokta sayı kopyalanır; değişken değerini taşır, m'yi değiştirmek n'i etkilemez. İkinci blokta b = a nesnenin kendisini değil ona giden başvuruyu (referans, reference) kopyalar; esas numarası benzetmesindeki gibi iki kişi aynı dosya numarasına sahip olur ve birinin yazdığı ötekinde görünür. Üçüncü blokta { ...a } yeni bir nesne yapar. Ama MDN'nin yüzeysel kopya (shallow copy) tanımıyla, bu kopyanın özellikleri kaynağın özellikleriyle aynı başvuruları paylaşır; kaynağı ya da kopyayı değiştirirsen ötekini de değiştirebilirsin, derin kopya (deep copy) ise tamamen bağımsızdır.9 Dördüncü blok bunu gösterir: rozetler dizisi iki nesne arasında ortaktır, birinde push ettiğin öbüründe görünür. Beşinci blokta structuredClone() derin kopya yapar; MDN, bu yöntemin değeri "structured clone algorithm" ile derin kopyaladığını söyler.10
| C | JavaScript | |
|---|---|---|
| Kopyalanan şey | Değer, ya da açıkça adres (&x) | İlkel değer kopyalanır; nesnede başvuru kopyalanır |
| Adresi görme | Evet: &, *, %p | Hayır; gizlidir |
| Bellek ayırma/geri verme | Elle: malloc/free | Çöp toplayıcı yapar |
| Sınır dışı erişim | Tanımsız davranış, çökme ya da sessiz bozulma | Dizi dışına okumada undefined, bellek bozulmaz |
Hukuk uygulamamızda kullanıcının durumunu tutan bir nesne düşün: { ad, puan, seri, rozetler: [...] }. Kerem'in prototipinde muhtemelen buna benzer bir nesne, bir puan ve bir seri alanı vardır. "Eski durumu sakla, yeni durumu hesapla" gibi bir mantıkta yazılan const yeni = eski; satırı, iki ayrı kullanıcı sanılırken aslında aynı nesneyi gösterir; yeni durumu değiştirmek eskisini de bozar. Ekranı yeniden çizdiren çerçevelerde (Bölüm 31, React) bu hata sık görülür, çünkü onlar "önceki durum ile yeni durum aynı mı?" sorusunu çoğu zaman başvuruya bakarak cevaplar. Çözüm, nesneyi her güncellemede kopyalamaktır ve nesne iç içeyse yüzeysel kopyanın yetmeyeceğini bilmektir. Agent'a "bu nesneyi yerinde mi değiştiriyor, yoksa kopyasını mı alıyor?" diye sormak, bu bölümün doğrudan ürünüdür.
Bölüm 5'te RAM'i adreslerle numaralanmış hücreler olarak anlattık; bu bölümde o adresin bir program değeri (pointer) olabileceğini ve bir dizinin bu hücrelerin art arda dizilmesi, bir string'in o dizinin \0 ile bitmesi olduğunu gördün. Bölüm 9'da "argümanlar kopyalanarak geçer" demiştik; burada kopyanın yığındaki ayrı çerçeveden geldiğini ve adres vererek bunun aşılabildiğini gördün. JavaScript'teki "nesne referansı" ve Go'daki * aynı fikirdir, yalnızca C'de çıplak, diğerlerinde giyinik. Kat planı dilinden: C, Kat 3'teki adres numaralarını Kat 5'e taşımış; modern diller ise o numaraları yeniden gizlemiş, yani bir soyutlama katmanı daha eklemiş (Bölüm 1.4).
Bu bölümde neler kuruldu
Dizi, aynı tipten değerlerin bellekte art arda tutulmasıdır; indeks sıfırdan başlar ve skor[i] "başlangıç + i × boy" hesabıdır. Karakter dizisi (string), char dizisidir ve \0 ile biter; bu yüzden strlen("HI!") 3 ama kapladığı yer 4 byte'tır. Adres, bir hücrenin numarasıdır; & adres verir, * adresten değere gider. Pointer, adres saklayan değişkendir ve 64 bitlik makinede 8 byte'tır. Argümanlar kopyalanır; adres vermek, çağrılan fonksiyonun asıl değişkeni değiştirmesine izin verir. Bellek yığın (fonksiyon çerçeveleri, otomatik) ve öbek (malloc/free, elle) olarak ayrılır; free unutulursa bellek sızıntısı olur. Ayrılan bloğun ötesine yazmak arabellek taşmasıdır ve bazen segmentation fault olarak görünür. Bellek güvenliği, çöp toplayıcı (JavaScript, Go) ya da sahiplik (Rust) gibi yollarla sağlanır; Go ayrıca pointer aritmetiğini dilden çıkarmıştır. JavaScript'te ilkel değerler kopyalanır, nesnelerde başvuru kopyalanır; { ...a } yüzeysel, structuredClone derin kopyadır.
Terimler sözlükte, Bölüm 10 başlığı altında.
CS50x 2026, Lecture 4 ders notları, şu başlıklar: Pointers, Strings, Pointer Arithmetic, Copying and malloc, Valgrind, Garbage Values, Swapping, Overflow. Diziler ve karakter dizileri için aynı kursun Lecture 2 notlarında Arrays ve Strings başlıkları. Derinleşmek istersen: Rust Kitabı, "What Is Ownership?" (sahiplik fikrinin kısa ve açık anlatımı) ve MDN, "Memory management" (JavaScript'te çöp toplama).
Takıldığın her yeri yapay zekâ öğretmenine sor. Bir öneri: ondan sana 4 elemanlı bir int dizisi için bellek haritası çizdirmesini iste: adresler 4'er artarak, kutular içinde; sonra aynı haritada *(p + 2)'nin hangi kutuyu gösterdiğini sen bul.
Bölüm sonu soruları
Kitaba bakmadan cevapla. Kâğıt kalem serbest.
int skor[3] dizisinde son elemana hangi indeksle erişilir?skor[3] dizinin dışındadır. Bkz. 10.1.char s[] = "HI!" bellekte kaç byte yer kaplar ve neden?int n = 50; int *p = &n; sonrasında *p neyi verir?* "pointer" demektir; ifadede *p "p'nin gösterdiği adrese git, oradaki değeri al" demektir. Adresi veren ise &n'dir. Bkz. 10.3.swap(int a, int b) fonksiyonu main'deki değerleri neden değiştirmez?malloc ile ayrılan bellek free edilmezse ne olur?malloc/free ile bellek ayırıp geri vermek programcının işidir. Bkz. 10.4.p + 1) yoktur?const b = a; ve b.puan = 99; yazınca a.puan da 99 oluyor. Neden?{ ...a } (yüzeysel) ya da structuredClone (derin) gerekir. Bkz. 10.6.Örnek cevabı göster
Pointer, bir değerin bellekteki adresini saklayan değişkendir; yani değerin kendisi değil, onun nerede durduğunu söyleyen bir numara. Mahkeme dosyasının esas numarası gibi: numara dosyanın kendisi değildir ama dosyayı bulmayı sağlar. Bir fonksiyona değeri verirsem fotokopisini vermiş olurum, fonksiyon kopyayı değiştirir ve aslı aynı kalır. Adresi verirsem fonksiyon aynı dosyayı açıp aslını değiştirebilir. Bu yüzden bir fonksiyonun çağıranın verisini kalıcı biçimde değiştirmesi gerekiyorsa değer yerine adres veririz.
Cevabı göster
Bellek güvenliği, programın yalnızca kendisine ayrılmış belleğe dokunmasının dil tarafından güvence altına alınmasıdır. C'de bunu denetleyen yoktur: taşma, sızıntı ve free sonrası kullanım programcının sorumluluğundadır. JavaScript ve Go çöp toplayıcıyla belleği otomatik geri verir (Go ayrıca pointer aritmetiğini çıkarmıştır); programcı malloc/free yazmaz. Rust çöp toplayıcı kullanmaz, sahiplik kurallarını derleyici denetler ve kurallara aykırı kod derlenmez; maliyet çalışma zamanında değil derleme zamanında ödenir.
Kaynaklar
- CS50x 2026, Lecture 3 ders notları. Kaynak: CS50 Hafta 3 notları; diziler ve arama,
strcmpile karakter dizisi karşılaştırma, segmentation fault. - CS50x 2026, Lecture 2 ders notları. Kaynak: CS50 Hafta 2 notları ve transkripti; diziler,
stringve NUL karakteri,strlen. - CS50x 2026, Lecture 4 ders notları. Kaynak: CS50 Hafta 4 notları ve transkripti; onaltılık sayı ve
0x,&ve*, pointer,string=char *, pointer aritmetiği,==ile adres karşılaştırma,malloc/free,NULL, Valgrind, çöp değerler, takas ve yığın çerçeveleri, taşma türleri,scanf. - Ian Wienand, Computer Science from the Bottom Up, Bölüm 5.2: Elements of a process: yığın ve yığın çerçevesi (dönüş adresi, argümanlar, yerel değişkenler).
- MDN, Memory management: bellek yaşam döngüsü, otomatik ayırma ve çöp toplama, erişilebilirlik (mark-and-sweep).
- The Go Authors, Go FAQ: tasarım gerekçesi ve çöp toplama, "Why is there no pointer arithmetic?", C ile bağlamanın bellek güvenliğinden vazgeçmek olması.
- Steve Klabnik ve Carol Nichols, The Rust Programming Language, "What Is Ownership?": sahiplik kuralları ve çöp toplama/elle yönetim karşılaştırması.
- MDN, Glossary: Primitive: JavaScript'in yedi ilkel türü ve değiştirilemezlik.
- MDN, Glossary: Shallow copy: yüzeysel ve derin kopya.
- MDN,
structuredClone(): derin kopya yöntemi.
Bu bölümdeki programların çıktıları (adres farkları, sizeof, strlen, segmentation fault, AddressSanitizer raporu, takas ve JavaScript sonuçları) yazar tarafından Linux x86-64, gcc 15.2.0 ve Node ile gerçekten çalıştırılarak elde edilmiştir.