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

C++'da Akıllı İşaretçiler

Bilgem 'Nightlord' Çakır

İşaretçiler(pointer), C++ dünyasına aslında C’den gelen bir kavram. C’ye de assembler’dan gelmişti. C++ programlarında pekçok hata bir kenarından işaretçiler ile ilgili oluyor. İşaretçilerle yapılan aritmetik işlemler, işaretçilerin sıfırlanmasının unutulması vs.

Yine C++ programlarında en sık yapılan hatalardan biri de bellek yönetimi hataları. Sistemden alınan ve iş bittikten sonra geri verilmeyen bellek, veya geri verilmiş belleğe bakmaya devam eden işaretçileri kullanmak vs.

İşte bu iki genel hata grubunun kesişiminde bulunan önemli bir bölümünü çözmek için akıllı işaretçiler (smart pointer) geliştirilmiş.

Pekçok farklı, akıllı işaretçi kütüphanesi geliştirilmiş olsa da, bellek yönetimi için şu an standart kullanmanız gereken akıllı işaretçiler STL ve boost içinde yer alanlar (ki boost içinde yer alanlar önümüzdeki yıllarda STL içine eklenecek).

Akıllı işaretçiler, bildiğimiz normal işaretçiler gibi görünüp kullanılabilen, ama belli koşullarda işaret ettikleri nesne üzerinde delete komutunu çağırıp, sisteme geri verilmesini sağlayan (yani akıllı) sınıflardır (aslında sınıf değil birer şablondurlar(template) ama kafanız karışmasın).

Basitten karmaşığa doğru elimizdeki seçenekler

  1. boost::scoped_ptr<>

  2. std::auto_ptr<>

  3. boost::shared_ptr<> ve boost::weak_ptr<>

Normalde bu üç işaretçi tipini doğru şekilde kullanırsanız, programınızın hiçbir yerinde delete komutunu kullanmanıza gerek kalmamalı. Yani hiçbir şeyi açıkça siz delete etmeden ve hiç bellek sızdırmadan C++ programı yazmak mümkün.

Aklınıza eğer akıllı işaretçiler ile ilgili performans kaygıları geliyorsa, hemen bu kaygılarınızdan sıyrılmak için boost.org’daki akıllı işaretçi performans analizi yazılarını okuyabilirsiniz. Göreceksiniz ki pekçok durumda hiç kaygılanmanıza gerek yok (hatta Herb Sutter der ki, bir programı profiler araçlarından geçirip neresinin performans bakımından zayıf halka olduğunu tespit etmeden, böyle gaipten kaygılar duymamak lazımdır). Kısacası anafikir, gerek STL gere boost içindeki akıllı işaretçiler son derece optimize edilmiş ve son derece iyi test edilmiş kod parçalarıdır. Bunlar hakkında performans kaygısı gütmek için, gerçekten neden bahsettiğini çok iyi biliyor olmak lazım.

1. scoped_ptr

boost::scoped_ptr en basit akıllı işaretçi türü. Yaptığı iş sadece bir tane nesneye işaret etmek ve o nesnenin tek sahibi olup, silinirken de nesneyi de kendisi ile beraber silmek.

Önce probleme bakalım.

void hede(){
    T* t = new T();
    // birseyler yap
    delete t;
}

Bu koddaki problem şu: Eğer fonsiyon ortadaki işlemler esnasında dönerse delete t komutu çalışmayacak ve t nesnesini sızdırmış olacağız. Bahsettiğimiz geri dönüş birkaç şekilde olabilir:

  1. İlk etapta fonsiyon yazılırken orta bölümde koşullu dallardan birinde return komutu ile çıkış yapılmış, fakat oraya delete konmamış olabilir.

  2. Başta olmasa bile sonrada kod geliştirilmeye devam edilirken ortaya bir return eklenmiş ve delete unutulmuş olabilir.

  3. Kodda bir return olmasa bile çağırılan herhangi bir fonksiyondan istisna(exception) tetiklenmiş olabilir. Bu durumda istisna yüzünden, kontrol, istisna yakalayıcı bir kod bloğuna rastlayana kadar bir üst çağıran fonksiyona çıkmaya devam eder.

Bu üç durumda da t nesnesini sızdırmış oluruz. Oysa ki bu üç durumda da yığıt(stack) içinde yaşayan bütün yerel nesneler yıkıcıları(destructor) çağrılarak yok edilirler. İşte bu yüzden biz de normal işaretçiler yerine scoped_ptr nesnelerini yığıtta yaratırız:

void hede(){
    boost::scoped_ptr<T> p( new T() );
    // birseyler yap
}   // fonksiyon kapsamından çıkılırken p siliniyor
    //ve baktığı nesneyi de siliyor

Bu şekilde en başta p akıllı işaretçisi yeni T nesnesine bakacak şekilde yaratılıyor ve ilkleniyor(initialization). Ardından fonksiyondan nasıl çıkılırsa çıkılsın, yerel yığıt değişkeni olan p silinirken, yıkıcı metodunun içinde delete çağrılıyor. Böylece fonksiyonda ne olursa olsun T nesnesini sızdırmıyoruz.

Yani akıllı işaretçimiz yaratıldığı kapsam(scope) içinde olunduğu sürece sahip olduğu nesneye işaret ederken, kapsam dışına çıkıldığı anda, kendisi yok olurken, işaret ettiği nesneyi de siliyor. Bu yüzden adı Kapsam İşaretçisi(Scoped Pointer).

scoped_ptr’nin bir diğer önemli özelliği de kopyalanamaması ve atanamaması(assignment). Bunun sebebi scoped_ptr’nin sadece bir objeye sahip olması ve bu sahipliği başka bir scoped_ptr nesnesine devredememesinin özel olarak hedeflenmiş olması.

boost::scoped_ptr<T> p1(new T() );
boost::scoped_ptr<T> p2(p1);  // olmaz!!! derleme hatasi verir
boost::scoped_ptr<T> p3;
p3 = p1  // olmaz!!! derleme hatasi verir

Bu kısıtlamanın konulmasındaki amaç, programcının yarattığı nesnenin ömrü ile ilgili daha net kod yazmasını sağlayarak, gelecekte olası problemleri önlemek. Örneğin yukarıdaki hede fonksiyonuna yıllar sonra başka bir programcı ilaveler yapmak durumunda kalırsa, orada yaratılan T objesinin ömrünün, o kapsam içinde sınırlı olması gerektiğini hemen anlayabilir. Böylece mesela yanlışlıkla, o ömrü daha uzun bir başka işaretçiye, p’nin değerini atamaz. Çünkü bunu denerse hemen derleme hatası alır.

Eğer bu kısıt olmasa ve bu atamayı yapabilseydi hata yapmış olacaktı. Mesela düz işaretçiler kullansa, atama yapılan daha uzun ömürlü işaretçi çürük(dangling) bir işaretçi haline gelecekti. Akıllı bir işaretçi kullansa da bu sefer T nesnesini, gerektiğinden uzun süre canlı tutmuş olacaktı.

scoped_ptr’ın kopyalanamaz ve atanamaz olması, kullanımı da çok daha kolaylaştırmış oluyor. Bundan sonra alışkanlık olarak aynı kapsam içinde dinamik olarak yaratıp sildiğiniz bütün nesneler için her zaman scoped_ptr kullanmanızı tavsiye ederim.

Bir uyarı daha. scoped_ptr’ ler kopyalanamaz ve atanamaz oldukları için std::vector, std::list gibi hiçbir STL kabına(container) konulamazlar. Çünkü STL kapları içlerine konulan nesne sınıflarının kopyalanabilir ve atanabilir olmasını gerektirir. Eğer scoped_ptr’leri STL kaplarına koyabilseydik, yine kapsamının dışında o nesneleri yaşatıyor olurduk, ki bu belirttiğim gibi tasarım hedefine ters. STL kapları ile kullanacağımız akıllı işaretçiler boost::shared_ptr ve boost::weak_ptr olacak.

Son olarak scoped_ptr’ler yerel nesneler dışında bir de pimpl(pimpıl diye okunur) yaklaşımında kullanılır. Bu yaklaşımdan şimdi bahsetmeyeceğim.

2. Auto_ptr

std::auto_ptr, geçen yazıda bahsettiğimiz boost::scoped_ptr’den bir adım daha yetenekli bir akıllı işaretçidir. Tıpkı scoped_ptr gibi auto_ptr de bir nesnenin sahibidir, ve kapsam dışına çıkılırken o nesnenin silinmesini sağlar.

void hede(){
    std::auto_ptr<T> p( new T() );
    p->hodo();
    …
}    // kapsamdan cikilirken T nesnesi silinir

Yine bu fonksiyonda ne olursa olsun (erken bir return ya da istisna) auto_ptr sayesinde temel istisna güvenliği (yani istisnalar tetiklendiği takdirde bellek sızdırmama garantisi) sağlanmış olur.

Ancak scoped_ptr’den farklı olarak auto_ptr, kopyalanabilir ve atanabilir. kopyalama/atama durumlarında nesnenin sahipliği el değiştirir. Başka bir deyişle bir nesneye herhangi bir anda yalnız bir auto_ptr sahip olabilir. Bir auto_ptr bu şekilde sahiplik kaybettiğinde resetlenmiş olur.

void hede(){
    std::auto_ptr<T> p1( new T() );
    p1->hodo();
    std::auto_ptr<T> p2( p1 ); // su anda artik p1 resetlendi
    p2->hodo();
    std::auto_ptr<T> p3;
    p3 = p2;   // artik p2 de resetlendi
    // sadece p3 nesnemizin sahibi
    p1->hodo()    // !!!!calisma zamani hatasi
    p2->hodo()    // !!!!calisma zamani hatasi
    p3->hodo()    // OK. cunku p3 nesneye bakiyor
}    // p1 ve p2 resetlendigi icin hicbirsey yapmiyor, p3 nesneyi siliyor

Dolayısıyla auto_ptr’ler sahip oldukları nesneyi kopyalama/atama yoluyla birbirlerine devredebilirler. Bu sebeple bazı durumlarda fonksiyonlara arguman olarak geçirilmeye, veya fonksiyonlardan geri döndürülmeye çok müsaittirler.

std::auto_ptr<T> Uretici(){
    return std::auto_ptr<T>( new T() );
}
void Tuketici( std::auto_ptr<T> p ){
    p->hodo();
}
void hede(){
    Tuketici( Uretici() );
}

Üretici ve Tüketici metodlar karşımıza çok sık çıkarlar. Örneğin birbirine mesaj gönderen veya iş atayan sınıflar mesaj nesneleri üretip tüketiyor olabilirler. Normalde bu yapıda nesnelerin üretilip, el değiştirip, tüketilip, silinmeleri pek çok potansiyel bug/sızıntı noktası taşır. Eğer düz işaretçiler kullanılırsa, nesnelerin sahibinin kim olduğu, hangi işaretçilerin ne zaman sıfırlanacağı gibi konular problem yaratabileceği gibi, herhangi bir basamakta olacak bir istisna tetiklemesi de sızıntıya sebep olur. auto_ptr kullanmak bu sorunların hepsini çok zarif bir şekilde çözer. Buna benzer bir problemle bir sonraki karşılaşmanızsa auto_ptr kullanmak aklınızda olsun.

Son olarak, gerek scoped_ptr, gerekse auto_ptr, sınıflarda üye değişken olarak da kullanılabilir. Böyle kullanıldıklarında sınıfın yıkıcı metodu içinde delete çağrılmasına gerek kalmamış olur.

class T{
public:
    T() : u( new U() ) {} // u ilkleme listesinde sistemden aliniyor
    ~T(){} // burada delete yapmaya gerek yok cunku nesnenin kapsami bitti
private:
    T( const T&);  // kopyalanamaz
    T& operator=( const T& );   // atanamaz
    std::auto_ptr<U> u;
};

Burada dikkat etmeniz gereken şey, kopya yapıcı ve atama operatörünün private yapılarak, sınıfın kopyalanamaz ve atanamaz hale getirilmesi. Nitekim sınıfın kopyalanmasında izin verseydik, bu sınıftan bir nesneyi başka bir nesneye kopyaladığımız anda, nesnelerden biri sahip olduğu nesneyi kaybedecekti. Çünkü bir nesnenin u auto_ptr’si diğer nesneninkine kopyalanacak, ve sadece bir auto_ptr nesneye bakmaya devam edecekti.

Aynı sebepten ötürü auto_ptr de tıpkı scoped ptr gibi STL kaplarında kullanılmaya uygun değildir. Kopyalanabilir olduğu halde, auto_ptr kopyaları birbirine denk olmadıkları için, STL kaplarının metodları kendi içlerinde kopyalamalar yaparken auto_ptr’leri istemeden resetlemiş olurlar. Mesela içi auto_ptr’lerle dolu bir std::vector kabına std::sort işlemini uyguladığınızı düşünün.

Bu noktalara dikkat edilerek kullanıldıklarında auto_ptr’ler özellikle üretici/tüketici ilişkileri olan sınıflar için çok zarif ve güvenli kod yazılmasını sağlarlar.

3. Shared_ptr

Akıllı işaretçiler arasında en güçlü özelliklere sahip olan boost::shared_ptr<>‘den bahsetmeye geldi sıra. Bu işaretçi sayesinde en zorlu bellek yönetim ve nesne ömrü problemlerini çözebiliyoruz.

shared_ptr, daha önce bahsettiğimiz scoped_ptr ve auto_ptr’ye temelde benziyor. Yine amacı bir nesneye işaret etmek ve doğru zamanda nesneyi silmek. Ancak diğer iki akıllı işaretçide olan bir nesneye yalnız bir işaretçinin “sahip” olması zorunluluğu yok. Aksine shared_ptr’leri kullanarak bir nesneyi birden fazla işaretçinin sahipliğine veya paylaşımına verebiliyoruz. Başka bir deyişle bir grup shared_ptr aynı nesneyi paylaşıyor ve paylaşan bütün işaretçiler ortadan kalktığında, nesne de siliniyor. Bu paylaşım kopyalama/atama yoluyla oluyor.

boost::shared_ptr<T> Yonetici::YeniTYarat(){
    boost::shared_ptr<T> p1( new T() );
    this->mKayitListesi.push_back( p1 );
    return p1;
}
void App::hede(){
    boost::shared_ptr<T> p = mYonetici->YeniTYarat();
    ...
    // p ile birseyler yap
    ...
}

Mesela bu örnekte önce YeniTYarat metodunun içinde p1 işaretçisi objeye bakan ilk işaretçi oluyor. Ardından p1′in bir kopyası bir vector kabına atılıyor. Yani artık iki sahip var. Biri p1, digeri de vectör’ün içindeki işaretçi. Sonra metoddan dönülürken, p1 geçici bir değişkene atanıyor (3. sahip ortaya çıkıyor) ve hemen ardından p1′in ömrü doluyor (kapsam sonuna gelindiği için). Bunun ardından hede metodunun kapsamı içinde isimsiz geçici işaretçi, p işaretçisine atanıyor (bu noktada geçici isimsiz işaretçinin ömrünün dolduğunu varsayabiliriz). Artık bu anda yine iki sahip var. Biri hede kapsamındaki p, diğeri mYönetici nesnesinin içindeki vektörde bulunan işaretçi. Hede metodundan çıkılırken de p yok oluyor ve tek sahip kalıyor. Eğer programın ilerleyen aşamalarında mYönetici nesnesi herhangi bir sebeple silinirse (ve en başta yarattığımız T nesnesine bakan başka shared_ptr yaratılmadıysa), o esnada vektörün içindeki shared_ptr’nin yıkıcı metodu T nesnesini silecek.

Görüldüğü gibi, bu şekilde shared_ptr’ler kullanarak nesnelerin sahipliğini istediğiniz kadar çok sayıda yere bölüştürebilir ve hiçbir zaman elinizdeki hiçbir shared_ptr’nin sallantıya düşmeyeceğinden (silinmiş bir nesneye bakmayacağından) emin olabilirsiniz.

shared_ptr’ler yukarıdaki örnekte görebileceğiniz gibi STL kapları ile kullanılmaya uygundurlar. Hatta karşılaştırma operatörleri de tanımlı olduğu için, ilişkisel kaplarda (associative container) yani std::map ve std::set gibi kaplarda da kullanılabilirler.

shared_ptr kullanırken dikkat etmeniz gereken iki kritik nokta var

Birincisi birden fazla argüman alan metodlara shared_ptr geçirirken, her zaman isimli bir shared_ptr kullanmak. Mesela elimizde soyle iki metod olsun

int g();
void f( boost::shared_ptr<T>, int );

Daha sonra bunları şöyle kullandığımızı düşünelim.

boost::shared_ptr<T> p( new T() );
f( p, g() );

Bu kullanım yukarıda bahsettiğimiz kurala uyuyor. Aynı kodu aşağıdaki gibi yazmamız ise sakıncalı:

f( boost::shared_ptr<T>(new T() ), g() );

Buradaki problem şu. C++ standardı bu şekilde yazılan bir kodda new T(), g() ve shared_ptr yapıcısının hangi sırada çağırılacağını tanımlamamıştır. Yani bu kod çalışırken, bu bahsettiğimiz üç parça herhangi bir sırada çalışabilir. Dolayısıyla eger önce new çalışır, ardından g() çalışır ve en son shared_ptr yapıcısı çalışırsa sızıntı tehlikesi vardır. Bu senaryoda eğer g() istisna tetiklerse henüz shared_ptr yaratılmadığı ve kapsamdan çıkılırken yıkıcısı çağırılmayacağı için, new ile alınan T nesnesi sızdırılmış olur.

Bu uyarıyı tam anlamamış olabilirsiniz. İstisna güvenliği konusu derin bir konu ve o konuda daha sonra yazacağım. Ancak siz anlamadıysanız bile, birden fazla argüman alan metodlara shared_ptr geçirirken bu şekilde satır tasarrufu yapmaya çalışmayın.

İkinci uyarı noktası ise nesneler arası döngüsel shared_ptr kullanım problemi. Mesela T cinsinden iki nesnemiz olsun. t1 ve t2 diyelim. T sınıfının üye değişkenlerinden biri de shared_ptr<T> tipine sahip olsun.

class T{
public:
    boost::shared_ptr<T> p;
};
void hede(){
    boost::shared_ptr<T> t1( new T() );
    boost::shared_ptr<T> t2( new T() );
    ....
    t1->p = t2;
    t2->p = t1;
}

Burada bir şekilde kodda t1 ve t2 nesneleri birbirlerine bakan bir shared_ptr’ye sahip olmuş durumda. Yani bir işaretçi döngüsü oluşmuş. Bu durumda bir problem oluyor. Dikkat ederseniz biribirine bakan böyle iki nesne olduğu zaman, dışarıdan bu nesnelere bakan bütün shared_ptr’ler silinse bile, bu iki nesnenin içlerindeki shared_ptr’ler kaldığı sürece nesneler silinmiyor. Başka bir deyişle iki nesne birbirini hayatta tutuyor. Örneğin yukarıdaki kodda hede metodundan çıkıldığında t1 ve t2 işaretçileri yok olacak ve bizim elimizde bu iki nesneye bakan bir işaretçi kalmayacak. Bu da bir nevi sızıntı.

İşte shared_ptr’ler böyle döngüsel kullanıma uygun olmadıkları için bu durumlarda döngüleri kırmak için weak_ptr adı verilen boost işaretçilerini kullanıyoruz.

4. Weak_ptr

Weak pointer ile bir veya birkac shared_ptr’nin baktığı bir nesneye bakan bir işaretçi elde edebiliyoruz. Fakat bu işaretçi shared_ptr’lerin referans sayacını artırmıyor. Örneğin

void hede(){
  boost::shared_ptr<int> p(new int(5));
  boost::weak_ptr<int> q(p);
   …
}

Burada q adlı weak_ptr yaratıldığı anda hala p işaretçisi nesnenin tek sahibi. Eğer p’nin kapsamından çıkarsak, p yok olduğu anda nesne de silinecek.

Peki weak_ptr’nin normal bir işaretçiden ne farkı var o zaman? Farkı şu, weak_ptr, normal işaretçiler veya shared_ptr gibi -> operatörünü desteklemiyor. Yani şöyle bir kod yazamıyoruz.

q->hodo();

q’nun gösterdiği nesneyi kullanmanın tek yolu var o da q’dan geçici bir shared_ptr yaratmak.

boost::shared_ptr<int> r = q.lock();
r->hodo();

eğer bu anda p ölmüş ve nesne yok olmuş ise lock() metodu null döndürecek. Dolayısıyla weak_ptr böylelikle aynı nesneye bakan işaretçilerden biri delete ile nesneyi sildiğinde diger işaretçilerin anlamsız bir adrese bakmaları problemini (“dangling pointer” diye de bilinir) çözmüş oluyor. Aynı zamanda yukarıda bahsettiğimiz, shared_ptr’ler ile oluşan döngüsel sızıntı problemini de çözüyor.

5. Sonuç

Akıllı işaretçiler doğru kullanmayı öğrendiğinizde çok işinize yarayabilecek araçlardır. Bol bol kurcalamanız tavsiye olunur.

plazma - (2006 - 2011)