C++:类和对象(中篇---类的6个默认成员函:构造、析构、拷贝构造、赋值、取地址、const取地址)

类和对象(中篇—类的6个默认成员函数)

上一篇博主讲了类和对象的基本概念以及定义一个类,最后实现了一个超级简单的Date类,这篇文章就基于上篇文章深入了解一下类的6个默认成员函数。

如果一个类中什么成员函数成员变量都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。
在这里插入图片描述

一、构造函数(初始化)

C语言中对于一些数据结构的初始化我们要写一个Init函数并且调用它,这样会比较麻烦,所以C++中引入了构造函数来完成数据的初始化,构造函数会在实例化出对象的时候自动调用,比较方便,并且保证每个对象都会被初始化。

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,保证每个数据成员都有一个合适的初始值,并且在对象的生命周期内只调用一次。构造函数是特殊的成员函数,需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务并不是开空间创建对象,而是初始化对象。

构造函数的特性

1. 函数名与类名相同。
如Date类定义一个构造函数 Date() ,直接初始化为“2019-1-1”,可以这样写

	Date()
	{
		_year = 2019;
		_month = 1;
		_day = 1;
	}

2. 构造函数无返回值。
3. 在类实例化出对象的时候编译器会自动调用构造函数。
4. 构造函数可以重载。
如下:Date类的两个构造函数构成函数重载

	Date()//无参调用
	{
		_year = 2019;
		_month = 1;
		_day = 1;
	}
	Date(int year, int month, int day)//传参调用
	{
		_year = year;
		_month = month;
		_day = day;
	}

构造函数的使用如下:(注意:无参的构造函数不能带() )

	Date d1;//无参的构造函数不能带()
	d1.Print();
	Date d2(2019, 2, 2);
	d2.Print();

5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
还是基于上面的Date类,如果我们将构造函数屏蔽掉,那么无参的构造函数Date d1;是可以编译运行通过的。这就说明我们没有写,编译器也会自动生成,如果我们写了构造函数的话编译器就会调用我们写的构造函数。

class Date
{
	public:
		/*
		// 如果用户显式定义了构造函数,编译器将不再生成
		Date (int year, int month, int day)
		{
		_year = year;
		_month = month;
		_day = day;
		}
		*/
	private:
		int _year;
		int _month;
		int _day;
		};
	void Test()
	{
		// 没有定义构造函数,对象也可以创建成功
		//因此此处调用的是编译器生成的默认构造函数
		Date d;
}

6.默认构造函数就是指不用传参也可以初始化对象的函数,在C++中具体有3种----编译器自动生成的默认构造函数、无参的默认构造函数、全缺省的默认构造函数。但是默认构造函数只能存在一个。
例如下方的代码会与歧义:

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

这两个默认构造函数分别是无参的默认构造函数、全缺省的默认构造函数,但是当我们在实例化调用Date d1;时,就会产生歧义,到底是调用无参的默认构造函数初始化为2019-1-1呢?还是调用全缺省的默认构造函数初始化为2019-2-2呢?

所以说呀!默认构造函数只能存在一个!但是我们推荐全缺省的默认构造函数,因为全缺省的默认构造函数就可以涵盖无参的默认构造函数,就是可以无参调用,也可以传参调用,就会比较全面一点。

7.对于编译器自动生成的默认构造函数我们又看不到它,那它到底都干了什么事情呢?原来编译器把类型分成了内置类型和自定义类型,内置类型就是已经定义好的类型,比如int,char等。自定义类型就是我们自己定义的,比如下面的Time。
接下来看这么一段代码我们就知道编译器自动生成的默认构造函数都干了那些事情。

class Time//Time类
{ 
public:
	Time()
	{
		std::cout << "Time()" << std::endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date//Date类
{
private:
	//内置类型
	int _year;
	int _month;
	int _day;
	//自定义类型
	Time _t;
};
int main()
{
	Date d1;
	system("pause");
	return 0;
}

这里我们写了Date类,Date类里面有内置类型和自定义类型,我们没有写Date类的构造函数,因此在实例化d1时,编译器会自动生成一个默认构造函数来初始化Date类里的成员变量。对于Time自定义类型我们写了它的构造函数。运行结果如下:
在这里插入图片描述
说明这里调用了Time类的默认构造函数。因此我们知道了如果我们没有写构造函数,编译器会自动生成一个默认构造函数,这个默认构造函数对内置类型好像没有干什么,但是它对自定义类型就会比较严格了,就会去调用自定义类型的构造函数来完成初始化。

二、析构函数(清理)

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

析构函数的特性

1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值。(因为析构函数无参数无返回值所以析构函数没有重载,因此只能存在一个析构函数)
3. 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数完成数据的清理工作。
4. 对象生命周期结束时,C++编译系统自动调用析构函数。
对于Date类其实是不需要进行析构的,因为这些内置类型在main函数结束时就会自动清理销毁。但是对于一些数据结构就需要我们自己实现析构函数来完成数据的清理工作,比如顺序表,如下:

class Seqlist
{
public:
	Seqlist(size_t capacity = 10)//构造函数
	{
		_array = (int *)malloc(capacity * sizeof(capacity));
		size_t _size = 0;
		size_t _capacity = capacity;
	}
	~Seqlist()//析构函数
	{
		free(_array);
		_array = nullptr;
		_size = _capacity = 0;
	}
private:
	int* _array;
	size_t _size;
	size_t _capacity;
};
int main()
{
	Seqlist s;
	return 0;
}

如上s对象的销毁是在main函数结束,也就是整个main函数栈帧被销毁时。但是s对象的清理工作是由我们写的~Seqlist控制的。调试的时候我们会发现,在执行到return 0;时,再按F11会去调用析构函数,所以我们知道了是在s对象生命周期结束的时候系统自动去调用析构函数。
5.如果我们不写析构函数,那么编译器会自动生成一个析构函数。那么这个析构函数干了什么事情呢?
看下方代码:

class String//String类
{
public:
	String(const char* str="jack")
	{
	    std::cout << "String()" << std::endl;
		_str = (char*)malloc(strlen(str)+1);
		strcpy(_str, str);
	}
	~String()
	{
		std::cout << "~String()" << std::endl;
		free(_str);
	}
private:
	char* _str;
};
class Person//Person类
{
private:
	String _name;
	int _age;
};
int main()
{
	Person p;
	return 0;
}

在这个代码中我们定义了一个Person类,实例化出了一个p对象,但是我们没有写Person类的构造和析构函数,因此编译器会自动生成构造和析构,对于内置类型int不作什么操作,但是对于自定义类型String则会去掉它的构造和析构。运行结果如下:
在这里插入图片描述
因此我们知道了如果我们没有写析构函数,编译器会自动生成一个析构函数,这个析构函数对内置类型没有干什么,但是它对自定义类型就会比较严格了,就会去调用自定义类型的析构函数来完成数据的清理。

6.注意:先构造的后析构,后构造的先析构(因为函数的调用是栈结构)

三、拷贝构造函数

在实例化对象的时候我们有时候想实例化出两个相同的对象,让他们定义出来的时候就是相同的,也就是这个对象并不存在,我们要用一个已经构造出来的另一个对象来拷贝构造它,那么该怎么办呢?这就是拷贝构造函数!Date类的拷贝构造函数如下:

//Date d2(d1)用d1拷贝构造d2
Date(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

//使用如下
Date d2(d1);

在这里插入图片描述

拷贝构造函数的特征

1.拷贝构造函数是构造函数的一个重载形式。 一般加上const,保证拷贝构造的实体不被改变。
2. 拷贝构造函数的参数只有一个且必须使用引用传参(&),使用传值方式会引发无穷递归调用(具体看图解,灰常重要!)
在这里插入图片描述
3. 若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝。
如果我们把Date类里自己写的拷贝构造函数屏蔽掉,我们发现依然完成了日期类的拷贝构造,这就是编译器自己默认生成的拷贝构造函数。
在这里插入图片描述
那么可能你就有疑问了?既然编译器会自己实现一个拷贝构造函数,那么我们为什么还要自己实现呢?那是因为编译器自己实现的构造函数是按内存存储按字节序完成拷贝的,对于Date日期类按照字节序拷贝是没有问题的,但是对于特殊的类就会产生问题,比如String字符类,如下所示的代码运行就会出现错误:

class String
{
public:
	String(char* str = "jack")
	{
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
	~String()
	{
		free(_str);
		_str = nullptr;
	}
private:
	char* _str;
};
int main()
{
	String s1;
	String s2(s1);
	return 0;
}

我们调试到构造函数都是正确的,也拷贝成功了。
在这里插入图片描述
但是我们发现两个对象指向的地址一样,这是因为我们没有写拷贝构造函数,编译器自动生成的是按字节序的浅拷贝,相当于是s2把s1里的地址拷了过来,它们指向同一块空间。
在这里插入图片描述
于是在调用s2的析构函数时,相当于把s1也释放了。
在这里插入图片描述
同一块空间不可以被释放两次,因此接下来析构s1时肯定就会报错了。

这就涉及到了深拷贝的问题,也就是不能单纯的按照字节序进行拷贝了,这种String类在进行拷贝构造的时候需要新开一块空间存放拷贝的值,这种拷贝方式就叫做深拷贝。
在这里插入图片描述

四、赋值运算符重载函数

赋值运算符重载函数实现的功能是有一个对象存在,我们要把它的值赋给另一个对象。说到这个我们得先了解一下运算符的重载,下面听我一一道来。

1.运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数原型:返回值类型 operator操作符(参数列表)
注意:
①不能通过连接其他符号来创建新的操作符:比如operator@(这里的@符号在C/C++里没有含义因此不能创建)
②重载操作符必须有一个类类型或者枚举类型的操作数(简单一点就是说操作符的重载是要针对自定义类型的,让自定义类型可以像内置类型一样进行加减乘除等简单运算)
③用于内置类型的操作符,其含义不能改变,例如:内置的整型+,加就是两个数的相加,不能改变它的含义
④作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的
操作符有一个默认的形参this,限定为第一个形参
.* / :: / sizeof / ?: / . 注意以上5个运算符不能重载

(1)类外面的情况

比如还是我们的Date类,在类外面实现日期的比较该如何实现?看下方代码。

class Date
{
public:
	Date(int year = 2019, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	int _year;
	int _month;
	int _day;
};
bool operator==(const Date& d1, const Date& d2)
{
	return (d1._year == d2._year)
		&& (d1._month == d2._month)
		&& (d1._day == d2._day);
}
bool operator<(const Date& d1, const Date& d2)
{
	return (d1._year < d2._year)
		|| (d1._year == d2._year && d1._month < d2._month)
		|| (d1._year == d2._year && d1._month == d2._month && d1._day < d2._day);
}
bool operator>(const Date& d1, const Date& d2)
{
	return (d1._year > d2._year)
		|| (d1._year == d2._year && d1._month > d2._month)
		|| (d1._year == d2._year && d1._month == d2._month && d1._day > d2._day);
}
int main()
{
	Date d1;
	Date d2(2019, 2, 2);
	//原始调用
	std::cout << operator==(d1, d2) << std::endl;
	std::cout << operator<(d1, d2) << std::endl;
	std::cout << operator>(d1, d2) << std::endl;
	//精简调用
	std::cout << (d1 == d2) << std::endl;
	std::cout << (d1 < d2) << std::endl;
	std::cout << (d1 > d2) << std::endl;
	system("pause");
	return 0;
}

这段代码需要注意的几个点:
①首先由于是类外面定义的运算符重载函数,因此为了保证类外面可以访问成员变量,类里面的成员变量的域作用限定符必须为public。

②在类外的运算符重载函数的写法类似于bool operator==(const Date& d1, const Date& d2)这样,并且‘==’、‘>’、‘<’这些运算符都是双目运算符,因此这些函数的参数都是两个,第一个参数为左值,第二个参数为右值。函数的参数建议加上const和引用,保证两个对象在进行比较时不能被修改。

③对于函数的调用有两种方法,第一种就是按照函数名规规矩矩的写函数调用,如operator==(d1, d2),但是这种调用可读性太差,因此有一种简便写法如d1 == d2,这里调用时加了括号()是因为cout输出语句中‘<<’操作符的优先级较高,因此需要给调用加上括号,加不加括号是根据实际调用情况来决定的。如果我们写的是精简调用方法那么编译器底层会自动转化为原始的调用方法。这两种调用方法推荐使用第二种精简的方法,这种代码可读性较强,并且通常都是采用这种写法。

(2)类里面的情况

还是我们的Date类,在类里面实现日期的比较该如何实现?看下方代码。

class Date
{
public:
	Date(int year = 2019, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//d1==d2
	bool operator==(const Date& d)
	{
		return _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 (_year > d._year)
			|| (_year == d._year && _month > d._month)
			|| (_year == d._year && _month == d._month && _day > d._day);
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	Date d2(2019, 2, 2);
	//原始写法
	std::cout << d1.operator==(d2) << std::endl;
	std::cout << d1.operator<(d2) << std::endl;
	std::cout << d1.operator>(d2) << std::endl;
	//精简写法
	std::cout << (d1 == d2) << std::endl;
	std::cout << (d1 < d2) << std::endl;
	std::cout << (d1 > d2) << std::endl;
	system("pause");
	return 0;
}

这段代码需要注意的几个点:
①针对类里面的运算符重载我们是要进行封装管理的,采用各种接口来完成对成员变量的操作,因此要加上域作用限定符。

②在类里的运算符重载函数的写法类似于bool operator==(const Date& d),那么这里‘==’也是双目运算符,为什么函数只有一个参数呢?这是因为这里有一个隐含的this指针,this指针就是第一个参数,这里的d就是第二个参数。函数的参数依然建议加上const和引用,保证对象在进行比较时不能被修改。

③对于函数的调用依然是有两种方法,第一种是按照对象调用成员函数的方法进行调用,如d1.operator==(d2),依然是可读性太差,因此有一种简便写法如d1 == d2,加了括号的原因和类外面调用运算符重载函数的精简写法加括号的原因相同,这里不再赘述。如果我们写的是精简调用方法那么编译器底层会自动转化为原始的调用方法,同时底层会加上this指针像这样d1.operator==(&d1,d2)。依然推荐使用第二种精简的方法,可读性较强。

2.赋值运算符重载

见下图详解:
参数类型注意要加引用,因为传参是传值的话会在传参时调用拷贝构造函数。
在这里插入图片描述
有可能出现自己给自己赋值的情况,因此为了防止这种情况我们对第一版进行修改:在进行赋值之前先进行判断,如果this指针指向的空间不等于传过来d的地址在进行赋值操作,(这里判断条件里的&是取地址)。

有可能会出现连续赋值的情况,因此对第二版进行修改:假如是d1=d2=d3,那么在执行这条语句时是从右往左执行的,也就是先执行d2=d3,那么这里的返回值应该是d2,然后再执行d1=d2,返回值为d1。也就是说返回的是赋值操作符的左值,因此需要返回的是this指针指向的值,所以我们需要返回* this(要解引用)。然后给这个赋值操作符重载函数加上返回值,返回值的类型为Date,注意这里是引用返回,因为虽然this指针出了函数栈帧销毁就不存在了,但是*this还存在,执行d2=d3时,*this就是d2,执行d1=d2时,*this就是d1,因此出了作用域返回的 *this依然存在,所以就用引用返回。那如果不用引用返回呢?不用引用返回的话,就会在这里创建一个临时变量,然后将this指针指向的值拷给这个临时变量,然后把临时变量返回,这里拷贝的时候就会调用拷贝构造函数,因此内部更复杂,还要另外开辟空间,所以不建议这种做法。

赋值运算符特点
  1. 参数类型要加&引用
  2. 返回值:返回返回值的引用
  3. 检测是否自己给自己赋值:赋值前先判断
  4. 返回*this
  5. 一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝
    如果我们把自己实现的赋值运算符重载函数屏蔽掉,发现依然可以完成赋值。
    在这里插入图片描述
    那么既然我们不写编译器会自己实现一个,那我们还需要自己实现吗?答案是肯定的,因为编译器自己实现的是按照字节序的浅拷贝,对于Date类可以,但是对于一些特殊的就不可以了,比如还是我们上面说的String字符类就不行了,这种就需要深拷贝。

如下代码就会出现问题,原因也是会造成同一块空间释放两次。

class String
{
public:
	String(const char* str = "jack")
	{
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
	~String()
	{
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};
int main()
{
	String s1("hello");
	String s2("world");
	s1 = s2;
}

那么对于这种会造成同一块空间释放两次的情况我们要进行深拷贝,那么怎么进行深拷贝呢?后序会发一篇文章讲解String类的深浅拷贝。

五、取地址及const取地址操作符重载

这两个默认成员函数一般不用重新定义 ,编译器默认会生成。

class Date
{
public:
	Date* operator&()
	{
		return this;
	}
	const Date* operator&()const
	{
		return this;
	}
private:
	int _year; 
	int _month; 
	int _day; 
};

这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容,或者不想让别人取到地址。如下:

Date* operator&()
{
	return nullptr;
}
const Date* operator&()const
{
	return nullptr;
}

六、const成员

void Date::Print()
{
	std::cout << _year << "-" << _month << "-" << _day << std::endl;
}

如果我们写的是如上的Print函数,那么要创建const对象时就会编不过。
具体原因是这样的:
在这里插入图片描述
Print函数接收的没有const修饰,因此我要创建的是只读的对象d9,传给Print函数就变成了可读可写的了,权限放大了,这是不允许的,因此Print函数也需要用const修饰一下this指针。

对于成员函数里要修饰隐含的this指针该怎么办呢?把const放在该成员函数的后面。

将const修饰的类成员函数称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
在这里插入图片描述
那么请思考下面的几个问题:

  1. const对象可以调用非const成员函数吗?
    不能
  2. 非const对象可以调用const成员函数吗?
    可以
  3. const成员函数内可以调用其它的非const成员函数吗?
    不能
  4. 非const成员函数内可以调用其它的const成员函数吗?
    可以

猜你喜欢

转载自blog.csdn.net/ETalien_/article/details/87897820