← Kursa Dön
📄 Text · 18 min

Value Categories — lvalue, rvalue, xvalue

C++'ta her ifadenin (expression) iki temel özelliği vardır: bir tipi (type) ve bir değer kategorisi (value category). Tipi zaten biliyorsun — int, string, double falan. Ama değer kategorisi çoğu programcının "aa bu da ne?" dediği, sonra öğrenince "aaa her şey mantıklı oldu" dediği kavram.

Bu ders, C++11 ile hayatımıza giren move semantics'in temelini oluşturan value category sistemini sıfırdan anlatıyor. Eğer daha önce "rvalue reference nedir?" sorusuna tatmin edici bir cevap bulamadıysan, doğru yerdesin.

Büyük Resim: Neden Umursuyoruz?

Şunu düşün: bir fonksiyona std::string geçiriyorsun. Bu string'in kopyasını mı almalı, yoksa içeriğini "çalıp" mı kullanmalı? İşte bu kararı derleyici senin yerine verebilir — ama bunun için ifadenin value category'sini bilmesi gerekiyor.

Kopyalamak pahalıdır. Taşımak (move) ucuzdur. Value category sistemi, derleyiciye "bu nesneyi artık kimse kullanmayacak, içeriğini rahatça al" deme imkânı verir.

Analoji: Mektup ve Havada Uçan Kağıt

Bunu somutlaştıralım:

lvalue = adres yazılı mektup. Zarfın üstünde gönderici adresi var. Mektubu istediğin zaman o adrese gidip tekrar bulabilirsin. Bellekte kalıcı bir yeri var, ismiyle erişebilirsin.

rvalue = havada uçan kağıt. Rüzgârda uçan bir kâğıt parçası. Şu an elinin altında ama bir saniye sonra yok olacak. Kalıcı bir adresi yok — yakalayamazsan gider.

Bu analoji bize şunu söylüyor: lvalue'lar güvenlidir, onlara referans verebilirsin çünkü duracaklar. rvalue'lar geçicidir — ya hemen kullan, ya da kaybol.

lvalue Nedir?

lvalue (locator value), bellekte tanımlı bir konumu olan ifadedir. Adını "left value"dan alır çünkü atama operatörünün solunda durabilir.

Ama daha doğru tanım şu: adresi alınabilen (&) her ifade bir lvalue'dur.

int x = 42;
int* ptr = &x;       // x bir lvalue — adresi alınabilir

std::string name = "Ali";
std::string* sp = &name;  // name bir lvalue

int arr[5];
int* ap = &arr[0];   // arr[0] bir lvalue

int& ref = x;
int* rp = &ref;      // ref de bir lvalue (x'e alias)

Temel kural basit: ismi varsa, lvalue'dur. Değişkenler, fonksiyon parametreleri, dizi elemanları, referanslar — hepsi lvalue.

Bir de derleyiciye sorabileceğin pratik test var: &ifade yazabiliyorsan, o ifade lvalue'dur.

int x = 10;
int y = 20;

&x;          // ✅ geçerli — x lvalue
&(x + y);    // ❌ derlenmez — (x + y) rvalue
&42;         // ❌ derlenmez — 42 rvalue
&std::string("merhaba"); // ❌ derlenmez — geçici nesne, rvalue

rvalue Nedir?

rvalue (right value), geçici bir değerdir. Bellekte kalıcı bir adresi yoktur — ifade değerlendirildikten sonra buharlaşır.

Literaller, aritmetik ifadelerin sonuçları ve fonksiyonların değer döndürdüğü durumlar rvalue'dur.

int result = 3 + 4;   // 3 + 4 → geçici bir rvalue, result'a kopyalanır
std::string s = std::string("geçici");  // sağ taraf geçici nesne — rvalue
double d = sqrt(2.0);  // sqrt(2.0) → geçici bir double, rvalue

rvalue'lar genelde bir satırın sonunda yok olurlar. Kimse onlara isim vermemiştir, kimsenin onlara tekrar erişmesi gerekmez.

prvalue ve xvalue Ayrımı

C++11 ile birlikte rvalue kavramı ikiye ayrıldı:

  • prvalue (pure rvalue): Gerçekten geçici olan, hiçbir nesneyle ilişkilendirilmemiş değerler. 42, 3.14, x + y, std::string("merhaba") gibi.

  • xvalue (eXpiring value): Bir nesne var ama ömrünün sonuna gelmiş, "artık beni kullanabilirsin" diyor. std::move(x) gibi.

Bu ayrım biraz akademik görünebilir ama move semantics'i anlamak için kritik. Birazdan xvalue'ya ayrıntılı değineceğiz.

C++11 Değer Kategorisi Taksonomisi

C++11 beş değer kategorisi tanımlar ama günlük hayatta üçünü bilmen yeterli:

        expression
        /        \
     glvalue    rvalue
     /    \     /    \
  lvalue  xvalue  prvalue
  • glvalue (generalized lvalue): lvalue + xvalue. "Bir konumu olan" ifadeler.

  • rvalue: xvalue + prvalue. "Taşınabilir" ifadeler.

Günlük kullanımda: lvalue mı, prvalue mı, xvalue mı — bu üçü yeter.

lvalue Reference (&) — Detaylı

lvalue reference, zaten bildiğin & referans. Bir lvalue'ya alias (takma ad) verir.

int x = 10;
int& ref = x;     // ref, x'in alias'ı
ref = 20;          // x artık 20
std::cout << x;    // 20

Önemli kural: normal lvalue reference, rvalue'ya bağlanamaz.

int& bad = 42;          // ❌ HATA — 42 bir rvalue
int& bad2 = x + y;      // ❌ HATA — x + y bir rvalue
std::string& s = std::string("test"); // ❌ HATA — geçici nesne

Derleyici buna izin vermez çünkü mantıksız: geçici bir değere referans verirsen, referans bir sonraki satırda çöpe işaret eder.

const lvalue Reference — Sihirli İstisna

Ama const lvalue reference, rvalue'ya bağlanabilir! Bu C++'ın en kullanışlı istisnalarından biri.

const int& ref = 42;    // ✅ geçerli!
const std::string& s = std::string("merhaba"); // ✅ geçerli!

void print(const std::string& s) {
    std::cout << s << "\n";
}

print("geçici string");  // ✅ geçici string → const ref'e bağlanır

Neden çalışıyor? Derleyici geçici nesnenin ömrünü referansın ömrüne kadar uzatır (lifetime extension). Yani geçici nesne, referans yaşadığı sürece yaşar.

Bu yüzden C++11 öncesinde fonksiyon parametrelerini const std::string& olarak yazmak altın standart'tı. Hem lvalue hem rvalue kabul ediyordu.

💡 İpucu: const T& hem lvalue hem rvalue alabilir. Bu yüzden C++11 öncesi kodlarda her yerde görürsün. Ama C++11 sonrası, rvalue'lar için daha iyi bir seçenek var: rvalue reference.

rvalue Reference (&&) — Geçici Nesneleri Yakalama

C++11'in getirdiği en büyük yeniliklerden biri: rvalue reference (&&).

rvalue reference, sadece rvalue'lara bağlanabilir. Amacı: geçici nesnelerin kaynaklarını "çalmak" (move etmek).

int&& rref = 42;        // ✅ geçerli — 42 rvalue
std::string&& s = std::string("geçici"); // ✅ geçerli

int x = 10;
int&& bad = x;          // ❌ HATA — x bir lvalue

Neden rvalue Reference'a İhtiyacımız Var?

Düşün: büyük bir std::vector<int> fonksiyondan dönüyor. Bu vektörün 10 milyon elemanı var.

std::vector<int> createBigVector() {
    std::vector<int> v(10'000'000, 42);
    return v;
}

std::vector<int> myVec = createBigVector();

C++11 öncesinde bu satır potansiyel olarak 10 milyon int'i kopyalayabilirdi. C++11 ile derleyici, dönen değerin geçici (rvalue) olduğunu bilir ve move constructor çağırır. Move constructor, vektörün iç pointer'ını "çalar" — kopyalama yerine pointer swap yapar. O(1) operasyon.

rvalue reference, move constructor ve move assignment operator'ı mümkün kılar:

class MyString {
    char* data;
    size_t length;

public:
    // Copy constructor — pahalı
    MyString(const MyString& other) {
        length = other.length;
        data = new char[length + 1];
        std::memcpy(data, other.data, length + 1);
    }

    // Move constructor — ucuz!
    MyString(MyString&& other) noexcept {
        data = other.data;        // pointer'ı çal
        length = other.length;
        other.data = nullptr;     // eski nesneyi temizle
        other.length = 0;
    }
};

Move constructor'daki MyString&& parametresi sadece rvalue'ları kabul eder. Yani sadece "artık kullanılmayacak" nesneler bu constructor'a düşer.

⚠️ Dikkat: Bir rvalue reference değişkeninin kendisi lvalue'dur! Bu çok kafa karıştırıcı ama mantıklı. MyString&& other parametresinin ismi other — ve ismi olan her şey lvalue'dur. Bu yüzden move constructor içinde other'ı başka bir yere move etmek istersen std::move(other) yazman gerekir.

xvalue — Süresi Dolan Değer

xvalue (eXpiring value), "bu nesne artık bitti, kaynaklarını alabilirsin" diyen ifadedir. En yaygın xvalue kaynağı: std::move().

std::string name = "Ali";
std::string other = std::move(name);  // std::move(name) → xvalue
// name artık "moved-from" durumda — geçerli ama belirsiz içerikli

std::move() aslında hiçbir şeyi taşımaz! Sadece lvalue'yu xvalue'ya dönüştürür (cast). Gerçek taşıma, move constructor veya move assignment operator tarafından yapılır.

// std::move basitçe şudur:
template<typename T>
T&& move(T& x) {
    return static_cast<T&&>(x);
}

xvalue'nun taksonomideki yeri: hem glvalue hem rvalue. Yani hem bir konumu var (bellekte duruyor) hem de taşınabilir (artık kullanılmayacak olarak işaretlenmiş).

xvalue Üreten Diğer İfadeler

std::move() dışında xvalue üreten durumlar:

struct Widget {
    std::string name;
};

Widget getWidget();

// Geçici nesnenin üyesine erişim → xvalue
std::string s = getWidget().name;  // getWidget().name bir xvalue

// static_cast<T&&> doğrudan kullanım
int x = 5;
int&& r = static_cast<int&&>(x);  // xvalue

"Bu lvalue mı, rvalue mı?" Tablosu

İşte en çok ihtiyaç duyacağın referans tablosu. Bu tabloyu ezberleme — mantığını anla:

İfadeKategoriNeden?
x (değişken)lvalueİsmi var, adresi alınabilir
42prvalueLiteral, geçici
3.14prvalueLiteral, geçici
"merhaba"lvalueString literal, bellekte kalıcı yeri var
x + yprvalueAritmetik sonucu, geçici
x++ (postfix)prvalueEski değerin kopyasını döner, geçici
++x (prefix)lvaluex'in kendisini döner
x = 5lvalueAtama, x'in kendisini döner
x += 3lvalueBileşik atama, x'in kendisini döner
*ptrlvalueDereference, bellekteki nesneye erişim
arr[2]lvalueDizi elemanı, bellekte yeri var
obj.memberlvalueÜye erişimi (obj lvalue ise)
ptr->memberlvaluePointer üzerinden üye erişimi
std::move(x)xvaluelvalue → xvalue dönüşümü
static_cast<int&&>(x)xvalueAçık rvalue ref cast
func() (return by value)prvalueGeçici nesne döner
func() (return by ref T&)lvalueVar olan nesneye referans döner
func() (return by T&&)xvaluervalue reference döner
std::string("test")prvalueGeçici nesne oluşturma
getWidget().namexvalueGeçici nesnenin üyesi
(x)lvalueParantez kategoriyi değiştirmez
x ? a : blvaluea ve b lvalue ise lvalue (karmaşık kurallar var)
throw exprprvaluevoid türünde prvalue
&x (address-of)prvaluePointer değeri, geçici
thisprvaluePointer değeri (ama *this lvalue)
*thislvalueNesnenin kendisi
nullptrprvalueLiteral, geçici
lambda ifadesiprvalueGeçici closure nesnesi
std::ref(x) dönüşüprvaluereference_wrapper geçici nesnesi

Birkaç sürpriz var bu tabloda. Özellikle "merhaba" string literal'inin lvalue olması çoğu kişiyi şaşırtır. Ama düşünürsen mantıklı: string literal'ler programın ömrü boyunca bellekte durur, yani kalıcı bir adresleri var.

Bir de x++ ile ++x farkına dikkat: x++ eski değerin bir kopyasını döner (prvalue), ++x ise x'in kendisini döner (lvalue). Bu yüzden ++x daha verimlidir — gereksiz kopya yok.

Move Semantics ile Bağlantı

Artık value category'leri anladığına göre, move semantics'in neden var olduğunu tam olarak kavrayabilirsin.

Problem: C++03'te her şey kopyalanıyordu. Büyük nesneler (vector, string, map) fonksiyonlardan dönerken veya fonksiyonlara geçirilirken tüm içerikleri kopyalanıyordu.

Çözüm: C++11, rvalue reference ile "bu nesne geçici, kaynaklarını al" sinyali vermeyi mümkün kıldı.

void processName(std::string name) {
    // name ile bir şeyler yap
}

std::string firstName = "Tolgahan";

// Senaryo 1: lvalue geçirme → kopya
processName(firstName);              // copy constructor çağrılır

// Senaryo 2: rvalue geçirme → move
processName(std::string("geçici"));  // move constructor çağrılır ✨

// Senaryo 3: açıkça move etme → move
processName(std::move(firstName));   // move constructor çağrılır ✨
// firstName artık kullanılmamalı!

Derleyici overload resolution sırasında şu kuralı uygular:

  • İfade lvalueconst T& veya T (copy) parametresine gider

  • İfade rvalueT&& (move) parametresine gider (varsa)

Bu sayede aynı fonksiyonun hem copy hem move versiyonunu yazabilirsin:

class Container {
    std::vector<int> data;

public:
    // lvalue'lar için — kopyala
    void setData(const std::vector<int>& d) {
        data = d;  // copy assignment
        std::cout << "Kopyalandi\n";
    }

    // rvalue'lar için — taşı
    void setData(std::vector<int>&& d) {
        data = std::move(d);  // move assignment
        std::cout << "Tasindi\n";
    }
};

Container c;
std::vector<int> myVec = {1, 2, 3, 4, 5};

c.setData(myVec);              // "Kopyalandi" — myVec lvalue
c.setData({10, 20, 30});       // "Tasindi" — geçici rvalue
c.setData(std::move(myVec));   // "Tasindi" — xvalue

auto&& — Forwarding Reference (Kısaca)

auto&& gördüğünde "rvalue reference" diye düşünme. Bu özel bir durum: forwarding reference (evrensel referans).

auto&& a = 42;         // 42 rvalue → a'nın tipi int&&
int x = 10;
auto&& b = x;          // x lvalue → b'nin tipi int&
const int y = 20;
auto&& c = y;          // y lvalue → c'nin tipi const int&

auto&&, bağlandığı ifadenin value category'sine göre ya lvalue reference ya da rvalue reference olur. Buna reference collapsing kuralları sebep olur.

Aynı şey template'lerde de geçerli:

template<typename T>
void wrapper(T&& arg) {
    // arg hem lvalue hem rvalue olabilir
    // İçerideki fonksiyona perfect forward:
    realFunction(std::forward<T>(arg));
}

wrapper(42);    // T = int, arg tipi = int&&
wrapper(x);     // T = int&, arg tipi = int& (reference collapsing)

std::forward, argümanın orijinal value category'sini koruyarak ileri aktarır. Bu konu başlı başına bir ders, şimdilik "auto&& her şeyi yakalar" bilgisi yeterli.

💡 İpucu: Range-based for loop'ta auto&& kullanmak güvenli ve verimlidir. Hem lvalue hem rvalue elemanlarla çalışır: ``cpp for (auto&& item : container) { // item ne olursa olsun doğru tipte bağlanır } ``

Copy Elision — Neden Kopya Oluşmaz?

Şu kodu düşün:

std::string getString() {
    return std::string("merhaba dünya");
}

std::string s = getString();

Kaç tane string oluşur? Cevap: bir tane. Copy elision (kopyalama elenmesi) sayesinde derleyici geçici nesneyi doğrudan s'in yerine oluşturur.

C++17 ile birlikte bu davranış zorunlu hale geldi (mandatory copy elision / guaranteed RVO). Yani derleyici optimizasyon olarak değil, standart gereği kopyayı eler.

// C++17 öncesi: derleyici bunu yapabilir (Named RVO)
std::string makeName() {
    std::string result = "Ali";
    result += " Veli";
    return result;  // NRVO — Named Return Value Optimization
}

// C++17 sonrası: bu zorunlu olarak elenir (prvalue → doğrudan hedefte oluşur)
std::string makeName2() {
    return std::string("Ali Veli");  // Mandatory elision
}

std::string name = makeName();   // Muhtemelen 0 kopya (NRVO)
std::string name2 = makeName2(); // Garantili 0 kopya (C++17)

Copy Elision Nasıl Çalışır?

Copy elision'ın arkasındaki mantık şu: derleyici, geçici nesnenin nereye kopyalanacağını önceden bilir. Bu yüzden geçici nesneyi hiç oluşturmaz — doğrudan hedef konumda (destination) oluşturur.

// Kavramsal olarak olan:
// 1. getString() geçici string oluşturur
// 2. Geçici string s'e kopyalanır/taşınır
// 3. Geçici string yok edilir

// Copy elision ile gerçekte olan:
// 1. String doğrudan s'in bellek alanında oluşturulur
// Bitti! Kopya yok, move yok.

Bu yüzden std::string s = getString(); yazarken "acaba kopya oluşur mu?" diye endişelenme. Modern C++ derleyicileri bunu halleder.

⚠️ Dikkat: Copy elision, move constructor'dan bile daha iyidir. Move O(1) olsa bile, elision gerçekten sıfır maliyet demektir. Bu yüzden fonksiyonlardan değer dönerken std::move(result) yazma — derleyicinin NRVO yapmasını engelleyebilirsin!

>

```cpp std::string bad() { std::string s = "test"; return std::move(s); // ❌ NRVO'yu engeller! }

>

std::string good() { std::string s = "test"; return s; // ✅ NRVO uygulanabilir } ```

Pratik: Value Category Test Etme

Bir ifadenin value category'sini programatik olarak test etmek istersen:

#include <type_traits>
#include <iostream>

// Bu helper ile test edebilirsin
#define VALUE_CAT(expr)                                    \
    (std::is_lvalue_reference_v<decltype((expr))>          \
        ? "lvalue"                                         \
        : std::is_rvalue_reference_v<decltype((expr))>     \
            ? "xvalue"                                     \
            : "prvalue")

int main() {
    int x = 42;
    int& ref = x;

    std::cout << "x:           " << VALUE_CAT(x) << "\n";
    std::cout << "42:          " << VALUE_CAT(42) << "\n";
    std::cout << "x + 1:       " << VALUE_CAT(x + 1) << "\n";
    std::cout << "std::move(x):" << VALUE_CAT(std::move(x)) << "\n";
    std::cout << "ref:         " << VALUE_CAT(ref) << "\n";
}

Çıktı:

x:           lvalue
42:          prvalue
x + 1:       prvalue
std::move(x):xvalue
ref:         lvalue

decltype((expr)) kullanımına dikkat: çift parantez önemli. Tek parantez decltype(x) değişkenin bildirilen tipini verir (int), çift parantez decltype((x)) ifadenin value category'sine göre referans tipini verir (int& çünkü lvalue).

Reference Binding Kuralları — Özet Tablosu

Hangi referans türü hangi value category'ye bağlanabilir?

Referans Türülvalueprvaluexvalue
T&
const T&
T&&
const T&&

const T& her şeye bağlanır — bu yüzden C++11 öncesinin "universal reference"ı buydu. Ama rvalue'lar için move yapma şansını kaybedersin çünkü const olduğu için içeriğini değiştiremezsin.

T&& sadece rvalue'lara bağlanır — bu tam olarak move semantics için gereken şey.

const T&& teknik olarak var ama pratikte neredeyse hiç kullanılmaz. Bir nesneyi "move edemeyeceğin ama geçici olduğunu bildiğin" durumlar çok nadirdir.

Overload Resolution ve Value Category

Derleyici, fonksiyon çağrısında birden fazla aday varsa value category'ye göre en uygun olanı seçer:

void process(const std::string& s) {
    std::cout << "const ref — kopya yok ama move de yok\n";
}

void process(std::string&& s) {
    std::cout << "rvalue ref — move yapabiliriz!\n";
}

int main() {
    std::string name = "Ali";

    process(name);               // "const ref" — name lvalue
    process(std::string("Bob")); // "rvalue ref" — geçici rvalue
    process(std::move(name));    // "rvalue ref" — xvalue

    // Eğer sadece const ref overload'u olsaydı,
    // hepsi oraya giderdi — çalışır ama move fırsatını kaçırırsın.
}

Bu mekanizma sayesinde STL container'ları (vector, map, string...) hem copy hem move destekler. push_back fonksiyonunun iki overload'u var:

// std::vector'ün içinden (basitleştirilmiş):
void push_back(const T& value);  // lvalue → kopyala
void push_back(T&& value);       // rvalue → taşı

Moved-From Durumu

std::move kullandıktan sonra nesne geçerli ama belirsiz (valid but unspecified) durumdadır.

std::string a = "merhaba";
std::string b = std::move(a);

// a şu an geçerli bir string — ama içeriği ne?
// Boş olabilir, "merhaba" olabilir, başka bir şey olabilir.
// Standart sadece şunu garanti eder:
// - a yıkılabilir (destructor güvenli)
// - a'ya yeni değer atanabilir

a = "yeni değer";  // ✅ bu güvenli
std::cout << a;     // "yeni değer"

Pratikte çoğu STL tipi move'dan sonra boş/default duruma gelir ama buna güvenme. Moved-from nesneyi ya yeniden ata ya da hiç kullanma.

Gerçek Dünya Örneği: Sink Parametresi

"Sink" pattern, bir fonksiyonun argümanın sahipliğini aldığı durumdur:

class Logger {
    std::vector<std::string> logs;

public:
    // Sink parametresi: by value al, içeri move et
    void addLog(std::string message) {
        logs.push_back(std::move(message));
    }
};

Logger logger;

std::string msg = "Sistem başlatıldı";
logger.addLog(msg);               // 1 kopya + 1 move
logger.addLog("Bağlantı kuruldu"); // 1 move (geçici → param → vector)
logger.addLog(std::move(msg));     // 1 move + 1 move (toplam 2 move, 0 kopya)

By-value sink parametresi hem lvalue hem rvalue için iyi çalışır. lvalue geldiğinde 1 kopya + 1 move, rvalue geldiğinde 2 move (kopyadan çok daha ucuz).

Özet

  • lvalue: İsmi olan, adresi alınabilen ifade. Değişkenler, referanslar, dizi elemanları. Bellekte kalıcı yeri var.

  • rvalue: Geçici ifade. Literaller, aritmetik sonuçları, fonksiyondan dönen değerler. Satır sonunda yok olur.

  • xvalue: Süresi dolmak üzere olan nesne. std::move() ile üretilir. Hem konumu var hem taşınabilir.

  • rvalue reference (&&) sayesinde derleyici geçici nesnelerin kaynaklarını kopyalamak yerine taşır — bu move semantics'in temelidir.

  • const T& her şeye bağlanır ama move yapamaz. T&& sadece rvalue'lara bağlanır ve move imkânı verir.

  • Fonksiyondan değer dönerken std::move kullanma — copy elision'ı (NRVO) engelleyebilir. Derleyiciye güven.