类和对象(中下)

发布于:2025-08-14 ⋅ 阅读:(14) ⋅ 点赞:(0)

ʕ • ᴥ • ʔ

づ♡ど

 🎉 欢迎点赞支持🎉

个人主页:励志不掉头发的内向程序员

专栏主页:C++语言



前言

上一章节我们说了我们类的默认成员函数的其中2个,分别是构造函数和析构函数,接下来我们再来了解了解剩下的4个函数吧。


一、拷贝构造函数

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

拷贝构造的特点:

1、拷贝构造函数时构造函数的一个重载

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

3、C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成。

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

5、像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现MyQueue的拷贝构造。这里还有一个小技巧,如果⼀个类显示实现了析构并释放资源,那么他就需要显示写拷贝构造,否则就不需要。

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

我们平时构造一个类的时候,我们可以直接用值去构造一个类,但是同时我们也有使用类去构造一个类的需求,

int main()
{
    int a = 10;
    int b = a;
    return 0;
}

向我们的用a去构造b一样,我们的类也有这样的需求

int main()
{
	Date d1(2025, 8, 7);
	d1.Print();

    // 拷贝构造
	Date d2(d1);
	d2.Print();
	return 0;
}

而我们的拷贝构造就是用来实现这一需求的。

而我们的拷贝构造的写法和构造函数十分相似,区别在于我们的拷贝构造在第一个参数位置必须是该类类型的引用。

// 如果除了类类型的引用外还有别的参数,那必须写成缺省参数
// 比如int a = 1这样,否则就不是拷贝构造且编译器会报错	
Date(Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

在第一个参数写入类类型的引用是为了防止无穷递归的出现。

这里的复制构造就是拷贝构造,只是叫法不同而已。

为什么会产生无穷递归呢?原因其实是因为C++规定传值传参要调用拷贝构造,我们都知道我们拷贝构造的作用就是为了复制一份类对象的,而此时如果我们不是传引用而是传值,首先我们要把1复制成a,这个时候我们就要调用拷贝构造,把a传过去,但是因为是传值传参,我们的C++规定要拷贝构造,所以在得先把我们拷贝构造的第一个参数复制成a,但是复制成a又得调用拷贝构造,就又得复制,所以就会无穷递归起来。

可能比较绕,大家可以花时间屡屡,本质就是如果我们传值时,我们要想要我们的1去变成a时,得先把我们的a当参数传过去,而此时如果是传值的话,那我们就得把我们的形参先复制成a才行,但是我们想要复制形参就得又继续拷贝构造,又产生一个形参,又得复制,又会参数.....。大致就是这样。但是引用就没有必要去复制形参了,因为形参本身就是a的别名,就没有必要复制了。所以就没有这种无穷递归了。我们一般会在引用前面加上const来防止不小心更改了成员变量,比较推荐这种写法。

在这个图上我们的虚线是传引用返回,而实线就是传值返回。我们传值得发生拷贝构造,所以以后我们传参能传引用就最好传引用,这样就可以减少拷贝构造从而增加效率了。

如果我们没有写拷贝构造的话,我们的编译器在我们进行拷贝是会对我们的自定义类型进行值拷贝/浅拷贝,也就是一个字节一个字节的拷贝。而自定义类型就会去调用它的拷贝构造了。可能有人就有疑问了,那我们的拷贝构造不写不就挺好,这样都会自己生成。实则不然,这主要和我们的浅拷贝有关。我们日期类的成员变量,没有指向什么资源,全是内置成员,那我们编译器自动生成的拷贝构造完全够用,我们就没有必要去实现我们的拷贝构造。但是比如我们的Stack这样的类。

typedef int STDateType;
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (STDateType*)malloc(sizeof(STDateType) * n);
		if (nullptr == _a)
		{
			perror("空间开辟失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}

	void Push(STDateType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDateType* tmp = (STDateType*)realloc(_a, sizeof(STDateType) * newcapacity);
			if (tmp == nullptr)
			{
				perror("空间开辟失败");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;
	}

	~Stack()
	{
		cout << "~Stack" << endl;
		free(_a);
		_a = nullptr;
		_capacity = _top = 0;
	}

private:
	STDateType* _a;
	int _capacity;
	int _top;
};

我们可以看到,这是一个Stack的类,它创建了自己的资源却没有实现拷贝构造,这个时候如果让编译器自动生成一个拷贝构造,那就是浅拷贝了,我们来看看它有什么坏处吧。

int main()
{
	Stack st1;

	st1.Push(1);
	st1.Push(2);
	
	Stack st2(st1);
    // 拷贝构造的第二种写法
    // 注意和下面的赋值重载区分
    Stack st2 = st1

	return 0;
}

我们可以看见我们st1先是插入了两个数后st2再尝试复制。

我们可以看到,我们的st2确实复制了st1,再调试走一步我们会发现

我们的程序崩溃了。真奇怪,我们的程序怎么会崩溃呢?我们在仔细观察一下上面的监视图就会发现我们st1和st2的_a的地址是一样的。这就是浅拷贝的弊端,浅拷贝是一个字节一个字节的拷贝,这会使我们所创建的数据被多个类所指向,此时我们类在程序结束时销毁的时候我们自己的数据会被析构多次,这是因为这些类都指向同一个资源,但是我们的空间不能析构一次以上,所以程序就崩溃了。所以我们为了避免这种事情的发生,必须自己写一个拷贝构造进行深拷贝。

	Stack(const Stack& st)
	{
		_a = (STDateType*)malloc(sizeof(STDateType) * st._capacity);
		if (_a == nullptr)
		{
			perror("空间开辟失败");
			return;
		}

        // 把st1中的内容复制到st2中去
		memcpy(_a, st._a, sizeof(STDateType) * st._capacity);
		_top = st._top;
		_capacity = st._capacity;
	}

此时如果我们需要进行深拷贝时就会走我们自己的逻辑而非编译器的浅拷贝逻辑了。

我们就避免了浅拷贝的问题,程序也就不会崩溃了。

我们现在就可以知道了为什么我们的C++的传值传参要调用拷贝构造,就是因为为了避免我们指向同一个资源的问题,我们如果不拷贝构造的话,我们在函数中如果删掉我们的资源,此时再析构程序就会崩溃,这个规定其实就是为了补C语言的坑的。

同时我们的函数返回时,如果是传值返回也会调用一次拷贝构造,编译器先是产生一个临时变量,然后临时变量再调用拷贝构造赋值给我们的对象。但是传引用返回就不会了。但是如果我们返回的是一个函数的局部对象,那我们函数调用结束后就销毁了,那我们传引用返回就是一个无效的引用,相当于野指针。

我们要不要写拷贝构造其实有一个记忆的小技巧,那就是要不要写我们的析构函数,如果创建了自己的资源就要写析构和我们的拷贝构造的条件是一样的,所以如果不用写析构函数就不用写拷贝构造,反之也是如此。

二、赋值运算符重载

2.1、运算符重载

运算符重载为我们的自定义对象实现了更多元化的操作,它可以让我们自定义的要求我们的这些运算符像+、-、*、/等应该怎么做,而非给予一个固定的做法去框住我们。

我们接下来一起具体的看看运算符重载吧:

1、当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符时,必须转换成调用对应的运算符重载,若没有对应的运算符重载,则会编译报错

2、运算符重载是具有特定名字的函数,它的名字是由operator和后面要重新定义的运算符共同组成,和其他的函数一样,它也具有其返回类型和参数以及函数体

3、重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元的运算符就有1个参数,二元的运算符就是2个参数,二元运算符的左侧运算对象传给第1个参数,右侧运算符对象传给第2个参数

4、如果一个重载运算符函数是成员函数,则它的第1个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。

5、运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致

6、不能通过连接语法中没有的符号来创建新的操作符:比如operator$

7、.*、::、sizeof、?:(三目运算符)、.(解引用)这5个运算符不能重载

8、重载操作符至少有⼀个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: int
operator+(int x, int y)(这种做法本身也是没有什么意义的)

9、⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-就有意
义,但是重载operator+就没有意义(时间相加是没有什么意义的)。

10、重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分(C++规定,后置++重载时,增加⼀个int形参,跟前置++构成函数重载,方便区分)。

11、重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调⽤时就变成了 对象<<cout,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第1个形参位置就可以了,第2个形参位置当类类型对象。

我们可以看到,我们的运算符重载的内容是比较多的,但是大家不要害怕,我们来一条一条的理顺。

// 注: operator和运算符之间不用加空格
// 这个函数名应该叫operator==而非operator
bool operator==(Date d1, Date d2)
{
}

bool operator<(Date d1, Date d2)
{
}

首先我们要知道,运算符重载本质上也就是一个函数,它的函数名叫做operator加要重载的运算符,就比如operator+、operator-和operator==等;由于它是一个函数,所以也是拥有函数体和返回值的,它们的返回值视具体情况而定,就比如我们的这个==的运算符重载就是想看看我们的Date是否相等,所以返回值就是bool。

而我们的参数数量就是看我们原来的内置类型时运算符是几元的,像*、++、--等都是一元运算符,所以就是传1个参数。而我们的+、-、*、/以及这里的==等都是我们二元运算符,就是传2个参数。这就是一个运算符重载的基本构成。

int main()
{
	Date d1;
	Date d2;
    
    // 这种写法类似于函数体调用,就和调用函数一样
	operator==(d1, d2);
    // 这样写就像直接用运算符一样
	d1 == d2;
	return 0;
}

它的调用方法就有这两种,我们一般推荐第二种调用方法,因为第一种调用方法就好像是写函数一样,看的很不方便,也很挫。而我们的传参也很简单,就是左边的传给第一个参数而右边的传给第二个参数。当然如果我们的自定义类型没有像这样进行运算符重载的话,我们像这样使用就会报错。

当然,如果我们想要进行类对象的运算符重载通常还是会遇到一些别的问题,就比如说我们要的成员变量是私有的,在类外无法使用的问题。

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==(Date d1, Date d2)
{
	return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day;
	
}

int main()
{
	Date d1;
	Date d2;

	operator==(d1, d2);
	d1 == d2;
	return 0;
}

我们operator==函数在类外使用了私有的成员变量,是运行是没有办法通过的,次数我们有3种办法:

1、把私有变公有(强烈不推荐)

当我们把私有变成公有的话,就没有办法限制不让外界使用和更改了。这就使我们的这个优秀的架构变得没有意义了。

2、给一个返回我们的成员变量的成员函数在类内去返回(一般)

class Date
{
public:
	int GetYear()
	{
		return _year;
	}
};

这样调用我们的函数就能得到我们的成员变量的值了。

3、直接把我们的运算符重载写入我们的类中(推荐)

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==(Date d2)
	{
		return _year == d2._year && _month == d2._month && _day == d2._day;

	}

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

但是我们要注意,我们在类内的成员函数在参数的第一个位置默认是隐藏了一个this指针,所以我们所传的参数就得少一个,同时我们的第一个参数直接写成员变量即可,就没有必要写this->了。如果我们全局和类中都写了运算符重载,编译器先调用类内的运算符重载(虽然不可能都写)。

调用的方法也很简单

int main()
{
	Date d1(2025, 8, 7);
	Date d2(2025, 9, 12);

	d1.operator==(d2);
	d1 == d2;
	return 0;
}

我们一般推荐第二次写法,第一种太麻烦了。

除了我们没有出现过的运算符,有5个运算符出现过但是也不能重载,分别是.*、::、sizeof、?:(三目运算符)、.(解引用)这5个运算符,我们要特别的记一下。

同时如果我们的重载运算符没有什么意义或者我们没有自动自定义的参数而全是内置类型就不能重载。

2.2、赋值运算符重载

赋值运算符重载也是一个默认的成员函数,它其实就是=的运算符重载,它的作用是把两个已经存在的对象直接的拷贝赋值,它和拷贝构造不同,拷贝构造是用一个对象去对一个还没有刚刚创建的对象进行初始化,但是赋值运算符重载是对两个已经存在且早就初始化的对象进行拷贝操作。

赋值运算符重载的特征:

1、赋值运算符重载是一个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成
const当前类类型引用,否则会传值传参会有拷贝

2、有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。

3、没有显式实现时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。

4、全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。但是如果有了指向的资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝。

赋值运算符重载和拷贝构造的结构是非常类似的,甚至使用的条件也是一致的(如果⼀个类显示实现了析构并释放资源,那么他就需要显示写赋值运算符重载,否则就不需要),但是它们的功能是完全不一样的。

// 赋值运算符重载
int main()
{
	Date d1(2025, 7, 24);
	Date d2(2024, 5, 26);

	d1 = d2;
	return 0;
}
//拷贝构造
int main()
{
	Date d1(2025, 7, 24);
	// Date d2(d1);
	Date d2 = d1;

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

    // 赋值运算符重载
	// 传引⽤返回可以减少拷⻉构造
	Date& operator=(const Date& d)
	{
		// 排除⾃⼰给⾃⼰赋值的情况
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		// d1 = d2表达式的返回对象应该为d1,也就是*this
		return *this;
	}

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

它的调用条件也是比较的容易的,和运算符重载一样。

int main()
{
	Date d1(2024, 7, 5);
	Date d2(d1);
	Date d3(2024, 7, 6);
	d1 = d3;

	Date d4 = d1;
	return 0;
}

大家可以自己看看哪个是拷贝构造,哪个是运算符重载。

同时,如果我们不显示的写赋值运算符重载的化,编译器为我们生成的是浅拷贝的方法,就会产生我们拷贝构造的问题,所以具体写不写就参照拷贝构造即可了。

三、取地址运算符重载

3.1、const成员函数

1、我们把const所修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后面

2、const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。const 修饰Date类的Print成员函数,Print隐含的this指针由Date* const this变为const Date* const this

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

    // 相当于
    // void Print(const Date* const this) const
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

由于我们有的时候想要把我们的this指针所对应的对象保护起来,不让人随便的更改,但是由于我们的this指针是无法在参数中显示的写明的,所以为了让这种方法可以实现,我们就可以在函数的末尾加上const从而实现这种操作,这样可以更好的封装和保护我们的代码,使外界无法再乱更改我们的数据。

3.2、取地址运算符重载

取地址运算符重载分为普通取地址运算符重载和const取地址运算符重载,这两个运算符重载⼀般编译器自动生成的就够我们用了,没必要去显示实现。除非一些很特殊的场景,比如我们不想让别人取到当前类对象的地址,就可以自己实现⼀份,然后随便乱写,乱返回⼀个地址。

class Date
{
public:
	Date* operator&()
	{
		return nullptr;
		// return *******
	}
	const Date* operator&()const
	{
		return nullptr;
		// return *******
		// 随便返回一个地址,
		// 这样别人抓想脑袋也不知道问题在哪里
		// 当然并不推荐这样做
	}
private:
	int _year;
	int _month;
	int _day;
};

以上的两个默认成员函数了解了解即可,并不是很长的使用。


总结

以上便是我们的C++类中的大致内容,还有一少部分留到下去说明,下一张我会用我们已经学了的内容去写一个日历的类,希望能够更加的巩固你们学到的内容,学到这里类其实也就大致上是说完了。大家可以松一口气了。

🎇坚持到这里已经很厉害啦,辛苦啦🎇

ʕ • ᴥ • ʔ

づ♡ど


网站公告

今日签到

点亮在社区的每一天
去签到