C++ Dilinde Sağ Taraf Referansları-II

Serkan Eser
9 min readMar 19, 2023

--

Bu yazımızda sağ taraf referanslarıyla ilgili incelemelerimize devam ediyoruz. Bu bölümde forwarding reference kavramını ve perfect forwarding olarak isimlendirilen yöntemi inceleyeceğiz.

Perfect Forwarding

Bu yöntem için mükemmel gönderim ifadesini kullanacağız. Mükemmel gönderim bir fonksiyonun kendisine geçirilen argümanları, fazladan bir kopyalama maliyetine sebep olmaksızın, başka bir fonksiyona gönderebilmesidir. Bu kullanıma örnek olarak gerçek işi yapan fonksiyonların sarmalanmasını (wrapper function) ve sınıfların başka sınıfları içerme yoluyla (composition) kullanmasını örnek verebiliriz. Gerçek fonksiyon çağrısı yerine onu sarmalayan bir fonksiyon yazılarak gerçek fonksiyon çağrısından önce bir takım işlemlerin yapılması istenebilir. Bir önceki yazımızda örnek olarak verdiğimiz Employee sınıfında, Employee sınıfının constructor fonksiyonları kendilerine geçirilen argümanları direkt std::string sınıfının ilgili constructor fonksiyonlarına geçirmekteydi. Fakat parametre sayısı arttıkça artan sayıda yüklenmiş (overloaded) fonksiyon yazmak gerekiyordu. Bu noktada bir önceki yazımızdaki son örneği yeniden incelemek faydalı olaraktır. Ayrıca bir önceki yöntemde std::string sınıfının yalnız kopyalayan veya taşıyan constructor fonksiyonları kullanılabilmekteydi. Bir örnek üzerinden bu durumu inceleyelim.

int main()
{
Employee e("mülayim", "istanbul", "memur");
return 0;
}

Employee sınıfınına gönderilen argümanlar, argüman parametre uyumunu sağlamak için, ilk olarak std::string türünden geçici birer nesneye dönüştürülmelidir. Sonrasında std::string sınıfının kopyalayan veya taşıyan constructor fonksiyonu tarafından Employee içindeki ilgili alana kopyalanmalıdır. Öte yandan std::string sınıfın direkt olarak bir yazı değişmezi alan aşağıdaki gibi bir yüklenmiş constructor fonksiyonu daha bulunmaktadır.

string (const char* s);

Bu durumda yazı değişmezlerinden geçici nesneler oluşturmaksızın Employee içinde direkt olarak std::string sınıfının const char* alan constructor fonksiyonunu kullanmak daha etkin kod üretilmesini sağlayacaktır. C++11 ile beraber bu yöntem sağ taraf referans parametreli fonksiyon şablonlarıyla mümkün olmaktadır. Bu amaçla dile 2 kural eklenmiştir. Şimdi bu kurallara bakalım.

Reference Collapsing

C++ dilinde, standartlara göre referanslar gerçekte birer nesne olmadığından dolayı, referansı gösteren referansların bulunmadığını daha önce söylemiştik. Bu durumda aşağıdaki kod geçersiz olacaktır.

int main()
{
int i;
int &r = i;
int & &rr = r; //geçersiz
}

Kodu sırasıyla g++ ve clang++ ile derlemek istediğimizde aşağıdaki hataları almaktayız.

error: cannot declare reference to ‘int&’, which is not a typedef or a template type argumenterror: 'rr' declared as a reference to a reference

g++ ile verilen hatanın sonunda template type argument ifadesinin geçtiğine dikkat ediniz. Şablonlardan kod oluşturulması sürecinde (template instantiation) sırasında ise derleyici referansları gösteren referans ifadeleri ile karşılaşabilmektedir. Aşağıdaki kodu inceleyelim.

#include <iostream>using namespace std;template <typename T>
void foo(T param)
{
cout << __PRETTY_FUNCTION__ << endl;
T& t = param;
}
int main()
{
int i;
foo<int&>(i);
return 0;
}

Şablon ilklendirilirken T tür parametresi (template type parameter) açık bir şekilde int& olarak belirtilmesine karşın kod hem C++11 hem de sonrası
için geçerlidir.

void foo(T) [with T = int&]

foo içindeki t değişkenin türünü merak ediyor olabilirsiniz. Bu amaçla C++11 ile dile eklenen decltype belirleyicisi (specifier) ve is_same type trait aracını kullanılarak değişkenin tipini belirleyebiliriz. foo şablonuna aşağıdaki değişikliği yapıp, C++11'e göre, yeniden derleyelim.

template <typename T>
void foo(T param)
{
cout << __PRETTY_FUNCTION__ << endl;

T& t = param;

if (std::is_same<decltype(t), int&>::value) {
cout << "t'nin türü: int&" << endl;
}
}

Kodu çalıştırdığımızda t değişkeninin değerinin int& olduğunu görmekteyiz.

void foo(T) [with T = int&]
t'nin türü: int&

Derleyici şablondan kod üretirken T& t için int& & t görmesine karşın t’nin türünü int& olarak belirlemiştir. C++11 ile birlikte, derleyici tarafından yapılan tür belirleme (type deduction) işlemleri sırasında referansları gösteren referansların bulunması durumunda nasıl ele alınacaklarıyla ilgili bir kural getirilmiştir. Bu kural reference collapsing olarak isimlendirilmektedir. Biz bu kuralı referansların daraltılması olarak isimlendireceğiz. Şablon, auto, decltype ile yapılan tür belirleme ve typedef, using ile yapılan eş isimlendirme işlemlerinde referans daraltılma işlemi uygulanmaktır. Biz şu an yalnız şablonlardaki kullanımı ile ilgilenmekteyiz. Kural oldukça basittir. Ancak birbirini izleyen sağ taraf referansları bir sağ taraf referansına dönüşmektedir. Onun dışındaki tüm kullanımlarda bir sol taraf referansı elde edilmektedir. Başka türlü ifade edecek olursak referanslardan biri sol taraf referansı ise sonuçta sol taraf referansı elde edilir. Aşağıdaki örnekler üzerinden bu durumu inceleyelim.

using lref = int&;
using rref = int&&;
int n;

Sadece r4 bir sağ taraf referansı göstermektedir. Bu sonucu aşağıdaki gibi bir kod ile doğrulayabilirsiniz.

if (std::is_same<decltype(r4), int&&>::value) {
cout << "r4 type is int&&" << endl;
}

Bir tür eş bildiriminin yapılmadığı aşağıdaki kullanımlar ise geçersizdir.

int&& && r5;
int&& & r6;
int& && r7;
int& & r8;

Mükemmel gönderim için C++11 ile beraber iki kural eklendiğini söylemiştik. Referans daraltım kuralı ikinci kuralla beraber anlamlı
hale gelmektedir. Şimdi ikinci kurala bakalım.

Sağ Taraf Referansları İçin Tür Belirleme Kuralları (type deduction rules)

Sağ taraf referansları tür belirleme işlemi sırasında farklı kurallara tabi tutulmaktadır. Fonksiyon şablonuna geçirilen argümanın sol veya sağ taraf değeri olmasına göre şablon tür çıkarımı farklı yapılmaktadır. Bir fonksiyon şablonunun fonksiyon parametresi T&& param olsun. Bu fonksiyon şablonuna U türünden bir sol taraf değeri argüman olarak geçirilirse T için U& çıkarımı yapılır. Bu durumda T&& tür ifadesi, U& && şekline dönüşecektir. Referans daraltım kurallarına göre U& && ise bir sol taraf referansı U& şekline dönüşecektir. Fonksiyona U türünden bir sağ taraf değeri geçirilmesi durumunda ise T için U çıkarımı yapılacaktır. Bu durumda T&&, U&& şekline yani bir sağ taraf referansına dönüşecektir. Bu şekilde bir sağ taraf referansı tür belirleme işlemine tabi tutuluyorsa kendisini ilklendiren ifadenin değer kategorisine göre sağ veya sol taraf referansına dönüşmektedir. Aşağıdaki örnek üzerinden bu durumu inceleyelim.

#include <iostream>

using namespace std;

template <typename T>
void foo(T&& param)
{
cout << __PRETTY_FUNCTION__ << endl;
if (std::is_same<decltype(param), int&>::value) {
cout << "param türü: int&" << endl;
}

if (std::is_same<decltype(param), int&&>::value) {
cout << "param türü: int&&" << endl;
}
}

int main()
{
int i;
foo(i);
cout << "*****************************" << endl;
foo(0);
}

Kodu derleyip çalıştıralım.

void foo(T&&) [with T = int&]
param türü: int&
*****************************
void foo(T&&) [with T = int]
param türü: int&&

foo fonksiyonuna int türden bir sol taraf değeri geçirildiğinde T için int& çıkarımı yapılmış ve referans daraltma sonrasında foo fonksiyonunun parametresi int& param şekline dönüşmüştür. int türden bir sağ taraf değeri geçirilmesi durumunda ise T için int çıkarımı yapılmış ve foo fonksiyonunun parametresi int&& param şekline dönüşmüştür.

Burada dikkat edilmesi gereken nokta foo fonksiyonuna geçirilen argümanın değer kategorisinin korunmuş olmasıdır. U türden bir sol taraf değeri için T şablon tür parametresi U& şekline dönüşürken, sağ taraf değeri için U şekline dönüşmektedir. Bu sayede şablonun bir sol taraf mı yoksa bir sağ taraf değeri için mi ilklendirildiği anlaşılabilmektedir. Sağ taraf referanslarının tür belirleme işlemine girdiklerinde sağ veya sol taraf referanslarına dönüşebilmelerinden dolayı ayrı bir isme sahip olmasının kavramsal olarak daha faydalı olacağı düşünülmüş. Bu amaçla bu tür referanslara ilk olarak Scott Meyers tarafından universal references ismi verilmesine karşın daha sonra standart dokümanlarda forwarding references tercih edilmiştir.

Buraya kadar olan kısmı özetleyecek olursak. Tür belirleme işlemine tabi tutulan sağ taraf referansları forwarding references olarak isimlendirilmektedir. Forwarding referanslar nasıl ilklendirildiklerine bağlı olarak sağ ve sol taraf referansına dönüşmektedir. Bu sayede bir şablondan hem sağ hem de sol taraf değerleri için kod üretilebilmektedir. Forwarding referansa sahip fonksiyon şablolarının asıl kullanım amacı kendilerine geçirilen argümanları, argümanların değer kategorilerini koruyarak, başka fonksiyonlara geçirmektir. Bu yöntemin perfect forwarding olarak isimlendirildiğini hatırlayınız. Aşağıdaki örneği inceleyelim.

#include <iostream>

using namespace std;

class X {};

void bar(X&) {cout << __PRETTY_FUNCTION__ << endl;}

void bar(X&&) {cout << __PRETTY_FUNCTION__ << endl;}

template <typename T>
void foo(T&& param) { bar(param); }

int main()
{
X x;
foo(x);
foo(X{});
}

Kodu derleyip çalıştıralım.

void bar(X&)
void bar(X&)

Her iki foo çağrısı sonucunda da sol taraf değerleri için yazılmış olan bar(X&) fonksiyonunun çağrıldığını görüyoruz. Nedenine bakacak olursak. Derleyici tür belirleme ve referans daraltma kurallarını uygulayarak foo(x) ve foo(X{}) için aşağıdaki eşdeğer foo fonksiyonlarını yazacaktır.

Not: Derleyici aslında C++ dili sentaksında bir kod değil sembolik makine dilindeki karşılığını üretmektedir.

void foo(X& param)  {bar(param);}
void foo(X&& param) {bar(param);}

Not: Derleyicinin foo şablonu için yazdığı fonksiyonlara gerçekte verdiği isimleri aşağıdaki gibi öğrenebiliriz. İlk olarak kodu aşağıdaki gibi derleyelim.

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

Sonrasında amaç kod içindeki foo fonksiyonuna ilişkin sembolleri aşağıdaki gibi öğrenebiliriz.

$ nm test.o | grep foo
0000000000000000 W _Z3fooI1XEvOT_
0000000000000000 W _Z3fooIR1XEvOT_

Dekore edilmiş isimlerin orjinal hallerini de aşağıdaki gibi öğrenebiliriz.
$ c++filt -t _Z3fooI1XEvOT_
void foo<X>(X&&)

$ c++filt -t _Z3fooIR1XEvOT_
void foo<X&>(X&)

Sol taraf referanslarının, bir isme sahip olmaları durumunda, sağ taraf değerlerine bağlanmalarına karşın kendilerinin bir sol taraf değeri olduğunu daha önce söylemiştik. Bu sebeple her iki foo fonksiyonu tarafından da bar(X&) fonksiyonu çağrılacaktır. Forwarding referansların kendilerine geçirilen argümanların değer kategorilerini koruduğunu biliyoruz. Bu durumda bar fonksiyonuna geçirilen argüman üzerinde bir tür dönüşümü yaparak uygun bar fonksiyonlarını çağırabiliriz. Aşağıdaki örneği inceleyelim.

#include <iostream>using namespace std;class X {};void bar(X&) {cout << __PRETTY_FUNCTION__ << endl;}void bar(X&&) {cout << __PRETTY_FUNCTION__ << endl;}template <typename T>
void foo(T&& param) {bar(static_cast<T&&>(param));}
int main()
{
X x;
foo(x); foo(X{});
}

Kodu derleyip çalıştıralım.

void bar(X&)
void bar(X&&)

Tür dönüştürme işleminden sonra uygun bar fonksiyonları çağrılmaktadır. Fonksiyon parametresi x ve X{} için oluşturulacak eşdeğer foo fonksiyonları aşağıdaki gibi olacaktır.

foo(x) için;

void foo(X& &&param) {bar(static_cast<X& &&>(param));}

// referans daraltma işlemi sonucunda
void foo(X& param) {bar(static_cast<X&>(param));}

foo(X{}) için;

void foo(X&& &&param) {bar(static_cast<X&& &&>(param));}

// referans daraltma işlemi sonucunda
void foo(X&& param) {bar(static_cast<X&&>(param));}

Şablon içerisinde açık bir şekilde tür dönüşümü yapmak yerine bu işlem için standart kütüphane eklenmiş olan std::forward isimli bir fonksiyon şablonu kullanılmaktadır. Örneği std::forward kullanacak şekilde değiştirelim.

#include <iostream>
#include <utility>
using namespace std;class X {};void bar(X&) {cout << __PRETTY_FUNCTION__ << endl;}void bar(X&&) {cout << __PRETTY_FUNCTION__ << endl;}template <typename T>
void foo(T&& param) {bar(std::forward<T>(param));}
int main()
{
X x;
foo(x); foo(X{});
}

Kodu derleyip çalıştıralım.

void bar(X&)
void bar(X&&)

Uygun bar fonksiyonlarının çağrıldığını görüyoruz.

Artık daha önce 8 adet constructor fonksiyonu yazmak zorunda kaldığımız Employee sınıfının constructor fonksiyonunu bir fonksiyon şablonu olarak aşağıdaki gibi yazabiliriz.

class Employee
{
public:
template <typename T1, typename T2, typename T3>
Employee(T1&& name, T2&& address, T3&& position)
: _name{std::forward<T1>(name)}
, _address{std::forward<T2>(address)}
, _position{std::forward<T3>(position)} {}

private:
string _name;
string _address;
string _position;
};

Employee sınıfı için gerekli constructor fonksiyonları geçirilen argümana göre derleyici tarafından yazılacaktır. Employee sınıfını daha önce olduğu gibi yine sağ ve sol taraf std::string türünden argümanlar ile ilklendirebiliriz.

int main()
{
string name{"mülayim"};
string address{"istanbul"};
string position{"memur"};

Employee e1(name, address, position);
Employee e2(string{"seyit"}, string{"istanbul"}, string{"kapıcı"});
}

Employee sınıfı içerisinde _name, _address ve _position üye değişkenleri std::string sınıfının kopyalayan veya taşıyan constructor fonksiyonları tarafından ilklendirilecektir. Ek olarak artık Employee sınıfı içerisinde, std::string sınıfının, bir yazı değişmezi alan constructor fonksiyonunu da kullanabiliriz.

int main()
{
Employee e1("zühtü", "istanbul", "köfteci");
}

Bu örnekte argüman olarak geçirilen yazı değişmezleri için geçici nesneler oluşturulmayacak ve bu argümanlar direkt olarak std::string sınıfının const char* parametreli constructor fonksiyonuna geçirilecektir.

Buraya kadar anlattıklarımızı konuyla ilgili tipik kullanımları gösteren örneklerle özetleyebiliriz.

  1. Bir sınıf kopyalama işlemine ek olarak taşıma işlemleri için constructor ve atama operatör fonksiyonlarını ekleyebilir.
class String {
public:
String() = default;

/*diğer kaynağı kopyala*/
String(const String& other)
{
//...
}

/*diğer kaynağı taşı*/
String(String&& other)
{
//...
}

/*diğer kaynağı kopyala, mevcut kaynağı bırak*/
String& operator=(const String& other)
{
//...
}

/*diğer kaynağı taşı, mevcut kaynağı bırak*/
String& operator=(String&& other)
{
//...
}
};

2. Bir sınıf kendisine geçirilen sol taraf argümanını std::move ile tür dönüşümü yaptıktan sonra başka bir sınıfın constructor fonksiyonuna gönderebilir.

#include <iostream>

using namespace std;

class Employee
{
public:
Employee(const string& name) : _name{name} {}
Employee(string&& name) : _name{std::move(name)} {}

private:
string _name;
};

int main()
{
Employee e1(string{"mülayim"});

// geçici bir string nesnesi oluşturularak argüman olarak geçirilir
Employee e2("mülayim");
}

3. Bir sınıf kendisine geçirilen argümanın değer kategorisi koruyarak bu argümanı başka bir sınıfın constructor fonksiyonuna gönderebilir. Bu amaçla constructor fonksiyonu bir şablon şeklinde yazılmış ve argüman aktarımı sırasıda std::forward kullanılmış olmalıdır. Aşağıdaki örnekte perfect forwarding yapılmaktadır.

#include <iostream>

using namespace std;

class Employee
{
public:
template <typename T>
Employee(T&& t) : _name{std::forward<T>(t)} {}
private:
string _name;
};

int main()
{
string name("mülayim");
Employee e1(name);
Employee e2(string{"mülayim"});
// geçici herhangi bir nesne oluşturulmaz
Employee e3("mülayim");
}

Bir sınıfın constructor veya operatör atama fonksiyonlarına ek olarak başka üye fonksiyonları da benzer özelliklere sahip olabilir. Tipik bir örnek olarak std::vector sınıfının push_back ve emplace_back fonksiyonlarını verebiliriz. std::vector, kopyalama ve taşıma için, 2 adet push_back fonksiyonuna ek olarak mükemmel gönderim için emplace_back isimli bir fonksiyon şablonuna sahiptir.

Forwarding referanslarla ilgili dikkat edilmesi gereken bir nokta bulunmakta. Bir sağ taraf referansının daha önce belirttiğimiz kurallara göre forwarding referans olarak ele alınması için tür çıkarımı derleyici tarafından yapılmalı ayrıca cv nitelendiricileri (const and volatile qualifiers) ile beraber kullanılmamalıdır. Aşağıdaki örnekleri inceleyelim.

#include <iostream>using namespace std;class X {};void bar(X&) {cout << __PRETTY_FUNCTION__ << endl;}void bar(X&&) {cout << __PRETTY_FUNCTION__ << endl;}template <typename T>
void foo(const T&& param) {bar(std::forward<T>(param));}
int main()
{
X x;
foo(x);
}

Kodu derleyip çalıştırdığımızda aşağıdaki hatayı almaktayız.

error: cannot bind ‘X’ lvalue to ‘const X&&’

const T&& param, const belirleyicisinden dolayı forwarding referansı olarak değil bir sağ taraf referansı olarak ele alınmaktadır. Bu yüzden foo şablonuna argüman olarak bir sol tarafı değeri geçirildiğinde derleyici bu duruma ilişkin bir hata üretmektedir. Tür çıkarımı yapılmaksızın şablon tür parametresinin açık bir şekilde belirtilmesi durumun da yine aynı durum oluşacaktır.

#include <iostream>using namespace std;class X {};void bar(X&) {cout << __PRETTY_FUNCTION__ << endl;}void bar(X&&) {cout << __PRETTY_FUNCTION__ << endl;}template <typename T>
void foo(T&& param) {bar(std::forward<T>(param));}
int main()
{
X x;
foo<X>(x);
}

Kodu derleyip çalıştırdığımızda aşağıdaki hatayı almaktayız.

error: no matching function for call to ‘foo(X&)’

Derleyicinin X türü için foo şablonundan üreteceği fonksiyonun parametresi bir sağ taraf referansı olduğundan argüman olarak bir sol taraf değerinin geçirilmesi hataya sebep olmaktadır.

Perfect forwarding ile ilgili incelemelerimizi tamamladık. Bir sonraki yazımızda std::move ve std::forward dönüşüm şablonlarına daha yakından bakacağız.

--

--