【C++】类和对象(中)—— 构造函数 + 析构函数 + 赋值拷贝 + 运算符重载

1. 类的默认六个成员函数

如果一个类中什么成员都没有,称为空类。空类中什么都没有吗?并不是的。任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。这就是C++比较复杂的初始化机制。

class Date{}

它们是特殊的成员函数,特殊的点非常多,后面一一展开。

🍬小边有话要说:对于下面介绍的默认成员函数。我们写就按照规则写,要写什么要心中有数。如果我们不写,编译器会自己生成一份,那它们有什么特征,也是这里比较复杂的地方,也要做到心中有数,也好决定我自己写不写。

2. 构造函数

2.1 构造函数概念

构造函数特殊的成员函数。注意,构造函数的虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象

在写数据结构时,我就吃过这样的亏,最后出了稀奇古怪的错误,调试然后发现忘记调用初始化函数了。忘记销毁了我好像还没有直观的感受,但我也看过别人有忘记释放资源把服务器搞挂了的故事。

🍓 那么构造函数就是,对象定义出来就自动调用保证对象一定是被初始化的了。

2.2 构造函数特征

🍓 特性 ——

  1. 函数名和类名相同
  2. 无返回值
  3. 对象实例化时,编译器自动调用对应的构造函数
  4. 构造函数可以重载

❄️来看日期类 ——

class Date
{
public:
	//1.无参构造函数
	Date()
	{
		_year = 0;
		_month = 1;
		_day = 1;
	}
	//2.带参构造函数 - 初始化成指定值
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	//对象实例化时,自动调用
	Date d1;//调用无参构造函数
	Date d2(2022, 1, 17);
	return 0;
}

上面这两个构造函数构成了函数重载,其实他们也可以合并成一个函数,实现同样功能 —— 那就是通过全缺省【推荐like this

	Date(int year = 0, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

注:无参构造函数 Date();和全缺省函数Date(int year = 2002, int month = 2, int day = 19);构成函数重载,语法上可以同时存在,但是,若有 无参调用Date d1;,则有二义性会报错。

❄️5. 但是如果在类中我们没有写构造函数,则C++编译器会自动生成一个无参的默认构造函数,(一旦用户显式定义编译器将不再生成)。d1对象调用了编译器生成的默认构造函数,但是d对象的_year /_month/_day,依旧是随机值。那么这个默认生成的构造函数干了什么?

在C++中把类型分为了两类 ——

  • 内置类型(基本类型)—— C语言原生带类型int/char/double/指针/内置类型的数组
  • 自定义类型 —— struct/class定义的类型

🍓 我们啥也不写编译器会默认生成构造函数 ——

  • 对于内置类型的成员变量不做处理

  • 对于自定义类型的成员变量,会去调用它的默认构造函数(即不用传参就可以调)初始化

    注:如果没有构造函数,编译器就会报错。(比如我显式的写了一个带参的Date()

为此,写了一个自定义类型,来验证第二点 —— 对于自定义类型,会去调用它的默认构造函数

class A
{
public:
	A()
	{
		cout << "A()" << endl;
	}
private:
	int _a;
};

class Date
{
public:
	
private:
	int _year;
	int _month;
	int _day;

	A _aa;
};

int main()
{
	//对象实例化时,自动调用
	Date d1;
	return 0;
}

可以看到,确实是调了_aa的默认构造函数,打印了 ——

再来解释一下 —— 所谓如果没有构造函数,编译器就会报错

如果我把上段代码中的class A做一点修改,就报错了——

在上面代码我们实例化d1时,在Date类中我们啥也没写,对于自定义类型变量_aa,会去调用它无参的默认构造函数。对于A这个类,我们没有写无参/全缺省的构造函数,然后还故意手欠写了一个带参的,那编译器也就没再生成。这就没有默认构造函数可调了,就报错咯。

❄️6. 任何一个类的默认构造函数(不用参数就可以调用),有三个 —— 无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数。无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个(语法上他们可以同时存在,但是如果有对象定义去调用就会报错)。

3. 析构函数

3.1 析构函数概念

与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象的一些资源清理工作。

3.2 析构函数的特征

析构函数是特殊的成员函数。

🍓 特征 ——

  1. 析构函数名是在类名前加上字符~
  2. 无参数无返回值。
  3. 一个类有且只有一个析构函数(无参数无法构成重载)。若未显式定义,系统会自动生成默认的析构函数。
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。

Date类为例,通过打印/调试都能看到在d1生命周期结束时,编译器自动调用了析构函数 ——

class Date
{
public:
    Date(int year = 2002, int month = 2, int day = 19)
	{
		_year = year;
		_month = month;
		_day = day;
	}
     
	~Date()
	{
		cout << "~Date()" << endl;
	}
    
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	return 0;
}

调试发现,这个析构函数好像什么都没有做。事实上,这个Date类也没有资源需要清理,不是所有的类都要析构函数。所以对于它不实现析构函数都是可以的。

那对于我们之前实现的栈这个类 ——

class Stack
{
public:
	//构造函数
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int)* capacity);
		if (_a == nullptr)
		{
			cout << "malloc failed" << endl;
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}

	//析构函数
	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
	Stack st1;
	Stack st2(20);
	return 0;
}

这样就保证了,栈定义出来,就一定被初始化了;出作用域,在堆上申请的空间一定被回收了。就不会再忘记手动InitDestroy

注:析构顺序?st2先清理,st1后清理(调试可看)

  1. 如果我们不写,编译器自动生成的析构函数,会做一些什么呢?

🍓与构造函数类似,它——

  • 对内置类型的成员变量不做处理
  • 对于自定义类型的成员变量会回去调它的析构函数

我们以用两个栈实现队列为例,(在这儿不谈题目思路,思路看我题解),主要看默认生成的作用 ——

class Stack
{
public:
	//构造函数
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int)* capacity);
		if (_a == nullptr)
		{
			cout << "malloc failed" << endl;
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}

	//析构函数
	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	int* _a;
	int _top;
	int _capacity;
};

class MyQueue {
public:
	// 我们不需要写,构造函数和析构函数
    // 默认生成的很有用
	// 对于自定义类型,会自动调用它的默认构造函数和析构函数
	/*MyQueue() {} */
    
	void push(int x) {}
	int pop() {}
	int peek() {}
	bool empty() {}
    
private:
	Stack _pushST;
	Stack _popST;
};

int main()
{
	MyQueue mq;
	return 0;
}

而之前的C语言实现,哎,要手动调用 ——

MyQueue* myQueueCreate() {
    MyQueue* q = (MyQueue*)malloc(sizeof(MyQueue));
    StackInit(&q->pushST);
    StackInit(&q->popST);
    return q;
}

void myQueueFree(MyQueue* obj) {
    StackDestroy(&obj->pushST);
    StackDestroy(&obj->popST);
    free(obj);
}

4. 总结

上文描述了太多细节了,确实容易晕,在此汇总一下,就非常非常清晰了 ——

4.1 构造函数

4.2 析构函数

知识框架 ——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K8T4lk5T-1642594487058)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220119181854832.png)]

5. 拷贝构造函数

5.1 拷贝构造函数概念

拷贝构造是用一个已存在的同类对象拷贝初始化一个即将创建的对象。这个概念要清晰,和本文6.2概念区分开。

5.2 拷贝构造函数特征

拷贝构造函数也是特殊的成员函数。

🍓其特征如下:

  1. 拷贝构造函数是构造函数的一个重载形式。它的函数名就是类名,无返回值。

  2. 拷贝构造函数的参数只有一个必须使用引用传参,使用传值方式会引发无穷递归调用

class Date
{
public:
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	//构造函数
	Date(int year = 0, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2002, 3, 7);
	Date d2(d1);
	return 0;
}

注:这里const Date&常引用,最明显的原因是防止误写,当然还有很多原因,后续学习。

🍓下面来解释一下,为什么拷贝构造函数的必须使用引用传参,因为使用传值方式会引发无穷递归调用

如果我们传值传参 ——

这里可能比较有疑惑的是,传值传参为什么是拷贝构造 ——

传值传参,就是把实参的值拷贝赋给形参,用同类型的来初始化你,其实就是一个拷贝构造。下面这段代码,调试可以观察到,先进入了拷贝构造函数,再进入了f(Date d)函数 ——

但是引用传参,d就是d1的一个别名。

  1. 如果没有显式定义,系统生成默认的拷贝构造函数

🍓这块儿和之前的构造函数和析构函数有点差别 ——

  • 内置类型成员,会完成字节序拷贝(浅拷贝)
  • 自定义类型成员,会去调用它的拷贝构造

我们来验证一下:

可以看到,编译器生成的默认构造函数,对于内置类型成员,确实完成了字节序的拷贝。也就是说像日期类这样的我们完全可以不写。

而对于栈呢 ?我们还啥也不写 ——

class Stack
{
public:
	//构造函数
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int)* capacity);
		if (_a == nullptr)
		{
			cout << "malloc failed" << endl;
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}

	//析构函数
	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
	Stack st1(10);
	Stack st2(st1);
}

就崩了💩 ——

这是因为 ——

像这种类,就不能用默认的了,要我们自己实现。

对于自定义类型变量,确实会调用它的拷贝构造函数,我们可以验证 ——

class A
{
public:
	A(const A& a)
	{
		cout << "A(const A&)" << endl;
	}

	A()
	{

	}
};

class Date
{
public:
	//构造函数
	Date(int year = 0, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
private:
	int _year;
	int _month;
	int _day;

	A _a;
};

int main()
{
	Date d1(2002, 3, 7);
	Date d2(d1);
	return 0;
}

6. 赋值运算符重载函数

在默认情况下,C++是不支持自定义类型对象使用运算符,因为系统也不知道运算规则。

比如,对于我们的日期类

 	Date d1(2022, 1, 19);
	Date d2(2022, 1, 31);

我想比较任意两个日期大小d1 < d2,想计算还有多少天过春节d2 - d1,都是没办法直接用运算符计算的。

为此,我们引入了运算符重载

6.1 运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数。

它的函数原型如下 ——

注:

  • 不能通过连接其他符号来创建新的操作符:比如operator@

  • 重载操作符必须有一个自定义类型操作数

  • 用于内置类型的操作符,其含义不能改变。例如:内置的整型+,不能改变其含义

  • .* (很很少见)、::sizeof? :(三目)、. 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。想想它们的意义,不能重载好像都是有道理的。

于是我们就实现了这样一个比较日期类大小的逻辑 ——

注:传参可以传值,但是在C++中建议传引用,这样可以减少拷贝提高效率。且,如果我们无需改变操作数,就用常引用const Date&(防止我一不小心改了形参,把实参也改了;而且const引用对于接收对象通吃,是权限的缩小/不变)

因为我的成员变量是private私有的,在类外不能访问,所以都标红了。那我姑且先把访问修饰限定去掉。

围绕此,我们要探讨两个问题 ——

🍓 1. d1 > d2;是怎样调用这个函数的?

🍓 2. 成员变量私有在类外访问不了如何解决?

6.1.2 Q&A1

🍓 d1 > d2;是怎样调用这个函数的?

F11调试可见,确实是调用了。事实上,编译器会把d1 > d2转化为operator>(d1, d2),看看有没有重载。

但一般也不会有人去写这第二行的形式,运算符重载的初衷就是为了增强可读性,不然你这跟函数调用有什么区别。

6.1.3 Q&A2

🍓 成员变量私有在类外访问不了如何解决?

我们为了让程序运行简单粗暴去掉了访问修饰限定符private,这实际上破坏了封装性。

我们也可以在类中写上诸如int GetYear() {}这样的函数,这不破坏封装性,但还是有些麻烦,也不常用。

那我们把它挪到类里面去!然而问题又来了 ——

这是因为,成员函数默认多了一个this指针。那我们来改造它一下 ——

class Date
{
public:
	Date(int year = 0, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	//d1.operator>(d2);
	bool operator>(const Date& d)
	{
		if (_year > d._year)
		{
			return true;
		}
		else if (_year == d._year && _month > d._month)
		{
			return true;
		}
		else if (_year == d._year && _month == d._month && _day > d._day)
		{
			return true;
		}
		else
		{
			return false;
		}
	}
private:
	int _year;
	int _month;
	int _day;
};

于是d1 > d2;我们这样调用它时,实际上会被编译器处理成d1.operator>(d2); ——

我们推荐写这种调用形式 ——

  d1.operator>(d2);

6.2 赋值运算符重载

6.2.1 赋值重载的概念

有了上文的铺垫,再来介绍赋值运算符重载,就轻松很多。

在6.1中,我们介绍了拷贝构造函数 —— 它是用一个已经存在的对象,拷贝初始化一个即将创建的对象。

	Date d1(2022, 1, 31);
	Date d2(d1); //拷贝构造

下面我们要介绍赋值重载,注意与上边的区别 —— 它是两个已经存在的对象,之间进行拷贝赋值。

	Date d1(2002, 3, 7);
	Date d2(2002, 2, 19);
	d1 = d2; //赋值重载

小思考 :这是拷贝构造还是赋值重载?

	Date d1(2002, 3, 7);
	Date d2 = d1;

根据定义嘛~ 这是拷贝构造

6.3.2 赋值重载的实现细节

有了上文6.1的关于运算符重载函数的知识铺垫,我们很容易就能写出这样的赋值重载函数 ——

class Date
{
public:
	Date(int year = 0, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
    // 赋值重载
	//d1.operator=(d2);
	void operator=(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};


int main()
{
	Date d1(2022, 1, 19);
	Date d2(2022, 1, 31);

	d1 = d2;
	d2.operator=(d1);
	return 0;
}

但这样写是不完美的,主要有以下两点:

🍓 1. 它是有返回值的

回想学习操作符时,有这样的连续赋值 ——

	int i, j, k;
	i = j = k = 10;//连续赋值,从左向右

在C++中,我们也逃不了日期类这样连续赋值,我们需要返回值——

	d1 = d2 = d3;

那我们可以传值返回 ——

	// d1 = d2;  =>  d1.operator=(d2);
	Date operator=(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
        
        //即返回d1 -- 返回了调用这个函数隐含的操作数
        return *this; 
	}

传值返回可以吗?可以。

众所周知,传值返回会生成一个临时拷贝的对象(调试/打印可以看到,上文那样的连续赋值会调用两次拷贝构造),传引用可以减少拷贝(调试可以看到对比,我自己都验了哈)。并且这里出了作用域,d1*this)还在,可以传引用返回

🍓 2. 自己给自己赋值,直接判断一下跳过

对于这种特殊情况,我们可以优化,如果地址一样,就不赋值了 ——

	Date& operator=(const Date& d)
	{
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		return *this;
	}

这样就完美啦!

🍓如果我们没写,编译器默认生成的赋值重载,跟拷贝构造做的事儿完全类似 ——

  • 对于内置类型成员,会完成字节序值拷贝 —— 浅拷贝。
  • 对于自定义类型成员,会调用它自己的operator=函数

在此不做赘述,看下边的小总结。

7. 小总结

7.1 拷贝构造函数

7.2 赋值运算符重载


本文完@边通书

  • 23
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 13
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浮光 掠影

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值