目錄
- 前言
- 一、右值引用與移動語義
- 1.1 左值引用和右值引用
- 1.2 右值引用使用場景和意義
- 1.3 右值引用引用左值及其一些更深入的使用場景分析
- 1.3.1 完美轉發
- 二、新的類功能
- 三、可變參數模板
前言
本篇文章我們繼續來聊聊C++11新增的一些語法——右值引用,我們在之前就已經講過了左值引用,并且左值引用給我們帶來了很多的好處直接減少了拷貝操作提高了效率,那么右值引用到底起什么作用呢?下面我們一起來學習吧!!
一、右值引用與移動語義
1.1 左值引用和右值引用
有關引用我們在之前的文章就講過,它其實就是給別人取別名,所以無論是左值引用還是右值引用都是給別人取別名,只不過取別名的對象不一樣罷了,左值引用就是給左值取別名,右值引用就是給右值取別名!!
什么是左值?什么是左值引用?
左值就是一個表示數據的表達式(如變量名或者解引用的指針),說的再通俗一點它是一個變量,標識一塊空間,空間中存儲著數據。我們可以獲取它的地址+可以對它賦值,左值可以出現賦值符號的左邊,右值不能出現在賦值符號左邊。定義時const修飾符后的左值,不能給他賦值,但是可以取它的地址。左值引用就是給左值的引用,給左值取別名。
int main()
{// 以下的p、b、c、*p都是左值int* p = new int(0);int b = 1;const int c = 2;// 以下幾個是對上面左值的左值引用int*& rp = p;int& rb = b;const int& rc = c;int& pvalue = *p;return 0;
}
什么是右值?什么是右值引用?
右值也是一個表示數據的表達式,如:字面常量、表達式返回值,函數返回值(臨時對象不能左值返回)等等,說的再通俗一點右值就是一個常量,它沒有變量去標識,因此也就不能取到它的地址,它只是一個數據!!右值引用就是對右值的引用,給右值取別名。
int main()
{double x = 1.1, y = 2.2;// 以下幾個都是常見的右值10;x + y;fmin(x, y);// 以下幾個都是對右值的右值引用int&& rr1 = 10;double&& rr2 = x + y;double&& rr3 = fmin(x, y);// 下面的語句都會編譯報錯,左操作數必須為左值//10 = 1;//x + y = 1;//fmin(x, y) = 1;return 0;
}
需要注意的是:右值是不能取地址的,但是給右值取別名后,會導致右值被存儲到特定位置,且可以取到該位置的地址。也就是說,不能取字面量 10 的地址,但是 rr1 引用后,可以對 rr1 取地址,也可以修改 rr1。如果不想 rr1 被修改,可以用 const int&& rr1 去引用。注:rr1 和 rr2 都是左值。
左值可以引用右值嗎?右值可以引用左值嗎?
int main()
{// 左值引用可以引用右值嗎? const的左值引用可以double x = 1.1, y = 2.2;//double& rr1 = x + y; // 編譯報錯, x + y是右值, 也是臨時對象, 臨時對象具有常屬性, 需要const保證權限平行const double& rr2 = x + y; // 可以// 右值引用可以引用左值嗎?不可以, 可以引用move以后的左值int a = 10;//int&& rr3 = a; // 編譯報錯, 右值引用不能引用左值int&& rr4 = 10; // 可以, 10是右值, 對右值取別名int&& rr5 = move(a); // move(a)的本質是得到一個右值表達式return 0;
}
左值引用與右值引用總結:左值引用只能引用左值,不能引用右值。但是 const 左值引用既可引用左值,也可引用右值。右值引用只能引用右值,不能引用左值,但是右值引用可以 move 以后的左值。
其實上面并不是右值引用的使用場景,因為const左值引用既能做到對左值引用又能做到對右值引用,那么增加右值引用不就是多余的嗎!!下面我們繼續來看看右值引用的使用場景。
1.2 右值引用使用場景和意義
左值引用解決的問題:
做參數:a. 減少拷貝,提高效率。b. 做輸出型參數
做返回值:a. 減少拷貝,提高效率(不能返回臨時對象的引用)。
從上述中我們可以知道左值引用的短板就是不能返回臨時對象的引用,那么對于自定義類型對象返回時,是不是一定會進行一次深拷貝的操作,所以右值引用設計出來就是為了解決此類問題的。
在講右值引用前,我們先來了解一組概念:右值有兩類,第一類是純右值,即內置類型右值;第二類是將亡值,即自定義類型右值。右值將亡值的資源可以轉移到指定的對象。
就好比日常生活中,有些重癥患者已經到了病入膏肓的情況,它可以將自身的一些器官捐贈給有需要的人身上。右值引用+移動語義最重要的是理解資源轉移的過程!
下面我們通過一段代碼來進行分析:
namespace bit
{class string{public:typedef char* iterator;iterator begin(){return _str;}iterator end(){return _str + _size;}string(const char* str = ""):_size(strlen(str)), _capacity(_size){_str = new char[_capacity + 1];strcpy(_str, str);}// s1.swap(s2)void swap(string& s){::swap(_str, s._str);::swap(_size, s._size);::swap(_capacity, s._capacity);}// 拷貝構造string(const string& s):_str(nullptr){cout << "string(const string& s) -- 深拷貝" << endl;string tmp(s._str);swap(tmp);}// 賦值重載string& operator=(const string& s){cout << "string& operator=(string s) -- 深拷貝" << endl;string tmp(s);swap(tmp);return *this;}~string(){delete[] _str;_str = nullptr;}char& operator[](size_t pos){assert(pos < _size);return _str[pos];}void reserve(size_t n){if (n > _capacity){char* tmp = new char[n + 1];strcpy(tmp, _str);delete[] _str;_str = tmp;_capacity = n;}}void push_back(char ch){if (_size >= _capacity){size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;reserve(newcapacity);}_str[_size] = ch;++_size;_str[_size] = '\0';}string& operator+=(char ch){push_back(ch);return *this;}string operator+(char ch){string tmp(*this);tmp += ch;return tmp;}const char* c_str() const{return _str;}private:char* _str;size_t _size;size_t _capacity; // 不包含最后做標識的\0};bit::string to_string(int value){bool flag = true;if (value < 0){flag = false;value = 0 - value;}bit::string str;while (value > 0){int x = value % 10;value /= 10;str += ('0' + x);}if (flag == false){str += '-';}std::reverse(str.begin(), str.end());return str; }
}int main()
{curry::string ret1 = curry::to_string(1234);return 0;
}
我們先來看看沒有C++11之前未新增移動拷貝的情況:
從上圖我們也可以看到編譯器優化后只調用了一次深拷貝操作,下面當我們在自定義類型中添加了移動構造后,看看編譯器會做什么處理:
從上圖我們可以看到當新增移動拷貝函數之后,編譯器識別為右值類型就會優先去調用移動拷貝函數,相較于深拷貝移動拷貝只是將資源進行了轉移——得到那塊空間的起始地址就能訪問那塊空間的所有資源!!避免了拷貝的操作,移動拷貝實際并不是真正的拷貝,其實就可以理解為改變了接收資源的對象!!所以右值引用在這種情況下提供了非常大的價值!!如果返回的對象是一個數組、一棵樹呢?是不是極大程度上減少了拷貝,提高了效率!!移動語義包括移動構造與移動賦值兩個成員函數!!
注:右值引用并不是直接作為返回值起作用的,右值引用返回臨時對象跟左值引用返回對象的情況是一樣的,臨時對象都是會被銷毀的!!不能直接返回臨時對象的左右值引用!!
我們通過監視窗口來繼續看看移動構造的具象過程:
int main()
{curry::string s1("hello world");curry::string ret1 = s1;curry::string ret2 = (s1 + '1');curry::string ret3 = move(s1); // move操作可以理解為將左值轉變為右值返回 -- 是一個表達式return 0;
}
通過上圖我們可以發現move操作是有風險的,它能將一個對象的資源轉交給另一個對象,轉移完之后這個對象就懸空了,我們不能對這個懸空的對象做操作!!當你不想用個資源了之后可以這樣做,如果后續你還要用到它就不能這么干了!!
我們來看看下面這種情況:
C++11后,STL 中的容器都是增加了移動構造和移動賦值的。
STL 容器的插入接口函數也增加了右值引用版本
總結:
- 右值引用使用的場景:自定義類型臨時對象返回或自定義類型對象作為右值進行傳參時,此時編譯器會優先進行移動拷貝間接減少拷貝,將右值資源直接進行轉移!!
- 左右值引用區別:左值引用是直接減少拷貝,而右值引用是間接減少拷貝,編譯器識別出是左值還是右值,如果是右值則不再進行深拷貝,直接進行移動拷貝轉移資源提高效率!!
1.3 右值引用引用左值及其一些更深入的使用場景分析
按照語法,右值引用只能引用右值,但右值引用一定不能引用左值嗎?因為:有些場景下,可能真的需要用右值去引用左值實現移動語義。當需要用右值引用引用一個左值時,可以通過move函數將左值轉化為右值。C++11中,std::move()函數位于頭文件中,該函數名字具有迷惑性,它并不搬移任何東西,唯一的功能就是將一個左值強制轉化為右值引用,然后實現移動語義。
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
// forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
1.3.1 完美轉發
模板中的&& 萬能引用
// 下面的 Fun都是重載函數, 因為引用也屬于類型
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }// 萬能引用:既能引用左值,也能引用右值
// 引用折疊
template<typename T>
void PerfectForward(T&& t)
{Fun(t);
}int main()
{PerfectForward(10); // 右值int a;PerfectForward(a); // 左值PerfectForward(std::move(a)); // 右值const int b = 8;PerfectForward(b); // const 左值PerfectForward(std::move(b)); // const 右值return 0;
}
模板中的&&不代表右值引用,而是萬能引用,其既能接收左值又能接收右值,這是大佬規定的語法。
我們輸出一下結果看看是否已經根據類型實現了萬能引用:
我們發現結果并不是我們想象的那樣:左值引用就調用左值引用對應的函數,右值引用就調用右值引用的函數??
我們繼續進行分析,在之前的例子中我們說過右值是不能取地址的例如10,但是右值引用過后int&& rr1 = 10;這時候你會發現rr1此時成為了一個變量,既然是變量就有空間(地址),所以此時的rr1就充當了左值!!好,我們繼續來看一個例子:
結論:模板的萬能引用只是提供了能夠接收同時接收左值引用和右值引用的能力,但是引用類型的唯一作用就是限制了接收的類型,后續使用中都退化成了左值,我們希望能夠在傳遞過程中保持它的左值或者右值的屬性, 就需要用我們下面學習的完美轉發!!
完美轉發:保持變量的屬性,防止提早轉變成左值屬性,導致調用的接口函數不一致。
注:完美轉發操作在容器中的各種函數中十分常見,因為C++11提供了右值引用+移動語義,并且在某些場景下我們需要一直轉發才能防止變量屬性提早轉變(退化為左值)!!
我們來看看完美轉發的場景:
template<class T>
struct ListNode
{ListNode* _next = nullptr;ListNode* _prev = nullptr;T _data;
};template<class T>
class List
{typedef ListNode<T> Node;
public:List(){_head = new Node;_head->_next = _head;_head->_prev = _head;}void push_back(T&& x){//Insert(_head, x);Insert(_head, std::forward<T>(x));}void push_back(const T& x){Insert(_head, x);}void Insert(Node* pos, T&& x){Node* prev = pos->_prev;Node* newnode = new Node;newnode->_data = std::forward<T>(x); // 關鍵位置// prev newnode posprev->_next = newnode;newnode->_prev = prev;newnode->_next = pos;pos->_prev = newnode;}void Insert(Node* pos, const T& x){Node* prev = pos->_prev;Node* newnode = new Node;newnode->_data = x; // 關鍵位置// prev newnode posprev->_next = newnode;newnode->_prev = prev;newnode->_next = pos;pos->_prev = newnode;}
private:Node* _head;
};int main()
{List<curry::string> lt;lt.push_back("1111");lt.push_back(curry::string("hello"));return 0;
}
我們來分析一下為什么會是這樣的結果:
我們來看看每個接口函數間不一直進行轉發會發生什么情況:
我們發現當insert函數中的x未進行轉發,導致提前退化為左值故調用賦值重載!!所以我們在某些場景下一定要小心將數據一直轉發下去!!
二、新的類功能
原來 C++ 類中,有 6 個默認成員函數:構造函數、析構函數、拷貝構造函數,賦值運算符重載、取地址重載和 const 取地址重載。重要的是前 4 個,后兩個用處不大。默認成員函數就是我們不寫編譯器會生成一個默認的。C++11 新增了兩個:移動構造函數和移動賦值運算符重載。拷貝構造函數和賦值運算符重載是針對左值的拷貝,而移動構造和移動賦值時針是右值的拷貝。不需要深拷貝的類,也就不需要自己寫移動構造和移動賦值。拷貝對象需要深拷貝時,自己寫移動構造和移動賦值。比如:string、vector 和 list 等。
針對移動構造函數和移動賦值運算符重載有一些需要注意的點如下:
- 如果你沒有自己實現移動構造函數,且沒有實現析構函數 、拷貝構造、拷貝賦值重載中的任意一個。那么編譯器會自動生成一個默認移動構造。默認生成的移動構造函數,對于內置類型成員會執行淺拷貝,對于自定義類型成員,則需要看這個成員是否實現移動構造,如果實現了就調用移動構造,沒有實現就調用拷貝構造。
- 如果你沒有自己實現移動賦值重載函數,且沒有實現析構函數 、拷貝構造、拷貝賦值重載中的任意一個,那么編譯器會自動生成一個默認移動賦值。默認生成的移動構造函數,對于內置類型成員會執行淺拷貝,自定義類型成員,則需要看這個成員是否實現移動賦值,如果實現了就調用移動賦值,沒有實現就調用賦值運算符重載。
- 如果你提供了移動構造或者移動賦值,編譯器不會自動提供拷貝構造和拷貝賦值。如果沒有移動構造和移動賦值,才會去調用拷貝構造和賦值運算符重載。
類成員變量初始化
C++11允許在類定義時給成員變量初始缺省值,默認生成構造函數會使用這些缺省值初始化。這個在類和對象就講了,這里就不再細講了。
強制生成默認函數的關鍵字 default
C++11 可以讓你更好的控制要使用的默認函數。假設你要使用某個默認的函數,但是因為一些原因這個函數沒有默認生成。比如:我們提供了拷貝構造,就不會生成移動構造了,那么我們可以使用 default 關鍵字顯示指定移動構造生成。
禁止生成默認函數的關鍵字 delete
如果能想要限制某些默認函數的生成,在 C++98 中,可以將該函數設置成 private,這樣只要其他人想要調用就會報錯。在 C++11 中更簡單,只需在該函數聲明加上 = delete 即可,該語法指示編譯器不生成對應函數的默認版本,稱 = delete 修飾的函數為刪除函數。
繼承和多態中的 final 與 override 關鍵字
final 可以修飾一個類,表示這個類不能被繼承;也能修飾一個虛函數,表示這個虛函數不能被重寫。override 修飾子類的虛函數,如果子類的虛函數沒有完成重寫,就會編譯報錯。
三、可變參數模板
其實在 C 語言中我們就已經接觸過可變參數了,只是我們并沒有深入的去研究過,上述printf的參數列表中...
就代表任意多個參數,這里我們就不去研究C語言中的可變參數模板到底是如何提取出參數的了,我們來看下C++是如何來操作的!!
C++11的新特性可變參數模板能夠讓您創建可以接受可變參數的函數模板和類模板,相比
C++98/03,類模版和函數模版中只能含固定數量的模版參數,可變模版參數無疑是一個巨大的改
進。然而由于可變模版參數比較抽象,使用起來需要一定的技巧,所以這塊還是比較晦澀的。現
階段呢,我們掌握一些基礎的可變參數模板特性就夠我們用了。
下面就是一個基本可變參數的函數模板
// Args是一個模板參數包,args是一個函數形參參數包
// 聲明一個參數包Args...args,這個參數包中可以包含0到任意個模板參數。
template <class ...Args>
void ShowList(Args... args)
{}
上面的參數 args 前面有省略號,所以它就是一個可變模版參數,我們把帶省略號的參數稱為參數包,它里面包含了 0 到 N(N>=0)個模版參數。我們無法直接獲取參數包 args 中的每個參數的,只能通過展開參數包的方式來獲取參數包中的每個參數,這是使用可變模版參數的一個主要特點,也是最大的難點,即如何展開可變模版參數。由于語法不支持使用 args[i] 這樣方式獲取可變參數,所以我們的用一些奇招來一一獲取參數包的值。
計算可變參數的個數
// 可變參數的模板
template <class ...Args>
void ShowList(Args... args)
{cout << sizeof...(args) << endl;
}int main()
{string str("hello");ShowList();ShowList(1);ShowList(1, 'A');ShowList(1, 'A', str);return 0;
}
通過上圖我們能準確的知道每次調用showList有多少參數,但是我們該如何展開其中的參數包獲取參數包的值呢?
遞歸函數方式展開參數包
template <class T>
void ShowList(const T& t)
{cout << t << endl;
}template <class T, class ...Args>
void ShowList(T value, Args... args)
{cout << value << " ";ShowList(args...);
}int main()
{ShowList(1);ShowList(1, 'A');ShowList(1, 'A', std::string("sort"));return 0;
}
此時傳遞了兩個模板參數,第一個模板參數接收的是傳過來的值,第二個接收的是剩余的參數包,打印完value之后再繼續遞歸展開剩余的參數包,直至參數包的個數為0遞歸結束!!
逗號表達式展開參數包
這種展開參數包的方式,不需要通過遞歸終止函數,是直接在expand函數體中展開的,printarg
不是一個遞歸終止函數,只是一個處理參數包中每一個參數的函數。
這種就地展開參數包的方式實現的關鍵是逗號表達式。我們知道逗號表達式會按順序執行逗號前面的表達式。
expand函數中的逗號表達式:(printarg(args), 0),也是按照這個執行順序,先執行
printarg(args),再得到逗號表達式的結果0。同時還用到了C++11的另外一個特性——初始化列
表,通過初始化列表來初始化一個變長數組, {(printarg(args), 0)…}將會展開成((printarg(arg1),0),
(printarg(arg2),0), (printarg(arg3),0), etc… ),最終會創建一個元素值都為0的數組int arr[sizeof…
(Args)]。由于是逗號表達式,在創建數組的過程中會先執行逗號表達式前面的部分printarg(args)
打印出參數,也就是說在構造int數組的過程中就將參數包展開了,這個數組的目的純粹是為了在
數組構造的過程展開參數包。
template <class T>
void PrintArg(T t)
{cout << t << " ";
}template <class ...Args>
void ShowList(Args... args)
{int arr[] = { (PrintArg(args), 0)... };cout << endl;
}int main()
{ShowList(1);ShowList(1, 'A');ShowList(1, 'A', std::string("sort"));return 0;
}
STL 容器中的 empalce 相關接口函數
我們可以看到C++11在很多容器中都新增了emplace相關的接口函數,并且emplace接口函數中使用了可變模板參數的萬能引用,那么相對于push_back()、insert等接口emplace有什么優勢的地方嗎?我們來看一組例子:
int main()
{list<curry::string> list;curry::string s1("1111");// 插入左值 -- 沒區別list.push_back(s1);list.emplace_back(s1);cout << endl;// 插入move()curry::string s2("22222");list.push_back(move(s1));list.emplace_back(move(s2));cout << endl;// 直接插入右值 -- 開始有區別//list.push_back("3333");//list.emplace_back("3333");return 0;
}
下面我們來看看區別在哪?
我們可以發現push_back比emplace_back多調用了一次移動構造將右值資源進行了轉移,那么為何emplace只會調用一次構造呢?原因是"3333"直接被當做const char*類型的參數包傳下去,最終直接去調用const char*的構造函數,而在push_back中"3333"要隱式類型轉換為string類型的對象,先去會去調用構造函數,然后它為右值類型會與移動構造進行匹配,所以它比emplace會多調用一次移動構造,但其實理論上倆者的效率都差不大多,因為需要深拷貝的類移動構造并不會消耗太多的時間!!
下面我們來看看淺拷貝類emplace_back與push_back的效率問題:
class Date
{
public:Date(int year = 1, int month = 1, int day = 1):_year(year), _month(month), _day(day){cout << "Date(int year = 1, int month = 1, int day = 1)" << endl;}Date(const Date& d):_year(d._year), _month(d._month), _day(d._day){cout << "Date(const Date& d)" << endl;}Date& operator=(const Date& d){cout << "Date& operator=(const Date& d))" << endl;return *this;}private:int _year;int _month;int _day;
};int main()
{// 沒有區別list<Date> list;Date d1(2024, 5, 18);list.push_back(d1);list.emplace_back(d1);cout << "-----------------" << endl;Date d2(2024, 5, 18);list.push_back(move(d1));list.emplace_back(move(d2));cout << "-----------------" << endl;// 有區別list.push_back(Date(2024, 5, 18));list.push_back({ 2024, 5, 18 });cout << "-----------------" << endl;list.emplace_back(Date(2024, 5, 18));list.emplace_back(2024, 5, 18);return 0;
}
注:由于我們實現的是淺拷貝類,所以移動構造與拷貝構造都是淺拷貝并無其他區別,這里我們就不去實現移動拷貝函數了。
通過上圖我們知道emplace少調用了一次拷貝構造,當這個淺拷貝的類非常大時是不是就減少了大量的拷貝提高了效率,所以從這個角度新增emplace接口還是有一定價值的!!