C++——类与对象(中)

目录

  • [1. 类的6个默认成员函数](#1. 类的6个默认成员函数)
  • [2. 构造函数](#2. 构造函数)
  • [3. 析构函数](#3. 析构函数)
  • [4. 拷贝构造函数](#4. 拷贝构造函数)
  • [5. 赋值运算符重载](#5. 赋值运算符重载)
  • [6. const成员函数](#6. const成员函数)
  • [7. 取地址及const取地址操作符重载](#7. 取地址及const取地址操作符重载)

1. 类的6个默认成员函数

如果一个类中什么成员都没有,简称为空类。

空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数:

  1. 构造函数:主要完成初始化工作
  2. 析构函数:主要完成清理工作
  3. 拷贝构造函数:使用同类对象初始化创建对象
  4. 赋值重载函数:主要把一个对象赋值给另外一个对象
  5. 普通对象取地址
  6. const对象取地址

默认成员函数他是默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。

2. 构造函数

定义:

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。

作用

构造函数在对象实例化时被调用,用来初始化对象,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。

特性

  1. 函数名与类名相同。
  2. 无返回值。(注意这里的无返回值不是代表void)
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。

构造函数分为无参的构造函数和有参的构造函数,其中无参构造函数和全缺省构造函数又称为默认构造函数。

接下来以一个例子为例来看看构造函数

cpp 复制代码
class Data
{
public:

	Data()//无参构造函数
	{}
	
	Data(int year, int month, int day)//有参构造函数
	{
		cout << "Data()" << endl;
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Data d; // 调用无参构造函数
	Data d1(2026, 1, 26);// 调用带参的构造函数
	return 0;
}

以上是构造函数有参和无参的定义,这里需要区分调用无参构造函数的写法,很多用在调用无参构造函数的时候习惯在后面加上空括号------Data d();这种写法是错误的,因为这会让编译器无法判断这是对象函数函数名。

5.如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

这里有几种情况

1.如果成员变量是内置类型,则不对其做其他处理,该默认构造函数会对它进行初始化

2.如果成员变量是自定义类型,则调用它自己的默认构造函数

3.C++中,在声明成员变量,如果给了缺省值,则会用缺省值进行初始化。

在这里说明一下:

C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类

型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型

用例子来看看我们显示定义的构造函数和编译器定义的默认构造函数

显式构造函数:

cpp 复制代码
class Data
{
public:

	Data()//无参构造函数
	{}
	
	Data(int year, int month, int day)//有参构造函数
	{
		cout << "Data()" << endl;
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Data d; // 调用无参构造函数
	Data d1(2026, 1, 26);// 调用带参的构造函数
	return 0;
}

结果:

默认构造函数

cpp 复制代码
class Data
{
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Data d;
	return 0;
}

结果:

总结一点:

构造函数一般情况下都是需要自己定义的

不需要自己写的情况主要有:内置类型成员有缺省值,全是自定义类型的成员。

3. 析构函数

概念

析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。它与我们的destory()函数功能类似。
特性

  1. 析构函数名是在类名前加上字符 ~
  2. 无参数无返回值类型。
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。(也就是出了作用域)
    现在来举一个例子:
cpp 复制代码
typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 3)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	~Stack()//显式定义的析构函数
	{
		cout << "~Stack()" << endl;
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	int _capacity;
	int _size;
};

void TestStack()
{
	Stack s;
	s.Push(1);
	s.Push(2);
}

int main()
{
	TestStack();
	return 0;
}

我们运行程序之后发现析构函数被调用:

在调试的时候我们也可以发现他是在生命周期结束后也就是出了TestStack这个函数作用域之后被调用。

5.关于编译器自动生成的析构函数

同样的,也是主要涉及以下两种情况:

1.如果成员变量是内置类型,则不对其做任何处理

2.如果成员变量是自定义类型,则调用它自己的默认析构函数

下面举个例子:

cpp 复制代码
class Time
{
	public:
		~Time()
		{
			cout << "~Time()" << endl;
		}
	private:
		int _hour;
		int _minute;
		int _second;
};
class Date
{
	private:
		// 基本类型(内置类型)
		int _year = 1970;
		int _month = 1;
		int _day = 1;
		// 自定义类型
		Time _t;
};
int main()
{
	Date d;
	return 0;
}

这个代码的析构函数的调用情况是

可以看到,内置类型的成员变量在生命周期结束后也没有被默认的析构函数清理,而自定义类型的成员变量则被自身的析构函数清理。

4. 拷贝构造函数

在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?答案当然是可以的,这个时候就要涉及到我们的拷贝构造函数

这里先提前说一个C++的规定

内置类型的拷贝直接拷贝

自定义类型的拷贝是传值的必须调用拷贝构造,包括赋值(注意两点,一是自定义类型,而是传值传参。

概念

拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。也就是自定义类型对象的传参要调用拷贝构造。

接下来先解释一下这个是什么情况要调用拷贝构造:

cpp 复制代码
void func(int i)
{

}

void func1(Date d)
{

}
int main()
{
	Date d1;
	
	func1(d1);
	func(10);
	return 0;
}

这个代码里面,func形参是内置类型,所以直接传值就行,但func1形参是自定义类型并且是传值,那么在传参时,要调用拷贝构造。
特征

  1. 拷贝构造函数是构造函数的一个重载形式
  2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
    这里来解释一下第二点
    首先要知道,C++规定自定义类型对象的传参是传值的必须要调用拷贝构造,接下来看这个例子分析:
cpp 复制代码
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// Date(const Date& d)   // 正确写法------在调用拷贝构造之后,拷贝构造是传引用不是传值,就不会出现又调用拷贝构造的情况。
	Date(const Date d)
		// 错误写法:编译报错,会引发无穷递归
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	Date d2(d1);//拷贝d1
	return 0;
}

这里的无限递归是怎么回事呢,原因是,调用函数先要传参,而我们自定义类型的传参就会调用一次拷贝构造,然后拷贝构造又会接着传参,又会接着调用拷贝构造

图示展示是这样的:

我再用调试来更加清晰的展示这个无限递归是怎么回事

当我们光标运行到func1函数时,我们按F11,它首先会去调用构造函数

那么上面无限递归的问题就是,传参之后,调用拷贝构造,拷贝构造也要进行传参,这样就反复传参,反复调用拷贝构造,就导致了无限循环。

那么我们要如何解决这样的问题呢:有两种放法:

1.用指针:原因是任何指针都是内置类型,只有自定义类型的才会进行拷贝构造。

2.引用(常用):原因是引用相当于给变量取别名,不涉及传值。这样我们就只有func1(d1)这一个是传值,而拷贝构造就不是传值了。

3.如果我们没有显示定义拷贝构造,这里也有两种情况:

1.内置类型完成值拷贝/浅拷贝

2.自定义类型会调用它自身的拷贝构造

举例:

cpp 复制代码
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;
	Date d2(d1);//拷贝d1
	return 0;
}

像上述代码,我们没有显示定义一个拷贝构造函数,但是它依旧能够进行拷贝,就是因为编译器自动生成了一个拷贝构造。
注意:看上面是不是觉得不用自己写拷贝构造也是可以的?接下来看一个例子:

cpp 复制代码
typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 3)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}
	~Stack()//显式定义的析构函数
	{
		cout << "~Stack()" << endl;
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}
private:
	DataType* _array;
	int _capacity;
	int _size;
};

int main()
{
	Stack s;
	Stack s1(s);
	return 0;
}

上述代码运行之后,程序会崩溃,这是什么原因呢

我们在进行拷贝时,由于没有显式的定义拷贝构造函数,那么编译器的默认拷贝构造函数对象按内存存储按字节序完成拷贝,也就是说对象s中的_array的内容被拷贝到对象s1中,那么这两对象中的指针指向的是同一片空间,那么s和s1生命周期结束后,析构函数要被调用两次,这是什么意思呢,也就是析构函数对一个空间清理了两次,这就会导致程序的崩溃。

在这里引出什么情况下使用引用返回,什么情况下使用传值返回

引用返回:当函数返回的对象出了函数作用域之后还存在,那么就最好使用引用返回

传值返回:当函数返回的对象出了函数作用域之后不存在了,那么必须使用传值返回,如果还用引用返回,那么将出现上述一样的程序崩溃,原理都是一样的。

5. 赋值运算符重载

运算符重载

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

比如我们要比较日期类对象d1和d2的大小,我们要通过一个比较大小的函数,将两个对象进行传参来进行比较,但是这样可读性太差。在C++中,我们可以将">"等运算符进行重载,这样我们对d1和d2的比较,就直接可以写成d1<d2等形式,增强可读性。

函数概念

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

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

这里实现一个简单的运算符重载函数

cpp 复制代码
bool operator>(const Date& d)
{
	if (_year > d._year)
		return true;
	else if (_year == d._year && _month > d._month)
		return true;
	else if (_year == d._year && _month == d._month && _day > d._day)
		return true;
	else
		return false;
}

返回值类型为bool,操作符为">",参数为const Date& d

我们来测试一下:

cpp 复制代码
//main
int main()
{	
	Date d1(2026, 2, 1);
	Date d2(2026, 1, 30);
	cout << (d1 > d2) << endl;
	return 0;
}

运行结果为1,也就是d1大于d2。

这里我们可以注意到,我们要传递两个参数,为什么我们写的运算符重载函数只有一个形参,这里解释一下

运算符重载函数里面的参数个数,取决于操作符所需要的操作数个数,比如比较大小">",他是需要d1和d2两个操作数,所以形参必须是两个

但是在这里为什么只有一个形参,这是因为我将这个运算符重载函数定义在了Date类里面,那么这个operator>函数就是成员函数,我们知道,类的成员函数里面,参数列表是会隐藏一个this指针的,所以其实在这里这个operator>函数已经有两个形参了。

这里是完整的代码:

cpp 复制代码
class Date
	{
	public:
		Date(int year = 1900, int month = 1, int day = 1)
		{
			_year = year;
			_month = month;
			_day = day;
		}
		Date(const Date& d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		bool operator>(const Date& d)
		{
			if (_year > d._year)
				return true;
			else if (_year == d._year && _month > d._month)
				return true;
			else if (_year == d._year && _month == d._month && _day > d._day)
				return true;
			else
				return false;
		}
	private:
		int _year;
		int _month;
		int _day;
	};
	
	int main()
	{	
		Date d1(2026, 2, 1);
		Date d2(2026, 1, 30);
		cout << (d1 > d2) << endl;
		return 0;
	}
	

我还需要强调一下,在输出d1>d2这个返回值时,要加上括号,因为"<<"的优先级比">"高。

运算符重载也可以在类外进行定义,这时候就要写两个显示形参,因为没有隐藏的this指针了。
注意:

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

赋值运算符重载

赋值运算符重载是默认成员函数,只能在类中进行定义

赋值运算符重载的作用就是赋值拷贝,是将两个已经存在的对象进行复制拷贝

接下来我将一步步来写,最终得到我们的赋值运算符重载
第一:运算符重载:

cpp 复制代码
void operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

int main()
{
	Date d1(2026, 2, 1);
	Date d2(2026, 1, 30);
	d1 = d2;//将d2赋值给d1,d1进行赋值拷贝
	d1.Print();
	return 0;
}

这里可以运行,也确实成功赋值拷贝了,但是如果是多个赋值就会出现问题:

cpp 复制代码
int main()
{
	Date d1(2026, 1, 30);
	Date d2, d3;
	d3 = d2 = d1;
	return 0;
}

报错:

也就是d2赋值给d3的时候出现报错,为什么呢?因为d1赋值给d2后,这个d2=d1表达式的返回值是void,所以并不能赋值给d3。
第二:赋值运算符重载

cpp 复制代码
Date operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
	return *this
}

int main()
{
	Date d1(2026, 1, 30);
	Date d2, d3;
	d3 = d2 = d1;
	return 0;
}

增加返回值后就可以进行连续的赋值。

如果按第二步的赋值运算符重载的话,其实每次我们进行赋值,都会调用一次拷贝构造,原因是我们这个是传值返回,传值返回每次都要创建临时变量来拷贝我们的返回值,在这里我们的返回值是自定义类型,那么拷贝返回值就要调用拷贝构造。

举例:

将上述代码运行之后:

因为赋值了两次,所以调用了两次拷贝构造。

最后

由于this指针出了赋值运算符构造函数后不销毁,所以我们可以考虑使用引用返回,这样就不会调用拷贝构造了。

cpp 复制代码
Date& operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
	return *this;
}

这就是我们最终的赋值运算符重载。

赋值运算符重载格式

参数类型 :const T&,传递引用可以提高传参效率
返回值类型 :T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
检测是否自己给自己赋值
返回*this :要复合连续赋值的含义


用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝

内置类型 成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。

这里的情况和拷贝构造函数时一样的。同样的涉及到资源管理的问题,我们要自己写赋值运算符重载,不然也可能出现像拷贝构造那样的程序崩溃问题。

补充说明

赋值运算符重载的赋值拷贝和拷贝构造的拷贝的区别

赋值运算符重载的赋值拷贝:两个已经存在的对象进行复制拷贝

拷贝构造的拷贝:一个已经存在的对象去初始化另一个对象

举个例子:

这里我就直接用编译器默认的赋值运算符重载

cpp 复制代码
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2026,1,30);
	Date d2;
//d2和d1都是已经存在了的对象
	d2 = d1;//赋值重载,可以复制到VS中看赋值运算符的颜色
//d1已经存在,用来初始化d5
	Date d5 = d1;//拷贝构造,也可以复制到VS中对比两者赋值运算符的颜色 
	
	return 0;
}

最后根据上面知识完成了一个日期类以及相关功能的实现。

6. const成员函数

概念:

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

代码示例:

cpp 复制代码
#include<iostream>
using namespace std;

class Date
{
public:
	Date(int year , int month, int day ) 
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	void Print() const
	{
		cout << _year << "-" << _month<< "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

对于成员函数Print,在其后面添加关键字const,就是const成员函数。

对于为什么要有这个const修饰,其实涉及一个权限的问题:

这里我们在main中写两个例子:

cpp 复制代码
int main()
{
	Date d1(2026, 2, 8);
	d1.Print();
	const Date d2(2026, 2, 16);
	d2.Print();//如果Print函数不加const修饰的话,d2是无法调用Print的
	return 0;
}

这里d2无法调用Print涉及到一个权限的问题

我们知道,类的成员函数的形参列表中,隐藏了一个this指针,这个this指针是Date*类型的,而在上述中,我们的d2是const Date,所以d2.Print()将d2的地址传给this指针时,由于d2被const修饰,导致其为常性,所以会出现权限被放大的情况,这时候编译器就会报错。

那么我们在Print函数后面加个const修饰,其实就是修饰this指针的,这样权限就不会发生放大的情况,只会发生平移或者缩小。

那么加了这个const修饰的好处是什么呢,刚刚也讲到,加了这个const修饰,就不会出现权限放大的情况,那么也就是说普通对象和const修饰的对象都可以使用,一定程度上防止出现程序报错。

注意:如果成员函数要修改成员变量,则不能加const修饰,这个很容易理解,加了const修饰,this指针指向的那个对象不就不可以修改数据了嘛。

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

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

举个例子:

cpp 复制代码
class Date
{ 
public :
Date* operator&()
{
return this ;
}

const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};

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

相关推荐
寻寻觅觅☆9 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
fpcc9 小时前
并行编程实战——CUDA编程的Parallel Task类型
c++·cuda
ceclar12310 小时前
C++使用format
开发语言·c++·算法
lanhuazui1011 小时前
C++ 中什么时候用::(作用域解析运算符)
c++
charlee4411 小时前
从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FAISS 实战
c++·faiss·onnx·rag·语义搜索
老约家的可汗11 小时前
初识C++
开发语言·c++
crescent_悦11 小时前
C++:Product of Polynomials
开发语言·c++
小坏坏的大世界12 小时前
CMakeList.txt模板与 Visual Studio IDE 操作对比表
c++·visual studio
乐观勇敢坚强的老彭12 小时前
c++寒假营day03
java·开发语言·c++
愚者游世12 小时前
brace-or-equal initializers(花括号或等号初始化器)各版本异同
开发语言·c++·程序人生·面试·visual studio