0.知识补充
C++把类型分成内置类型(基本类型)和自定义类型。
- 内置类型:int/char/double…/任意指针
- 自定义类型:class/struct…定义的类型
默认构造:不用传参的构造都是默认构造——无参的、全缺省的、编译器自动生成的。
1.类的6个默认成员函数
首先,对于任何一个类,都需要Init初始化和Destroy释放空间,在C语言中,我们每次使用结构体都需要自己手动对其初始化和释放清理空间,使用起来十分的不方便。
所以C++中添加了可以让编译器在类中自动生成初始化、空间释放清理等成员函数的功能。
若在一个类中什么都不写(空类),则编译器会在其中自动生成6个默认成员函数,分别是:
- 构造函数
- 析构函数
- 拷贝构造
- 赋值重载
- 取地址
- const取地址
!!!默认成员函数都是天选之子,即使不写也会自动生成!!!
2.构造函数
首先编写一个简单的Stack类进行引入:
class Stack
{
public:
void Init(int capacity = 4)//初始化
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a = nullptr)
{
perror("malloc申请空间失败");
exit(-1);
}
_capacity = capacity;
_size = 0;
}
void Destroy()//释放空间
{
//...
}
void Push(int x = 0)//插入
{
//...
}
private:
int* _a;
int _capacity;
int _size;
};
int main()
{
Stack s1;
s1.Init(4);
s1.Push(1);
s1.Destroy();
return 0;
}
对于Strack类,我们每次创建一个对象都要通过Init来初始化对象,使用起来不方便,所以引入了构造函数来解决这个问题。
2.1.构造函数的特性
构造函数是特殊的成员函数,注意构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
构造函数的特征:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦 用户显式定义任意一个,编译器将不再生成。
- 单参数的构造函数支持隐式类型的转换,可以将自定义类型转换为内置类型
特征解析:
1.以Stack类为例,它的构造函数名也是Stack;
2.没有返回值的意思不是返回void,而是实际意义上的没有返回值,直接写为Stack;
3.会自动调用,即即使用户不手动调用初始化对象,编译器也会自动对初始化,这点比较重要;
4.构造函数可以重载,意思是一个类可以有多个构造函数,即一个类可以有多种初始化的方式,至于会使用哪个构造函数来初始化,则是取决于参数;
5.只要自己显式定义任意一个构造函数,编译器就不会自动生成了,就算是自己定义了有参的,没有写无参的,而调用的时候需要使用的是无参构造函数,编译器依旧不会自动生成无参默认构造函数;
以栈类为例:
class Stack
{
//无参构造函数
Stack()
{
_a = nullptr;
_size = _capacity = 0;
}
//构造函数支持函数重载
//有参构造函数
Stack(int capacity)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a = nullptr)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
};
int main()
{
Stack s1;//实例化对象s1,自动调用析无参构造函数对s1进行初始化
Stack s2(4);//实例化对象s2,自动调用有参的析构函数对s2进行初始化
return 0;
}
2.1.1.全缺省构造函数
有些构造函数中,重载的两个构造函数可以通过全缺省参数合二为一:
//以日期类为例:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
2.1.2.构造函数的调用方法
以日期类的调用为例说明
1.无参构造函数:Date d1;——通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。
2.有参构造函数:Date d2(2023,1,1);——这句不能理解为d2.Date(2023,1,1);因为在走这句程序的时候d2还没有实例化,是定义好了才在后面初始化的;其次这句加了括号为什么不会判定为函数声明呢?因为如果是函数声明的话括号内应该有参数类型。
2.2.系统自动生成的默认构造函数
C++规定对象在实例化的时候必须调用构造函数!
如果我们自己写了任意一个构造函数,就会调用我们自己写的那个,不再生成默认构造函数;
如果我们没有写构造函数,则编译器会自动生成一个默认构造函数,并调用这个自动生成的。
//以日期类为例
class Date
{
public:
Date(int year, int month, int day)
{}
private:
int _year;
int _month;
int _day;
};
int main()
{
Data d1;//初始化失败,因为我们自己写了有参的构造函数,而这里调用的是无参的
//并且因为我们已经写了一个构造函数,所以系统不再会自动生成无参构造函数
return 0;
}
那么系统自动生成的默认构造函数是什么样的呢?
默认生成的构造函数:
- 对内置类型的成员不做处理
- 对自定义类型的成员,调用它的默认构造(不用传参的构造都是默认构造——无参的、全缺省的、编译器自动生成的)
因为对内置类型不做处理,所以若是类中的成员变量是内置类型,在通过自动生成的构造函数初始化时虽然能初始化成功,但是这些内置类型的成员变量会赋随机值。
2.2.1.不应该使用自动生成默认构造函数的场景
//以日期类为例:
class Date
{
public:
//自动生成默认构造函数
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date.d1;
d1.Print();//日期类中的年月日设置为私有,要通过函数访问打印
//打印出的年月日为随机值
return 0;
}
解析:
因为日期类中的年月日都是内置类型int,自动生成的构造函数对其不做处理,会赋随机值。
所以,对于像日期类这种类型的类,不要使用默认生成的构造函数。
2.2.2.可以使用自动生成默认构造函数的场景
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a = nullptr)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
~Stack()
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
class MyQueue
{
public:
//默认生成构造函数,对自定义类型调用它的默认构造
void Push(int x)
{}
private:
Stack _pushST;//自定义类型
Stack _popST;//自定义类型
}
int main()
{
MyQueue.q1;//自动生成默认构造函数并调用
return 0;
}
解析:
MyQueue类中的成员变量都是自定义类型Stack,自动生成的构造函数调用它的默认构造(不用传参的构造)。
这里同样不用显式定义析构函数,因为自动生成的析构函数~ MyQueue会对自定义类型调用它的默认析构~Stack。
所以在应用是直接MyQueue.q1;对对象进行实例化即可,其他的编译器会自动完成,如果用C语言编写这个程序会麻烦很多,在这种情况下应用自动生成的默认构造很便利。
2.3.C++11对内置类型不初始化的补丁
C++11 中针对内置类型成员不初始化的缺陷打了补丁。
即:内置类型成员变量在类中声明时可以给默认值(缺省值)。
这样就可以处理自动生成的构造函数对内置类型不处理的问题了,会使用缺省值初始化,而不是赋随机值。如果显式初始化了,那么就不用缺省值。
//以日期类为例
class Date
{
public:
//自动生成默认构造函数
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1;//在声明的时候可以给缺省值
int _month = 1;//在声明的时候可以给缺省值
int _day = 1;//在声明的时候可以给缺省值
};
3.析构函数
与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由
编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
3.1.析构函数的特征
析构函数的许多性质与构造函数类似:
- 析构函数名是在类名前加上字符 ~。
- 无参数、无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
注意:析构函数不能重载。 - 对象生命周期结束时,C++编译系统系统自动调用析构函数。
- 对内置类型的成员不做处理;对自定义类型的成员,调用它的默认析构。
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
C语言和C++释放清理空间对比:
//这里只写函数的调用方法对比
//C语言
--------------------
//...
Stack s1;
StackInit(& s1);
StackPush(&s1, 1);
StackDestroy(&s1);
//...
--------------------
//C++
--------------------
//...
Stack s1(4);//调用有参构造函数
s1.Push(1);
//没有申请资源的话不用显式调用析构,可以自动调用默认析构函数
//...
--------------------
//析构函数
~Stack()
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
4.拷贝构造
拷贝构造函数:只有单个形参,该形参是对本类的类型对象的引用(一般常用const修饰),在用已存在的类的类型对象创建新对象时由编译器自动调用。
class Date
{
public:
//构造函数
Date(int year = 2023, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
//构造函数的重载,只有参数不同构成重载
Date(const Date& d)//此处若不用传引用,用传值传参则会无穷递归
{
_year = d.year;
_month = d.month;
_day = d.day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2023, 1, 1);
Date d2(d1);//拷贝构造
return 0;
}
补充:拷贝构造的使用方法:
//将d1拷贝给d2
Date d2(d1);
Date d2 = d1;//也可以这样写,二者都是拷贝构造
注意第二种用法,不是赋值,是拷贝构造
4.1.拷贝构造的特征
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须是类的类型对象的引用,使用传值方式编译器会直接报错,因为会引发无穷递归调用。
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
- 在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
4.1.1.自定义类型传值传参的拷贝构造
传值传参需要对实参进行拷贝。
对于传值传参:
- 内置类型,编译器可以直接拷贝
- 自定义类型,需要调用拷贝构造
//传值传参
void Func1(Date d)
{}
//传引用传参
void Func2(Date& d)
{}
那么为什么对于自定义类型的参数,编译器不能直接拷贝而需要调用其拷贝构造呢?
因为对于自定义类型对象的拷贝,有时候能成功,有时候会出错:
1.可以直接拷贝的自定义类型:比如日期类——浅拷贝
2.不可以直接拷贝的自定义类型:比如栈类——深拷贝
st1拷贝st2时,若直接拷贝,则会将st2中_a指针所包含的地址直接拷贝到st1的_a指针中导致两个指针指向同一块空间。
当其中一个对象调用析构函数,将_a所指向的空间释放了,再过一会另一个对象也要调用析构函数释放这块空间,但是一块空间不能析构两次。
其次由于两个指针指向同一块空间,会导致数据的覆盖写入。
综上所述:
自定义类型需要调用拷贝构造拷贝——深拷贝。
4.4.2.拷贝构造只能用传引用传参的解析
拷贝构造如果用传引用传参会造成无穷递归:
而传引用传参在传参数时不用拷贝,就不存在自定义类型调用拷贝构造而导致无穷递归的问题了。
注意:使用传引用传参的时候要用const修饰,因为传引用传参时创建了实参的别名传入,等同于直接传入实参,加const是为了防止实参被修改。同时如果传入了const对象,这里不用const修饰的话会导致权限放大。
4.2.自动生成的默认拷贝构造解析
默认生成的拷贝构造:
1.对于内置类型,会进行值拷贝或者浅拷贝;
2.对于自定义类型,会去调用它的拷贝构造;
既然对于内置类型和自定义类型他都会做处理,是不是说明我们可以在任何情况下都是用自动生成的拷贝构造呢?答案很明显错误。
例:
class Stack
{
public:
Stack(int capacity = 10)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a = nullptr)
{
perror("malloc fail");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
~Stack()
{
if (_a != nullptr)
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);//使用默认生成的拷贝构造
return 0;
}
若对栈类直接调用默认生成的拷贝构造,会导致有两个指针指向同一块空间,会造成数据的覆盖、多次调用析构导致空间的多次释放程序崩溃等问题。
补充:在上面的程序中,s2先析构,s1后析构,因为后定义的会先析构,s1先定义先进栈,s2后定义后进栈,后进栈的先出栈。所以在s2先调用析构释放了空间以后,s1的指针会指向已经释放的空间。
在这种情况下要使用深拷贝(后续讲解)。
那么我们什么时候应该自己实现拷贝构造,什么时候可以使用自动生成的拷贝构造呢?
——当我们自己实现了析构函数来释放空间就需要自己来实现拷贝构造。
因为如果自己实现了析构函数,就说明涉及了资源管理,自然需要自己实现。
使用自动生成的拷贝构造例:MyQueue
class Stack
{
public:
Stack(int capacity = 10)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a = nullptr)
{
perror("malloc fail");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
//简易的深拷贝拷贝构造
Stack(const Stack& s)
{
_a = (int*)malloc(sizeof(int) * s._capacity);
if (_a = nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_a, s._a, sizeof(int) * s._size);
_size = s._size;
_capacity = s._capacity;
}
~Stack()
{
if (_a != nullptr)
{
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
}
private:
int* _a;
int _size;
int _capacity;
};
class MyQueue
{
public:
//构造函数:默认生成,对于自定义类型Stack会去调用它的构造函数;对于内置类型int不会处理,但是给了缺省值
//析构函数:默认生成,对于自定义类型stack会去调用它的析构函数;对于内置类型int不做处理,但是不影响
//拷贝构造:默认生成,对于自定义类型stack会去调用它的拷贝构造;对于内置类型int完成值拷贝
private:
int size = 0;
Stack st1;
Stack st2;
};
5.赋值重载
默认生成的赋值重载:
对内置类型,调用这个成员的赋值重载
对自定义类型,完成浅拷贝/值拷贝
5.1.运算符重载operator
1.运算符重载的目的:增强程序的可读性。
运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名以及参数列表,其返回值类型和参数列表与普通的函数类似。
2.函数名:关键字operator + 需要重载的运算符符号。
3.函数组成:返回值类型 operator操作符(参数列表)
补充:函数重载和运算符重载虽然都叫重载,但是完全不一样:
运算符重载:让自定义类型的对象可以使用运算符。
函数重载:支持函数名相同、参数不同的函数可以同时使用。
5.1.1.运算符重载的实现
//以日期类为例
//实现==的运算符重载
//1.写在类外:
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
//2.写在类内:
//第一个参数d1是this
//即d1:左操作数;d2:右操作数
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
注意:
- 有几个操作数,就有几个参数,一一对应;
- 如果有两个参数,则第一个参数是左操作数,第二个参数是右操作数;
- 作为类成员函数重载时,其形参看起来比操作数数目少一个,因为成员函数的第一个参数为隐藏的this。
- 在使用流插入直接对运算符运算符重载打印时,要注意<<和重载运算符的优先级,加上括号再打印比较好。
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个自定义类型的参数
- 用于内置类型的运算符,其含义不能改变
- .* 域作用限定符::、类型及算sizeof、三目运算符? : 、成员访问符 . 注意以上5个运算符不能重载 (第一个.*平时基本不会使用)
5.1.2.运算符重载的使用方式
可以直接把“operator运算符(参数)”看作是函数名,并且可以对其直接显式调用。也可以正常使用,直接使用运算符,编译器会将其自动转换成“operator运算符(参数)”的形式运行。
以前面写的运算符重载为例来使用:
cout << operator==(d1, d2) << endl;//写在类外的重载
cout << d1.operator==(d2) << endl;//写在类内的重载
cout << (d1 == d2) << endl;
//一般用第二种,两者含义相同
5.2.赋值运算符重载
赋值运算符重载的特征:
对内置类型成员变量是直接赋值。
对自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
赋值运算符的特点:
1.赋值运算符有返回值;
2.赋值运算符支持连续赋值;
i = j = k;
//k先赋值给j,返回值是j,然后再将j赋值给i
赋值运算符重载的实现:
//d1 = d2
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
解析:
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值检测是否自己给自己赋值
- 返回*this :要复合连续赋值mn的含义
- if的判断是为了解决出现d1 = d1这种自己给自己赋值的情况发生时出现浪费。
注意:赋值运算符只能重载成类的成员函数不能重载成全局函数。
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。
5.2.1.赋值运算符重载的使用
不要混淆赋值运算符重载和拷贝构造的使用:
已经实例化好的两个对象是赋值运算符重载;
而拷贝构造是用一个已经实例化好的对象去初始化另外一个对象。
//拷贝构造
Date d1(d2);
Date d1 = d2;
//赋值运算符重载
d1 = d2;
6.const成员函数
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
注:const成员函数,const对象和普通对象都可以调用。
6.1.const成员函数的使用方法:
使用格式:
返回值类型 函数名 () const
{}
const在函数的声明和定义处都要添加。
例:
class A
{
public:
//const修饰*this
//因为*this是隐藏参数,不能直接加const
//所以只能加后面,现在指针类型变成了const A*
void print() const
{
cout << _a << endl;
}
private:
int _a = 20;
};
注意:
如果在类定义时,用const修饰,则在调用成员函数传参的时候传入的是const A* this,此时如果成员函数没有使用const成员函数,则会出现权限的放大,会导致编译器报错。
const对象不可以调用非const成员函数!
因为如果是const对象,则调用成员函数的时候传入的隐藏this指针也是const修饰的const A* this,传入普通的A* this中,会出现权限放大!
例如:
class A
{
public:
void print()
{
cout << _a << endl;
}
private:
int _a = 20;
};
int main()
{
A a1;
const A a2;
a1.print();//可以正常运行
a2.print();//权限放大,const A* this传参时传入A* this
return 0;
}
总结:对于内部不改变成员变量的成员函数,最好都加上const,因为const成员函数不管是不是const对象都可以调用。
7.取地址操作符重载、const取地址操作符重载
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可。
因为没有自己实现重载的价值,没有可以操作的空间。
例:
class A
{
public:
//取地址操作符重载
A* operator&()
{
return this;
}
//const取地址操作符重载
A* operator&() const
{
return this;
}
private:
int _a = 10;
};
int main()
{
A a1;
const A a2;
cout << &a1 << endl;//取地址
cout << &a2 << endl;//const取地址
return 0;
}
补充:只有特殊情况,才需要重载,比如想让别人获取到指定的内容。
8.知识补充
1.友元函数friend
在类外可以访问类内的私有成员。
用法,以Add函数为例:
在类内加入friend void Add(const Date& d1, const Date& d2);即可。
class Date
{
friend void Add(const Date& d1, const Date& d2);
public:
//...
private:
//...
}
//定义在类外
void Add(const Date& d1, const Date& d2)
{//...}
2.定义在类内的函数默认为内联函数inline,使用时直接原地展开