1、类的六个默认成员函数
如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
2、构造函数
2.1 构造函数的概念
我们这里来看看日期类的初始化:
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Init(2022, 7, 5);
d1.Print();
return 0;
}
运行结果:
我们刚接触C++,一定会这样初始化。
如果我们实例化的对象太多了,忘记初始化对象了,程序运行出来的结果可能就是随机值了,也可能出问题。
这里C++祖师爷想到了,为我们设计了构造函数。
我们先来看一下忘记初始化直接打印的结果:
这里是随机值,那这是为什么呢?我们接着往下看。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
1. 函数名与类名相同。
2. 无返回值(不是void,是不用写)。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。
我们先写一个日期类的构造函数来看看:
class Date
{
public:
Date()//构造函数,无参构造
{
cout << "Date()" << endl;
_year = 1;
_month = 1;
_day = 1;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day;
}
private:
int _year;
int _month;
int _day;
};
我们测试看一下:
我们main函数里没有调用构造函数,但是这里打印了我们做的标记,这里我们实验出来了实例化对象时构造函数是自动调用的。
我们再来看将我们写的构造函数注释掉会发生什么:
我们能看到,注释掉后,仍然能打印出来,只不过是随机值。因为当我们不写,编译器会自动生成默认的构造函数,并自动调用。
C++将类型分为内置类型(基本类型):如int,char,double,int*……(自定义类型*也是);
自定义类型:如class,struct,union……。
并且这里我们能看出来,对于内置类型的成员不会处理,在C++11,支持成员变量给缺省值,算是补漏洞了。
2.2.1 构造函数的重载:
class Date
{
public:
Date()
{
cout << "Date()" << endl;
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
Date d2(2023, 8, 1);//这里初始化必须是这样写,这是语法
d2.Print();
return 0;
}
运行结果:
注意:我们在实例化对象的时候,当调用的构造函数没有参数,不能在对象后加括号,语法规定。
如果这样写,编译器分不清这到底是函数声明还是在调用。d2不会混淆是因为有传值,函数声明不会出现那样的写法。
2.2.2 全缺省的构造函数:
我们其实可以将上面的两个构造函数合并为一个
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
Date d2(2023, 8, 1);
d2.Print();
Date d3(2023, 9);
d3.Print();
return 0;
}
运行结果:
全缺省构造函数才是最适用的。无参构造与全缺省可以同时存在,但是不建议这样写,虽然不报错,但是在调用全缺省时我们不想传参,编译器不知道我们到底想调用哪个构造,会产生二义性。
我们再看用两个栈实现队列的问题:
class Stack
{
public:
Stack(int n = 4)
{
if (n == 0)
{
_a = nullptr;
_size = -1;
_capacity = 0;
}
else
{
int* tmp = (int*)realloc(_a, sizeof(int) * n);
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
_a = tmp;
_size = -1;
_capacity = n;
}
}
void Push(int n)
{
if (_size + 1 == _capacity)
{
int newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
int* tmp = (int*)realloc(_a, sizeof(int) * newcapacity);
if (nullptr == tmp)
{
perror("realloc fail:");
exit(-1);
}
_a = tmp;
_capacity = newcapacity;
}
_a[_size++] = n;
}
int Top()
{
return _a[_size];
}
void Pop()
{
assert(_size > -1);
_size--;
}
void Destort()
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
bool Empty()
{
return _size == -1;
}
private:
int* _a;
int _size;
int _capacity;
};
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
一般情况下都需要我们自己写构造函数,决定初始化方式,成员变量全是自定义类型,可以考虑不写构造函数。会调用自定义类型的默认构造函数。
总结:无参构造函数、全缺省构造函数、我们不写编译器默认生成的构造函数,都可以认为是默认构造函数,并且默认构造函数只能存在一个(多个并存会产生二义性)。
3、析构函数
3.1 析构函数的概念
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数(对内置类型不做处理,自定义类型会调用它自己的析构函数)。注意:析构函数不能重载。
4. 对象生命周期结束时,C++编译系统会自动调用析构函数。
我们先来看日期类的析构:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
cout << "Date(int year = 1, int month = 1, int day = 1)" << endl;
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
cout << "~Date()" << endl;
_year = 0;
_month = 0;
_day = 0;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2;
return 0;
}
运行结果:
我们这里可以看出析构函数也是自动调用的。
我们不写,编译器自动生成默认的析构函数。
析构函数的调用顺序跟栈类似,后实例化的先析构。
如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
我们画图来看一下:
栈中的析构函数就代替了栈的销毁:
class Stack
{
public:
Stack(int n = 4)
{
if (n == 0)
{
_a = nullptr;
_top = -1;
_capacity = 0;
}
else
{
int* tmp = (int*)realloc(_a, sizeof(int) * n);
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
_a = tmp;
_top = -1;
_capacity = n;
}
}
~Stack()
{
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
//void Destort()
//{
// free(_a);
// _a = nullptr;
// _top = _capacity = 0;
//}
void Push(int n)
{
if (_top + 1 == _capacity)
{
int newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
int* tmp = (int*)realloc(_a, sizeof(int) * newcapacity);
if (nullptr == tmp)
{
perror("realloc fail:");
exit(-1);
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = n;
}
int Top()
{
return _a[_top];
}
void Pop()
{
assert(_top > -1);
_top--;
}
bool Empty()
{
return _top == -1;
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
对于栈这样的,我们析构函数代替了销毁函数,析构函数会自动调用,以前我们需要手动调用销毁函数的接口,现在不用调用了。
因此,构造函数和析构函数最大的优势是自动调用。
4、拷贝构造函数
4.1 拷贝构造函数的概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
4.2 特征
拷贝构造函数也是特殊的成员函数,其特征如下:
1. 拷贝构造函数是构造函数的一个重载形式。
2. 拷贝构造函数的参数只有一个且必须是同类型对象的引用,使用传值方式编译器会引发无穷递归调用。
3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
拷贝构造就像是复制粘贴一样。
拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器会引发无穷递归调用。
传值拷贝会发生无穷递归,我们来写一个拷贝构造函数。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
_year = 0;
_month = 0;
_day = 0;
}
private:
int _year;
int _month;
int _day;
};
void func(Date d)
{
d.Print();
}
int main()
{
Date d1(2023, 8, 2);
func(d1);
return 0;
}
内置类型的拷贝是直接拷贝,自定义类型的拷贝要调用拷贝构造完成。
在vs2019中,传值传参编译器会报错:
因此,我们要是写拷贝构造函数,形参必须是同类型的引用:
引用是给变量起别名,析构自动调用的顺序是后定义先析构,拷贝的时候d1还没有析构,因此是可以使用引用的,这样就不会导致递归拷贝了。
我们再来看栈的拷贝构造:
typedef int DataType;
class Stack
{
public:
Stack(int n = 4)
{
if (n == 0)
{
_a = nullptr;
_size = -1;
_capacity = 0;
}
else
{
int* tmp = (int*)realloc(_a, sizeof(int) * n);
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
_a = tmp;
_size = -1;
_capacity = n;
}
}
//拷贝构造
Stack(Stack& s)
{
_a = s._a;
_size = s._size;
_capacity = s._capacity;
}
~Stack()
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
void Push(int n)
{
if (_size + 1 == _capacity)
{
int newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
int* tmp = (int*)realloc(_a, sizeof(int) * newcapacity);
if (nullptr == tmp)
{
perror("realloc fail:");
exit(-1);
}
_a = tmp;
_capacity = newcapacity;
}
_a[_size++] = n;
}
int Top()
{
return _a[_size];
}
void Pop()
{
assert(_size > -1);
_size--;
}
bool Empty()
{
return _size == -1;
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);
return 0;
}
我们这里为栈写的拷贝构造,我们来试一下拷贝构造:
这里为什么引发了异常呢?
我们调试看看:
这里我们可以看到,s1的_a与s2的_a地址是一样的,当s2拷贝完后就会析构,s2的_a被释放掉后,s1还会再调用一次析构函数,这时再去释放_a,_a的空间已经被释放过了,就会引发空指针异常的问题。
因此,对于有空间申请的对象,在写拷贝构造的时候必须要深拷贝。
我们来改正代码:
typedef int DataType;
class Stack
{
public:
Stack(int n = 4)
{
if (n == 0)
{
_a = nullptr;
_size = -1;
_capacity = 0;
}
else
{
int* tmp = (int*)realloc(_a, sizeof(int) * n);
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
_a = tmp;
_size = -1;
_capacity = n;
}
}
//拷贝构造
Stack(Stack& s)
{
cout << "Stack(Stack& s)" << endl;
//深拷贝
_a = (DataType*)malloc(sizeof(DataType) * s._capacity);
if (nullptr == _a)
{
perror("malloc fail:");
exit(-1);
}
memcpy(_a, s._a, sizeof(DataType) * (s._size+1));
_size = s._size;
_capacity = s._capacity;
}
~Stack()
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
void Push(int n)
{
if (_size + 1 == _capacity)
{
int newcapacity = _capacity == 0 ? 4 : 2 * _capacity;
int* tmp = (int*)realloc(_a, sizeof(int) * newcapacity);
if (nullptr == tmp)
{
perror("realloc fail:");
exit(-1);
}
_a = tmp;
_capacity = newcapacity;
}
_a[_size++] = n;
}
int Top()
{
return _a[_size];
}
void Pop()
{
assert(_size > -1);
_size--;
}
bool Empty()
{
return _size == -1;
}
private:
int* _a;
int _size;
int _capacity;
};
运行结果: