右值引用

紫冰凌發表於2024-04-29

右值引用

左值和右值

何為左值右值?

左值一般是指一個指向特定記憶體的具有名稱的值(具名物件),它有一個相對穩定的記憶體地址,並且有一段較長的生命週期。而右值則是不指向穩定記憶體地址的匿名值(不具名物件),它的生命週期很短,通常是暫時性的。

基於上述特徵可以使用取地址符&來判斷左值和右值,能取到記憶體地址的值為左值,否則為右值

    int kk = 0;
    auto a = &1; // lvalue required as unary '&' operand
    auto a2 = &kk;
    auto a3 = &++kk;
    auto a4 = &kk++; // lvalue required as unary '&' operand

x++和++x雖然都是自增操作,但是卻分為不同的左右值。其中x++是右值,因為在後置++操作中編譯器首先會生成一份x值的臨時複製,然後才對x遞增,最後返回臨時複製內容。而++x則不同,它是直接對x遞增後馬上返回其自身,所以++x是一個左值。

通常字面量都是一個右值,除字串字面量以外

int x = 1; set_val(6); auto p = &"hello world";

編譯器會將字串字面量儲存到程式的資料段中,程式載入的時候也會為其開闢記憶體空間,所以我們可以使用取地址符&來獲取字串字面量的記憶體地址

int x = 1;

int get_val()
{
    return x;
}

auto y = &get_val();// lvalue required as unary '&' operand

變數x是一個左值,但是它經過函式返回以後變成了一個右值。原因和x++類似,在函式返回的時候編譯器並不會返回x本身,而是返回x的臨時複製。

void set_val(int val)
{
  int *p = &val;
  x = val;
}

對於set_val函式,該函式接受一個引數並且將引數的值賦值到x中。set_val(6);實參6是一個右值,但是進入函式之後形參val卻變成了一個左值,我們可以對val使用取地址符。

左值引用

當我們需要將一個物件作為引數傳遞給子函式的時候,往往會使用左值引用,因為這樣可以免去建立臨時物件的操作。

常量左值引用除了能引用左值,還能夠引用右值。這點常應用於複製建構函式和複製賦值運算子函式的函式形參列表,兩個函式的形參都是一個常量左值引用。

class X {
public:
  X() {}
  X(const X&) {}
  X& operator = (const X&) { return *this; }
};

X make_x()
{
  return X();
}

int main() 
{
  X x1;
  X x2(x1);
  X x3(make_x());
  x3 = make_x();
}

如果去掉const則 X x3(make_x());和x3 = make_x();會報錯因為在此時make_x()返回右值,而去掉const後複製建構函式和複製賦值運算子函式無法接受左值。而宣告為常量左值引用後既可以應用左值,又可以引用右值,還不會建立臨時物件,而是直接使用傳入的 X 物件,這樣可以避免額外的複製操作,提高了效率。

缺點:一旦使用了常量左值引用,就表示我們無法在函式內修改該物件的內容(強制型別轉換除外)。

右值引用

右值引用是一種引用右值且只能引用右值的方法,右值引用是在型別後新增&&。

#include <iostream>

class X
{
public:
    X() { std::cout << "X ctor" << std::endl; }
    X(const X &x) { std::cout << "X copy ctor" << std::endl; }
    X &operator=(const X &x)
    {
        std::cout << "X assignment operator" << std::endl;
        return *this;
    }
    ~X() { std::cout << "X dtor" << std::endl; }
    void show() { std::cout << "show X" << std::endl; }
};

X make_x()
{
    X x1;
    return x1;
}

int main()
{
    X &&x2 = make_x();
    x2.show();
    return 0;
}

如果將X &&x2 = make_x()這句程式碼替換為X x2 =make_x()在沒有進行任何最佳化的情況下應該是3次構造(我也覺得是三次,但是不知道為什麼編譯器最佳化全部關了還是兩次,少一次複製構造),首先make_x函式中x1會預設構造一次,然後return x1會使用複製構造產生臨時物件,接著X x2 =make_x()會使用複製構造將臨時物件複製到x2,最後臨時物件被銷燬。

執行上面的程式碼結果如下

X ctor
X copy ctor
X dtor
show X
X dtor

從執行結果可以看出上面的程式碼只發生了兩次構造。第一次是make_x函式中x1的預設構造,第二次是return x1引發的複製構造。不同的是,由於x2是一個右值引用,引用的物件是函式make_x返回的臨時物件,因此該臨時物件的生命週期得到延長,所以我們可以在X &&x2 = make_x()語句結束後繼續呼叫show函式而不會發生任何問題。

移動語義

#include <iostream>
class BigMemoryPool {
public:
  static const int PoolSize = 4096;
  BigMemoryPool() : pool_(new char[PoolSize]) {}
  ~BigMemoryPool()
  {
      if (pool_ != nullptr) {
            delete[] pool_;
      }
  }

  BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
  {
      std::cout << "copy big memory pool." << std::endl;
      memcpy(pool_, other.pool_, PoolSize);
  }

private:

  char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool& pool)
{
  return pool;
}

BigMemoryPool make_pool()
{
  BigMemoryPool pool;
  return get_pool(pool);
}

int main()
{
  BigMemoryPool my_pool = make_pool();
}

上面的程式碼共呼叫了3次複製建構函式

1.get_pool返回的BigMemoryPool臨時物件呼叫複製建構函式複製了pool物件。

2.make_pool返回的BigMemoryPool臨時物件呼叫複製建構函式複製了get_pool返回的臨時物件。

3.main函式中my_pool呼叫其複製建構函式複製make_pool返回的臨時物件。

第二次和第三次的複製構造是影響效能的主要原因。在這個過程中都有臨時物件參與進來,而臨時物件本身只是做資料的複製。

使用移動語義進行最佳化

class BigMemoryPool {
public:
  static const int PoolSize = 4096;
  BigMemoryPool() : pool_(new char[PoolSize]) {}
  ~BigMemoryPool()
  {
      if (pool_ != nullptr) {
            delete[] pool_;
      }
  }

  BigMemoryPool(BigMemoryPool&& other)
  {
      std::cout << "move big memory pool." << std::endl;
      pool_ = other.pool_;
      other.pool_ = nullptr;
  }

  BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
  {
      std::cout << "copy big memory pool." << std::endl;
      memcpy(pool_, other.pool_, PoolSize);
  }

private:

  char *pool_;
};

移動構造函接受的是一個右值,其核心思想是透過轉移實參物件的資料以達成構造目標物件的目的,在函式中沒有了複製構造中的記憶體複製,取而代之的是簡單的指標替換操作。它將實參物件的pool_賦值到當前物件,然後置空實參物件以保證實參物件析構的時候不會影響這片記憶體的生命週期。

執行 BigMemoryPool my_pool = make_pool();結果如下

copy big memory pool.
move big memory pool.
move big memory pool.

後面兩次的建構函式變成了移動建構函式,因為這兩次操作中源物件都是右值(臨時物件),對於右值編譯器會優先選擇使用移動建構函式去構造目標物件。當移動建構函式不存在的時候才會退而求其次地使用複製建構函式。

同樣的也有移動賦值運算子函式,編譯器對於賦值源物件是右值的情況會優先呼叫移動賦值運算子函式,如果該函式不存在,則呼叫複製賦值運算子函式。

BigMemoryPool& operator=(BigMemoryPool&& other)
{
    std::cout << "move(operator=) big memory pool." << std::endl;
    if (pool_ != nullptr) {
        delete[] pool_;
    }
    pool_ = other.pool_;
    other.pool_ = nullptr;
    return *this;
}

值類別

左值對應前文描述的左值,純右值對應前文描述的右值。

什麼是將亡值?

將亡值屬於泛左值的一種,它表示資源可以被重用的物件和位域,通常這是因為它們接近其生命週期的末尾,另外也可能是經過右值引用的轉換產生的。

將亡值產生的途徑

第一種是使用型別轉換將泛左值轉換為該型別的右值引用:

static_cast<BigMemoryPool&&>(my_pool)

第二種在C++17標準中引入,我們稱它為臨時量實質化,指的是純右值轉換到臨時物件的過程。每當純右值出現在一個需要泛左值的地方時,臨時量實質化都會發生,也就是說都會建立一個臨時物件並且使用純右值對其進行初始化,這也符合純右值的概念,而這裡的臨時物件就是一個將亡值。

struct X {
  int a;
};

int main()
{
  int b = X().a;
}

X()是一個純右值,訪問其成員變數a卻需要一個泛左值,所以這裡會發生一次臨時量實質化,將X()轉換為將亡值,最後再訪問其成員變數a。

將左值轉換為右值

透過static_cast將左值轉換為將亡值,實現被右值引用繫結。

int i = 0;
int &&k = static_cast<int&&>(i);    // 編譯成功

轉化後i依然有著和轉換之前相同的生命週期和記憶體地址。轉化最大作用是讓左值使用移動語義。

void move_pool(BigMemoryPool &&pool)
{
  std::cout << "call move_pool" << std::endl;
  BigMemoryPool my_pool(pool);
}

int main()
{
  move_pool(make_pool());
}

在上面的程式碼中,move_pool函式的實參是make_pool函式返回的臨時物件,也是一個右值,move_pool的形參是一個右值引用,但是在使用形參pool構造my_pool的時候還是會呼叫複製建構函式而非移動建構函式。為了讓my_pool呼叫移動建構函式進行構造,需要將形參pool強制轉換為右值:

void move_pool(BigMemoryPool &&pool)
{
  std::cout << "call move_pool" << std::endl;
  BigMemoryPool my_pool(static_cast<BigMemoryPool&&>(pool));
}

函式模板std::move也可以幫助我們將左值轉換為右值,由於它是使用模板實現的函式,因此會根據傳參型別自動推導返回型別,省去了指定轉換型別的程式碼。

void move_pool(BigMemoryPool &&pool)
{
  std::cout << "call move_pool" << std::endl;
  BigMemoryPool my_pool(std::move(pool));
}

萬能引用和引用摺疊

如果一個變數或者引數被宣告為T&&,其中T是被推導的型別,那這個變數或者引數就是一個萬能引用。

void foo(int &&i) {}    // i為右值引用

template<class T>
void bar(T &&t) {}        // t為萬能引用

int get_val() { return 5; }
int &&x = get_val();      // x為右值引用
auto &&y = get_val();     // y為萬能引用

C++11中新增了一套引用疊加推導的規則——引用摺疊。在這套規則中規定了在不同的引用型別互相作用的情況下應該如何推匯出最終型別

只要有左值引用參與進來,最後推導的結果就是一個左值引用。只有實際型別為非引用和右值引用才可以推到出右值引用。

完美轉發

#include <iostream>
#include <string>

template<class T>
void show_type(T t)
{
  std::cout << typeid(t).name() << std::endl;
}

template<class T>
void normal_forwarding(T t)
{
  show_type(t);
}

int main()
{
  std::string s = "hello world";
  normal_forwarding(s);
}

normal_forwarding的轉發缺陷在於每呼叫一次normal_forwarding(T t) 就會造成一次額外的複製,改為void normal_forwarding (const T &t)可以避免不必要的複製,而且既可以接受左值又可以接受右值,但是後續無法修改字串。使用萬能引用改善常量左值引用帶來的常量性缺點。

template<class T>
void perfect_forwarding(T &&t)
{
  show_type(static_cast<T&&>(t));
}

相關文章