引用
- 左值引用,建立既存對象的別名
- 右值引用,可用於為臨時對象延長生命周期
- 轉發引用,保持函數實參的類別
- 懸置引用,對象生命周期已經結束的引用,訪問改引用為未定義行為
- 值類別,左值,純右值,亡值
- std::move, std::forward
類型推導
引用塌縮(折疊)
可以通過模板或者 typedef 中的類型操作構成引用的引用,但是C++不認識多個&
的,所以就產生一個規則,左值引用 &
, 右值引用 &&
,在結合的時候,可以把左值引用看作是顯性基因,只要有左值引用,那么結合就折疊成左值引用,要兩個都是隱形基因(&&
)的情況,才不會進行折疊。
typedef int& lref;
typedef int&& rref;
int n;
lref& r1 = n; // r1 的類型是 int&
lref&& r2 = n; // r2 的類型是 int&
rref& r3 = n; // r3 的類型是 int&
rref&& r4 = 1; // r4 的類型是 int&&
右值引用作為函數實參 的類型推導
- 左值引用 (模板參數為右值引用).
- 左值(普通函數調用)
寫個小例子就可以看出效果了,普通函數的情況如下,模板的示例見 std::forward 分析
int foo(int &&arg) { std::cout << "int &&\n"; } // 不會被調用
int foo(int &arg) {std::cout << "int &\n";} // 兩個函數只能存在一個
// int foo(int arg) { std::cout << "int\n"; }
int main() {
int &&rref = 1;
foo(rref); // int 或者 int &
}
指針與引用的聯系與區別
指針和引用經常會一起出現,個人的理解
- 指針,存儲地址的變量,能夠存儲任何的地址,自身也需要分配內存,比如 nullptr,並且能夠任意修改(無cv限定情況)。
- 引用,對象或者函數的別名,必須初始化且不能修改,語義上不分配內存,故指針不能指向引用,反之,引用可以綁定指針(指針自身是具名對象)。但在實現上(gcc)還是會分配內存
通過一個例子就可以看的很清楚,兩者都是 訪問地址 來實現的,但由於歷史原因我們一說到地址就會想到指針。
void ref() {
int value = 13;
int &lref = value;
lref = 9;
int *p = nullptr;
p = &value;
*p = 21;
}
_Z3refv:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $13, -20(%rbp)
leaq -20(%rbp), %rax # 取 value 的地址 &value
movq %rax, -8(%rbp) # 將 value 的地址轉移,這兩步可以不需要的
movq -8(%rbp), %rax
movl $9, (%rax) # 賦值 lref = 9
movq $0, -16(%rbp) # 指針初始化
leaq -20(%rbp), %rax # 同上,取地址
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl $21, (%rax) # 賦值 *p = 21
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
在使用上來說,引用優於指針的地方在於,引用避免了空指針的判斷,並且在使用上和值語義相近。
google 的 coding style 上也有針對引用和指針參數的規范,入參如果不能夠被改變的話,使用 const T &
,如果是需要使用指針或者參數可變的情況下使用指針入參。
// 形式如下
void do_something(const std::string& in, char *out);
左值引用和懸置引用
左值引用的定義清晰,就是既存對象的別名,當作披着地址的皮來使用就可以,並且也能延長生命周期(const T &
接收),見延長右值引用分析。
懸置引用在使用不當的時候可能出現,如下
struct Foo {
Foo() : value(13) {}
~Foo() { value = -1; }
int value;
};
Foo &get_foo() {
Foo f;
return f;
}
int main() { Foo &f = get_foo(); }
// 反匯編,只截取 get_foo()
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
pushq %rbx
subq $24, %rsp
.cfi_offset 3, -24
leaq -20(%rbp), %rax // 對象 f 的地址
movq %rax, %rdi // 構造函數的隱藏參數
call _ZN3FooC1Ev // 調用構造函數
movl $0, %ebx
leaq -20(%rbp), %rax
movq %rax, %rdi
call _ZN3FooD1Ev // 析構函數
movq %rbx, %rax // 最后返回的是 rax(rax = rbx),但是這個 rbx 是沒有來源的,訪問直接段錯誤
addq $24, %rsp
popq %rbx
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
當出現這種懸置引用的時候,再去訪問就不知道是什么錯誤了,好消息是編譯器可以識別這個問題並且發出警告的。
右值引用
右值引用就是為了延長生命周期而生的,這里再扯一下,左值引用也是可以做到這一點的,但是不能夠通過左值引用修改。
拿一下 cppreference 中的例子,右值引用是通過 &&
使得編譯器指令重排而延長生命周期的,而左值引用是 const T &
進行py交易的,
在以上函數增加一個友元函數,重載 +
操作符。
friend Foo operator+(const Foo &lhs, const Foo &rhs) {
Foo foo;
foo.value = lhs.value + rhs.value;
return foo;
}
int main() {
Foo f1;
const Foo &lref = f1 + f1;
// rf.value = 1;
Foo &&rref = f1 + f1; // 臨時變量 f1 + f2 的引用
rref.value = 4; // 相同
}
// 反匯編取重載函數和main函數代碼
_ZplRK3FooS1_:
.LFB6:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
movq %rdi, -8(%rbp) // rdi 是構造函數的第一個參數,當函數返回對象時,就是這樣做的
movq %rsi, -16(%rbp) // lhs
movq %rdx, -24(%rbp) // rhs
movq -8(%rbp), %rax
movq %rax, %rdi
call _ZN3FooC1Ev // 調用構造函數
movq -16(%rbp), %rax
movl (%rax), %edx // lhs.value
movq -24(%rbp), %rax
movl (%rax), %eax // rhs.value
addl %eax, %edx // edx = lhs.value + rhs.value
movq -8(%rbp), %rax
movl %edx, (%rax) // foo.value = edx
nop
movq -8(%rbp), %rax // return foo
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
main:
.LFB8:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
leaq -28(%rbp), %rax // 取 f1 的地址
movq %rax, %rdi
call _ZN3FooC1Ev // Foo f1;
leaq -24(%rbp), %rax // 重載函數內的 臨時對象,當重載函數返回對象時,編譯器便把對象指針傳進去
leaq -28(%rbp), %rdx // rhs,f1
leaq -28(%rbp), %rcx
movq %rcx, %rsi // lhs,f1
movq %rax, %rdi
call _ZplRK3FooS1_ // 調用重載函數
leaq -24(%rbp), %rax
movq %rax, -8(%rbp)
leaq -20(%rbp), %rax // 第二次調用的重載函數內的 臨時對象指針
leaq -28(%rbp), %rdx // rhs,f1
leaq -28(%rbp), %rcx
movq %rcx, %rsi // lhs,f1
movq %rax, %rdi
call _ZplRK3FooS1_ // 第二次調用重載函數
leaq -20(%rbp), %rax // 這兩個值是相等的,也就是返回的臨時對象指針
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl $4, (%rax) // rref.value = 4;
leaq -20(%rbp), %rax
movq %rax, %rdi
call _ZN3FooD1Ev // 析構函數被移動到作用域之外也就是main函數里面了
leaq -24(%rbp), %rax
movq %rax, %rdi
call _ZN3FooD1Ev
leaq -28(%rbp), %rax
movq %rax, %rdi
call _ZN3FooD1Ev
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
可以看到 &&
和 const T &
產生的匯編代碼幾乎是一樣的,兩者都提供了常量引用的語義,是編譯器的實現也在函數返回對象的情況下模糊了這兩者的區別(生成匯編代碼),所以在有些情況下,在未提供 f(T &&)
重載則會調用 f(const T &)
。但是區別在於常量左值引用是不可修改的。
一些函數提供了兩個引用的重載版本,如 std::vector::push_back()
,允許自動選擇copy構造函數和移動構造函數。
值類別
-
左值
簡單粗暴的理解就是在操作符的左邊的表達式,但是C++的概念比較的多,例如,++i 這個是左值,i++ 就是純右值了,字符串常量也沒有想到是左值吧,因為不能修改,所以不能存在於表達式的左邊。
cppreference 中的概念陳述的非常多,簡單而言就是有分配內存的對象就是左值,只有這種情況才能夠用於初始話左值引用(字符串常量,const char *
)。 -
純右值
取不到地址的表達式,如內建類型值,this指針,lambda -
亡值
差不多可以理解為,作為一個臨時量,內存中存在數據,如果不延長生命周期的話,該對象就會被銷毀。std::move 產生的就是亡值。
然后上面的種類繁多,又有混合類別產生:
- 泛左值,左值和亡值,也就是內存有數據的對象
- 右值,純右值和亡值,不能被左值引用綁定的對象
std::move std::forward
std::move
右值引用變量的名稱是左值,而若要綁定到接受 右值引用參數的重載,就必須轉換到亡值,這是移動構造函數與移動賦值運算符典型地使用 std::move 的原因。
函數名稱和目的相關,但內部實現沒有什么移動的操作,就一個轉換類型,見 libstdcxx 源碼。
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
std::forward
轉發引用利用 std::forward
保持實參值類型進行完美轉發,完美轉發詳細的說一下,它的實現也不是很復雜,有兩個重載函數,實際上都是類型轉換,
// 轉發左值為左值或右值,依賴於 T
template <typename _Tp>
constexpr _Tp &&
forward(typename std::remove_reference<_Tp>::type &__t) noexcept {
return static_cast<_Tp &&>(__t);
}
// 轉發右值為右值並禁止右值的轉發為左值
template <typename _Tp>
constexpr _Tp &&
forward(typename std::remove_reference<_Tp>::type &&__t) noexcept {
static_assert(!std::is_lvalue_reference<_Tp>::value,
"template argument substituting _Tp is an lvalue reference type");
return static_cast<_Tp &&>(__t);
}
參考上面的 引用折疊 ,以下給定例子的參數類型推導:
template <typename T> void foo(const T &arg) { std::cout << "const T &\n"; }
template <typename T> void foo(T &arg) { std::cout << "T &\n"; }
template <typename T> void foo(T &&arg) { std::cout << "T &&\n"; }
template <typename T> void wrapper(T &&arg) { foo(std::forward<T>(arg)); }
int main() {
Foo f1;
const Foo f2;
wrapper(f1); // T &
wrapper(f1 + f1); // T &&
wrapper(f2); // const T &
}
- 若 wrapper 調用的入參為右值,則 T 被推導為
Foo
, 這樣std::forward
就把右值引用轉發給 foo - 若 wrapper 調用的入參為
const
限定左值,則推導 T 為const Foo &
,在引用折疊下std::forward
將 const 左值引用傳遞給 foo - 若 wrapper 掉用的入參為非const左值,則推到 T 為
Foo &
,在引用折疊下std::forward
將非 const 左值引用傳遞給 foo
另外,對類型的推導過程都是在編譯期完成的,不同的限定或者引用類型的c++代碼生成的匯編代碼沒有區別,為了編譯期匹配到正確的函數調用。
參考
- 引用聲明,cppreference 引用聲明。