C++ lvalue & rvalue referansları ve Move Semantics

Gorkem Demirtas
5 min readJun 1, 2023

--

Merhaba, bu yazımın konusu her ne kadar C++ 11 ile gelen rvalue referansı ve move semantics olsada derinlere inmeden öncelikle lvalue ve rvalue nedir bundan bahsetmek istiyorum.

Peki nedir bu lvalue ve rvalue ? Aslında en basit ve en kolay tabirle
lvalue: Bellekte bir alana sahip olan nesneler olurken
rvalue: lvalue olmayan herşeydir. Örneğin geçici nesneler.

Şimdi somut örnekler ile devam edecek olursak;

Bu örnekte (7.satır) x bellekte bir alana sahip olduğundan bir lvalue olurken eşitliğin sağındaki 1 geçici bir değer olduğu için rvalue olur.

bir sonraki işlemde ise y değişkenine x + 1 değeri atanmış, burada tahmin edebileceğiniz üzere y değeri lvalue olurken x her ne kadar kendisi lvalue olmasına karşın x + 1(2) geçiçi bir nesne olup bellekte bir adresi olmadığı için rvalue olur.

son satırda ise z ve x değişkenleri her ikisde bellekte bir adrese sahip oldukları için lvaluedur.

Peki şöyle birşey yapmak isteseydik;

int x;
5 = x; //Error

Bizi “expression must be a modifiable lvalue” şeklinde uyarıyor, atama yapacağımız değer değiştirirebilir(non const/mutable) ve bellekte bir alana sahip lvalue olmalı.

Şimdi gelen biraz daha dili karıştalım başka örnek verelim;

Referansları kısaca hatırlamak gerekirse aslında referanslarda pointerlar gibi başka bir nesneyi gösteren ifadelerdi, fakat pointerdan farklı olarak bu işlemi derleyici yaptığı için daha güvenliydi.

Şimdi örneğimize gelecek olursak kodu ilk satırdaki haliyle derlemek istediğimizde “non-const lvalue reference to type ‘int’ cannot bind to a temporary of type ‘int’” şeklinde bir hata verecektir, hatadanda anlaşılacağı üzere 1 değeri geçici bir nesne(rvalue) ve bellekte bir adrese sahip değil dolayısıyla bu yanlış bir ifade.

Fakat burada bir şeye dikkatinizi çekmek istiyorum “non const lvalue reference”
Yani diğer kısımdaki kod geçerli const lvalue referansına rvalue ataması yapabiliyoruz 1 değerine her ne kadar rvalue olsada bir const lvalue referansına atanırken derleyici tarafından kendisine alan tahsis ediliyor.

x2'nin değerini ve adresini yazdırmak istersek;

std::cout << "Value of x2: " << x2 << " Adress: " << &x2 << std::endl;
//Value of x2: 1 Adress: 0x2010F1(Adres temsilidir)

Fakat değeri değiştirmek istediğimizde const olarak işaretlendiği için hata alacağız işte buradada karşımıza C++ 11 ile gelen rvalue referansı çıkıyor.
Aslında C++ den önce
const int& x gibi bir tanımlama sadece referans olarak ifade edilebiliyordu.

rvalue referansı & move semantics

rvalue referansları C++ 11 ile gelip genellikle

  • Move semantics
  • perfect forwarding

ile birlikte kullanılır, bu iki ifadeyide türkçeye çevirmek istemedim.

Buradaki asıl amaç geçici objeleri atama işlemlerinde gereksiz kopyalamanın önüne geçmek. Bir diğer avantaj ise bir nesnenin sahipliğini değiştirerek tek bir sahibi olduğundan emin olmak, buna en güzel örnek ise std::unique_ptr bu da başka bir yazının konusu.

öncelikle bir rvalue referans nasıl tanımlanır onu görelim;

int&& x = 1;

Ifadesi C++ 11 ile geçerli bir ifade, şimdi az önceki örneğimizi hatırlıyormusunuz ? const lvalue referansı ile tuttumuz değişkenin değerini const olarak işaretlendiği için değiştirememiştik artık bunu yapabiliriz.

int&& x = 1;
x++;
std::cout << x << std::endl;

Peki bu bizim ne işimize yarıyacak ?

Daha öncede söylediğim gibi aslında en önemli kullanılma sebebi gereksiz kopyalardan kaçınmak bunuda yeni nesne örneği yaratmaktansa işimizin bittiği nesnenin sahipliğini değiştirerek/taşıyarak yada başka bir tabirle değerlerini çalarak yapıyoruz. Bu yazının sonunda her iki yöntemide uygulayarak bir performans testi yapacağız :)

Basit bir örnek vererek başlayalım;

string s1("Selam");
string s2("Merhaba");

s2 = s1;
std::cout << "s1: " << s1 << " s2: " << s2 << std::endl;

Kodunun çıktısı “s1: Selam s2: Selam” olacaktır.
Fakat aynı işlemi aynı işlemi std::move ile yaparsak;

string s1("Selam");
string s2("Merhaba");

s2 = std::move(s1);
std::cout << "s1: " << s1 << " s2: " << s2 << std::endl;

s1 değişkeninin değerini kaybettik ne oldu peki ? hemen std::move’a bakalım

template <class _Tp>
_LIBCPP_NODISCARD_EXT inline _LIBCPP_INLINE_VISIBILITY _LIBCPP_CONSTEXPR
typename remove_reference<_Tp>::type&&
move(_Tp&& __t) _NOEXCEPT {
typedef _LIBCPP_NODEBUG_TYPE typename remove_reference<_Tp>::type _Up;
return static_cast<_Up&&>(__t);
}

Aslında burdan anlamız gereken std::move fonsiyonunun gelen argumanı rvalue referansına cast edip geri döndürdüğü, ama bu bizim s1 değerini kaybetmemizi açıklamıyor.

İşte buradada karşımıza C++ 11 ile gelen Move Constructor ve Move Assignment Operator çıkıyor yukarıdaki örnekte aslında move assigment operatorü ile nesnenin sahipliği değiştirildi. Aynı kodu std=c++11 flag’i olmadan derleseydim Copy assigment operatürü çağırılacak ve her iki nesnede değerini koruyacaktı.

Şimdi yukarıda bahsettiğim senaryoyu tekrar düşünün 2 nesnemiz olsun ve bunlardan bir tanesine artık ihtiyacımız kalmasın bu durumda objenin içerisindeki değerleri teker teker kopyalamak yerine neden sahipliğini değiştirmiyoruz ?

Somut bir örnek yapalım Array adında bir generic bir sınıfımız olsun içerisinde T tipinde veri barındırsın

#include <iostream>

template<typename T>
class Array
{
public:
Array(size_t size) {
data = new T[size];
this->size = size;
std::cout << "Default constructor called" << std::endl;
}

Array(const Array& other) {
data = new T[other.size];
this->size = other.size;
for(size_t i = 0; i < size; i++)
data[i] = other.data[i];
std::cout << "Copy constructor called" << std::endl;
}

~Array()
{
if(data)
delete[] data;
}
private:
T* data;
size_t size;
};

int main()
{
Array<int> x(5);
Array<int> y = x;
}

Data değişkenin değerlerini kopyamamız gerekti bu örnekte her ne kadar olmasada x değişkeninine artık ihtiyacamızın olmadığı bir senaryoyu ele alalım bu durumda artık kullanmayacağımız, bellekte tutulan veya tutulmayan(Buna en güzel örnek Array sınıfımızın örneğini döndüren bir fonksiyon olabilir) bir değişken üzerinde işlem yaptık.

Bu işlemi sahipliğini değiştirerek yapacak olursak yani elemanları teker teker(Deep Copy) kopyalamak yerine pointerin işaret ettiği adresi değiştirip işimizin bittiği nesnenin değerlerini Yıkıcı methodda hatalı bir duruma yol açmaması için sıfırlayalım.

 Array(Array&& other) {
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
std::cout << "Move constructor called" << std::endl;
}

mainde Array<int> y = x; ifadesini Array<int> y = std::move(x);
ile kullandığımıda artık move constructor cağırılmış olacak.

Array sınıfını rvalue olarak döndüren methodumuz olsaydı örneğin;
Array<int> createArray(size_t size);
Bu obje zaten geçici olarak oluşacağı ve değerlere sahip olduğu için rvalue referansı sayesinde bu nesneyi tutup değerlerini alabildik.

Son olarak basit bir performans testi yaparak yazımızı bitirelim

int main()
{

std::chrono::steady_clock::time_point start;
std::chrono::nanoseconds diff_copy;
std::chrono::nanoseconds diff_move;

start = std::chrono::steady_clock::now();
for(int i = 0; i < 10000; i++) {
Array<int> x(100);
Array<int> y = x;
}
diff_copy = std::chrono::steady_clock::now() - start;

start = std::chrono::steady_clock::now();
for(int i = 0; i < 10000; i++) {
Array<int> x(100);
Array<int> y = std::move(x);
}
diff_move = std::chrono::steady_clock::now() - start;

std::cout << "Copy: " << diff_copy.count() << std::endl;
std::cout << "Move: " << diff_move.count() << std::endl;

}
Copy: 9786667
Move: 1342625

Kaynaklar

--

--