C++ Dilinde Sağ Taraf Referansları-I

Serkan Eser
Nettsi Bilişim Teknoloji A.Ş.
18 min readJan 29, 2021

Bu blog yazımızda, C++11 ile dile eklenen, sağ taraf referanslarına ( Rvalue reference) bir giriş yapacağız. Sağ taraf referansları C++11 ile gelen en önemli özelliklerden birini oluşturmaktadır. Dil ve özellikle standart kütüphane içerisinde birçok yere dokunmaktadır. Sağ taraf referanları ve çevresindeki konular ilk bakışta kafa karıştırıcı olabilmektedir. Bu yazımızda temel olarak sağ taraf referanslarının ne olduğunu ve hangi problemleri çözdüğünü mümkün olduğunca örnekler vererek incelemeye calışacağız. Konuyu birkaç blog yazısı içerisinde bölerek anlatmayı planlıyorum.

Sağ taraf referansları C++11 ile beraber yeni bir referans türü olarak dile eklendi. Yalnız sağ taraf değerlerine (rvalue) bağlanabilen bu referanslar sağ taraf referansları olarak isimlendirilmiştir. C++11 öncesi var olan referanslar ise, C++11 ile beraber, sol taraf referansları (Lvalue reference) olarak yeniden isimlendirilmiştir.

Sağ taraf referansları && deklaratörü kullanılarak tanımlanırlar.

int&& r = 0;

r bir sağ taraf referansını göstermektedir. && karakterlerinin bir sağ taraf referansı gösterebilmesi için derleyici tarafından tek bir atom olarak yorumlanması gerekmektedir. Bu yüzden bu karakterler arasında boşluk bulunmamalıdır. Aralarında boşluk bulunması durumunda derleyici tarafından bir referansa tutulan başka bir referans olarak ele alınmaktadır. C++ dilinde referanslar, standartlara göre, gerçekte birer nesne olarak tarif edilmediklerinden dolayı referansları gösteren referanslara (references to references) izin verilmemektedir. Referans dizilerinin geçersiz olması da aynı gerekçeye dayanmaktadır. Yukarıdaki tanımlama işlemini aşağıdaki gibi yapmak isteseydik derleme zamanı hatası alırdık.

int & &r = 0;

Yukarıdaki ifade için clang++ aşağıdaki hata mesajını üretmektedir.

error: 'r' declared as a reference to a reference

C++11 öncesinde sağ taraf değerlerinin referans yoluyla değiştirilmesine izin verilmemekteydi. Bu sebeple sağ taraf değerlerine bağlanan referanslar const olmak zorundaydı. C++11 ile beraber sağ taraf referanslarının eklenmesiyle sağ taraf değerlerinin referans yoluyla değiştirilmesi mümkün hale gelmiştir.

int &&rr = 0;
++rr;
const int &r = 1;
++r; //error: increment of read-only reference ‘r’

Referanslar adres işlemlerinin gizlendiği, yüksek seviyeli, göstericiler (pointer) olarak görülebilir. Yukarıdaki örnekte her iki referans tanımı için de benzer makine kodu üretilmektedir. 0 ve 1 değerleri bellekte geçici alanlara yerleştirilmekte ve bu alanların başlangıç adresleri referanslara geçirilmektedir. Farklılık geçici alan üzerinde değişiklik yapılıp yapılamamasına ilişkindir. const sol taraf referansları ve sağ taraf referansları geçici nesnelere bağlanarak onların ömürlerini uzatmaktadır (life extention). Aşağıdaki örneği inceleyelim.

#include <iostream>class X 
{
public:
~X() {std::cout << __PRETTY_FUNCTION__ << std::endl;}
};
int main()
{
X{};
std::cout << "program sonlanıyor" << std::endl;
}

Örneği derleyip çalıştırdığımızda önce X türünün destructor fonksiyonunun çağrıldığını sonrasından ekrana “program sonlanıyor” yazısının basıldığını görüyoruz.

X::~X()
program sonlanıyor

Şimdi geçici nesneye bir sağ taraf referansı bağlayarak kodu yeniden derleyip çalıştıralım.

int main() 
{
X&& rr = X{};
std::cout << "program sonlanıyor" << std::endl;
}

Bu durumda X türünün destrutor fonksiyonu ekrana “program sonlanıyor” yazısı basıldıktan sonra çağrılmaktadır.

program sonlanıyor
X::~X()

X türden geçici bir nesnenin ömrü yerel rr referansına bağlanmış ve rr değişkeni sonlanana kadar korunmuştur. Bir referans geçici bir değişkene bağlandığında referans var olduğu sürece geçici değişken de varlığını korumaktadır. Bu kuralın istisnası olarak fonksiyonların geri dönüş değerlerini verebiliriz. Fonksiyonların dönüş değerlerinin ömrü uzaltılmaz. Bir fonksiyon sonlandığında fonksiyona ait yığın (stack) alanı geri verilmektedir. Örneğin aşağıdaki gibi bir kullanım yanlıştır.

#include <iostream>using namespace std;class X
{};
X&& foo()
{
return X{};
}
int main()
{
X x = foo();
}

Kodu derlediğimizde derleyici aşağıdaki uyarı mesajını üretmektedir. Burada yapılan hata yerel bir değişkenin adresini dönmeye eşdeğerdir.

warning: returning reference to temporary

Kopyalama optimizasyonuna ilişkin önceki blog yazılarımızda C++11 öncesinde, ekstra karmaşıklık barındıran kodlama teknikleriyle, geçici değişkenler üzerinde işlemlerin nasıl yapılabildiğini incelemiştik. C++11 ile beraber dilin içindeki araçlar kullanılarak bu işlemler çok daha anlaşılır ve kolay bir hale gelmiştir. Ayrıca daha önce mümkün olmayan bazı işlemler de yapılabilir olmuştur.

Sağ taraf referanslarının kullanımını anlayabilmek için belli bir düzeyde de olsa sol (lvalue) ve sağ (rvalue) taraf değerlerinin ne olduğunu bilmek faydalı olacaktır. Daha sonraki incelemelerimize temel teşkil edeceğinden dolayı şimdi sağ ve sol taraf değerlerinin ne olduğuna bakalım.

Değer Kategorileri (Value Categories)

C ve C++ dillerinde her bir ifade (expression), tür (type) ve değer kategorisi (value category) olmak üzere iki adet özelliğe sahiptir. İfadeler temel olarak operant, operatör, değişmez (literal) ve isimlerden oluşmaktadır. Her bir ifade türünden bağımsız olarak bir değer kategorisine aittir. Bir örnek üzerinden bu duruma bakalım.

int i;
i = 0;

i, int türden bir sol taraf değeridir. 0 ise yine aynı türden bir sağ taraf değeridir.

C dilinde başlangıçta ifadeler sol taraf (lvalue) ve sağ taraf (rvalue) değeri olmak üzere ikiye ayrılmaktaydı. Bir ifadenin değer sınıflandırması yapılırken eşitlik operatörü referans alınmaktaydı. Yani bir ifade eşitliğin solunda kullanılabiliyor ise sol taraf değeri olarak diğer ifadeler ise sağ taraf değeri olarak tanımlanmaktaydı. C diline daha sonraları const belirleyicisinin eklenmesiyle beraber bu tanım geçerliliğini yitirmiştir.

const int i = 0;
i = 1; //error: assignment of read-only variable

i değişkeni bir sol taraf değeri olmasına karşın eşitlik operatörünün solunda kullanılması geçerli değildir. const belirleyicisiyle beraber bir ifadenin eşitliğin solunda kullanımı geçersiz olmasına karşın ifade yine de bir sol taraf değeri olabilmektedir. Bu sebeple C dilinde sol ve sağ taraf değerlerinin tanımlarının gözden geçirilmesi gerekti. Sol taraf değerleri eşitliğin solunda bulunabilen ifadeler için değil bellekte bir konum belirten (locator value) ifadeler için kullanılmaya başlandı.

C++ dilindeki değer kategorileri C dilinden miras alınmıştır. C++ dilinde kullanıcı tanımlı türlerin de (user type) bulunmasında dolayı, C dilinin aksine, sağ taraf değerleri eşitliğin solunda da bulunabilmektedir. Aşağıdaki örneği inceleyelim.

class X
{
int a;
};
X foo()
{
return X{};
}
int main()
{
X{} = X{};
foo() = X{};
}

X{} ve foo() ifadeleri sonucunda birer geçici değişken üretilmektedir. Bu ifadeler sağ taraf değeri göstermelerine karşın eşitliğin solunda kullanılabilmektedir.

Not: Böyle bir kullanımın ne işe yaradığını merak edebilirsiniz, Temporary Proxy olarak bilinen yöntemi bu tür bir kullanıma örnek olarak verebiliriz.

const değişkenler gibi fonksiyon ve dizi isimleri de bellekte erişilebilir, yani adresi alınabilir, bir alanı gösterdiklerinden sol taraf değeri olmalarına karşın eşitliğin sol tarafında kullanımları geçerli değildir.

C++11 ile beraber yeni değer kategorilerinin eklenmesiyle beraber konu daha da karışık bir hale gelmiştir. C++11 standartlarında değer kategori sınıflandırılması aşağıdaki gibi gösterilmiştir.

lvalue, xvalue ve prvalue olmak üzere 3 adet temel değer kategorisine ek olarak glvalue ve rvalue olmak üzere iki adet üst kategori daha tanımlanmıştır. C++11 sonrası bir ifade lvalue, xvalue veya prvalue kategorilerinden birine ait olmak zorundadır.

Yukarıda verdiğimiz örneklerden bir ifadenin değer kategorisini belirlemek için eşitlik operatörüne göre bir tanımlama yapılamayacağını görmüştük. Özetleyecek olursak sağ taraf değeri gösteren sınıf türünden geçici bir nesnenin eşitliğin solunda yer alması geçerliyken const değişkenler, diziler ve fonksiyonlar birer sol taraf değeri olmalarına karşın eşitliğin solunda yer almaları geçerli değildir.

Maalesef değer kategorilerine ilişkin, istisna durumlarından ötürü, kısa ve özlü tanımlar yapmak oldukça zordur. Bu yüzden standartlarda bu kategorilere ait örneklerden teker teker bahsedilmektedir. Bir geliştirici olarak konuyu belli düzeyde anlamak yeterli olduğundan genelde konuyu anlatan dokümanlarda belli bir dereceye kadar doğru yaklaşık tanımlar (rule of thumb) yapılmakta ve sağ taraf referanslarının kullanımına odaklanılmaktadır.

Örneğin bu tanımlardan bir tanesi bir ifadenin değerlendirilmesi (expression evaluation) sonucunda bir isim (name) elde ediliyorsa bu ifadenin sol taraf olduğu şeklindedir.

int a;
a = 1;
std::string s;
s = "test";

Bir isme sahip a ve s ifadeleri birer sol taraf değeridir fakat isim üzerinden yapılan bu tanım tüm sol taraf değerlerini kapsamamaktadır. Örneğin aşağıdaki ifadeler bir isme karşılık gelmemelerine karşın bir sol tarafı değeri göstermektedir.

#include <iostream>int& getint()
{
static int i;
return i;
}
int main()
{
*(int*)0xFFFFFFFF = 0; // (1)
int *p;
*p = 0; // (2)
*new std::string() = "test"; // (3) getint() = 0; // (4) "text"; // (5)
}

Örnek kod başarılı şekilde derlenecektir. Çalışma zamanında ise 1. ve 2. kullanımlardaki tanımsız davranışlardan (undefined behavior) dolayı muhtemelen seg. fault hatası alacağız fakat bu aşamada yalnız kodun sözdizimsel olarak geçerli olup olmadığı ile ilgileniyoruz. Örneğimizdeki kullanımlara kısaca bakalım.

  1. 0xFFFFFFFF adresli bellek bölgesine direkt olarak erişiliyor, ortada bir isim olmamasına karşın ifade bir sol taraf değeri göstermektedir
  2. *p ifadesi, p göstericisinde tutulan adresin gösterdiği bellek bölgesine karşılık gelmektedir, erişilen bellek bölgesine ait bir isim olmamasına karşın ifade bir sol taraf değeri göstermektedir
  3. std::string türünden bir nesne dinamik olarak oluşturuluyor, ifadeye ilişkin bir isim bulunmamasına karşın ifade bir sol taraf değeri göstermektedir
  4. getint fonksiyonu ile isimsiz bir sol taraf referansı üzerinden bir atama işlemi yapılıyor, getint() ifadesi bir sol taraf değeri göstermektedir
  5. “text” değişmezi, sonlandırıcı NULL karakter dahil 5 karakterlik isimsiz bir diziye karşılık gelmektedir, ifade bir isme sahip olmamasına karşın yine bir sol taraf değeri göstermektedir

2. ve 4. kullanımlarda ilk bakışta bir isim var gibi görünmesine karşın aslında p ve getint isimleri üzerinden indirection ve referans yoluyla isimsiz alanlara erişilmektedir.

Bir diğer popüler tanım ise bir ifadenin adresinin alınıp alınamamasına göredir. Burada kast edilen built-in adres operatörüdür. Yoksa bir sınıfın adres operatörü yüklenerek (address-of operator overloading) sınıf türlerine ait sağ taraf değerinin de adresleri alınabilmektedir. Bu tanım, isim üzerinden yapılan, bir öncekine tanıma göre daha kapsayıcı olmasına karşın bir istisnası bulunmaktadır. Bit alanları (Bit field) sol taraf değeri göstermesine karşın, adreslenebilir alanlara hizalanmaları garanti olmadığından, adresleri alınanamamaktadır. Bir ifadenin adresinin alınabilmesi o ifadenin sağ veya sol taraf değeri gösterdiği konusunda bir kriter olmasına karşın bir ifadenin
tam olarak hangi değer kategorisine ait olduğunu belirlemekte yeterli değildir. Şimdi C++11 ile gelen temel değer kategorilerine sırayla bakalım.

lvalue

Bellekte bir yer belirten (locator value) ifadeler lvalue olarak sınıflandırılmaktadır. Değişken, fonksiyon, dizi isimleri, sol taraf referansı
dönen fonksiyonlar ve yazı değişmezleri (string literal) örnek olarak verilebilir. Aşağıdaki ifadeler birer sol taraf değeri göstermektedir.

int a;a = 0;++a = 0; // pre-increment and pre-decrement ifadeleri
--a; // sol taraf değeri göstermektedir
*(int*)0xFFFFFFFF = 0;int *p;*p = 0;
p[0] = 0;

prvalue (pure rvalue)

Yazı değişmezleri hariç diğer tüm değişmezler (literal) birer prvalue göstermektedir.

nullptr, this, true, sayılar, enum sembolik sabitleri gibi değişmezler, değer dönen fonksiyon ve operatör ifadeleri, lambda ifadeleri, aritmetik işlem sonuçları ve geçici değişkenler (temporary object) tipik örnekler olarak verilebilir.

int a;
int b;
std::string foo();
int bar();
(a + b)foo();
bar();
std::string{"test"};a++;
a--;
&a;
666;

xvalue (eXpring value)

Bazı durumlarda sol taraf değerlerinin de sağ taraf değeri gibi ele alınması istenebilir. Örneğin bir değişkenin değeri bir daha kullanılmayacaksa, yani ömrünün sonuna geldiyse, değişken bir sağ taraf değeri gibi ele alınabilir. Bu durumda değişken bir sol taraf değeri olmasına karşın geçici bir değişken gibi ele alınabilir. Bu kullanıma takas (swap) fonkiyonunu ve standart vektör sınıfının içsel olarak tuttuğu alanın genişlemesini örnek verebiliriz. Bu kullanımları daha sonraki blog yazılarımızda inceleyeceğiz.

Sağ taraf referansı dönen fonksiyonlar ve sağ taraf referansına ilişkin tür dönüşüm işlemleri xvalue değeri üretmektedir. Bu dönüşüm sayesinde sağ taraf referansları sol taraf değerlerine bağlanabilmektedir. Bu dönüşüm işlemi için std::move şablonu kullanılmaktadır. Bu şablonu daha sonra inceleyeceğiz.

xvalue ifadeler sol taraf değerlerinden, tür dönüştürme işlemi ile, elde edilmelerine karşın eşitliğin sol tarafında bulunamazlar.

#include <iostream>int&& foo(int& i)
{
return static_cast<int&&>(i);
}
int main()
{
int i;
foo(i); // xvalue static_cast<int&&>(i); // xvalue std::move(i); // xvalue
}

glvalue (generalized lvalue)

lvalue ve xvalue kategorilerinin toplamı glvalue (genelleştirilmiş sol taraf değeri) olarak isimlendirilmiştir.

rvalue

xvalue ve prvalue kategorilerinin toplamı rvalue (sağ taraf değeri) olarak isimlendirilmiştir. Sağ taraf değeri denildiğinde prvalue ile beraber xvalue değerleri de anlaşılmalıdır.

Değer kategorisi sınıflandırmasında xvalue kagetorisinin her iki üst gruba da ait olduğuna dikkat ediniz. Bir lvalue ifadesi üzerinde prvalue gibi işlem yapılmak istendiğinde tür dönüşümü yapılabildiğini ve yeni ifadenin xvalue olarak isimlendirildiğini söylemiştik. Aşağıdaki örneği inceleyelim.

std::string {"test"}; // (1) prvaluestd::string s{"test"};
std::move(s); // (2) xvalue
// s değişkenin faaliyet alanı devam ediyor

1.ifadedeki geçici değişken bir prvalue değeri göstermekte ve ifade sonunda sınıfın destructor fonksiyonu çağrılmaktadır. 2.ifade ise bir xvalue değeri göstermektedir. xvalue ifadeleri, aslında bir sol tarafını gösterebildiği fakat buna karşın bir prvalue gibi işleme girdikleri için, her iki üst gruba da dahil edilmiştir.

Sağ taraf ve const sol taraf referansları ancak rvalue yani prvalue ve xvalue ifadelerine bağlanabilmektedir. Ayrıca glvalue ifadeleri rvalue gereken bir yerde kullanıldığında lvalue-to-rvalue, array-to-pointer ve function-to-pointer dönüşümleri yapılarak prvalue olarak ele alınmaktadır. Aşağıdaki örneğe bakalım.

int a;
int b;
a = b; // b değer olarak işleme giriyor
char c[4];
char *p = c; // c, dizinin ilk elemanının adresi olarak
// işleme giriyor
void foo();
void (*pf)() = foo; // foo, fonksiyon başlangıç adresi olarak
// işleme giriyor

Bir ifadenin hangi değer kategorisini ait olduğunu C++11 ile eklenenen decltype belirleyicisi ve STL içindeki type traits araçlarını kullanarak belirleyebiliriz. Standart kütüphane içinde type traits olarak isimlendirilen ve type_traits başlık dosyasında, derleme zamanında tür bilgisi elde etmek için kullanılan, şablonlar bulunmaktadır.

Aşağıdaki örnekteki print_value_category isimli macro’yu bu amaçla kullanabiliriz.

#include <iostream>#define print_value_category(expr) ({\
if (std::is_lvalue_reference<decltype((expr))>::value) {\
std::cout << #expr ": lvalue" << std::endl;\
}\
else if (std::is_rvalue_reference<decltype((expr))>::value) {\
std::cout << #expr ": xvalue" << std::endl;\
}\
else {\
std::cout << #expr ": prvalue" << std::endl;\
}\
})
int main()
{
int i;
char c[4];
print_value_category(0);
print_value_category(i);
print_value_category(std::move(i));
print_value_category(std::string{});
print_value_category(&c);
}

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

0: prvalue
i: lvalue
std::move(i): xvalue
std::string{}: prvalue
&c: prvalue

Buradaki is_lvalue_reference ve is_rvalue_reference şablonları derleme zamanında tür belirlemeye yarayan şablonlardır. Bu şablonlar genel olarak birden çok yüklenmiş (overloaded) şablon olarak yazılmakta ve uygun şablon açılarak türe ilişkin bilgi elde edilmektedir. Bu yöntem SFINAE (Substitution Failure Is Not An Error) olarak isimlendirilmektedir.

C++11 ile beraber, sağ taraf referansları kullanılarak, argümanlarının değer kategorilerine göre yüklenmiş (overload) fonksiyonlar yazılabilmektedir. Sağ taraf referanslarının temel kullanımlarını aşağıdaki gibi özetleyebiliriz.

  • Sadece geçici veya sonlanmak üzere olan değişkenleri argüman olarak alan fonksiyonlar yazılabilir. Bu sayede argümanların gereksiz yere kopyası çıkarılmaksızın direk üzerlerinde işlem yapılabilir veya sahip oldukları kaynaklar taşınabilir (move semantics).
  • Fonksiyon şablonları kullanılarak başka fonksiyonları sarmalayan fonksiyonlar etkin bir biçimde yazılabilmektedir. Bu yöntem
    perfect forwarding olarak isimlendirilmektedir.
  • Kopyalanamayan bazı türlerin STL kapları (container) ile kullanımı mümkün hale gelmiştir.

Şimdi bu kullanımlara bakalım.

Sol ve Sağ Taraf Değerlerine Göre Yüklenmiş Fonksiyonlar

Aşağıdaki örneği inceleyelim.

#include <iostream>class X
{
public:
X() {}
X(int) {}
};
void foo(const X&)
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
void foo(X&&)
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
int main()
{
X x;
foo(x); // (1)
foo(X{}); // (2)
foo(0); // (3)
foo(std::move(x)); // (4)
}

Kodu derleyip çalıştırdığımızda 1. ifade için const X& parametreli foo fonksiyonunun diğer ifadeler için ise X&& parametreli foo fonksiyonunun çağrıldığını görüyoruz.

void foo(const X&)
void foo(X&&)
void foo(X&&)
void foo(X&&)

2. ifadede foo fonksiyonuna geçici bir değişken argüman olarak geçirilmiştir. 3. ifade için derleyici sınıfın int parametreli constructor fonksiyonunu kullarak gizli bir geçici değişken oluşturmuş ve foo fonksiyonuna argüman olarak geçirmiştir. 4. ifadede ise std::move fonksiyonu ile tür dönüşümü yapılarak sol taraf değeri bir sağ taraf değeri (xvalue) olarak ele alınmıştır.

foo(X&&) fonksiyonu olmasaydı tüm ifadeler için foo(const X&) fonksiyonu çağrılacaktı. const sol taraf referansları hem sol hem de sağ taraf değerlerine bağlanabilmektedir. Çağrılacak fonksiyonun belirlenmesi sürecinde (ovearload resolution) sağ taraf değerleri için foo(const X&) aday olmasına karşın foo(X&&) tam uyum (exact match) sağlamaktadır. Bu yüzden sol taraf değerleri için foo(const X&) sağ taraf değerleri için ise foo(X&&) çağrılmaktadır.

Sağ taraf referansları çoğunlukla sınıfların copy constructor fonksiyonlarında ve assignment operator fonksiyonlarında kullanılmaktadır. Tipik bir kullanım aşağıdaki gibidir.

class data
{
public:
data() {}
data(const data& other)
{
/*diğer kaynağı kopyala*/
}
data(data&& other)
{
/*diğer kaynağı taşı*/
}
data& operator=(const data& other)
{
/*diğer kaynağı kopyala, mevcut kaynağı bırak*/
}
data& operator=(data&& other)
{
/*diğer kaynağı taşı, mevcut kaynağı bırak*/
}
};

Kopyalama işleminin aksine taşıma işlemi sonucunda taşıma yapılan nesne değişmektedir. Sahip olduğu kaynağı taşınan nesne geçerli fakat belirli olmayan (valid but unspecified state) bir durumda bırakılmalıdır. Yani taşıma yapılan nesne için sınıfın destructor fonksiyonu bir hata üretmemeli ayrıca bu nesneye yeni bir değer atanabilmelidir. Örnek olması açısından taşıma işlemini destekleyen basit bir yazı sınıfını aşağıdaki gibi yazabiliriz.

#include <iostream>
#include <memory>
#include <cstring>
using namespace std;class String
{
public:
String();
String(const char* str);
String(const String& other);
String(String&& other);
String& operator=(const String& other);
String& operator=(String&& other);
~String();
void print() const;
private:
char* _p;
size_t _len;
};
String::String() : _len{0}, _p{nullptr} {}String::String(const char *str)
{
_len = strlen(str) + 1;
_p = new char[_len];
std::copy(str, str + _len, _p);
}
String::String(const String& other)
: _len{other._len}
, _p{new char[other._len]}
{
cout << __PRETTY_FUNCTION__ << endl;
std::copy(other._p, other._p + _len, _p);
}
String::String(String&& other)
: _len{other._len}
, _p{other._p}
{
cout << __PRETTY_FUNCTION__ << endl;
other._p = nullptr;
}
String& String::operator=(const String& other)
{
cout << __PRETTY_FUNCTION__ << endl;
if (this != &other) {
delete[] _p;
_p = nullptr;
_len = other._len;
_p = new char[_len];
std::copy(other._p, other._p + _len, _p);
}
return *this;
}
String& String::operator=(String&& other)
{
cout << __PRETTY_FUNCTION__ << endl;
if (this != &other) {
delete[] _p;
_len = other._len;
_p = other._p;
other._p = nullptr;
}
return *this;
}
String::~String()
{
delete[] _p;
}
void String::print() const
{
cout << _p << endl;
}

const String& parametreli constructor ve assignment operator fonksiyonları kopyalama, String&& parametreli olanlar ise taşıma fonksiyonlarıdır. Bu fonksiyonlar İngilizce copy/move constructor ve copy/move assignment operator olarak isimlendirilmektedir. Özellikle atama operatörlerini birden çok yöntemle yazmak mümkündür. Bu konuya daha sonra değineceğiz. Fakat tüm yöntemlerde nihayetinde kopyalama işlemlerinde derin kopyalama (deep copy) taşıma işlemlerinde ise yüzeysel kopyalama (shallow copy) yapılmakta ve taşıma yapılan nesnenin daha önce tuttuğu kaynak ile bağı kesilmektedir. Kopyalama işlemlerini aşağıdaki gibi test edebiliriz.

int main()
{
String s1("Mary had a little lamb");
String s2 = s1;
s2.print();
cout << "******************" << endl; String s3;
s3 = s1;
s3.print();
}

Kodu derleyip çalıştırdığımızda kopyalama yapan fonksiyonların çağrıldığını görüyoruz.

String::String(const String&)
Mary had a little lamb
******************
String& String::operator=(const String&)
Mary had a little lamb

Taşıma işlemlerini ise aşağıdaki gibi test edebiliriz.

int main()
{
String s1("Mary had a little lamb");
String s2 = std::move(s1);
s2.print();
cout << "******************" << endl; String s3;
s3 = String{"Mary had a little lamb"};
s3.print();
}

Kodu derleyip çalıştırdığımızda taşıma yapan fonksiyonların çağrıldığını görüyoruz.

String::String(String&&)
Mary had a little lamb
******************
String& String::operator=(String&&)
Mary had a little lamb

Örneğimizde s2 nesnesini oluştururken geçici bir değişken üzerinden kopyalamak yerine std::move ile tür dönüşümü yaptık. Konuyla ilgili önceki blog yazımızda kopyalamaya ilişkin derleyici optimizasyonundan bahsettiğimiz bölümde derleyicinin aynı türden geçici bir nesneyi kopyalamak yerine direkt olarak yeni nesneyi oluşturabildiğinden (copy elision) bahsetmiştik. Bu durumda aşağıdaki örnek için taşıma yapan constructor fonksiyonu çağrılmayacaktır.

int main()
{
String s = String{"test"};
}

Ancak derleyicinin yaptığı bu optimizasyonu kapattığımızda taşıma işlemi yapılacaktır. gcc ve clang derleyicilerine bu amaçla -fno-elide-constructors anahtarı geçirilebilir. Derleme optimizasyonuyla taşıma işlemi arasındaki ilişkiye daha sonraki yazılarımızda değineceğiz.

Sağ taraf referansları ilk bakışta yalnız taşıma işlemi için kullanılıyor gibi görünmesine karşın aynı zamanda, herhangi bir kopya çıkarmaksızın, sağ taraf değerleri üzerinde değişiklik yapmak için de kullanılabilir. Bir örnek üzerinden bu durumu inceleyelim.

Yazı sınıfımıza toUpper isimli bir üye fonksiyon eklemek istediğimizi düşünelim. Bu fonksiyonu aşağıdaki gibi yazabiliriz.

String String::toUpper()
{
String tmp(*this);
std::transform(_p, _p + _len
, tmp._p
, [](unsigned char c)
{ return std::toupper(c); });
return tmp;
}

toUpper fonksiyonu yazının orjinal halini bozmaksızın büyük harflerden oluşan bir kopyasını dönmektedir. Fakat eğer yazı sınıfına ait nesne bir sağ taraf değeri ise, örneğin geçici bir değişken, bu durumda sonlanmak üzere olan bir nesnenin gereksiz yere bir kopyası çıkarılacaktır. Burada dikkat edilmesi gereken bir diğer konu ise toUpper fonksiyonunun parametre almamasıdır. toUpper üye fonksiyonuna üzerinde çalışacağı nesnesin adresi gizli olarak geçirilmektedir. this anahtar sözcüğü ile bu adrese erişebilmekteyiz. Bu noktada const fonksiyonları hatırlamak faydalı olacaktır. Bir fonksiyon çağrıldı nesnenin const olup olmamasına göre yüklenebilmektedir (const overloading). Aşağıdaki örneği inceleyelim.

#include <iostream>using namespace std;class X
{
public:
X() = default;
void foo() {cout << __PRETTY_FUNCTION__ << endl;}
void foo() const {cout << __PRETTY_FUNCTION__ << endl;}
};
int main()
{
X x;
x.foo();
const X cx;
cx.foo();
}

Kodu derleyip çalıştırdığımızda const ve const olmayan nesneler için farklı fonksiyonların çağrıldığını görmekteyiz.

void X::foo()
void X::foo() const

Fonksiyonun bildirimindeki const nitelendiricisi (qualifier) this
ile adresine eriştiğimiz nesnenin const olması gerektiğini göstermektedir. Benzer şekilde bir nesnenin sol veya sağ taraf değeri oluşuna göre de yüklenmiş fonksiyonlar yazılabilir. Bu amaçla referans niteleyicileri (ref-qualifier) kullanılmaktadır. Aşağıdaki örneği inceleyelim.

#include <iostream>using namespace std;class X
{
public:
X() = default;
void foo() & {cout << __PRETTY_FUNCTION__ << endl;}
void foo() && {cout << __PRETTY_FUNCTION__ << endl;}
};
int main()
{
X x;
x.foo(); X{}.foo();
}

X türünden sol ve sağ taraf değerleri için farklı fonksiyonlar çağrılmaktadır.

void X::foo() &
void X::foo() &&

Artık toUpper fonksiyonumuza geri dönebiliriz. toUpper fonksiyon çiftini aşağıdaki gibi yazabiliriz.

String String::toUpper() &
{
String tmp(*this);
std::transform(_p, _p + _len
, tmp._p
, [](unsigned char c)
{ return std::toupper(c); });
return tmp;
}
String String::toUpper() &&
{
std::transform(_p, _p + _len
, _p
, [](unsigned char c)
{ return std::toupper(c); });
return tmp;
}

Bu durumda aşağıdaki çağrılarda nesnenin kopyası oluşturulmaksızın kendisi üzerinde işlem yapılmaktadır.

String foo()
{
return String{"test"};
}
int main()
{
String{"test"}.toUpper();
foo().toUpper();
}

Bir diğer örnek olarak sıralama (sort) işlemi verilebilir. Bir kap (container) sağ taraf değeri gösteriyorsa içeriği kopyalanmaksızın yerinde sıralama
(in place sorting) yapılabilir. Son bir örnek olarak ise iki yazının eklenmesini verebiliriz. Standart yazı sınıfı std::string, iki yazının eklenmesi için sınıfın üye olmayan, farklı parametrik yapıya sahip, operator+ fonksiyonları barındırmaktadır. operator+ fonksiyonu iki yazıyı ekleyip sonucu değer olarak dönmektedir. Aşağıdaki örnekler üzerinden bu durumu inceleyelim.

string s1{"Mary had a "};
string s2{"little lamb"};
string s3 = s1 + s2;

GNU Standard C++ kütüphanesinde bu işlem aşağıdaki gibi tanımlanmıştır.

template<typename _CharT, typename _Traits, typename _Alloc>
basic_string<_CharT, _Traits, _Alloc>
operator+(const basic_string<_CharT, _Traits, _Alloc>& __lhs,
const basic_string<_CharT, _Traits, _Alloc>& __rhs)
{
basic_string<_CharT, _Traits, _Alloc> __str(__lhs);
__str.append(__rhs);
return __str;
}

Standart string sınıfı basic_string şablonunun ilk tür parametresi char olacak şekilde typedef edilmiş halidir.

typedef basic_string<char> string;

operator+ fonksiyonunun her iki parametresinin de sol taraf referansı olduğuna dikkat ediniz. Fonksiyonun başlangıcında __str(__lhs) ile +
operatorünün solundaki nesnenin bir kopyası çıkarılmış ve diğer nesne bu kopyanın sonuna eklenmiştir. Şimdi toplama işlemine bir sağ taraf değerinin girdiği aşağıdaki örneği inceleyelim.

string s1{"little lamb"};
string s2 = string{"Mary had a "} + s1;

Bu durumda aşağıdaki parametrik yapıya sahip operator+ fonksiyonu çağrılacaktır.

template<typename _CharT, typename _Traits, typename _Alloc>
inline basic_string<_CharT, _Traits, _Alloc>
operator+(basic_string<_CharT, _Traits, _Alloc>&& __lhs,
const basic_string<_CharT, _Traits, _Alloc>& __rhs)
{ return std::move(__lhs.append(__rhs)); }

İlk parametrenin sağ taraf referansı olduğuna dikkat ediniz. Herhangi bir kopyalama işlemi yapılmaksızın direkt olarak geçici nesnenin sonuna ekleme işlemi yapılmış ve std::move ile tür dönüşümü yapılarak fonksiyonun geri dönüş değerinin taşınabilmesi sağlanmıştır.

Parametresi sağ taraf referans türünden olan fonksiyonlar kendilerine geçirilen argümanlara ait kaynakları transfer edebildikleri veya bir kopya çıkarmaksızın üzerlerinde işlem yapabildikleri gibi bu argümanları başka fonksiyonlara da geçirebilirler. İlk olarak genel bir örnek üzerinden bu durumu inceleyelim.

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

Kodu derleyip çalıştırdığımızda foo fonksiyonuna bir sağ taraf değeri geçirilmesine karşın bar(X&) fonksiyonunun çağrıldığını görüyoruz.

void bar(X&)

foo fonksiyonu kendisine geçirilen argümanı üzerinde herhangi bir işlem yapmaksızın direkt bar fonksiyonuna geçirmiştir. Burada aslında foo
fonksiyonuna geçirilen geçici X{} değişkenin bar(X&&) fonksiyonuna geçirilmesi doğru olacaktır. Bu sayede main fonksiyonu içinde tanımlanan geçici X değişkenin kaynakları bar fonksiyonu içinde transfer edilebilir.

Peki derleyici foo içinde bir sağ taraf referansı ile yapılan çağrıya karşılık neden bar(X&) fonksiyonunu seçti? İlk bakışta bir sağ taraf değeri için çağrılan foo fonksiyonu içindeki bar fonksiyonu çağrısında da sağ taraf değeri alan fonksiyonun çağrılacağı düşünülebilir. Fakat sağ taraf referansları, sağ taraf değerlerine bağlanmalarına karşın kendileri birer sol taraf değeridir. Bu yüzden direkt sağ taraf referansı türünden nesneler ile yapılan çağrılarda sol taraf değeri kabul fonksiyonlar çağrılmaktadır. Daha önce bir ifadenin hangi değer kategorisini ait olduğunu decltype belirleyicisi ve type traits araçları ile belirleyebileceğimizi görmüştük. Aşağıdaki örneği inceleyelim.

#include <iostream>#define print_value_category(expr) ({\
if (std::is_lvalue_reference<decltype((expr))>::value) {\
std::cout << #expr ": lvalue" << std::endl;\
}\
else if (std::is_rvalue_reference<decltype((expr))>::value) {\
std::cout << #expr ": xvalue" << std::endl;\
}\
else {\
std::cout << #expr ": prvalue" << std::endl;\
}\
})
class X {};int main()
{
X&& rr = X{};
print_value_category(X{});
print_value_category(rr);
}

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

X{}: prvalue
rr: lvalue

rr bir sağ taraf referansına bağlanmasına karşın kendisi bir sol taraf değeridir. Bir sağ taraf referansının bir isme sahip olup olmaması ait olduğu değer sınıfını belirlemektedir. İsimli sağ taraf referansları bir sol taraf değeri (lvalue) belirtmesine karşılık isimsiz sağ taraf referansları xvalue belirtmektedir. Bu farklılığı anlamak önemlidir. Sol taraf referanslarında ise referans türünden bir ifadenin bir isme sahip olmaması bir farklılık oluşturmamaktadır. Aşağıdaki örneği inceleyelim.

#include <iostream>#define print_value_category(expr) ({\
if (std::is_lvalue_reference<decltype((expr))>::value) {\
std::cout << #expr ": lvalue" << std::endl;\
}\
else if (std::is_rvalue_reference<decltype((expr))>::value) {\
std::cout << #expr ": xvalue" << std::endl;\
}\
else {\
std::cout << #expr ": prvalue" << std::endl;\
}\
})
class X {};X&& foo(X& param) { return static_cast<X&&>(param); }int main()
{
X&& rr = X{};
print_value_category(rr);
X x;
print_value_category(foo(x));
}

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

rr: lvalue
foo(x): xvalue

foo fonksiyonu çağrı ifadesi sonucunda isimsiz X&& türünden bir değer elde edilmektedir. foo fonksiyonunun yaptığı iş sadece kendisine argüman olarak geçirilen ifadenin xvalue yani bir sağ taraf değeri olarak ele alınamasını sağlamaktır. Daha önce de belirttiğimiz gibi standart kütüphaneye bu amaçla std::move isimli bir dönüşüm fonksiyonu eklenmiştir. Bu fonksiyonunun nasıl yazıldığını daha sonra inceleyeceğiz. Şimdi daha önce incelediğimiz örnek üzerinde değişiklik yaparak tekrar derleyelim.

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

bar fonksiyonuna geçirilen argümanı direkt geçirmek yerine std::move ile tür dönüşümü yaptığımızda bar(X&&) fonksiyonunun çağrıldığını görüyoruz.

void bar(X&&)

Şimdi daha gerçekçi bir örnek üzerinden benzer duruma bakalım.

#include <iostream>using namespace std;class Employee
{
public:
Employee(const string& name) : _name{name} {}
Employee(string&& name) : _name{std::move(name)} {}
string getName() {return _name;}
private:
string _name;
};
int main()
{
string name("mülayim");
Employee employee1(name);
Employee employee2(string{"seyit"});
}

Employee sınıfı constructor yoluyla kendisine geçirilen bir ismi saklamaktadır. Employee sınıfının constructor fonksiyonları kendilerine geçirilen isim üzerinde herhangi bir işlem yapmaksızın direkt olarak string sınıfının uygun constructor fonksiyonuna geçirmektedir. Bu kullanım oldukça yaygındır. Tipik bir örnek olarak standart vektör sınıfının push_back fonksiyonlarını verebiliriz. std::vector sınıfı sol ve sağ taraf değerlerinin geçirildiği 2 adet push_back fonksiyonuna sahiptir.

void push_back(const T& value);
void push_back(T&& value);

Bu yöntem bu haliyle kullanışlı olmasına karşın parametre sayısı arttıkça yazılacak fonksiyon sayısı da artmaktadır. Employee sınıfımızın çalışan ismiyle beraber adres ve pozisyonu da aldığını düşünelim. Bu durumda Employee sınıfı aşağıdaki gibi yazılabilir.

#include <iostream>using namespace std;class Employee
{
public:
Employee(const string& name, const string& address
, const string& position) : _name{name}
, _address{address}
, _position{position} {}
Employee(const string& name, const string& address
, string&& position) : _name{name}
, _address{address}
, _position{std::move(address)} {}
Employee(const string& name, string&& address
, const string& position) : _name{name}
, _address{std::move(address)}
, _position{position} {}
Employee(const string& name, string&& address
, string&& position) : _name{name}
, _address{std::move(address)}
, _position{std::move(position)} {}
Employee(string&& name, const string& address
, const string& position) : _name{std::move(name)}
, _address{address}
, _position{position} {}
Employee(string&& name, const string& address
, string&& position) : _name{std::move(name)}
, _address{address}
, _position{} {}
Employee(string&& name, string&& address
, const string& position) : _name{std::move(name)}
, _address{std::move(address)}
, _position{position} {}
Employee(string&& name, string&& address
, string&& position) : _name{std::move(name)}
, _address{std::move(address)}
, _position{std::move(position)} {}
private:
string _name;
string _address;
string _position;
};

Her bir parametreye sol veya sağ taraf değerleri geçirilebilmeli bu yüzden parametre sayısı N olmak üzere 2 üzeri N adet yüklenmiş (overload) fonksiyon yazılmalıdır. Örneğimiz için 8 adet constructor yazmak zorunda kaldık. Ancak bu sayede aşağıdaki örnek çağrılar mümkün olabilmektedir.

int main()
{
string name{"mülayim"};
string address{"istanbul"};
string position{"memur"};
Employee e1(name, address, position);
Employee e2(name, string{"ankara"}, position);
Employee e3(string{"rıfkı"}, address, string{"müdür"});
Employee e4("mülayim", "istanbul", "memur");
}

Bu yöntemin bazı durumlarda kullanışsız olmasından dolayı C++11 ile birlikte ayrıca, perfect forwarding olarak isimlendirilen, alternatif bir yöntem daha getirilmiştir. Bu yöntemi bir sonraki blog yazımızda inceleyeceğiz.

--

--