【C++】 --- 类和对象(中)
类和对象(中)
- 1. 类的默认成员函数
- 2. 构造函数
- 3. 析构函数
- 4. 拷贝构造函数
- 5. 赋值运算符重载
- 5.1 运算符重载
- 5.2 赋值运算符重载
- 5.3 日期类的实现
- 6. 取地址运算符重载
- 6.1 const成员函数
- 6.2 取地址运算符重载
1. 类的默认成员函数
默认成员函数:用户没有显式的实现,但是编译器会自动生成的成员函数。
一个类,我们不写的情况下编译器会生成6个默认成员函数,这六个中最重要的是前4个,取地址重载的两个不是很重要,了解即可。
C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个我们在C++11的文章中讲解。
默认函数很重要也很复杂,我们需要从两个方面去学习。
- 我们不写的时候,编译器默认生成的函数的行为是什么,是否可以满足我们的需求
- 编译器默认生成的函数不满足我们的需求的时候,我们该如何自己是实现?
2. 构造函数
构造函数是特殊的成员函数,构造函数的主要任务是在对象实例化的时候初始化对象,构造函数的本质就是替代Stack结构实现时写的Init函数的功能。
- 构造函数的函数名和类名相同
- 无返回值,也不需要写 void
- 构造函数可以重载。
- 对象实例化时系统会自动调用对应的构造函数
下面我们给出日期类构造函数的实现和调用。
#include<iostream>
using namespace std;
class Date
{
public:
// 1.⽆参构造函数
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
// 2.带参构造函数
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 d1;
Date d2(2024, 2, 3);
d1.Print();
d2.Print();
return 0;
}
实例化对象时,如果要调用无参的构造函数,对象名的后面不可以加 ( ) ,
因为这样也可以被解释为 返回值类型为Date的、函数名为d1的函数的声明。
在单步调试的过程中,编译器分别调用了无参构造函数和有参构造函数把对象d1、d2初始化了。
// 1.⽆参构造函数
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
// 2.带参构造函数
Date(int year = 1, int month = 1 , int day = 1 )
{
_year = year;
_month = month;
_day = day;
}
int main()
{
Date d1;
}
我们能不能把带参构造函数全缺省呢?
当我们在vs上写出这段代码的时候,编译器会提示报错了,因为 d1对象不知道应该调用无参还是全缺省的构造函数,因为在语法上都可以做出合理的解释。所用我们在写构造函数不会同时写无参的和全缺省的,我们一般只给出一个全缺省的构造函数,实例化对象可以实现多样化。
int main()
{
Date d1;
Date d2(2024, 2, 3);
Date d3(2013);
d1.Print();
d2.Print();
d3.Print();
return 0;
}
如果类中没有显式定义构造函数,则C++编译器会自动生成⼀个无参的默认生成的构造函数,⼀旦用户显式定义编译器将不再生成。
那我们就不显式的给出构造函数,来看一下编译器默认给出的构造函数的功能。
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;
}
编译默认生成的构造函数,对于内置类型的初始化是没有要求的,也就是是否初始化以及初始化的值,取决于编译器。很显然vs就没有初始化。
默认生成的构造函数不等于默认构造函数。默认构造函数 == 无参构造函数、全缺省构造函数、不写时编译器默认生成的构造函数。这三个构造函数有且只有一个存在,不可以同时存在。无参构造函数和全缺省构造函数在调用的时候有歧义,这前面已经提到。总结一下就是不传实参就可以调用的构造函数就是默认构造函数。
根据上面的定义,当我们给类写了一个带参数但不是全缺省的构造函数的时候,这个类就没有默认构造函数啦!
对于自定义类型(class/struct等关键字自己定义的类型)成员变量,编译器会调用这个成员变量的默认构造函数做初始化。
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
int main()
{
MyQueue q1;
return 0;
}
两个栈实现一个队列是一道经典的算法,我们用这个为例看一下,自定义类型的成员变量的默认初始化情况。
我们可以看到当我们实例化了一个队列对象的时候,这个队列对象的自定义类型的成员就会自动调用自己的默认构造函数完成初始化。
当一个类中的自定义成员变量没有默认构造函数,就会报错。
一般情况下,构造函数都需要我们自己来写,在很少的情况下构造函数不需要我们自己来写,例如MyQueue这个类,成员变量都是自定义类型且都拥有自己的默认构造函数。
3. 析构函数
析构函数域构造函数功能相反,完成对象中资源的清理释放工作。析构函数的功能类比于Stack实现的Destroy功能。
- 析构函数名是在类名前加上符号~。
- 无参数无返回值(不需要加void)。
- 一个类只一个析构函数。
- 对象的生命周期结束时,系统会自动调用析构函数。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//析构函数
~Date()
{
cout << "~Date" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
return 0;
}
在程序的整个调试过程中,先调用了构造,后调用了析构。Date类不需要释放任何的资源,所以以后不需要显式的给出Date类的析构函数。
下面给出Stack,Stack的析构函数要释放掉初始化时malloc在堆上申请的内存空间,防止内存泄露,这个时候就需要显式的给出对应的析构函数。
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
cout << "Stack()" << endl;
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc");
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
int main()
{
Stack s1;
return 0;
}
跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用他的析构函数。
Date不写析构函数没有问题,因为Date类没有资源需要释放,但是Stack类不行,如果不写析构,编译器自动生成的析构函数不会free(_a)就会造成内存泄露了。
但是我们在前面提到的两个栈模拟的队列类就可以没有析构函数,因为这个类的成员都是自定义类型,自定义类型会自动的调用自己的析构函数。
class Stack
{
///.....
}
class Myqueue
{
public:
Myqueue()
{
cout << "Myqueue()" << endl;
}
~Myqueue()
{
cout << "~Myqueue()" << endl;
}
private :
Stack _pushst;
Stack _popst;
};
int main()
{
Myqueue test1;
return 0;
}
这里我们发现:我们显⽰写析构函数,对于⾃定义类型成员也会调用他的析构,也就是说自定义类型成员无论什么情况都会自动调用构造函数和析构函数。
析构函数是一定要写的吗?这里总结一下:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如Date;如果默认生成的析构就可以⽤,也就不需要显式写析构,如MyQueue;但是有资源申请时,⼀定要自己写析构,否则会造成资源泄漏,如Stack。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
cout << "Date()" << endl;
}
//析构函数
~Date()
{
cout << "~Date()" << endl;
}
};
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
cout << "Stack()" << endl;
}
~Stack()
{
cout << "~Stack()" << endl;
}
};
class Myqueue
{
public:
Myqueue()
{
cout << "Myqueue()";
}
~Myqueue()
{
cout << "~Myqueue()" << endl;
}
private :
Stack _pushst;
Stack _popst;
};
int main()
{
Date d1;
Stack s1;
Myqueue test1;
return 0;
}
这里我们把主体的实习逻辑掏空,基本只留下构造函数和析构函数。
我们发现一个结论:一个局部域的多个对象,先定义的后析构。
4. 拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造函数是一个特殊的构造函数。
先给出日期类的构造函数
Date(int year = 1, 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;
}
我们可以看出拷贝构造函数是构造函数的一个重载,有了拷贝构造函数,我们就可以通过拷贝对象的方式来初始化一个对象了。
int main()
{
Date d1;
Date d2(d1);
return 0;
调试发现,执行到Date d2(d1)
的时候编译器确实去调用拷贝构造函数,完成了对d1对象的拷贝并初始化了d2对象。
拷贝构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引⽤,后⾯的参数必须有缺省值。
Date (const Date& d, int x = 1)
{
//... 这也是一个拷贝构造函数,但是一般不会这样来写
}
C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以自定义类型传值传参和传值返回都会调用拷贝构造完成。
这里先说一个传值传参的例子,来铺垫一下为什么拷贝构造的参数如果是传值就会引发无穷递归。
void Func(Date dd)
{
}
int main()
{
Date d1;
Func(d1);
return 0;
}
这里经过调试发现,确实是调用了拷贝构造初始化出了dd,如果不想调用拷贝构造,传引用、传指针也是可以的。
拷贝构造函数的第⼀个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归
是否可以写成Date(Date* p)
呢?可以,但是这不是拷贝构造,这只是一个普通的构造。调用方式为Date d1(&d2)
。
拷贝构造的调用还可以这么来写。
int main()
{
Date d1;
Date d3 = d1;
}
下面举个例子:使用调用拷贝构造的两种方式来说明自定义类型的传值返回也是调用了拷贝构造。
Date f()
{
Date ret;
//.....
return ret;
}
在f()函数中,我们定义了一个ret对象,完成了某功能,并把ret作为返回值返回了。
int main()
{
Date d1 = f();
Date d2(f());
return 0;
}
值得注意的是,这里语法上是进行了两次拷贝构造,第一次是临时对象拷贝ret,第二次是d拷贝临时对象,但是临时对象具有常性,所以拷贝构造函数形参前要加const
,防止权限放大的问题。
若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造
日期类是不用写拷贝构造的,完成浅拷贝/值拷贝即可
int main()
{
Date d1;
Date d2(d1);
return 0;
}
栈类默认给出的不行,因为栈的成员变量中有指针,浅拷贝会使两个对象的指针指向同一块资源空间。
int main()
{
Stack st1(10);
Stack st2 = st1
return 0;
}
这里两个对象指向了同一块空间,st2后构造先析构,然后st1在析构一次,但是同一块空间不能释放两次,其次,两个对象共用同一块空间,两个对象的操作修改数据会影响到对方。所以我们需要自己实现一个深拷贝的拷贝构造
Stack(const Stack& st)
{
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a)
{
perror("malloc");
return;
}
memcpy(_a, st._a, sizeof(STDataType) * st._top);
_top = st._top;
_capacity = st._capacity;
}
这样实现了深拷贝,就没有析构两次,共用空间的问题了。
为什么自定义类型的拷贝行为要调用拷贝构造函数呢?部分自定义类型的浅拷贝,可能出现共用资源情况,导致各种问题。
MyQueue这个类其实也不用显式实现拷贝构造函数,它的成员为自定义类型,会自己调用自己拷贝构造。
这里总结一下:如果⼀个类显式实现了析构并释放资源,那么他就需要显式写拷贝构造,否则就不需要。
最后指出一点:传值需要拷贝构造,传引用没有拷贝,传引用的系统开销更小,但不是所用的情况可以使用传引用。
例如下面使用引用做返回值
Stack& Funcc()
{
Stack st;
return st;
}
int main()
{
Stack ret = Funcc();
}
不能使用引用做返回值,因为栈对象释放后和Funcc函数栈帧销毁后,ret才使用栈对象st拷贝了一份数据。首先这里的引用是一个野引用,类型于一个野指针,其次资源释放后,数据改变,ret拷贝的数据也不是st的数据。这里可以传值返回,也可以把定义static Stack st
把st对象放在静态区,这样Funcc函数栈帧销毁了,st对象还是存在的。
5. 赋值运算符重载
5.1 运算符重载
当运算符背用于类类型的对象时,C++语言允许我们通过运算符重载的形式给运算符指定新的含义。
下面先给出一个日期比较的 <
运算符的重载
bool operator<(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;
}
return false;
}
运算符重载是具有特殊名字的函数,他的名字是由operator和后面要定义的运算符构成,运算符重载函数的参数个数和该运算符的运算对象一样多。
int main()
{
bool ret = operator<(d1,d2);
return 0;
}
这样重载函数会报错的,因为这是定义在全局的写法,但是成员变量_year、_month、_day
是私有的。
这里给出几种修改的方式:
- 把成员变量公开,这是不推荐的方式,因为破坏了类的封装,成员变量可以被随意的访问修改。
- getxxx方法,学过Java的朋友都知道这个,把成员变量私有,对外提供get/set方法。
- 友元,这是面向对象(下)要讲到的方法。
- 重载为成员函数,其实这个
operator<
函数本就应该是日期类的成员函数,下面给出改造方式。
bool operator<( const Date& x2)
{
if (_year < x2._year)
{
return true;
}
else if (_year == x2._year && _month < x2._month)
{
return true;
}
else if (_year == x2._year && _month == x2._month && _day < x2._day))
{
return true;
}
return false;
}
重载为成员函数后,则它的第一个运算对象默认传给隐式的this指针,所以,运算符重载作为成员函数后,传参比运算对象少一个。
int main()
{
bool ret = d1.operator<(d2);
return 0;
}
这是运算符重载为成员函数后的调用方式。
当然可以写成bool ret = d1 < d2
,编译会把d1 < d2
转化为函数调用的形式。
- 重载后符号的优先级和结合性不会改变,例如重载了一个
*
和一个+
,那么*
的 优先级还是比+
要高。 - 不能通过连接语法中没有的符号来创建新的操作符:比如operator@
.* :: sizeof ? : .
这五个运算符不能重载。
下面来解释一下.*
运算符:主要用于调用成员函数的函数指针。
class A:
{
public:
void func()
{
cout << "A::func()" <<endl;
}
};
typedef void (A::*PF)();//定义成员函数指针类型
int main()
{
PF pf = &A::func;
return 0;
A obj;
(ojb.*pf)();//成员函数的调用
//简单了解即可
}
- 重载操作符⾄少有⼀个类类型参数,不能通过运算符重载改变内置类型对象的含义。
int operator+(int x ,int y)
{
return x-y; //错误
}
-
⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,⽐如Date类重载operator-就有意义,但是重载operator*就没有意义。
-
重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,⽆法很好的区分。 C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,方便区分。
(这里假设我们已经实现了+=运算符重载)
Date& operator++()//前置++运算符重载
{
*this += 1;
return *this
}
Date operator(int i )//后置++运算符重载
{
Date tmp(*this)
*this += 1;
return tmp;
}
这里我们看到了this在成员函数中显式的使用、拷贝构造函数、引用返回等使用场景。
5.2 赋值运算符重载
赋值运算符重载是⼀个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,这⾥要注意跟拷贝构造区分,拷贝构造用于⼀个对象拷贝初始化给另⼀个要创建的对象。
赋值运算符重载是⼀个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成const 当前类类型引用,否则会传值传参会有拷贝
有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。
void operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
d2 = d1
这样是可以的 ,但是d3 = d2 =d1
这样不行,因为=的结合性是右向左,先d2=d1
即d2.operator(d1)
这个函数的返回值为void。
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
为了防止d1 = d1
这样的情况发生,其实这样做没有什么问题,就是自己给自己赋值,相当于什么都没有做。
Date& operator=(const Date& d)
{
if(d1 !=&d) //在深拷贝场景的时候很有必要
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
没有显式实现时,编译器会自动生成⼀个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。
像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显⽰实现赋值运算符重载。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器⾃动⽣成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷⻉(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是⾃定义类型Stack成员,编译器⾃动⽣成的赋值运算符重载会调⽤Stack的赋值运算符重载,也不需要我们显⽰实现MyQueue的赋值运算符重载。这⾥还有⼀个⼩技巧,如果⼀个类显式实现了析构并释放资源,那么他就需要显式写赋值运算符重载,否则就不需要。
这里给上面的四个默认成员函数一个总结
1.构造函数一般都要自己写,自己定义初始化
2. 析构,构造时有申请资源(如malloc 或者 fopen 等),就需要显式写析构函数。
3. 拷贝构造和赋值重载,显式写了析构,内部管理资源,就需要显式实现深拷贝。
5.3 日期类的实现
链接
6. 取地址运算符重载
6.1 const成员函数
int main()
{
const Date d1(2020,1,1);
d1.Print();
//print(Date* const this)
//void Print() const;后
//print(const Date* const this)
return 0;
}
上面这个d1对象是调不动Print()函数,因为成员函数形参位置有一个默认的this指针Date* const this
,因为d1是 const Date类型的所以 d1传给this对应的实参的类型为const Date *
,这里是一个权限放大,由于成员函数的形参位置不允许显式的写this,所以我们就需要使用const成员函数来解决这个问题。
const成员函数就被const修饰的成员函数,const修饰成员函数放在成员函数参数列表的后面。const实际修饰的是该成员函数隐含的this指针指向的内容,表示在该成员函数中不能对类的任何成员进行修改。
不是所有的成员函数都应该被const修饰,只有成员函数不修改成员变量的时候才建议使用const修饰。
6.2 取地址运算符重载
取地址运算符重载分为普通取地址重载和const取地址重载,这本篇文章要讲到的最后两个默认成员函数,一般编译器默认生成的就足够我们使用了。
int main()
{
Date d1(2020, 1, 1);
Date d2 = d1;
cout << &d1 << endl;
cout << &d2 << endl;
return 0;
}
当我们有特殊的需求的时候也可以显式的实现这两个默认成员函数,例如不希望随意获取对象的地址时候,返回空地址或者返回一个随机的野地址。
Date* operator&() // Date* const this
{
return nullptr;
//return (Date*)0x000AAFF0
}
const Date* operator&() const // const Date* const this
{
return nullptr;
}