1. 类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情
况下,都会自动生成下面6个默认成员函数。
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值重载函数
- 取地址重载函数
- const取地址重载函数
具体各个函数所能实现的大概功能如下:
1.1 构造函数
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,保证每个数据成员都有 一个合适的初始值,并且在对象的生命周期内只调用一次。但需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
如:Test()
或Test(int data = 0)
特征如下:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。没有定义构造函数,对象也可以创建成功,但是调用的是编译器生成的默认构造函数。
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。
初始化列表
在创建对象时,编译器通过调用构造函数,给对象中各个成员变量一个合适的初始值,但是不能将其称作为类对象成员的初始化,构造函数体中的语句只能将其称作为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值,因此我们就需要采用初始化列表来对类的成员对象进行初始化赋值。
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
例如:class Test { public: Test(int data=0) : m_data(data) { } private: int m_data; };
但需要注意的是:
- 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
- 类中包含以下成员,必须放在初始化列表位置进行初始化:
- 引用成员变量
- const成员变量
- 自定义类型成员(该类没有默认构造函数)
我们应该尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。
成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
举个例子来说:
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print() {
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
}
int main() {
A aa(1);
aa.Print();
}
该代码中,当调用print函数的时候,对应_a1的值为1,而_a2的值为一个随机值。虽然初始化列表中写的是先初始化_a1,再用_a1的值去初始化_a2,但是实际调用的时候,程序会先初始化_a2,然后在初始化_a1,是按照类中成员变量的次序,而不是按照初始化列表中次序来初始化。
构造函数不仅可以构造与初始化对象,对于单个参数的构造函数,还具有类型转换的作用。我们可以用explicit修饰构造函数,将会禁止单参构造函数的隐式转换。
1.2 析构函数
析构函数:与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。
如:~Test()
特性:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值。
- 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
编译器生成的默认析构函数,对会自定义类型成员调用它的析构函数。
对象调用析构函数的顺序:与构造函数的顺序相反。
1.3 拷贝构造函数
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象
创建新对象时由编译器自动调用。
特性:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。
举个例子实现Test类的拷贝构造函数:
Test(const Test &t)
{
m_data = t.m_data;
}
若是参数列表中不是按引用传递,即若没有&
符号的话,拷贝构造函数会无限的递归调用,导致死循环。
原因:
先看一段代码:
Test t(100); Test t1 = t;
当执行到第2行代码的时候,t1会调用拷贝构造函数,该代码就可以看成
t1.Test(t)
,用对象来初始化对象,就会调用拷贝构造函数,那么如果拷贝构造函数的参数没有&
,则每次调用它时,系统都会先创建一个临时的对象复制现在的对象t
,那么对象初始化对象,调用拷贝构造函数,……,然后自己又没有返回条件,这样就会造成无限的递归调用。
若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝。
1.4 赋值重载函数
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型或者枚举类型的操作数
- 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不 能改变其含义
- 作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的操作符有一个默认的形参this,限定为第一个形参.
.*
、::
、sizeof
、? :
、.
注意以上5个运算符不能重载,其余的运算符都可以进行重载
举个例子实现Test类的赋值运算符重载:
//该函数的返回值也可以变为 void 类型的,但考虑到可能会出现连等的情况,即 t1 = t2 = t;因此需要返回一个Test &类型
//当然,返回一个Test类型也可以,它们之间的差距就是一个不用调用拷贝构造函数,一个需要。
Test & operator=(const Test& t) //const 是语义要求, & 是语法要求
{
if(*this != &t) // 这里的&t 不是别名,是取t的地址,这样做的目的防止自己给自己赋值
{
m_data = t.m_data;
}
return *this;
}
简单总结一下,赋值运算符主要有以下几点:
- 参数类型
- 返回值
- 检测是否自己给自己赋值
- 返回 *this
- 一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。
1.5 取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
举个例子:
Test* operator&()
{
return this ;
}
const Test* operator&() const
{
return this ;
}
碎片知识:如果想要阻止拷贝函数,赋值运算符重载等等,可以将这些函数方法都放入private或protected中去。
2. 深浅拷贝
在拷贝构造函数的小节中,我们可以看到若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝。 浅拷贝可以理解为,操作系统把需要拷贝的那块内存中的二进制代码拷贝一份到一个新的内存中去。但是这种情况有时候会出现问题,比如说:
class String
{
public:
String(const char* str = "")
{
_str = (char*)malloc(strlen(str) + 1);
strcpy(_str, str);
}
~String()
{
cout << "~String()" << endl;
free(_str);
}
private:
char* _str;
};
int main()
{
String s1("hello");
String s2 = s1;
}
当一运行该程序的时候,程序就崩溃了,由于该代码并没有显式的定义拷贝构造函数,因此,程序运行时,编译器会按字节序来将s1的成员变量拷贝给s2,这就导致了s1和s2指向同一个变量空间,当main函数结束的时候,系统会自动的调用s2的析构方法来释放其成员变量,然后再调用s1的析构函数来释放其成员变量,由于s1和s2的成员变量是一样,那么就造成的Double Free,于是程序就崩溃掉了。
如图:
当main函数结束后,Tmp空间会被释放两次,从而造成崩溃。
因此,为解决该问题,就需要对其进行深拷贝,深拷贝就是在堆上开辟出一个新的空间来存放原来空间的内容,这样当遇到上种情况时,就不会造成double free,导致程序崩溃。
如图:
实现拷贝构造函数深拷贝方法:
String& operator=(const String& t)
{
if(this != &t)
{
free(this->_str);//如果不free可能会造成内存泄漏
_str = (char* )malloc(strlen(t._str)+1);
strcpy(_str,t._str);
}
return *this;
}
这时候,当程序结束后,s2调用析构函数释放tmp2,s1调用析构函数释放tmp1,二者互不干扰,程序正常结束。
一般,当一个类的成员变量中含有指针类型的参数时,均需要对其拷贝构造函数进行深拷贝赋值。