C++类与对象(中)

前言

在上一篇文章------C++类与对象(上),我们已经学习了类的创建等基本概念,本篇文章我们将进行类与对象的深入学习。类与对象是C++中重要的思想,本文将会进行详细的讲解。

文章目录

前言

一、类的默认成员函数

二、构造函数

构造函数的特点:

三、析构函数

析构函数的特点:

四、拷贝构造函数(重要)

拷贝构造的特点:

五、赋值重载

运算符重载

赋值运算符重载

赋值运算符重载的特点:

六、取地址运算符重载

const成员函数

取地址运算符重载


一、类的默认成员函数

默认成员函数就是哪怕用户没有显式实现,编译器也会自动生成的成员函数称为默认成员函数

对于⼀个类,我们不写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是这6个中的前4个,既是重点也是难点。C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个本文不会做过多描述,以后的文章中会进行学习。

我们从两个方面去学习:

第⼀:我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求。

我在这里可以提前说一下:编译器默认生成的函数大部分场景都不满足我们的需求,要自己实现,同时编译器默认生成函数操作具有不确定性,为了保证安全性,我们大多数时候都是应写尽写

第二:编译器默认生成的函数不满组我们的需求,我们需要自己实现,那么如何自己实现?重点

二、构造函数

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象(我们常使用的局部对象是栈帧创建时,空间就开好了),而是 对象实例化时初始化对象 。构造函数的本质是要替代我们以前Stack和Date类中写的Init函数的功能,构造函数自动调用的特点就完美的替代的了Init。

构造函数的特点:

1.构造函数基本格式:函数名等于类名,无返回值(返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此),函数参数针对具体的情况自行判断。

以下是date类的三种构造函数:

cpp 复制代码
#include<iostream>
using namespace std;
class date
{
public:
	/*date()
	{
		_year= 1949;
		_month = 10;
		_day = 1;
		cout << "无参构造" << endl;
	}*/
	//date(int year,int month=10,int day=1)
	//{
	//	_year = year;
	//	_month = month;
	//	_day = day;
	// cout << "半缺省构造" << endl;
	//}
	date(int year=1949,int month=10,int day=1)//这里最好就用全缺省的默认构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	 cout << "全缺省构造" << endl;
	}
private:
	int _year;
	int _month;
	int _day;

};
int main()
{
	/*date d1;*/
	//date d2(1949);
	/*date d3;*/
	return 0;
}

2. 类实例化出对象时,会自动调用构造函数。当我们进行data d1;操作后,就会自动对类成员变量进行初始化。

3. 构造函数可以重载。

4. 如果类中没有显式定义构造函数,则C++编译器会自动生成⼀个无参的默认构造函数。相对的,如果用户创建了构造函数,那编译器就不会再生成了。

  1. **无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数,都叫做默认构造函数。**但是这三个函数有且只有⼀个存在,不能同时存在(不然函数不知道调用哪个)。

无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。要注意很多同学会认为默认构造函数是编译器默认生成那个叫默认构造,实际上无参构造函数、全缺省构造函数也是默认构造,总结一下就是不传实参就可以调用的构造就叫默认构造。

  1. 编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器。在这里初始化成了随机值

对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要用初始化列表才能解决,初始化列表,我们下个章节再细细讲解。

我们大概构造一个利用栈实现的MyQueue类(只是有一个大概的模型):

在调试时发现,在MyQueue q1;语句之后,就开始进入stack类的构造函数,相当于编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化。那我们把stack类的构造函数隐藏看看:发现编译器调用了Stack的默认构造函数(编译器自己生成的),还是通过了,但其实这样是有一定风险的。

说明:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型, 如:int/char/double/指针等,自定义类型就是我们使⽤class/struct等关键字自己定义的类型。

三、析构函数

析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作(资源一般是申请的空间)。析构函数的功能类比我们之前Stack实现的Destroy功能,而像Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。意思是只有对象申请了资源才真正需要析构函数。

析构函数的特点:

1. 析构函数基本格式:函数名是在类名前加上字符 ~(很有意思的是:~是取反,那么说明构造函数与析构函数的功能是相反了),同样无参数无返回值。 (这里跟构造类似,也不需要加void)

2. ⼀个类只能有⼀个析构函数。若未显式定义,系统会自动生成默认的析构函数。

  1. 对象生命周期结束时,系统会自动调用析构函数 ,并且规定,(因为是栈帧嘛,先进后出)⼀个局部域的多个对象,C++规定后定义的先析构

  2. 跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员(其实内置类型也没有进行资源申请)不做处理,自定类型成员会调用他的析构函数

  3. 还需要注意的是哪怕我们自己写了析构函数,对于自定义类型成员也会调用他的析构,也就是说自定义类型成员无论什么情况都会自动调用析构函数。

cpp 复制代码
#include<iostream>
using namespace std;
class stack
{
public:
	stack()//我这里为了方便,就直接初始化容量为4
	{
		_arr = (int*)malloc(sizeof(int)*4);
		_capacity = 4;
		_top = 0;
		cout << "stack()" << endl;
	}
	~stack()
	{
		free(_arr);
		_arr = nullptr;
		_capacity = _top = 0;
		cout << "~stack()" << endl;
	}
private:
	int* _arr;
	int _capacity;
	int _top;
};

class MyQueue
{
public:
	~MyQueue()
	{
		cout << "~MyQueue()" << endl;;
	}
private:
	stack _pushstack;
	stack _popstack;
};
int main()
{
	MyQueue q1;
	return 0;
}
  1. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如Date;如果默认生成的析构就可以用,也就不需要显示写析构,如MyQueue;但是有资源申请时,⼀定要自己写析构,否则会造成资源泄漏,如Stack。

由于构造函数以及析构函数,编译器会自动调用,这就完美替代了我们在C中常用的Init以及Destroy函数。

四、拷贝构造函数(重要)

如果⼀个构造函数的第⼀个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是⼀个特殊的构造函数。那我们大概能想到,构造函数是为了完成对象的初始化,如果有1个参数与自己是同类型的类的引用,那么我们是不是就可以将这个对象的成员拷贝过来。

我们根据拷贝构造的定义写一个来试试功能:

cpp 复制代码
#include<iostream>
using namespace std;
class date
{
public:
	//这里再提一嘴,当我们调用类成员函数时,会有一个隐式的this指针
	//date* const this
	date(int year=2000,int month=1,int day=1)
	{
		this->_year = year;
		_month = month;
		_day = day;
	}
	//这里的d,是被拷贝对象的别名
	//那为什么非要用引用?
	void Print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}
	date(const date& d)
//这里的const为了保证被拷贝对象的成员变量不因操作失误而被改变
	{
		this->_year = d._year;
		this->_month = d._month;
		this->_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	date d1;
	d1.Print();
	date d2(d1);
	d2.Print();
	return 0;
}

拷贝构造的特点:

  1. 拷贝构造函数是构造函数的⼀个重载。

  2. 拷贝构造函数的第⼀个参数必须是类类型对象的引用 ,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用(规定传值传参会调用拷贝构造,那如果拷贝构造也是进行传值传参,那是不是会无限的递归)。 拷贝构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引用,后面的参数必须有缺省值。

当我把赋值构造函数中的&去掉后,编译发生如下错误

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

调试函数后发现,下一步就会直接进入拷贝构造函数。

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

我们把自己写的拷贝构造隐藏,看看能否实现拷贝

  1. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_arr指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,
cpp 复制代码
#include<iostream>
using namespace std;
class stack
{
public:
	stack()
	{
		_arr = (int*)malloc(sizeof(int) * 4);
		_capacity = 4;
		_top = 0;
	}
	~stack()
	{
		free(_arr);
		_arr = nullptr;
		_capacity = _top = 0;
	}
	/*stack(const stack& s)
	{
		_arr = (int*)malloc(sizeof(int) * s._capacity);
		_top = s._top;
		_capacity = s._capacity;
		memcpy(_arr, s._arr, sizeof(int) * s._top);
	}*/
private:
	int* _arr;
	int _capacity;
	int _top;
};
int main()
{
	stack s1;
	stack s2(s1);
	return 0;
}

我们发现,如果是使用系统自己生成的拷贝构造,两个栈中_arr,完全一样,这样会导致当我们对s1进行操作时,s2也被操作了,很显然,我们期望的拷贝构造不是这样的。

所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现 MyQueue的拷贝构造。这里还有⼀个小技巧,如果⼀个类显示实现了析构并释放资源,那么他就 需要显示写拷贝构造,否则就不需要。

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

五、赋值重载

将赋值重载之前,我们先认识一下什么是运算符重载?

运算符重载

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

运算符重载是具有特殊名字的函数,他的名字是由operator和后⾯要定义的运算符共同构成。和其 他函数⼀样,它也具有其返回类型和参数列表以及函数体。

但是重载操作符至少有⼀个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: int operator+(int x, int y),否则会导致混乱。

重载运算符函数的参数个数和该运算符作用的运算对象数量⼀样多。⼀元运算符有⼀个参数,⼆元 运算符有两个参数,二元运算符的左侧运算对象传给第⼀个参数,右侧运算对象传给第二个参数。

我们尝试在date类外实现加号的重载:

我们发现由于date类中的成员变量是private类型的,那如果我们要获取d1以及d2对象的成员变量,那我们就需要在类中创建get函数,这样就太麻烦了,那我们尝试在类内实现重载:

cpp 复制代码
#include<iostream>
using namespace std;
class date
{
public:
	date(int year=2000,int month=1,int day=1)
	{
		this->_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}
	//其实对于date类来说,这里不使用拷贝构造也没问题
	date(const date& d)
	{
		this->_year = d._year;
		this->_month = d._month;
		this->_day = d._day;
	}
	//因为类的成员函数会有隐式的this指针
	//它的第⼀个运算对象默认传给隐式的this指针,
	// 因此运算符重载作为成员函数时,参数⽐运算对象少⼀个
	bool operator ==(date& d)
	{
		return _year == d._year&&
			_month == d._month 
			&&_day == d._day;
	}
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;
	if (d1 == d2)
	{
		cout << "yes";
	}
	else
		cout << "no";
	return 0;
}

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

我们可以看到d1传给了this指针。

运算符重载以后,其优先级和结合性与对应的内置类型运算符保持⼀致。不能通过连接语法中没有的符号来创建新的操作符:比如operator@。

.* :: sizeof ?: . 注意以上5个运算符不能重载。

⼀个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-就有意义,但是重载operator+就没有意义。

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

cpp 复制代码
date& operator++()
{
	cout << "前置++" << endl;
	return *this;
}
date& operator++(int)
{
	date tmp;
	cout << "后置++" << endl;
	return tmp;
}

重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第⼀个形参位置,第⼀个形参位置是左侧运算对象,调用时就变成了对象<<cout,不符合使用习惯和可读性。 重载为全局函数把ostream/istream放到第⼀个形参位置就可以了,第⼆个形参位置当类类型对象。(这里干讲太生硬了,以后会进行代码的实现)

赋值运算符重载

赋值运算符重载是⼀个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,这里要注意跟拷贝构造区分,拷贝构造用于⼀个对象拷贝初始化给另⼀个要创建的对象。

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

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

我们根据上面的内容写出如下代码:

cpp 复制代码
#include<iostream>
using namespace std;
class date
{
public:
	date(int year = 2000, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	} 
	date& operator=(const date& d)
	{
		this->_day = d._day;
		this->_month = d._month;
		this->_year = d._year;
		return *this;
	}
	void Print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	date d1(2026, 4, 3);//这里是调用了全缺省的构造函数
	d1.Print();
	date d2;
	d2.Print();
	d1 = d2;//d1.operator=(d2);
	d1.Print();
	return 0;
}
  1. 没有显式实现时,编译器会自动生成⼀个默认赋值运算符重载,默认赋值运算符重载新行为跟默认拷贝构造函数类似**,对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数**。这里其实与前面的拷贝构造有点像

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

那我们这里就实现一个MyQueue类,同时包含赋值重载(这里的代码只是用于展示对应功能,实际存在较大风险):

cpp 复制代码
#include<iostream>
using namespace std;
class stack
{
public:
	stack()
	{
		_arr = (int*)malloc(sizeof(int) * 4);
		_capacity = 4;
		_top = 0;
	}
	~stack()
	{
		free(_arr);
		_arr = nullptr;
		_capacity = _top = 0;
	}
	stack(const stack& s)//拷贝构造
	{
		_arr = (int*)malloc(sizeof(int) * s._capacity);
		_top = s._top;
		_capacity = s._capacity;
		memcpy(_arr, s._arr, sizeof(int) * s._top);
	}
	stack& operator=(const stack& s)
	{
		int* tmp = (int*)malloc(sizeof(int) * s._capacity);
		_top = s._top;
		_capacity = s._capacity;
		free(_arr);
		_arr = tmp;
		memcpy(_arr, s._arr, sizeof(int) * s._top);
		return *this;
	}
	int* _arr;
	int _capacity;
	int _top;
};
class MyQueue
{
private:
	stack popstack;
	stack pushstack;
};
int main()
{
	stack s1;
	s1._top = 2;
	stack s2;
	s2 = s1;
	return 0;
}

六、取地址运算符重载

const成员函数

将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后 面。const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。const 修饰Date类的Print成员函数,Print隐含的this指针由 Date* const this 变为 const Date* const this。

如果我们不希望成员函数对类的成员对象进行修改,那么我们就可以考虑在成员函数后面加const进行修饰,这里其实可以看作权限的缩小,但是如果是对const 修饰过的对象来说就必须使用const修饰过的成员函数,否则就会导致权限的放大。

后者这种情况编译器报错

cpp 复制代码
#include<iostream>
using namespace std;
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;
};
int main()
{
	// 这⾥非const对象也可以调⽤const成员函数是⼀种权限的缩⼩
	Date d1(2024, 7, 5);
	d1.Print();
	const Date d2(2024, 8, 5);
	d2.Print();
	return 0;
}

取地址运算符重载

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

cpp 复制代码
class date
{
public:
	date* operator &()
	{
		return this;
	}
	date* operator &()const
	{
		return nullptr;
		//return this;
	}
private:
	int _year;
	int _month;
	int _day;
};

以上就是关于类的构造函数的全部内容,下一篇文章我们将要针对本文内容,实现一个date类,包含本文内容的绝大多数知识点,用于复习巩固。

相关推荐
山栀shanzhi2 小时前
深入C++之:一个类有几张虚函数表?
c++·面试
江奖蒋犟2 小时前
【C++】map和set
开发语言·数据结构·c++·set·map
森G2 小时前
3.1、移植Qt程序到ARM平台----移植Qt程序到ARM平台(扩展)
arm开发·c++·qt
tankeven2 小时前
HJ168 小红的字符串
c++·算法
白杆杆红伞伞2 小时前
Qt Event
开发语言·qt
Magic--2 小时前
Qt 桌面计算器项目
开发语言·qt
李昊哲小课2 小时前
Python办公自动化教程 - 第2章 单元格样式魔法 - 让表格变得美观专业
开发语言·python·excel·openpyxl
张健11564096482 小时前
QT创建线程
开发语言·qt
鲸渔2 小时前
【C++ 输入输出】cin、cout、cerr 与格式化输出
开发语言·c++·算法