类和对象(中)—— 类的六个默认成员函数
目录
1.类中的默认成员函数
2.构造函数
为什么要有构造函数
什么是构造函数
构造函数做了什么
默认生成的构造函数功能的分析
C++11的补救
什么时候自己写构造函数
3.析构函数
为什么要有析构函数
什么是析构函数
析构函数做了什么
默认生成的析构函数功能的分析
什么情况下自己写析构函数
4.拷贝构造函数
为什么要有拷贝构造函数
什么是拷贝构造函数
拷贝构造的无穷递归
拷贝构造函数的做了什么
默认生成的拷贝构造函数功能分析
什么情况下自己写拷贝构造函数
拷贝构造函数的经典调用场景
5.赋值运算符重载函数
运算符重载函数
为什么需要运算符重载函数
什么是运算符重载函数
运算符重载的注意事项
什么是赋值运算符重载函数
默认的赋值运算符重载
6.取地址和const取地址运算符重载函数
const成员函数
取地址和const取地址成员函数
7.总结
1.类中的默认成员函数
笔者我在《类和对象(上)》这篇文章中,对类类型的大小进行了讲解,感兴趣的读者可以阅读一下: 文章链接https://blog.csdn.net/D5486789_/article/details/143301530?spm=1001.2014.3001.5501
我们知道,如果一个类里面什么都不写,这个类就是空类,只占用一个字节,用来标识存在的对象,但是空类里面就真的什么都没有吗?并不是的,任何类在什么都不写的情况下,编译器会默认生成以下六个默认成员函数(用户没有显示实现,编译器自动生成的成员函数,用户一旦显示实现,编译器就不在生成)。
C++11之后,出现了右值引用,于是C++标准委员会为了进一步提高C++的性能,又增加了两个默认成员函数 移动构造函数 和 移动赋值运算符重载(这两个默认成员函数暂时不做讲解)。所以,总共有8个默认成员函数;下面,我将依次介绍前六个默认成员函数。
2.构造函数
为什么要有构造函数
你是否还记得在使用C语言写数据结构代码的时候,我们经常要调用某某Init() 函数用来初始化我们创建的数据结构对象。
下面代码以栈为例:
问题是我们是否经常忘记初始化,如果没有忘记是否也略显麻烦?那么C++就在想,能否在创建对象时,直接就将信息设置进去呢?于是,大佬们就发明了构造函数。
什么是构造函数
那什么是构造函数呢?构造函数是一个特殊的成员函数,其特征如下:
- 函数名与类名相同。
- 无返回值,不需要写void。
- 创建对象时由编译器自动调用,可以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
- 构造函数可以重载。
- 如果类中没有显示定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦显示定义则不再生成。
调用无参构造的时候不能写stu s1(); 因为这样和函数声明无法区分;调用带参构造函数的时候,需要指明参数。
构造函数做了什么
构造函数的功能:构造函数虽然叫做构造函数,但是并不是开辟空间,创建对象,而是初始化对象。
默认生成的构造函数功能的分析
如果我们写了构造函数,对成员的初始值是我们自己给的,我们很清楚每个成员的初始值是什么,但是在我们不写构造函数的情况下,编译器默认生成的构造函数干了什么,我们是不清楚的,下面我们一起来研究一下。
补充:C++将类型划分为内置类型和自定义类型,内置类型就是语言原本就拥有的类型,比如:int、double……;自定义类型就是我们自己定义的类类型,比如用class、struct定义的类型。
需要注意的是:指针的类型也是内置类型。
下面代码中,B类中有一个A类的对象和一个int类型的变量,A类中有一个int类型的变量,B类没有实现构造函数,A类实现了构造函数,我们在main函数中定义一个B类对象,看看会发生什么。
#include <iostream>
using namespace std;
class A
{
public:
// A类有构造函数
A() { _a = 100; }
private:
int _a;
};
class B
{
public:
// B类没有构造函数
private:
int _b; // 内置类型
A _a; // 自定义类型
};
int main()
{
B obj_b; // 定义一个B对象
return 0;
}
通过调试观察结果如下:obj_b对象中的内置类型并没有被初始化,结果是一个随机值,该对象中的A类对象中的成员_a被初始化成了100,说明调用了A类的构造函数。
我们暂时可以得出这样的结论:
- 默认生成的构造函数对于内置类型不做处理。
- 默认生成的构造函数对于自定义类型,会去调用自定义类型的构造函数。
此时我们将A的构造函数动一下手脚,改成这样:
class A
{
public:
// A类有构造函数
A(int num) { _a = num; }
private:
int _a;
};
结果如下:代码运行出错,这是因为,A类的构造函数需要传递参数,但是编译器自动调用B类中A类的构造函数的时候,并不知道传递什么参数,这需要用户来指定。
所以,上面的结论应该被修改为:
- 默认生成的构造函数对于内置类型不做处理。
- 默认生成的构造函数对于自定义类型,会去调用自定义类型的 不传参就可以调用的构造函数(默认构造函数)。
补充一下:
什么是默认构造函数?默认构造函数是指不需要传递参数就可以调用的构造函数。
通常有如下三个:
- 用户不写,编译器默认生成的。
- 用户写的无参的构造函数。
// 无参的构造函数 A() { _a = 100; }
- 用户写的全缺省的构造函数。
// 全缺省的构造函数 A(int a = 0) { _a = a; }
注意:这三个函数构成重载,但是不能同时存在,否则无参调用时,编译器不知道调用哪一个。
class A { public: // 这两个函数不能同时存在!!! // 无参的构造函数 A() { _a = 100; } // 全缺省的构造函数 A(int a = 0) { _a = a; } private: int _a; };
特别注意:默认生成的构造函数 包含于 默认构造函数,默认构造函数 包含于 默认成员函数!!!
为了进一步验证我们上面得出的结论,我们将A类的构造函数去掉,看看结果是什么?
调用B类中A类对象的默认构造函数的时候,A类中只有内置类型,默认构造函数对内置类型不做处理,所以,都是随机值,结果符合预期,结论成立。
C++11的补救
在定义对象的时候,对于内置类型的变量,我们肯定希望它能获得一个确定的初识值,即使是在调用编译器默认生成的构造函数的情况下;而不是随机值,于是C++11针对于默认生成的构造函数做了补救 —— 允许内置类型的成员变量在声明的时候赋初始值。
如下:还是上面的例子,我们修改A类如下代码所示,看看结果是什么?
class A
{
public:
private:
int _a = 200;
};
结果如下:我们给A类的内置类型成员指定了初始值,但是并没有给B类的内置类型成员指定初始值,结果就是_a被赋值为指定的初始值,_b为随机值。
什么时候自己写构造函数
既然C++的类中能够默认生成构造函数,那我们还需不需要自己写呢?
这个要看情况,一般来说我们都需要自己写构造函数,如果一个类中的内置类型成员都在声明的时候给了初始值,其他的成员都是自定义类型,我们可以考虑让编译器自己生成构造函数。
3.析构函数
为什么要有析构函数
我们现在可以回头看看这段代码:这段代码中有什么问题么?如果你比较细心的话,你就会发现,我们初始化对象之后,释放对象之前,并没有清理对象中的资源;看来,不仅仅初始化容易忘记,清理资源也容易忘记,按着C++的作风,肯定又要设置一个自动调用的函数了,这个函数就是析构函数。
什么是析构函数
析构函数是一个特殊的成员函数,其特征和构造函数相似,具体如下:
- 析构函数名是在类名前加上 ~。
- 析构函数没有参数,也没有返回值(不需要显示写void)。
- 一个类只能有一个析构函数,也就是说析构函数不能重载;如果没有显示定义,编译器会自动生成一个。
- 在对象生命周期结束时,由编译系统自动调用。
析构函数做了什么
析构函数的功能:析构函数与构造函数功能恰好相反,构造函数是用来初始化对象的,析构函数就是用来清理对象中的资源的。需要注意的是,析构函数并不完成对象本身的销毁,这个工作是由编译器来完成的。
默认生成的析构函数功能的分析
析构函数如果是我们自己写的,我们肯定清楚它干了什么,但是析构函数在用户不写的情况下,会自动生成一个,这个默认生成的析构函数干了什么,我们需要探究一下。
先看这份代码:
#include <iostream>
using namespace std;
class A
{
public:
~A() { _a = 0; }
private:
int _a = 100;
};
class B
{
public:
private:
int _b = 200;
A _sub_a;
};
int main()
{
B obj_b;
return 0;
}
运行结果如下:我们在main函数中定义的B类对象obj_b中有一个内置类型的成员_b,还有一个自定义类型的成员_sub_a,_sub_a中有一个成员_a,当对象obj_b销毁之后,_b的值还是之前的值,但是_sub_a中的成员_a的值通过析构函数设置为了0。
所以,我们可以得出以下结论:
- 默认生成的析构函数对于内置类型不做处理。
- 默认生成的析构函数对于自定义类型会去调用它的析构。
什么情况下自己写析构函数
析构函数既然能够默认生成,那我们还有没有必要自己写呢?
这主要取决于我们的类有没有申请资源,通常是指内存资源:
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数。比如cube类:
- 如果类中有申请资源时,一定要写,否则会造成资源泄漏。比如my_stack类:
4.拷贝构造函数
为什么要有拷贝构造函数
在C语言中,允许使用结构体实参对象直接给结构体形参对象传参,C++ 继承了这种方式,只不过在C++中,不在叫结构体对象,而是类对象,但是这种方式存在缺陷。
例如:当直接将对象传参时,会将对象中的成员依次赋值给被赋值对象中的成员,如果这个类是申请了资源的类,那么,两个对象就会使用同一份资源,析构的时候,这份资源就会被析构两次,这会导致程序运行崩溃。
上面这种只将对应的值拷贝给对方的拷贝叫做浅拷贝,所以,为了避免这种浅拷贝所带来的问题,C++引入拷贝构造函数,在拷贝对象的时候,由用户自己控制,对于申请资源的类,我们可以为每个类对象单独申请一份资源,从而避免一份资源释放多次的问题。
什么是拷贝构造函数
拷贝构造函数也是一个特殊的成员函数,具有以下特征:
- 拷贝构造函数也是构造函数,是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个,且必须是类类型对象的引用;如果使用传值传参会引发无穷递归(后面分析)。
- 用已存在的类类型对象创建新对象时由编译器自动调用。
- 如果没有显示定义,编译器会生成默认的拷贝构造函数。
拷贝构造的无穷递归
先看两个拷贝构造函数:
我们发现,不管是浅拷贝的还是深拷贝的拷贝构造函数,其参数都是 类类型的引用, 这是为什么呢?我们接着看。
当我们需要传递类类型的参数的时候,编译器就会自动调用拷贝构造函数,拷贝出形参对象,如下所示:
如果拷贝构造函数是传值传参,就会造成以下循环递归的场景:
如果拷贝构造函数的参数是引用的话,调用拷贝构造函数的时候就不需要拷贝对象,就避免了无穷递归的问题。
拷贝构造函数的做了什么
拷贝构造函数的作用是 用已存在的类类型对象初始化新创建的对象。如果我们显示写了,我们很清楚拷贝构造函数做了什么,但是不写的时候,默认生成的拷贝构造函数做了什么我们并不清楚。
默认生成的拷贝构造函数功能分析
我们通过这段代码来分析:
class my_stack
{
public:
my_stack(size_t capacity = 3)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败!!!");
}
_capacity = capacity;
_top = 0;
}
~my_stack()
{
cout << "~my_stack()" << endl;
free(_a);
_capacity = _top = 0;
_a = nullptr;
}
private:
int* _a;
int _capacity;
int _top;
};
int main()
{
my_stack s1;
my_stack s2(s1);
return 0;
}
调试结果如下:在my_stack类中,我们并没有实现拷贝构造函数,但是,我们用s1拷贝构造s2,只能调用默认生成的拷贝构造函数,我们发现,s1和s2的中的成员变量的值都相同。
所以,我们可以得出结论:
- 默认生成的拷贝构造函数对于内置类型完成值拷贝(按内存存储的字节序完成拷贝)。
- 对于自定义类型,我们可以参考前面几个默认成员函数,肯定会调用该类型的拷贝构造函数。
什么情况下自己写拷贝构造函数
在讨论为什么要有拷贝构造函数的时候,我们其实已经对这个问题有了一定的认识。
上面这种只将对应的值拷贝给对方的拷贝叫做浅拷贝,如果类对象申请了资源,进行浅拷贝的话就会造成多个对象指向同一份资源,对象释放的时候,对应的资源会释放多次,这会造成程序崩溃。
所以,对于申请了资源的类,我们应该自己写拷贝构造函数,如果类中没有申请资源,可以使用默认生成的拷贝构造函数。
拷贝构造函数的经典调用场景
我们已经知道拷贝构造函数的功能是用已经存在的对象初始化创建的对象了。那么什么情况下,会用已经存在的对象初始化创建对象呢?
有三个典型的应用场景:
- 场景一:使用已存在的对象创建新对象
- 场景二:函数参数类型为类类型对象
- 场景三:函数返回值类型为类类型对象
下面代码包含了这三个场景:
class cube
{
public:
cube(int l, int w, int h)
{
cout << "构造函数 ";
printf("%x \n", this);
_length = l;
_width = w;
_height = h;
}
cube(const cube& c)
{
cout << "拷贝构造函数 ";
printf("%x \n", this);
_length = c._length;
_width = c._width;
_height = c._height;
}
~cube()
{
cout << "析构函数";
printf("%x \n", this);
_length = 0;
_width = 0;
_height = 0;
}
private:
int _length;
int _width;
int _height;
};
cube test(cube c)
{
cube t(c);// 场景一
return t; // 场景三
}
int main()
{
cube c1(3, 4, 5);
test(c1); // 场景二
return 0;
}
上面代码的调用逻辑如下:
我们来分析一下这些对象的销毁顺序: 由于调用函数需要建立栈帧,函数的参数和变量都需要压栈,首先创建main函数的栈帧,c1入栈,然后创建test函数的栈帧,接着c入栈,再t入栈,生成的临时变量通常存储在调用该函数的作用域的栈内存中。
所以,我们可以得到变量压栈图如下:栈是后进先出的数据结构,所以,析构对象的时候从上往下依次析构。
我们可以看到,传值传参和传值返回都需要调用拷贝构造函数,这样一来会有不小的开销, 为了提高程序的执行效率,在能够使用引用的场景下,我们应该尽量使用引用。
5.赋值运算符重载函数
运算符重载函数
在讲解赋值运算符重载之前呢,笔者我想先谈一谈运算符重载。
为什么需要运算符重载函数
在C++中,数据类型被分成了两种,内置类型和自定义类型。对于内置类型来说,可以直接使用各种运算符,因为内置类型是语言自己定义的,编译的时候直接转换成指令即可;但是,自定义类型不是语言定义的,不能直接使用运算符。
那自定义类型的对象需要使用运算符的时候怎么办呢?这个时候,C++引入运算符重载函数解决该问题。
所以,为什么需要运算符重载函数呢?
- 因为自定义类型无法像内置类型一样直接使用运算符,进行运算符操作的时候不方便。
什么是运算符重载函数
运算符重载函数是具有特殊函数名的函数,具体如下:
- 函数名字为:关键字operator后面接需要重载的运算符符号。如:operator >
- 函数原型为:返回值类型 operator操作符(参数列表)
其实运算符重载函数和普通函数不同的地方就是函数名,运算符重载函数的函数名必须为 operator+操作符,不能随意乱起名字。
我们来看一个例子:重载cube类的 == 运算符
bool operator==(const cube& c)
{
if (_length == c._length && _width == c._width && _height == c._height)
{
return true;
}
return false;
}
你应该听说过函数重载吧,那运算符重载和函数重载有什么关系吗?
运算符重载的目的是让自定义类型可以直接使用运算符,函数重载的目的是允许出现参数不同的同名函数。运算符重载是对运算符进行重载,函数重载是对函数进行重载,他们之间没有关系。
运算符重载的注意事项
- 不能通过连接其他符号来创建新的操作符:比如operator#
- 重载操作符必须有一个类类型参数:因为运算符重载本身就是为了类类型的数据创造的,所以重载的运算符函数必须要有类类型的参数。
- 不能改变运算符的含义:比如判断相等,实现就需要实现成判断相等,不能实现成比较大小。
- 作为类成员函数重载时,其形参数看起来比操作数少一个,成员函数有一个隐藏的this指针。
- .* :: sizeof ?: . 这五个运算符不能重载。
重载运算符之后,当自定义类型使用运算符的时候,编译器会自动调用对应的运算符重载函数。
什么是赋值运算符重载函数
赋值运算符重载函数是一个特殊的成员函数,如果我们没有显示实现,编译器会生成一个默认的,如果我们显示实现了,编译器不会生默认的。
其格式如下:
- 参数类型:const T&,传递引用可以提高传参效率。
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值。
- 检测是否自己给自己赋值。
- 返回*this :要复合连续赋值的含义。
具体一点就是重载了赋值运算符的函数,具体代码示例如下:
cube& operator=(const cube& c) // 赋值运算符重载
{
if (this != &c)
{
_length = c._length;
_width = c._width;
_height = c._height;
}
return *this;
}
赋值运算符重载是用已经存在的对象给已经存在的对象赋值,具体细节如下:
- 在自定义类型对象调用两个操作数的运算符重载函数的时候,默认左操作数传递给第一个参数,右操作数传递给第二个参数。(如果是成员函数的话,第一个参数传递给this指针)
默认的赋值运算符重载
默认生成的赋值运算符函数干了什么?默认生成的赋值运算符函数的行为和默认生成的拷贝构造函数的行为类似,或者说很像,都是以逐字节的方式进行拷贝。
- 对于内置类型的成员完成值拷贝。
- 对于自定义类型的成员会去调用它的赋值运算符重载函数。
既然在用户不显示实现的情况下,编译器会生成一个默认的成员函数,那我们还需要自己写吗?这一点还是和拷贝构造函数是类似的;我们来看下面的例子:
- 没有申请资源的类的浅拷贝赋值,这样没有任何问题。
- 申请了资源的类浅拷贝赋值,这个时候就会造成两个or多个对象共享同一份资源,同时,被赋值的对象原来的资源丢失,造成内存泄漏。
所以,对于没有申请资源的类,我们可以使用编译器生成的赋值运算符重载函数完成值拷贝,但是,对于申请了资源的类,不能只完成值拷贝,也就不能使用默认生成的赋值运算符重载函数。
对于上面问题,我们需要自己实现深拷贝的赋值运算符重载函数,如下所示:这样一来就可以避免上面浅拷贝引发的问题了。
6.取地址和const取地址运算符重载函数
在讲解这两个成员函数之前,先来看看const成员函数。
const成员函数
什么是const成员函数呢?C++中将const修饰的成员函数称之为const成员函数。
class cube
{
public:
// const成员函数
void print() const
{
cout << _length << endl;
cout << _width << endl;
cout << _height << endl;
}
private:
int _length;
int _width;
int _height;
};
const修饰成员函数,实际上修饰的是成员函数隐藏的this指针所指向的内容,表明不能在该成员函数中对类的任何成员进行修改。
编译器对const成员函数的处理如下:
关于const成员函数的几个问题
既然成员函数有const和非const的,对象也有const和非const的,那他们之间可以互相调用吗?
- 问题一:非const对象调用const成员函数?不可以,会造成权限缩小。
- 问题二:非const对象调用非const成员函数?可以,权限是平移的。
- 问题三:const对象调用const成员函数?可以,权限是平移的。
- 问题四:const对象调用非const成员函数?不可以,会造成权限放大。
具体过程如下:
取地址和const取地址成员函数
我们以cube类为例,其取地址和const取地址运算符重载函数如下所示
// 取地址
cube* operator&()
{
return this;
}
// const 取地址
const cube* operator&() const
{
return this;
}
这两个成员函数在我们不写的情况下,都会自动生成,默认生成的这两个成员函数功能如下:
- const对象就会调用const取地址运算符重载函数,返回const修饰的地址,该地址指向的内容不能改变。
- 非const对象就会调用非const取地址运算符重载函数,返回非const修饰的地址,该地址指向的内容可以改变。
既然编译器可以自动生成,还需要自己写么?一般来说是不需要的,使用默认生成的即可,但是对于特殊需求需要自己实现。比如:想让别人获取到指定内容。
7.总结
空类中并不是啥也没有,而是会生成6个默认的成员函数,分别是:
- 默认生成的构造函数。
- 默认生成的析构函数。
- 默认生成的拷贝构造函数。
- 默认生成的赋值运算符重载函数。
- 默认生成的取地址运算符重载函数。
- 默认生成的const取地址运算符重载函数。
它们对于内置类型和自定义类型的处理分别是:
1、默认生成的构造函数
- 对于内置类型成员不做处理。
- 对于自定义类型成员,会去调用自定义类型的构造函数。
2、默认生成的析构函数
- 对于内置类型成员不做处理。
- 对于自定义类型成员,会去调用自定义类型的构造函数。
3、默认生成的拷贝构造函数
- 对于内置类型成员完成值拷贝(按内存存储的字节序完成拷贝)。
- 对于自定义类型成员,调用该自定义类型的拷贝构造函数。
4、默认生成的赋值运算符重载函数
- 对于内置类型的成员完成值拷贝。
- 对于自定义类型的成员会去调用它的赋值运算符重载函数。
5、默认生成的取地址运算符重载函数
- 返回const修饰的地址,该地址指向的内容不能改变。
6、默认生成的const取地址运算符重载函数
- 返回非const修饰的地址,该地址指向的内容可以改变。