作者:@小萌新
专栏:@C++初阶
作者简介:大二学生 希望能和大家一起进步
本篇博客目标:梳理自己六个小时学到的知识 并且将类和对象知识分享给大家
专注的去做一件事
如果累了就去休息
C++ 类和对象 中
- 本章学习目标
- 前言
- 一. 构造函数
- 1.1 概念
- 1.2 特性
- 1.2.1 带参构造函数
- 1.2.2 无参构造函数
- 1.2.3 缺省参数构造函数
- 1.2.4 无构造函数
- 1.3 构造函数的作用
- 1.4 C++11的补丁
- 二. 析构函数
- 2.1 概念
- 2.2 特性
- 2.3 自动析构
- 三. 拷贝函数
- 3.1 概念
- 3.2 深拷贝 浅拷贝
- 3.3 拷贝构造函数典型调用场景
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
- 四. 赋值运算符重载
- 4.1 概念
- 4.2 使用方法(运算重载)
- 4.3 赋值重载
- 4.4 默认的赋值重载符
- 4.5 赋值重载的总结
- 总结
本章学习目标
- 类的六个默认成员函数是什么?
- 详讲构造函数
- 详解析构函数
- 详解拷贝构造函数
- 详解赋值运算符重载函数
前言
如果一个类中什么成员都没有,简称为空类。 空类中真的什么都没有吗?
其实并不是这样子,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
这是比较官方的解释 通俗语言解释咋回事捏
就是说你只要生成了一个类 哪怕是空的 编译器都会给你生成下面图中这六个默认成员函数
class Date {};
但是呢 编译器写的默认函数很多时候不能满足我们的要求
那这个时候是不是就要我们自己写了
那么要怎么写这六个默认函数呢?
来 让我们带着这个问题进入下面的章节
一. 构造函数
1.1 概念
对于下面的Data类
我们这里实现了两个函数 分别是初始化和打印
class Date
{
public:
void Init(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;
d1.Init(2022, 11, 3);
d1.Print();
return 0;
}
实现效果如下
但是呢 每次初始化对象的时候都要调用Init函数是不是很烦啊
并且容易忘记是不是
那么这个时候 我们的构造函数就应运而生了
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证
每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
这里是对于构造函数简单的一个介绍
1.2 特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任
务并不是开空间创建对象,而是初始化对象。
构造函数具有以下的特性 在罗列出来之后我们会一一解释
- 函数名和类名相同
- 无返回值
- 对象实例化时编译器自动调用对应的构造函数
- 可以函数重载(作用就是我们可以提供多个构造函数 提供多种初始化方法 )
- 如果类中没有定义的化 编译器会默认生成(其实在上面的学习目标中已经讲了)
还是老样子 先上代码
1.2.1 带参构造函数
我们可以看到 完全符合构造函数的描述
所以说这就是一个标准的带参构造函数
它的初始化方式是这样子的
我们来看看运行结果怎么样
可以完美运行
1.2.2 无参构造函数
这个和带参构造函数相差不了多少
我们这里简单写一下
这就是一个简单的无参构造函数
并且这两个函数构成函数重载
我们来看看 最后运行结果是什么样子
我们发现最后是不是一个就是打印我们设定的默认值 一个就是打印我们传递的值
1.2.3 缺省参数构造函数
这个写法也很简单
最下面是不是就是一个简单的构造函数啊
那么这里有三个问题来了
首先 它们构成函数重载嘛?
其次 我们可以这么使用它嘛?
什么场景下可以使用呢?
第一个问题 它们构成函数重载
构不构成重载首先看参数相同不相同
它们之间的参数是不同的吧
所以说它们否成函数重载
第二个问题 我们可以这么使用它嘛?
不可以!
因为当我们不传参进去的时候 编译器会不知道调用哪个函数 从而产生歧义
第三个问题 我们什么时候可以使用它呢?
答案是屏蔽掉一个重载函数
然后不使用能够产生歧义的方式传参
就像这样
1.2.4 无构造函数
我们将上面的函数转到反汇编之后会发现 在进入初始化d1的确实调用了函数
那么假设我们没有默认构造函数呢
就像这样 我们进入反汇编看看
我们可以发现 这里还是调用了call函数
这是不是就说明 假设我们没有设置默认构造函数 系统会自己默认生成啊
但是我们可以发现这里的数据是不是不太聪明的样子啊
综合上面的几种情况 我们可以验证上面的特性并且得出结论
**当我们没有写构造函数的时候系统会默认生成一个构造函数 但是呢这个构造函数和我们预期的值可能会有所差异 所以这里还是推荐构造函数的值自己来写 **
1.3 构造函数的作用
对于栈这种数据结构来说 初始化是必要的
因此在c++中我们可以使用构造函数对其进行初始化
我们来看代码
我们可以进入到调式看看效果怎么样
另外我们可以发现 this指针就是指向我们的结构体的
如果这里不初始化就会报错 (没有开辟动态内存嘛)
1.4 C++11的补丁
这里构造函数的初始化规则很奇怪 它只会对于自定义类型初始化 对于内置类型不会初始化
在C++11的补丁当中给予了这么一个规则
即:内置类型成员变量在类中声明时可以给默认值。
类似这样子
我们可以发现 这个时候没有了构造函数也不会报错 非常的方便
二. 析构函数
2.1 概念
学习了构造函数之后我们知道了函数是如何初始化的了
那么问题来了 最后时候函数是如何销毁的呢?
就像栈 如果我们忘记调用destroy了是不是就会出现内存泄漏的问题啊
这里C++中也给出了解决方案 这就是析构函数
2.2 特性
还是一样 在罗列之后我们会在下面一一解释
- 析构函数名是在类名前加上字符 ~
- 无参数无返回值类型
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构
函数不能重载 - 对象生命周期结束时,C++编译系统系统自动调用析构函数
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
free(_a);
_a = nullptr;
}
void Push(int x)
{
//……
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
int main()
{
Stack s;
s.Push(1);
return 0;
}
我们运行下看看
我们可以发现 这里确实是默认调用了析构函数的
2.3 自动析构
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
class MyQueue {
public:
void push(int x)
{
//_pushST.Push(x);
}
private:
Stack _pushST;
Stack _popST;
};
// 面向需求:编译器默认生成就可以满足,就不用自己写,不满足就需要自己写
// Date Stack的构造函数需要自己写
// MyQueue构造函数就不需要自己写,默认生成就可以用
// Stack的析构函数,需要我们自己写
// MyQueue Date就不需要自己写析构函数,默认生成就可以用
int main()
{
MyQueue q;
return 0;
}
我们可以发现 在我们定义MyQueue的时候 并没有写析构函数
而是系统自己析构了
三. 拷贝函数
3.1 概念
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
那么在创建对象时候能不能创建一个和对象一模一样的新对象呢?
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存
在的类类型对象创建新对象时由编译器自动调用。
我们先来看看拷贝函数的形式
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)//析构
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)//拷贝构造
{
cout << "拷贝构造" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 9, 22);
d1.Print();
//拷贝一份d1
Date d2(d1); //拷贝构造,拷贝初始化
d2.Print();
return 0;
}
我们来运行下看看结果怎么样
我们发现 这里是不是用的传引用传值啊
那么大家仔细想想 如果我们使用传值传递呢 这样子可以嘛?
我们来试试看
这里vs2022编译器上做了优化啊 在以前的编译器上如果你使用传值传参的话是会无限递归下去的
为什么呢?
我们将函数改成这个结构 大家是不是好理解一点
这个结构觉不觉得很眼熟
是不是跟我们学递归的时候写的函数十分类似啊
是不是这样子就好理解了
3.2 深拷贝 浅拷贝
-
拷贝构造函数是构造函数的一个重载形式
-
拷贝函数参数有且只能有一个 并且必须要是类型对象的引用 不能是传值传递
-
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝
代码表示如下
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
//Time(const Time& t)
//{
// _hour = t._hour;
// _minute = t._minute;
// _second = t._second;
// cout << "Time::Time(const 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 d1;
// 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
// 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数
Date d2(d1);
return 0;
}
我们来看看它的debug过程
我们可以发现 我们这里把拷贝函数屏蔽掉之后依然开始拷贝d1(这就是因为系统生成了默认的拷贝函数了)
我们将这种拷贝类型叫做浅拷贝
那么既然系统会给我们写好拷贝函数 是不是我们不用写了呢?
正确答案是不是的
因为系统给我们写的拷贝是按照字节序的拷贝
遇到某种情况可能会遇到bug
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
void Push(int x)
{
// ....
// 扩容
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
// 需要写析构函数的类,都需要写深拷贝的拷贝构造 Stack
// 不需要写析构函数的类,默认生成的浅拷贝的拷贝构造就可以用 Date/MyQueue
class MyQueue {
public:
void push(int x)
{
_pushST.Push(x);
}
private:
Stack _push
Stack _popST;
size_t _size = 0;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(2);
// 1 2
Stack st2(st1);
st2.Push(10);
st1.Push(3);
// 1 2 10
MyQueue q1;
MyQueue q2(q1);
return 0;
}
我们可以看到当我们没有拷贝的时候函数是可以正常运行的
然后我们在拷贝下试试
我们可以发现 当我们使用拷贝函数的时候 是不是就直接报错了啊 这是为什么呢?
在解答这个问题之前我们先来看看这个操作
这里我们可以发现是没有问题的 那么我们接着下一步操作呢
我们再释放一次a
这里是不是报了一个和上面一模一样的错误啊
那么我们就很明确了 上面的函数肯定是对于一块空间多次释放了
那么这是为什么呢?
我们来看图
之后我们释放s1的时候释放了这一块动态开辟的内存
释放s2的时候又释放了一次 所以当然会报错啦
那么这个时候我们就要自己写一个深拷贝
这个时候我们的深拷贝会自己开辟一块动态内存 是不是就不会报错了啊
注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请
时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
3.3 拷贝构造函数典型调用场景
使用已存在对象创建新对象
这个很简单 上面已经给出例子了
函数参数类型为类类型对象
函数返回值类型为类类型对象
MyQueue q3 = q1;
就比如说这一行代码
四. 赋值运算符重载
4.1 概念
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
**函数名字为:**关键字operator后面接需要重载的运算符符号。
**函数原型:**返回值类型 operator操作符(参数列表)
有点怪怪的是吧
我们先来看它的一个使用场景
这里我们定义了一个日期类 要看它的天数是否相同(年月日都相同)
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;
};
int main()
{
Date d1(2022, 9, 21);
Date d2(2022, 9, 22);
cout << (d1 == d2) << endl;
return 0;
}
我们可以发现 这里使用了“==” 操作符比较不了
这是为什么呢?
其实仔细想想就能明白 因为编译器并没有能够很好的处理自定义类之间的比较关系
那么这个时候就应该我们的operator函数上场了
4.2 使用方法(运算重载)
我们使用operator之后发现报了个这样子的错误 (参数太多)
当然啦 这是语法规定
那么只有一个参数这个时候我们应该怎么比较呢?
那么现在我们的this指针就派上用场了
bool operator==( const Date& d2)
{
// 这里为了方便大家理解只写一个this 其他地方不写
return this->_year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
这里函数写法如上
我们再试试==能不能用
我们发现 这样子就可以完美运行了
这里我们要注意的点有
不能通过连接其他符号来创建新的操作符:比如operator@ 重载操作符必须有一个类类型参数
用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
. :: sizeof ?: .注意以上5个运算符不能重载。这个经常在笔试选择题中出现。*
4.3 赋值重载
顾名思义嘛 我们这里要讲关于赋值重载的问题
那么对比于运算重载赋值重载有什么特点呢?
我们这里先给出以下格式 在后面的讲解中会逐个解释它们
参数类型:const T&,传递引用可以提高传参效率
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
检测是否自己给自己赋值
返回*this :要复合连续赋值的含义
我们先来看以下代码
void operator=(const Date& d) //&:避免拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
简单验证下 说明我们写的 = 赋值运算符是对的
这里简单解释下
因为是引用传参数 所以说d是d2的别名 而因为是d1调用的函数 所以说this指针是指向d1的
我们都知道对于赋值操作来说 是要满足链式赋值的
比如说
那么我们来看看我们重载的赋值运算满足不满足这个规律
我们可以发现 报错了 这是为什么呢?
看看操作信息 这是由于我们=操作符重载后的返回值是void
我们修改下 改成Date看看
我们发现 设定了返回值之后就可以完美运行了
4.4 默认的赋值重载符
我们上面已经介绍过 六大默认函数都有一个特点 就是如果你不写 编译器就会默认给你写一个
那么我们来看看系统给的赋值操作符是什么样子的
咦 好像也可以完成我们的任务
那么是不是系统给的默认赋值重载符就没有问题呢?
这里给出我们之前的栈函数
class Stack
{
public:
Stack(int capacity = 4)
{
cout << "Stack(int capacity = 4)" << endl;
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
void Push(int x)
{
// ....
// 扩容
_a[_top++] = x;
}
private:
int* _a;
int _top;
int _capacity;
};
void TestStack()//析构两次,且内存泄漏
{
Stack st1;
st1.Push(1);
st1.Push(2);
Stack st2(10);
st2.Push(10);
st2.Push(20);
st2.Push(30);
st2.Push(40);
st1 = st2;
}
int main()
{
TestStack();
return 0;
}
我们可以发现这里st2指针指向的内存地址是不是改变了啊
是不是实际上造成了内存泄漏(st2指针指向的地址释放不了)
和多次释放的问题(st1指针指向的地址多次释放 )
所以说系统的默认赋值重载并不能满足我们的需要
这里我们应该自己再写一个
我们来看看结果
这里要注意一点 我们返回栈一定要引用返回
不然的话就会引起拷贝构造 使用完毕之后会析构 导致我们st1里里面指针指向的空间被释放
而在程序结束之后会二次(析构)释放 所以说会报错
4.5 赋值重载的总结
一
- 参数尽量使用引用传参 提高效率
- 返回值尽量使用引用返回 为了提高效率和连续赋值
- 要检查是否自己给自己赋值 加个if判断就好 如果自己给自己赋值就是无意义的 所以说直接break;
- 返回值要返回*this (其实就是返回当前类)
二
赋值运算符只能重载成类的成员函数不能重载成全局函数
这个很好理解 你要是重载全局函数不就乱套了嘛?
三
当用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值
四
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现
上面有很多的例子说明了
总结
本篇博客主要带领大家学习了四种重要的默认函数
其中构造函数和析构函数是一对 一个初始化 一个销毁
构造函数跟拷贝函数很像 区别在于拷贝函数多了一个引用参数
赋值重载函数的注意点很多 总结起来需要内存管理的函数要特别注意
以上就是本篇博客的全部内容啦
由于博主才疏学浅所以难免会出现错误 希望大佬可以及时指正
如果本篇博客帮助到你的话别忘了一件三连啊
阿尼亚 哇酷哇酷!