C++类的六个默认成员函数(详细解析与总结)

目录

前言:

一、构造函数

a.特点

b.注意事项

1.首先明确什么是默认构造函数

2.默认构造函数对内置类型与自定义类型的处理

c.总结

二、析构函数

a.特点

b.注意事项

1.什么时候写析构函数?

2.析构函数对内置类型与自定义类型的处理

c.总结

三、拷贝构造

a.特点

b.特点的逐个分析以及注意事项

1.为什么拷贝构造要使用传引用返回?

2.为什么使用const修饰类类型对象的引用?

3.系统提供构造与拷贝构造的规则

4.拷贝构造对内置类型与自定义类型的处理

5.如果需要析构函数,则一般都需要拷贝构造与赋值重载

c.总结

四、运算符重载 

a.特点

b.实现日期类的比较

c.函数重载与运算符重载

d.总结

五、赋值重载

a.日期类实现赋值重载

b.赋值重载与拷贝构造

c.赋值重载对类型的处理

d.再来看流插入

e.日期类打印自定义类型

f.补充内联函数

g.总结

六、const修饰的非静态成员函数

七、const修饰的取地址操作符重载

总结:

本篇内容对六个默认成员函数进行了一个较为详细的分析,需要反复的复习并配合使用,其中构造,拷贝构造与运算符重载比较重要,在后面用的很多,后面还会有文章对这部分进行补充,例如友元函数和一些关键字。


前言:

本篇介绍的是有关于c++类中的六个默认成员函数(文章中附上了总结性的思维导图),为什么说是默认成员函数呢?因为大多数情况下,再一个类里面,如果没有写,编译器将会自动生成;如果写了某个,编译器将不会生成对应的默认成员函数;其次对于空类,六个默认成员函数不会生成,只会生成1byte的占位符。 


 

一、构造函数

a.特点

注意:
1.不用写返回值不代表返回值是void。

2.构造函数可重载说明一个类可以有多个构造函数。

3.第三条说明栈里面对象实例化需要初始化,可以在类里面用构造函数来初始化,再实例化对象就会自动调用初始化的构造函数了;无参的构造函数直接对对象实例化就自动调用;有参的构造函数在对象实例化时传参即可调用。

b.注意事项
1.首先明确什么是默认构造函数
class Date
{
public:

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

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

上面的代码是没自己写构造函数,所以可以直接调用编译器默认生成的,在对象实例化的时候自动初始化,注意自动生成的是无参的构造。

class Date
{
public:
   
    //是默认构造
	Date()//无参的构造
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month, int day)//带参的构造
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

int main()
{
    //无参的构造调用不能这样写,编译器无法分明是函数声明还是构造函数
    Date();
    
    //去调用无参的默认构造
	Date d1;
    
    //带参函数构造调用
    Date d2(2024, 3, 27);

	return 0;
}

第一个构造为无参的构造,我们将它看为默认构造函数;第二个构造函数为带参的构造,不是默认的构造函数,需要显示的调用。

它们构成函数重载,需要注意的是,调用无参的构造不能直接写成Date(),这样编译器识别不出这是函数声明还是构造函数的调用。

class Date
{
public:
   
    //是默认构造
	Date()//无参的构造
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	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;
	}

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

int main()
{
    //无参的构造调用不能这样写,编译器无法分明是函数声明还是构造函数
    Date();
    
    //去调用默认构造
	Date d1;
    
    //带参函数构造调用
    Date d2(2024, 3, 27);

	return 0;
}

上面的第三个构造函数为全缺省的构造函数,将无参的与带参的构造函数结合到了一起,我们也认为它为默认构造函数。

但是需要考虑的是,全缺省的能和无参的一起使用吗???

答案是不能的,如果同时存在,那调用无参的构造函数应该去调用哪一个呢?会产生歧义,编译器会报对重载函数的调用不明确的错误;同样的,代码中第二个构造与第三个构造也不能同时存在,如果同时存在,那调用带参的构造函数时应该去调用谁呢?编译器会报构造函数已定义的错误。

2.默认构造函数对内置类型与自定义类型的处理

在我们调用默认生成的构造函数的时候,会发现,为什么用默认的构造函数初始化,对象中的成员变量为随机值,这有什么mao用???

这就关于我们默认成员函数的另一性质了,对于默认构造函数来说:

内置类型成员不做处理,自定义类型成员取调用它的默认构造(不用传参的构造)。

c++把类型分为内置类型(基本类型)与自定义类型:

内置类型:语言提供的类型,如int,char,double...以及这些类型的任意指针。

自定义类型:class,struct,union等自己定义的类型

后来c++11为了处理内置类型的初始化,增加了一条:

内置类型的声明位置可以给缺省值,注意不是初始化(因为给缺省值没有开空间),所以再对内置类型初始化时就不用给缺省值了(有些编译器会直接初始化为0,但是不要依赖)。 

c.总结

二、析构函数

a.特点

在对象销毁时会自动调用析构函数,完成对象中资源的清理工作。

b.注意事项
1.什么时候写析构函数?

析构函数什么时候写呢?像我们后面要完成的日期类可以不写析构函数,因为对象为局部变量,出了作用域就销毁了;而对于像栈的实现,有动态开辟的空间,在堆区上开辟空间,要写析构函数清理资源,否则会造成内存泄露,后面会举例说明。

2.析构函数对内置类型与自定义类型的处理

与构造函数一样,内置类型不做处理,自定义类型会调用它的析构函数。 

c.总结

三、拷贝构造

拷贝构造涉及内容比较深入,且注意点也很多,需要细细分析。

a.特点

b.特点的逐个分析以及注意事项
class Date
{
public:
	/*Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	*/

	Date(int year = 12, 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;
	}

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

int main()
{
	//调用默认(全缺省)构造函数并使用函数中的缺省值
	Date d1;

	//调用默认(全缺省)构造函数并传参
	Date d2(2024, 3, 27);

	//调用拷贝构造
	Date d3(d2);
    //Date d3=d2;
		 
	return 0;
}
1.为什么拷贝构造要使用传引用返回?

结合我们前面的知识,赋值其实就是拷贝(联想c++基础语法篇的隐式类型转换),对于类的对象来说,传值其实就是要去调用它的拷贝构造初始化,但是我们进行拷贝构造需要先传参,传参又是传值传参,传值传参又要调用拷贝构造......就陷入了死递归,且编译器会立刻报错。再根据我们之前c++语法讲解的使用传引用返回可以减少拷贝,所以我们在这里使用一个传引用返回就解决了这里的问题。(c++基础语法篇链接:http://t.csdnimg.cn/hCM1P)到这里有没有体会到知识融汇贯通的感觉呢?

2.为什么使用const修饰类类型对象的引用?

根据上面的代码,假设d2为const对象修饰的,我们在传参的时候传给类类型的引用d,此时d不是一个const修饰的引用,所以可以对其进行修改,这样我们就将权限放大了,而在任何时候权限只能缩小不能放大。

3.系统提供构造与拷贝构造的规则

搜先,由于拷贝构造是构造函数的重载,其实也就是构造的一种,所以我们如果自己写了拷贝构造,编译器就不会给我们提供任何默认的构造函数了,所以再想使用编译器生成的默认的无参构造函数初始化,就不行了,需要我们自己提供构造函数:

其次,如果我们提供了普通的有参的构造函数,且不是拷贝构造,那编译器将不再提供无参的默认构造,但是会提供拷贝构造(是浅拷贝),如下没有手写拷贝构造d3也能完成拷贝:

4.拷贝构造对内置类型与自定义类型的处理

对于拷贝构造而言,自定义类型的拷贝,需要调用拷贝构造;

内置类型可以直接拷贝,就像我们将d2拷贝给d3;或者需要深拷贝(默认生成的或者我们直接赋值的是浅拷贝,深拷贝需要我们自己写),因为拷贝是按字节去拷贝的,对于像动态开辟的变量,只是按字节拷贝,拷贝出来这两个动态变量在堆区上指向的位置却还是一样的,就像我们将栈的实现封装为stack这样的一个类,内置类型使用了动态开辟的变量,再进行拷贝,这样就会引发问题:

可以看到,拷贝出来的两个对象中的_a都指向了同一块空间(因为你只是负责拷贝字节,没法改变空间的指向啊),这样就会引发析构时需要析构两次,并且在这个场景下参与拷贝的两个对象插入删除数据会互相影响。

深拷贝:

再给st2开辟一块空间,再让st1按字节拷贝过去

以上是针对内置类型的深浅拷贝,下面是对于自定义类型的默认拷贝构造:

对于MYQueue这样的自定义类型,编译器默认生成的这三个构造函数的都可以用。

_size是内置类型,是值拷贝。

成员变量中的_pushST与_popST都是自定义类型(这里的stack没有封装成一个类,跟上面的stack不一样,这里是stack是自定义类型,会调用拷贝构造,但是这个自定义类型中有动态开辟的变量,所以要考虑深拷贝;而上面的stack是一个类,_a是它的成员,是一个内置类型,默认是浅拷贝(值拷贝),所以也需要考虑深拷贝。二者有些区别,注意区分),如果使用编译器默认生成的就是浅拷贝,因为它们都含有动态开辟的变量:

5.如果需要析构函数,则一般都需要拷贝构造与赋值重载

有了上面的知识,我们再来理解这句话就更加明白了,因为如果需要我们手写析构函数,一般来说是默认的析构函数不够用了,例如动态开辟的变量,所以为了防止刚刚提到的浅拷贝导致的双重释放,我们就要自己来写一个拷贝构造与赋值重载来防止浅拷贝。析构也通常用来清理拷贝构造函数产生的临时变量,所以拷贝构造后一般紧跟就是析构。

c.总结

四、运算符重载 

下面要谈到赋值重载,就要先了解运算符重载。

a.特点

b.实现日期类的比较
class Date
{
public:
	/*Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	*/

	Date(int year = 12, 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;
	}

	//d1==d2
	bool operator==(const Date& d)//这里使用引用是因为函数传参也需要拷贝,引用来减少拷贝
	{
		return  _year == d._year//this->_year==d._year
			    && _month == d._month
			    && _day == d._day;
	}

	//d1<d2
	bool operator<(const Date& d)
	{
		 return (_year < d._year) 
			||((_year == d._year )&&( _month < d._month)) 
			|| ((_year == d._year) && (_month == d._month) &&( _day < d._day));
	}

	//d1<=d2
	bool operator<=(const Date& d)
	{
		return (*this < d) || (*this == d);
	}


	//d1>d2
	bool operator>(const Date& d)
	{
		return !(*this <= d);
	}

	//d1>=d2
	bool operator>=(const Date& d)
	{
		return !(*this < d);
	}

	//d1!=d2
	bool operator!=(const Date& d)
	{
		return !(*this == d);
	}
	

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

int main()
{

	Date d1(2023, 3, 28);
	Date d2(2024, 3, 28);

	//调用时相当于d1.operator==(d2)
	//调用也可以写为operator==(d1,d2),但一般不会这样写
	cout<<(d1 == d2)<<endl;//0

	cout<<(d1 < d2)<<endl;//1

	cout<<(d1 > d2)<<endl;//0
	
	cout<<(d1 >= d2)<<endl;//0

	cout<<(d1 <= d2)<<endl;//1
	
	cout<<(d1 != d2)<<endl;//1

	//调用默认(全缺省)构造函数并使用函数中的缺省值
	//Date d1;

	//调用默认(全缺省)构造函数并传参
	//Date d2(2024, 3, 27);

	//调用拷贝构造
	//Date d3(d2);
		 
	return 0;
}

注意使用时:

例如d1==d2,也可以写为operator==(d1,d2)(但是一般不会这样写),在调用的时候会转换为d1.operator==(d2),所以传的this指针也是第一个参数就是d1,第二个参数为d2。

这里比较返回bool值判断真假,在打印时需要注意,<<的优先级高于==等运算符,所以要加上括号。

进行比较的运算符的重载时,可以先写==与<或者其它的,然后其它的可以复用。

c.函数重载与运算符重载

运算符重载实际就是函数重载。

d.总结

五、赋值重载

我们来围绕日期类来进行举例说明,且下面的知识的梳理都是循序渐进的。

a.日期类实现赋值重载
class Date
{
public:
	/*Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	*/

	Date(int year = 12, 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;
	}

	//d1==d2
	bool operator==(const Date& d)//这里使用引用是因为函数传参也需要拷贝,引用来减少拷贝
	{
		return  _year == d._year//this->_year==d._year
			    && _month == d._month
			    && _day == d._day;
	}

	//d1<d2
	bool operator<(const Date& d)
	{
		 return (_year < d._year) 
			||((_year == d._year )&&( _month < d._month)) 
			|| ((_year == d._year) && (_month == d._month) &&( _day < d._day));
	}

	//d1<=d2
	bool operator<=(const Date& d)
	{
		return (*this < d) || (*this == d);
	}


	//d1>d2
	bool operator>(const Date& d)
	{
		return !(*this <= d);
	}

	//d1>=d2
	bool operator>=(const Date& d)
	{
		return !(*this < d);
	}

	//d1!=d2
	bool operator!=(const Date& d)
	{
		return !(*this == d);
	}
	

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

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

		return *this;
	}

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

int main()
{

	Date d1(2023, 3, 28);
	Date d2(2024, 3, 28);
	Date d3(2025, 3, 28);
	//调用时相当于d1.operator==(d2)
	//调用也可以写为operator==(d1,d2),但一般不会这样写
	cout<<(d1 == d2)<<endl;//0

	cout<<(d1 < d2)<<endl;//1

	cout<<(d1 > d2)<<endl;//0
	
	cout<<(d1 >= d2)<<endl;//0

	cout<<(d1 <= d2)<<endl;//1
	
	cout<<(d1 != d2)<<endl;//1

	d3 = d1 = d2;

	d1.Print();
	d3.Print();
	//调用默认(全缺省)构造函数并使用函数中的缺省值
	//Date d1;

	//调用默认(全缺省)构造函数并传参
	//Date d2(2024, 3, 27);

	//调用拷贝构造
	//Date d3(d2);
		 
	return 0;
}

注意:
 1.为什么要带返回值?

因为对于赋值运算符来说,例如i=j=k这个表达式,j=k这个表达式返回左操作数,返回值    再作为右操作数再赋值给给i。  

所以我们要支持连续赋值,例如d3=d1=d2,d1作为返回值对应*this,所以返回d1,那为什么要用*this呢?因为*this出了赋值重载的作用域还在(因为d1是在主函数定义的,生命周  期在主函数里)所以可以直接返回*this。

 2.为什么参数要用引用?

因为可以减少拷贝。当然这里不加&也不会死循环,因为传值传参调用拷贝构造,我们已经实现过拷贝构造了,调用完拷贝构造就进赋值重载函数了,只有自己对自己传值传参的拷贝构造才会死循环。

 3.为什么函数体要这样写?

函数中要考虑自己对自己赋值,判断一下防止自己对自己赋值。这里调用d1=d2也就是调用d1.operator=(d2)。

b.赋值重载与拷贝构造

注意区分赋值重载与拷贝构造,赋值重载是对于两个实例化的对象而言的。 

c.赋值重载对类型的处理

理解为与拷贝构造一样,也要考虑双重释放的问题。

d.再来看流插入

有了前面的知识储备,我们再来理解一下流插入与流提:

查阅官方文档,我们可以看到流插入也是经过了运算符的重载,而这个类就是我们库中的ostream这个类,运算符重载被封装在这个类里面了;而对于的我们使用cout,cin(cin是istream的对象)就是根据这个类创建的对象,然后去调用对应的运算符重载函数。

再进一步理解为什么我们使用c++中的流插入与流提取可以自动识别类型了,因为是在运算符重载中帮你实现好了:

上面的运算符重载都构成函数重载。假设我们现在有一个int类型的i与double类型的d,去使用流插入就是:

cout<<i;//cout.operator<<(i)   --->int
cout<<d;//cout.operator<<(d)  --->double

e.日期类打印自定义类型

结合前面的知识,我们知道了cout其实就是ostream的对象,现在我们来完成日期类的打印:

上面的可以完成吗?答案是不可以的,因为格式不符合我们的习惯:

调用的时候,由于第一个参数我们需要传递this指针,所以调用的时候写法只能如上图写。

那怎么办呢?我们不定义在类中不就好了:

 但是又发现,类中的保护起来的成员不让访问啊?那我们再让成员放开,有些不值当啊,所以我们可以有两种解决方案:

提供公有的成员函数来获取例如GetYear()等;或者提供一个友元函数(友元函数在类和对象的总结篇会细说):

注意位置放在类中公有和私有域的外面,这样我们的友元函数就能访问类中的成员了,此时调用就变为了:

现在完了吗?还没有!我们还需要考虑连续的调用(流插入从左往右连续调用),所以我们需要返回值,out是cout的别名,返回的其实就是cout:

完整代码:

class Date
{
	friend ostream& operator << (ostream& out, const Date& d);


public:
	/*Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	*/

	Date(int year = 12, 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;
	}

	//d1==d2
	bool operator==(const Date& d)//这里使用引用是因为函数传参也需要拷贝,引用来减少拷贝
	{
		return  _year == d._year//this->_year==d._year
			    && _month == d._month
			    && _day == d._day;
	}

	//d1<d2
	bool operator<(const Date& d)
	{
		 return (_year < d._year) 
			||((_year == d._year )&&( _month < d._month)) 
			|| ((_year == d._year) && (_month == d._month) &&( _day < d._day));
	}

	//d1<=d2
	bool operator<=(const Date& d)
	{
		return (*this < d) || (*this == d);
	}


	//d1>d2
	bool operator>(const Date& d)
	{
		return !(*this <= d);
	}

	//d1>=d2
	bool operator>=(const Date& d)
	{
		return !(*this < d);
	}

	//d1!=d2
	bool operator!=(const Date& d)
	{
		return !(*this == d);
	}
	

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

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

		return *this;
	}

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

ostream& operator << (ostream & out, const Date & d)
{
	out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
	
	return out;
}

int main()
{

	Date d1(2023, 3, 28);
	Date d2(2024, 3, 28);
	Date d3(2025, 3, 28);
	//调用时相当于d1.operator==(d2)
	//调用也可以写为operator==(d1,d2),但一般不会这样写
	cout<<(d1 == d2)<<endl;//0

	cout<<(d1 < d2)<<endl;//1

	cout<<(d1 > d2)<<endl;//0
	
	cout<<(d1 >= d2)<<endl;//0

	cout<<(d1 <= d2)<<endl;//1
	
	cout<<(d1 != d2)<<endl;//1

	d3 = d1 = d2;

	d1.Print();
	d3.Print();

	cout << d3;
	//调用默认(全缺省)构造函数并使用函数中的缺省值
	//Date d1;

	//调用默认(全缺省)构造函数并传参
	//Date d2(2024, 3, 27);

	//调用拷贝构造
	//Date d3(d2);
		 
	return 0;
}

f.补充内联函数

如果我们在头文件中定义一个函数:

如果没有定义只有声明,那么就需要链接找到定义,call的时候没有地址,就需要到符号表里面去找函数的地址;如果有定义,再编译阶段就能拿到函数的地址,直接call地址就能找到这个函数。

而内联函数不会进符号表

所以我们在将内联函数定义到头文件时,要确保头文件中有定义和声明,这样在编译的阶段就能拿到函数的地址。不然没有定义需要去链接call内联函数的地址,就要去符号表找函数的地址,但是是找不到的。

g.总结

六、const修饰的非静态成员函数

 调用Print这里传递的还有&aa给this指针,而&aa的类型是const A*,传递给Print()后,变成了A* this,权限被放大了,但*this又不能改变,所以如图const写后面表示const修饰*this,this类型变成了const A*,不这样写编译器会报错。类内部不改变成员变量的成员函数,最好加上const(声明定义分离的都要加),const对象和普通对象都能调用。

 

通常间接的调用需要注意要加上const防止权限放大。Print中的this指针是个const A*类型的,用法Func的x去调用要确保x是一个const A类型的,防止权限放大。

七、const修饰的取地址操作符重载

一般不需要重载,使用编译器默认生成的即可。

也可写:

需要注意的是,不能加任何参数,不然就是被看做按位与&的重载了。

可以用作于不想让别人取地址,直接返回一个空。

总结:

本篇内容对六个默认成员函数进行了一个较为详细的分析,需要反复的复习并配合使用,其中构造,拷贝构造与运算符重载比较重要,在后面用的很多,后面还会有文章对这部分进行补充,例如友元函数和一些关键字。

  • 57
    点赞
  • 55
    收藏
    觉得还不错? 一键收藏
  • 32
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值