C++ Dilinde Tür Bilgisinin Silinmesi (Type Erasure)

Serkan Eser
Nettsi Bilişim Teknoloji A.Ş.
15 min readApr 17, 2020

Bu yazımızda tür silme (type erasure) olarak isimlendirilen kavramın C++ dilindeki karşılığına ve uygulama pratiğinde nerelerde karşımıza çıktığına bakacağız. Type erasure kavramı C++ ile ilgili ilk karşılaştığımız kavramlardan biri değil fakat önemli kullanım alanlarına sahip. Örneğin STL içerisindeki std::any, std::function, std::variant ve std::shared_ptr gibi modern C++ araçlarının tasarımında bu yöntem kullanılmaktadır.

Tür bilgisi neden silinmeli?

C, C++ gibi statik türler üzerinde çalışan dillerde bazı işlemlerin türe bağımlı olarak tekrarlanması istenmeyen bir durum oluşturmaktadır. Uygulama pratiğinde belli düzeyde türden bağımsız kod yazılması gereken durumlar ortaya çıkmaktadır. Bu duruma tipik bir örnek olarak C dilindeki standart qsort fonksiyonunu verebiliriz. qsort fonksiyonu bir dizi içerisindeki ardışıl elemanları belli bir kurala göre sıralamaktadır. Sıralama algoritması olarak, adından da anlaşılacağı gibi, quick sort algoritmasını kullanmaktadır. qsort fonksiyonuna sıralayacağı dizinin başlangıç adresi bir biçimde geçirilmelidir. Fakat burada bir problem ile karşılaşmaktayız. qsort fonksiyonu yazılırken hangi türden elemana sahip dizileri sıralayacağı henüz belli değildir. qsort fonksiyonunun sıralaması beklenen tüm türden diziler için ayrı versiyonlarını yazmak kodlama pratiği açısından kabul edilebilir değildir. Peki bu durumda kütüphane geliştiricileri nasıl bir yol izlemektedir?

Uygulanan çözüm bir şekilde tür bilgisinin silinmesidir (type erasure). qsort fonksiyonunun prototipi aşağıdaki gibidir:

void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
  • base: dizinin başlangıç adresi
  • nmemb: dizideki eleman sayısı
  • size: dizideki bir elemanın byte türünden genişliği
  • compar: karşılaştırma fonksiyonunun adresi

qsort fonksiyonu sıralama yapacağı dizinin türü hakkında bir bilgi sahibi değildir. Bu sebeple dizinin elemanları arasındaki karşılaştırma işlemlerinde kullanılacak olan fonksiyon kütüphane kullanıcısı tarafından geçirilmelidir. Dizinin türüne göre karşılaştırma fonksiyonu değişmesine karşın parametrik yapısı aynı kalmalıdır. C dilinde bu amaçla kullanılan araç hepimizin yakından bildiği void * türüdür. Bir örnek üzerinden bu duruma bakalım.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int cmpstringp(const void *p1, const void *p2)
{
return strcmp(* (char * const *) p1, * (char * const *) p2);
}
int main(int argc, char *argv[])
{
int i;
int n;
const char* a[] = {"ccc", "bbb", "aaa"};
n = sizeof(a) / sizeof(a[0]); qsort(a, n, sizeof(char *), &cmpstringp); for (i = 0; i < n; ++i) {
printf("%s ", a[i]);
}
printf("\n"); return 0;
}

Örneği test.c adıyla saklayıp aşağıdaki gibi derleyip çalıştırabiliriz.

$ gcc -otest test.c$ ./test
aaa bbb ccc

Yukarıdaki örnek yazıları sözlükteki konumlarına göre sıralamaktadır. Şimdi benzer şekilde int türden bir diziyi, yine qsort kullanarak, sıralamak istediğimizi düşünelim.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int cmpintp(const void *p1, const void *p2)
{
return *(int *)p1 - *(int *)p2;
}

int main(int argc, char *argv[])
{
int i;
int n;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};

n = sizeof(a) / sizeof(a[0]);

qsort(a, n, sizeof(int), &cmpintp);

for (i = 0; i < n; ++i) {
printf("%d ", a[i]);
}

printf("\n");

return 0;
}

Her iki örnekte de qsort fonksiyonuna son argüman olarak farklı türden karşılaştırma yapan fakat parametrik yapıları aynı olan fonksiyon adresleri geçirdik. Karşılaştırma fonksiyonları içinde gerçek türlere ait karşılaştırma işlemlerinin yapılabilmesi için void * türünden gerçek türlere dönüşüm yapılmalıdır. İlk örnek için const char * ikinci örnek için ise int türüne dönüşüm yapıldığını görmekteyiz. Bu durumda aslında karşılaştırma fonksiyonlarının arayüzünden tür bilgisini önce silmiş daha sonra ise gerçek türlere bir dönüşüm (cast) yapmış olduk. Silinmiş türlerden gerçek türlere yapılan bu dönüşüm işlemi reification olarak isimlendirilmektedir. Bu yöntem ciddi anlamda esneklik kazandırmasına rağmen iki temel problemi bulunmaktadır.

İlk problem olarak qsort fonksiyonuna bir fonksiyon göstericisi geçirildiği için derleyicinin inline işlemi yapamaması gösterilmektedir. Fakat yöntemin doğası gereği bu indirection işlemi yapılmaktadır. Yani karşılaştırma fonksiyonu direkt olarak çağrılmak yerine adresi argüman olarak geçirilmekte ve sonrasında indirect bir çağrı işlemi yapılmaktadır. O yüzden bu durumu belli ölçüde göz ardı edebiliriz. İkinci problem ise tür güvenliğine ilişkindir. Karşılaştırma fonksiyonlarının parametrelerinden tür bilgisi silindiği için başka türe ilişkin bir karşılaştırma fonksiyonu qsort fonksiyonuna herhangi bir derleme zamanı hatası alınmadan geçirilebilir. Örneğin int bir diziyi yazı karşılaştırması yapan bir karşılaştırma fonksiyonu ile sıralamaya çalışırsak bu durumda kod derlenecektir. Sonrasında muhtemelen bir çalışma zamanı hatası ile karşılaşacağız fakat her zaman bir hatanın derleme zamanında yakalanması işimizi kolaylaştıracaktır.

C++ dilinde tür silmek için kullanılan yöntemler

C++ dilinde ise benzer bir işlem için popüler olarak kullanılan yöntem yakından aşina olduğumuz çalışma zamanı çokbiçimliliğidir (run time polymorphism). Çalışma zamanı çokbiçimliliği nesnelerin gerçek türleri önceden bilinmeksizin ortak özelliklerine dayanılarak ele alınmasını ifade etmektedir. Bu yöntemde taban sınıf türemiş sınıfların gerçeklediği ya da başka bir ifadeyle özelleştirebildiği bir arayüz sunmaktadır. Yani taban sınıf virtual ya da pure virtual metotları barındırmakta ve türemiş sınıflar bu metotları override etmektedir. Bir türemiş sınıf nesnesinin taban sınıf göstericisi veya referansı ile temsil edilebildiğini hatırlayınız (Liskov Substitution Principle). Şimdi bu yöntemi, dört ayaklı sevimli dostlarımızı temsil eden, klasik bir örnek üzerinden inceleyelim.

class animal
{
public:
virtual void make_sound() const = 0;
};

animal sınıfı sadece bir arayüz sunmaktadır yani animal türünü miras alacak sınıflar make_sound fonksiyonunu gerçeklemelidirler. animal türü ile bu türden miras alan türler arasında katı bir bağımlılık ilişkisi yani is-a ilişkisi mevcuttur. Bu sayede animal türünden miras alan sınıflar da animal gibi ele alınabilmektedir. Aşağıdaki basit örnek üzerinden bu duruma bakalım.

#include <iostream>

class animal
{
public:
virtual void make_sound() const = 0;
virtual ~animal() = default;
};

class cat : public animal
{
public:
void make_sound() const override
{ std::cout << "miyav" << std::endl; }
};

class dog : public animal
{
public:
void make_sound() const override
{ std::cout << "hav hav" << std::endl; }
};

// foo fonksiyonunun gerçek animal türüne bir bağımlılığı yoktur
void foo(const animal& animal)
{
animal.make_sound();
}

int main()
{
cat c;
dog d;

foo(c);
foo(d);

std::cout << "------------------------" << std::endl;

// animal'dan türeyen sınıf örneklerinin adresleri bir
// container içinde tutulabilir
const animal* animal_arr[] = {&c, &d};
for (auto e : animal_arr) {
e->make_sound(); // gerçek türe ilişkin kod çalışır
}

return 0;
}

foo fonksiyonu gerçek animal türü göz ardı edilerek yazılmıştır. Yani animal'dan türeyen herhangi bir sınıf örneğini kabul etmektedir. Benzer şekilde animal'dan türeyen diğer sınıflara ait örneklerin adresleri bir dizi içerisinde saklanabilmektedir. Kodu, test.cpp adıyla sakladıktan sonra, derleyip çalıştıralım.

$ g++ -otest test.cpp -std=c++11

$ ./test
miyav
hav hav
------------------------
miyav
hav hav

Örnekteki foo fonksiyonunda ve animal_arr dizisinde gerçek tür bilgisinin silindiğini söyleyebiliriz. Fakat tür bilgisi bir şekilde silinse dahi her iki durumda da, make_sound çağrısı, çalışma zamanında bir virtual dispatch tablosu üzerinden yapılarak gerçek türe ait kod çalıştırılmaktadır.

Bu yöntemin sağladığı olanaklar açıktır. Bu sayede eski kodlar değiştirilmeden yeni kodlar eklenebilir (open-closed principle). Örneğin foo fonksiyonu değiştirilmeksizin yeni bir beagle sınıfı eklenebilir ve bu sınıfa ait örnek foo fonksiyonuna geçirilebilir. Fakat bize bu esneklik yine bir maliyetle gelmektedir.

İlki performansa ilişkindir. foo fonksiyonunu göz önüne alacak olursak parametresi animal türünden referans olmasına karşın hangi türe ait make_sound fonksiyonunun çağrılacağı derleme zamanında belli değildir. foo fonksiyonuna geçirilen örneğin gerçek türü neyse o türe ait make_sound fonksiyonu çağrılmalıdır. Derleyiciler bu durumda çokbiçimli türler (polymorphic types) için bir virtual dispatch tablosu oluşturur ve bu türe ait örneklerin içerisine bu tablonun başlangıç adresini yerleştirir. Dolayısıyla bir virtual fonksiyon çağrımı için önce dispatch tablosuna erişilmeli oradan da gerçek fonksiyon adresine erişilerek fonksiyon çağrısı yapılmalıdır. Bu durumda fazladan iki adet indirection işlemi yapılmaktadır. Kısıtlı kaynağa sahip ortamlarda çok fazla virtual fonksiyon çağrısı yapmak bu sebeple maliyetli olabilmektedir. Fakat bu yöntemin sunduğu esneklik düşünülürse özel durumlar dışında bu maliyeti göz ardı edebiliriz.

İkinci problem ise kodun tasarımına ilişkindir. Türetme ilişkisinde türler arasında katı bir bağ (tight coupling) oluşturmuş oluyoruz. Bu durumda türetme hiyerarşisi içindeki bir türdeki değişiklik diğer türleri de etkileyebilmektedir. Örneğin taban sınıfa bir pure virtual metot eklenirse tüm türeyen sınıflar bu fonksiyonu gerçeklemelidir.

Peki incelediğimiz örnek için konuşacak olursak cat ve dog sınıfları ortak bir atadan gelmeseydi bu türlere ait örnekleri yine tek bir fonksiyon üzerinden işleyebilir ya da bir container’da tutabilir miydik? Aslında bu yazımızda gerçekte incelemek istediğimiz konunun özünü burası oluşturuyor. Cevabımız evet. Şimdi ilk olarak bu işlemi nasıl yapabileceğimize sonrasında ise elde edilen faydalara bakalım. Konu belli seviyede karmaşıklık barındırdığından adım adım ilerleyelim.

cat ve dog sınıflarımız artık aşağıdaki gibi olsun.

class cat
{
public:
void make_sound() const { std::cout << "miyav" << std::endl; }
};

class dog
{
public:
void make_sound() const { std::cout << "hav hav" << std::endl; }
};

Her iki türün artık make_sound fonksiyonuna sahip olmaktan öte ortak bir yanı bulunmuyor. Bu iki tür arasında bir türetme ilişkisi olmadığından Liskov’un yerine geçme prensibini uygulamamız da mümkün değil. Peki ne yapacağız?

Bu durumda adapter pattern bir çözüm sunmakta. Bu farklı türleri tek bir tür gibi ele alabilmek için bu türlerin arayüzlerini uygunlaştırabiliriz. Bu durumda her bir tür için bir uygunlaştırıcı bir sınıf yazılmalıdır. Uygunlaştırıcı sınıfları ortak bir atadan türeteceğiz. cat sınıfı için bir uygunlaştırıcı sınıfı aşağıdaki gibi tanımlayabiliriz.

class adapter_base
{
public:
virtual void make_sound() const = 0;
virtual ~adapter_base() = default;
};

class cat_adapter : public adapter_base
{
public:
cat_adapter(cat c) : c_(c) {}
void make_sound() const override { c_.make_sound(); }
private:
cat c_;
};

Bu noktada aklınıza şöyle bir soru gelebilir, hani türetme işlemi yapmak istemiyorduk. Evet, cat ve dog sınıfları için herhangi bir türetme işlemi istemiyoruz. Kaldı ki bu türler bir başkası tarafından yazılmış olabilirler. Örneğin bu türler bir kütüphaneye ait ise bu türleri değiştirme şansımız bulunmuyor. Uygunlaştırıcı sınıfları ise biz yazıyoruz ve buradaki amacımız aralarında türetme ilişkisi bulunmayan sınıflara ait örneklere, ortak bir atadan türeyen, uygunlaştırıcı sınıf örnekleri üzerinden ulaşmak.

cat_adapter sınıfı adapter_base sınıfından miras almakta ve bir make_sound fonksiyonu tanımlamaktadır. cat_adapter sınıfının make_sound fonksiyonu cat sınıfının make_sound fonksiyonuna delege etmekte yani cat sınıfının make_sound fonksiyonunu çağırmaktadır. Bu durumda örnek kodumuz aşağıdaki gibi olacaktır.

#include <iostream>

class cat
{
public:
void make_sound() const { std::cout << "miyav" << std::endl; }
};

class dog
{
public:
void make_sound() const { std::cout << "hav hav" << std::endl; }
};

class adapter_base
{
public:
virtual void make_sound() const = 0;
virtual ~adapter_base() = default;
};

class cat_adapter : public adapter_base
{
public:
cat_adapter(cat c) : c_(c) {}
void make_sound() const override { c_.make_sound(); }
private:
cat c_;
};

class dog_adapter : public adapter_base
{
public:
dog_adapter(dog d) : d_(d) {}
void make_sound() const override { d_.make_sound(); }
private:
dog d_;
};

void foo(const adapter_base& param)
{
param.make_sound();
}

int main()
{
cat c;
dog d;

foo(cat_adapter(c));
foo(dog_adapter(d));

return 0;
}
$ ./test
miyav
hav hav

Aslında bu noktada yine klasik çalışma zamanı çokbiçimliliği ile karşı karşıyayız. cat ve dog sınıflarının yerini bir biçimde cat_adapter ve dog_adapter sınıfları aldı fakat nihayetinde cat ve dog sınıflarına dokunmadan bu sınıfları ortak özelliklerine göre işleyebildik. foo fonksiyonu hangi türe ilişkin make_sound fonksiyonunu çağırdığını bilmemesine karşın bu ortak uygunlaştırıcı arayüz üzerinden gerçek türlere ilişkin fonksiyonları çağırabilmektedir. Fakat burada uygulama pratiği açısından bir sorunla karşı karşıyayız. Her sınıf için böyle bir uygunlaştırıcı sınıf mı yazacağız? Bu yöntem ölçeklenebilir değil yani çok sayıda benzer işlem her tür için yapılmalı. Bu durumda türe göre tekrarlanan kod yazma işini kendi üzerimizden alıp derleyiciye yükleyebiliriz. Bu problem tam olarak generic programlamanın çözmeyi hedeflediği problemlerden birini oluşturmakta. Her tür için ayrı bir uygunlaştırıcı sınıf yazmak yerine tek bir tür şablonu (class template) yazacağız.

class adapter_base
{
public:
virtual void make_sound() const = 0;
virtual ~adapter_base() = default;
};

template<typename T>
class animal_adapter : public adapter_base
{
public:
animal_adapter(T animal) : animal_(animal) {}
void make_sound() const override { animal_.make_sound(); }
private:
T animal_;
};

Ayrı ayrı cat ve dog uygunlaştırıcı sınıfları yazmak yerine tek bir animal_adapter sınıfı yazdık. Sınıf şablonları da normal türler gibi yine bir türden miras alabilirler. Bu sayede yeni türler için yeni uygunlaştırı türler yazmak zorunda kalmayacağız, bu tekrarlanan işlemi derleyici bizim için yapacak. Bu durumda örneğimizin son hali aşağıdaki gibi olacaktır.

#include <iostream>

class cat
{
public:
void make_sound() const { std::cout << "miyav" << std::endl; }
};

class dog
{
public:
void make_sound() const { std::cout << "hav hav" << std::endl; }
};

class adapter_base
{
public:
virtual void make_sound() const = 0;
virtual ~adapter_base() = default;
};

template<typename T>
class animal_adapter : public adapter_base
{
public:
animal_adapter(T animal) : animal_(animal) {}
void make_sound() const override { animal_.make_sound(); }
private:
T animal_;
};

void foo(const adapter_base& param)
{
param.make_sound();
}

int main()
{
cat c;
dog d;

foo(animal_adapter<cat>(c));
foo(animal_adapter<dog>(d));

return 0;
}
$ ./test
miyav
hav hav

Fakat maalesef hala işimiz bitmedi. adapter_base türünü ve animal_adapter şablonunu global isim alanında tanımladık bu sebeple foo fonksiyonu çağrısına baktığımızda çok da sevimli olmayan bir söz dizimi görmekteyiz. C++’da çoğu zaman bu durumdayız fakat en azından burada bu karmaşıklığı bir sınıf altında gizleyebiliriz. adapter_base türünü ve animal_adapter şablonunu başka bir tür altında gizlemek mümkün. Bu amaçla animal isimli bir sınıf yazabiliriz. adapter_base sınıfı ve animal_adapter şablonundan oluşturulacak türler bu yeni animal sınıfımızın içsel türleri olacaktır.

class animal
{
class adapter_base
{
public:
virtual void make_sound() const = 0;
virtual ~adapter_base() = default;
};

template<typename T>
class animal_adapter : public adapter_base
{
public:
animal_adapter(T animal) : animal_(animal) {}
void make_sound() const override { animal_.make_sound(); }
private:
T animal_;
};

//...
};

Bu durumda animal sınıfı, make_sound fonksiyonu içeren, herhangi türden bir örneği kabul etmeli. Yani animal türü make_sound fonksiyonunu içeren herhangi bir tür ile ilklendirilebilmeli. Burada yine şablonları kullanacağız. Normal sınıfların da üye fonksiyon şablonlarına (member function template) sahip olabileceğini hatırlayalım. Bu durumda animal sınıfının constructor fonksiyonu için aşağıdaki gibi bir şablon yazabiliriz.

class animal
{
//...
adapter_base *adapter_;
public:
template<typename T>
animal(T animal) : adapter_(new animal_adapter<T>{animal}) {}
};

Not: Örneklerimizde karmaşıklığı arttırmamak için bu aşamada akıllı göstericiler yerine normal göstericileri kullandık.

Yukarıdaki kod bir miktar izahı hak ediyor. Aslında yapılmak istenen iş oldukça basit. Amacımız ilk olarak animal sınıfına geçirilen örneğin bir kopyasını sınıf içerisinde oluşturmak. animal sınıfının constructor’ına geçirilen örneğin türü derleyici tarafından belirlendikten (deduced) sonra bu tür kullanılarak animal_adapter şablonundan gerçek bir tür üretilmekte ve animal sınıfına geçirilen örneğin bir kopyası bu tür içerisinde tutulmaktadır. Sonrasında animal türüne yapılan make_sound çağrıları bu nesneye delege edilmektedir. Bu aşamada bir şekil konuyu kavramamızda yardımcı olacaktır.

cat ve dog sınıflarına ek olarak, make_sound fonksiyonu içeren, rubber_duck isimli bir sınıf daha olduğunu düşünelim. animal türünden bir örnek üzerinden bu farklı türlerin make_sound fonksiyonları çağrılabilir. Tek başına çalışma zamanı çokbiçimliliğini kullansaydık cat ve dog sınıflarını ortak bir atadan türetebilmemize karşın rubber_duck bize sorun çıkaracaktı. Çünkü rubber duck hepimizin bildiği gibi gerçek bir canlı değil. rubber_duck sınıfını da cat ve dog gibi ortak bir animal sınıfından türetseydik Liskov’un yerine geçme prensibini ihlal (liskov substitution principle violation) etmiş olacaktık. animal sınıfı ise böyle bir problem taşımıyor amacımız make_sound fonksiyonunu barındıran tüm türleri kabul etmek. Bunun neden önemli bir özellik olduğunu std::function şablonunu incelediğimizde daha iyi anlayacağız. Şimdi parça parça yazdığımız animal sınıfına ait kodları birleştirelim.

#include <iostream>

class cat
{
public:
void make_sound() const { std::cout << "miyav" << std::endl; }
};

class dog
{
public:
void make_sound() const { std::cout << "hav hav" << std::endl; }
};

class animal
{
class adapter_base
{
public:
virtual void make_sound() const = 0;
virtual ~adapter_base() = default;
};

template<typename T>
class animal_adapter : public adapter_base
{
public:
animal_adapter(T animal) : animal_(animal) {}
void make_sound() const override { animal_.make_sound(); }
private:
T animal_;
};

adapter_base *adapter_;

public:
template<typename T>
animal(T animal) : adapter_(new animal_adapter<T>{animal}) {}

void make_sound() const { adapter_->make_sound(); }
};

void foo(const animal& param)
{
param.make_sound();
}

int main()
{
cat c;
dog d;

foo(c);
foo(d);

return 0;
}
$ ./test
miyav
hav hav

Benzer şekilde cat ve dog sınıflarının örneklerini, aralarında herhangi bir türetme ilişkisi olmaksızın, aynı container için tutmak da mümkün.

int main()
{
animal animal_arr[] = {cat{}, dog{}};

for (auto&& e : animal_arr) {
e.make_sound();
}

return 0;
}
$ ./test
miyav
hav hav

Aslında bu aşamada gerçek hedefimize ulaştık yani konunun özünü kavramış bulunuyoruz. Fakat konunun sadece cat ve dog örnekleri ile teorik kalmaması için bu yöntemin STL içindeki aşina olduğumuz modern C++ araçlarının tasarımında nasıl kullanıldığına da temel düzeyde bakacağız.

STL içindeki type erasure içeren türler

Burada amacımız STL içindeki type erasure kullanan bazı türleri belli bir seviyede incelemek. Sırasıyla std::any, std::function ve std::shared_ptr içerisinde bu yöntemin nasıl kullanıldığına bakacağız. Gerçek implementasyonları oldukça karmaşık olabilen bu türlerin mümkün olan en basite indirgenmiş halleri üzerinde inceleme yapacağız. Konunun özü dışındaki detaylarla ilgilenmeyeceğiz. İlk olarak std::any türüne bakalım.

std::any

C++ diline C++17 standartlarıyla beraber eklenen std::any türü, kendisi bir şablon olmamasına karşın, herhangi türden bir değeri saklayabilmektedir. std::any türü belli ölçüde daha önce yazdığımız animal türüne benzemektedir. std::any türünde, animal türünden farklı olarak, türü silinen değere daha sonra bir tür bilgisi ile erişmeye çalışıyoruz. std::any kullanımına ilişkin bir örnek aşağıdadır.

#include <any>
#include <iostream>

int main()
{
std::any a = 1;
std::cout << std::any_cast<int>(a) << '\n';
a = 3.14;
std::cout << std::any_cast<double>(a) << '\n';
a = true;
std::cout << std::any_cast<bool>(a) << '\n';
}

Kodu C++17 standartlarına göre derleyip çalıştıralım.

1
3.14
true

Örnekte std::any türünden bir değişkende hem aritmetik hem de bool türünden değerler saklanabilmekte sonrasında özel bir cast işlemi ile bu değerlere ulaşılabilmektedir. Şimdi basit bir any sınıfını biz yazalım. Herhangi bir isim çakışmasını engellemek için kendi sınıflarımızı expr isim alanı (namespace) altında yazacağız. Daha önce yazdığımız animal türünü referans alacağız fakat öncesinde küçük bir isimlendirme değişikliği yapacağız. animal sınıfında adapter_base ve animal_adapter isimlerini kullanmıştık artık bu isimler yerine daha genel concept ve model isimlerini kullanacağız.

namespace expr
{
class any
{
class concept
{
public:
virtual const std::type_info& type_info() const = 0;
virtual ~concept() = default;
};

template<typename T>
class model : public concept
{
public:
model(T value) : value_(value) {}
const std::type_info& type_info() const override
{ return typeid(T); }
T value_;
};

concept *concept_ptr_;

public:
template<typename T>
any(T value) : concept_ptr_(new model<T>{value}) {}

template <typename T>
T any_cast() {
if (std::type_index(typeid(T))
== std::type_index(concept_ptr_->type_info()))
{
return static_cast<model<T>&>(*concept_ptr_).value_;
}
throw std::bad_cast();
}
};
}

int main()
{
expr::any a = 1;
std::cout << a.any_cast<int>() << '\n';
a = 3.14;
std::cout << a.any_cast<double>() << '\n';
a = true;
std::cout << a.any_cast<bool>() << '\n';

return 0;
}
$ ./test
1
3.14
true

expr::any sınıfının içerisinde sakladığı nesneye ilişkin bir tür bilgisi tutulmaktadır. Biz örneğimizde C++’ın RTTI özelliğini kullandık. any türünün type erasure işlemi yaptıktan sonra tuttuğu değeri gerçek türü ile nasıl döneceği aslında implementasyon detayı. Burada bizim asıl ilgilendiğimiz kısım std::any türünün gerçekte başka türden değerleri nasıl saklayabildiğidir.

std::shared_ptr

İlk bakışta std::shared_ptr ile type erasure arasında bir ilişki kurulamayabilir. std::shared_ptr sonuçta çoklu sahipliği destekleyen bir akıllı gösterici şablonu. İncelememize ilk olarak klasik bir örnek ile başlayalım.

#include <iostream>

class animal
{
public:
~animal() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};

class cat : public animal
{
public:
~cat() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};

int main()
{
animal *a = new cat;
delete a;

return 0;
}

Kodu derleyip çalıştırdığımızda yalnız animal sınıfının destructor’ının çağrıldığını buna karşın cat sınıfının destructor’ının çağrılmadığını görüyoruz.

animal::~animal()

Bu klasik problemin çözümü taban sınıf destructor’ını virtual yapmak. Fakat bu değişikliği yapmadan aynı örnek için normal göstericileri kullanmak yerine shared_ptr’yi kullanalım.

#include <iostream>
#include <memory>

class animal
{
public:
~animal() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};

class cat : public animal
{
public:
~cat() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};

int main()
{
std::shared_ptr<animal> sp = std::make_shared<cat>();

return 0;
}

Kodu derleyip çalıştırdığımızda türemiş sınıf destructor’ının da çağrıldığını görüyoruz.

cat::~cat()
animal::~animal()

Hatta aşağıdaki gibi bir kullanım durumunda dahi türemiş sınıf destructor’ı çağrılacaktır.

int main()
{
std::shared_ptr<void> sp = std::make_shared<cat>();

return 0;
}

Şimdi bu aşamada aklınıza iki soru gelebilir. Birincisi shared_ptr yerine unique_ptr kullansaydık ne olurdu, diğeri ise shared_ptr bu işlemi nasıl başardı? İlk sorunun cevabını size bırakıyorum. İkinci sorumuzun cevabı ise type erasure.

std::shared_ptr sınıfı reference counting yapmakta ve dışarıdan bir deleter alabilmektedir. Biz std::shared_ptr’nin tuttuğu kaynağı nasıl geri verdiğini, yani type erasure ile ilgili kısmı, göstermek amacıyla sadece bu kısmı gösteren bir örnek vereceğiz. Örnek ilk başta yazdığımız animal sınıfından çok farklı değil.

#include <iostream>

class animal
{
public:
~animal() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};

class cat : public animal
{
public:
~cat() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};

namespace expr
{
template<typename T>
class shared_ptr
{
class deleter_base
{
public:
virtual ~deleter_base() = default;
};

template<typename U>
class deleter : public deleter_base
{
public:
~deleter() { delete d_ptr_; }
deleter(U *ptr) : d_ptr_(ptr) {}
U *d_ptr_;
};

deleter_base *deleter_base_ptr_;
T *ptr_;

public:
template<typename Y>
shared_ptr(Y* ptr) :
deleter_base_ptr_(new deleter<Y>{ptr}) {}
T& operator * () { return *ptr_; }
T* operator -> () { return ptr_; }

~shared_ptr() { delete deleter_base_ptr_; }
};
}

int main()
{
expr::shared_ptr<animal> sp = new cat;

return 0;
}
cat::~cat()
animal::~animal()

delete işlemi nesnenin gerçek türü üzerinden yapılmaktadır.

std::function

Son olarak std::function şablonuna bakalım. std::function uygun herhangi bir callable türünü kabul etmektedir. Genel olarak fonksiyonlar, fonksiyon objeleri ve lambda ifadeleri callable türleri oluşturmaktadır. std::function, STL içerisindeki, type erasure ile ilgili en güzel örneklerden birini oluşturmaktadır. İlk olarak aşağıdaki örneği inceleyerek başlayalım.

#include <iostream>
#include <functional>

struct X
{
void operator()() const {
std::cout << "function object" << std::endl;
}
};

void foo()
{
std::cout << "global function" << std::endl;
}

int main()
{
std::function<void()> f = foo;
f();

f = []() { std::cout << "lambda" << std::endl; };
f();

f = X{};
f();

return 0;
}
global function
lambda
function object

Farklı callable türden örnekler tek bir std::function örneği üzerinden çağrılabilmektedir. Kendimiz de, type erasure kullanarak, indirgenmiş bir function şablonunu aşağıdaki gibi yazabiliriz.

template<typename T>
class function;

template<typename R, typename... Args>
class function<R(Args...)>
{
public:
template<typename T>
function(T callable) : ptr_{ new model<T>{callable}} {}
R operator()(Args... args) {ptr_->invoke(args...);}

private:
class concept
{
public:
virtual R invoke(Args...) = 0;
};

template<typename T>
class model : public concept
{
public:
model(const T& param) : obj_{ param } {}
R invoke(Args... args) {obj_(args...);}
private:
T obj_;
};

concept *ptr_;
};

Kod ilk bakışta variadic template ve template specialization içerdiği için karmaşık gibi gelebilir fakat aslında yapılan işlem özünde diğer örneklerden farklı değil. Bir callable örneği saklanmakta ve operator() fonksiyonuna yapılan çağrılar bu callable örneğine delege edilmektedir. Örnek expr::function şablonumuzu aşağıdaki gibi test edebiliriz.

#include <iostream>

namespace expr
{
template<typename T>
class function;

template<typename R, typename... Args>
class function<R(Args...)>
{
public:
template<typename T>
function(T callable) : ptr_{ new model<T>{callable}} {}
R operator()(Args... args) {ptr_->invoke(args...);}

private:
class concept
{
public:
virtual R invoke(Args...) = 0;
};

template<typename T>
class model : public concept
{
public:
model(const T& callable) : callable_{ callable } {}
R invoke(Args... args) {callable_(args...);}
private:
T callable_;
};

concept *ptr_;
};
}

struct X
{
void operator()() const {
std::cout << "function object" << std::endl;
}
};

void foo()
{
std::cout << "global function" << std::endl;
}

int main()
{
expr::function<void()> f = foo;
f();

f = []() { std::cout << "lambda" << std::endl; };
f();

f = X{};
f();

return 0;
}
global function
lambda
function object

Farklı türden callable örneklerinin çağrılabildiğini görüyoruz.

Özet

Konunun özünü kaçırmamak adına yaptığımız incelemeleri özetleyelim. Türe bağlı olarak bazı kodların değişik versiyonlarının yazılması istenmeyen bir durum oluşturmaktadır. Bu durumun çözümü için tipik olarak void * türü ve çalışma zamanı çokbiçimliliği kullanılmaktadır. Her iki yöntemin de dezavantajlarından bahsetmiştik. Tekrarlayacak olursak void * türü için tür güvenliği (type safety) çalışma zamanı çokbiçimliliği için de türler arası katı bağın sebep olduğu sorunları söyleyebiliriz. Tür silme ise burada bize ayrı bir kapı açmakta. Bu yöntemin özünde tür bağımsız kodlama ile (generic programming) çalışma zamanı çokbiçimliliği beraber kullanılmaktadır. Bu sayede aralarında herhangi bir türetme ilişkisi olmayan türlere ait örnekler beraber ele alınabilmekte ve STL içindeki std::any, std::variant, std::shared_ptr ve std::function gibi tür ve şablonlar yazılabilmektedir.

Kaynaklar

--

--