• 自认为最好的rule_of_five


    1. 右值引用

    1.1 概念及universal reference

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

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

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

    template
    void f(T&& param);
    
    f(10);///<模板中的param是右值,因为10是纯右值
    
    int x = 10;
    f(x);///<模板中的param是左值,因为x是左值
    
    template
    class Test
    {
    	Test(Test&& param);///<不是universal reference,因为没有类型推导,是右值引用
    }
    
    void f(std::vector&& pram);//不是universal reference,因为在调用这个函数之前vector已经确定了。
    
    template
    void f(const T&& param);///<不是universal reference,因为加了const
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    1.2 引用折叠

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

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

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

    int&& var1 = 0; ///
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

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

    void P(int& x)
    {
    	cout<<"lvalue"<
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    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(x));
    }
    int main() {
      forward(1);
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    1.5 特殊情况的例子,返回值。

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

    ff::five xxx()
    {
    	ff::five b("aac");
    	return b;
    }
    int main() {
      ff::five a("bbc");
      std::cout<
4
  • 1

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

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

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

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

	return c;

}
int main() {
	ff::five a("bbc");
	std::cout<
copy constructor
4

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

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

	return c;
}
int main() {
  ff::five a("bbc");
  std::cout<
4

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

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

2. rule of five

2.1 简单逻辑的代码

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

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

#include 
#include 
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 

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"<
2.2.3 解析

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

  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  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::vectora1,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 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

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"<
$ 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

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

  • 相关阅读:
    C++ Map empty()实例讲解
    转债打新监听
    ASP.NET Core 6.0对热重载的支持
    【周末读书】认知驱动:做成一件对他人很有用的事
    LeetCode-1408-数组中的字符串匹配
    centos docker中无法安装软件的解决方法
    【Web前端大作业实例网页代码】html+css新闻资讯网页带dw模板和登陆注册(9页)
    spring-boot 项目,nacos启动异常
    java计算机毕业设计虚拟物品交易网站源码+系统+数据库+lw文档
    解读 FlashAttention
  • 原文地址:https://blog.csdn.net/qigezuishuaide/article/details/126443105