[Cpp基础] [02] 拷贝/移动与引用

Last Updated on 2022年9月27日

这一部分主要介绍左值,右值,引用,拷贝和移动.这些可以说是C++11显著区别于以前的C++(还有C)的特性.本文主要从易用的角度介绍值类型(值类型实际要更多一些).

拷贝与移动的基本定义

  • "拷贝"和"移动"是从逻辑层面定义的,在最终的指令层面,只有"拷贝",没有"移动"操作.
  • 对于自定义类,拷贝与移动的实现有基本的准则(非硬性),这些准则是:
    • 拷贝:不应对源对象做任何修改.
    • 移动:可以对源对象做修改,但是源对象被修改后必须可以被赋值,且可以安全的析构.
  • 注意,对于移动操作.我们不能对移出后剩余的部分做任何假定,在没有重新赋值前,不应该使用移出后剩下的对象
    • 例如:对一个vector<int> a;,其中的所有元素都被auto b = std::move(a);移出后,不能假定a.size()值为0了.
  • 一般而言:
    • 对于同时支持拷贝和移动的类型,我们一般认为,移动操作的性能开销应该优于拷贝.
    • 对于锁,线程等逻辑上唯一的资源,其拷贝没有意义,只能进行移动.例如,锁对象之间的移动是在转交锁的所有权.
    • 对于某个类型,如果拷贝的性能足够好(满足需求),那么应该优先使用拷贝.
  • 一个常见的风格: 所有类型都应该是copy-only或move-only的,必要时,可以给move-only的类型添加一个clone().
    • 使用这种风格编程的主要优势是可以降低心智负担,进而降低维护成本.
    • 这种风格认为,如果类型支持拷贝,那么拷贝一定是高效的,否则就应该让它变成move-only.这样一来,只要编译器不报错,那么代码的基本性能就有保障.

左值,右值

  • 语言层面的"对象"可以是左值/右值其中一种.
  • 从形式上说, 如果某个"对象"只在一个expression中有意义,那么它就是右值,如临时量,字面值常量.
  • 左值意味更加持久的生命周期, 右值的生命周期是完全由编译器管理的.

左值引用T&与右值引用T&&

  • 将引用理解为特殊的指针,对于理解引用的行为很有帮助.(大部分编译器的底层实现就是常量指针)
    • 将对象绑定到引用时,就是自动取地址并赋值给指针.
    • 通过引用访问对象时,会自动的对指针加解引用符号.
    • 除此之外,编译器不会做其他操作.
  • 大部分时候,当你遇到困惑时,都可以通过将引用换为指针的方式来辅助自己进行分析.例如
    • 绑定仅是取地址,所以绑定操作不会触发任何的构造函数
    • 引用销毁时只是指针的销毁,不会触发绑定对象的析构函数.
    • 将ref又绑定到ref2上,就是指针的拷贝,自然也不会涉及对象的拷贝(移动).
    • 左值引用,右值引用是"类型不同的"的指针.
    • 以引用形式进行函数传参(返回)时,就是在传入(返回)指针.
  • 左值只可能绑定到左值引用,即{T &,const T &}.
  • 右值只可能绑定到右值引用或const T &,即{T &&,const T &&,const T &}.
    • 注意,右值绑定到const T &是一个cast, 并不是精确匹配,也就是说它可能触发隐式类型转换, 例如const Foo & a = Bar(),等价于const Foo & a = Foo(Bar()),只要Foo支持从Bar移动构造即可.
  • 引用是一种类型,但是C++限制引用必须被初始化,且不能重复绑定,且不能放在容器中.(主要是历史包袱)
    • 必须放在容器中时,使用指针会更合理.
  • 在涉及模板时, 存在"引用折叠"的特性, 因为推导出的类型T可以是引用类型, 这就可能和已有的引用重复.
    • 含有&的都折叠为&,例如MyT && &,MyT & &&,MyT & &,其整体上的类型和MyT&等价;
    • MyT && &&折叠为MyT&&
  • 注意:引用只能绑定到在编译期长度已知的数组,
    • void f(int(&r)[]);//误,必须有长度
    • void f(int(&r)[100]);//正
    • Type array[N]; auto &ref = array;//正,编译器可推断长度
    • Type array[100]; auto (&ref)[100] = array;//正,手动告知长度
    • 在模板中我们可以利用引用推断数组的长度,template<class T,int N> void f(T(&r)[N]);//由编译器来推断长度

引用作为形参/返回值

  • 引用主要是作为函数的形参或返回值类型, 在常规代码中极少用到引用.

    • 常规代码中,常常用引用来给对象起别名,这可能会让代码更好看,例如auto & point_info = *p_full_point_information;,不但可以让名字变短,还能避免在后续的代码中使用->.
  • 在函数重载中的规则如下.

    • 右值将精确匹配到T&&const T&&形参的函数。当这两个函数未定义时,才可能重载到cosnt T &
    • 左值对象只能匹配到T&const T&
    • 左值对象和右值对象一般都能初始化T形参,T形参和引用形参优先级相同.
  • 虽然逻辑上T&&,const T&&,T&,const T&都是合法的类型,在实践中中,我们只应该使用const T&T&&.

    • T&总是应该由T *替代,强调对象可能被修改.
  • 从逻辑上说,T&&T&形参传入的对象允许被修改.

    • 一般而言,我们会认为T&&型传入的对象一定会被修改,且仅保证修改后的对象仅仅可以安全的析构.
    • T&型传入的对象是否被修改并不确定
    • 从编程风格上说,如果传入对象不会被修改,就应该用const T&,使用T&&T&意味着传入对象一定会被修改.
  • const T&const T&&传入的对象不能修改

    • const T&&仅在成员重载this等极其冷门的场合中使用,在没有明确的理由时,应优先使用const T&
  • 作为函数的返回形参时,习惯上我们常使用const T&T&

    • 返回const T&&T&&的也是理论上可行的,一般只用在模板中.

引用相关的static_cast

  • 通过static_cast<T&&>可以将对象视作右值来处理.
    • 例如 static_cast<int &&>(a) = 10;就会触发编译错误,等号左侧是一个右值.
    • 注:xxx_cast<yy>(obj)要求obj只能是左值表达式
  • std::move(obj)表达式的效果和static_cast<T&&>(obj)一致,实现上使用了模板.

引用与拷贝控制

有了之前的背景作为铺垫,我们就可以详细的介绍C++中的拷贝和移动了.

  • 对于自定义类T
    • T(const T&)为拷贝构造,T& operator=(const T&)为拷贝赋值.
    • T(T&&)为移动构造,T& operator=(T&&)为移动赋值.
  • 因为移动构造函数同时会修改源对象和目标对象,如果因异常中断,移动过程就可能无法恢复现场(导致异常不安全).基于此,标准库在vector等容器中,只当容器元素的移动控制函数为noexcept时才会使用移动.