Programlamanın dört temel ayağı olan değişkenler, fonksiyonlar, döngüler ve kontrol yapılarından sonra, C ya da C++ öğrenenlerin ilk zorlu engeli bizim “işaretçi” dediğimiz, pointer’lardır. Her ne kadar defalarca anlatılsa da, nedense işin püf noktasının farkına varılma süresi kişiden kişiye fark gösterir. Bir de ben anlatmayı denemek istiyorum, bakalım ne kadar başarılı olacağım.
Başlamadan önce değişken tiplerine tekrar bakmakta fayda var. Değişkenleri programımızda değerleri hatırlamak ve kaydetmek için kullanırız. Temel olan char, int, double ve float değişkenleri çeşitli tipteki değerleri tutmaya yarar.
Bu temel tipteki değerler aynı zamanda bizim için doğal değerlerdir; kafamızdan rahatça bir tam sayı, ondalıklı sayı ya da bir karakter seçebiliriz.
Bir değeri hatırlayabilmek için bir değişken yaratıp, değeri bu değişkene atadığımızda, bu değer bellekte bir yerde tutulur. Değişkenin ismini kullanarak daha sonra tekrar bu değere ulaşabiliriz. Eğer adı “ortalama” olan, float tipinde bir değişkenimiz varsa, bu değişkene de 0.5 değerini atadıysak, daha sonra “ortalama” ifadesini gördüğümüz her yerde bu değer kullanılacaktır. Programımız, “ortalama” ismiyle değere ulaşabilecektir.
Bu durumda değişken tipleri kadar verdiğimiz isimler de önemli oluyor. İsimler değerimize erişmemizi sağlıyor. Bir bakıma değere ulaşmamızı sağlayan bir anahtar gibi.
Programımız değişkendeki değeri bellekte bir yere kaydediyor ve değişkenin adıyla bu değere erişim sağlıyor dedik. Değişken adı yerine, doğrudan kaydettiği yere bakarak da değeri görebiliriz. Bunu yapabilmek için değişkenin yerini bilmemiz yeterli.
C/C++ dillerinde değişkenin kaydedildiği yeri öğrenmek için, değişken ismiyle birlikte & operatörünü kullanıyoruz. Aşağıdaki kısacık kodda printf fonksiyonunda %p biçimlendiricisini kullanarak, bir değişkenin bellekteki adresini ekrana yazdırıyoruz.
#include <stdio.h> int main() { int x = 10; printf("%p\n", &x); return 0; }
Bu adres her çalıştırdığımızda farklı değere sahip olacaktır. Bu cümlede kullandığımız değer ifadesi ile, sanki bir tam sayı değeri gibi, bu adresleri de değişkenlerde tutabilmeliyiz. Böylece bu değerleri de hatırlama ve kaydetme şansımız olur. Adres tutan bu değişkenlere işaretçi diyoruz. İşaretçi denmesinin sebebi de, adresin bellekteki bir değeri göstermesi, ya da işaret etmesinden kaynaklanıyor.
Kafa karıştıran nokta, bu değişken tipinin int ya da double gibi bir değişken tipi isminin olmaması. Mesela “pointer” gibi bir değişken tipi olamaz mıydı? O zaman işaret edilen yerdeki değerin hangi tipte olduğunu anlamamız zorlaşırdı. Bir adres var elimde, ama orada hangi tip değişken var? Tam sayı mı, ondalıklı mı, yoksa kullanıcının tanımladığı bir tip mi? Bu yüzden değişken tiplerine ait işaretçiler vardır. Bu tipler kullanıcı tanımlı yapılar da olabilir.
İşaretçileri tanımlarken * operatörünü kullanıyoruz; bu operatör bir bakıma & operatörünün zıttı gibidir. Nasıl & operatörünü bir değişkenin adresini öğrenmek için kullanıyorsak, * operatörü ile de bir işaretçi değişkenin gösterdiği yerdeki değere erişmek için kullanabiliriz.
#include <stdio.h> int main() { int x = 10; int *px = &x; printf("%p adresindeki değer %d.\n", px, *px); return 0; }
Bu kısa kodda değere erişmek için ister doğrudan x değişkenini, ya da * operatörü ile birlikte işaretçiyi, px, kullanabiliriz.
Adresi tutmam benim ne işime yarayacak? Zaten değişkene isimleriyle erişebiliyorum, niye bir de adreslerle uğraşayım? İşaretçi olmadan programlama yapılamıyor mu?
İşaretçiler C ve C++ programlama dillerinin çok önemli bir parçası. Bu dillerin yapıları gereği bazı işlevleri yerine getirmek için işaretçilere ihtiyaç duyuluyor.
Bunlardan ilki C ve C++’ın fonksiyonlara parametreleri değer olarak geçirmeleri. Örneğin, toplama adında bir fonksiyonunuz olduğunu ve bu fonksiyonun iki parametresinin toplamını döndürdüğünü düşünelim.
int toplama(int a, int b) { return a + b; }
Buradaki a ve b değişkenleri “yerel” değişkenlerdir. Yani toplama fonksiyonuna ait süslü parantezlerin dışında tanımlı değillerdir. Siz bu fonksiyonu ister toplama(3,5) olarak çağırabilirsiniz, yani a ve b’nin tutacağı (hatırlayacağı) değerleri elle belirleyebilirsiniz, ya da toplama(x,y) gibi başka iki değişken verebilirsiniz. Programlama diliniz de bu x ve y değişkenlerinin tuttuğu değerleri a ve b’ye atayacaktır. Bu atama işlemi sonucunda, x ve y’nin değerleri a ve b’de de olacaktır.
Bu fonksiyon size bir tam sayı döndürdükten sonra a ve b, ve bu iki değişkenin tuttuğu değerler bellekten silinecektir. Fonksiyonun çağırıldığı yerde dönen değeri ne yapacağınız size kalmıştır. İster bir değişkende saklayabilirsiniz, ister doğrudan ekrana yazabilirsiniz, başka bir fonksiyona parametre olarak gönderebilirsiniz… Ne yaparsanız yapın, artık a ve b değişkenleri yoktur. Doğal olarak sizin x ve y değişkenleriniz de fonksiyon çağırılmadan önceki hallerindedirler.
Bunun en klasik örneği, iki değişkenin yerlerinin değiştirildiği “swap” fonksiyonlarıdır. Elinizdeki x ve y değişkenlerinin değerlerinin yerlerini değiştirmek istiyorsunuz. Eğer x = 3 ise ve y = 5 ise, “swap” işlemi sonrasında x = 5 ve y = 3 olmalı. Bunu anlatırken de hep iki bardağın içindeki sıvıların, biri çay, biri kahve, nasıl yer değiştirebileceğiniz sorulur. Üçüncü bir bardağa ihtiyacınız vardır. Çayı üçüncü bardağa dökersiniz, sonra kahveyi çayın eskiden durduğu bardağa, son olarak da üçüncü bardaktaki çayı da kahvenin artık boş olan bardağına.
Bu “swap” işlemini sık yapmanız gereken yerler vardır. Mesela sıralama algoritmaları. Bu algoritmalarda sık sık iki değişkenin yeri değiştirilir. O yüzden bu işlemi bir fonksiyon ile yapmak istersiniz.
void swap(int a, int b) { int temp = a; a = b; b = temp; return; }
Bu fonksiyonda, tıpkı toplama fonksiyonundaki gibi, a, b ve temp değişkenleri fonksiyon tamamlandığında artık geçersiz olacaktır. Bu fonksiyonu swap(3,5) olarak çağırdığınızı düşünün. Artık 5’leriniz 3, 3’leriniz 5 mi? Tabii ki değil. O zaman swap(x,y) yaptığımızda niye farklı bir şey olmasını bekleyelim? Fonksiyon çağırılırken yapılan, x gördüğümüz yere 3, y gördüğümüz yere de 5 yazmak. Değişkenlerle değil, değişkenlerin değerlerini parametre olarak gönderiyoruz aslında.
Sorunu aşmak için parametre olarak adresleri gönderebiliriz. Burada artık değişkenlere atanan tam sayı değerleri değil, tam sayıları tutan adres değerleridir. İşin güzel yanı, adres kopyalansa da işaret edilen yer aynıdır.
void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; return; }
Artık bu fonksiyonu çağırırken kafadan adres yazamayacağımız için elimizdeki değişkenlerin adreslerini verelim; mesela swap(&x, &y) gibi. Bu durumda a ve b değişkenlerinde x ve y’nin adresleri tutulacaktır. Yine değer gönderiliyor, ama bu sefer gönderilenler adresler. İlk adımda temp değişkenine a’nın gösterdiği yerdeki değeri atıyoruz, sonra a’nın gösterdiği yere, b’nin gösterdiği yerdeki değeri atıyoruz. Son olarak da b’nin gösterdiği yere temp’in tuttuğu, a’nın gösterdiği adreste daha önce duran değeri atıyoruz. İşlem tamam. Fonksiyonun döndüğü yerde x ve y değişkenlerinin değerlerinin yeri değişti, ama aslında adresleriyle oynamadık. İkisi de hala aynı adresteler.
Bir de görsel anlatmaya çalışayım. Üstteki bellek şemasını “swap” fonksiyonunun çağırıldığı yerde, x ve y’nin değerlerini ve adreslerini göstermek için çizdim. İlk sütunda yer alan benim uydurduğum bellek adresleri var. İkinci sütunda bu adresteki “değerler” var. Son sütunda da bu değerlere erişebileceğimiz değişken isimleri var. Böylece x = 12; ve y = 14; olarak tanımlanmışlar diyebiliriz. Biz swap(&x, &y) dediğimiz zaman, a ve b işaretçilerine x ve y’nin adres değerleri, yani 0x0015 ve 0x0017 değerleri atanıyor.
Şimdi de swap fonksiyonun çalıştığı yerdeki değişkenlere ve adreslere bakalım. Dediğimiz gibi a değişkeninde az önce atadığımız değer, 0x0015 var. Aynı şekilde b değişkeninde de 0x0017 var. Burada bir de int tipinde olan temp değişkeni var. İlk olarak, int temp = *a; satırıyla a’nın gösterdiği adresteki değer temp’e atanıyor. Burada a’nın gösterdiği değeri bulmak için a’nın değeri olan 0x0015 alınıyor ve bellekte o adreste ne yazdığına bakılıyor. Böylece temp değişkenine 12 değeri atanmış oluyor. Sonraki satırlarda * operatörü ile işaretçilerin gösterdikleri yerlerdeki değerlere erişilerek benzer işlemler yapılıyor. Fonksiyon tamamlandığında ve döndüğünde a, b ve buradaki temp değişkenleri artık tanımlı olmayacaklar; temp zaten geçici bir değişkendi, a ve b’de ise bizim kullandığımız değişkenlerin adresleri vardı. Bunlara gerçekten de ihtiyacımız yok. Fonksiyon tamamlandığında bizim amacımız olan iki değişkendeki değerlerin yer değişme işlemi gerçekleşmiş olacaktır.
Buradaki problemimiz C/C++ programlama dillerinde fonksiyon parametrelerindeki değerlerin fonksiyonlara geçmesiydi. Biz de, gerçek değişkenler üzerinde işlem yapabilmek için, değişkenlerin değerlerini değil, onların adreslerini parametre olarak gönderdik. Aslında gönderdiğimiz gene bir değer, ama adres içeren bir değer. Adres içeren bu değer bize erişmek istediğimiz yeri gösterdiği için fonksiyon içinde, çağırılan yerdeki değişkenlerin değerleri üzerinde değişiklik yapabildik. Bu son cümlede de birçok referans var, o yüzden şöyle diyelim: swap fonksiyonunun çağırıldığı yerde tanımlı x ve y değişkenlerinin değerleri üzerinde oynayabilmek için, onların adreslerinin tutulduğu ve swap fonksiyonunda tanımlı a ve b değişkenlerini kullandık. Bu a ve b değişkenleri sayesinde de x ve y’nin bellekteki yerlerine ve değerlerine erişip, değişiklik yapabildik. Bu işlem sonucunda x ve y değişkenlerinin kendileri ile çalışmış olduk.
Dikkat çekici bir nokta bu işaretçi değişkenlerin de bir adresinin olması. Eğer swap içinde bir de &a desek bize hangi adres dönecek? Üstteki görsele göre 0x19a3 değeri.
Görüldüğü üzere, C/C++ dillerinde işaretçi olmadan işimiz daha zor olurdu. İşaretçiler aynı zamanda diziler (array) ile çok yakın bir bağ içerisindedir. Elbette dinamik bellek yönetiminde de çok işimize yararlar. Bu ikisini de başka bir yazıda ele alırız diye umut ediyorum.