plazma - amatör bilgisayar kültürü

C++ Kursu 4

Bilgem 'Nightlord' Çakır

1. İşaretçiler

Daha önce bir takım değişkenleri tanımlamaktan ve kullanmaktan bahsettik. Bu değişkenlerin hepsinin de bellekte durduğunu biliyoruz. İşte bir değişkenin bellekte durduğu yere o değişkenin “adresi” denir. Bu adres genelde platformun mimarisine bağlı büyüklükte bir sayıdır. Örneğin 32 bitlik bir sistemde bir değişkenin adresi de 32 bitlik bir sayıdır.

Bellek denilen şey art arda dizilmiş baytlardan oluşur. Bu baytlara istediğimiz bilgileri yazarız. Zaten siz bir değişken tanımladığınızda, o değişkenin tipine bağlı olarak bellekte bir grup bayt değişken için ayrılır. Örneğin çoğu 32 bitlik sistemde siz int tipinden bir değişken tanımladığınızda bellekte 4 baytlık bir alan ayrılır.

Bir değişkenin adresini almaya yarayan operatör & operatörüdür. Örneğin

int a = 1;
std::cout << &a;

dediğiniz zaman sisteminizin a için ayırdığı baytlardan ilkinin adresini ekrana yazdırmış olursunuz.

C++ bu tip değişken adresleri ile çeşitli işlemler yapabilmeniz için “işaretçileri” destekler. Bir işaretçi kendisi de bir değişkendir. İçerisinde başka bir değişkenin “adresini” taşır. Başka bir deyişle başka bir degişkene “işaret eder”. Bir nevi “şurada falanca tipte bir değişken var” der. Bunu biraz açalım.

İşaretçiler gösterdikleri değişkenin tipi ile bağlantılıdırlar örneğin

int a = 5;
int* pa = &a;

Burada önce a adında tamsayı tipinde bir değişken tanımladık ve o değişkeni hemen 5 ile yükledik. Ardından “int tipinde bir değişkene bakan”, pa adında bir işaretçi tanımladık. Ve ona a değişkeninin adresini yükledik. İkinci satır bu demektir.

İşaretçiler aracılığıyla onların gösterdikleri değişkenlere erişebiliriz. Bunun için *operatörünü aşağıdaki gibi kullanırız

std::cout<<*pa;

bu komut a’daki değeri yani 5’i yazdırır. Aynı şekilde

*pa = 10;

Diyerek a değişkeninin değerini değiştirebiliriz.

İşaretçiler ile diziler arasında da akrabalık vardır diyebiliriz. Bir dizi tanımladığımız zaman dizinin adını aynı zamanda dizinin ilk elemanına bakan bir işaretçi gibi kullanabiliriz. Örneğin

int dizi[3] = {1, 2, 3};
*dizi = 5;
std::cout<< dizi[0];

dediğimizde 5 sonucu yazdırılır. Ancak dizilerin işaretçilerden farkı başka bir int değişkeninin adresini dizi’ye atayamamanızdır (oysa dizi bir dizi değil de işaretçi olsaydı atayabilirdiniz)

Değişkenlerin nerelerde ne amaçla kullanıldığını merak ediyor olabilirsiniz. Bu kullanımları yeri geldikçe birlikte göreceğiz.

2. C++’da Bellek Yönetimine Giriş

İşaretçilerin kullanıldığı yerlerden biri dinamik bellek yönetimidir. Şu ana kadar bütün gördüğümüz değişken tanımlamalarında, derleyici programın derlenişi esnasında ihtiyaç duyulan bellek miktarını bilebilir ve bu belleğin ayrılmasını dolaylı olarak sağlar.

Oysa bazen programcı, bellek tasarrufu için ya da başka sebeplerle çalıştığı sistemden bellek isteyecek şekilde programı yazmaya ihtiyaç duyar. Bu durumlarda programımıza bazı durumlarda sistemden bir miktar bellek isteyen komutlar yazarız.

İşte “bana biraz bellek ver” diye konuştuğumuz şahsiyete aslında “C++ runtime” deniyor. Bu programınıza eklenen bir kod bütünü. İşletim sistemi ile sizin programınız arasındaki bazı bağlantıları yaparak, programınızın çalışması için zemin hazırlıyor. Runtime’ın yaptığı işler arasında programınızın ihtiyacı olan belleğin bir kısmını siz istedikçe size sağlamak da var.

Aynı şekilde kullanıp artık ihtiyacınız kalmayan belleği de yine sisteme iade etmek gerekir. Siz bir bellek parçasını sisteme iade ettiğinizde o bellek sonraki isteklerde size verilebilecek bir kaynak olarak müsait hale gelir.

Eğer hep sistemden bellek alır ve hiç iade etmezseniz, eninde sonunda sistemde bellek kalmaz ve programınızın başına kötü şeyler gelir. Bu şekilde iade edilmesi gereken belleği iade etmeyi unutmak C++ programlarında sık yapılan bir hata türüdür ve özel bir adı vardır: Bellek Sızdırma.

Şimdi sistemden nasıl bellek istediğimize bakalım:

int* pa = new int();

Sistemden bellek isterken new komutunu kullanırız. Bu esnada o bellekte saklayacağımız şeyin tipini de söyleriz. Ve bu istek bize bir işaretçi döndürür. Bu işaretçi yeni oluşturulan değişkenin adresini taşımaktadır. Örneğin yukarıda bir tamsayı saklayacak kadar belleği sistemden istiyoruz. Eğer runtime istediğimiz belleği bulabilir ise (bellek bitmemişse bulur) uygun büyüklükte bir bellek parçasını ayrırır ve o parçanın adresini döndürür.

Dönen şey bir adres olduğu için onu bir işaretçide saklamak zorundayız. İşte bu işaretçilerin en çok kullanıldığı yerlerden biridir. Bu tamsayı değişkenini hep işaretçi üstünden kullanırız.

İşimiz bittikten sonra da bu belleği delete komutu ile sisteme iade ederiz

delete pa;

böylece bellek sızıntısı olmamış olur.

Benzer şekilde new ve delete kullanarak bir dizi saklayacak kadar bellek de sistemden isteyebiliriz. Üstelik new ile bu şekilde bellek isterken dizilerden farklı olarak değişken boylarda dizileri saklamaya yetecek kadar bellek alabiliriz. Bunu biraz açalım. Şu koda bakın:

std::cout<<”kac sinav”;
int kacSinav;
std::cin>>kacSinav;
int sinavNotlari[kacSinav];
// malesef calismaz!!!

Burada dikkat ederseniz bir dizi oluşturmak istiyoruz. Ama dizinin kaç elemanı olacağını programı yazarken bilmiyoruz. Çünkü bu değer program çalışırken kullanıcı tarafından giriliyor. Kullanıcı belki 4 diyecek belki 500. Bu yüzden kacSınav değişkeni büyüklüğünde dizi tanımlamaya çalışıyoruz. Ancak bu kod derleyici de hata verir.

C++’da dizilerin bellek yönetimi derleyiciler tarafından ayarlanır. Derleyici bir dizi tanımı gördüğünde o dizi için gereken belleği alan kodu siz birşey yapmadan programınıza ekler. Derleyicinin bunu yapabilmesi için derleme anında gereken belleğin miktarını kestirebilmesi gerekir. Bu yüzden dile şu kural konmuştur. Dizilerin ebatları değişken olamaz, sabit olmak zorundadır.

Ancak new kullanarak derleme zamanı yerine çalışma zamanında bellek istemini yaparsak önümüz açılıyor. Bu durumlar için new komutunu şöyle kullanabiliriz.

Int* sinavNotlari = new int[kacSinav];

Tıpkı dizilerdeki gibi köşeli parantezleri kullanıyoruz. Bu ifadede parantezin içine değişken gelebilir. Çünkü istek program çalışırken yapılıyor. Programımız bu satıra geldiğinde kacSınav adet tamsayıyı saklayabilecek büyüklükte bir bellek parçasını sistemden ister. Sistem bulduğu belleğin adresini sınavNotları işaretçisine yükler.

İşaretçiler ve diziler akraba demiştik. Bu noktada [] indeksleme operatörünü tıpkı dizilerde olduğu gibi işaretçilerle de kullanabiliriz. Yani

sınavNotları[2] = 30;

gibi bir ifade geçerlidir.

New[] ile alınan belleği sisteme iade ederken delete komutu da biraz farklı yazılarak kullanılır:

delete [] sinavNotlari;

Bu şekilde ayrı bir delete kullanılmasının sebebi şu. Derleyici sınavNotları adlı int* işaretçisinin bir int’lik bir bölgeye mi yoksa n int’lik bir bölgeye mi baktığını bilmiyor. Burada delete[] kullandığımızda sisteme, “bu adresten başlayan daha önce new[] ile aldığım bütün belleği iade ediyorum” demiş oluyoruz.

Bu bilgileri kullanarak geçen sayıdaki örnek programımızı biraz daha geliştirelim. Bu sefer sinav notlarını iki boyutlu bir dizide tutmak yerine tek boyutlu “öğrenci sayısı x sınav sayısı” kadar tamsayı içeren bir bellek parçasında tutacağız. m’inci öğrencinin n’inci sınavına ulaşmak için de “(m x sınav sayısı) + n” formülü ile indeksleme yapacağız.

#include <iostream>

int main()
{
  // kac ogrenci
  std::cout<<”kac ogrenci?”;
  int kacOgrenci;
  std::cin>>kacOgrenci;

  // kac sinav
  std::cout<<”kac sinav?”;
  int kacSinav;
  std::cin>>kacSinav;

  // dongu ile notlari al
  İnt* sinavNotlari = new int[kacOgrenci * kacSinav];

  for(int o=0; o<kacOgrenci; ++o)
  {
    for(int i=0; i<kacSinav; ++i)
    {
      int index = o * kacSinav + i;
      std::cout << “sinav notu: ”;
      std::cin >> sinavNotlari[index];
    }
  }

  // sonuclari hesapla ve goster
  int sinifToplam = 0;
  for(int o=0; o<10; ++o)
  {
    int ogrenciToplam = 0;
    for(int i=0; i<3; ++i)
    {
      int index = o * kacSinav + i;
      ogrenciToplam += sinavNotlari[index];
    }

    int ogrenciOrtalama = ogrenciToplam / kacSinav;
    std::cout<<”ogrenci ”<< o 
      <<” ortalamasi: ” << ogrenciOrtalama
      << std::endl;

    sinifToplam += ogrenciOrtalama;
  }

  int sinifOrtalama = sinifToplam / kacOgrenci;
  std::cout << “Sinif ortalamasi: ” << sinifOrtalama
    << std::endl;
}

Aslında asıl zevkli bölümlere yeni geliyoruz. Şimdi programcılıktaki en önemli kavramlardan birinden bahsetme zamanı

3. C++’da Fonksiyonlar

Fonksiyonlar bir programı dizayn ve organize ederken en faydalı araçlardan biridir. Fonksiyon genelde bir takım argümanı girdi olarak alıp, bir takım işlemler yaptıktan sonra bir sonucu geri döndüren kod bloklarıdır.

int kucukOlan(int a, int b)
{
  if (a < b)
  {
    return a;
  }
  else
  {
    return b;
  }
}

Burada bir fonksiyon görüyorsunuz. İlk satır fonksiyonumuzun dışarıdan bilinmesi gereken bilgilerini özetler.

int kucukOlan(int a, int b)

bu fonksiyon iki adet tamsayıyı girdi olarak alır. Bu iki sayıdan küçük olanı geri döndürür. İlk satırda sırasıyla fonksiyonun;

  1. ne tipte bir değer döndürdüğü

  2. adı

  3. kaç tane ve ne tipte girdiler aldığı

ilan edilir. Programın geri kalanı sadece bu üç bilgiye dayanarak bu fonksiyonu çağırabilir. Bu üç bilginin bütününe fonksiyonun “imzası” adı verilir.

Kursun ilk bölümünden hatırlayacağınız return komutu değer döndüren fonksiyonlarda ikinci bir rol daha üstlenir. Return’den sonra gelen değer (sabit veya değişken) fonksiyondan döndürülen değer olur.

Tanımlanmış bir fonksiyonu programınızın geri kalanında adıyla çağırabilirsiniz.

int enDusukNot;
enDusukNot = kucukOlan(aliNot, veliNot);
...

Burada program akışı ikinci satırdan fonksiyonun ilk komutuna atlar. Fonksiyona geçirilen argümanlar aliNot ve veliNot, fonksiyonun içinde a ve b değerlerine eşitlenir. Ve fonksiyon a ile b’yi karşılaştırıp küçük olanını return komutu ile döndürür. Program akışı bu noktada tekrar fonksiyonu çağıran satıra döner. Fonksiyondan dönen değer (yani aliNot ve veliNot arasında küçük olan) enDusukNot adlı değişkene atanır ve program akışı bir sonraki satırdan devam eder.

Programın ilerisinde başka bir yerde aynı fonksiyonu başka değerlerle yine çağırabiliriz

enAzHarclık = kucukOlan(hasanHarclık, ayseHarclık);

Bu sefer tahmin edeceğiniz gibi aynı fonksiyona tekrar atlanır ve farklı iki argüman değeri a ve b’ye geçirilir. Fonksiyon bu sefer bu yeni iki değerin daha küçük olanını döndürür.

Dikkat ederseniz fonksiyonlar tekrar tekrar değişik girdilerle kullanılıp bize istediğimiz işi yapan faydalı araçlara dönüşürler. Bir nevi programın geri kalanına bir “hizmet sağlarlar”. Bu yüzden çoğu zaman bir fonksiyonu çağıran başka lokasyonlara fonksiyonun “müşterisi” denir. Bu hizmet sağlama ve müşteri kavramlarını başka yerlerde de göreceğiz.

Şu ana kadar programlarımızda kullandığımız main bloğu da aslında bir fonksiyondur. Adı main olan bir fonksiyon. Fonksiyonlar bazen hiç argüman almayabilir ve hiçbir değer döndürmeyebilir. İşte kullandığımız main de öyle bir fonksiyon. Değer almayan bir fonksiyon tanımlarken fonksiyonun imzasında parantezlerin arası boş bırakılır. Değer döndürmeyen bir fonksiyon tanımlanırken de imzada dönüş değeri olarak “void” ifadesi kullanılır. Bu yüzden örneklerde gördüğünüz ana kod bloğumuz

void main()

diye başlıyor.

Fonksiyonların birkaç önemli faydası vardır:

  1. Programınızda birden fazla yerde kullandığınız bir grup komutu fonksiyon haline getirirseniz o komutları sadece bir kere fonksiyon tanımında yazıp değişik yerlerde tekrarlamak zorunda kalmazsınız. Bu programınızın daha küçülmesini sağlar, ayrıca eğer o fonksiyonu oluşturan komut satırlarında bir hata yapmış ve sonra o satırları pekçok yerde tekrarlamışsanız (yani fonksiyon yerine her lazım olan yerde satırları copy/paste yapmışsanız) bu hatayı sonradan farkedip düzeltmek istediğinizde o copy/paste yapılan her yere gidip düzeltmeyi yapmanız gerekir. Oysa fonksiyon kullanırsanız hatayı bir yerde “fonksiyonun içinde” düzeltebilirsiniz ve bütün müşteriler bu düzeltmeden faydalanır.

  2. Programınızın daha organize ve okunabilir olmasını sağlarlar. Biz şu ana kadar örneklerimizde herşeyi main fonksiyonu içinde yazdık. Bu çok küçük programlar için problem olmasa da zamanla okunmaz bir hal alır. Kod satırlarını alt fonksiyonlara bölerek organize etmek program yazmayı ve okumayı kolaylaştırır.

  3. Programın değişik bölümlerinin sadece bazı detaylarla ilgilenip o detayların dışındaki konularla ilgilenmemesini sağlarlar. Bu aslında bütün bilgisayar biliminde çok önemli bir kavramdır ve buna “Soyutlama” denir. Mesela yukarıdaki örneğimizde küçük olan fonksiyonu, programın geri kalanını, “iki sayıdan küçük olanı bulma” probleminden soyutluyor. Bu şu anlama gelir. Programın geri kalanını yazarken iki sayıdan küçük olanı bulma ihtiyacınız doğarsa bunu nasıl yapacağınızı düşünmek veya yazmak zorunda değilsiniz. O problem kucukOlan fonksiyonunda bir kere çözüldü ve tekrar tekrar kullanılabilir. Bu, o fonksiyonu çağırdığınız yerdeki kod satırlarının daha sade ve odaklı olmasını da sağlar. Bunu daha ileride daha net göreceksiniz.

Biz şu an fonksiyonlar üzerinde çok durmayacağız. Çünkü aslında C++ ile nesne yönelimli programlar yazarken fonksiyonlardan daha çok onlara çok benzeyen “sınıf metodlarını” kullanacağız. Bu konu da gelecek sayıda...

plazma - (2006 - 2011)