C++ 引用分析

引用

  • 左值引用,創建既存對象的別名
  • 右值引用,可用於爲臨時對象延長生命週期
  • 轉發引用,保持函數實參的類別
  • 懸置引用,對象生命週期已經結束的引用,訪問改引用爲未定義行爲
  • 值類別,左值,純右值,亡值
  • std::move, std::forward

類型推導

引用塌縮(摺疊)

能夠經過模板或者 typedef 中的類型操做構成引用的引用,可是C++不認識多個& 的,因此就產生一個規則,左值引用 &, 右值引用 &&,在結合的時候,能夠把左值引用看做是顯性基因,只要有左值引用,那麼結合就摺疊成左值引用,要兩個都是隱形基因(&&)的狀況,纔不會進行摺疊。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&&

右值引用做爲函數實參 的類型推導

  1. 左值引用 (模板參數爲右值引用).
  2. 左值(普通函數調用)

寫個小例子就能夠看出效果了,普通函數的狀況以下,模板的示例見 std::forward 分析app

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 &
}

指針與引用的聯繫與區別

指針和引用常常會一塊兒出現,我的的理解函數

  1. 指針,存儲地址的變量,可以存儲任何的地址,自身也須要分配內存,好比 nullptr,而且可以任意修改(無cv限定狀況)。
  2. 引用,對象或者函數的別名,必須初始化且不能修改,語義上不分配內存,故指針不能指向引用,反之,引用能夠綁定指針(指針自身是具名對象)。但在實現上(gcc)仍是會分配內存

經過一個例子就能夠看的很清楚,二者都是 訪問地址 來實現的,但因爲歷史緣由咱們一說到地址就會想到指針。this

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 &,若是是須要使用指針或者參數可變的狀況下使用指針入參。google

// 形式以下
void do_something(const std::string& in, char *out);

左值引用和懸置引用

左值引用的定義清晰,就是既存對象的別名,看成披着地址的皮來使用就能夠,而且也能延長生命週期(const T & 接收),見延長右值引用分析
懸置引用在使用不當的時候可能出現,以下spa

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交易的,code

在以上函數增長一個友元函數,重載 + 操做符。對象

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構造函數和移動構造函數。

值類別

  1. 左值
    簡單粗暴的理解就是在操做符的左邊的表達式,可是C++的概念比較的多,例如,++i 這個是左值,i++ 就是純右值了,字符串常量也沒有想到是左值吧,由於不能修改,因此不能存在於表達式的左邊。
    cppreference 中的概念陳述的很是多,簡單而言就是有分配內存的對象就是左值,只有這種狀況纔可以用於初始話左值引用(字符串常量,const char *)。

  2. 純右值
    取不到地址的表達式,如內建類型值,this指針,lambda

  3. 亡值
    差很少能夠理解爲,做爲一個臨時量,內存中存在數據,若是不延長生命週期的話,該對象就會被銷燬。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++代碼生成的彙編代碼沒有區別,爲了編譯期匹配到正確的函數調用。

參考

  1. 引用聲明,cppreference 引用聲明。
相關文章
相關標籤/搜索