C++ 引用分析

引用

  • 左值引用,建立既存对象的别名
  • 右值引用,可用于为临时对象延长生命周期
  • 转发引用,保持函数实参的类别
  • 悬置引用,对象生命周期已经结束的引用,访问改引用为未定义行为
  • 值类别,左值,纯右值,亡值
  • 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&&

右值引用作为函数实参 的类型推导

  1. 左值引用 (模板参数为右值引用).
  2. 左值(普通函数调用)

写个小例子就可以看出效果了,普通函数的情况如下,模板的示例见 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 &
}

指针与引用的联系与区别

指针和引用经常会一起出现,个人的理解

  1. 指针,存储地址的变量,能够存储任何的地址,自身也需要分配内存,比如 nullptr,并且能够任意修改(无cv限定情况)。
  2. 引用,对象或者函数的别名,必须初始化且不能修改,语义上不分配内存,故指针不能指向引用,反之,引用可以绑定指针(指针自身是具名对象)。但在实现上(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构造函数和移动构造函数。

值类别

  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 引用声明。
上一篇:《黑客攻防技术-系统实战》--利用缓冲区溢出执行任意代码


下一篇:QSYS中的cfi控制器组件到哪里去了