自认为最好的rule_of_five

1. 右值引用

1.1 概念及universal reference

左值引用只能绑定左值,右值引用只能绑定右值,常量左值可以绑定一切:左值、右值、常量左值和常量右值。

universal reference:需要自动推导的类型

有两种情况,一个是模板的自动推导(只有T&&这种格式才是,其外加一点点都不行),一个是auto的自动推导

template<typename T>
void f(T&& param);

f(10);///<模板中的param是右值,因为10是纯右值

int x = 10;
f(x);///<模板中的param是左值,因为x是左值

template<typename T>
class Test
{
	Test(Test&& param);///<不是universal reference,因为没有类型推导,是右值引用
}

void f(std::vector<T>&& pram);//不是universal reference,因为在调用这个函数之前vector<T>已经确定了。

template<typename T>
void f(const T&& param);///<不是universal reference,因为加了const

1.2 引用折叠

上一节讲了universal reference,类型推导的时候,如果有多种类型配合在一起会怎么样,这就是引用折叠:

  • 所有的右值引用叠加到右值引用上依然还是一个右值引用
  • 所有的其它引用类型之间的叠加都将变成左值引用

左值和右值是独立于它们的类型,右值引用类型可能是左值也可能是右值。具名的右值引用是左值,未命名的右值引用是右值

int&& var1 = 0; ///<var1是右值引用
auto&& var2 = var1;///<var2是universal reference,所以需要推导
				   ///var1是具名的右值所以是左值,var2是右值引用
				   ///根据引用折叠第二条,所以var2是左值引用

int w1, w2;
auto&& v1 = w1;///<v1被左值初始化,所以是左值引用

decltype(w1)&& v2 = w2;///<会报错,因为w2是左值,v2是固定的右值,右值引用不能绑定左值
decltype(w1)&& v3 = std::move(w2);///<v3是右值引用,用右值可以给右值引用赋值

再来一个例子,证明具名右值和非具名右值,传入forward的是右值引用,但是变成了x就是具名的了,它就变成了左值

void P(int& x)
{
	cout<<"lvalue"<<endl;
}
void P(int&& x)
{
	cout<<"rvalue"<<endl;
}

void forward(int&& x)
{
	P(x);
}
int main() {
  forward(2);
  return 0;
}
1.3 forword和完美转发

这个用于处理“具名的右值引用是左值”,本来forward函数中的x在P(x)是左值了,所以第一个打印的应该左值,被std::forard()修饰后的类型恢复了其本身的类型——左值,因此它的类型是右值。

void P(int& x) { cout << "lvalue" << endl; }
void P(int&& x) { cout << "rvalue" << endl; }

void forward(int&& x) {
  P(x);
  P(std::forward<int>(x));
}
int main() {
  forward(1);
  return 0;
}
1.4 eg

C++11将右值分为常量右值和将亡值,常量右值就是数字啥的,将亡值指的如果函数有返回值,那个返回值就是。

int fun(void)
{
    int x=1;
    return x;
}
int main(void)
{
    int x=0;
    int& y = x;///<左值引用
    int&& z = fun();///<将亡值的右值引用
    int&& a = 0;///<常量右值的右值引用

    const int& X = x;///<常量左值可以接受左值引用
    const int& Z = z;///<常量左值可以接受右值引用
    return 0;
}

1.5 特殊情况的例子,返回值。

返回局部变量的值:
1.局部变量是手动创建的:RVO,没有任何消耗

ff::five xxx()
{
	ff::five b("aac");
	return b;
}
int main() {
  ff::five a("bbc");
  std::cout<<xxx().size_<<std::endl;
  return 0;
}
4

2.局部变量是外面构造传进来:拷贝构造

ff::five xxx(ff::five b)
{
	return b;
}
int main() {
  ff::five a("bbc");
  std::cout<<xxx(a).size_<<std::endl;
  return 0;
}
copy constructor
move constructor
4

返回全局变量的值:会调用拷贝构造

ff::five c("bbc");
ff::five xxx()
{

	return c;

}
int main() {
	ff::five a("bbc");
	std::cout<<xxx().size_<<std::endl;
	return 0;
}
copy constructor
4

返回全局变量的引用:不会有消耗

ff::five c("bbc");
ff::five& xxx()
{

	return c;
}
int main() {
  ff::five a("bbc");
  std::cout<<xxx().size_<<std::endl;
  return 0;
}
4

返回局部变量的引用:报错,很简单,不能返回局部变量的指针

ff::five& xxx(ff::five c)
{
	return c;
}
int main() {
  ff::five a("bbc");
  std::cout<<xxx(a).size_<<std::endl;
  return 0;
}

2. rule of five

2.1 简单逻辑的代码

如果函数内部有new就要建5个函数:析构、拷贝构造、拷贝赋值、移动构造和移动赋值。

下面是我认为对的,这里有很多要注意的。

  • 拷贝一类,本质都是用一个现有的类给另一个类赋值,因此拷贝的参数都是常量引用
  • 移动一类,本质都是右值引用给现有类,因此移动的参数都是右值引用,因为移动一类需要将原参数清空,因此不是常量右值
  • 构造一类,本质上和普通构造没有区别,可以理解为普通的重载,因此将other对象的东西复制进来就行
  • 赋值一类,本质上是重载=运算符,而且等号左侧的这个对象肯定是已经存在的,用新的值覆盖掉,因此原有的参数必须安全清空,赋值肯定是比拷贝快的
#include <stdc++.h>
#include <string.h>
using namespace std;

class five {
 public:
  char* str_;
  five(const char* tmp) {
    if (nullptr != tmp) {
      size_t n = strlen(tmp) + 1;
      str_ = new char(n);
      memcpy(str_, tmp, n - 1);
      str_[n - 1] = '\0';
    }
    cout << "constructor" << endl;
  }

  ~five() {
	  deconstructor();
 }
  five(const five& other)  //拷贝构造
  {
    if (nullptr != other.str_) {
      size_t n = strlen(other.str_) + 1;
      str_ = new char(n);
      memcpy(str_, other.str_, n - 1);
      str_[n - 1] = '\0';
    }
    cout << "copy constructor" << endl;
  }
  five& operator=(const five& other)  //<拷贝赋值
  {
    if (nullptr != other.str_) {
      size_t n = strlen(other.str_) + 1;
	  deconstructor();
	  str_ = new char(n);
	  memcpy(str_, other.str_, n - 1);
      str_[n - 1] = '\0';
    }
    cout << "copy assignment" << endl;
    return *this;
  }

  five(five&& other)  //<移动构造
  {
    str_ = other.str_;
    other.str_ = nullptr;
    cout << "move constructor" << endl;
  }
  five& operator=(five&& other)  //<移动赋值
  {
	deconstructor();
    str_ = other.str_;
    other.str_ = nullptr;
    cout << "move assignment" << endl;
    return *this;
  }
 private:
  void deconstructor()
  {
	  delete str_;
	  str_ = nullptr;
  }
};
2.2 copy & swap idiom
2.2.1 上面rule_of_five的缺点
  1. 无效的判断

上面会有很多if(other.str_==nullptr)的判断,这种判断大多数明显是无效的,能不能省掉呢?

  1. noexcept

下面会通过vector扩容讲拷贝构造和移动构造的关系。总的来说,因为new可能会失败,第一种办法没法标注noexcept。

下面链接中的说法感觉不是很好,它说的是:移动构造中,如果先析构this->str_再构造新的数据,万一new失败了,析构的也都丢了。所以只能先构造再析构,但这种方法会造成第三个缺点。

  1. 代码冗余

这个确实是,上面代码有很多重复的地方,虽然可以通过抽出函数的方法来解决,但是我能不能换一种思路呢?不按照原本四个函数的功能去实现,就是这里的办法。

上面的三个理由,感觉都没有那么强烈,只有我自己得出的那个理由比较靠谱。

https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom

2.2.2 代码实现
#include <stdc++.h>

namespace ff {
class five {
 public:
  char* str_;
  size_t size_;
  five() {
    str_ = nullptr;
    size_ = 0;
  }

  five(const char* str) : str_(nullptr) {
    if (nullptr != str) {
      size_ = strlen(str) + 1;
      str_ = new char(size_);
      memcpy(str_, str, size_);
    }
    // cout<<"constructor"<<endl;
  }

  five(const five& other)  ///< copy constructor
  {
    if (nullptr != other.str_) {
      size_ = strlen(other.str_) + 1;
      str_ = new char(size_);
      memcpy(str_, other.str_, size_);
    }
    std::cout << "copy constructor" << std::endl;
  };

  five(five&& other) noexcept  ///< move constructor
  {
    str_ = std::exchange(other.str_, nullptr);
    size_ = std::exchange(other.size_, 0);
    std::cout << "move constructor" << std::endl;
  };

  void swap(five& f) noexcept {
    std::swap(str_, f.str_);
    std::swap(size_, f.size_);
  }

  five& operator=(five copy) noexcept {
    copy.swap(*this);
    std::cout << "assignment" << std::endl;
    return *this;
  }

  friend void swap(five& first, five& second) noexcept { first.swap(second); }

  ~five() {
    if (nullptr == str_) {
      delete[] str_;
      str_ = nullptr;
    }
  }
};
}  // namespace ff
int main() {
  ff::five a("bbc");
  ff::five b("dde");
  swap(a, b);

  ff::five c = a;//<拷贝构造
  ff::five d = std::move(c);//<移动构造

  c = a;//<拷贝赋值
  c = std::move(b);//移动赋值
  return 0;
}
2.2.3 解析

准确的说这个叫rule of four and a-half,因为里面不是五个函数,而是“四个半”。

  • 析构函数:普通的

  • 拷贝构造:没啥特殊的,和普通的构造一毛一样。

  • 移动构造:这里用了exchange函数,这个函数的作用就是将第一个参数的结果返回,第二个参数的结果赋给第一个参数。

  • 赋值函数:重载了赋值运算符,参数必须只能是值,不能是左值或右值引用

  • swap函数:这个就是那半个参数必须只能是左值引用

    此外还可以添加一个友元的swap函数

  1. 为什么赋值函数要用值传递

这样是为了让这个函数变成noexcept的,所有的构造发生在函数外。如果是左值,那就会调用拷贝构造生成,进来赋值,如果传进来的是右值,那就会调用移动构造生成再赋值。

再来一个问题,右值引用和左值引用和值是三种不同的类型,现象就是三者可以进行重载。那问题就来了,既然是三种类型,值应该不支持左值和右值传入的才对啊,应该会提示函数不存在啊,为什么能过呢?

首先值传递在汇编阶段会生成两个函数,一个是值的一个是右值的,就是说一个函数会变成两个(这个我没看懂,是同事看的汇编)。那为什么要这么做呢?大帝的说法是右值引用可以绑定值。或者可以猜测为是历史原因。例如一个函数void func(int x),如果你在调用的时候完全可以写为func(3);,但是这个3是纯右值啊,凭什么可以给值赋值啊,应该有这样的函数才对void func(int&& x);,但这是历史问题,C开始就一直这么做,现在发明了右值引用,不允许我这么传参数了?凭啥?就类似的原因。这个3是因为编译器会生成一个临时变量a,用3给a赋值,这样就可以了。只不过这个操作会在后续被编译器优化掉,3会直接放在寄存器中。

上面这段有主管臆测的部分,没有绝对的证据证明。

  1. 为什么四个半能用

如果是拷贝赋值,最好使用左值引用当参数,如果是移动赋值应该是右值引用啊,那为啥这里的重载用的却是值呢?

如果是移动赋值,肯定还是要用赋值函数,但是参数不对,因此就会先在本地调用移动构造,将右值引用的值移动到一个临时变量,再把这个变量传入赋值函数(用值传入,不会有问题吗?)总的来说就是移动赋值需要先调用移动构造,再调用赋值函数。同理,拷贝赋值就是先调用拷贝构造再调用赋值函数。

  1. 为什么要重写swap函数

赋值函数中调用的copy.swap()这个成员函数,而不是用的std::swap(copy, *this),因为这会造成循环引用。

swap的原理大概是下面这个。std::swap()是利用的重载赋值运算符,而重载出来的赋值运算符,再次调用std::swap(),这样就死循环了。

template <class T> void swap (T&a, T&b)
{
  T c(std::move(a)); a=std::move(b); b=std::move(c);
}
  1. 为什么要用友元

这个是因为ADL。ADL的原理是虽然某个函数在调用的时候没有声明namespace,但是如果全部参数都属于同一个namespace,就会调用那个namespace的函数。例如swap的两个参数都是std::vector,在调用swap的时候不需要声明。

std::vector<int>a1,a2;
std::swap(a1, a2);///<这个std可以不加
swap(a1, a2);

而友元的作用很像成员函数,但是它的作用域并不属于类。例如上面swap如果不用友元就要声明一个类内的swap函数,完了再在namespace中,类外部分再写一个swap函数。因为ADL中经常有人用swap(a, b)这种写法,要是只用成员函数就只能a.swap(b)

大帝曾经曰过:要尽量多的减少成员函数和friend的个数,因为这样会破坏类的封装性,主要的原因是减少访问私有变量的次数,例如任何容器都没有swap函数这个成员,都是放在单独的algorithm中。反正类内和类外都要有个swap,莫不如用friend一次性解决。

  1. 为什么要用noexcept

这个非常重要,noexcept的原理是告诉别人我这个函数非常稳定,不可能会出错。如果不加,那就是可能会出错。一般而言,只有绝对不会出现问题的函数才会被加这个标记。

那这个标记有啥用呢?我目前发现了一个非常重要的情况,如果vector不出现扩容,push_back的类型如果是值,那自然会调用拷贝构造,如果传入的是右值的,那必然是移动构造,这都没问题,但是如果vector扩容呢?问题就来了,如果加了noexcept的,会调用拷贝构造,如果没加的就会调用移动构造。这样效率就会差很多了。

这里移动相关的都可以加noexcept,因为它们就是不会出错,最大可能出错的是new,而new就算失败也是传进来之前就出错了,而不是函数本身异常。

int main() {
  ff::five a("bbc");
  std::vector<ff::five> b;
  b.push_back(a);
  std::cout << b.capacity() << std::endl;
  b.push_back(a);
  std::cout << b.capacity() << std::endl;
  b.push_back(a);
  std::cout << b.capacity() << std::endl;
  return 0;
}

下面是移动构造加了noexcept的,很明显,1->2移动一个,2->4移动两个

copy constructor
1
copy constructor
move constructor
2
copy constructor
move constructor
move constructor
4

下面是没加noexcept的,看到没,竟然还用的拷贝构造,天呐!!!!原来vector扩容是可以没有消耗的,怪不得大帝说没有删除的情况,vector是最好用的数据结构了。

copy constructor
1
copy constructor
copy constructor
2
copy constructor
copy constructor
copy constructor
4

https://stackoverflow.com/questions/5695548/public-friend-swap-member-function

3. 返回值优化(RVO)

当临时变量时,右值引用可以延长这个变量的生命周期,省去复制的操作,但是很多时候根本用不到这个。例如函数返回一个对象,这个对象是局部变量的临时值,右值引用还用不了,那要怎么做才能减少复制呢?下面代码rule_of_five是cppreference的,我只是加了些打印


#include<stdc++.h>

using namespace std;

class rule_of_five
{
public:
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
    rule_of_five(const char* s = "") : cstring(nullptr)
    {
        if (s)
        {
            std::size_t n = std::strlen(s) + 1;
            cstring = new char[n];      // allocate
            std::memcpy(cstring, s, n); // populate
        }
		std::cout<<"constructor"<<std::endl;
    }

    ~rule_of_five()
	{
		if(nullptr!=cstring)
		{
			delete[] cstring; // deallocate
			std::cout<<"deconstuct"<<std::endl;
		}
	}

    rule_of_five(const rule_of_five& other) // copy constructor
    : rule_of_five(other.cstring) {std::cout<<"copy constructor"<<std::endl;}

    rule_of_five(rule_of_five&& other) noexcept // move constructor
    : cstring(__exchange(other.cstring, nullptr)) {std::cout<<"move constructor"<<std::endl;}

    rule_of_five& operator=(const rule_of_five& other) // copy assignment
    {
		std::cout<<"copy assignment"<<std::endl;
        return *this = rule_of_five(other);
    }

    rule_of_five& operator=(rule_of_five&& other) noexcept // move assignment
    {
		std::cout<<"move assignment"<<std::endl;
        std::swap(cstring, other.cstring);
        return *this;
    }

// alternatively, replace both assignment operators with
//  rule_of_five& operator=(rule_of_five other) noexcept
//  {
//      std::swap(cstring, other.cstring);
//      return *this;
//  }
};

rule_of_five return_five()
{
	auto f = rule_of_five("what the hell?");
	std::cout<<f.cstring<<std::endl;
	return f;
}

int main()
{
	rule_of_five a = return_five();
	cout<<a.cstring<<endl;
	return 0;
}
$ g++ rule_of_five.cpp -Wall -g
$ ./a.out
constructor
what the hell?
what the hell?
deconstruct

有意思吧,竟然只创造了一次对象?是真的,就是只创造了一次,那这是怎么做到的呢?这个就很有意思了,编译器竟然把return_five()中局部变量的值直接赋值出来了,如果打印地址就会发现,这个a的地址竟然都和函数内的f一样,有没有很帅。

不过这个RVO是可以被关掉的,比如之前为了仔细看到移动构造的使用在编译的时候增加-fno-elide-constructors编译选项,可见结果就不一样了。

constructor
move constructor
what the hell?
move constructor
move constructor
what the hell?
deconstuct

这样的结果是很好理解的,创建好临时变量后,通过移动构造给到f,打印好f的结果后返回,又通过移动构造,把结果给到a。

那如果删掉移动构造呢?那就会启用拷贝构造来代替,就会不停地构造析构。

constructor
constructor
copy constructor
deconstuct
what the hell?
constructor
copy constructor
deconstuct
constructor
copy constructor
deconstuct
what the hell?
deconstuct

如果返回函数内部的参数内容,返回内部的引用就可以,注意不要返回局部栈变量。此外我还问过大帝返回右值引用的问题,他说:为啥要返回右值引用,这个需求就很奇怪。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tux~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值