目录
1.前言
我们都知道一个类中如果没有任何成员,则叫做空类。那么空类中真的什么都没有吗?
答案是否定的,当一个类中一个成员没有时,编译器会自动生成六个默认成员函数,它们不仅可以提高我们运行的效率,还可以增加代码的可读性,让我们来深入了解一下这六个成员函数吧。
2.构造函数
2.1 概念
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证
每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任
务并不是开空间创建对象,而是初始化对象。
其特征如下:
1. 函数名与类名相同。
2. 无返回值。(也不需要void)
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
void Init()
{
a = (int*)malloc(sizeof(int) * 4);
if (a == nullptr)
{
perror("malloc fail\n");
return;
}
capacity = 4;
top = 0;
}
int main()
{
Stack s;
s.Init();
}
上述代码就是我们在写栈时写的初始化,有些时候可能会忘记进行初始化,所以引入了构造函数
Stack(int capacitydefault = 4)
{
a = (int*)malloc(sizeof(int) * capacitydefault);
if (a == nullptr)
{
perror("malloc fail\n");
return;
}
capacity = capacitydefault;
top = 0;
}
上述代码就是我们在类中写的构造函数,这时哪怕我们忘记初始化,编译器也会在对象实例化时自动调用构造函数。
我们上面说过类中没有成员时,编译器会自动生成默认成员函数,而构造函数身为六个默认构造函数之一,我们如果不写,编译器会自动生成吗?答案是肯定的,这时会有一个疑问,既然编译器会自动生成,那我们还费那么大力气写它干嘛。
我们先看一下下面的代码:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
//内置类型
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
}
上面的代码中,我们没有写构造函数,用编译器自动生成的进行调用,但结果中三个变量变成了随机值,为什么?我们再看下面的代码:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
//内置类型
int _year;
int _month;
int _day;
//自定义类型
Stack _st;
};
这串代码及结果我们又可以看到当我们加上自定义类型的变量时,再运行后三个变量就被初始化了,为什么?
解释原因前先介绍一下内置类型和自定义类型:
内置类型:像是int/char/double/指针等这些基础类型
自定义类型:自定义、用struct/class等定义的类型
原因:
我们不写构造函数时,编译器会自动生成默认无参构造函数,它不会对内置类型做处理,只有出现自定义类型时才会去调用编译器自动生成的默认构造函数。
而上面那种声明了自定义类型的变量后内置类型也被初始化的这种情况实际是编译器的不同版本导致的,有的版本就像我用的vs2022的一样会对其初始化,但vs2013就不会对其初始化了,所以构造函数不要完全依赖于编译器自动生成的。
搞清楚了这些问题后,我们再来看一下面的这串代码:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
//内置类型
int _year=1;
int _month=1;
int _day=1;
};
int main()
{
Date d1;
d1.Print();
}
这里我们在每个内置类型声明时给它们先赋一个值,但这里并不是初始化:
因为这个位置是声明,在声明处给他们赋值是默认的缺省值,如果我们没有写构造函数,那么运行后的结果就会用这些缺省值来代替,而只要我们写了构造函数后,就不会再用这些缺省值了。
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 d2(2024,1,1);
Date d1;//这里是无参构造函数,后面不加括号,否则就变成了声明
return 0;
}
我们前面提到了那么多次默认构造函数,那么默认构造函数只是编译器自动生成的吗?
编译器自动生成的只是默认构造函数中的一种,那默认构造函数都有那些?
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为
是默认构造函数一句话概括就是:不传参就可以调用的就是默认构造函数。
经过上面的讲解我们了解了构造函数,这里进行简单的总结:
1、一般情况下,尽量自己写构造函数
2、那么什么时候不写,用系统默认的?
a.内置类型都有缺省值,并且初始化符合要求
b.全是自定义类型的构造,且这些类型都定义默认构造
最后一句的意思是:假如我们创建了一个类myclass,这个类外定义了三个类Type1,Type2,Type3,并且这三个都在myclass类中各自声明了一个自定义变量:
Type1 t1;
Type2 t2;
Type3 t3;这三个类中有我们写的构造函数,这时,myclass类中就可以不写构造函数,直接用编译器自动生成的。
3.析构函数
3.1 概念
前面介绍了初始化的构造函数,那么有初始化怎么会没有销毁呢。C++引入了析构函数用来对对象的销毁。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.2 特性
析构函数是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
前面我们解释了析构函数用在销毁对象,那么销毁的是内置类型还是自定义类型又或者是两者都会调用呢?我用下面的代码来解释:
class Time
{
public:
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
这里程序运行后的结果是~Time()
在main函数中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main函数中创建了Date对象d,而d中包含4个成员变量,其中_year,_month,_day三个是内置类型成员,销毁时不需要资源清理,自然不需要调用析构函数,最后系统直接将其内存回收即可;
而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。
但是:
main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显示提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁,main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数。
那么什么时候需要写析构函数,什么时候不需要写呢?
1、一般情况下,有动态申请资源,就需要显示写析构函数释放资源
class Stack { private: int* _a; };
2、没有动态申请的资源,不需要写析构
class Date { private: int _year; int _month; int _day; };
3、需要释放资源的成员都是自定义类型,不需要写析构。
class MyQuene { private: Stack _push; Stack _pop; };
了解了析构函数和构造函数,下面有串代码,我们来思考一下a和b的构造函数和析构函数的调用顺序。然后为下面四个函数排一下顺序吧。
a构造,a析构,b构造,b析构
class A
{
public:
A()
{}
~A()
{}
};
class B
{
public:
B()
{}
~B()
{}
};
void F()
{
A a;
B b;
}
int main()
{
F();
}
答案:a构造,b构造,b析构,a析构
因为在F函数中首先定义了A的对象a,然后进入A中调用它的构造函数,因为此时a对象并没有销毁,所以不会调用a析构函数,接着进入到B中,调用它的构造函数,出来后该F函数被销毁时,a和b会按照与调用构造函数相反的顺序调用析构函数,所以会先去调用b的析构函数,再调用a的析构函数。
4.拷贝构造函数
4.1 概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造函数的作用就是将一个已存在的对象中的信息拷贝给另一个对象。
4.2 特性
拷贝构造函数也是特殊的成员函数,其特征如下:
1. 拷贝构造函数是构造函数的一个重载形式。2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
这里为什么使用传值方式会引发无穷递归呢?
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int main()
{
Date d1(2024,11,10);
Date d2(d1);//调用拷贝构造的方式
}
这里我们用传值的方式调用拷贝构造函数,编译器会对此发出警告,就是因为引发了无穷递归
原因:在了解无穷递归问题之前,我们先了解一下内置类型是如何传参的:当我们传递内置类型的变量时,编译器会将这个变量直接拷贝给形参,这也是我们后面会提到了浅拷贝/值拷贝;当我们传递自定义类型变量给形参时,编译器不会直接将对象拷贝给实参,而是先调用这个对象的拷贝构造函数后再传入实参。
下面我用正确的方式进行验证:
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void func(Date t)
{};
int main()
{
Date d1(2024,11,10);
func(d1);
}
这里我们创建了一个形参为Date t的func函数,看是否会向上面说的那样传入自定义对象时会先调用它的拷贝构造函数:
当我们进入调试时,一步一步走可以发现:当我们在main函数中运行到func函数时,不会进入func函数中,而是先进入到Date的拷贝构造函数中,执行完后,又回到了mian中的func,再往后才会进入func函数中。
由此我们验证了:当实参为自定义类型时,编译器不会直接进入该函数也不会直接拷贝给对应的形参,而是先调用它的拷贝构造函数。
当我们有了上面的理解后,我们就可以解释了为什么我们以传值方式调用时会出现无穷递归的现象:
当我们以传值的方式将d1拷贝给d2时,因为d1是自定义对象,所以编译器会自动调用d1的拷贝构造函数,进入拷贝构造函数后,因为拷贝构造中d为传值的形参,所以又会再次调用拷贝构造,依次循环就形成了无穷递归。
了解了为什么要用传引用而不是传值后,我们再来说一下加const的目的,const可加可不加,但是如果不加的话像遇到下面这种情况编译器是无法检测出来的。
Date(Date& d)
{
d._year=_year;
d._month=_month;
d._day=_day;
}
这串代码中,我们将拷贝的与被拷贝的值弄反了,这样编译器没有报错,但实际的结果是错的,当我们加上const时,他会限制d对象的值无法改变,所以遇到这样类似的错误编译器就会报错了。
3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
就像我们上面说的那样,内置类型会被直接拷贝过去,而自定义类型则是去调用对应的拷贝构造函数。
Stack st1;
Stack st2(st1);
但我们写栈时不写拷贝构造函数,用编译器自动生成的拷贝函数,会发现程序会崩溃,是什么原因?
因为我们在编译器生成的默认拷贝构造函数中,自定义类型会以浅拷贝的方式进行的,这种是直接将st1中的所有类型拷贝到st2中,内置类型中的_top和_capacity是int型没问题,但还有int* 指针型_a,拷贝函数会将_a所在的空间也拷贝过去,那么此时st1和st2中的_a就处在了同一空间, 而初始化时又给_a分配了空间,所以结束时会执行析构函数,而这些自定义类型会以压栈的形式进入栈帧,而出栈的顺序是先入后出,因为st1是先入的,所以st1是后出的,st2是先出的,所以st2中的_a先被销毁,此时所在的空间被析构了,而到st1时,st1空间又被析构,同一空间连续被析构两次,自然会出现上述代码编译的错误。除了多次析构同一片空间,还有其他问题:当改变st1中的数时,因为st1和st2在同一空间,所以st2中的值也会发生改变。
结论:类中如果没有资源申请时,拷贝构造函数可写可不写;但如果类中有资源申请时,拷贝构造函数就必须要写,不然自定义变量就会变成浅拷贝,会出现上述说的错误。
5.赋值运算符重载
5.1 运算符重载
为了增加代码的可读性,C++增加了运算符重载函数,它可以帮助我们让自定义类型也可以像内置类型一样使用加减乘除等符号。
格式:返回值类型+operator操作符(参数列表)
注意:
1、要重载的操作符必须是C++本身就有的,不可以创建新的操作符
2、参数中必须有一个是类类型参数
3、用于内置类型使用的操作符,我们重载给自定义类型使用时,不可以改变改操作符本身的含义
4、作为类成员函数重载时,形参数比实际操作数要少写一个,因为类成员函数中有this,会自动指向第一个对象
5、.* :: sizeof ?: . 注意以上5个运算符不能重载。
例如,我们想要比较一下两个日期的大小,可以用下面代码的形式:
bool func(const Date& x1, const Date& x2)
{
if (x1._year > x2._year)
{
return true;
}
else if (x1._year == x2._year && x1._month > x2._month)
{
return true;
}
else if (x1._year == x2._year && x1._month == x2._month && x1._day > x2._day)
{
return true;
}
else
return false;
}
这种形式我们可以看到写的很多,并且每次调用都需要调用函数,不仅麻烦,可读性也较差,如果我们可以用内置类型那样直接用大于号或小于号来表示,不仅方便而且可读性也会更好,所以这里就可以用运算符重载函数来对符号进行重载来让自定义类型也可以使用这些操作符了。
bool operator>(const Date& x1,const Date& x2)
{
if (x1._year > x2._year)
{
return true;
}
else if (x1._year == x2._year && x1._month > x2._month)
{
return true;
}
else if (x1._year == x2._year && x1._month == x2._month && x1._day > x2._day)
{
return true;
}
else
return false;
}
Date st1(2022, 11, 24);
Date st2(2023, 11, 24);
st1 > st2;
这样写我们就可以直接用 > 来比较两个日期类的大小了,大大增加了可读性,但是这里也会出现一个错误:因为我们是在类外面定义的这个重载函数,所以三个私有的内置类型变量就无法使用,为了解决这样的问题我们只需将其放到类中,但要注意因为放到类里以后,他就变成了成员函数了,所以这时就有了this指针充当第一个形参,所以我们在写形参时就需要少写一个参数,不然就会报错,最终形式如下面所示,不仅可以解决内置类型私有问题,还可以让代码变得更加简练。
bool operator>(const Date& x)
{
if (_year > x._year)
{
return true;
}
else if (_year == x._year && _month > x._month)
{
return true;
}
else if (_year == x._year && _month == x._month && _day > x._day)
{
return true;
}
else
return false;
}
st1>st2;
st1.operator>(st2);
上面的两行代码底层逻辑是相同的,所以两者都可以表达对于两个日期的比较。
了解了运算符重载的基本知识,我们可以探讨一下对于cout和cin的了解,我们都知道C++中用cout和cin代替了C语言中的printf和scanf,好处在于它们不需要我们考虑传输数据的类型,因为编译器会自动识别,那它们的底层逻辑是如何实现的呢?我们在这里说到了这个知识,大家可能都知道了与运算符重载有关,我们在使用cout和cin前会包含iostream这个头文件,而这个文件的下一级还有两个文件:istream和ostream,这两个中存放着对cou和cin关于int,char等类型的重载函数。
我这里截取了一些ostream中对各类型的重载函数,我们看到了这些都是对于内置类型的重载,而自定义类型的重载函数则需要我们自己去写。
void Date::operator<<(ostream& out)
{
out << _year << "年" << _month << "月" << _day << "日" << endl;
}
int main()
{
Date d1(2025, 3, 5);
Date d2;
cout << d1;//error:没有与这些操作数匹配的"<<"运算符
}
当我们写了对cout的重载函数后,会发现最后一行报错了,因为在成员函数中有默认this指针充当第一个形参,我们这里按正常的对于cout的调用是放到第一个位置,要打印的d1放到第二个位置,但这样的话就变成了cout传给了this,d1传给了out,而cout中没有这些要打印的值,而d1又不具有打印能力,自然会报错了,要想不报错,我们只需将cout和d1互换位置即可,但那样很别扭,所以要想解决this指针影响的问题,我们只需将此函数放到全局域中就可以去掉this指针的影响了。
但是这里还有问题,当我们将其放到全局域中时,我们使用的私有类型就无法使用了,这里对函数可以在类外使用私有类型会简单介绍两种方式:
1、对于Date类我们可以在类中写三个成员函数,然后让他们分别返回三个私有类型变量
int GetYear() { return _year; } int GetMonth() { return _month; } int GetDay() { return _day; } ostream& operator<<(ostream& out,const Date& d) { out << d.GetYear() << "年" << d.GetMonth() << "月" << d.GetDay() << "日" << endl; return out; }
2、在类中使用友元函数声明
//友元函数声明(可以放到类中的任何位置) friend ostream& operator<<(ostream& out, const Date& d); void operator<<(ostream& out,const Date& d) { out << d._year << "年" << d._month << "月" << d._day << "日" << endl; }
友元函数带着friend,我们可以简单的理解就是clas相当于是一个家,函数被加上friend后就变成了这个家中主人的朋友,就可以进入家中,在外面也可以使用家中的一些东西,详细会在后面讲解
我们在内置类型中时常使用连续打印的情况,而我们这样写只可以保证打印一个,那么我们如何做可以使得编译器连续打印?要想明白自定义类型的连续打印,我们需先了解内置类型中连续打印的大致流程:
例如我们打印两个变量a和b:cout<<a<<b;首先打印a后接着cout回去执行b,那么自定义类型与此相似,我们要打印d1和d2两个自定义对象:cout<<d1<<d2;自然也是先打印d1后cout再去打印d1,但我们打印完d1后cout和d1也都已经被打印了,而对自定义对象的打印是我们自己写的重载,编译器无法像内置类型一样打印完一个再打印另一个,为了让cout可以持续存在,我们只需返回out就可以了
ostream& operator<<(ostream& out,Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
只需将返回类型更改成ostream就可以让out持续存在了。
5.2 赋值运算符重载
上面我们深入了解运算符重载知识后,了解赋值运算符重载就会变得较为简单了。
赋值运算符重载格式:
参数类型:const T&,传递引用可以提高传参效率
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
检测是否自己给自己赋值
返回*this :要复合连续赋值的含义
赋值运算符重载函数属于运算符重载中的一种,为什么不将运算符重载函数放到六个默认函数里呢?因为六个默认函数编译器会自动生成,而我们上面讲的那些运算符重载函数是无法自动生成的,但赋值运算符重载函数是可以由编译器自动生成的。
下面是赋值运算符重载函数的基础形式,可以解决基本的自定义类型的赋值运算,但如果遇到连续赋值情形,这样就无法进行了。
void operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
}
int main()
{
Date d2, d3;
d2 = d3;
}
下述代码与上面我们说的cout的情形类似,但cout是因为与要打印的对象顺序反了,而这里的原因又是什么?
首先我们要理解内置类型是如何进行的,内置类型的连续赋值是从右至左,一个赋值完成后,会将赋值完成后的值作为返回值再赋值给下一个变量;
了解了内置类型的连续赋值,我们可以发现一个重点:每次赋值后是通过返回值对下一个便量进行赋值的。那么自定义类型也是这样的:赋值完成后需要返回赋值后的对象
Date d1(2024, 11, 10);
Date d2, d3;
d2 = d3 = d1;
我们只需将函数更改成下方这样就可以了
前面的入门篇我们了解了不用引用做返回值时内置类型会先创建一个临时对象来存储返回值,而自定义类型则会调用拷贝构造函数后再返回,所以我们这里的返回类型用传引用返回就可以不调用拷贝构造函数,直接返回,提高了效率。
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
当然,这样写并不是最完整的展现,因为当遇到自己赋值给自己时所有赋值过程都要再执行一遍,我们设置一个if条件,这样就可以在自己赋值自己时直接返回。
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
下面还有几点需要注意:
1、用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
赋值运算符重载函数与前面的几个默认函数一样,当我们对于像Date这样的只含有内置内省成员变量的类时,可以不写赋值运算符重载函数,直接用编译器自动生成的;但对于像Stack这样的含有自定义类型的成员对象的类来说就需要自己写了。
2、赋值运算符只能重载成类的成员函数不能重载成全局函数
为什么?因为当我们在全局域中定义了赋值运算符重载函数时,类中又会生成一个默认的重载函数,这样两个重载函数就会发生冲突
当然如果在类中声明而在类外定义这样是没问题的。
了解了赋值运算符重载函数后,不知道大家有没有一个疑问:赋值运算符重载函数与拷贝构造函数的运用非常相似,那二者的调用区别在于是否有赋值运算符吗?
这里先说结论:有赋值运算符可能会调用拷贝构造,也有可能调用赋值重载函数
原因:看调用的是哪个函数,最主要的是其他原因:
拷贝构造函数:用一个已经存在的对象初始化另一个对象
Date d1; Date d2=d1;//Date d2(d1)
赋值重载函数:两个已经存在的对象之间的复制拷贝
Date d2, d3; d2 = d3;
6.取地址及const取地址操作符重载
这两个默认成员函数不需要我们自己写,编译器自动生成的函数基本上就可以解决所有问题了。所以这里直接放到一起了。
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};
这里出现了const跑到了函数的后面,这是const的一种用法,这里着重讲解一下const
6.1 const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数
隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
Date d1(2025, 3, 1);
const Date d2(2025, 3, 3);
d1.Print();
d2.Print();//error
这里d1和d2都调用了Print函数,但d2却报错了,是因为Print函数没有用const修饰,而这里d2用const限制了,那么const限制的d2想要调用没有const限制的Print函数,这属于权限的放大,因为权限只可以平移和缩小,所以d2自然会报错。这时我们只要将Print也加上const就可以了,但为什么要加到后面而不是前面呢?因为加在前面的const限制的是返回值,代表返回值不可以改变,而这里const限制的是d2这个对象,对应的是函数的参数,后面的const限制的就是参数,因为成员函数中的this不可以显示在形参中,所以为了给它加上限制const就const加在函数后代表加在了参数中。
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
由上述例子我们可以看出加const是有好处的,我们在成员函数中加const,这样const对象和普通对象都可以调用了。
那么是否所有的成员函数都可以加const?
这要看const限制的对象在函数中是否需要发生改变,例如:+=的重载函数就不可以加,这个函数的本质就是要改变自身对象的值,如果加了const就没有意义了;而像+的重载函数就可以,因为这个函数的本质是将它加上一个数的值赋给另一个对象,它本身没有发生改变,所以可以加const。
------------------------------------------------------ end --------------------------------------------------------------------