类和对象(中)----第二部分

x33g5p2x  于2022-06-20 转载在 其他  
字(12.3k)|赞(0)|评价(0)|浏览(300)

前言

哈喽大家好,我是鹿 九 丸 \color{red}{鹿九丸}鹿九丸,今天给大家带来的是类和对象(中)的第二部分,最近这段时间忙着复习,所以没有进行更新,从今天开始恢复更新!
如果大家在看我的博客的过程中或者学习的过程中以及在学习方向上有什么问题或者想跟我交流的话可以加我的企鹅号:2361038962 \color{red}{2361038962}2361038962,或者寄邮件到相应的邮箱里:2361038962 @ q q . c o m \color{red}{2361038962@qq.com}2361038962@qq.com,我会尽量帮大家进行解答!

4. 拷贝构造函数

4.1 概念

拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰,防止在拷贝构造函数中将被拷贝的对象修改),在用已存在的类类型对象创建新对象时由编译器自动调用。
问:这个地方为什么要传引用呢?

答:因为函数调用时的传值调用的拷贝本身也是一种拷贝,此时也调用了构造函数!如果我们在构造函数中不使用引用,将会无穷无尽的调用拷贝构造函数。当然,其实我们也可以传指针,但是传了指针之后,编译器会将它当作是普通构造函数,而不是拷贝构造函数了。

例如:

int main()
{
	Date d1;
	Func(d1);//Func函数调用的过程中就调用了构造函数
}

问:拷贝构造和构造函数的区别是什么?

答:拷贝构造也是构造函数的一种,只不过前面的构造函数是普通构造,拷贝构造是特殊的构造函数。

4.2 特征

拷贝构造函数也是特殊的成员函数,其特征如下:

  1. 拷贝构造函数是构造函数的一个重载形式。
  2. 拷贝构造函数的参数只有一个且必须使用引用传参(使用引用传参后将不会自动调用拷贝构造函数),使用传值方式会引发无穷递归调用。
//Date拷贝构造函数的定义
Date(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

如果我们不使用引用:

//Date拷贝构造函数的定义
Date(const Date d)//使用d1来初始化d(相当于const Date d(d1)),同类型的对象初始化就要调用拷贝构造函数,非自定义类型的对象直接拷贝即可
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}
int main()
{
    Date d1;
    Date d2(d1);
	Date d2 = d1;//这种写法和上面的那行是一样的效果,因为都是通过已经创建的对象来对未创建的对象进行初始化,其
    return 0;
}

画图理解:

  1. 若未显式定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝(类似memcpy函数),这种拷贝我们叫做浅拷贝,或者值拷贝。
    下面拿日期类代码举例:
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();
    //注意:下面的写法是错误的
    //d2(d1);
    //拷贝构造函数只能用来初始化要创建的对象,而不能用于已经创建的对象
	return 0;
}

在上面的代码中,我们并没有写拷贝构造函数,使用的是编译器默认的拷贝构造函数,但是程序依然输出了正确的结果,这是因为默认的构造函数对于内置类型进行了浅拷贝(逐字节拷贝,类似memcpy),即d1和d2两块内存空间中存储的值一模一样,所以能够输出正确的结果:

  1. 那么编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?

代码:

class F
{
public:
	//构造函数
	F()
	{
		_arr = (int*)malloc(sizeof(int) * 10);
	}

	//析构函数
	~F()
	{
		free(_arr);
	}
private:
	int* _arr;
};
int main()
{
	F f1;
	F f2(f1);
	return 0;
}

上述代码执行将会报错,为什么?因为我们此时没有定义拷贝构造函数,所以使用的是默认的拷贝构造函数,所以执行的是浅拷贝,或者说是值拷贝,结果如图所示:

我们在途中看到,f1和f2的_arr指向的都是同一块空间,但是f1和f2作为两个独立的变量会执行两次析构函数,即将malloc开辟在堆上的空间释放两次,所以程序会出现崩溃的现象。同时不仅仅这个地方会出现问题,在修改数据的时候也会互相影响。

那么正确的拷贝构造函数应该怎么写呢?

{
public:
	//构造函数
	F()
	{
		_arr = (int*)malloc(sizeof(int) * 10);
	}
	//拷贝构造函数
	F(F& f)
	{
		_arr = (int*)malloc(sizeof(int) * 10);
	}
	//析构函数
	~F()
	{
		free(_arr);
	}
private:
	int* _arr;
};
int main()
{
	F f1;
	F f2(f1);
	return 0;
}

此处会涉及到深拷贝问题,后面会进行学习。

浅拷贝的问题:
1、指向一块空间,修改数据会互相影响
2、这块空间析构时会释放两次,程序会崩溃。

问:对于内置类型的数组类型,浅拷贝是否可以达成我们的目的?

答:可以的,因为数组是开辟在栈区上的,而不是开辟在堆区上的。下面是代码验证:

class F
{
public:
	//构造函数
	F()
	{
		for (int i = 0; i < 10; i++)
		{
			_arr[i] = i;
		}
	}

private:
	int _arr[10];
};
int main()
{
	F f1;
	F f2(f1);
	return 0;
}

调试截图:

我们可以看到默认的拷贝构造函数能够实现我们的目标,完成数组的拷贝。所以内置类型的数组类型,也算是内置类型。

  • 默认拷贝构造函数对于内置类型和自定义类型有不同的处理方法:

  • 内置类型:内置类型的成员会完成值拷贝(浅拷贝)。

  • 自定义类型:自定义类型的成员,去调用这个成员的拷贝构造。
    代码:

class Time
{
public:
	Time(int hour = 0, int minute = 0,int second = 0)
	{
		_hour = hour;
		_minute = minute;
		_second = second;
	}
    //没写Time的拷贝构造函数,所以调用的就是默认拷贝构造函数

private:
	int _hour;
	int _minute;
	int _second;
};
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;
	Time _time;
};
int main()
{
	Date d1;
	Date d2(d1);//对于Time类型的成员变量,调用了其默认的构造函数(浅拷贝),对其进行拷贝
	return 0;
}

调试截图:

5. 赋值运算符重载

5.1 运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名字为:关键字operator后面接需要重载的运算符符号

函数原型:返回值类型 operator操作符(参数列表)

注意:

  • 不能通过连接其他符号来创建新的操作符:比如operator@
  • 重载操作符必须有一个类类型或者枚举类型的操作数
  • 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不能改变其含义,我们就不能将其改为-
  • 作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的 操作符有一个默认的形参this,限定为第一个形参
  • .*::sizeof?:. 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。

第一种:全局的operator

//全局的operator==
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;
};
bool operator==(const Date& d1, const Date& d2)
{
	return d1._year == d2._year
		&& d1._month == d2._month
		&& d1_day == d2._day;
}
int main()
{
	Date d1;
	Date d2(d1);
	//1.第一种调用方式
	if (operator== (d1, d2))
	{
		cout << "==" << endl;
	}
	//2.第二种调用方式
	if (d1 == d2)//编译器会处理成对应重载运算符调用if(operator== (d1, d2))
	{
		cout << "==" << endl;
	}
	return 0;
}

我们运行之后上面的程序会报错,因为我们在类外访问了private成员变量,这是非法的。有三种修改方式:

  • 将private改为public。但是这种方法是非常不推荐的,因为我们一般会将数据设为私有的,不想被使用者知道或者进行修改。
  • 采用接口的方式
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;
	}
	int GetYear()
	{
		return _year;
	}
	int GetMonth()
	{
		return _year;
	}
	int GetDay()
	{
		return _year;
	}
private:
	int _year;
	int _month;
	int _day;
};

bool operator==(const Date& d1, const Date& d2)
{
	return d1.GetYear() == d2.GetYear()
		&& d1.GetMonth() == d2.GetMonth()
		&& d1.GetDay() == d2.GetDay();
}
//在上面的这种写法中会出现错误,为什么?
//下面来进行分析:
//此时d1和d2是const Date& d1类型的,调用的时候会将其地址传过去,所以传过去的类型应该是const Date*
//而this指针的类型是Date * const,所以不能发生上述类型转换,属于权限的放大,应该像下面这样写:
bool operator==(Date& d1, Date& d2)//将const去掉就好
{
	return d1.GetYear() == d2.GetYear()
		&& d1.GetMonth() == d2.GetMonth()
		&& d1.GetDay() == d2.GetDay();
}
//或者像下面这样进行修改
void Print()const 
{
	cout << _year << "-" << _month << "-" << _day << endl;
}
int GetYear()const 
{
	return _year;
}
int GetMonth()const 
{
	return _year;
}
int GetDay()const 
{
	return _year;
}
//上面的代码相当于下面的代码
void Print(const Date* const this)
{
	cout << _year << "-" << _month << "-" << _day << endl;
}
int GetYear(const Date* const this)
{
	return _year;
}
int GetMonth(const Date* const this)
{
	return _year;
}
int GetDay(const Date* const this)
{
	return _year;
}
//这样进行修改之后,再执行上面报错的操作就是权限的缩小了,这是被编译器所允许的

总结:建议成员对象函数中不修改成员变量的函数,都建议在成员函数的后面加上一个const,进而使this指针的类型变成const Date *

  • 采用友元的方式(此处不作详解,会破坏封装)。

第二种:类中的operator

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;
	}
	bool operator==(const Date& d)//因为类中的函数自动传了this指针
	{
		return _year == d._year
			&& _month == d._month
			&& _day == d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	Date d2(d1);
	//1.第一种调用方式
	if (d1.operator== (d2))//
	{
		cout << "==" << endl;
	}
	//2.第二种调用方式
	if (d1 == d2)//编译器会处理成if(d1.operator(&d1, d2))
	{
		cout << "==" << endl;
	}
	return 0;
}

问:如果类里面的和全局的operator都同时存在,那么优先调用哪一个?

答:优先调用类里面的!

练习写日期类<的重载。

bool operator<(const Date& d)
{
	if (_year < d._year
		|| (_year == d._year && _month < d._month)

		|| (_year == d._year && _month == d._month && _day < d._day))
	{
		return true;
	}
	else
		return false;
}

5.2 赋值运算符重载

首先先对拷贝构造和赋值运算符进行区分。

Date d1(2022, 5, 17);
Date d2(d1);//拷贝构造--一个存在的对象去初始化另一个要创建的对象
Date d3 = d1;//这是拷贝构造--一个已经存在的对象去初始化另一个已经存在的对象
Date d3;//这条语句执行之后,d3已经存在
d3 = d1;//赋值重载--两个已经存在的对象之间赋值

对于日期类赋值重载的实现:

void operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

我们此处写的并不够全面,为什么?因为按照正常的赋值语法来说,a = b = c这样的操作是合法的,赋值表达式是有返回值的,但是我们上面实现的返回值类型为void,所以不支持连续赋值,所以我们还要进行改进:

Date& operator=(const Date& d)//传引用返回是为了提高效率,避免拷贝
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
	return *this;
}

此时我们还要考虑一种情况,就是自己给自己进行赋值,这样的行为是没有什么意义的,所以我们要避免这样的操作。

下面就是改进后的赋值运算符重载(标准版

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

注意:当我们没有写赋值重载函数的时候,我们在代码中写的赋值操作就会自动执行拷贝构造函数,来完成赋值操作。

赋值运算符主要有以下五点:

  1. 参数类型
  2. 返回值
  3. 检测是否自己给自己赋值
  4. 返回*this
  5. 一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。

问:那么编译器生成的默认赋值重载函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?

答:答案是有必要的,比如涉及到管理非栈区空间的时候,就会涉及到深拷贝的问题,此时就需要我们自己来实现赋值重载函数。

6. const成员

将const修饰的类成员函数称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this 指针,表明在该成员函数中不能对类的任何成员进行修改。

我们知道this指针的类型是Date* const,在成员函数的后面加上const之后,this指针的类型就变成了const Date* const

下面有四个问题:

  1. const对象可以调用非const成员函数吗?
    不行,这属于权限的放大!
  2. 非const对象可以调用const成员函数吗?
    可以,这属于权限的缩小!
  3. const成员函数内可以调用其它的非const成员函数吗?
    这个是什么意思呢?我们用代码来举例:
bool Date::operator==(const Date& d)
{
    Print();//这就是在成员函数内调用其它的成员函数
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}

上面的例子就可以演示在成员函数内调用其它的成员函数,本质上其实就是通过this指针进行调用的,即this->Print

所以上面的问题是不可以的,因为const成员函数内的this指针的类型是const Date* const,而非const成员函数内的this指针的类型是Date *const,属于权限的放大,所以是不可以的!

  1. 非const成员函数内可以调用其它的const成员函数吗?
    可以,属于权限的缩小。

7.取地址及const取地址操作符重载

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

class Date
{
public:
	Date(int year,int month,int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date* operator&()//普通的取地址操作符重载
	{
		return this;
	}
	const Date* operator&()const//const取地址操作符重载
	{
		return this;
	}
private:
	int _year;
	int _month;
	int _day;
};

注意:此处有一个最佳匹配原则:

假如我们像下面这样进行定义变量:

Date d1(2022, 5, 22);//会优先调用普通的取地址操作符重载----最佳匹配
const Dare d2(2022, 5, 23);//会优先调用const取地址操作符重载----最佳匹配
//如果我们只定义了const取地址操作符重载,那么上面的两种都只会调用const取地址操作符重载

问:假如我们只定义了普通的取地址运算符重载,但是我们想对上面的d2变量进行取地址操作,那么编译器会如何进行处理呢?

答:此时编译器会调用默认的const取地址操作符重载,然后进行取地址操作。即不会调用普通的取地址操作符重载,当然,也无法成功调用。

结论:上面的两种默认成员函数只要没写,都会生成默认的操作符重载函数。

那么,既然编译器能够自动生成,那么我们是否还要写这个重载呢?

有时候是必须写的,比如我们不想让别人知道我们定义变量的地址,我们就可以像下面这样写:

class Date
{
public:
	Date(int year,int month,int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date* operator&()//普通的取地址操作符重载
	{
		return nullptr;
	}
	const Date* operator&()const//const取地址操作符重载
	{
		return nullptr;
	}
private:
	int _year;
	int _month;
	int _day;
};

这样即使别人进行取地址运算符操作得到的也只是空指针了!

8.<<(流插入)和>>(流提取)运算符重载

在实现之前,我们先了解以下cin和cout这两个对象。

从图中可以看到,cin是istream类型的对象,cout是ostream类型的对象。

我们能够使用cin和cout对内置类型的对象进行流插入和流提取操作,且编译器能够自动判断类型,这是因为编译器已经对内置类型进行了函数重载:

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void operator<<(std::ostream& out)
	{
		out << _year << "-" << _month << "-" << _day << endl;
	}
	
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2022, 5, 22);
	const Date d2(2022, 5, 23);
	cout << d1;
	return 0;
}

此时我们发现程序无法通过,为什么?

我们首先来清楚cout调用的时候是什么样的:

cout << i;
cout.operator<< (i);//流插入是cout的成员函数,cout是ostream类型的对象

我们上面实现的是下面这样的:

cout << d1;//这样写的意思是cout.operator<<(d1);这跟我们的实现不相符合,因为我们实现的第一个参数是this指针,第二个参数才是out对象
d1 << cout;//这样写是可以的
d1.operator<<(cout);//这样写跟上一行代码是一样的

我们可以对上面的写法进行改进,将<<运算符重载写成全局的:

void operator<<(std::ostream& out, const Date& d)
{
	out << d._year << "-" << d._month << "-" << d._day << endl;
}

接下来就是解决在类外无法访问类中的成员变量的问题,有两种解决方案:

  1. 采用接口的方式(通过public里的接口函数来得到类中的成员变量)
class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	int GetYear()const
	{
		return _year;
	}
	int GetMonth()const
	{
		return _month;
	}
	int GetDay()const
	{
		return _day;
	}
private:
	int _year;
	int _month;
	int _day;
};
void operator<<(std::ostream& out, const Date& d)
{
	out << d.GetYear() << "-" << d.GetMonth() << "-" << d.GetDay() << endl;
}
  1. 友元的方式
class Date
{
public:
	friend void operator<<(std::ostream& out, const Date& d);//该函数在类外可以访问到类内的成员变量
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};
void operator<<(std::ostream& out, const Date& d)
{
	out << d._year << "-" << d._month << "-" << d._day << endl;
}
int main()
{
	Date d1(2022, 5, 22);
	const Date d2(2022, 5, 23);
	cout << d1;//可以被编译器进行转化为:operator(cout,d1)
	return 0;
}

问:我们定义的函数的参数是否必须是out?可以是其它标识符嘛?

答:不需要必须是out,也可以是其它的标识符,不用cout的原因是因为命名污染,因为我们已经引入了cout。此处的out只是一个函数内形参。

问:为什么我在.h文件里面定义了全局函数的时候,发现程序出现了链接错误?

答:因为.h文件在其它几个.cpp文件中展开,编译之后在其它.cpp文件中形成符号表的时候都会出现,所以链接时编译器不知道链接哪一个,所以会出现链接错误。

问:我们常常将类的定义放在.h文件中,在多个.cpp文件中包含,那么在类中定义的成员函数为什么就不会出现链接错误?

答:在类中定义的成员函数被默认为是内联函数,即使有的函数很长,但是编译器会将它的属性默认为是内联函数,即不会通过符号表的形式进行链接,编译器自己会进行处理。

问:到底什么情况下要通过符号表的形式去链接寻找函数的定义?

答:在当前的.cpp文件中只有函数的声明,但是却没有函数的定义,只有这种情况下才去寻找,比如我们在.cpp文件中有了函数的声明,但是没有函数的定义,这个时候会通过符号表链接的方式进行寻找。但是像类的成员函数的情况,我们将.h文件包含到.cpp文件后,就有了函数的定义,即使被处理成了内联函数,但是我们仍然能通过那个函数对应的符号找到函数的地址(地址在符号表形成的时候形成)。

上面的代码仍然是不完善的,我们看下面的表达式:

cout << i1 << i2 << i3 << endl;

我们刚才写的无法实现像这样的功能,所以仍然需要进行完善:

cout的运算符重载:

class Date
{
public:
	friend std::ostream& operator<<(std::ostream& out, const Date& d);//该函数在类外可以访问到类内的成员变量
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};
std::ostream& operator<<(std::ostream& out, const Date& d)
{
	out << d._year << "-" << d._month << "-" << d._day << endl;
    return out;
}

这样才是最完善的版本。

>>运算符的重载:

class Date
{
public:
	friend std::istream& operator>>(std::istream& in, Date& d);//该函数在类外可以访问到类内的成员变量
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};
std::istream& operator>>(std::istream& in, Date& d)
{
	in >> d._year >> d._month >> d._day;
	return in;
}

当然,上面并没有对输入的日期进行检验,事实上应该进行检测输入的数据是否合法的。

下面是帮助理解cin和cout的图:

相关文章