C++类中隐藏的几个默认函数你知道吗
作者:我叫RT
Test类中隐藏的六个默认的函数
class Test { public: //默认的构造函数 Test(); //析构函数 ~Test(); //拷贝构造函数 Test(const Test &t); //赋值函数 Test& operator=(const Test &x); //一般对象取地址函数 Test* operator&(); //常对象取地址函数 const Test* operator&()const; private: int data; //int *data; // 注意:如果成员中含有指针类型,需重载拷贝函数与赋值函数 // 否则会造成浅拷贝 // 另外,需要注意在析构函数中,释放类中使用的额外资源(堆区申请的资源) };
1.构造函数
作用:对象所在的内存空间做初始化 、给对象赋资源
特点:
1.可以重载 :可以根据实际需要进行缺省的、多参重载
2.不依赖对象:对象无法调用构造函数,只能在对象定义点被调用
//成员函数类外实现,需在函数名前指定作用域,否则编译器会认为在定义一个普通的函数 Test::Test() //类中默认的构造函数 { } //此外,构造函数可以支持重载,我们可以根据需要自己写一些构造函数 //需要注意的是,如果我们自己写了构造函数,那么编译器就不会提供默认的构造函数了 Test::Test(int d = 0 ) //缺省的构造函数 { data = d; } Test::Test(int d = 0 ):data(d) //缺省的构造函数,用初始化列表的方式初始化 { }
两者初始化的区别在于,初始化列表是真正意义上的初始化,它告诉编译器在实例化对象的时候以何种方式对成员赋值,而在前者的赋值规则写在了构造函数内部,是在已经生成了成员变量之后再进行的赋值操作。
初始化列表示例:
Tips: 注意区分列表参数初始化和列表初始化的区别 。列表参数初始化即在函数的形参列表后通过 fun(int ia) :mval(ia)
冒号+括号的这种方式初始化,而列表初始化一般是指如 std::vector<int> vec{ 1,2,3,4,5 }; vec{1,2,3,4,5};
这种,在定义时通过 { } 括起来的列表初始化“数组”的行为。 事实上,在C++11标准中还有一种就地初始化的概念,这里先不做讨论。
对于 初始化列表 有几点特性需要注意:
比如以下操作,成员变量有引用类型和const类型,在C++中规定const类型为一个常量,定义时必须初始化,而引用我们认为是一个变量的别名也需要在定义时就初始化。所以以下操作只能使用初始化列表的方式初始化。
class Test { public: /* error 常量、引用在定义时就初始化 Test(int a, int b,int c) { ma = a; mb = a; // error mc = a; // error } */ Test(int a, int b, int c):ma(a),mb(b),mc(c) { } private: int ma; int& mb; const int mc; };
此外,如果有多个成员变量需要使用初始化列表的方式初始化,需要注意一点细节,初始化的顺序只与成员变量的定义顺序相关。
如以下程序,可以写成Test(int a):ma(mb), mb(a){}
或Test(int a):mb(a),ma(mb){}
因为成员变量的定义顺序为int mb; int ma;
,也就是说赋值顺序与初始化列表无关,只与成员变量被定义的顺序有关。
class Test { public: Test(int a):ma(mb), mb(a) //mb先被定义出来,先给mb赋值,再给ma赋值 { } /* 下面错误的写法: 解释: 1. mb先定义,ma后定义,两者的使用参数列表初始化的顺序是先 mb, 再 ma 2. 在初始化之前 ma 与 mb 都是随机值,或被填充为0xcccccccc (具体看编译器实现) 3. 在初始化时, mb(ma) ,则mb被初始化为无效值(随机值或0xcccccccc) ma(a) , ma 被初始化为 a 的值。 因此,如果调用Test(10), 则 mb: -858993460 ma: 10 */ Test(int a) :mb(ma), ma(a) { } public: void Show() { std::cout << "ma: " << ma << std::endl; std::cout << "mb: " << mb << std::endl; } private: int mb; int ma; };
注:以下函数的Test类成员均为 int *ma
,表示数据成员为指针时,各成员函数的实现方法。
2.析构函数
作用:释放对象所占的其他资源。
特点:
不可重载 : 对象销毁时会调用析构函数,并释放空间。依赖对象:可手动调用即this->~Test()
或 Test t; t.~Test()
,但是不建议,因为对象销毁时会自动调用,如果手动调用可能会引起内存空间的重复析构导致程序崩溃
//默认的析构函数 Test::~Test() { //没有额外的资源,什么都不写 }
//如果程序中有额外的空间需要释放 class Test { public: //构造函数 Test(int ia = 0) { data = new int{ ia }; //data指向一块堆区内存 } //析构函数 ~Test(); private: int* data; }; //析构函数 Test::~Test() { delete data; //把额外空间的释放写进析构函数 data = nullptr; }
3.拷贝构造函数
作用:拿一个已存在的对象来生成相同类型的新对象
注意:类中提供的拷贝构造函数为一个浅拷贝类型,即如果成员变量中含有指针类型,它在进行拷贝构造的时候不会进行额外空间的开辟,最终会造成函数析构时的错误。
class Test { public: //构造函数 Test(int ia = 0) { data = new int{ ia }; //data指向一块堆区内存 } //拷贝构造函数 Test(const Test &t); //一定要传引用,否则在开辟形参的过程中会递归的调用拷贝构造函数来构造形参,而函数始终无法执行 private: int* data; }; //默认的拷贝构造函数 Test::Test(const Test &t) { data = t.data; //浅拷贝,只把现有的成员变量进行拷贝,没有对堆区内存进行拷贝,使多个对象的data指向了同一片堆区空间,在对象销毁时会造成空间的重复释放引发程序崩溃。 } //拷贝构造函数 Test::Test(const Test &t) { data = new int; //如果是字符类型data = new char[strlen(t.data) + 1]; // 注意strlen() 函数不能传递nullptr参数 strcpy_s(data,sizeof(int), t.data); } // 或者使用初始化列表的方式 Test::Test(const Test& t) :data(new int{*(t.data)}) { }
4.赋值运算符的重载函数
作用:拿一个已存在的对象给相同类型的已存在对象赋值
实现步骤
1.赋值判断
2.释放旧资源
3.生成新资源
4.赋值
class Test { public: //构造函数 Test(int ia = 0) { data = new int{ ia }; //data指向一块堆区内存 } //赋值函数 Test& operator=(const Test &x); //以自身类类型的引用的方式返回 private: int* data; }; //默认的赋值函数(浅拷贝) Test& Test::operator=(const Test &x) { if(this!=&x) //自赋值判断 { data=x.data; //浅拷贝 } return *this; //返回自身类类型的引用 } //赋值函数(深拷贝) Test& Test::operator=(const Test &x) { if(this!=&x) //自赋值判断 { delete data; //释放原资源 //delete[] data; 如果申请的空间是多个,即数组形式,需要delete [] data 释放 data = new int; //开辟空间 memcpy(data, x.data, sizeof(data)); // 赋值 } return *this; //返回自身类类型的引用 }
5.一般对象取地址函数
//一般对象取地址函数 Test::Test* operator&() { return this; }
6.常对象取地址函数
//常对象取地址函数 const Test::Test* operator&()const { return this; }
C++11以后增加了右值引用的概念,同时增加了移动构造函数、和移动赋值函数。
7.移动构造函数
作用:针对某些情况构造对象的优化,避免重复的开辟内存。
使用场景:比如把临时对象的资源作为构建新对象的资源使用,而临时对象销毁时,资源继续被其他对象使用(这里就节省了一次旧对象资源的的销毁与新对象资源申请的开销)。
class Test { public: //构造函数 Test(int ia = 0) :data(new int{ ia }) {} // 拷贝构造 .. // 赋值.. // 析构.. // 移动构造 Test(Test&& rhs) { this->data = rhs.data; // 资源的转移 rhs.data = nullptr; // 资源的释放 } /* //使用初始化列表的方式 Test(Test&& rhs) :data(rhs.data) { rhs.data = nullptr; } */ public: void print() { std::cout << (void*)data << std::endl; } private: int* data; }; int main() { Test t(10); t.print(); Test t2(std::move(t)); // 把 t 的资源转移给 t2 t.print(); t2.print(); return 0; }
8.移动赋值函数
作用:资源的转移,针对某些情况下,节省内存的开辟。
class Test { public: //构造函数 Test(int ia = 0) :data(new int{ ia }) {} // 拷贝构造 .. // 赋值.. // 析构.. // 移动构造 // 移动赋值 Test& operator=(Test&& rhs) noexcept // 不抛出异常 { if (this != &rhs) // 防止自赋值 { delete data; // 销毁当前资源 this->data = rhs.data; // 转移资源,即接收对方资源 rhs.data = nullptr; // 对方放弃资源的拥有 } return *this; } public: void print() { std::cout << (void*)data << std::endl; } private: int* data; }; int main() { Test t(10), t2(20); t.print(); t2.print(); t2 = std::move(t); // 把 t 的资源交给 t2 t.print(); // 输出 00000000 t2.print(); return 0; }
补充:
另外一个关于移动构造的话题是异常。对于移动构造函数来说,抛出异常有时是件危险的事情。因为可能移动语义还没完成,一个异常却抛出来了,这就会导致一些指针就成为悬挂指针。因此程序员应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept关键字,可以保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行,而不是造成指针悬挂的状态。而标准库中,我们还可以用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义。
关于移动构造函数的示例程序,引用自《深入理解C++11》一书:
#include <iostream> using namespace std; class HasPtrMem { public: HasPtrMem() :d(new int(3)) { cout<<"Construct:"<<++n_cstr<<endl; } HasPtrMem(const HasPtrMem& h) :d(new int(*h.d)) { cout<<"Copy construct:"<<++n_cptr<<endl; } HasPtrMem(HasPtrMem&& h) :d(h.d) {//移动构造函数 h.d = nullptr;//将临时值的指针成员置空 cout<<"Move construct:"<<++n_mvtr<<endl; } ~HasPtrMem() { delete d; cout<<"Destruct:"<<++n_dstr<<endl; } int* d; static int n_cstr; static int n_dstr; static int n_cptr; static int n_mvtr; }; int HasPtrMem::n_cstr = 0; int HasPtrMem::n_dstr = 0; int HasPtrMem::n_cptr = 0; int HasPtrMem::n_mvtr = 0; HasPtrMem GetTemp() { HasPtrMem h; cout<<"Resource from"<<__func__<<":"<<hex<<h.d<<endl; return h; } int main() { HasPtrMem a = GetTemp(); cout<<"Resource from"<<__func__<<":"<<hex<<a.d<<endl; } //编译选项:g++ -std=c++11 test.cpp -fno-elide-constructors
输出:左边是输出结果,右边是注释
Construct:1 // 在GetTemp() 函数中,执行 HasPtrMem h; 构造对象 Resource fromGetTemp:0x1047f28 // 在GetTemp() 函数中,执行cout << ... Move construct:1 // 在GetTemp() 函数中,return h; 产生临时对象,此时第一次调用移动构造 Destruct:1 // 进入main() 函数时,GetTemp();调用结束、进行清栈(栈帧回退),析构掉局部对象( h ) Move construct:2 // 在main() 函数中,执行 GetTemp(); 后产生的返回值是一个临时无名对象,调用了移动构造函数,此时第二次调用移动构造 Destruct:2 // 在main() 函数中,执行了a = GetTemp(); 后,临时对象生存期结束,析构掉临时对象(函数返回值) Resource frommain:0x1047f28 // 在main() 函数中,执行cout << ... Destruct:3 // main() 函数结束,对象 a 生存周期结束,销毁对象 a
需要注意的是,在编译器中存在被称为RVO/NRVO的优化(RVO,Return Value Optimization,返回值优化,或者NRVO,Named Return Value optimization)。因此在上述编译时使用了 -fno-elide-constructors
选项在g++/clang++中关闭这个优化,这样可以使我们在代码运行的结果中较为容易地利用函数返回的临时量右值。
如果在编译的时候不使用该选项的话,我们写的很多含有移动语义的函数都被省略了。例如以下的代码
A ReturnRvalue(){A a();return a;} A b=ReturnRvalue();
b 变量实际就使用了ReturnRvalue函数中a的地址,任何的拷贝和移动都没有了。通俗地说,就是b变量直接“霸占”了a变量。这是编译器中一个效果非常好的一个优化。不过RVO/NRVO并不是对任何情况都有效。比如有些情况下,一些构造是无法省略的。还有一些情况,即使RVO/NRVO完成了,也不能达到最好的效果。
总体而言,移动语义除了可以解决某些情况下编译器无法解决的优化问题,还在一些其他特殊的场合有着重要的用途(比如在unique_ptr中禁止构造函数,却可以通过移动的构造或移动赋值对unique_ptr拥有的资源进行转移)。
总结
本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注脚本之家的更多内容!