C++——类和对象(三) 默认成员函数之拷贝构造函数,赋值运算符重载函数

目录

前言

一、拷贝构造函数

1、拷贝构造函数的作用

2、拷贝构造函数的特点

3、编译器生成的拷贝构造函数

4、什么情况下可以不自己显示定义拷贝构造函数

二、赋值运算符重载

1、运算符重载

2、赋值运算符重载

(1)赋值运算符重载的作用

(2)赋值运算符重载的特点

3、编译器生成的赋值运算符重载

4、什么情况下可以不自己显示定义赋值运算符重载

三、取地址运算符重载

1、const成员函数

2、取地址运算符重载

总结


前言

上篇我们讲解了前两个默认成员函数,构造函数和析构函数,再看本篇文章之前小编建议大家如果没有看过上一篇的或者还没怎么理解的就再看一遍,因为本篇文章讲的拷贝构造也会有一点点复杂,那我们就话不多说,开始正文


一、拷贝构造函数

1、拷贝构造函数的作用

拷贝构造函数是用一个已经存在的对象初始化另一个对象的,C++规定,自定义类型的拷贝行为都要去调用拷贝构造,所以像刚才说的自定义类型一个对象拷贝另一个对象以及传值传参,传值返回,都要去调用拷贝构造。

2、拷贝构造函数的特点

(1) 拷贝构造函数是构造函数的一个重载,所以也是函数名与类名相同,没有返回值

(2) 拷贝构造函数可以有多个参数,但第一个参数必须是类类型对象的引用,如果使用传值编译器会直接报错,因为会引发无穷递归,后面的参数必须给上缺省值,只要不满足这条规定,那这个函数就不是拷贝构造函数


用日期类来举一个例子

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

    //拷贝构造函数
    //d2(d1)
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2(d1);
    Date d3 = d1;
	d1.Print();
	d2.Print();
    d3.Print();

	return 0;
}

拷贝构造函数的写法如上,在写的时候一定不要写反,是用d1来初始化d2,那d1传参上去也就是d,把d当中的成员赋值给d2,d2的成员是用this指针访问的,还有就是Date d3 = d1也是拷贝构造,虽然有赋值符号,但它并不是赋值,而是拷贝构造,等我们讲完赋值重载会回来再去区分他们,所以大家一定要看到最后哦!

用指针也是可以的

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

	Date(Date* d)
	{
		_year = d->_year;
		_month = d->_month;
		_day = d->_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;
}

但是用指针这个函数就不满足了之前说过的规定,那它也就不是一个拷贝构造函数,而是一个普通的构造函数


我们通过打印的方式来看一下是否传值传参之前调用了拷贝构造

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

	Date(const Date& d)
	{
		cout << "Date(const Date& d)" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

void func(Date d)
{
	cout << "void func(Date d)" << endl;
}

int main()
{
	Date d;
	func(d);
	return 0;
}

在这里增加了一个func函数,我们可以很清晰地看到传参之前先去调用了拷贝构造,传值返回也是一样的道理,要去拷贝构造,如果把func函数改一下,改成这样

Date func(Date d)
{
	cout << "void func(Date d)" << endl;

	return d;
}

那调用情况就是这样


而只要传值了就被编译器直接禁掉

本质上是因为,每次调用拷贝构造之前是传值传参,传值传参是一种拷贝,就得形成一个新的拷贝构造,会形成死递归


3、编译器生成的拷贝构造函数

拷贝构造函数是一个默认成员函数,我们不写编译器会默认生成一个,编译器默认生成的拷贝构造函数对于内置类型会进行值拷贝(浅拷贝),对于自定义类型会去调用它的拷贝构造

像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完 成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但 是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,会出现两个指针指向同一块空间的情况,那就会出现同一块空间析构两次,而且修改其中一个另一个也会改,浅拷贝程序会直接崩溃,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。


下面用Stack类举深拷贝的例子

typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}

    // st1(st)
	Stack(const Stack& st)
	{
		_a = ((STDataType*)malloc(sizeof(STDataType) * st._capacity));
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_top = st._top;
		_capacity = st._top;
	}

	void push(STDataType val)
	{
		_a[_top++] = val;
	}

	STDataType& Top()
	{
		return _a[_top - 1];
	}

	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
int main()
{
	Stack st;
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	Stack st1(st);
	cout << st.Top() << endl; // 4
	cout << st1.Top() << endl; // 4
	return 0;
}

st1开一块跟st一样大的空间,也就是sizeof(STDataType)*st的容量大小cacacity,,然后把数据拷贝下来,从st._a拷贝到_a中,用memcpy拷贝sizeof(STDataType)*st的有效数据个数top,再让_top和_capacity和st的变成一样的,这里面的st1中的成员变量都是用this指针访问的,只是没有显示写出来,现在就是st和st1指向不同的空间,那析构两次以及改一个会影响另一个的问题也就迎刃而解了。


4、什么情况下可以不自己显示定义拷贝构造函数

全部是内置类型且没有指向的资源时,如Date类,全部是自定义类型成员时,像MyQueue这样的类型内部是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现 MyQueue的拷贝构造。这里还有一个小技巧,如果⼀个类显示实现了析构并释放资源,那么他就需要显示写拷贝构造,否则就不需要。

来看MyQueue这个类

typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}

	Stack(const Stack& st)
	{
		cout << "Stack(const Stack& st)" << endl;
		_a = ((STDataType*)malloc(sizeof(STDataType) * st._capacity));
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_top = st._top;
		_capacity = st._top;
	}

	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};


class MyQueue
{
public:
	MyQueue(int capacity = 4)
		:_st1(capacity)
		,_st2(capacity)
	{}

	~MyQueue()
	{}
private:
	Stack _st1;
	Stack _st2;
};

int main()
{
	MyQueue mq1;
	MyQueue mq2(mq1);
	return 0; 
}

我们没有显示定义MyQueue的拷贝构造,mq2中有两个栈所以就去调用了两次Stack的拷贝构造,所以就打印了两句。


所以大家再来感受下再讲引用的时候提到的,引用传参和传引用返回可以提高效率,就是因为如果传值传参和传值返回需要去调用拷贝构造,如果是像Stack这样的深拷贝的类代价会很大,所以在保证对象出了作用域不销毁的情况下就用引用做返回值,但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。虽然传引用返回可以减少拷贝,但是一定要确保返回对象,在出了作用域后还在,才能用引用返回,否则即使代价再大也要传值返回。


二、赋值运算符重载

1、运算符重载

当运算符被用于类类型的对象时,C++允许我们通过运算符重载的形式指定新的含义。C++规
定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编
译报错。
运算符重载是具有特殊名字的函数,他的名字是由operator和后面要定义的运算符共同构成。和其
他函数⼀样,它也具有其返回类型和参数列表以及函数体。
重载运算符函数的参数个数和该运算符作用的运算对象数量⼀样多。一元运算符有一个参数,二元
运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。
如果一个重载运算符函数是成员函数,则它的第一个运算对象默认传给隐式的this指针,因此运算
符重载作为成员函数时,参数比运算对象少一个。
运算符重载以后,其优先级和结合性与对应的内置类型运算符保持⼀致。
不能通过连接语法中没有的符号来创建新的操作符:比如operator@。
重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,比如: int operator+(int x, int y)
一个类需要重载哪些运算符,是看哪些运算符重载后有意义
重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。
C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,方便区分。

有五个运算符不能重载:.    .*    ::    ?:    sizeof,要特殊急一下,是点,点星,域作用限定符,三目,sizeof


下面用一个日期类的+=实现一个运算符重载

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

	Date& operator+=(int day)
	{
		_day += day;

		return *this;
	}

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

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

int main()
{
	Date d(2025, 1, 10);
	d += 5; // d.operator+=(5)
	d.Print(); // 2025-1-15
	return 0;
}

这里我们只是举个运算符重载的例子,没有考虑天和月超出的情况,这里让天加等了5,再返回自己,因为Date出了作用域不销毁,所以可以传引用返回,而我们写d += 5,其实也就是转换成了d.operator+=(5),一次函数调用,也可以显示的写成这种函数调用的形式,不过为了可读性一般不会这么写,我们可以从汇编角度看得更清楚


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

	Date& operator+=(int day)
	{
		_day += day;

		return *this;
	}

	Date& operator++()
	{
		_day += 1;

		return *this;
	}

	Date operator++(int)
	{
		Date tmp(*this);
		*this += 1;

		return tmp;
	}

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

private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2025, 1, 10);
	Date d2 = ++d1;
	Date d3(2025, 1, 10);
	Date d4 = d3++;
	d1.Print();  //2025-1-11
	d2.Print();  //2025-1-11
	d3.Print();  //2025-1-11
	d4.Print();  //2025-1-10
	return 0;
}

上面我们重载了前置++和后置++,前置++是返回++之后的值,所以可以返回引用,后置++是返回++之前的值,所以需要用一个临时变量tmp,这里是一个拷贝构造,*this+=1也是去复用的刚刚发重载的+=,变自己,但返回的是++之前的值也就是tmp,返回局部变量不能用引用返回,要传值返回,d1就是自己++,变成11号,d2是d1++之后的值,也是11号,d3自己++了,是11号,d4返回的是d3++之前的值,也就是还没自增的时候,就是10号,这就要运算符重载的使用方式方法,而具体的实现日期类的功能,我们写一篇文章再来实现,到时候会把功能实现的更全,而且会考虑月和天是异常的情况,这篇文章只是举例说明运算符重载的方法,以及它的本质是函数调用


2、赋值运算符重载

(1)赋值运算符重载的作用

赋值重载用于完成两个已经存在的对象直接的拷贝赋值,这里要注意跟拷贝构造区分,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象。

(2)赋值运算符重载的特点

赋值运算符重载是一个运算符重载,规定必须重载为成员函数,如果写成全局的,那默认成员函数没写就会生成一个,就会有两个赋值重载,调用会有歧义,赋值运算重载的参数建议写成const当前类类型引用,否则会传值传参会有拷贝。有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景,且需要判断是否自己给自己赋值,有些场景自己给自己赋值会出现问题。


下面用日期类举一个例子

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		, _month(month)
		, _day(day)
	{}
	
    //传引用返回        传值传参
	Date& operator=(const Date& d)
	{
        //用地址判断是否自己给自己赋值
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}

		return *this;
	}

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

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

int main()
{
	Date d1(2025, 1, 10);
	Date d2(2025, 1, 11);

	d1 = d2;
	d1.Print(); // 2025-1-11
	d2.Print(); // 2025-1-11
	return 0;
}

3、编译器生成的赋值运算符重载

赋值运算符重载是一个成员函数,我们不写,编译器会默认生成一个,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。
像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就 可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。像Stack这样的类,虽然也都是 内置类型,但是_a指向了资源,编译器自动生成的赋值运算符重载完成的浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。

下面用Stack类举深拷贝的例子

typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}

	Stack(const Stack& st)
	{
		cout << "Stack(const Stack& st)" << endl;
		_a = ((STDataType*)malloc(sizeof(STDataType) * st._capacity));
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_top = st._top;
		_capacity = st._top;
	}
    

    //赋值重载
	Stack& operator=(const Stack& st)
	{
		if (this != &st)
		{
	        free(_a);
		    _a = ((STDataType*)malloc(sizeof(STDataType) * st._capacity));
		    memcpy(_a, st._a, sizeof(STDataType) * st._top);
		    _top = st._top;
		    _capacity = st._capacity;
	    }

	    return *this;
    }

	void push(STDataType val)
	{
		_a[_top++] = val;
	}

	STDataType& Top()
	{
		return _a[_top - 1];
	}

	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};
int main()
{
	Stack st;
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(5);
	Stack st1;
	st1.push(1);
	st1.push(2);
	st1.push(3);
	st1.push(4);
	st1 = st;
	cout << st.Top() << endl;  //5
	cout << st1.Top() << endl; //5
	return 0;
}

赋值重载需要先把原空间释放掉,然后给_a重新开一块跟st一样大的空间,再拷贝数据,再改其他成员,st和st1的栈顶元素就都变成了5。

4、什么情况下可以不自己显示定义赋值运算符重载

全部是内置类型且没有指向的资源时,如Date类,像MyQueue这样的类型内部是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载, 也不需要我们显示实现MyQueue的赋值运算符重载。这里还有一个小技巧,如果一个类显示实现 了析构并释放资源,那么他就需要显示写赋值运算符重载,否则就不需要。

Stack& operator=(const Stack& st)
{
	if (this != &st)
	{
		cout << "Stack& operator=(const Stack& st)" << endl;
		free(_a);
		_a = ((STDataType*)malloc(sizeof(STDataType) * st._capacity));
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_top = st._top;
		_capacity = st._capacity;
	}

	return *this;
}


class MyQueue
{
public:
	MyQueue(int capacity = 4)
		:_st1(capacity)
		,_st2(capacity)
	{}

	~MyQueue()
	{}
private:
	Stack _st1;
	Stack _st2;
};

int main()
{
	MyQueue mq1;
	MyQueue mq2;
	mq1 = mq2;
	return 0; 
}

我们在Stack类的赋值重载中加上一句打印

可以看到自定义类型去调用了它的赋值重载


三、取地址运算符重载

1、const成员函数

将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后
面。 const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year)
		, _month(month)
		, _day(day)
	{}

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

private:
	int _year;
	int _month;
	int _day;
};
const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改const 修饰Date类的Print成员函数,Print隐含的this指针由 Date* const this 变为 const Date* const this
int main()
{
	Date d1(2025, 1, 10);
	const Date d2(2025, 1, 11);
	d1.Print();
	d2.Print();
	return 0;
}

const对象可以调用,普通对象也可以调用,是权限减小,所以在不改变成员变量的函数内,都可以加const来修饰this指针指向的内容不能改变。


2、取地址运算符重载

取地址运算符重载分为普通取地址运算符重载和const取地址运算符重载,一般这两个函数编译器自动生成的就可以够我们用了,不需要去显示实现。除非一些很特殊的场景,比如我们不想让别人取到当前类对象的地址,就可以自己实现一份,胡乱返回一个地址。
class Date
{
public:
	Date* operator&()
	{
		 //return this;
		 return nullptr;
	}
	
	const Date* operator&()const
	{
		//return this;
		return nullptr;
	}
private:
	int _year; // 年
	int _month; // ⽉
	int _day; // ⽇
};

最后两个默认成员函数不重要,平时99%的场景都用不到,大家只需要掌握前四个默认成员函数就可以。

总结

有了运算符重载很多自定义类型的行为就都是可控的了,是一个C++很伟大的发明,下一篇文章将会来讲解日期类的全部实现,大家不要走开哟!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值