C++ Şablon Bok Çukuru
Şu aralar C++ ile bir Entity Component System sistemi yapmakla uğraşmaktayım. Entity Component System’i (kısaca ECS) açıklamam gerekirse, genellikle oyunlarda kullanılan bir sistemdir ve temel amacı, oyundaki her bir varlığı (Entity) sadece veri içeren birden fazla bileşenden (Component) oluşturmak ve bu bileşenler üzerinde dönen sistemler (System) ile oyunu ayağa kaldırmaktır. Ana iki faydası ise sistemlerin, ardışık bileşenler üzerinde dönmesinden kaynaklı olarak işlemcinin önbelleğini oldukça verimli bir şekilde kullanması ile oyundaki birçok kavramın birbirinden bağımsız hâle gelmesidir -ki bu da oyunun kodunun anlaşılabilirliği için oldukça faydalıdır-. Bu konu üzerine konuşabileceğim şeyler var ama bu gönderinin konusu bu değil. Bu sistemin işinin çoğunu (mesela bileşen listeleri gibi) yürütme değil derleme zamanında gerçekleştirmek istemem sonucu olarak düştüğüm -affedersiniz- bok çukurunu anlatmak istiyorum bugün.
C++ şablonlarından (template) bahsetmem gerekiyor ilk
olarak. C++ kullanmış aşağı yukarı herkes ama az ama çok şablonları
görmüş olsa gerekir. İlk başta dile eklenme amacı, farklı türler
üzerinde çalışması gereken ve bu türlerin hepsine aynı davranan bilimum
veri yapıları ve algoritmaları yazarken kolaylık sağlamaktır. Klasik
örneği vermek gerekirse:
std::vector<int> v = {};
v.push_back(5);
v.push_back(10);
std::vector<double> c = {};
c.push_back(3.14);
c.push_back(2.71);Burada std::vector bir şablon tip (template
type) olduğundan dolayı farklı tiplerde veriyi depolayabileceğimiz
varyantlarını istediğimiz gibi oluşturabiliyoruz. Örnekte
int ve double tiplerinden veri tutabiliyor
vektörlerimiz. Şablonların temel niteliklerinden birisi farklı tip
parametresi (type parameter) almış tiplerin birbiriyle
varsayılan olarak uyumsuz olmasıdır -yani v = c gibi bir
ifade yasaktır-. Bu da dilin tip güvenliği açısından önemlidir. Keza bu
vektörler sadece belirtilen tiplerden veri depolayabilir, aynı vektörde
farklı tiplerde veriler depolamak için daha farklı tip parametreleri
kullanmak gerekir.
Şablonlar
Buraya kadar hava hoş. Şablonlar sayesinde hem tip güvenliğini ihlal
etmiyoruz (C’de benzer bir vektör türü için void*
kullanmamız gerekli ve bu da vektörün hangi tip veri depoladığı
bilgisini kaybetmemiz demek, bir bakıma tip silme yani type
erasure uyguluyoruz, bunu yöntemi kullanan diller mevcut, o ayrı
mesele) hem de kodu tekrarlamamız gerekmiyor (C’de makro cehennemine
girmeden vektör yapmaya kalkışırsak vereceğimiz her tip parametresi için
ayrı vektör yazmamız gerekir, bu da takdir edersiniz ki çok
koddur). C++’ın bir dönemler çağdaşı dillere göre en büyük
üstünlüklerinden birisi bu şablon kullanımı. Ki derlenen kod da elle
yazılmış gibi hızlı, yani dizilerin/vektörün işlenme şeklinde temel
değişiklikler yapmadan std::vector’den daha hızlı bir dizi
sistemi yapamayız. C++’ın çok sayıda bedelsiz soyutlamasından
(zero-cost abstraction) birisi bu şablonlar. Dediğim gibi
buraya kadar her şey mükemmel.
Ancak şablonlar sadece bu amaçla kullanılabilen şeyler değiller. Öncelikle şunu söylemem gerekir: C++’ın şablon sistemi Turing-complete’tir (bunu çeviremedim). Elbette bu hâliyle yapabileceklerimiz sınırlı. Fakat buna sebep olan birtakım ekstra özellik mevcut C++’ın şablon sisteminde -özellikle de yeni sürümlerinde-.
Turing-complete Şablonlar
Değişken Sayıda Argüman (Variadic Arguments)
C++ şablonlarını tanımlamaya ufaktan bakalım:
template<typename T>
class Tip {/* ... */};Buradaki Tip, sadece tek bir şablon parametresi alıyor:
T. typename ise bu T’nin bir tip
olduğunu belirtiyor. Bu tipi kullanırken Tip<int>
gibi kullanabiliriz anlamına geliyor bu. Fakat C++’ta şablonlar C++11
standardından beridir değişken sayıda argüman da alabilir.
template<typename... Ts>
class Tip {/* ... */};Burada Ts aslında -boş olma ihtimali olan- bir parametre
listesine tekabül ediyor. Artık Tip<int> dışında
Tip<int, float> veya
Tip<int, float, std::string> de verilebilir oluyor.
Ancak bu Ts şu anki hâliyle kullanılabilir değil.
Ts üzerinde for döngüsü dönemiyoruz herhangi
bir şekilde. İşte bu noktada şunu söylemek gerekir: C++ şablonları
fonksiyonel programlama mantığıyla çalışır. Turing-complete
derken aslında Turing makinesi usûlü döngüler ve şartlarla değil, Lambda
kalkülüsü usûlü özyineleme ve desen eşleme (pattern matching)
ile düşünmek gerekir. Birden fazla şablon tanımlayacağımız anlamına
geliyor bu.
template<typename... Ts>
class Tip {};
template<typename T, typename... Ts>
class Tip<T, Ts...> {};
template<>
class Tip<> {};İkinci ve üçüncü tanımlarda değişik durumlar var görebileceğiniz üzere.
Eksik ve Tam Özelleştirmeler (Partial and Full Specializations)
Üstteki kodda gerçekleşen şey özelleştirmelerdir.
İlk tanım aslında ana şablon (primary template), yani o şablonun genel tanımı, diğer özelleştirmeler tutmadığı takdirde kullanılacak olan tanımı, yani temel durum (base case).
İkinci tanım ise eksik özelleştirme (partial
specialization). Eksik olmasının sebebi, kendisinin de şablon
argümanına sahip olması (üstünde T ve Ts
argümanları görülebiliyor). En az bir parametre içeren bütün
Tip kullanımları bu tanımı kullanacaktır, Yani
Tip<int>, Tip<float>,
Tip<int, float>,
Tip<int, float, std::string, std::queue<long>>
gibi örneklerin hepsi bu tanımı kullanacakken Tip<>
bu tanımı kullanmayacaktır (şablon parametresi olmadığı, dolayısıyla
T’ye denk gelen bir parametreye sahip olmadığı için).
Üçüncü tanım ise bir tam özelleştirme (full specialization).
Tam olmasının sebebi herhangi bir şablon parametresi almıyor olması,
görebileceğiniz üzere template ifadesinden sonraki liste
boş. Bunun anlamı, bu özelleştirmeye denk sadece bir tane olası
parametre listesinin bulunmasıdır -Tip<> dışındaki
hiçbir kullanım üçüncü tanımı kullanmayacaktır-.
Burada görebileceğimiz üzere aslında yazdığımız iki özelleştirme,
bütün olası Tip kullanımlarını karşılıyor, parametre
verilirse ikinci, verilmezse üçüncü tanım kullanılıyor. Haskell gibi
desen eşleştirme destekleyen bir dil kullanmışsanız bunun şundan çok
farklı olmadığını fark etmişsinizdir:
tip (t:ts)
tip ()Ancak Haskell’de yazmak pek mümkün değil çünkü şu anda bu
argümanlarla hiçbir şey yapılmıyor, yani Tip’in içerisinde
hiçbir şey mevcut değil. Gelin head işlevini tanımlayalım
bu şekilde, yani listenin ilk parametresini dönsün. Tabii ki şablonlarda
“dönmek” kavramı yok, biz de bunun yerine döndüreceğimiz tipi
tanımlayacağız.
template<typename...>
class Head {};
template<typename T, typename... Ts>
class Head<T, Ts...> {
public:
using type = T;
};
template<>
class Head<> {};Artık Head<int, float>::type dediğimizde
int anlamına geliyor. Ancak fark etmişsinizdir ki
Head<>::type diye bir tip yok. Boş bir listenin
head’i olması saçma olurdu keza. Haskell’de benzeri bir tanım yapmamız
gerekirse şöyle olabilir
head (x:xs) = xBoş tuple (demet diye çevrilmiş de bana bile tuhaf geldi)
desenine (head ()) fonksiyon tanımlamadım çünkü öyle bir
durum/desen geldiği takdirde hata verilmesi gerekmeli, keza Haskell’in
kendi head’i de bu şekilde çalışıyor. Aradaki en önemli
fark hata mesajları olsa gerek, “type,
Head<>’in bir elemanı değil” gibi sorunun kendisini
belirtmeyen hata mesajları ile karşılaşmak oldukça kolay. Bazı
durumlarda static_assert ile daha düzgün hata mesajları
verilebilir fakat boş listede herhangi bir argüman olmadığı için
assert ettiğimiz ifadeyi dolaylayamıyoruz, dolayısıyla da boş
Head olmasa dahi static_assert çalışıp
kodumuzun derlenmesini önlüyor.
Head’de bahsetmek istediğim ufak bir kısım ise
using. Anladığınızı düşünüyorum ama yine de anlatmam
gerekirse yaptığı şey, o isim alanı içerisinde (namepsace
veya class/struct) bir tipe başka bir isim
vermek. C’deki typedef’in daha da güçlü hâli, nitekim
kendisi de şablon olabiliyor.
Tamsayı Argümanlar
Desen eşleme yetmediği gibi tamsayı parametre verebiliyoruz
şablonlara. Bunun için argüman listesinde yapmamız gereken tek
değişiklik typename yerine int -veya herhangi
bir tamsayı türü- yazmak.
Derlenme Zamanı Değişkenler
C++11 ile hayatımıza constexpr diye bir ifade girdi. Bir
değişken constexpr olarak tanımlandığı takdirde derlenme
zamanında erişilebilir olur ve şablonlara parametre olarak verilebilir
hâle gelir. using’in tip değil değere dönüşen hâli olarak
düşünebiliriz constexpr değişkenleri. O zaman derleme
zamanında faktoriyel hesaplayabilir anlamına geliyor bu!
template<int N>
struct Factorial {
static constexpr int value = N * Factorial<N-1>::value;
};
template<>
struct Factorial<0> {
static constexpr int value = 1;
};Haskell’de aynı fonksiyonu yazmak istesek şuna benzer bir şey olurdu:
factorial n = n * factorial (n - 1)
factorial 0 = 1Görebileceğiniz üzere çok daha kalabalık olması haricinde C++ şablonları ile Haskell büyük ölçüde eşleşiyorlar mantık olarak.
Şablon Cehennemi
Benim bu aralar cebelleştiğim şey ise bir tip listesi yaratmak. Bu tip listesi basitçe
template<typename... Ts>
struct List {};olarak tanımlanmış durumda. Bunun elbette çoğu işlevi için iki olasılık var: boş liste ve elemanlı liste.
template<typename T, typename... Ts>
struct List<T, Ts...> {};
template<>
struct List<> {};Gelin bu listenin birkaç özelliğini nasıl yaptığımı göstereyim.
template<typename T, typename... Ts>
struct List<T, Ts...> {
using head = T;
};
template<>
struct List<> {};Daha önce de bahsettiğim gibi, head işlevi boş listede
tanımlı değil.
template<typename T, typename... Ts>
struct List<T, Ts...> {
using tail = List<Ts...>;
};
template<>
struct List<> {
using tail = List<>;
};Boş listenin tail’ı da boş liste. Bunu hata olarak da
belirtebilirdik tabii.
at işlevini oluşturmak biraz daha sıkıntılı. Ayrı bir
“işlev şablonu” oluşturmak gerekiyor using ile tanımlanmış
şablonları özelleştiremediğimiz için.
template<int index, typename T>
struct At {};
template<typename T, typename... Ts>
struct At<0, List<T, Ts...>> {
using type = T;
};
template<int index, typename T, typename... Ts>
struct At<index, List<T, Ts...>> {
using type = At<index - 1, List<Ts...>>::type;
};
template<typename T, typename... Ts>
struct List<T, Ts...> {
template<int index>
using at = At<index, List<T, Ts...>>::type;
};
template<>
struct List<> {};Bu örnek çok kalabalık görünüyor olabilir -ki öyle- ama parça parça gidersek daha anlaşılır olacağını düşünüyorum.
İlk başta At diye bir şablon tanımladık, bir tamsayı ve
bir tip alıyor ve hiçbir şey içermiyor. Sonrasında ise eleman içeren bir
listede index’in 0 olduğu durumu özelleştirdik. Bu durumda
baş eleman sonucumuz olduğu için type tipini başa
(T’ye) eşitledik. Sonrasında ise diğer olasılığa baktık
yani listenin kalanında aramaya (elbette index’i 1
azaltarak). List’in kendisinde ise basitçe bu şablonu uygun
parametrelerle “çağırdık”.
Haskell’deki karşılığı şu şekilde olur (artık ayrı bir
List olduğu için tuple değil Haskell listeleri ile
fonksiyonu tanımlayabiliriz, çok da fark eden bir şey değil tabii).
at 0 [t:ts] = t
at index [t:ts] = at (index - 1) tsfind, bir değer (tamsayı) döndüren bir işlev ama yapılma
şekli çok da farklı değil diğerlerine kıyasla.
template<typename Q, int index, typename T>
struct find {};
template<typename Q, int index>
struct find<Q, index, List<>> {
static constexpr auto value = -1;
};
template<typename Q, int index, typename... Ts>
struct find<Q, index, List<Q, Ts...>> {
static constexpr auto value = index;
};
template<typename Q, int index, typename T, typename... Ts>
struct find<Q, index, List<T, Ts...>> {
static constexpr auto value = find<Q, index + 1, List<Ts...>>::value;
};
template<typename T, typename... Ts>
struct List<T, Ts...> {
template<typename Q>
static constexpr auto find = ::find<Q, 0, List<T, Ts...>>::value;
};
template<>
struct List<> {
template<typename Q>
static constexpr auto find = -1;
};Ancak burada “kuyruk özyinelemesi” (tail recursion) kavramını kullandım diğerlerinden farklı olarak. Eğer diğer türlü olsa idi indisin -1 olma durumunda (yani verilen tipin bulunamaması durumunda) sonucun -1 olarak kalması için başka uğraşmak zorunda kalacaktım. Haskell’de yazarsak da şu şekilde oluyor:
find q index [] = -1
find q index [q:ts] = index
find q index [t:ts] = find q (index + 1) tsSon olarak da fonksiyonel programlamanın en temel işlevlerinden olan
map’i göstermek istiyorum.
template<typename Appended, typename T>
struct Append {};
template<typename Appended, typename... Ts>
struct Append<Appended, List<Ts...>> {
using type = List<Ts..., Appended>;
};
template<template<typename, typename...> typename F, typename Ret, typename T>
struct map {};
template<template<typename, typename...> typename F, typename Ret>
struct map<F, Ret, List<>> {
using type = Ret;
};
template<template<typename, typename...> typename F, typename Ret, typename T, typename... Ts>
struct map<F, Ret, List<T, Ts...>> {
using type = map<F, Append<F<T>, Ret>, List<Ts...>>::type;
};
template<typename T, typename... Ts>
struct List<T, Ts...> {
template<template<typename, typename...> typename F>
using map = typename ::map<F, List<>, List<T, Ts...>>::type;
};
template<>
struct List<> {
template<template<typename, typename...> typename F>
using map = List<>;
};Aslında bu map’ten çok wrap’e denk geliyor.
Bu işlev aslında benim bu şablon batağına düşmemin temel sebebiydi
diyebilirim (bir tip listesinin her elemanı için ayrı birer
std::vector). Buradaki en fantastik kısım ise muhtemelen
template<typename, typename...> typename F kısmı. Bu,
“F en az bir şablon parametresi alan bir şablondur”
anlamına geliyor. Eğer şablonlar işlevler ise (ki bizim kullanımımızda
öyle çalışıyorlar) bu kullanım sayesinde “yüksek dereceli çeşit”leri
(higher order kind) göstermek mümkün hâle geliyor. Burada ufak
bir Append tanımlamak zorunda kaldım çünkü aynı anda hem
Ret’i hem T’yi (List’imiz)
bölemiyoruz. Haskell’de bu Append önceden ++
işelci olarak tanımlı ama.
Haskell’deki karşılığı da şu oluyor:
map f ret [] = []
map f ret [t:ts] = map f (ret ++ [(f t)]) [ts]Kapanış
C++ şablonları bir bok deliğidir. Diğer dillerin çoğunda pek mümkün
olmayan ya da derleme değil yürütme zamanında ancak mümkün olan şeyleri
yapabilmemizi sağlaması ise oldukça güçlü bir nitelik kılar şablonları.
Zig, Rust ve Nim gibi daha modern dillerde veya Lisp gibi başından beri
üstprogramlama (metaprogramming) düşünülmüş diller dışındaki
dillerde derleme zamanında işlem yapmak oldukça sıkıntılı iken C++’ta
her ne kadar üstprogramlama sonradan eklenen bir özellik olsa da güçlü
ve hünerli biçimde yer almakta. Bu bok deliğine düşmemek diğer insanlar
için çok zor değil muhtemelen, insanlar her yere virtual
koyup geçiyor çoğunlukla veya C++’ı “Sınıflı C” (C with
classes) olarak kullanıyor. Fakat benim gibi birine bu şablon
imkanlarını verirseniz (ki sayıca o kadar az değiliz) şablonları
istismar etmekten kaçınmayacaktır.
Şablonların oluşturduğu hiç de zarif olmayan fonksiyonel üstprogramlama dili için düşünmenin en kolay yolunun Haskell gibi bu amaç için tasarlanmış saf bir fonksiyonel programlama dili olduğu kanaatindeyim. Nitekim fark etmişsinizdir ki Haskell kodu ile C++ şablonu neredeyse birebir eşleşmekte ama C++ şablonunu görmek, algılamak, çözümlemek, yazmak ve okumak çok daha zor. Bunun bir “algoritma”sını (tırnak içinde çünkü muhtemelen katı bir algoritmadan ziyade insana yönelik bir tarif olacak) oluşturmayı düşünmüyor değilim açıkçası.
Bir dahaki C++ gönderim muhtemelen CRTP (Curiously Recurring
Template Pattern, İlginç Şekilde Tekrar Eden Şablon Deseni) üzerine
olacaktır -ki kendisi apayrı bir bok çukurudur-. Çağdaş C++’ın birçok
nimeti var ancak insanlar bunların pek farkında değiller ne yazık ki.
Rust’tan pek farklı olmayan bir hafıza güvenliğine ve Lisp seviyesinde
bir üstprogramlama kapasitesine sahip olmasına karşın insanlar C++’ı
çoğunlukla bol sınıf işaretçili new ve delete
çorbası olarak öğrendiğinden ötürü maalesef C++’ın kötü bir dil olduğunu
düşünüyorlar.