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

Last Updated on 2020年4月19日

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

经验谈: 除非”移动”能带来特别明显的性能优势,所有”支持拷贝的自定义类”都应该把移动操作设为=delete的.一方面并不是所有人都熟悉移动语义,另一方面,移动语义可能会引入许多bug. 综上,要么类实例是只能拷贝的,要么是只能移动的.

拷贝与移动的基本定义

  • “拷贝”和”移动”是从逻辑层面定义的,物理层面只有”拷贝”,没有”移动”操作.

  • 对于自定义类,拷贝与移动的实现有基本的准则(非硬性),这些准则是:

    • 拷贝:不应对源对象做任何修改.
    • 移动:可以对源对象做修改,但是源对象被修改后必须可以被赋值,且可以安全的析构.
  • 注意,对于移动操作.我们不能对移出后剩余的部分做任何假定,在没有重新赋值前,不能使用剩下的对象
    • 例如:对一个vector<int> a;,其中的所有元素都被移出后,不能假定a.size()值为0了.
  • 一般而言:
    • 对于涉及资源的类型(如vector),我们一般认为,移动操作的性能开销应该优于拷贝.
    • 对于锁,线程等逻辑上唯一的资源,其拷贝没有意义,只能进行移动.例如,锁对象之间的移动是在转交锁的所有权.
    • 对于内置类型,物理上只存在拷贝操作.其”移动”只能用拷贝实现.
    • 内置类型的”移动”一般仅在编译器为我们合成移动赋值/移动构造时出现.

左值,右值

  • 从形式上说,代码中,仅”编译器创建的匿名临时量”和”字面值常量”是右值,其余的对象都是左值.
  • 一般情况下,左值意味着我们可以将其放在等号左侧,右值则不行.
    • const T obj型的对象只不过在编译期禁用了operator=.

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

  • 将引用理解为特殊的指针,对于理解引用的行为很有帮助.(大部分编译器的底层实现就是常量指针)
    • 将对象绑定到引用时,就是自动取地址并赋值给指针.
    • 通过引用访问对象时,会自动的对指针加解引用符号.
    • 除此之外,编译器不会做其他操作.
  • 大部分时候,当你遇到困惑时,都可以通过将引用换为指针的方式来辅助自己进行分析.例如
    • 绑定仅是取地址,所以绑定操作不会触发任何的构造函数
    • 引用销毁时只是指针的销毁,不会触发绑定对象的析构函数.
    • 将ref又绑定到ref2上,就是指针的拷贝,自然也不会涉及对象的拷贝(移动).
    • 左值引用,右值引用是”类型不同的”的指针.
    • 以引用形式进行函数传参(返回)时,就是在传入(返回)指针.
  • 左值只可能绑定到左值引用,即{T &,const T &}.

  • 右值只可能绑定到右值引用或const T &,即{T &&,const T &&,const T &}.
  • 任何类型的”右值”都可能绑定到const T &,此时会创建一个临时量,一定要注意.
    • 例如const Foo & a = Bar(),等价于const Foo & a = Foo(Bar())
  • 引用是一种类型,但是C++限制引用必须被初始化,且不能重复绑定,且不能放在容器中.(主要是历史包袱)
    • 如果需要和引用行为相似的对象,目前只能直接使用指针.(或者自定义一个类)
  • 引用类型复合时存在”引用折叠”,该特性一般仅用在模板中
    • 含有&的都折叠为&,例如T&& &,T& &&,T& &,其整体上的类型和T&等价;
    • T&& &&折叠为T&&
  • 注意:引用只能绑定到在编译期长度已知的数组,
    • 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]);//由编译器来推断长度

引用作为形参/返回值

  • 引用的作用主要是作为函数的形参,在常规代码中较少用到引用

    • 常规代码中创建的引用都会给对象取名,所以通过引用名访问对象时,总是被当做左值处理.
    • 为右值对象取名后,右值对象的寿命延长到”名字”不可见为止.
  • 在函数重载中的规则如下.
    • 右值将精确匹配到T&&const T&&形参的函数。当这两个函数未定义时,才可能重载到cosnt T &
    • 左值对象只能匹配到T&const 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&&)为移动构造,T& operator=(T&&)为移动赋值.
  • 因为移动构造函数同时会修改源对象和目标对象,如果因异常中断,移动过程就可能无法恢复现场(导致异常不安全).基于此,标准库在vector等容器中,只当容器元素的移动控制函数为noexcept时才会使用移动.