程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> [C++]右值引用和轉移語義

[C++]右值引用和轉移語義

編輯:關於C++

右值引用和轉移語義

本文嘗試著解釋何為右值引用和轉移語義以及使用它們具有優勢,並提供相關案例分析。

定義

左值和右值

首先我們先來理解一下什麼是左值和右值

C/C++語言中可以放在賦值符號左邊的變量,左值表示存儲在計算機內存的對象,左值相當於地址值。右值:當一個符號或者常量放在操作符右邊的時候,計算機就讀取他們的“右值”,也就是其代表的真實值,右值相當於數據值。

C/C++語言中可以放在賦值符號左邊的變量,即具有對應的可以由用戶訪問的存儲單元,並且能夠由用戶去改變其值的量。左值表示存儲在計算機內存的對象,而不是常量或計算的結果。或者說左值是代表一個內存地址值,並且通過這個內存地址,就可以對內存進行讀並且寫(主要是能寫)操作;這也就是為什麼左值可以被賦值的原因了。相對應的還有右值:當一個符號或者常量放在操作符右邊的時候,計算機就讀取他們的“右值”,也就是其代表的真實值。簡單來說就是,左值相當於地址值,右值相當於數據值。右值指的是引用了一個存儲在某個內存地址裡的數據。

左值右值翻譯:

L-value中的L指的是Location,表示可尋址。A value (computer science)that has an address.

R-value中的R指的是Read,表示可讀。in computer science, a value that does not have an address in a computer language.

左值和右值是相對於賦值表達式而言的。左值是能出現在賦值表達式左邊的表達式。左值表達式可以分為可讀寫的左值和只讀左值。右值是可以出現在賦值表達式右邊的表達式,他可以是不占據內存空間的臨時量或字面量,可以是不具有寫入權的空間實體。如

int a=3;
const int b=5;
a=b+2; //a是左值,b+2是右值
b=a+2; //錯!b是只讀的左值但無寫入權,不能出現在賦值符號左邊
(a=4)+=28; //a=4是左值表達式,28是右值,+=為賦值操作符
34=a+2; //錯!34是字面量不能做左值

(from 百度百科)

左值引用

左值引用根據其修飾符的不同,可以區分為常量左值引用和非常量左值引用。左值引用實際上就是指針。

非常量左值引用只能綁定到非常量左值,不能綁定到常量左值和常量右值,(因為非常左值可以改變其值,但常量不可改變,性質相矛盾),非常量右值。而如果綁定到非常量右值,就有可能指向一個已經被銷毀的對象。

常量左值引用能綁定到非常量左值,常量左值,非常量右值,常量右值。

右值引用

從實踐角度講,它能夠完美解決C++中長久以來為人所诟病的臨時對象效率問題。從語言本身講,它健全了C++中的引用類型在左值右值方面的缺陷。從庫設計者的角度講,它給庫設計者又帶來了一把利器。從庫使用者的角度講,不動一兵一卒便可以獲得“免費的”效率提升…

右值引用 (Rvalue Referene) 是 C++ 新標准 (C++11, 11 代表 2011 年 ) 中引入的新特性 , 它實現了轉移語義 (Move Sementics) 和精確傳遞 (Perfect Forwarding)。它的主要目的有兩個方面:

消除兩個對象交互時不必要的對象拷貝,節省運算存儲資源,提高效率。

能夠更簡潔明確地定義泛型函數。

左值引用和右值引用的語法

為了區別,C++把&作為左值引用的聲明符,把&&作為右值引用的聲明符。

void process_value(int& i) {
    std::cout << "LValue processed: " << i << std::endl;
}
void process_value(int&& i) {
    std::cout << "RValue processed: " << i << std::endl;
}

int main() {
    int a = 0;
    process_value(a);
    process_value(1);  //  1對於編譯器而言就是臨時對象。
}

output:

LValue processed: 0
RValue processed: 1
Program ended with exit code: 0

但是如果臨時對象通過一個接受右值的函數傳遞給另一個函數時,就會變成左值,因為這個臨時對象在傳遞過程中,變成了命名對象

void process_value(int& i) {
    std::cout << "LValue processed: " << i << std::endl;
}

void process_value(int&& i) {
    std::cout << "RValue processed: " << i << std::endl;
}

void forward_value(int&& i) {
    //  在函數傳遞中i被認為是命名對象。
    process_value(i);
}

int main() {
    int a = 0;
    process_value(a);
    process_value(1);
    forward_value(2);
}

output:

LValue processed: 0
RValue processed: 1
LValue processed: 2
Program ended with exit code: 0

轉移語義

右值引用是用來支持轉移語義的。轉移語義可以將資源 ( 堆,系統對象等 ) 從一個對象轉移到另一個對象,這樣能夠減少不必要的臨時對象的創建、拷貝以及銷毀,能夠大幅度提高 C++ 應用程序的性能。臨時對象的維護 ( 創建和銷毀 ) 對性能有嚴重影響。

轉移語義是和拷貝語義相對的,可以類比文件的剪切與拷貝,當我們將文件從一個目錄拷貝到另一個目錄時,速度比剪切慢很多。

通過轉移語義,臨時對象中的資源能夠轉移其它的對象裡。(注意是臨時對象中的“資源”而不是臨時對象本身!這裡所謂的使用資源是指指針的指向問題,通過改變指針的指向可以直接使用臨時對象中的資源。所以如果,臨時對象中並不涉及動態分配內存的問題時,轉移語義並不能起到作用,也不必起作用。)

在現有的 C++ 機制中,我們可以定義拷貝構造函數和賦值函數。要實現轉移語義,需要定義轉移構造函數,還可以定義轉移賦值操作符。對於右值的拷貝和賦值會調用轉移構造函數和轉移賦值操作符。如果轉移構造函數和轉移拷貝操作符沒有定義,那麼就遵循現有的機制,拷貝構造函數和賦值操作符會被調用。

普通的函數和操作符也可以利用右值引用操作符實現轉移語義。

實例:實現轉移構造函數和轉移賦值函數

以一個簡單的 string 類為示例,實現拷貝構造函數和拷貝賦值操作符。
示例1:沒有轉移構造函數和轉移copying函數

 class MyString { 
 private: 
  char* _data; 
  size_t   _len; 
  void _init_data(const char *s) { 
    _data = new char[_len+1]; 
    memcpy(_data, s, _len); 
    _data[_len] = '\0'; 
  } 
 public: 
  MyString() { 
    _data = NULL; 
    _len = 0; 
  } 

  MyString(const char* p) { 
    _len = strlen (p); 
    _init_data(p); 
  } 

  MyString(const MyString& str) { 
    _len = str._len; 
    _init_data(str._data); 
    std::cout << "Copy Constructor is called! source: " << str._data << std::endl; 
  } 

  MyString& operator=(const MyString& str) { 
    if (this != &str) { 
      _len = str._len; 
      _init_data(str._data); 
    } 
    std::cout << "Copy Assignment is called! source: " << str._data << std::endl; 
    return *this; 
  } 

  virtual ~MyString() { 
    if (_data) free(_data); 
  } 
 }; 

 int main() { 
  MyString a; 
  a = MyString("Hello"); 
  std::vector vec; 
  vec.push_back(MyString("World")); 
 }

output:

 Copy Assignment is called! source: Hello 
 Copy Constructor is called! source: World

這個類基本滿足我們的需求。但是實際上他的效率很低。因為每一次賦值操作符的調用都會先析構原有的對象的內存,臨時對象構造,復制,析構等一系列操作。拷貝構造函數也是如此。非常地低效。於是有人想到,如果能夠直接使用臨時對象已經申請的資源,既能節省資源,有能節省資源申請和釋放的時間。這正是定義轉移語義的目的。

示例2:實現轉移構造函數和轉移copying函數

class MyString {
private:
    char* _data;
    size_t   _len;
    void _init_data(const char *s) {
        _data = new char[_len+1];
        memcpy(_data, s, _len);
        _data[_len] = '\0';
    }
public:
    MyString() {
        _data = NULL;
        _len = 0;
    }

    MyString(const char* p) {
        _len = strlen (p);
        _init_data(p);
    }

    MyString(const MyString& str) {
        _len = str._len;
        _init_data(str._data);
        std::cout << "Copy Constructor is called! source: " << str._data << std::endl;
    }

    MyString& operator=(const MyString& str) {
        if (this != &str) {
            _len = str._len;
            _init_data(str._data);
        }
        std::cout << "Copy Assignment is called! source: " << str._data << std::endl;
        return *this;
    }
    MyString(MyString&& str) {
        std::cout << "Move Constructor is called! source: " << str._data << std::endl;
        _len = str._len;
        _data = str._data;
        str._len = 0;
        str._data = NULL;
    }
    MyString& operator=(MyString&& str) {
        std::cout << "Move Assignment is called! source: " << str._data << std::endl;
        if (this != &str) {
            clear();
            _len = str._len;
            _data = str._data;
            str._len = 0;
            str._data = NULL;
        } 
        return *this; 
    }
    void clear() {
        delete _data;
        _data = NULL;
        _len = 0;
    }
    virtual ~MyString() {
        if (_data) free(_data);
    }
};

output:

Move Assignment is called! source: Hello 
Move Constructor is called! source: World

注意新添加的兩個函數:

    MyString(MyString&& str) {
        std::cout << "Move Constructor is called! source: " << str._data << std::endl;
        _len = str._len;
        _data = str._data;
        str._len = 0;
        str._data = NULL;
    }
    MyString& operator=(MyString&& str) {
        std::cout << "Move Assignment is called! source: " << str._data << std::endl;
        if (this != &str) {
            clear();
            _len = str._len;
            _data = str._data;
            str._len = 0;
            str._data = NULL;
        } 
        return *this; 
    }

和拷貝構造函數類似,有幾點需要注意:

參數(右值)的符號必須是右值引用符號,即“&&”。 參數(右值)不可以是常量,因為我們需要修改右值。 參數(右值)的資源鏈接和標記必須修改。否則,右值的析構函數就會釋放資源。轉移到新對象的資源也就無效了。

觀察其源碼,我們可以不難發現,實際上就是轉移資源(指針),從而避免了臨時對象的復制。

由此看出,編譯器區分了左值和右值,對右值調用了轉移構造函數和轉移賦值操作符。節省了資源,提高了程序運行的效率。

有了右值引用和轉移語義,我們在設計和實現類時,對於需要動態申請大量資源的類,應該設計轉移構造函數和轉移賦值函數,以提高應用程序的效率。

此處補充一點關於拷貝構造函數的小知識:
如果我們在main函數裡面寫下以下語句時:

MyString a(MyString("Hello")); 

什麼都不會輸出。為什麼呢?

原來,在C++中,下面三種對象才需要調用拷貝構造函數(有時也稱“復制構造函數”):

1) 一個對象作為函數參數,以值傳遞的方式傳入函數體; 2) 一個對象作為函數返回值,以值傳遞的方式從函數返回; 3) 一個對象用於給另外一個對象進行初始化(常稱為賦值初始化);

此處用到了3)的道理。

但如果你寫下:

MyString a(move(MyString("Hello"))); 

編譯器會調用轉移拷貝構造函數。因為move返回了一個右值引用。

標准庫函數std::move

move的源碼:

void move(basic_ios&& __rhs) {
    move(__rhs);
}
basic_ios<_CharT, _Traits>::move(basic_ios& __rhs) {
    ios_base::move(__rhs);
    __tie_ = __rhs.__tie_;
    __rhs.__tie_ = 0;
    __fill_ = __rhs.__fill_;
}

由於標准庫其他相當復雜的內容不在本文的討論范圍,所以不share出來,我們大概分析move源碼可以看出來,實際上就是把一個右值的指針傳給了左值,然後右值的指針指向空。實現了交換指針。

由於編譯器只對右值引用才能調用轉移構造函數和轉移賦值函數,而所有命名對象都只能是左值引用,如果已知一個命名對象不再被使用而想對它調用轉移構造函數和轉移賦值函數,也就是把一個左值引用當做右值引用來使用,怎麼做呢?標准庫提供了函數 std::move,這個函數以非常簡單的方式將左值引用轉換為右值引用。

 void ProcessValue(int& i) { 
  std::cout << "LValue processed: " << i << std::endl; 
 } 

 void ProcessValue(int&& i) { 
  std::cout << "RValue processed: " << i << std::endl; 
 } 

 int main() { 
  int a = 0; 
  ProcessValue(a); 
  ProcessValue(std::move(a)); 
 }
/* output:
 LValue processed: 0 
 RValue processed: 0
*/

std::move在提高 swap 函數的的性能上非常有幫助,一般來說,swap函數的通用定義如下:

template  swap(T& a, T& b) 
{ 
    T tmp(a);   // copy a to tmp 
    a = b;      // copy b to a 
    b = tmp;    // copy tmp to b 
}

有了 std::move,swap 函數的定義變為 :

template  swap(T& a, T& b) 
{ 
    T tmp(std::move(a)); // move a to tmp 
    a = std::move(b);    // move b to a 
    b = std::move(tmp);  // move tmp to b 
}

通過 std::move,一個簡單的 swap 函數就避免了 3 次不必要的拷貝操作。

精確傳遞 (Perfect Forwarding)

本文采用精確傳遞表達這個意思。”Perfect Forwarding”也被翻譯成完美轉發,精准轉發等,說的都是一個意思。

精確傳遞適用於這樣的場景:需要將一組參數原封不動的傳遞給另一個函數。

“原封不動”不僅僅是參數的值不變,在 C++ 中,除了參數值之外,還有一下兩組屬性:左值/右值和 const/non-const。 精確傳遞就是在參數傳遞過程中,所有這些屬性和參數值都不能改變。在泛型函數中,這樣的需求非常普遍。

下面舉例說明。函數 forward_value 是一個泛型函數,它將一個參數傳遞給另一個函數 process_value。
forward_value 的定義為:

 template  void forward_value(const T& val) { 
  process_value(val); 
 } 
 template  void forward_value(T& val) { 
  process_value(val); 
 }

函數 forward_value 為每一個參數必須重載兩種類型,T& 和 const T&,否則,下面四種不同類型參數的調用中就不能同時滿足:

  int a = 0; 
  const int &b = 1; 
  forward_value(a); // int& 
  forward_value(b); // const int& 
  forward_value(2); //  const int&

對於一個參數就要重載兩次,也就是說如果函數有n個參數,就要重載2^n。這個函數的定義次數對於程序員來說,是非常低效的。我們看看右值引用如何幫助我們解決這個問題:

template 
void forward_value(T&& val) { 
    process_value(val); 
}

只需要定義一次,接受一個右值引用的參數,就能夠將所有的參數類型原封不動的傳遞給目標函數。四種不用類型參數的調用都能滿足,參數的左右值屬性和 const/non-cosnt 屬性完全傳遞給目標函數 process_value。這個解決方案不是簡潔優雅嗎?

  int a = 0; 
  const int &b = 1; 
  forward_value(a); // int& 
  forward_value(b); // const int& 
  forward_value(2); // const int&&

C++11 中定義的 T&& 的推導規則為:

右值實參為右值引用,左值實參仍然為左值引用。

一句話,就是參數的屬性不變。這樣也就完美的實現了參數的完整傳遞。
右值引用,表面上看只是增加了一個引用符號,但它對 C++ 軟件設計和類庫的設計有非常大的影響。它既能簡化代碼,又能提高程序運行效率。每一個 C++ 軟件設計師和程序員都應該理解並能夠應用它。我們在設計類的時候如果有動態申請的資源,也應該設計轉移構造函數和轉移拷貝函數。在設計類庫時,還應該考慮 std::move 的使用場景並積極使用它。

測試程序

//
//  test
//
//  Created by 顏澤鑫 on 4/30/16.
//  Copyright ? 2016 顏澤鑫. All rights reserved.
//

#include 
#include 
using namespace std;
// just use to test
class test {
private:
    int* array;
    int size;
public:
    test() {
        array = NULL;
        size = 0;
    }
    test(int *arrays, int sizes) {
        array = new int [sizes];
        for (int i = 0; i != sizes; i++) {
            array[i] = arrays[i];
        }
        size = sizes;
    }
    test(test &orig) {
        cout << "L-value reference occur!" << endl;
        array = new int [orig.size];
        while (size != orig.size) {
            array[size] = orig.array[size];
            size++;
        }
    }
    test(test &&orig) {
        cout << "R-value reference occur!" << endl;
        array = orig.array;
        size = orig.size;
        orig.array = NULL;
    }
    test& operator = (const test &orig) {
        if (this != &orig) {
            cout << "L-value reference occur!" << endl;
            array = new int [orig.size];
            while (size != orig.size) {
                array[size] = orig.array[size];
                size++;
            }
        }
        return *this;
    }
    test& operator = (test &&orig) {
        if (this != &orig) {
            cout << "R-value reference occur!" << endl;
            array = orig.array;
            size = orig.size;
            orig.array = NULL;
        }
        return *this;
    }
};
test check() {
    int a[4] = {1, 2, 3, 4};
    test temp = test(a, 4);
    cout << &temp << endl;
    return temp;
}
// In this program, I try to explain how constructor works and how R-value reference works.
int main() {
    // test 1
    int a[4] = {1, 2, 3, 4};
    cout << "test 1" << endl;
    test temp_test1(check());
    cout << &temp_test1 << endl;
    // Through observe the address of the temporary pointer in func check()
    // and the address of temp_test1, we can see that they are the same object
    // It is clear that the IDE transfer the temporary object to global object in secret
    // without using r-value reference or copy constructor to save time.
    cout << endl;

    cout << "test 1 again" << endl;
    test temp = test(a, 4);
    test temp_test1_again(temp);
    // If we have created an object which is not temporary object, the IDE will use copy constructor.
    cout << endl;

    cout << "test 2" << endl;
    // Move function is used to transfer an object to R-value and obviously the IDE will use
    // r-value copy constructor.
    test temp_test2(move(test(a, 4)));
    cout << endl;

    cout << "test 2 again" << endl;
    test temp2 = test(a, 4);
    test temp_test2_again(move(temp2));
    // Move function can also transfer a l-value object to a r-value object
    // which means that if we will never use temp2 again, we can let it be a r-value object
    // and give it to temp_test2_again, saving time without copy constructor.
    cout << endl;

    cout << "test 3" << endl;
    test temp_test3;
    test temp_test3_s(a, 4);
    temp_test3 = temp_test3_s;
    //  just like the copy constructor.
    cout << endl;

    cout << "test 3 again" << endl;
    test temp_test3_again;
    test temp_test3_again_s(a, 4);
    temp_test3_again = move(temp_test3_again_s);
    cout << endl;

    cout << "test 4" << endl;
    test temp_test4;
    temp_test4 = test(a, 4);
    // If temp_test4 has been created and pass a temporary object to it,
    // the IDE will use r-value reference assignment operator.
    cout << endl;
    return 0;
}
// There is an another question we should know.
// R-value reference doesn't mean we can transfer an address of one object to another object,
// but we can transfer the "source" of one object to another object directly,
// saving much for avoid using copy/assignment operation.
// That is what I have mistaten before.
//

本文大部分知識點來自C++11 標准新特性: 右值引用與轉移語義。少許解釋與理解屬於我個人拙劣見解。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved