C++ 逆向之 move 函式

lostin9772發表於2024-11-02

眾所周知,在 C++ 11 後,增加了右值引用型別,那麼函式引數傳遞一共有三種方式,分別是非引用型別傳遞(值傳遞)左值引用傳遞右值引用傳遞,其中值傳遞會對實參進行一份複製傳遞給函式,左值引用和右值引用則直接引用實參傳遞給函式,這就是它們最大的區別。

為什麼要區分值傳遞和引用傳遞呢?對於一些小型的變數,或許沒有感覺有太大區別,但是對於一些大型的物件(比如容器、類物件等),執行一個複製操作是非常消耗效能的一件事情,而且我們可能有這種需求,比如整個專案中希望某個物件只存在一個例項,出於以上需求,因此就有了 std::move 函式的用武之地。

一、std::move 函式的作用

std::move 是 C++ STL 中的一個函式模板,它的主要作用有兩個:

  1. 所有權轉移:將一個物件的資源(例如動態分配的記憶體、檔案控制代碼等)轉移給另一個物件,從而避免不必要的複製操作,提高程式執行的效能和效率。
  2. 移動語義:將一個左值(或左值引用)轉化為右值引用,避免複製的同時,也能夠使得一個左值作為實參傳遞給只接受右值引用引數的函式。

在對 std::move 函式進行逆向之前,我們首先透過一個簡單的示例程式來對該函式的功能和特性有一個直觀的認識:

class MyClass
{
private:
	int* __value;    // 模擬類內部的資源

public:
	// 無參建構函式
	MyClass() 
	{
		std::cout << "呼叫了無參建構函式" << std::endl;
	};

	// 有參建構函式
	MyClass(int value) : __value(new int(value))    // 會新開闢記憶體並複製資源
	{
		std::cout << "呼叫了有參建構函式:MyClass(int value)," << 
			"MyClass 類中的資源 __value 指標的值為:" << __value << std::endl << std::endl;
	}

	// 解構函式
	~MyClass()
	{
		if (__value)    // 釋放資源記憶體
		{
			delete __value;
		}
		std::cout << "呼叫了解構函式:~MyClass()" << std::endl;
	}

	// 複製建構函式
	MyClass(const MyClass& other) : __value(new int(*other.__value))    // 會新開闢記憶體並複製資源
	{
		std::cout << "呼叫了複製建構函式:MyClass(MyClass& other)," <<
			"複製類中的資源 __value 指標的值為:" << __value << std::endl << std::endl;
	}

	// 賦值運算子過載
	MyClass& operator=(const MyClass& other)    // 會新開闢記憶體並複製資源
	{
		if (&other == this)    // 自我賦值直接返回,避免賦值開銷
		{
			return *this;
		}
		if (__value)    // 如果被賦值物件存在資源,則進行釋放
		{
			delete __value;
		}
		__value = new int(*other.__value);    // 進行資源賦值

		std::cout << "呼叫了過載後的賦值運算子:MyClass& operator=(MyClass& other)," <<
			"被賦值類中的資源 __value 指標的值為:" << __value << std::endl << std::endl;

		return *this;
	}

	// 移動建構函式
	MyClass(MyClass&& other) noexcept : __value(other.__value)
	{
		if (other.__value)
		{
			other.__value = nullptr;    // 轉移資源所有權
		}

		std::cout << "呼叫了移動建構函式:MyClass(MyClass&& other)," <<
			"被移動賦值類中的資源 __value 指標的值為:" << __value << std::endl << std::endl;
	}
};

在上面的類中,為了演示 std::move 的功能,我們分別新增了無參建構函式、有參建構函式、解構函式、複製建構函式、賦值運算子過載和移動建構函式。

首先我們來呼叫有參建構函式、複製建構函式以及演示賦值運算子過載:

// 呼叫有參建構函式,會新開闢記憶體並複製資源
MyClass myClass1(10);

// 呼叫複製建構函式,會新開闢記憶體並複製資源
MyClass myClass2(myClass1);

// 呼叫無參建構函式,並對構造物件進行賦值,會新開闢記憶體並複製資源
MyClass myClass3;
myClass3 = myClass1;    // MyClass myClass3 = myClass1; 該語句會呼叫複製建構函式

執行結果如下:
C++ 逆向之 move 函式

我們可以明顯的看出,有參建構函式、複製建構函式以及賦值運算子,都會申請記憶體並將原物件複製,產生效能上的開銷,然後我們新增移動建構函式進行對比:

// 呼叫移動建構函式,轉移資源的所有權,不會開闢新記憶體和複製資源
MyClass myClass4(std::move(myClass1));
//MyClass myClass4 = std::move(myClass1);

執行結果如下:
C++ 逆向之 move 函式

在移動建構函式中,我們實現了對原類物件所有權的轉移,並沒有出現開闢記憶體和複製資源的情況出現,提高了程式執行的效能,再者,在我們的移動建構函式中,形參為右值引用,而在例項中,我們透過 std::move 函式將左值 myClass1 傳遞給了只接受右值引用的移動建構函式。

二、左值、右值、左值引用和右值引用

在 C++ 中,左值是可以被取地址的表示式,它具有變數名,且在記憶體中具有永續性;而右值是臨時的、不可取地址的表示式,表示式結束後記憶體會被回收,該右值也不復存在;而左值引用和右值引用是分別為左值和右值取了別名。

例如:

int x = 10;

在這個表示式中,x 為左值,而 10 為右值,表示式結束後 x 依然存在,而右值 10 是臨時的。

我們繼續來擴充套件程式碼:

int x = 10;
int& y = x;    // 把 x 的地址傳給了左值引用 y
int&& z = std::move(x);    // 把 x 的地址轉化為右值傳給了右值引用 z

那麼現在問題就出現了:

  1. x、y 和 z 分別是左值、右值、左值引用還是右值引用?
  2. x、y 和 z 有區別嗎?

首先我們來看第一個問題,粗略一看,大家可能會以為 x 會被當做左值傳遞,y 會被當做左值引用傳遞,而 z 會被當做右值引用傳遞,那是不是真的是這樣子呢?我們來測試一下就知道了!

為了清楚地得看出他們分別是左值還是右值,我們新增兩個左右值形參函式,並進行列印測試:

void process(int& i) {
    std::cout << "Lvalue reference to " << i << std::endl;
}


void process(int&& i) {
    std::cout << "Rvalue reference to " << i << std::endl;
}

process(x);
process(y);
process(z);
process(10);

執行結果如下:
C++ 逆向之 move 函式

第一個、第二個和第四個結果都在意料之中,但是第三個結果就令人詫異了,明明我們定義的是一個右值引用變數,怎麼傳遞給形參裡面會被當做左值引用來傳遞呢?

為了搞清楚原理,我們可能有必要去深究一下底層的彙編程式碼,看看到底發生了什麼事情!

我們知道 process(int i) 函式是不能和 process(int& i)process(int&& i) 共存的,因為共同存在同一個名稱空間之內的話,編譯器分不清你是要進行值傳遞還是進行引用傳遞,從而產生錯誤:有多個過載函式例項與引數列表匹配,比如 process(10),既可以進行值傳遞呼叫 process(int i) 函式,也可以進行右值引用傳遞,呼叫 process(int&& i) 函式,那我們需要對兩種情況進行分開討論。

首先我們來討論 x、y 、z 和 10 進行值傳遞的時候,彙編層面發生了什麼操作:
應用層程式碼

void process(int i) {
    std::cout << "non reference to " << i << std::endl;
}
process(x);
process(y);
process(z);
process(10);

彙編層程式碼

    process(x);
00007FF7CA0E6D40  mov         ecx,dword ptr [x]  -> [x]=0xA,對 x 的值進行了複製
00007FF7CA0E6D43  call        process (07FF7CA0E1546h)  
    process(y);
00007FF7CA0E6D48  mov         rax,qword ptr [y]  -> [y]=0x0000005CB58FFAB4,這個是 x 的地址
00007FF7CA0E6D4C  mov         ecx,dword ptr [rax]  -> 透過 x 的地址得到 x 值並對其進行複製
00007FF7CA0E6D4E  call        process (07FF7CA0E1546h)  
    process(z);
00007FF7CA0E6D53  mov         rax,qword ptr [z]  -> [z]=0x0000005CB58FFAB4,這個是 x 的地址   
00007FF7CA0E6D57  mov         ecx,dword ptr [rax]  -> 透過 x 的地址得到 x 值並對其進行複製  
00007FF7CA0E6D59  call        process (07FF7CA0E1546h)  
    process(10);
00007FF7CA0E6D5E  mov         ecx,0Ah    -> 直接對引數 10 進行了複製  
00007FF7CA0E6D63  call        process (07FF7CA0E1546h) 

透過分析我們可以得出兩個結論:

  1. 不管是 x、y、z 還是 10,在進行值傳遞的時候,底層都會發生複製的情況
  2. 細心地朋友會發現,左值 x 在進行值傳遞的時候是直接對 x 進行複製的,右值 10 在進行值傳遞的時候是直接對 10 進行複製的,但是左值引用 y 和右值引用 z 中儲存的是 x 的地址,在進行值傳遞的時候,先得到 x 的地址,然後再透過 x 的地址得到 x 的值,最後對 x 的值進行複製

然後我們繼續看當 x、y、z 和 10 被作為引用傳遞的時候會發生什麼情況:
應用層程式碼

void process(int& i) {
    std::cout << "Lvalue reference to " << i << std::endl;
}


void process(int&& i) {
    std::cout << "Rvalue reference to " << i << std::endl;
}
process(x);
process(y);
process(z);
process(10);

彙編層程式碼

    process(x);
00007FF63DAA24B0  lea         rcx,[x]  -> rax=0x000000B3478FFC14,直接取變數 x 的地址作為函式引數   
00007FF63DAA24B4  call        move<int> (07FF63DAA154Bh)  
    process(y);
00007FF63DAA24B9  mov         rcx,qword ptr [y]  -> [y]=0x000000B3478FFC14,其實也是直接取的變數 x 的地址作為函式引數
00007FF63DAA24BD  call        move<int> (07FF63DAA154Bh)  
    process(z);
00007FF63DAA24C2  mov         rcx,qword ptr [z]  -> [z]=0x000000B3478FFC14,其實也是直接取的變數 x 的地址作為函式引數
00007FF63DAA24C6  call        move<int> (07FF63DAA154Bh) 
    process(10);
00007FF63DAA24CB  mov         dword ptr [rbp+124h],0Ah  -> 實參 10 作為一個右值引用存放在了棧上作為函式引數,這個儲存位置是臨時的,存放一個右值 10 
00007FF63DAA24D5  lea         rcx,[rbp+124h]  -> 將棧上這個臨時存放的右值 10 的棧地址傳遞給函式作為函式引數  
00007FF63DAA24DC  call        process (07FF63DAA1550h) 

透過對以上程式碼進行分析,我們得出兩個結論:

  1. x、y、z 都作為左值引用傳遞,而 10 作為右值引用傳遞
  2. 不管是左值引用傳遞還是右值引用傳遞,在彙編層面都沒有發生值的複製操作,提高了程式執行的效能

到這裡我們還是沒有解決右值引用 z 為什麼會被當做左值引用傳遞的問題,繼續分析,我們分別對它們儲存的值以及地址進行列印:

int x = 10;
int& y = x;    // 把 x 的地址傳給了左值引用 y
int&& z = std::move(x);    // 把 x 的地址轉化為右值傳給了右值引用 z
printf("x: %d\r\n", x);  
printf("&x: %p\r\n", &x);
printf("y: %d\r\n", y);
printf("&y: %p\r\n", &y);
printf("z: %d\r\n", z);    
printf("&z: %p\r\n", &z);

執行結果如下:
C++ 逆向之 move 函式

透過這個結果可以看出,在應用層看來,x、y 和 z 是完全等價的,我們知道一個變數是由記憶體地址和其對應地址中的值組成的,而 x、y 和 z 表示的記憶體地址和其對應地址中的值又完全相等,雖然左值 x 和 左值引用 y、右值引用 z 在彙編層面的操作有所不同(彙編層面左值引用 y 和右值引用 z 儲存的其實是 x 的地址),但是應用層遮蔽了這一差異,所以才會有引用是給變數取了一個別名的說法,其實代表的是同一個變數,都可以被取地址、都有變數名且在記憶體中具有永續性,只要 x 不消失,y 和 z 就不會消失,所以這就解釋了為什麼我們定義的右值引用 z 會被當做左值引用傳遞給函式。

到這裡其實第二個問題:x、y 和 z 有區別嗎?,也迎刃而解,x 和 y、z 在彙編層面的操作是有差異,但是在應用層面遮蔽了這一差異,所以應用層 x、y、z 完全等價,連語義都是等價的,x、y、z 作為引數傳遞的時候都會被當做左值。

那麼如果我們有需求一定要將 x、y、z 都被當做右值傳遞,呼叫右值引用作為引數的過載函式例項應該怎麼做呢?我們可以如下操作:

int x = 10;
int& y = x;    // 把 x 的地址傳給了左值引用 y
int&& z = std::move(x);    // 把 x 的地址轉化為右值傳給了右值引用 z

process(std::move(x));
process(std::move(y));
process(std::move(z));
process(10);

執行結果如下:
C++ 逆向之 move 函式

三、逆向 std::move 函式

透過上面的演示,我們知道了 std::move 函式能夠將左值、左值引用轉為右值引用,那麼底層到底是如何實現的呢?

我們揪出 VS 編譯器中底層對 std::move 函式的定義:

template <class _Ty>
struct remove_reference {
    using type = _Ty;    // 如果引數是非引用型別,則直接返回非引用型別本身 _Ty
    using _Const_thru_ref_type = const _Ty;
};

template <class _Ty>
struct remove_reference<_Ty&> {
    using type = _Ty;    // 如果引數是左值引用,則移除左值引用,返回其底層的非引用型別(引數為 int& 則返回 int)
    using _Const_thru_ref_type = const _Ty&;
};

template <class _Ty>
struct remove_reference<_Ty&&> {
    using type = _Ty;    // 如果引數是右值引用,則移除右值引用,返回其底層的非引用型別(引數為 int&& 也返回 int)
    using _Const_thru_ref_type = const _Ty&&;
};

template <class _Ty>
using remove_reference_t = typename remove_reference<_Ty>::type;    // 移除左值或右值引用,返回其底層的非引用型別

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);
}
  • _NODISCARD:這是一個屬性,用於告訴編譯器,這個函式的返回值不應該被忽略。如果呼叫者忽略了返回值,編譯器會發出警告。
  • constexpr:表示這個函式可以在編譯時計算,並且可以用於常量表示式中。
  • noexcept:表示這個函式保證不會丟擲異常。
  • remove_reference_t<_Ty>:透過程式碼可知,如果 _Ty 為 int& 或 int&&,則 remove_reference_t<_Ty> 等價於 int

其他的程式碼我相信讀者都能看懂,值得一提的是,std::move 函式功能實現的精髓,正是我們接下來要介紹的兩個主角:結構體模板 remove_reference_t 和 萬能引用 &&

首先我們來看結構體模板 remove_reference_t,它等價於 typename remove_reference<_Ty>::type,而關鍵字 typename 是告訴編譯器 remove_reference<_Ty>::type 是一個依賴於模板引數 _Ty 的型別,當我們有一個巢狀從屬型別(即依賴於模板引數的型別)時,我們需要使用關鍵字 typename 來明確告訴編譯器。

那麼我們剔除關鍵字 typename 來繼續研究 remove_reference<_Ty>::type,它對應有三個結構體模板,分別對應 _Ty 為非引用型別(原始型別)、左值引用型別和右值引用型別,對應的功能如下:

  1. remove_reference:當我們傳入的 _Ty 為非引用型別(原始型別),例如 int,則返回非引用型別本身 int
  2. remove_reference<_Ty&>:當我們傳入的 _Ty 為左值引用型別,例如 int&,則返回其底層非引用型別 int
  3. remove_reference<_Ty&&>:當我們傳入的 _Ty 為右值引用型別,例如 int&&,則返回其底層非引用型別 int

所以,remove_reference_t<_Ty> 的功能其實是移除左值或右值引用,返回其底層的非引用型別,我們可以看如下示例:

 // 非引用型別(值傳遞)、左值引用和右值引用
remove_reference_t<int> d = 40;
remove_reference_t<int&> e = 50;
remove_reference_t<int&&> f = 60;
std::cout << "remove_reference_t<int>:d=" << d << " e=" << e << " f=" << f << std::endl;

remove_reference<int>::type a = 10;    // 非引用型別使用版本
remove_reference<int&>::type b = 20;    // 左值引用使用左值特例版本
remove_reference<int&&>::type c = 30;    // 右值引用使用右值特例版本
std::cout << "remove_reference<int>::type:a=" << a << " b=" << b << " c=" << c << std::endl;

執行結果如下:
C++ 逆向之 move 函式

可以看到 remove_reference_t<_Ty> 確實移除了 _Ty 型別的左右值引用,所以當 _Tyint&int&& 的時候 return static_cast<remove_reference_t<_Ty>&&>(_Arg); 語句其實等價於 return static_cast<int&&>(_Arg),即都不管 _Ty 是左值引用(int&)還是右值引用(int&&)型別,都返回其底層的非引用型別(int)。

那既然不管我們傳入結構體模板的是左值引用還是右值引用都返回其底層的非引用型別,那麼為什麼只有 move(_Ty&& _Arg) 一個右值引用的特例版本呢,翻遍原始碼也沒有找到 std::move 的左值引用特例版本。

接下來就輪到我們萬能引用 T&& 發力了!在模板中,萬能引用 T&& 既能接受左值引用,又能接受右值引用,這就是 C++ 11 引入的新特性--引用摺疊。

當我們將引用型別傳遞到模板引數或涉及型別推倒的場景時,就可能會出現引用巢狀的情況,比如我們剛剛討論的,當 _Ty 的型別為 int&& 時,傳入到結構體模板中的 _Ty&& 就變成了 int&&&&,那這種情況就是透過引用摺疊來處理的,摺疊的規則如下:

  1. T& + & = T&
  2. T& + && = T&
  3. T&& + & = T&
  4. T&& + && = T&&

其實記憶起來也非常方便,只有右值引用(&&)傳遞到萬能引用 T&& 中才會摺疊為右值引用,其他的情況都會別摺疊為左值引用!

所以 std::move 既能接受左值引用,也能接受右值引用,且統一返回左值引用!

為了證實我們的觀點,我們也來手搓一個偽造的 std::move 函式:

// 偽造的 std::move() 函式
template <class T>
remove_reference_t<T>&& fake_move(T&& arg)
{
    return static_cast<remove_reference_t<T>&&>(arg);
}

int x = 10;

// std::move 函式演示
std::cout << "原始的 std::move() 函式" << std::endl;
process(std::move(x));
process(std::move(20));
std::cout << std::endl;

std::cout << "從原始碼中擷取出來的 move() 函式" << std::endl;
process(move(x));
process(move(20));
std::cout << std::endl;

std::cout << "偽造的 fake_move() 函式" << std::endl;
process(fake_move(x));
process(fake_move(20));
std::cout << std::endl;

執行結果如下:
C++ 逆向之 move 函式

相關文章