首頁 > 軟體

一文搞懂c++中的std::move函數

2022-07-09 22:01:55

前言

在探討c++11中的Move函數前,先介紹兩個概念(左值和右值)

左值和右值

首先區分左值和右值

左值是表示式結束後依然存在的持久物件(代表一個在記憶體中佔有確定位置的物件)

右值是表示式結束時不再存在的臨時物件(不在記憶體中佔有確定位置的表示式)

便攜方法:對錶示式取地址,如果能,則為左值,否則為右值

int val;
val = 4; // 正確 ①
4 = val; // 錯誤 ②

上述例子中,由於在之前已經對變數val進行了定義,故在棧上會給val分配記憶體地址,運運算元=要求等號左邊是可修改的左值,4是臨時參與運算的值,一般在暫存器上暫存,運算結束後在暫存器上移除該值,故①是對的,②是錯的

左值參照

右值參照

std::move函數

  • std::move作用主要可以將一個左值轉換成右值參照,從而可以呼叫C++11右值參照的拷貝建構函式
  • std::move應該是針對你的物件中有在堆上分配記憶體這種情況而設定的,如下

remove_reference原始碼剖析

在分析std::move()std::forward()之前,先看看remove_reference,下面是remove_reference的實現:

template<typename _Tp>
struct remove_reference
{ typedef _Tp   type; };
 
// 特化版本
template<typename _Tp>
struct remove_reference<_Tp&>
{ typedef _Tp   type; };
 
template<typename _Tp>
struct remove_reference<_Tp&&>
{ typedef _Tp   type; };

remove_reference的作用是去除T中的參照部分,只獲取其中的型別部分。無論T是左值還是右值,最後只獲取它的型別部分。

std::forward原始碼剖析

轉發左值

template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }

先通過獲得型別type,定義_t為左值參照的左值變數,通過static_cast進行強制轉換。_Tp&&會發生參照摺疊,當_Tp推導為左值參照,則摺疊為_Tp& &&,即_Tp&,當推導為右值參照,則為本身_Tp&&,即forward返回值與static_cast處都為_Tp&&

轉發右值

template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{
  static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
        " substituting _Tp is an lvalue reference type");
  return static_cast<_Tp&&>(__t);
}

不同於轉發左值,_t為右值參照的左值變數,除此之外中間加了一個斷言,表示當不是左值的時候,也就是右值,才進行static_cast轉換。

std::move()原始碼剖析

// FUNCTION TEMPLATE move
template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

std::move的功能是:

  • 傳遞的是左值,推導為左值參照,仍舊static_cast轉換為右值參照。
  • 傳遞的是右值,推導為右值參照,仍舊static_cast轉換為右值參照。
  • 在返回處,直接範圍右值參照型別即可。還是通過renive_reference獲得_Tp型別,然後直接type&&即可。

所以std::remove_reference<_Tp>::type&&,就是一個右值參照,我們就知道了std::move乾的事情了。

小結

  • 在《Effective Modern C++》中建議:對於右值參照使用std::move,對於萬能參照使用std::forward。
  • std::move()與std::forward()都僅僅做了型別轉換(可理解為static_cast轉換)而已。真正的移動操作是在移動建構函式或者移動賦值操作符中發生的
  • 在型別宣告當中, “&&” 要不就是一個 rvalue reference ,要不就是一個 universal reference – 一種可以解析為lvalue reference或者rvalue reference的參照。對於某個被推導的型別T,universal references 總是以 T&& 的形式出現。
  • 參照摺疊是 會讓 universal references (其實就是一個處於參照摺疊背景下的rvalue references ) 有時解析為 lvalue references 有時解析為 rvalue references 的根本機制。參照摺疊只會在一些特定的可能會產生"參照的參照"場景下生效。這些場景包括模板型別推導,auto 型別推導, typedef 的形成和使用,以及decltype 表示式。

std::move使用場景

在實際場景中,右值參照和std::move被廣泛用於在STL和自定義類中實現移動語意,避免拷貝,從而提升程式效能。 在沒有右值參照之前,一個簡單的陣列類通常實現如下,有建構函式拷貝建構函式賦值運運算元過載解構函式等。深拷貝/淺拷貝在此不做講解。

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷貝構造
    Array(const Array& temp_array) {
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
     
    // 深拷貝賦值
    Array& operator=(const Array& temp_array) {
        delete[] data_;
 
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
 
    ~Array() {
        delete[] data_;
    }
 
public:
    int *data_;
    int size_;
};

該類的拷貝建構函式、賦值運運算元過載函數已經通過使用左值參照傳參來避免一次多餘拷貝了,但是內部實現要深拷貝,無法避免。 這時,有人提出一個想法:是不是可以提供一個移動建構函式,把被拷貝者的資料移動過來,被拷貝者後邊就不要了,這樣就可以避免深拷貝了,如:

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷貝構造
    Array(const Array& temp_array) {
        ...
    }
     
    // 深拷貝賦值
    Array& operator=(const Array& temp_array) {
        ...
    }
 
    // 移動建構函式,可以淺拷貝
    Array(const Array& temp_array, bool move) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 為防止temp_array解構時delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
    ~Array() {
        delete [] data_;
    }
 
public:
    int *data_;
    int size_;
};

這麼做有2個問題:

  • 不優雅,表示移動語意還需要一個額外的引數(或者其他方式)。
  • 無法實現!temp_array是個const左值參照,無法被修改,所以temp_array.data_ = nullptr;這行會編譯不過。當然函數引數可以改成非const:Array(Array& temp_array, bool move){...},這樣也有問題,由於左值參照不能接右值,Array a = Array(Array(), true);這種呼叫方式就沒法用了。

可以發現左值參照真是用的很不爽,右值參照的出現解決了這個問題,在STL的很多容器中,都實現了以右值參照為引數的移動建構函式移動賦值過載函數,或者其他函數,最常見的如std::vector的push_backemplace_back。引數為左值參照意味著拷貝,為右值參照意味著移動。

class Array {
public:
    ......
 
    // 優雅
    Array(Array&& temp_array) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 為防止temp_array解構時delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
public:
    int *data_;
    int size_;
};

如何使用:

// 例1:Array用法
int main(){
    Array a;
 
    // 做一些操作
    .....
     
    // 左值a,用std::move轉化為右值
    Array b(std::move(a));
}

範例:vector::push_back使用std::move提高效能

// 例2:std::vector和std::string的實際例子
int main() {
    std::string str1 = "aacasxs";
    std::vector<std::string> vec;
     
    vec.push_back(str1); // 傳統方法,copy
    vec.push_back(std::move(str1)); // 呼叫移動語意的push_back方法,避免拷貝,str1會失去原有值,變成空字串
    vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1會失去原有值
    vec.emplace_back("axcsddcas"); // 當然可以直接接右值
}
 
// std::vector方法定義
void push_back (const value_type& val);
void push_back (value_type&& val);
 
void emplace_back (Args&&... args);

在vector和string這個場景,加個std::move會呼叫到移動語意函數,避免了深拷貝。

除非設計不允許移動,STL類大都支援移動語意函數,即可移動的。 另外,編譯器會預設在使用者自定義的classstruct中生成移動語意函數,但前提是使用者沒有主動定義該類的拷貝構造等函數(具體規則自行百度哈)。 因此,可移動物件在<需要拷貝且被拷貝者之後不再被需要>的場景,建議使用std::move觸發移動語意,提升效能。

還有些STL類是move-only的,比如unique_ptr,這種類只有移動建構函式,因此只能移動(轉移內部物件所有權,或者叫淺拷貝),不能拷貝(深拷貝)

std::unique_ptr<A> ptr_a = std::make_unique<A>();

std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移動賦值過載函數‘,引數是&& ,只能接右值,因此必須用std::move轉換型別

std::unique_ptr<A> ptr_b = ptr_a; // 編譯不通過

std::move本身只做型別轉換,對效能無影響。 我們可以在自己的類中實現移動語意,避免深拷貝,充分利用右值參照和std::move的語言特性。

std::vector<int> b(5);
b[0] = 2;
b[1] = 2;
b[2] = 2;
b[3] = 2;

// 此處用move就不會對b中已有元素重新進行拷貝構造然後再放到a中
std::vector<int> a = std::move(b);

將vector B賦值給另一個vector A,如果是拷貝賦值,那麼顯然要對B中的每一個元素執行一個copy操作到A,如果是移動賦值的話,只需要將指向B的指標拷貝到A中即可,試想一下如果vector中有相當多的元素,那是不是用move來代替copy就顯得十分高效了呢?建議看一看Scott Meyers 的Effective Modern C++,裡面對移動語意、右值參照以及型別推導進行了深入的探索

萬能參照

首先,我們先看一個例子

#include <iostream>
using std::cout;
using std::endl;
template<typename T>
void func(T& param) {
    cout << param << endl;
}
int main() {
    int num = 2019;
    func(num);
    return 0;
}

這樣例子的編譯輸出不存在什麼問題,但是如果修改成下面的呼叫方式呢?

int main(){
    func(2019);
    return 0;
}

編譯器會產生錯誤,因為上面的模板函數只能接受左值或者左值參照(左值一般是有名字的變數,可以取到地址的),我們當然可以過載一個接受右值的模板函數,如下也可以達到效果

template<typename T>
void func(T& param) {
    cout << "傳入的是左值" << endl;
}
template<typename T>
void func(T&& param) {
    cout << "傳入的是右值" << endl;
}

int main() {
    int num = 2019;
    func(num);
    func(2019);
    return 0;
}

輸出結果

傳入的是左值

傳入的是右值

第一次函數呼叫的是左值得版本,第二次函數呼叫的是右值版本。但是,有沒有辦法只寫一個模板函數即可以接收左值又可以接收右值呢?

C++11中有萬能參照(Universal Reference)的概念:使用T&&型別的形參既能繫結右值,又能繫結左值

但是注意了:只有發生型別推導的時候,T&&才表示萬能參照(如模板函數傳參就會經過型別推導的過程);否則,表示右值參照

所以,上面的案例我們可以修改為

template<typename T>
void func(T&& param) {
    cout << param << endl;
}
int main() {
    int num = 2019;
    func(num);
    func(2019);
    return 0;
}

參照摺疊

萬能參照說完了,接著來聊參照摺疊(Reference Collapse),因為完美轉發(Perfect Forwarding)的概念涉及參照摺疊。一個模板函數,根據定義的形參和傳入的實參的型別,我們可以有下面四中組合:

左值-左值 T& & # 函數定義的形參型別是左值參照,傳入的實參是左值參照

template<typename T>
void func(T& param) {
    cout << param << endl;
}
int main(){
    int num = 2021;
    int& val = num;
    func(val);
}

左值-右值 T& && # 函數定義的形參型別是左值參照,傳入的實參是右值參照

template<typename T>
void func(T& param) {
    cout << param << endl;
}

int main(){
    int&& val = 2021;
    func(val);
}

右值-左值 T&& & # 函數定義的形參型別是右值參照,傳入的實參是左值參照

template<typename T>
void func(T&& param) {
    cout << param << endl;
}

int main(){
    int num = 2021;
    int& val = num;
    func(val);
}

右值-右值 T&& && # 函數定義的形參型別是右值參照,傳入的實參是右值參照

template<typename T>
void func(T&& param) {
    cout << param << endl;
}

int main(){
    int&& val = 4;
    func(val);
}

但是C++中不允許對參照再進行參照,對於上述情況的處理有如下的規則:

所有的摺疊參照最終都代表一個參照,要麼是左值參照,要麼是右值參照。規則是:如果任一參照為左值參照,則結果為左值參照。否則(即兩個都是右值參照),結果才是右值參照

即就是前面三種情況代表的都是左值參照,而第四種代表的右值參照

完美轉發

下面接著說完美轉發(Perfect Forwarding),首先,看一個例子

#include <iostream>
using std::cout;
using std::endl;
template<typename T>
void func(T& param) {
    cout << "傳入的是左值" << endl;
}
template<typename T>
void func(T&& param) {
    cout << "傳入的是右值" << endl;
}
template<typename T>
void warp(T&& param) {
    func(param);
}
int main() {
    int num = 2019;
    warp(num);
    warp(2019);
    return 0;
}

輸出的結果

傳入的是左值
傳入的是左值

是不是和預期的不一樣,下面我們來分析一下原因:

warp()函數本身的形參是一個萬能參照,即可以接受左值又可以接受右值;第一個warp()函數呼叫實參是左值,所以,warp()函數中呼叫func()中傳入的引數也應該是左值;第二個warp()函數呼叫實參是右值,根據上面所說的參照摺疊規則,warp()函數接收的引數型別是右值參照,那麼為什麼卻呼叫了呼叫func()的左值版本了呢?這是因為在warp()函數內部,右值參照型別變為了左值,因為引數有了名稱,我們也通過變數名取得變數地址

那麼問題來了,怎麼保持函數呼叫過程中,變數型別的不變呢?這就是我們所謂的“變數轉發”技術,在C++11中通過std::forward()函數來實現。我們來修改我們的warp()函數如下:

template<typename T>
void warp(T&& param) {
    func(std::forward<T>(param));
}

則可以輸出預期的結果

傳入的是左值
傳入的是右值

參考博文

現代C++之萬能參照、完美轉發、參照摺疊(萬字長文):https://blog.csdn.net/guangcheng0312q/article/details/103572987

C++ 中的「移動」在記憶體或者暫存器中的操作是什麼,為什麼就比拷貝賦值效能高呢?:https://www.zhihu.com/question/55735384

一文讀懂C++右值參照和std::move:https://zhuanlan.zhihu.com/p/335994370

到此這篇關於c++中的std::move函數的文章就介紹到這了,更多相關c++ std::move函數內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com