前言
學習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:這是一個類型萃取工具。無論T是int、int&還是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) 是:在函數模板中,將參數原封不動地轉發給另一個函數,同時完全保留參數的所有屬性(包括它是左值還是右值、是否帶有
const或volatile修飾符)。template <typename T> T&& forward(typename std::remove_reference<T>::type& t) noexcept { return static_cast<T&&>(t); }-
如果原始參數是左值,轉發後仍然是左值。
-
如果原始參數是右值,轉發後仍然是右值。
-
-
萬能引用
- 使用
&&符號 - 必須發生模板類型推導:通常出現在
template <typename T>之後的T&&(不能加const)。 - 形式必須完全匹配:必須是具體的
T&&,不能有const或std::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
傳遞了右值
調用左值函數
傳遞了右值
調用右值函數
思考
++i 和 i++的區別,為什麼要習慣性寫++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 擴容需要搬移元素時,如果你的移動構造函數不聲明 noexcept,vector 為了保證在搬移失敗時能回滾,會放棄使用高效的“移動語義”,轉而使用效率較低的“拷貝構造”。