fatih bakır

Shared Pointer

Daha önceki bir yazıda referans takibinin temel mantığını ufak bir Mesh örneği üzerinden basitçe konuşmuştuk. Eğer ki o yazıyı okumadıysanız ve referans sayımı tekniğine âşina değilseniz öncelikle geri dönüp bir gözden geçirmenizi öneririm.

Bir giriş olarak yeterli olsa da, o yazıdaki örneğimizin gözle görülür eksikleri var:

Açmak gerekirse, önceki referans sayımı sistemimizi örneğin ses dosyaları yada kaplamalar için kullanamıyoruz, referans takibi yapılması gereken her sınıf için ayrı bir referans takibi sistemi yazmak çok da gerçekçi yada temiz değil. Ki esasında tüm mesh nesnelerini de değil, sadece Load/UnloadMesh ikilisi ile yaratılan nesneleri takip edebiliyoruz, yine çok etkileyici değil.

Bunun yanında, mesh yönetim işlerinin global iki metoda yüklenmiş olmasından kaynaklanan, referans sayımının global bir map üstünden yapılması meselesi var. Bu yöntemin iyi ve kötü yanları olsa da, genel amaçlı bir referans sayım mekanizması yazmak istiyorsak, tüm nesneleri ortak bir yerden takip etmenin çok da bir anlamı yok.

Bu yazının amacı, üstte saydığımız sorunları çözecek alternatifler tanıtmak. Öncelikle şu UnloadMesh meselesinden kurtulalım. Önceki yazının sonunda, Mesh nesnelerini scope’dan düşerken otomatik iade eden bir MeshHandle sınıfı oluşturmuştuk:

class MeshHandle
{
  Mesh* _ptr;
public:
  MeshHandle(Mesh* ptr) : _ptr(ptr) {}
  MeshHandle(const MeshHandle&) = delete; // geleceğiz
  ~MeshHandle() { UnloadMesh(_ptr); }
    
  Mesh* get() { return _ptr; }
};

Bu sınıf, çok güzel bir başlangıç noktası olabilir. Öncelikle UnloadMesh(_ptr); ifadesinin ne iş yaptığını hatırlayalım: bu method, bir şekilde bu pointerın gösterdiği mesh nesnesine ait referans sayısını 1 azaltmalı, ve eğer referans sayısı 0 olursa, hafızadan meshi temizlemeli.

Map’siz referans takibi

Bir gözlem yapalım: referans sayısına MeshHandle nesnelerimiz ulaşabiliyor olsaydı, referans azaltma ve gerektiğinde bu nesneyi silme işlerini MeshHandle içinde yapabilir, dolayısıyla da UnloadMesh gibi bir fonksyona ihtiyaç duymazdık. LoadMesh de, aptal bir pointer yerine MeshHandle dönse, oyun kodunun tamamında bir meshin farkında olmadan leak edilmesinin önüne geçebilirdik. > Daha da iyisi bu kolaylığın bir şekilde performans yada hafıza masrafı yok, tek bir ekstra byte yada clock cycle harcamıyoruz.

Unutmayın ki önceki yöntemimizde kullanıcılar LoadMesh ile mesh yükledikten sonra UnloadMesh ile bir sebepten (basitçe unutkanlık yada exception fırlatılması gibi) dolayı bu nesneyi geri veremezlerse, nesne uygulama kapanana kadar hafızada kalacaktı:

Collider* MakeMeshCollider(const std::string& meshName)
{
  Mesh* mesh = LoadMesh(meshName);
  
  // ... meshten collider oluşturma bişeyleri
  
  return new MeshCollider { ... };
  // mesh'i iade etmeyi unuttuk
}

void DrawMeshDebug(const std::string& meshName)
{
  Mesh* mesh = LoadMesh(meshName);

  Graphics::Draw(mesh); 
  //üstteki call exception fırlatırsa mesh'i iade edemiyoruz

  UnloadMesh(mesh);
}

Yeni yöntemimizle bu tarz sorunların tamamının önüne geçiyoruz, zira bir MeshHandle nesnesi scope’dan düşerken otomatik olarak referans sayısını azaltıp gerekirse silme işlemini gerçekleştiriyor. LoadMesh fonksyonu da bir MeshHandle nesnesi döndüğü için, kasıtlı olarak bozmak için zorlamadığımız sürece herhangi bir resource leak yada sallanan pointera sahip olmamız mümkün olmuyor.

İkinci sorunumuz referans sayılarını global bir std::map<Mesh*, int> nesnesi içinde tutuyor olmamız. Meshlerin tekrar tekrar yüklenmesinin önüne geçmek istediğimizden dolayı, bir şekilde global bir listede yüklü meshleri takip etmeliyiz; fakat artık LoadMesh fonksyonunun da MeshHandle nesnesi dönmesiyle beraber, LoadMeshin sayaçları takip etmesinin çok bir anlamı yok: mâdem sayaç bilgisine MeshHandle nesnelerinin destructor’larında sayacı azaltıp gerektiğinde silmesini sağlıyoruz, aynı zamanda sayacı arttırma işlemini de constructor’larda yapabiliriz:

class MeshHandle
{
  Mesh* _ptr;

public:
  MeshHandle(Mesh* ptr) : _ptr(ptr) 
  {
  	int& meshRefCount = GetMeshRefCount(_ptr);
  	++meshRefCount;
  }

  MeshHandle(const MeshHandle& rhs) : MeshHandle(rhs._ptr) {}

  ~MeshHandle() 
  {
  	int& meshRefCount = GetMeshRefCount(_ptr);
  	if (--meshRefCount) return;
   	// bir şekilde meshi hafızadan sil
   }
  
  Mesh* get() { return _ptr; }
};

int& GetMeshRefCount(const Mesh* meshPtr)
{
  static std::map<const Mesh*, int> refCounts;
  return refCounts[meshPtr]; 
  // eğer bu key yoksa, otomatik olarak 0 olarak oluşturuluyor
  // dolayısıyla güvenle dönebiliriz
}

MeshHandle LoadMesh(const std::string& mesh)
{
  static std::map<std::string, Mesh*> _loadedMeshes;
  if (std::find(_loadedMeshes.begin(), _loadedMeshes.end(), mesh) 
    != _loadedMeshes.end() && GetMeshRefCount(_loadedMeshes[mesh]))
  {
  	// zaten yüklü
  	return MeshHandle { _loadedMeshes[mesh] };
  }

  // bir şekilde diskten mesh'i yükle

  _loadedMeshes[mesh] = meshPtr;
  return MeshHandle { meshPtr };
}

Şu anda referans sayımı işini komple MeshHandle yüklenmiş olsa da yine de referans sayılarını global bir map içinde tutuyoruz. Ayrıca LoadMesh fonksyonu hala referans sayım işleriyle ve aptal pointerlarla fazla haşır neşir oluyor. Eğer takip edemediyseniz, kendi içinde yine global bir map tutuyor, eğer ki yüklemeye çalıştığımız mesh bu map içinde varsa, ve o pointera ait referans sayısı 0 değilse, içinde tuttuğu pointerdan bir MeshHandle nesnesi oluşturuyor. Aksi takdirde, diskten meshi yükleyip ona ait bir MeshHandle nesnesi dönüyor.

Çaktırmıyor olsa da, GetMeshRefCount fonksyonunun içindeki static refCounts değişkeni normal stack değişkenlerinin aksine fonksyonu her çağırdığımızda tekrar oluşturulmuyor, dolayısıyla global bir map’ten hâla kurtulamadık.

İlk olarak istediğimiz şey, LoadMesh fonksyonunun olabildiğince MeshHandlelar üzerinden çalışması. Dolayısıyla LoadMesh fonksyonunun kendi içinde aptal mesh pointerları tutmasındansa, yine MeshHandleları tutmasını istiyoruz. İstenen bir mesh zaten yüklü ise, içinde tuttuğu MeshHandlelardan birini kopyalayıp dönebilir. Bu arada bu örneğe kadar bir copy constructorumuz bulunmuyordu zira referans sayıları şu ana kadar MeshHandle nesnelerinden görülemiyordu. Yeni LoadMeshimiz şu şekle gelebilir:

MeshHandle LoadMesh(const std::string& mesh)
{
  static std::map<std::string, MeshHandle> _loadedMeshes;
  if (std::find(_loadedMeshes.begin(), _loadedMeshes.end(), mesh) 
      != _loadedMeshes.end() 
      && GetMeshRefCount(_loadedMeshes[mesh]) > 1) 
	// buradaki MeshHandle hariç bir referans var mı?
	{
		return _loadedMeshes[mesh];
	}
	
	// bir şekilde diskten mesh'i yükle
	
  _loadedMeshes[mesh] = MeshHandle { meshPtr };
  return meshPtr;
}

Bu durumda, o global _loadedMeshes mapindeki handledan dolayı her meshin her zaman en az 1 referansı olacak, dolayısıyla da asla silinmeyecekler. Derme çatma bir çözüm olarak, MeshHandleların referans sayısı 1 olduğunda silmelerini sağlayabiliriz:

MeshHandle::~MeshHandle() 
{
  int& meshRefCount = GetMeshRefCount(_ptr);
  if (--meshRefCount > 1) return;
  ... bir şekilde meshi hafızadan sil ...
}

Fakat şimdi de, sildiğimiz Meshlere ait ölü bir MeshHandle nesnemiz bulunabiliyor. Bu durumda, handle nesnelerimizin ölü olup olmadıklarını bilmeleri faydalı olabilir zira LoadMesh tuttuğu handleların ölü olup olmadığına, dışarıdaki bir referans sayısından değil de, doğrudan kendilerine bakarak karar verebilir:

class MeshHandle
{
  // ... aynı
  bool isDead() const {
    return GetMeshRefCount(_ptr) > 1;
  }
};

MeshHandle LoadMesh(const std::string& mesh)
{
	static std::map<std::string, MeshHandle> _loadedMeshes;
	if (std::find(_loadedMeshes.begin(), _loadedMeshes.end(), mesh) 
		!= _loadedMeshes.end() 
		&& !_loadedMeshes[mesh].isDead()) 
		// üst satır: buradaki MeshHandle ölü mü?
	{
		// hayır değil
		return _loadedMeshes[mesh];
	}

	// bir şekilde diskten mesh'i yükle

	_loadedMeshes[mesh] = MeshHandle { meshPtr };
	return meshPtr;
}

Bu son adımımızla beraber, GetMeshRefCount fonksyonuna MeshHandle dışında ihtiyaç duyan hiç bir yer bırakmadık. Başka bir deyişle referans sayımını tamamen soyut bir hale getirmek için sadece o fonksyonu MeshHandleın bir private static memberı yapmak yeterli:

class MeshHandle
{
private:
	static int& GetMeshRefCount(const Mesh* meshPtr)
	{
		static std::map<const Mesh*, int> _refCounts;
		return _refCounts[meshPtr];
	}
};

Şu anda, global map sorunumuzun global kısmından kurtulduk. Şimdi, mapten de kurtulmanın vakti geldi. Map kullanmanın doğrudan negatif bir yanı yok, fakat referans sayımıza her ulaşmaya çalıştığımızda bir map gezmenin bedelini ödemek (O(logN) olsa da) iyi bir yaklaşım değil. unordered_map kullanarak hash table ile ortalama O(1) ulaşım sağlayabilsek de, bu sefer de daha fazla hafıza harcayacağız. Yapmamız gereken gözlem, her MeshHandle tek bir referans sayısına ulaşıyor. Yani, zaman zaman a nesnesinin, zaman zaman b nesnesinin referans sayısına ulaşmaya çalışmıyor. Yapabileceğimiz, doğrudan referans sayısına bir pointer tutmak olabilir:

class MeshHandle
{
    Mesh* _ptr;
    int* _refCnt;
public:
    MeshHandle(Mesh* ptr) :
        _ptr(ptr),
        _refCnt(&GetMeshRefCount(_ptr))
    {
        *_refCnt++;
    }
    // aynı
};

Kodun kalanında da artık her seferinde GetMeshRefCount yapmaktansa, referans sayısına _refCnt pointerından ulaşırsak, map gezme sorunumuzun üstesinden gelmiş oluyoruz. Fakat, bu durumda da madem map içinden sadece bir kere arama yapacağız, o zaman neden map kullanıyoruz? Doğrudan referans sayısını free store’da oluşturup, aynı meshi gösteren tüm MeshHandle nesnelerinin de aynı sayıyı işaret etmelerini sağlarsak, anlamsız map kullanımımızdan da kurtulmuş oluruz.

Maplerden eleman ekleyip çıkarıldığında, var olan iterator, pointer ve referanslar geçersiz olmuyor, dolayısıyla sallanan pointerlara sahip olmayacağız. Iterator geçersizlemenin ne olduğunu bilmiyorsanız, şuraya alalım.

class MeshHandle
{
    Mesh* _ptr;
    int* _refCnt;
public:
    MeshHandle(Mesh* ptr) :
        _ptr(ptr),
        _refCnt(new int(0))
    {
        *_refCnt++;
    }

    MeshHandle(const MeshHandle& rhs) : 
        _ptr(rhs._ptr),
        _refCnt(rhs._refCnt)
    {
        *_refCnt++;
    }

    MeshHandle(MeshHandle&& rhs) :
        _ptr(rhs._ptr),
        _refCnt(rhs._refCnt)
    {
        rhs._ptr = nullptr;
        rhs._refCnt = nullptr;
        // var olan bir handle'dan taşıdığımız için
        // referans sayısında oynama yapmaya gerek yok
    }

    ~MeshHandle()
    {
        if (!_ptr || !_refCnt) return;

        --*_refCnt;
        if (_refCnt) return;

        delete _ptr;
        delete _refCnt;
    }

    MeshHandle& operator=(MeshHandle&& rhs) = delete;
    MeshHandle& operator=(const MeshHandle& rhs) = delete; // hele bi dursunlar
};

Eşitleme operatörlerini henüz yazmadık, zira önceki gösterdikleri elemanın referans sayısını azaltıp, eğer ki 0 olursa silip sonra yeni elemanı göstermeleri gerekiyor, çok basit fakat şimdilik acelesi yok.

Dikkat etmeniz gereken, copy constructor’ın mesh ve referans sayısı pointerını kopyalayıp referansı arttırması, bu sayede, elimizde bulunan bir MeshHandle nesnesinden, yeni MeshHandle nesneleri oluşturuyoruz. Ayrıca move constructor’un benzer bir arttırmaya gitmediğine de dikkat çekmek gerek.

Bu yaptığımızla beraber, artık ne global ne private static hiç bir şekilde bir map kullanmamıza gerek yok, referans sayısını tutan değişkeni sade bir mesh pointerindan yeni bir MeshHandle oluştururken free store’dan alıp, yeni nesnenin içinden gösteriyoruz. Bu nesneyi kopyalarken yada taşırken de, mesh pointerının yanında referans sayısının da doğru şekilde gittiğinden emin olursak, güvenli bir referans sayan handle sınıfına sahip olmuş oluyoruz.

Genel amaçlı, referans sayan handlelar

Şu ana kadar, hep Mesh nesnelerini takip eden bir MeshHandle sınıfı yazdık. Tabi ki kendi işini çok iyi yapıyor olsa da, Mesh nesnelerine özel hiç bir şey yapmıyor. Ayrıca, bu tarz referans takibine ihtiyaç duyacak pek çok nesnemiz de var, ses dosyaları, ara videolar, animasyonlar gibi. İhtiyaç duyacağımız her bir sınıf için bir de handle sınıfı yazmak çok da verimli değil. Yapmamız gereken, MeshHandle sınıfını, herhangi bir tip için çalışacak, template bir sınıf haline getirmek:

template <class T>
class RefHandle
{
    T* _ptr;
    int* _refCnt;
    
public:
    RefHandle(T* ptr) :
        _ptr(ptr),
        _refCnt(new int(0))
    {
        ++*_refCnt;
    }

    RefHandle(const RefHandle& rhs) : 
        _ptr(rhs._ptr),
        _refCnt(rhs._refCnt)
    {
        ++*_refCnt;
    }

    RefHandle(RefHandle&& rhs) :
        _ptr(rhs._ptr),
        _refCnt(rhs._refCnt)
    {
        rhs._ptr = nullptr;
        rhs._refCnt = nullptr;
    }

    ~RefHandle()
    {
        if (!_ptr || !_refCnt) return;

        --*_refCnt;
        if (_refCnt) return;

        delete _ptr;
        delete _refCnt;
    }
};

Bir kaç tipi, T yapmaktan başka hiç bir şey yapmamış olsam da, şu anda elimizde çok amaçlı, referans sayımı yapan bir handle sınıfı var. Örneğin:

using IntHandle = RefHandle<int>;
using FloatHandle = RefHandle<float>;
using StringHandle = RefHandle<std::string>;
using AudioHandle = RefHandle<AudioEffect>;
//ve hatta
using MeshHandle = RefHandle<Mesh>;

Önceki örneklerde hep Mesh kullandığımız için çok düşünmesek de, pointerlar inheritance ilişkisinde temel olarak kullanılması gereken zımbırtılar. Örneğin:

Base* bPtr = new Derived();
bPtr->virtualFunction();
delete bPtr;

Gibi bir uygulama doğal şekilde çalışıyor. Handle nesnemiz her ne kadar içinde pointer tutuyor olsa da, bir RefHandle<Base> nesnesini, bir Derived pointeri ile oluşturamayız. Yada, var olan bir RefHandle<Derived> nesnesinden, RefHandle<Base> nesnesi oluşturamayız:

RefHandle<Base> bPtr = new Derived(); // hata
RefHandle<Derived> dPtr = new Derived(); // ok
RefHandle<Base> bPtr = dPtr; // yoo

Fakat bu tarz kullanımlar son derece doğal ve bir kullanıcının bekleyeceği şeyler. Bu tarz kullanımları desteklemekse inanılmaz kolay. Yapmamız gereken, T*dan başka bir tip olan U* alan template bir constructor oluşturmak, ve içerdeki pointerı doğrudan verilen pointerlar oluşturmaya çalışmak. Eğer ki U*, doğal yollardan (inheritance gibi) T*‘a dönüşebiliyorsa ne âla. Yok eğer dönüşemiyorsa, korkunç bir derleme hatasıyla beraber işlemi iptal ediyoruz:

template <class T>
class RefHandle
{
    //aynı
public:
    template <class U>
    RefHandle(U* ptr) : 
	_ptr(ptr), 
	_refCnt(new int(0) 
    {
        ++*_refCnt;
    } 
    
    template <class U>
    RefHandle(const RefHandle<U>& rhs) : 
        _ptr(rhs._ptr),
        _refCnt(rhs._refCnt)
    {
        ++*_refCnt;
    }
    
    template <class U>
    RefHandle(RefHandle<U>&& rhs) : 
        _ptr(rhs._ptr),
        _refCnt(rhs._refCnt)
    {
        rhs._ptr = nullptr;
        rhs._refCnt = nullptr;
    }
}

Tabi ki bu sekilde derleyicinin hata vermesini beklemek yerine metaprogramming hileleriyle doğrudan fonksyonların oluşmasına izin vermeyebilirdik, fakat o da başka bir yazının konusu olabilir.

Bu fonksyonlar ile, inheritance ve dönüşüm kurallarına saygı duyan bir handle oluşturmuş olduk. Ayrıca, handle’ımız giderek daha fazla normal bir pointer gibi davranmaya başladı. Bunu bir adım daha ileri götürebiliriz.

Operator overloading koş!

Şu anda, handle’larımızdan işe yarayacak pointerları almanın yolu get() metoduyla içerdeki pointerı almak. Fakat bu her zaman pratik ve doğal olmayabilir. Şanslıyız ki, c++, pointer syntaxını overload edebilmemizi sağlıyor. Örneğin, RefHandle<T> sınıfımızın -> ve * operatörünü overload edersek, sanki bir T*mış gibi kullanabiliriz:


Share this: