C++易被忽略的知識點:移動語義 & 左值右值

目錄

lvalue 和 rvalue

rvalue 引用

移動語義

移動語義的概念

強制移動


lvalue 和 rvalue

每個表達式都會得到 lvalue 或 rvalue。它們的區別是,lvalue是一個持久存在的值,其內存地址可被用來持續存儲值;rvalue是一個暫時存儲的結果。之所以稱爲 lvalue,是因爲得到 lvalue 的表達式通常出現在賦值運算符的左邊,而 rvalue 只能出現在賦值運算符的右邊。表達式的結果不是 lvalue,就是 rvalue。包含一個變量名稱的表達式總是 lvalue。

注意:雖然名稱中帶有 value,但是 lvalue 和 rvalue 是表達式分類,而不是值分類。

 

int a{}, b{1}, c{-2};
a = b + c;
double r = std::abs(a * c);
auto p = std::pow(r, std::abs(c));

第一條語句把 a、b和c 定義成 int 類型,分別初始化爲0、1 和 -2。之後,名稱a、b 和 c 都是 lvalue。

第二條語句中,會臨時存儲 b+c 的結果,並且複製到 a 中。執行完該語句後,就捨棄保存 b+c 結果的空間。因此,b+c 是 rvalue。

涉及函數時,存在臨時結果這一點就更加明顯了。例如,在第三條語句中,a*c 被計算,作爲臨時值存儲在內存中的某個位置。然後,這個臨時結果作爲實參被傳遞給 std::abs() 函數。這使得 a*c 成爲 rvalue。std::abs() 自己返回的值也是臨時的。它只是存在一小段時間,直到被隱式轉換爲一個 double 值。

對於第四條語句的兩個函數調用,也是同樣的道理。例如,std::abs() 返回的值顯然只臨時存在,以用作 std::abs() 的實參。

注意:大部分函數調用表達式都是rvalue。只有返回引用的函數調用是 lvalue。返回引用的函數調用可出現在內置賦值運算符的左側,說明它們是 lvalue,典型的容器的下標運算符 (operator[]()) 和 at() 函數是很好的例子。例如,如果 v 是 vector<int>,那麼 v[1] = 1; 和v.at(2) = 2; 都是完全有效的語句。顯然,它們是 lvalue。                                                                                          源自:《C++17入門經典》

 

觀察以下實例,判斷給定表達式是 lvalue 還是 rvalue:

int *x = &(a+b);
int *y = &std::abs(a*d);
int *z = &123;
int *w = &a;
int *u = &v.at(2);

當週圍語句完成執行後,存儲表達式 b+c 和 std::abs() 結果的內存將立即回收。如果允許它們存在,指針 x 和 y 將成爲懸掛指針,沒有人能夠查看它們。這意味着這些表達式是 rvalue。所有數值字面量是 rvalue,而編譯器不允許獲取數值字面量的地址。

對於基本類型的表達式,lvalue 和 rvalue 的區別一般不重要。對於類類型的表達式,這種區別比較重要;而且即使是類類型表達式,也只有在某些情況下才重要,例如,把表達式傳遞給函數,且函數有專門定義爲接收 rvalue 表達式結果的重載時,或者當在容器中存儲對象時。區分左值和右值的一個簡單辦法是:看能不能對錶達式取地址,如果能,則爲左值,否則爲右值。

#include <iostream>
#include <string>

using namespace std;

char &get_val(string &str, string::size_type ix)
{
	return str[ix];
}

char *get_val1(char a[], char *b)
{
	b = &a[0];
	return b;
}

int main()
{
	string s("a value");
	cout << s << endl;

	get_val(s, 0) = 'A';
	cout << s << endl;
	
	return 0;
}

輸出:
a value
A value

 

rvalue 引用

引用是一個名稱,可用作其他某個事物的別名。但是,實際上有兩種類型的引用:lvalue 引用和 rvalue 引用。在多數情況下使用的引用是 lvalue 引用。通常,lvalue 引用是另外一個變量的別名;之所以叫作 lvalue 引用,是因爲它通常引用一個持久的數據存儲位置,可以出現在賦值運算符的左邊。

rvalue 引用也可以是變量的別名,這一點和 lvalue 引用相同,但與 lvalue 引用不同,rvalue 引用能夠引用一個 rvalue 表達式的結果,即使這個值一般來說是臨時的。綁定到 rvalue 引用,就延長了這種臨時值的生存期。只要 rvalue 引用還在作用域內,用於臨時值的內存就不會被丟棄。要指定 rvalue 引用,需要在類型名的後面使用兩個 & 符號,例如:

int count {5};
int && rtemp {count + 3};
std::cout << rtemp <<std::endl;
int & rcount {count};

這段代碼可以編譯並運行,但不是使用 rvalue 引用的正確方式,這段代碼僅僅演示了 rvalue 引用的含義。

 

 

移動語義

移動語義的概念

如果想避免高開銷的複製,可以使用引用或指針。不過,從C++11開始,有了另一種新的更強大的方法,現在不只能夠複製對象,還可以移動對象。移動語義允許高效地將一個對象傳輸到另一個對象,不進行深層複製。在C++11 之前的複製過程:

vector<string> vstr;
//創建一個含有20000字符串的vector,每個字符串含有1000個字符。
......
vector<string> vstr_copy1(vstr);

vector 和 string 類都使用動態內存分配,因此它們必須定義使用某種 new 版本的複製構造函數。爲初始化對象 vstr_copy1,複製構造函數 vector<string> 將使用 new 給 20000 個 string 對象分配內存,而每個 string 對象又將調用 string 的複製構造函數,該構造函數使用 new 爲 1000 個字符分配內存。接下來,全部兩千萬個字符都將從 vstr 控制的內存複製到 vstr_copy1 控制的內存中,這裏的工作量是很大的。移動語義

考慮以下情況:

vector(string) allcaps(const vector<string> & vs)
{
    vector<string> temp;
    return temp;
}

假設這樣使用它:
vector<string> vstr;
vector<string> vstr_copy1(vstr);               // 1
vector<string> vstr_copy2( allcaps(vstr) );    // 2

從表面上看,語句 1和語句 2類似,它們都使用一個現有的對象初始化一個 vector<string> 對象。如果深入探索這些代碼,將發現allcaps() 創建了對象 temp,該對象管理兩千萬個字符;vector 和 string 的複製構造函數創建這兩千萬個字符的副本,然後程序刪除allcaps() 返回的臨時對象。這可不是一個小小的臨時對象啊!

如果編譯器將對數據的所有權直接轉讓給 vstr_copy2,不是更好嗎?也就是說,不將兩千萬個字符複製到新的地方,再刪除原來的字符,而是將字符留在原來的地方,並將 vstr_copy2 與之相關聯。這種方法被稱爲移動語義(move semantics),但不要被它的名字所迷惑,移動語義實際上避免了移動原始數據,而只是修改了記錄。

要實現移動語義,需要採取某種方式讓編譯器知道什麼時候需要複製,什麼時候不需要。這就是右值引用發揮作用的地方。可定義兩個構造函數,其中一個是常規的複製構造函數,它使用 const 左值引用作爲參數,這個引用關聯到左值實參,如語句 1中的 vstr ;另一個是移動構造函數,它使用右值引用作爲參數,該引用關聯到右值實參,如語句 2中的 allcaps(vstr) 的返回值。賦值構造函數可執行深複製,而移動構造函數只調整記錄。在將所有權轉移給新對象的過程中,移動構造函數可能修改其實參,這意味着右值引用參數不應是cosnt。

瞭解完需求及概念後,爲了使用移動構造函數,並分析它與複製構造函數的不同。以下代碼在各個構造函數中添加了輸出信息的語句,用於區分不同構造函數的調用情況。

移動構造函數示例代碼:

#include <iostream>
#include <string>
using namespace std;

class Asd
{
	private:
		int n;                    //元素的數量
		char * pc;                //指向數據
		static int numsum;        //對象的數量
		void Showobject() const;  //展示對象信息,元素數量及地址

	public:
		Asd();
		explicit Asd(int k);
		Asd(int k, char ch);

		Asd(const Asd &f);      //copy constructor  複製構造函數
		Asd(Asd &&f);           //move constructor  移動構造函數

		~Asd();
		Asd operator +(const Asd &f) const;
		void Showdata() const;
};

int Asd::numsum = 0;

Asd::Asd()
{
	++numsum;
	n = 0;
	pc = NULL;
	cout << "Default constructor, number of objects:" << numsum <<endl;
	Showobject();
}

Asd::Asd(int k) : n(k)
{
	++numsum;
	cout << "Int constructor, number of objects:" << numsum <<endl;
	pc = new char[n];
	Showobject();
}

Asd::Asd(int k, char ch) : n(k)
{
	++numsum;
	cout << "Int, char constructor, number of objects:" << numsum <<endl;
	pc = new char[n];
	for(int i = 0; i < n; i++)
	{
		pc[i] = ch;
	}
	Showobject();
}

Asd::Asd(const Asd &f) : n(f.n)
{
	++numsum;
	cout << "Copy constructor, number of objects:" << numsum <<endl;
	pc = new char[n];
	for (int i = 0; i < n; i++)
	{
		pc[i] = f.pc[i];
	}
	Showobject();
}

Asd::Asd(Asd &&f) : n(f.n)
{
	++numsum;
	cout << "Move constructor, number of objects:" << numsum <<endl;
	pc = f.pc;
	f.pc = NULL;
	f.n = 0;
	Showobject();
}

Asd::~Asd()
{
	cout << "Destructor, number of objects:" << --numsum <<endl;
	cout << "Delete object:\n";
	Showobject();
	cout << "End" <<endl;
	delete [] pc;
}

Asd Asd::operator +(const Asd &f)const
{
	cout << "Entering Operator+() \n";
	Asd temp = Asd(n + f.n);
	for (int i = 0; i < n; i++)
		temp.pc[i] = pc[i];
	for (int i = n; i < temp.n; i++)
		temp.pc[i] = f.pc[i-n];
	cout << "Leaving Operator+() \n";
	return temp;
}	

void Asd::Showobject() const
{
	cout << "Number of elements:" << n;
	cout << " Data address: " << (void *)pc <<endl;
}

void Asd::Showdata() const
{
	if (n == 0)
		cout << "Empty";
	else
		for(int i = 0; i < n; i++)
			cout << pc[i];
	cout <<endl;
}

int main()
{
	{
		Asd one(10, 'x');
		Asd two = one;
		Asd three(10, 'o');
		Asd four(one + three);   //這裏調用了operator+(),移動構造函數
		cout << "one:";
		one.Showdata();
		cout << "two:";
		two.Showdata();
		cout << "three:";
		three.Showdata();
		cout << "four:";
		four.Showdata();
	}
	return 0;
}

對象 two 是對象 one 的副本,它們的數據是相同的,但是數據的地址不一樣。另一方面,在方法 operator+() 中創建的對象的數據地址與對象 four 存儲的數據地址相同,而對象 four 是由移動構造函數創建的。另外,在創建對象 four 後,爲臨時對象調用了析構函數。之所以是臨時對象,因爲它們的元素數和數據地址都是0。

如果不關閉 RVO (返回值優化),是看不到調用移動構造函數的。一般不用太在意這一點,這是因爲編譯器進行優化的結果與未優化時的結果相同。

 

移動構造函數

雖然使用右值引用可以支持移動語義,但這並不會自己神奇的發生。要想完成相應的功能,需要兩個步驟。

(1) 右值引用讓編譯器知道何時使用移動語義

Asd two = one;            //匹配複製構造函數
Asd four(one + three);    //匹配移動構造函數

對象 one 是左值,與左值引用相匹配,而表達式 one+three 是右值,與右值引用匹配。因此,右值引用讓編譯器使用移動構造函數來初始化對象 four。

 

(2) 編寫移動構造函數

Asd::Asd(Asd &&f) : n(f.n)
{
    ++numsum;
    pc = f.pc;         // pc指向f.pc
    f.pc = NULL;       //f.pc指向空,這樣就可以避免調用析構函數時對同一個空間釋放兩次。
    f.n = 0;
    Showobject();
}

 

賦值

適用於構造函數的移動語義也適用於賦值運算符。例如:

(1) 複製賦值運算符

Asd Asd::operator =(const Asd &f)
{
    if (this == &f)
        return *this;
    delete [] pc;
    n = f.n;
    pc = new char[n];
    for (int i = 0; i < n; i++)
        pc[i] = f.pc[i];
    return *this;
}

 

(2) 移動賦值運算符

Asd Asd::operator =(Asd && f)
{
    if (this == &f)
        return *this;
    delete [] pc;
    n = f.n;
    pc = f.pc;
    f.n = 0;
    f.pc = nullptr;
    return *this;
}

 

強制移動

移動構造函數和移動賦值運算符使用右值。如果要讓它們使用左值,該怎麼辦?例如,程序可能分析一個包含候選對象的數組,選擇其中一個對象使用,並丟棄數組。如果可以使用移動構造函數或移動賦值運算符來保留選定的對象,那該多好啊。然而,若按以下方式做:

Asd test[10];
Asd best;
int pick;
...
best = test[pick];

由於 test[pick] 是左值,因此會使用複製賦值運算符,而不是移動賦值運算符。但如果讓 test[pick] 看起來像右值,便將使用移動賦值運算符。爲此,可使用運算符 static_cast<> 將對象的類型強制轉換成 Asd &&,但自C++11提供了一種更簡單的方式——使用 std::move()函數。

代碼如下:

#include <iostream>
#include <string>
using namespace std;

class Asd
{
	private:
		int n;                     //元素的數量
		char * pc;                 //指向數據
		static int numsum;        //對象的數量
		void Showobject() const;  //展示對象信息,元素數量及地址

	public:
		Asd();
		explicit Asd(int k);
		Asd(int k, char ch);

		Asd(const Asd &f);        //copy constructor  複製構造函數
		Asd(Asd &&f);             //move constructor  移動構造函數

		~Asd();
		Asd operator +(const Asd &f) const;
		Asd & operator =(const Asd &f);
		Asd & operator =(Asd &&f);
		void Showdata() const;
};

int Asd::numsum = 0;

Asd::Asd()
{
	++numsum;
	n = 0;
	pc = NULL;
}

Asd::Asd(int k) : n(k)
{
	++numsum;
	pc = new char[n];
}

Asd::Asd(int k, char ch) : n(k)
{
	++numsum;
	pc = new char[n];
	for(int i = 0; i < n; i++)
		pc[i] = ch;
}

Asd::Asd(const Asd &f) : n(f.n)
{
	++numsum;
	pc = new char[n];
	for (int i = 0; i < n; i++)
		pc[i] = f.pc[i];
}

Asd::Asd(Asd &&f) : n(f.n)
{
	++numsum;
	pc = f.pc;
	f.pc = NULL;
	f.n = 0;
}

Asd::~Asd()
{
	delete [] pc;
}

Asd Asd::operator +(const Asd &f)const
{
	Asd temp = Asd(n + f.n);
	for (int i = 0; i < n; i++)
		temp.pc[i] = pc[i];
	for (int i = n; i < temp.n; i++)
		temp.pc[i] = f.pc[i-n];
	return temp;
}	

Asd & Asd::operator =(const Asd &f)
{
	cout << "Copy assignment:\n";
	if (this == &f)
		return *this;
	delete [] pc;
	n = f.n;
	pc = new char[n];
	for (int i = 0; i < n; i++)
		pc[i] = f.pc[i];
	return *this;
}	

Asd & Asd::operator =(Asd && f)
{
	cout << "Move assignment:\n";
	if (this == &f)
		return *this;
	delete [] pc;
	n = f.n;
	pc = f.pc;
	f.n = 0;
	f.pc = nullptr;
	return *this;
}

void Asd::Showobject() const
{
	cout << "Number of elements:" << n;
	cout << " Data address: " << (void *)pc <<endl;
}

void Asd::Showdata() const
{
	if (n == 0)
		cout << "Empty";
	else
		for(int i = 0; i < n; i++)
			cout << pc[i];
	cout <<endl;
}

int main()
{
	{
		Asd one(10, 'x');
		Asd two = one + one;
		cout << "One:";
		one.Showdata();
		cout << "Two:";
		two.Showdata();
		Asd three, four;

		cout << "Three = One:\n";
		three = one;
		cout << "Now, object three:";
		three.Showdata();

		cout << "and object one = ";
		one.Showdata();

		cout << "Four = one + two\n";
		four = one + two;               //移動賦值運算符

		cout << "Now, object four = ";
		four.Showdata();

		cout << "four = move(one)\n";
		four = move(one);

		cout << "Now, object four = ";
		four.Showdata();

		cout << "and object one = ";
		one.Showdata();

	}
	return 0;
}

從上面實驗可看出,將 one 賦給 three 調用了複製賦值運算符,但將 move(one) 賦給 four 調用的是移動賦值運算符。對大多數程序員來說,右值引用帶來的主要好處並非是讓他們能夠編寫使用右值引用的代碼,而是能夠使用利用右值引用實現移動語義的庫代碼。例如,STL類現在都有複製構造函數、移動構造函數、複製賦值運算符和移動賦值運算符。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章