C++从零开始系列篇(四):C++类和对象(中)——六大默认成员函数:构造函数,析构函数,拷贝构造函数,赋值运算符重载重载,取地址运算符重载

🧑‍💻博主名称:鱼子星_

✅数据结构专栏:【数据结构】

✅算法竞赛专栏:【算法竞赛】

✅C++系列专栏:【C++从零开始系列】


默认成员函数

默认成员函数即自己不写编译器会生成的成员函数,C++中为类中提供了6种默认的成员函数(本篇主要讲解较为重要的4个)。

默认成员函数也可以由程序员自己写,一般根据编译器生成的默认成员函数是否符合我们的要求来确定是否自行写默认成员函数。

1. 构造函数

构造函数是一个默认成员函数,用于对类的成员变量初始化,它在一个类的对象被定义时自动调用。当程序中没有显式的定义构造函数时,编译器会自动生成一个构造函数。

1.1 构造函数的特点

  • 构造函数的函数名和类名相同,且无返回值
  • 构造函数支持函数重载
cpp 复制代码
class Date
{
public:
	//构造函数
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	//构造函数支持函数重载
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
  • 当构造函数被显式的定义时,编译器将不再生成构造函数
cpp 复制代码
class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

💻 运行结果:编译器报错。由于类中定义了一个需要传参的构造,此时编译器停止生成默认构造,类中一个默认构造函数都没有。


  • 无需传参的构造函数称为默认构造函数,默认构造函数有且只能有一个,这也就代表了无参,全缺省,编译器生成的构造函数只能同时存在一个
cpp 复制代码
class Date
{
public:
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

💻 运行结果:编译器报错。同时出现两个默认构造函数,调用时会产生歧义。


  • 编译器生成的默认构造函数对内置类型不会进行特殊的处理,对自定义类型会调用自定义类型的构造函数。如果类的成员中有自定义类型,显式的构造函数也会自动调用该自定义类型的构造函数

📚 内置类型有:int,double,char,指针。自定义类型:结构体,类类型。

cpp 复制代码
typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		cout << "Stack()" << endl;
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}
private:
	int* _a;
	int _capacity;
	int _top;
};
class MyQueue
{
public:
	//默认构造函数,会自调用两个自定义类型的构造函数
	MyQueue()
	{
		cout << "MyQueue()" << endl;
	}
private:
	//两个自定义类型
	Stack PushSt;
	Stack PopSt;
}
int main()
{
	MyQueue q;
	return 0;
}

💻 输出:

-



🖂 提示

构造函数一般用来初始化,由于编译器生成的默认构造函数不会对内置类型处理,所以类中一般都需要自己写一个构造构造函数来初始化对象。当类中写好构造函数后,就不需要担心对象忘记初始化这个问题了。


2. 析构函数

如果一个类中涉及到动态内存的开辟,当对象生命周期结束时,就需要程序员手动的去释放这个空间。由于这个操作很频繁,我们将这个销毁操作封装成一个成员函数Destory()。每当一个对象需要销毁时就调用一次 Destory ,需要销毁就调用,需要销毁......

不难发现,这样频繁的去调用很麻烦,而且万一有时候忘记调用了 Destory 怎么办?这样就会导致内存泄漏。那有没有更好的销毁方法呢?有的,使用析构函数来解决。

cpp 复制代码
typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		cout << "Stack()" << endl;
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}
	//涉及到动态内存开辟,需要释放内存
	void Destory()
	{
		free(_a);
		_a = nullptr;
		_capacity = _top = 0;
	}
private:
	int* _a;
	int _capacity;
	int _top;
};
int main()
{
	Stack st1, st2, st3;
	//需要频繁的调用Destory(),非常的麻烦,还容易忘记
	st1.Destory();
	st2.Destory();
	st3.Destory();
	return 0;
}

析构函数与构造函数相反,析构函数一般用于自定义类型的清理(释放内存),在对象的生命周期结束时自动被调用。

2.1 析构函数的特点

  • 析构函数的函数名为~ + 类名,无参,无返回值
  • 一个类中有且只能有一个析构函数
  • 如果类中没有显式的定义析构函数,编译器会自动生成一个析构函数。编译器生成的析构函数对内置类型不会进行处理
cpp 复制代码
typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		cout << "Stack()" << endl;
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}
	//一个类只能有一个析构函数,其不能传递参数
	//析构函数在对象生命周期结束时会被自行调用
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_capacity = _top = 0;
	}
private:
	int* _a;
	int _capacity;
	int _top;
};
  • 不管是编译器生成的析构函数,还是显式定义的析构函数,遇到成员为自定义类型时,都会自动调用这个自定义类型的析构函数。
cpp 复制代码
class MyQueue
{
public:
	//对于自定义类型的对象
	//MyQueue类型的对象被创建时会自动调用自定义类型的构造函数
	//该对象生命周期结束时会自动调用自定义类型的析构函数
	MyQueue()
	{
		cout << "MyQueue()" << endl;
	}
	//可写可不写,都会自动调用Stack的析构函数
	~MyQueue()
	{
		cout << "~MyQueue()" << endl;
	}
private:
	Stack stPush;
	Stack stPop;
};
int main()
{
	MyQueue q;
	return 0;
}

💻 输出:

-


  • 当一个作用域中定义了多个类,后定义的类先析构(满足栈帧的特性)

📚 在编译器生成的析构函数够用(如 Date 类)或者成员变量都是自定义类型(如 MyQueue 类)的时候,就不需要自己定义析构函数。但是如果一个类中有动态内存的开辟,就一定要自己定义析构函数。

3. 拷贝构造函数

在C++中对于一个内置类型的拷贝和赋值很方便,编译器直接根据字节大小拷贝,那么对于类类型的拷贝,编译器是如何运作的呢?在C++中引入了拷贝构造函数来解决类类型的拷贝的问题。

cpp 复制代码
int a = 1;
int b = a;   //直接把 a 的值拷贝给 b
cpp 复制代码
class A
{};
A x;
A y = x;   //这里需要如何赋值呢?

拷贝构造其实就是构造函数的重载,一般用于两个相同的类类型的对象的拷贝,赋值。

🗋 拷贝构造的演示

cpp 复制代码
class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//拷贝构造函数
	Date(Date& d)
	{
		cout << "Date(Date& d)" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1(2025, 6, 9);
	//使用拷贝构造来对相同类型的对象进行赋值操作
	Date d2(d1);
	Date d3 = d1;
	return 0;
}

3.1 拷贝构造函数的特点

  • 拷贝构造函数不能传值传参,其参数必须是类类型的引用(指针也可以做到如下功能,但是C++中规定传引用的构造才是拷贝构造),否则编译器会报错

🔍️ 为什么拷贝构造函数不能传值传参?

在讲解这个问题前,需要先了解一个前置知识。当遇到类类型的赋值,函数传参,函数返回时(这些本质上都是类类型的拷贝)编译器都会先调用拷贝构造函数进行参数的拷贝,再执行后续操作。 为了方便讲解,我们设置如下情景:

cpp 复制代码
void f(Date d1)
{
	cout << "f(Date d1)" << endl;
}
int main()
{
	Date d1;
	f(d1);
	return 0;
}

💻 输出:

-


根据结果可以发现,当使用类类型传值传参时,首先会调用拷贝构造函数。那么,试想如果拷贝构造函数使用传值传参,会发生什么?当调用拷贝构造时,需要进行传值传参,此时又会形成一个新的拷贝构造,调用这个新的拷贝构造又会生成一个拷贝构造。此时就引发了语义上的无穷递归

所以C++中规定,拷贝构造函数不能使用传值传参,需要传引用。

  • 如果拷贝构造函数没有被显式的定义,编译器会自动生成一个拷贝构造。编译器生成的拷贝构造的会对内置类型进行简单的值拷贝(这里我们称为浅拷贝),对自定义类型,会调用它的拷贝构造。

🔍️ 既然编译器会对内置类型进行值得拷贝,而自定义类型终归还是由内置类型组成的,那么是否代表我们不需要自己写拷贝构造函数呢?我们来看如下程序

cpp 复制代码
typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		cout << "Stack()" << endl;
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}
	void push(const STDataType& x)
	{
		//扩容......
		_a[_top++] = x;
	}
	void print()
	{
		for (int i = 0; i < _top; i++) cout << _a[i] << " ";
		cout << endl;
	}
	STDataType top()
	{
		return _a[_top - 1];
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_capacity = _top = 0;
	}
private:
	int* _a;
	int _capacity;
	int _top;
};
int main()
{
	Stack st1;
	Stack st2(st1);
	return 0;
}

💻 运行结果:程序崩溃。

-


这里程序崩溃的原因是一个类的对象被析构两次或者说一个内存空间被释放了两次。那么为什么一个内存空间会被释放两次呢?我们通过调试来看

-

通过调试发现,编译器自动生成的拷贝构造是直接将 st1._a 的值拷贝给了st2._a,也就是说这两个成员变量指向的是同一个地址。

-

但是这并不是预期的效果,预期的效果是st1._ast2._a 分别指向不同的空间,且指向的空间大小和值都相同。这就说明此时编译器生成的拷贝构造不符合需要,需要自己写一个拷贝构造。

-

📑 总结:当类中有成员变量占有动态内存的资源时,一定要自己写拷贝构造函数进行深拷贝。

🗋 显式的定义拷贝构造

cpp 复制代码
/*编译器生成的拷贝构造会对内置类型进行值拷贝*/
/*但是仅仅只是拷贝,也就是浅拷贝*/
/*对于有动态开辟行为的类,需要显式的定义拷贝构造进行深拷贝*/
Stack(const Stack& st)
{
	cout << "Stack(const Stack& st)" << endl;
	STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
	if (tmp == nullptr)
	{
		perror("malloc fail");
		return;
	}
	_a = tmp;
	memcpy(_a, st._a, sizeof(STDataType) * st._top);
	_capacity = st._capacity;
	_top = st._top;
}

📚 Stack 类需要自己写拷贝构造,但是Date类,MyQueue类使用编译器生成的拷贝构造就够了,那么如何判断何时需要自己写拷贝构造呢?这里有人给出了答案。如果一个类需要显式的写析构函数,那就一定要显式的写拷贝构造函数。

4. 赋值运算符重载

4.1 运算符重载

在一个程序中,对于内置类型的加,减,乘,除等操作,都是被提前设定好的,可以直接使用并得到结果的。但是对于自定义类型,确不能直接使用这些运算符。

cpp 复制代码
int a = 1;
int b = a * 10 - 10;
Date d1;
/* 没有经过特殊处理,编译器会报错 */
Date d2 = d1 + 10; 

那有没有什么办法让自定义类型可以和内置类型一样随意的使用运算呢?C++之父曾经也想到了这个问题。于是,就有了运算符重载这个方法。

4.1.1 什么是运算符重载?

运算符重载,就是写一个特殊函数让运算符有一些特殊的功能使得它可以直接被用于自定义类型上。为什么要叫运算符重载呢?因为那个特殊的关于运算符的函数就是重载函数。

试想一个运算符每次被调用都要先根据到底是什么类型调用了这个运算符,从而发挥不同的功能。而不同的类型又很多样,所以干脆全部都弄成重载函数。

4.1.2 运算符重载的特性
  • 运算符重载的函数名比较特殊,定义为operator + 你想要重载的运算符。例如:重载 + 的函数名为operator+
  • 运算符重载有返回值,其返回值取决于执行完这个操作后需要返回什么东西,比如两个整型执行完加法后需要返回一个整型代表相加的结果。
  • C++中规定,一元运算符的运算符重载的函数参数只能有一个,二元运算符之能有两个。

📚 运算符重载可以写成全局函数或成员函数。当写成全局函数时会面临类的私有成员访问不到的问题,此时有以下几种解决方案:

  1. 将成员写成public
  2. 对于每个成员写一个getxxx的函数
  3. 友元(日期类的实现中会讲到)
  4. ⭐将运算符重载写成成员函数(目前最推荐)

❗注意:当运算符重载写成成员函数时,this 会强行占用一个参数的位置,此时二元运算符就只能再传一个参数,一元运算符不能传参了。

📁 Date.h

cpp 复制代码
class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// == 的运算符重载
	bool operator==(const Date& d);
private:
	int _year;
	int _month;
	int _day;
};
/* 将自定义类型的运算符重载写成全局函数会面临私有成员访问不了的问题 */
/* 解决方案 */
/* 将运算符重载写成成员函数 */
/* bool operator==(Date& x, Date& y)
{
	return x._year == y._year
		&& x._month == y._month
		&& x._day == y._day;
} */

📁 Date.cpp

cpp 复制代码
bool Date::operator==(const Date& d)
{
	return _year == d._year
		&& _month == d._month
		&& _day == d._day;
}
  • 运算符重载后,其对应的优先级和结合性与对应内置类型的运算符相同
  • 运算符重载的函数参数是有前后顺序的,参数的前后顺序对应其在运算符中的前后顺序。例如:对于d1 - 10 此时有一个函数operator-(Date& x, int& day)编译器会将 d1 传递给函数的第一个参数,将 10 传递给第二个参数。

📚 涉及到自定义类型的做为函数参数的情况,能传引用尽量传引用,如果不想改变参数的值,就传 const 引用。

📁 Date.cpp

cpp 复制代码
/* 函数参数的有序性 */
Date Date::operator-(const int& day) const
{
	//......
	/* 详细的实现在-->运算符重载子篇------日期类的实现 */
}
bool Date::operator<(const Date& d)
{
	if (_year < d._year)
	{
		return true;
	}
	else if (_year == d._year && _month < d._month)
	{
		return true;
	}
	else if (_month == d._month && _day < d._day)
	{
		return true;
	}
	return false;
}
  • 不能使用运算符重载对原先没有的运算符赋予新的意义。例如:operaotr@
  • .* | :: | sizeof | ? : | . 注意这5个运算符不能进行运算符重载
  • 运算符重载的函数参数至少要有一个自定义类型,例如:operator+(int, int) 被视为不合法,不能通过运算符重载改变内置类型运算的效果。
  • 只有当自定义类型对应运算符重载写出来有意义时才会写,否则就不写。例如:对于 Date 类,Date + int 就有意义(可以算一个日期加某个天数后日期是什么),Date * Date 就没有意义。
  • 对于前置的++和后置++的运算符重载,C++中规定,后置++的运算符重载的函数的最后一个参数加上一个 int 类型作为前置和后置的区分(- - 也同理)

📚 后置++运算符重载的 int 类型可以写形参的名字也可以不写,因为那个 int 类型只是单纯为了区分前后置。关于前后置++,- - 具体的实现在:😀😀😀

cpp 复制代码
//前置++
Date& operator++();
//后置++
Date operator++(int);
//前置--
Date& operator--();
//后置--
Date operator--(int):
  • 对于 << 和 >> 的运算符重载必须写成全局函数,否则 this 指针会抢占函数第一个参数的位置,这会导致输出方式为 << cout,输入方式为 >> cin,这样不符合常规。只需将它们的运算符重载写成全局函数,第一个参数放 istream 或 ostream 的对象即可。

📁 Date.h

cpp 复制代码
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);

📚 << 和 >> 的运算符重载需要写成全局函数,但是这样就不能访问 Date 类的私有成员了,解决方案就是使用友元,后面会细讲友元,这里只需将两行代码添加进 Date 类中即可

cpp 复制代码
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);

📁 Date.cpp

cpp 复制代码
ostream& operator<<(ostream& out, const Date& d)
{
	out << d._year << "年" << d._month << "月" 
	<< d._day << "日";
	return out;
}
istream& operator>>(istream& in, Date& d)
{
	in >> d._year >> d._month >> d._day;
	return in;
}

📁 test.cpp

cpp 复制代码
int main()
{
	Date d1;
	//写了>>和<<的运算符重载后日期类可以直接使用这两个运算符
	cin >> d1;
	cout << d1 << endl;
}

4.2 赋值运算符重载

当一个类的对象的初始化是使用相同类型的对象时,会自动调用拷贝构造函数。那么如果两个本身就存在的对象互相赋值会发生什么呢?这个操作就涉及到了另外一个默认成员函数,赋值运算符重载

cpp 复制代码
//自动调用拷贝构造
Date d1;
Date d2 = d1;
//两个已经存在的对象互相赋值->赋值运算符重载
Date d3;
d3 = d1;
4.2.1 赋值运算符重载的特性
  • 赋值运算符的重载本质上就是对 = 的运算符重载,函数名为operator=
  • 赋值运算符重载可以传值传参,但是为了提高效率,一般使用传引用的方式。为了支持多次赋值的场景,函数还会将赋值后的对象的引用返回

📚 赋值运算符重载是为了支持如下多次赋值的场景,执行逻辑为:语句从右往左执行,d3 给 d2 赋值后会将 d2 的引用返回,此时会生成一个临时对象给 d1 赋值

cpp 复制代码
d1 = d2 = d3;
  • 由于赋值运算符重载是默认成员函数,所以它一定要定义在类中

📁 Date.cpp

cpp 复制代码
const Date& Date::operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}
  • 如果类中没有显式的定义赋值运算符重载,编译器会自动生成,和拷贝构造函数的原理相同,编译器生成的赋值运算符重载对于内置类型只会进行浅拷贝/值拷贝,对于自定义类型,会调用它的赋值运算符重载

📚 由于编译器生成的赋值运算符重载只会进行浅拷贝,所以如果类中有占有动态内存的成员,需要自己写赋值运算符重载进行深拷贝。赋值运算符重载一般和拷贝构造同时存在,即如果一个类需要显式的写拷贝构造函数,那么它就需要显式的写赋值运算符重载

cpp 复制代码
typedef int STDataType;
class Stack
{
public:
	Stack(int n = 4)
	{
		cout << "Stack()" << endl;
		_a = (STDataType*)malloc(sizeof(STDataType) * n);
		_capacity = n;
		_top = 0;
	}
	//拷贝构造
	Stack(const Stack& st)
	{
		cout << "Stack(const Stack& st)" << endl;
		STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
		if (tmp == nullptr)
		{
			perror("malloc fail");
			return;
		}
		_a = tmp;
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_capacity = st._capacity;
		_top = st._top;
	}
	//赋值运算符重载
	const Stack& operator=(const Stack& st)
	{
		STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
		if (tmp == nullptr)
		{
			perror("malloc fail");
			return;
		}
		_a = tmp;
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_capacity = st._capacity;
		_top = st._top;
		return *this;
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_capacity = _top = 0;
	}

📚 一个对象已经存在另外一个对象还是初始化的赋值操作调用拷贝构造函数 ,两个对象都已经初始化完了的赋值操作调用赋值运算符重载

5. 取地址运算符重载

5.1 const 成员函数

如果一个类类型的对象被 const 修饰,那么除了构造函数和析构函数外,所有的普通的成员函数它都不能调用,原因如下:

假设该对象时 Date 类的一个对象,当该对象调用成员函数时,编译器会自动传递一个指向该对象的 this 指针,此时 this 指针原型为:Date* const this 而这里涉及到了权限的放大,编译器会报错。

如果要解决这个问题,需要修改 this 指针,将其改成 const Date* const this,但是C++又规定了 this 指针不能在成员函数参数位置显式的传递,所以,为了解决这个问题,C++中又引入了一个新的定义方式,const 成员函数

  • 定义:const 成员函数的定义很简单,只需在原先的函数名后加上关键字 const 即可
  • 本质:const 成员函数本质上只是将传递的 this 指针改成被 const 修饰了。这样就使得调用成员函数的对象不能被 this 指针修改

📚 为了成员函数接口尽可能的多样,对于不会修改成员变量的成员函数,尽量都定义成 const 成员函数。

cpp 复制代码
/* const 成员函数定义的方式 */
返回值 函数名(参数) const
{
	......
}

5.2 取地址运算符重载

取地址运算符重载分为普通的取地址运算符重载和 cosnt 取地址运算符重载,一般情况下不需要自己显式的定义,编译器生成的就够用了。但是如果你不想让别人拿到对象的地址,可以使用取地址运算符重载胡乱返回一个地址

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

本篇完结

下期预告

  • C++从零开始系列篇(五):C++类和对象(下)------初始化列表,类型转换,static成员,友元,内部类,匿名对象,编译器的优化拓展