引用
左值引用, 建立既存对象的别名
右值引用, 可用于为临时对象延长生命周期
转发引用, 保持函数实参的类别
悬置引用, 对象生命周期已经结束的引用, 访问改引用为未定义行为
值类别, 左值, 纯右值, 亡值
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 引用声明.
来源: https://www.cnblogs.com/shuqin/p/12237009.html