1、构造函数
写栈的时候,我们需要写入各种函数,最前面要有初始化,退出时要有销毁,但这两个结果经常被忘记,总需要操作者记着销毁和初始化,而C++对此有不同的做法,但也并不是很简便。
C++想让类自动初始化对象。因此创造了构造函数这个概念。
创造函数是一个成员函数,用来初始化对象
特征:
1、函数名与类名相同
2、无返回值
3、对象实例化时编译器自动调用对应的构造函数
4、构造函数可以重载,一个类可以有多个构造函数
复习一下,重载需要两个函数的参数类型、个数、类型顺序至少有一个不同
有了构造函数,我们可以这样写代码。
class Stack
{
public:
Stack()
{
_a = nullptr;
_size = _capacity = 0;
}
Stack(int n)
{
_a = (int*)malloc(sizeof(int) * n);
if (nullptr == _a)
{
perror("malloc fail");
return;
}
_capacity = n;
_size = 0;
}
void Push(int x)
{
_a[_size++] = x;
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack st;
return 0;
}
main函数里写上Stack st,那么程序执行后st就被初始化了,会运行第一个Stack ()这个函数。如果在括号里写上数字,带上参数,那么就会调用Stack(int n) 这个函数。
Stack st(4)不可以写作st.Stack(4)。st还没有实例化,所以不能调用函数;加之不能用对象来调用构造函数,构造函数是比较特殊的。
如果写Stack st()会报错,是因为这个代码不确定意图,编译器不知道这是在单纯地声明函数,还是定义对象,所以不能使用。
2、析构函数
既然有初始化,那也要有销毁。但析构函数又不是为了销毁,对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。创建的对象在main函数结束后就会跟随着栈帧销毁而销毁,而析构只是清理资源。
析构函数的特点:
1、析构函数名是在类名前加上字符~
2、无参无返回值类型
3、一个类只能有一个析构函数。若未显示定义,系统会自动生成默认的析构函数
4、对象生命周期结束时,C++编译系统自动调用析构函数
~Stack()
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
C++中就可以利用 出了作用域会自动调用析构函数的特点,在写数据结构时,代码就会方便很多,并且调用某个成员函数也无需传参,因为参数都在类的私有里面,类自己调用即可。当然成员函数的定义也得写对。
3、构造和析构函数的细究
构造函数
1.基础特点
部分特点
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
这两个函数叫做默认成员函数,默认成员函数还有其他几个,会在之后几篇出现。下面详细介绍构造/析构函数。
我们用之前日期的类来写。
class Date
{
public:
Date()
{
_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;
Date d2(2023, 2, 4);
d1.Print();
d2.Print();
return 0;
}
这里也可以用到缺省参数,把两个Dtae合并
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date d1;
Date d2(2023, 2, 4);
Date d3(2023, 2);
d1.Print();
d2.Print();
d3.Print();
有了这个缺省参数,原先的构造函数Date()就不能存在了,因为这样已经存在歧义了,编译器不知道该用哪个。
2、默认构造函数
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
当使用自动生成的构造/析构函数时,可能会有这样的结果
初始化呢?怎么出来这些个随机值?这也就是C++构造函数的缺陷
C++把类型分为内置(基本)类型和自定义类型。内置类型如int/char…,自定义类型则是class/struct等自己定义的类型。
自动生成的构造/函数,内置类型成员如果给了缺省值,也就是在声明时给值,那就用这个值,否则不做处理,自定义类型的成员,会调用默认构造函数,且不需要传参。
所以这里生成了随机数。写栈,写队列等数据结构时,都是自定义类型,这时候默认函数很简便。
为了解决这个缺陷,C++做了一个补丁,内置类型成员变量在类中声明时可以使用默认值,也就是缺省值。
private:
//声明位置给缺省值
int _year = 1;
int _month = 1;
int _day = 1;
如果用默认构造,那么就会使用这个缺省值,如果给了构造函数,那么就会使用构造函数里的值,不过如果构造里没有给全,剩下的就用缺省值。
Date()
{
_year = 10;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
//声明位置给缺省值
int _year = 1;
int _month = 1;
int _day = 1;
但是默认构造会产生随机值,会影响程序,所以一般需要自己写构造函数,并且对于自定义类型,使用默认构造会更不可控。
3、构造函数的唯一性
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
简单来说,不传参就可以调用构造函数。且要有一个观点的认知,默认构造函数不只是编译器自动生成的构造函数,只要无参,全缺省也是无参。
析构函数
当我们知道了构造函数的特点后,析构函数也是一样,内置不处理,自定义即调用。创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数。不过对于析构要做的事,也有所不同。如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
4、拷贝构造函数
1、拷贝构造的传参
class Date
{
public:
Date(int year = 2023, 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(2023, 2, 6);
Date d2;
return 0;
}
如果想把d2变成d1的拷贝,相当于d2被初始化成d1。这样作用的构造函数就是拷贝构造函数,不过拷贝构造是构造函数的一种重载形式。那么我们在d1里写一个构造函数。
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
但是呢,这时候Date下面会有红线,也就是这样是错误写法。编译器会告诉你第一个参数不应该是Date,并且实际运行起来这将会是一个无穷递归的函数。
我们一步步理解这个问题。
void Func1(Date d)
{
}
void Func2(Date& d)
{
}
int main()
{
Date d1(2023, 2, 6);
//Date d2;
Func1(d1);
Func2(d1);
return 0;
}
这里是一个很正常的函数,参数类型是Date,传引用和传值。但与常见的有不同的是,这是一个自定义类型的传参,如果是内置类型,那么编译器自有规则地去拷贝,但自定义类型编译器无法正常拷贝。
编译器在拷贝自定义类型时,会调用拷贝构造。如果是内置类型,比如int,那么编译器就会做浅拷贝,也就是按字节拷贝过去,实际上C/C++大多是浅拷贝,编译器也是浅拷贝。而自定义类型,比如栈,队列等等,如果是浅拷贝,那么按照字节拷贝,两个自定义类型的变量指向的会是同一块空间,在程序结束时,就会对同一块空间调用两次析构函数,这就出错了。
C++在这方面会用传引用来拷贝,这样就相当于深拷贝了。不过引用就需要考虑一个问题,d2的数据变动会影响d1。
不过还有一个问题没有解决,为什么会无穷递归?
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
上面的func函数里,有传值和传引用,调试起来看看内部如何变化:传值时,程序先走一遍Date类,会调用拷贝构造和构造函数等,再走func函数,这里就是一个纯粹的传参,Func函数将d1传给Date d;而像上面这样写拷贝构造,那么也是一样,把d1传过去,d1传过去就要调用拷贝构造,因为d1是自定义类型,所以要调用拷贝构造函数,拷贝构造函数括号里的Date d又会再次调用d1的拷贝构造,所以就是无穷递归。
用引用的拷贝构造就是这样
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
做拷贝时可以这样写。
Date d1(2023, 2, 6);
Date d2(d1);
Date d3 = d1;
2、默认拷贝构造函数
拷贝构造函数和构造/析构函数一样,也是默认成员函数,所以系统会自动生成,对于日期类这样不改变资源的类,默认拷贝构造没问题;但是像栈这样的类,就会崩溃。与正常的数据不同,自定义类型除了有数据,还指向堆上的一块空间,那么这样只按值拷贝,两个栈指向同一块空间,数据一旦更改,两者都改,并且有析构两次的风险。 两个指针指向同一块空间,有一个置空后,指向NULL,那么另一个指针也会变成野指针。
拷贝构造函数的默认情况也有缺陷,那么什么时候需要自己写拷贝构造函数呢?
有析构函数存在时,需要写拷贝构造函数
这里作为一个参考,因为析构函数意味着这里有资源管理。
所以默认拷贝构造函数对于自定义类型,会调用这个成员的拷贝构造。
结束。