首页 > 编程语言 >C++填坑系列——EffectiveModernC++之特殊成员函数

C++填坑系列——EffectiveModernC++之特殊成员函数

时间:2024-03-02 14:56:07浏览次数:26  
标签:int s2 C++ Sample 填坑 other Address EffectiveModern 拷贝

Chapter 移步现代c++之特殊成员函数

Item 17: Understand special member function generation

总结:
有必要了解各个函数什么时候自动生成;自动生成的函数有可能产生预期外的行为


特殊成员函数(编译器自动生成):默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符;
默认构造函数:仅当类不存在用户声明的构造函数时才自动生成;
拷贝构造函数:仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete;
拷贝赋值运算符:仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete;
移动操作:仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成;


c++11的三五法则:用户自定义了析构函数的类必须定义拷贝构造函数和拷贝赋值运算符;尽量定义移动构造函数和移动赋值运算符;

理解特殊成员函数的生成(也就是你自己没有声明的但是c++编译器会帮你自动生成的成员函数)。

c++98有四个这样的函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符。注意:这几个函数只有在需要的时候才会被生成,比如你在代码中使用了这几个函数但是类中没有明确声明。

c++11添加了两个函数:移动构造函数、移动赋值运算符。

移动操作被编译器自动生成后,执行的动作是:对类的non-static数据成员逐成员移动,但其实这个移动不一定会确实发生,因为对于不可移动类型执行的还是拷贝操作,所以确切来说,自动生成的移动构造函数和移动赋值运算符执行的操作是对类的non-static数据成员逐成员移动,支持移动的就移动,不支持移动的就拷贝

编译器生成拷贝操作和移动操作的区别:

  1. 两个拷贝函数是独立的。即:你只声明了其中的一个拷贝函数,但是在代码中有使用另一个拷贝函数,那编译器也会自动为你生成这个拷贝函数;(c++98和c++11中都是这样的规则)
  2. 两个移动函数不是独立的。即:你只声明了其中的一个移动函数,编译器不管你在代码中是否有使用另一个,也不会再帮你生成了;
  3. 声明拷贝不会生成移动。即:如果一个类显示声明了拷贝函数(构造或赋值),编译器就不会自动生成移动函数了;
  4. 声明移动不会生成拷贝。如果一个类显示声明了移动函数(构造或赋值),编译器就不会自动生成拷贝函数了;

1. 两个拷贝函数是独立的

class Sample {
 public:
  Sample() : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = i * i;
    }
    printf("Default Constructor Called, Address %p \n", this);
  }

  Sample(const Sample& other) : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = other.p[i];
    }
    printf("Copy Constructor, Address %p \n", this);
  }

  ~Sample() {
    printf("Deconstructor Called, Address %p \n", this);
    delete[] p;
  }

 public:
  int* p;
};

int main() {
  Sample s1;
  Sample s2{s1};
  Sample s3;
  s3 = s2;
  printf("s1.p %p\n", s1.p);
  printf("s2.p %p\n", s2.p);
  printf("s3.p %p\n", s3.p);
}

代码的输出如下,当然这个代码存在一个问题(后面讲述),这里主要看下两个拷贝函数是如何自动生成的。

Default Constructor Called, Address 0x7ff7b0146338  // 构造s1调用默认构造函数 Sample s1;     s1地址 0x7ff7b0146338
Copy Constructor, Address 0x7ff7b0146330             // 构造s2调用拷贝构造函数 Sample s2{s1}; s2地址 0x7ff7b0146330
Default Constructor Called, Address 0x7ff7b0146318  // 构造s3调用默认构造函数 Sample s3;     s3地址 0x7ff7b0146318
s1.p 0x7f94fe706000
s2.p 0x7f94fe705f80
s3.p 0x7f94fe705f80         // s3的p地址和s2的p地址一样,说明这里还是调用了拷贝赋值运算符 s3 = s2;
Deconstructor Called, Address 0x7ff7b0146318        // 析构s3
Deconstructor Called, Address 0x7ff7b0146330        // 析构s2
base1(88042,0x7ff85025db80) malloc: Double free of object 0x7fcc5ff05f80  // 析构s2的时候出现了问题,原因是在s3 = s2
base1(88042,0x7ff85025db80) malloc: *** set a breakpoint in malloc_error_break to debug

接下来看下这个代码的问题base1(88042,0x7ff85025db80) malloc: Double free of object 0x7fcc5ff05f80object 0x7fcc5ff05f80这个地址的对象被free了两次,为啥会这样呢?

主要还是编译器自动生成的拷贝函数执行的是逐成员拷贝操作,s3 = s2;这行代码调用编译器自动生成的拷贝赋值运算符时,会生成类似这样的代码:

Sample& operator=(const Sample& other) {
  if (this != &other) {
    this->p = other.p;
  }
  return *this;
}

这样的话s3的p和s2的p指向了同一份内容,那在析构完s2再析构s3就会出现析构两次的问题了。所以说后面会讲述一个原则:我们自己声明定义任意的一个拷贝函数后,另一个建议也同时自定义。

需要我们再次添加一个拷贝赋值运算符的代码,

  Sample& operator=(const Sample& other) {
    if (this != &other) {
      int* new_p = new int[10];
      for (int i = 0; i < 10; ++i) {
        new_p[i] = other.p[i];
      }
      delete[] p;
      p = new_p;
    }
    printf("Copy Assignment, Address %p \n", this);
    return *this;
  }

2. 两个移动函数不是独立的

如果你给类声明了一个移动构造函数,就表明对于移动操作你有自己的具体实现,那这个与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止移动赋值运算符的生成,声明移动赋值运算符同样阻止编译器生成移动构造函数。

具体的理解可以借鉴上面1中的解释,逐成员操作可能会出现问题。

lass Sample {
 public:
  Sample() : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = i * i;
    }
    printf("Default Constructor Called, Address %p \n", this);
  }

  Sample(const Sample& other) : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = other.p[i];
    }
    printf("Copy Constructor, Address %p \n", this);
  }

  Sample& operator=(const Sample& other) {
    if (this != &other) {
      int* new_p = new int[10];
      for (int i = 0; i < 10; ++i) {
        new_p[i] = other.p[i];
      }
      delete[] p;
      p = new_p;
    }
    printf("Copy Assignment, Address %p \n", this);
    return *this;
  }

  Sample(Sample&& other) {
    p = other.p;
    other.p = nullptr;
    printf("Move Constructor Called, Address %p \n", this);
  }

  ~Sample() {
    printf("Deconstructor Called, Address %p \n", this);
    delete[] p;
  }

 public:
  int* p;
};

int main() {
  Sample s1;
  printf("s1.p %p\n", s1.p);
  Sample s2{std::move(s1)};
  printf("s1.p %p\n", s1.p);
  printf("s2.p %p\n", s2.p);
  Sample s3;
  s3 = std::move(s2);
  printf("s3.p %p\n", s3.p);
}

代码的输出如下,可以看到代码中虽然调用了移动赋值运算符s3 = std::move(s2);的操作,但是输出的还是Copy Assignment, Address 0x7ff7bc3dd318,也就是调用的依旧是拷贝赋值运算符,编译器并没有为我们自动生成移动赋值运算符的函数。

Default Constructor Called, Address 0x7ff7bc3dd338  // Sample s1;
s1.p 0x7fb5b8706000
Move Constructor Called, Address 0x7ff7bc3dd320     // Sample s2{std::move(s1)};
s1.p 0x0
s2.p 0x7fb5b8706000
Default Constructor Called, Address 0x7ff7bc3dd318  // Sample s3;
Copy Assignment, Address 0x7ff7bc3dd318             // s3 = std::move(s2);
s3.p 0x7fb5b8705f00
Deconstructor Called, Address 0x7ff7bc3dd318 
Deconstructor Called, Address 0x7ff7bc3dd320 
Deconstructor Called, Address 0x7ff7bc3dd338

3. 声明拷贝不会生成移动

如果声明拷贝函数(构造或者赋值)就表示一般的拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。

class Sample {
 public:
  Sample() : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = i * i;
    }
    printf("Default Constructor Called, Address %p \n", this);
  }

  Sample(const Sample& other) : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = other.p[i];
    }
    printf("Copy Constructor, Address %p \n", this);
  }

  Sample& operator=(const Sample& other) {
    if (this != &other) {
      int* new_p = new int[10];
      for (int i = 0; i < 10; ++i) {
        new_p[i] = other.p[i];
      }
      delete[] p;
      p = new_p;
    }
    printf("Copy Assignment, Address %p \n", this);
    return *this;
  }

  // Sample(Sample&& other) {
  //   p = other.p;
  //   other.p = nullptr;
  //   printf("Move Constructor Called, Address %p \n", this);
  // }

  // Sample& operator=(Sample&& other) {
  //   if (this != &other) {
  //     delete[] p;
  //     p = other.p;
  //     other.p = nullptr;
  //   }
  //   printf("Move Assignment Called, Address %p \n", this);
  //   return *this;
  // }

  ~Sample() {
    printf("Deconstructor Called, Address %p \n", this);
    delete[] p;
  }

 public:
  int* p;
};

int main() {
  Sample s1;
  Sample s2{std::move(s1)};
  Sample s3;
  s3 = std::move(s2);
}

在代码中我们注释掉了移动函数,具体输出如下所示,可以看到虽然在代码中调用Sample s2{std::move(s1)};s3 = std::move(s2);计划去调用移动函数,但是程序的输出还是调用的是拷贝函数,这就说明了我们在代码中声明了拷贝函数之后编译器也就不会为我们自动生成移动函数了,而且即使使用std::move计划去调用移动操作,其实实际调用的还是拷贝操作。

Default Constructor Called, Address 0x7ff7be439338 
Copy Constructor, Address 0x7ff7be439330 
Default Constructor Called, Address 0x7ff7be439318 
Copy Assignment, Address 0x7ff7be439318 
Deconstructor Called, Address 0x7ff7be439318 
Deconstructor Called, Address 0x7ff7be439330 
Deconstructor Called, Address 0x7ff7be439338

4. 声明移动不会生成拷贝

这个同第3点类似,我们直接看代码吧,下面的代码把拷贝函数都注释掉了。那在程序实际调用该两个函数的时候,可以看到编译器有告警,提示我们并没有声明和定义这两个函数。

class Sample {
 public:
  Sample() : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = i * i;
    }
    printf("Default Constructor Called, Address %p \n", this);
  }

  // Sample(const Sample& other) : p(new int[10]) {
  //   for (int i = 0; i < 10; ++i) {
  //     p[i] = other.p[i];
  //   }
  //   printf("Copy Constructor, Address %p \n", this);
  // }

  // Sample& operator=(const Sample& other) {
  //   if (this != &other) {
  //     int* new_p = new int[10];
  //     for (int i = 0; i < 10; ++i) {
  //       new_p[i] = other.p[i];
  //     }
  //     delete[] p;
  //     p = new_p;
  //   }
  //   printf("Copy Assignment, Address %p \n", this);
  //   return *this;
  // }

  Sample(Sample&& other) {
    p = other.p;
    other.p = nullptr;
    printf("Move Constructor Called, Address %p \n", this);
  }

  Sample& operator=(Sample&& other) {
    if (this != &other) {
      delete[] p;
      p = other.p;
      other.p = nullptr;
    }
    printf("Move Assignment Called, Address %p \n", this);
    return *this;
  }

  ~Sample() {
    printf("Deconstructor Called, Address %p \n", this);
    delete[] p;
  }

 public:
  int* p;
};

int main() {
  Sample s1;
  // 编译器告警:无法引用 函数 "Sample::Sample(const Sample &)" (已隐式声明) -- 它是已删除的函数
  Sample s2{s1};
  Sample s3;
  // 编译器告警:无法引用 函数 "Sample::operator=(const Sample &)" (已隐式声明) -- 它是已删除的函数
  s3 = s1;
}

三五法则

在C++中,三五法则(也称为rule-of-three/five)是关于如何正确管理资源(如内存、文件句柄、网络连接等)的一条最佳实践准则。这个法则随着C++11的引入而从三规则变成了五规则。

rule-of-three

在C++11之前,如果你的类需要自定义以下三个特殊成员函数中的任何一个,那么你通常也需要自定义另外两个:

  1. 析构函数(Destructor)
  2. 拷贝构造函数(Copy Constructor)
  3. 拷贝赋值运算符(Copy Assignment Operator)

这是因为如果你需要自定义析构函数来管理资源,那么你很可能也需要自定义拷贝构造函数和拷贝赋值运算符来正确地复制这些资源,防止例如双重释放等问题。

rule-of-five

C++11引入了移动语义和右值引用,这意味着除了上述三个函数,还有两个函数也可能需要自定义:

  1. 移动构造函数(Move Constructor)
  2. 移动赋值运算符(Move Assignment Operator)

如果你的类管理的资源可以被“移动”(例如,通过转移指针的所有权而不是复制整个数据),那么你应该提供移动构造函数和移动赋值运算符。这样可以提高效率,特别是在涉及到临时对象或大量数据转移的情况下。

所以,如果你自定义了析构函数,为了确保类的行为正确,你可能需要考虑实现或删除(通过显式声明为= delete)这五个特殊成员函数中的其他函数。这样做可以确保对象的拷贝和移动操作是安全的,不会导致资源泄露或其他错误。

成员函数模板不会阻止特殊成员函数的自动生成

即使我们定义的类中有类似拷贝构造函数和拷贝赋值运算符的模板函数,编译器依旧会为我们自动生成特殊成员函数。

下面的代码中,可以看到我们定义了两个类拷贝操作Sample(const T& other)Sample& operator=(const T& other),但是从程序的输出s1s2的地址一样,s1.ps2.p的地址一样,不难看出其实也是执行了复制的操作的。也就是说编译器还是自动生成了拷贝构造函数和拷贝赋值运算符。

class Sample {
 public:
  Sample() : p(new int[10]) {
    for (int i = 0; i < 10; ++i) {
      p[i] = i * i;
    }
    printf("Default Constructor Called, Address %p \n", this);
  }

  template <typename T>
  Sample(const T& other) {}

  template <typename T>
  Sample& operator=(const T& other) {}

 public:
  int* p;
};

int main() {
  Sample s1;
  Sample s2{s1};
  printf("s1 addr %p\n", &s1);
  printf("s2 addr %p\n", &s2);
  printf("s1.p addr %p\n", s1.p);
  printf("s2.p addr %p\n", s2.p);
}

上述代码的输出是

Default Constructor Called, Address 0x7ff7b48ed338 
s1 addr 0x7ff7b48ed338
s2 addr 0x7ff7b48ed330
s1.p addr 0x7faa53f06000
s2.p addr 0x7faa53f06000

标签:int,s2,C++,Sample,填坑,other,Address,EffectiveModern,拷贝
From: https://www.cnblogs.com/pplearn/p/18048632

相关文章

  • C++ 多线程笔记2 线程同步
    C++多线程笔记2线程同步并发(Concurrency)和并行(Parallelism)并发是指在单核CPU上,通过时间片轮转的方式,让多个任务看起来像是同时进行的。实际上,CPU在一个时间段内只会处理一个任务,但是由于切换时间非常快,用户感觉像是多个任务同时在进行。这种方式的优点是可以充分利用CPU资源,......
  • C++ 类构造函数 & 析构函数
    带参数的构造函数默认的构造函数没有任何参数,但如果需要,构造函数也可以带有参数。这样在创建对象时就会给对象赋初始值,如下面的例子所示:1#include<iostream>2usingnamespacestd;34classLine5{6public:7voidsetLength(doublelen);8......
  • 万物容器与 c++ 类型反射
    这是一篇组会分享,并且是拖了很长很长时间的那种。这次不会再鸽了这篇文章可以说是针对某cpp佬的公众号的两篇原创内容的笔记c++反射--包容一切的all容器(上)c++反射--包容一切的all容器(中)什么是反射这个好像没有严格的定义,但是概括的说,「反射」是指在程序运行期对程序......
  • C++中cin的详细用法
    1.cin简介cin是C++编程语言中的标准输入流对象,即istream类的对象。cin主要用于从标准输入读取数据,这里的标准输入,指的是终端的键盘。此外,cout是流的对象,即ostream类的对象,cerr是标准错误输出流的对象,也是ostream类的对象。这里的标准输出指的是终端键盘,标准错误输出指的是终端的......
  • C++填坑系列——类型推导 decltype
    decltypedecltype主要是为了解决类型推导的问题,特别是在模板编程和泛型编程中应用较广泛。decltype关键字用于以表达式为参数,推导表达式返回的类型,该类型会保留所有信息。c++11提出的新特性,decltype关键字。和auto一样都是用来做编译时类型推导的,但是也有一些区别:auto:从......
  • C++填坑系列——左值和右值
    c++的表达式首先介绍下c++的表达式是什么?看下cppreference是怎么说的。Anexpressionisasequenceofoperatorsandtheiroperands,thatspecifiesacomputation.也就是说,在C++中,表达式(Expression)是由操作数(Operands)和运算符(Operators)组成的序列。左值和右值就是c++中......
  • C++填坑系列——lambda表达式
    lambda表达式总结:lambda表达式原理:被编译器转换为类+初始化对象的代码;格式:[captureslist](paramslist)specifiersexception->retType{funtionbody}按值捕获和按引用捕获的优缺点以及解决方法;一.lambda原理lambda函数会被编译器转换为类,并定义实现一个operato......
  • C++ 类访问修饰符
    私有(private)成员成员和类的默认访问修饰符是private,如果没有使用任何访问修饰符,类的成员将被假定为私有成员。私有成员变量或函数在类的外部是不可访问的,甚至是不可查看的。只有类和友元函数可以访问私有成员。实际操作中,我们一般会在私有区域定义数据,在公有区域定义相关的函数......
  • C++类开发的第六篇(虚拟继承实现原理和cl命令的使用的bug修复)
    Class_memory接上一篇末尾虚拟继承的简单介绍之后,这篇来详细讲一下这个内存大小是怎么分配的。使用clcl是MicrosoftVisualStudio中的C/C++编译器命令。通过在命令行中键入cl命令,可以调用VisualStudio的编译器进行编译操作。cl命令提供了各种选项和参数,用于指定源......
  • C++ 把引用作为返回值
    通过使用引用来替代指针,会使C++程序更容易阅读和维护。C++函数可以返回一个引用,方式与返回一个指针类似。当函数返回一个引用时,则返回一个指向返回值的隐式指针。这样,函数就可以放在赋值语句的左边。例如,请看下面这个简单的程序:1#include<iostream>23usingnamesp......