【C++】类和对象-深度剖析默认成员函数-上

 
> 🍃 本系列为初阶C++的内容,如果感兴趣,欢迎订阅🚩
> 🎊个人主页:[小编的个人主页])小编的个人主页
>  🎀   🎉欢迎大家点赞👍收藏⭐文章
> ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍


目录

🐼类的默认成员函数

⭐️概念:

 🐼构造函数

⭐️概念:

⭐️特点:

 🐼 析构函数

⭐️概念

⭐️特点:

  🐼拷贝构造函数

⭐️概念

⭐️剖析:


🐼类的默认成员函数

⭐️概念:

默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数

在C++中有6个默认成员函数:构造函数(包括无参和带参)、析构函数、拷贝构造函数、赋值运算符重载、const成员函数以及取地址操作符

这些默认成员函数前四个比较重要。它们在类的创建、初始化、清理和资源管理中起着关键作用。

 🐼构造函数

 我们先来看看这样一个例子:

#include<iostream>
using namespace std;
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(2024, 11, 14);
    d1.Print();

    Date d2;
    d2.Init(2022, 11, 14);
    d2.Print();
}

创建对象d1,d2,在调用Print方法之前,我们都需要分别先调用Init方法,我们能不能想个方法,在对象创建时,就可以完成初始化,即d1,d2的初始化信息导进去。这就需要构造函数来完成

⭐️概念:

构造函数是特殊的成员函数,在创建类对象时由编译器自动调用,用于在创建对象时初始化对象。

🐟需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象(我们常使用的局部对象是栈帧创建时,空间就开好了),而是对象实例化时初始化对象也就是在对象诞生时,就给对象适合的值,进行实例化,这样对象刚出生,就给成员变量一个合适的初始值。并且在对象生命周期只初始化一次

例如:在创建对象d1,d2时,就已经对成员变量   _year; _month; _day一个合适的初始值,不需要再单独调用Init函数了。

⭐️特点:

  1. 构造函数名与类名相同
  2. 构造函数无返回值。(返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)
  3. 对象实例化时系统会自动调用对应的构造函数。
  4. 构造函数可以重载。也就是构造函数可以有多个,具体调用哪个看参数。
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成⼀个无参的默认构造函数,⼀旦用户显式定义编译器将不再生成。
  6. 不传实参就可以调用的构造就叫默认构造,无参构造函数全缺省构造函数、我们不写构造时编译器默认生成的构造函数都叫做默认构造函数但是这三个函数有且只有⼀个存在,不能同时存在。无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。

举个例子:

#include<iostream>
using namespace std;
class Date
{
public:
	//有参构造函数
	/*Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}*/
	默认构造函数
	全缺省
	Date(int year = 1, int month =1 , int day= 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//无参
	//Date()
	//{
	//	_year = 2024;
	//	_month = 11;
	//	_day = 14;
	//}
	void Print()
	{
		cout << _year << '-' << _month << '-' << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	d1.Print();
   	Date d2(2024, 11, 14);
	d2.Print();
}

🐋我们创建一个对象d1的同时,这里调用默认构造函数,编译器自动调用构造函数;创建了对象d2,这里调用带参数的构造函数。我们这里没有显示定义Init函数来对我们的对象初始化,是因为在创建对象时已经完成了对对象的初始化

运行结果:

注意:如果通过无参构造函数创建对象时,对象后面不跟括号,如Date d1()这是错误写法,否则编译器无法  区分这里是函数声明还是实例化对象 会报错: warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意⽤变量定义的?)

🌏如果我们不显示写:编译器对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器。如果我们把默认构造函数去除,调用编译器自已的默认构造函数,vs运行结果:

🌴对于自定义类型(struct class)成员变量,要求调用这个成员变量的默认构造函数初始化

比如两个栈(Stack)实现队列(MyQueue),其中自定义类型Stack为成员变量,这就需要我们对Stack进行初始化,而MyQueue会自动调用这个成员变量(Stack)的默认构造函数初始化,我们不需要显示写

如:

#include <assert.h>
#include<iostream>
using namespace std;
class Stack
{
public:
	Stack(int n = 4)
	{
		int* tmp = (int*)malloc(sizeof(int)*n);
		if (tmp == nullptr)
		{
			perror("malloc fail");
			return;
		}
		capacity = n;
		arr = tmp;
		cout << "调用Stack默认构造函数" << endl;
	}
	private:
	int* arr;
	size_t top;
	size_t capacity;
};
	//两个栈实现队列
//这里自定义类型stack,默认构造函数已经实现,myqueue可以不写默认构造函数
// 编译器会自动调用自定义类型的默认构造函数
class MyQueue
{
public:
	
	// 不需要写构造,默认生成就可以用
private:
	Stack pushlit;
	Stack poplit;
};

int main()
{
	MyQueue q1;
	
	return 0;
}
解释

在上述例子中,我们实现了Stack的默认构造函数,而MyQueue没有实现构造函数,这并不是说MyQueue调用编译器自动生成的默认构造函数,而是编译器会自动调用自定义类型的默认构造函数。

总结:这里自定义类型stack,默认构造函数已经实现,myqueue可以不写默认构造函数。

运行结果:

调试观察:

我们创建了一个MyQueue的类对象q1,q1有两个Stack类型的成员变量pushlit,poplit,MyQueue会自动调用这个成员变量(Stack)的默认构造函数初始化

 🐼 析构函数

🌟如果说构造函数是是在对象诞生时给对象实例化。那么析构函数就是完成对象销毁时对对资源的清理和释放

⭐️概念

析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的, 函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作

⭐️特点:

  1. 析构函数名是在类名前加上字符~;
  2. 无参数无返回值。(这里跟构造类似,也不需要加void)
  3. ⼀个类只能有⼀个析构函数。若未显式定义,系统会自动生成默认的析构函数。不能析构两次,否则可能造成空间的重复释放;
  4. 对象生命周期结束时,系统会自动调用析构函数。

举例:

class  animal
{
public:
	animal()
	{
		std::cout << "自动调用构造函数" << std::endl;
	}
	~animal()
	{
		std::cout << "自动调用析构函数" << std::endl;
	}

private:

};
int main()
{
	animal dog;
	std::cout << "对象销毁" << std::endl;

	return 0;
}

运行结果:

在上述例子中我们定义了一个动物类,并创建了一个对象dog,我们发现,如果我们显示的定义构造函数和析构函数,编译器会自动调用我们生成的构造函数和析构函数在对象创建时调用构造函数,在对象销毁时,编译器会自动调用析构函数

跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定类型成员会调用他的析构函数。  

如:


#include<iostream>

class  animal
{
public:
	animal()
	{
		std::cout << "自动调用构造函数" << std::endl;
	}
	~animal()
	{
		std::cout << "自动调用析构函数" << std::endl;
	}

private:

};
class fish
{
public:
	~fish()
	{
		std::cout << "自动调用析构函数fish" << std::endl;
	}
private:
	animal carp;//鲤鱼
	animal  eel;//鳗鱼
};

int main()
{
	fish f1;
	std::cout << "对象销毁" << std::endl; 
	return 0;
}

运行结果:

🌲我们在上述又新定义了一个鱼类🐋,鱼类的成员变量都是animal类的,这时候我们不需要显示的在fish中写析构函数,因为编译器会自动调用自定义类型(animal)的析构函数。

🌏如果我们显示写,编译器也会先调用他的析构函数,再调用自定义类型的析构函数。就是我们显示写析构函数,对于自定义类型成员也会调用他的析构,则先调用fish的析构函数,再调用两个自定义成员变量的析构函数,也就是说自定义类型成员无论什么情况都会自动调用析构函数。

🌿如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如Date;如 果默认生成的析构就可以用,也就不需要显示写析构,如MyQueue;但是有资源申请时,⼀定要自已写析构,否则会造成资源泄漏,如Stack

下面代码是上面文字的解释:

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//Date类没有申请的资源,可以不写析构函数
private:
	int _year;
	int _month;
	int _day;
};

using namespace std;
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;
	std::cout << "自动调用构造函数" << std::endl;

	}
	//Stack中有申请的资源,析构函数一定要写
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
		std::cout << "自动调用析构函数" << std::endl;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};

int main()
{
	Stack s1;
	std::cout << "对象销毁" << std::endl; 
	return 0;
}

解释

🌲析构函数的功能类比我们之前Stack实现的Destroy功能,而像Date没有 Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的

注意:⼀个局部域的多个对象,C++规定后定义的先析构

🔍总结:我们发现有了构造函数和析构函数确实方便了很多,不会再忘记调用nit和Destory函数了,也方便了不少

构造函数基本都需要,析构函数是有对象有资源要释放时需要,否则,可以不显示定义。如果没有显示的写构造函数和析构函数,编译器会自动生成默认生成的构造函数和析构函数,在对象创建和销毁时调用时,会自动调用。

  🐼拷贝构造函数

🌟在我们现实生活中,会有双胞胎。在C++的世界里,也会有对象的双胞胎,比如创建了一个对象d1,再拷贝另一个与d1完全相同的对象d2即用d1初始化一个对象化d2,这就需要另一个默认成员函数,就是拷贝构造函数.

⭐️概念

🌲如果⼀个构造函数的第⼀个参数是自身类类型的引用(&),一般是(const)引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是⼀个特殊的构造函数,是构造函数的重载

⭐️剖析:

我们来看下面这个例子再来解释拷贝构造函数一些问题和要求:


#include<iostream>
using namespace std;
class Date
{
public:
//	
//	默认构造函数
//	全缺省//这里可以任意选择一种构造函数进行初始化
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//拷贝构造函数
	// //正确写法
	Date(const Date& d)
	{
		this->_year = d._year;
		this->_month = d._month;
		this->_day = d._day;
	}
	//错误写法
	/*Date( Date d)
	{
		this->_year = d._year;
		this->_month = d._month;
		this->_day = d._day;
	}*/

	void Print()
	{
		cout << _year << '-' << _month << '-' << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2024,11,14);
	Date d2(d1);
	d1.Print();
	d2.Print();
}

解释

🌾我们这里创建了一个对象d1,并调用默认构造函数的全缺省进行初始化。我们这里又想创建一个与d1完全相同的对象d2(Date d2(d1));,我们在之前学习了this指针,其实,这里完整的实参是Date d2(&d2,d1),形参是Date(const Date* this,const Date& d);实现了将d1一一赋值给d2(这里拷贝构造函数中的this指针可以省去,这里仅是为了理解),这样我们就创建了一个以d1为模版的d2。


我们现在来解决几个问题:

👀为什么拷贝函数的第⼀个参数是自身类类型的引用(&)?

拷贝构造函数的参数只有⼀个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用

C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里传值传参要调用拷贝构造

🌾如果直接将d1传过去,d1是自定义类型的对象,所以这里涉及传值传参要调必须先调用拷贝构造函数,而如果这样的写法,调用拷贝构造函数又是传值传参,就又要先调用拷贝构造函数,形成了逻辑递归死循环。如图:

即:如果是传值传参,必须先调用拷贝构造函数,而拷贝构造函数又会引起传值传参,又要调用新的拷贝构造

这里直接用&引用,就不会引发传值,因为引用在语法层d就是d1,没有额外申请空间,就不会引发传值调用,就不会调用新的拷贝构造函数。所以只有第⼀个参数是自身类类型的引用(&)的构造函数是拷贝构造函数

👀为什么拷贝函数的第⼀个参数前要加const修饰?

因为在拷贝过程中,我们只希望原拷贝对象(d2)拷贝复制被拷贝对象(d1),不希望原拷贝对象(d1)发生改变,如果不加const,很容易就将原拷贝对象修改,这样做,是希望在拷贝过程中,不改变原拷贝对象(d1),通常,我们写拷贝构造函数时都要在第⼀个参数引用(&)前+(const)加强代码的健壮性

👀可否直接用将d1的地址传过去,形参指针来接收呢?

理论上是可以的。

代码:

//指针接收//这就不是拷贝函数,而是构造函数
//Date d2(&d1)
Date(Date* d)
{
	cout << "Date(Date* d)" << endl;
	_year = d->_year;
	_month = d->_month;
	_day = d->_day;
}

🌸不过这样写,虽然也能完成,但也不符合C++拷贝构造函数的定义,这就不是拷贝构造函数了,而是普通的构造函数,这样写Date(const Date& d)才是拷贝构造函数,而不是指针。

👀只要进行传值传参的自定义类型对象进行拷贝行为必须调用拷贝构造?

我们以下面这个例子为例:


Date fun1()
{
	Date d3;
	return d3;
}

//传值传参调用拷贝构造
void fun2(Date d)
{

}
int main()
{
	Date d4 = fun1();
	fun2(d4);
}

🌸调用fun1时,创建了对象d3并返回,在C++中,我们知道,传值返回,要先拷贝在一个临时变量中,所以就调用了拷贝构造函数;在fun2中,实参和形参的传值传参就会先调用拷贝构造函数。总结,在自定义类型的传值传参,或传值返回,代价会更大,因为都会先调用拷贝构造

我们还要知道拷贝构造函数的两种写法

Date d1;
Date d2(d1);
Date d2 = d1;//这种方式也表示拷贝构造

我们再来看下面一个例子,引用返回对象的易错点:


// Date Func2()
Date& Func2()
{
	Date tmp(2024, 7, 5);
	tmp.Print();
	return tmp;
}


int main()
{
	// Func2返回了⼀个局部对象tmp的引用作为返回值 
	// Func2函数结束,tmp对象就销毁了,相当于了⼀个野引用

	Date d1 = Func2();
	d1.Print();
}

🌿我们先区分:传值返回会产生一个临时对象调用拷贝构造,传值引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是⼀个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于⼀个野引用,类似⼀个野指针⼀样。虽然传引用返回可以减少拷贝,但是⼀定要确保返回对象,在当前函数结束后还在,才能用引用返回

🌿若未显式定义拷贝构造,编译器会生成自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造。

对于内置类型成员变量的浅拷贝:

#include<iostream>
using namespace std;

class Date
{
public:
	//	
	//	默认构造函数
	//	全缺省//这里可以任意选择一种构造函数进行初始化
	Date(int year = 1, int month = 1, int day = 1)
	{
		_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 d2(d1);

	d1.Print();
	d2.Print();
	return 0;
}

运行结果:

🌻我们这里没有显示的写拷贝构造函数,但是发现d2还是拷贝了d1。因为在C++中,如果我们不写拷贝构造函数,自动生成的拷贝构造对内置类型成 员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造。


下面我们再来看一个拷贝对象有资源时的深拷贝,如栈,向堆上开辟空间,就属于有资源的拷贝,这时候如果再发生浅拷贝,会有冲突,如:

#include<iostream>
using namespace std;
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;
	}
	//Stack(const Stack& st)
	//{
	//	// 需要对_a指向资源创建同样大的资源再拷贝值 
	//	_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;
	//}
	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
				sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;
	}
	void Pop()
	{
		_a[_top - 1] - 1;
		_top--;
	}
	//取栈顶元素
	STDataType Top()
	{
		return _a[_top - 1];
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};

int main()
{
	Stack st1;
	
	st1.Push(1);
	st1.Push(2);
	st1.Push(3);
	st1.Push(4);
	Stack st2(st1);//st1和st2指向同一片空间
	st1.Pop();
	st1.Pop();

	cout << st2.Top()<< endl;
}

像Stack这样的类,虽然也都是内置类型,但 是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求😞,所以需要我们自已实现深拷贝(对指向的资源也进行拷贝😊。

🌻如果编译器自动生成的拷贝构造完成的值拷贝/浅拷贝来解决深拷贝会造成两个问题,(1)由于st1,str2的成员变量_a都指向同一片空间,对st1对象的改变就会影响到st2,(2),对象st1,st2销毁编译器都会自动析构函数,导致同一片内存空间被释放两次,造成内存异常访问。所以,对于有资源的类,我们需要我们自已显示完成深拷贝(拷贝构造函数)。

st1,st2指向同一片内存空间:

💫我们再来看第二个:对自定义类型成员变量会调用他的拷贝构造。如下述例子:

#include<iostream>
using namespace std;
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;
	}
	Stack(const Stack& st)
	{
		// 需要对_a指向资源创建同样大的资源再拷贝值 
		_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;
	}
	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
				sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;
	}
	void Pop()
	{
		_a[_top - 1] - 1;
		_top--;
	}
	//取栈顶元素
	STDataType Top()
	{
		return _a[_top - 1];
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};

class MyQueue
{
private:
	Stack _pushst;
	Stack _popst;
};

int main()
{
    // MyQueue⾃动⽣成的拷⻉构造,会⾃动调⽤Stack拷⻉构造完成pushst/popst 
     // 的拷⻉,只要Stack拷⻉构造⾃⼰实现了深拷⻉,就没问题 
	MyQueue q1;
	MyQueue q2(q1);
}

💫像MyQueue这样的自定义类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现MyQueue的拷贝构造。这一点和构造函数和析构函数相似。

这里自动调用Stack拷贝构造函数代码

调试结果:

✌️这里有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写拷贝构造,否则就不需要。 

对于类中成员变量都是内置类型的,如果有资源,拷贝函数需要,否则不需要,对于自定义类型,拷贝函数会自动调用它的拷贝构造函数。

感谢你看到这里,如果觉得本篇文章对你有帮助,点赞👍收藏 ⭐️吧,你的支持就是我更新的最大动力。⛅️🌈 ☀️ 

评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值