博客 / 詳情

返回

【C++】移動語義和完美轉發

前言

學習C++移動語義和完美轉發筆記,記錄左值、右值、std::move()、萬能引用、引用摺疊等相關內容。

概念

  • 左值 (lvalue) 它是在內存中有明確存儲地址、可以被尋址的值。如果你可以對一個表達式取地址(使用 & 運算符),那麼它就是一個左值。左值通常是持久的,在它所在的定義域結束之前一直存在

  • 左值引用(Lvalue Reference)本質上就是給一個現有的左值起了一個“別名”,左值引用定義即初始化。

    • 普通左值引用 (T&):只能綁定到非 const 左值
    • 常量左值引用 (const T&):可以綁定到一切(左值、const 左值、右值)。
const int& temp = 10; 
//編譯器會在內存中產生一個臨時變量存儲 10。
//temp 綁定到這個臨時變量上。
//這個臨時變量的壽命會變得和引用 temp 一樣長。
  • 右值 (rvalue) 右值就是那些臨時出現、沒有持久名字、無法取地址的值。如果你無法對一個表達式使用 & 取地址運算符,或者它是一個即將銷燬的臨時對象,它就是右值。右值通常是“瞬時”的,在包含它的表達式執行完之後,它就會被立即銷燬.

  • 右值引用(Rvalue Reference)一種綁定到右值(臨時對象)的引用類型

    int&& rref = 10;

  • std::move 並不移動任何東西。它的唯一作用是:強制將一個左值轉為右值引用

    template <typename T> 
    typename std::remove_reference<T>::type&& move(T&& t) {    
        return static_cast<typename std::remove_reference<T>::type&&>(t); 
    }
    
    • std::remove_reference<T>::type:這是一個類型萃取工具。無論 Tintint& 還是 int&&,它都能把修飾符去掉,只留下純粹的底層類型 int

    • static_cast 將輸入變量 t 強制轉換為該類型的右值引用(即 type&&

  • std::forward 如果原始參數是左值,轉發後仍然是左值。如果原始參數是右值,轉發後仍然是右值

template <typename T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
    return static_cast<T&&>(t);
}
  • 移動語義(Move Semantics) 本質是資源所有權的轉移,即將一個臨時對象(右值)持有的資源轉移,來避免昂貴的深拷貝操作。是由類實現的功能(通過移動構造函數)

  • 完美轉發(Perfect Forwarding) 是:在函數模板中,將參數原封不動地轉發給另一個函數,同時完全保留參數的所有屬性(包括它是左值還是右值、是否帶有 constvolatile 修飾符)。

    template <typename T>
    T&& forward(typename std::remove_reference<T>::type& t) noexcept {
        return static_cast<T&&>(t);
    }
    
    • 如果原始參數是左值,轉發後仍然是左值

    • 如果原始參數是右值,轉發後仍然是右值

  • 萬能引用

    • 使用 && 符號
    • 必須發生模板類型推導:通常出現在 template <typename T> 之後的 T&&(不能加 const)。
    • 形式必須完全匹配:必須是具體的 T&&,不能有 conststd::vector<T>&& 等修飾。
    • 萬能引用是完美轉發的“門”。 它把參數原封不動地領進來(無論是左值還是右值),然後配合 std::forward 把它原封不動地送出去。
  • 引用摺疊 是 C++ 編譯器在處理“引用的引用”時遵循的一套自動簡化規則。只要有左值引用(&)參與,結果就是左值引用;只有全是右值引用(&&)時,結果才是右值引用。

內容

左值

內存中有明確存儲地址、可以被尋址的值

int a = 10;        // a 是左值(有名字,可取地址)
a = 20;            // a 在左邊,OK

int* p = &a;       // p 是左值
*p = 30;           // *p(解引用結果)是左值

int** pp = &(*p);  // 我們可以對 (*p) 再次取地址 ,可以看到*p是可以取地址的

const int b = 5;   // b 是左值(雖然不可修改,但它有內存地址,是具名變量)

void func(int&& x) {
    // 這裏的 x 類型是右值引用,但 x 本身是一個有名字的變量
    // 所以在函數內部,x 是一個左值!
    int* p = &x; // 這是合法的
}

左值引用

對左值的引用,即給左值起別名,必須初始化。

int a = 10;
int& ref = a;  // ref 是 a 的左值引用

const int& r3 = 10; // 正確!

std::cout << "a" << "b" << "c"; // 每次調用 << 都返回 cout 的左值引用

右值

沒有固定地址、沒有名字的值,通常是臨時結果、字面量或即將銷燬的對象。

10;                 // 純右值:字面量

a + b;              // 純右值:運算的中間結果,沒有名字

func(5);            // 如果 func 返回一個值(非引用),func(5) 就是右值

std::string("Hi");  // 純右值:臨時構造的匿名對象

右值引用

綁定到右值上。 它的符號是 &&

int&& r1 = 10;          // 正確:10 是右值

int a = 10;
int&& r3 = std::move(a); // 正確:std::move 把左值轉成了右值(將亡值)

int n = 5;
// int&& r = ++n; // 錯誤:++n 返回的是修改後的 n 本身(有地址)
int&& r = n++;    // 正確:n++ 返回的是一個臨時副本(舊值 5),n 本身已變

萬能引用&引用摺疊

template<typename T>
void func(T&& param); // 這是一個萬能引用
//場景 A:傳入左值變量 
int a = 10; 
func(a);

//因為 a 是左值,根據萬能引用的特殊推導規則,編譯器將 T 推導為 int&。
//為什麼不能是int;因為如果為 int,函數變為 void func(int && param) 這個函數只能接收右值,所以編譯器只能推導為int &
//代入模板:函數簽名變成 void func(int& && param);。
//引用摺疊:根據規則,int& && 含有左值引用,摺疊為 int&。
//最終形態:void func(int& param); —— 成功以左值引用的方式接收了變量。


//場景 B:傳入右值字面量 20
func(20);
//推導:因為 20 是右值,編譯器將 T 推導為 int。
//模板:函數簽名變成 void func(int&& param);。
//摺疊:沒有衝突,或者看作 int&&,保持 int&&。
//最終形態:void func(int&& param); —— 成功以右值引用的方式接收了臨時變量

移動語義

我們將一個臨時對象賦值給另一個對象時,會觸發深拷貝。比如一個包含 很多數據的 std::vector,拷貝它需要重新分配內存再複製數據,這非常耗時。

移動語義 允許我們直接獲取臨時對象的資源,只需修改指針指向,而不必重新分配內存。

#include <iostream>
#include <vector>
#include <utility>

class MyBuffer {
private:
    int* data; // 唯一的私有屬性

public:
    // 構造函數 explicit  防止隱式轉換   
    //MyBuffer b2 = 100;   ❌ 編譯錯誤,不能隱式轉換
    explicit MyBuffer(int value) : data(new int(value)) {
        std::cout << "分配內存並存入: " << *data << std::endl;
    }

    // 析構函數
    ~MyBuffer() {
        if (data) {
            delete data;
            data = nullptr;
            std::cout << "釋放內存" << std::endl;
        }
    }

    // ---------------------------------------------------------
    // 拷貝構造函數 (Copy Constructor) - 深拷貝
    // ---------------------------------------------------------
    MyBuffer(const MyBuffer& other) : data(other.data ? new int(*other.data) : nullptr) {
        std::cout << "深拷貝數據: " << (data ? *data : 0) << std::endl;
    }

    // ---------------------------------------------------------
    // 移動構造函數 (Move Constructor)
    // ---------------------------------------------------------
    MyBuffer(MyBuffer&& other) noexcept : data(other.data) {
        other.data = nullptr;
        std::cout << "資源所有權已轉移" << std::endl;
    }

    // ---------------------------------------------------------
    // 移動賦值運算符 (Move Assignment Operator)
    // ---------------------------------------------------------
    MyBuffer& operator=(MyBuffer&& other) noexcept {
        std::cout << "執行移動賦值" << std::endl;
        
        if (this != &other) {
            delete data;        // 1. 釋放當前對象持有的舊內存
        	data = other.data;  // 2. 接管新資源
        	other.data = nullptr;
        }
        return *this;
    }

    // 訪問器方法
    int getValue() const {
        return data ? *data : 0;
    }

    void setValue(int value) {
        if (data) {
            *data = value;
        } else {
            data = new int(value);
        }
    }

    // 檢查是否擁有資源
    bool isEmpty() const {
        return data == nullptr;
    }
};

int main() {
    std::cout << "=== 測試移動語義 ===" << std::endl;
    
    // 測試移動構造函數
    MyBuffer b1(100);
    MyBuffer b2(std::move(b1)); // 觸發移動構造
    // b1 現在是"有效但未指定狀態"
    std::cout << "b1是否為空: " << b1.isEmpty() << std::endl;
    std::cout << "b2的值: " << b2.getValue() << std::endl;
    
    // 測試移動賦值
    MyBuffer b3(300);
    MyBuffer b4(400);
    std::cout << "\n移動賦值前 - b3: " << b3.getValue() << ", b4: " << b4.getValue() << std::endl;
    b4 = std::move(b3);
    std::cout << "移動賦值後 - b3是否為空: " << b3.isEmpty() << std::endl;
    std::cout << "移動賦值後 - b4: " << b4.getValue() << std::endl;
    
    return 0;
}

out

root1@ubuntu:~/work/hello/build$ ./hello_cmake_g 
=== 測試移動語義 ===
分配內存並存入: 100
資源所有權已轉移
b1是否為空: 1
b2的值: 100

分配內存並存入: 300
分配內存並存入: 400

移動賦值前 - b3: 300, b4: 400
執行移動賦值
移動賦值後 - b3是否為空: 1
移動賦值後 - b4: 300
釋放內存
釋放內存

完美轉發

#include <iostream>
#include <utility>

void target(int& x) { 
	std::cout << "調用左值函數\n"; 
}
void target(int&& x) { 
	std::cout << "調用右值函數\n"; 
}

template <typename T>
void perfectForwarder(T&& arg) {
    // std::forward 會根據 T 的類型決定是 cast 成左值還是右值
    target(std::forward<T>(arg)); 
}

int main() {
    int a = 10;
    std::cout << "傳遞了右值\n"; 
    perfectForwarder(a);  
   
    std::cout << "傳遞了右值\n"; 
    perfectForwarder(20); 
}

out

root1@ubuntu:~/work/hello/build$ ./hello_cmake_g 
傳遞了右值
調用左值函數
傳遞了右值
調用右值函數

思考


++ii++的區別,為什麼要習慣性寫++i ?

  • 前置自增 ++i (左值)

    在 C++ 的底層實現中,前置自增的操作類似於:“先給這個內存地址裏的值加 1,然後把這個地址傳回去。”

    • 返回類型: 通常是引用類型(如 T&)。

    • 內存邏輯: 它直接在原變量上操作,不產生中間人。

    • 為什麼是左值: 因為它返回的是變量本身,它在表達式結束後依然存在,擁有確定的內存地址。

  • 後置自增 i++ (右值)

    後置自增的操作邏輯則複雜一些:“先把當前的值存到一個臨時地方,給原變量加 1,然後把剛才那個臨時值傳回去。”

    • 返回類型: 通常是按值返回(如 T)。

    • 內存邏輯: 產生了一個臨時對象(Temporary Object)

      • 為什麼是右值: 那個臨時副本在表達式執行完的那一刻就被銷燬了。它沒有持久的“身份”,你無法通過地址再次找到它,因此它是右值。

對於內置類型(如 int): 現代編譯器非常聰明,通常會把 i++ 優化掉,使兩者性能一致。

對於自定義類型(如迭代器 std::map::iterator): 區別極大。

  • ++i 直接修改內部指針並返回引用。
  • i++ 必須先調用拷貝構造函數創建一個副本,修改原值,最後返回那個副本。這個副本的創建和隨後的析構都是額外的開銷。


std::move 與 std::forward 的本質區別?

  • std::move<T>(x)無條件轉換。不管 x 是什麼,通通強制轉為右值引用。
  • std::forward<T>(x)有條件轉換。只有當 T 被推導為右值引用時,才將其轉換為右值引用;否則保持左值屬性。


移動構造函數為什麼要加 noexcept

為了 STL 容器的異常安全性。std::vector 擴容需要搬移元素時,如果你的移動構造函數不聲明 noexceptvector 為了保證在搬移失敗時能回滾,會放棄使用高效的“移動語義”,轉而使用效率較低的“拷貝構造”。


user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.