C++20 standartı ile gelen Range kütüphanesini sizlere aktardığım yazımda, kütüphanenin sahip olduğu tembel hesaplama yaklaşımından ve dile kazandırdığı faydalardan bahsetmiştim. Range kütüphanesini tanıtırken teknik detaylara yer vermeden, sahip olduğu adaptörleri ve üreteçleri örnek kullanımları ile aktarmaya çalışmıştım. Ancak, tembel hesaplama tekniğini anlamadan, ranges kütüphanesini anlamak pek mümkün olmayacağı için, sizlere biraz lazy evaluation kavramından bahsetmek ve range kütüphanesinin bunu nasıl sağladığını anlatmak istiyorum.
İlk olarak 1995 yılında Expression Templates tekniği ile karşımıza çıkan tembel yaklaşım mimarisi, Blaze, Boost.UBlas, Blitz gibi numerik hesaplamalar için geliştirilen kütüphanelerin çekirdek mimarisini oluşturmaktadır. Tembel yaklaşım ile hesaplama, temelinde çok basit bir mimariye sahip olmasına rağmen, performans etkisi inanılmaz ölçülerde olabilmektedir.
Tembel yaklaşım mimarisinin bu zamana kadar bilinmesine rağmen geliştiriciler tarafından zorunda kalınmadıkça
tercih edilmemesinin sebebi, Modern C++ öncesinde auto
tip çıkarıcısı, variadic templates
, labmda expressions
gibi sentaksı rahatlatan özelliklerin bulunmamasıydı. C++11 ve sonrasında dile getirilen birçok yenilik ve
özellik ile birlikte, range kütüphanesi gibi tembel yaklaşım kullanan kütüphanelerin anlaşılırlığı ve kullanılabilirliği,
her seviyeden geliştirici tarafından anlaşılır hale gelmiştir.
Eager Evaluation - Anlık Hesaplama
Normal şartlar altında yazdığımız kodları tasarlarken, kodların yazıldığı yerde çalıştırıldığını, sonucunda da geriye bir şey döndürüldüğünü veya bir işlem yapıldığını kabul ederek tasarımlarımızı gerçekleştiririz. Anlık hesaplama yöntemi ile yazdığımız kodların çağrıldıkları yerde işletildiklerini biliyor olmak, kod üzerinde determinizmi sağlaması ve daha kontrollü olması sayesinde, bizlerin en sık tercih ettiği yöntem haline gelmiştir.
Bu yöntem ile çarpım işlemi gerçekleştiren int multiply(int x, int y)
fonksiyonunu yazmak istediğimizde, fonksiyonun yapısı
aşağıdaki gibi olacaktır:
1. int multiply(int x, int y) { return x * y; }
2.
3. int main() {
4. int x = 2, y = 3;
5. int tmp = multiply(x, y);
6. int result = multiply(4, tmp);
7. // ... and many other calculations ...
8. cout << "result: " << result << "\n";
9. }
Anlık hesaplama tekniğinde multiply
fonksiyonu, verilen değerlerin çarpımını anlık olarak hesaplayarak geriye 6 değerini
tmp
değişkenine 5. satırda eşitleyecektir. Sonra, hesapladığı geçici değişken ile diper çarpım işlemini gerçekleştirerek
sonucunu result
değişkenine atayacaktır. Konsola yazdırıldığı yere kadar hiç kullanılmamış olsa da hesaplanmış olan result
değeri, uygulama içerisinde tutularak 8. satırda kullanılacaktır.
Lazy Evaluation - Tembel Hesaplama Mimarisi
Eğer multiply
fonksiyonu tembel hesaplama mimarisini kullanmış olsaydı, çarpım işlemleri birleştirilerek 8. satırda
sonuç kullanılmak istenene kadar işletilmeyecekti. Bu sırada result
değişkeni yeni yöntemde, işlemlerin sonucu yerine,
verilen işlemlerin bilgilerini tutan ara bir nesne olacaktı. Bu sayede sonucu hesapyalacak çarpım işlemlerini son ana kadar
öteleyerek, yalnızca gerektiği yerde çalıştırmasını sağlayabilecektir.
Yukarıdaki işlemleri tembel yaklaşımla yapmak için, çözüme sorundan başlayarak ilerleyelim ve ilk önce multiply
fonksiyonumuzu
tipten bağımsız ve geriye çarpım işlemlerini tutacak olan tembel sınıfı dönecek şekilde tekrar yazalım:
template <typename T1, typename T2>
struct lazy_t; // (1)
template <typename T1, typename T2> // (2)
lazy_t<T1, T2> multiply(T1 x, T2 y)
{
return lazy_t<T1, T2>(x , y); // (3)
}
-
lazy_t
adını verdiğimiz, çarpım işlemlerini tutarak son ana kadar öteleyecek olan ara sınıfımızı tanımlayalım. Tembel hesaplama için kullanacağımız ana yapı bu sınıf olacak ve çarpım işlemlerini işlemleri sınıfımız üzerinden tamamlıyor olacağız -
Çarpım işlemine gönderilecek argümanlar,
int
olabileceği gibi bir önceki çarpım işleminden dönenlazy_t
sınıfı da olabileceğinden, argüman tiplerininin tipten bağımsız olması gerekmektedir. Bu nedenlemultiply
fonksiyonumuzu iki farklı template tip alan bir yapıda gerçekleştiriyoruz -
multiply
fonksiyonunun, sadeceT1
veT2
tip bilgileri ile birlikte aldığı argümanlarılazy_t
sınıfına aktararak geriyelazy_t<T1, T2>
sınıfı dönmesi yeterli. Fonksiyonun döndürdüğü sınıfın sahip olduğu tip bilgileri ve değerler, verilen bütün işlemleri birleştirilmesinde kullanılarak nihai sonucun hesaplanması sağlanacakModern C++ ile gelen
auto
tip çıkarıcısı olmadan önce, tembel yaklaşımla yaratılan ara sınıfların tiplerinin net olarak tanımlanması gerekiyordu.
İki veya üç işlemin birleşiminden ortaya çıkan ara sınıfların bile tiplerinin belirlenmesi veya yazılmasının çok zor olmasından dolayı C++’da tembel hesaplama yaklaşımıyla kod yazılması tercih edilmiyordu.
Tembel hesaplama yaklaşımını sunan, multiply
fonksiyonunun geri döndürüğü lazy_t
sınımızı geliştirmeye başlayalım. İki farklı template
tip alan sınıfımızın yapısı aşağıdaki gibi olacaktır:
template <typename T1, typename T2>
struct lazy_t
{
lazy_t(const T1& first_,
const T2& second_)
: first(first_)
, second(second_) { }
template <typename result_t>
operator result_t() const {
return first * second; // (2)
}
private:
T1 first; // (1)
T2 second; // (1)
};
-
Yaratıldığı tip bilgileri ile verilen değişkenleri saklamalıyız; bu değişkenleri sonuç hesaplanacağı zaman kullanacağız
-
Tembel sınıfımızı sonuç istendiği zaman çarpma işlemini yapacak şekilde tür atanma operatörünün özelleştirerek, sınıfımızın tuttuğu hesaplamaları yapmasını sağlıyoruz.
Örneği basit ve yalın tutmak adına,
int result = lazy_t<...>;
işlemi yapıldığında hesaplamaların yapılmasını sağladım.
Yazdığımız kodları birleştirelim ve tembel hesaplama tekniğiyle bir çarpım işlemi gerçekleştirelim:
1. int main()
2. {
3. int x{ 1 }, y{ 2 }, z{ 3 };
4. auto lazy_y_z = multiply(y, z);
5. auto lazy_x_y_z = multiply(x, lazy_y_z);
6.
7. std::cout << "type of lazy_x_y : "
8. << typeid(lazy_y_z).name() << "\n";
9.
10. std::cout << "type of lazy_x_y_z: "
11. << typeid(lazy_x_y_z).name() << "\n";
12.
13. int result = lazy_x_y_z;
14. std::cout << "result of lazy operations: "
15. << result << "\n";
16. }
8. type of lazy_x_y : struct lazy::lazy_t<int, int>
11. type of lazy_x_y_z: struct lazy::lazy_t<int, struct lazy::lazy_t<int, int> >
14. result of lazy operation: 6
Yukarıda yazdığımız kod başarıyla çalışacak ve int result = lazy_x_y_z;
dediğimiz yerde çarpım işlemi
gerçekleşerek sonucu result
değişkenine atayacaktır. Fakat bunu yapabilmesi için son bir düzenlemeye daha
ihtiyacımız var.
Çarpım işlemlerini birleştirdiğimizde, bütün çarpımları yapacak olan son tembel nesnemizin
tuttuğu tiplere bakacak olursak: T2
template tipinin int
yerine bir önceki çarpım işlemi için yaratılan
tembel nesnein olduğunu göreceğiz. int
tipi ile X ve Y tiplerinden oluşturulan lazy<X, Y>
sınıfı
için özelleştirilmiş bir ‘*’ çarpım operatörünü yazmamız gerekiyor.
template <typename result_t, typename U1, typename U2>
result_t operator*(const result_t& left, const lazy_t<U1, U2>& oth)
{
return left * oth.first * oth.second;
}
oth.first
veyaoth.second
değişkenlerinin tiplerinin farklılazy<T1, T2>
sınıflarından oluşturulan nesneler olabileceğini unutmayın!
Böyle bir durumda ‘operator*(…)’ çarpım operatörü ilgili çarpım işlemi için de çağrılacak ve bu sayede çarpım işlemleri bağlantılı bir şekilde tamamlanacaktır.
Sağ tarafında lazy_t
türünden sınıf alan çarpım operatörü özelleştirmesi sayesinde, geliştirdiğimiz tembel çarpım
fonksiyonumuz istediğimiz kadar çarpım işlemini birleştirerek çarpma işlemi için birleştirebilecektir.
Yazdığımız kodları birleştirirsek kodumuzun son hali aşağıdaki gibi olacaktır:
#include <iostream>
template <typename T1, typename T2>
struct lazy_t
{
lazy_t(const T1& first_, const T2& second_)
: first(first_)
, second(second_) { }
template <typename result_t>
operator result_t() const {
return first * second;
}
private:
template <typename result_t, typename U1, typename U2>
friend result_t operator*(const result_t& left,
const lazy_t<U1, U2>& oth);
T1 first;
T2 second;
};
template <typename result_t, typename U1, typename U2>
result_t operator*(const result_t& left,
const lazy_t<U1, U2>& oth)
{
return left * oth.first * oth.second;
}
template <typename T1, typename T2>
lazy_t<T1, T2> multiply(T1 x, T2 y)
{
return lazy_t<T1, T2>(x, y);
}
int main()
{
int x{ 2 }, y{ 2 }, z{ 2 };
auto lazy_result = multiply(x, multiply(y, z));
std::cout << "result of lazy operations: " << int(lazy_result) << "\n";
}