【C++】类的默认成员函数,构造函数、析构函数、拷贝构造函数、运算符重载

        这篇我们继续学习C++类和对象部分,大概会说一下类的一些默认成员函数,构造函数、析构函数、拷贝构造函数、运算符重载这些知识。

1.类的默认成员函数

        默认成员函数就是用户没有显示实现,编译器会自动生成的成员函数。一个类,我们在不写的情况下编译器会默认生成6个默认成员函数(C++11后还增加了两个默认成员函数,新增的后面再说),重点学习前四个,后两个稍微了解即可。

 默认成员函数很重要,也比较复杂,我们要从两个方面去学习:

1.我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求(大多数不满足)。

2.编译器默认生成的函数不满足我们的需求时,我们需要自己实现,那么我们怎么实现?

2.构造函数 

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

2.1构造函数的基础特点

共4点:

1.函数名与类名相同。

2.无返回值。(什么都不给,连void都不需要写)

3.对象实例化时系统会自动调用对应的构造函数。

4.构造函数可以重载。

 以时间类Date为例。

class Date
{
public:
	void Print()
	{
		cout << _year << "." << _month << "." << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

 我们现在尝试写一个无参的构造函数。构造函数就是代替了初始化函数。

//函数名和类名相同,无返回值
Date()  
{
	_year = 1;
	_month = 1;
	_day = 1;
}
class Date
{
public:
	Date()  //无参构造函数
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	void Print()
	{
		cout << _year << "." << _month << "." << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

我们之前写的栈,还记得吗,需要我们手动调用STInit();

 而这个构造函数会自动被调用

int main()
{
	Date da;
	da.Print();
	return 0;
}

 其实在我们实例化对象时构造函数就调用了,就是在执行Date da;这条语句时。

构造函数可以重载,那我们再写一个带参的构造函数

//带参数的构造函数
Date(int year, int month, int day)
{
	_year = year;
	_month = month;
	_day = day;
}

 这种有参数的构造函数,调用的时候直接在对象后面加括号然后传参调用

Date d2(2024, 8, 9);
d2.Print();

 

这里也说一下为什么无参的构造函数实例化对象后面不加(),因为加了是下面这个样子。

Date da();
da.Print();

这里的Date da(); 这句到底是函数声明还是对象实例化?这样写就和函数声明区分不开,所以不加括号。

构造函数也可以是全缺省构造函数

Date(int year = 2024, int month = 8, int day = 9)
{
	_year = year;
	_month = month;
	_day = day;
}

这里构造函数因为是函数重载,所以全缺省构造函数和无参构造函数不能同时存在,在之前介绍函数重载的时候就说过,不清楚的去看看【C++】C++入门知识详解(下)-CSDN博客

 用全缺省构造函数是最好的,因为我们可以不传参,都传参,传一部分参。

class Date
{
public:
	Date(int year = 2024, int month = 8, int day = 9)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "." << _month << "." << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date da;   //不传参
	da.Print();

	Date d2(2024, 1, 1); //都传参
	d2.Print();

	Date d3(2023);  //传递一部分参
	d3.Print();

	return 0;
}

 2.2构造函数的进阶特点

共3点:

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

2.无参的构造函数、全缺省的构造函数、我们不写构造时编译器自动生成的构造函数,叫做默认构造函数。但是这三个有且只有一个存在,不能同时存在

 比如前面我们举过的例子,带参的但不是全缺省的构造函数不是默认构造函数。

class Date
{
public:
	Date(int year, int month, int day) //带参但不是全缺省
	{
		_year = year;
		_month = month;
		_day = day;
	}
	void Print()
	{
		cout << _year << "." << _month << "." << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d2;
	d2.Print();
	return 0;
}

 

我们对第二条特征总结一下就是,不传参就可以调用的构造就叫做默认构造

3.我们不写,编译器默认生成的构造,对内置类型成员变量和自定义成员变量不同。

 2.3 Stack的构造函数

typedef int STDateType;
class Stack
{
public:
	//构造函数代替STInit函数
	Stack(int n = 4)  //函数名与类名相同,无返回值
	{
		_top = 0;
		_capacity = n;
		_a = (STDateType*)malloc(n * sizeof(STDateType));
		if (nullptr == _a)
		{
			perror("malloc fail");
			return;
		}
	}

	//...

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

 这里就用了全缺省默认构造。

3.析构函数

        析构函数和构造函数功能相反,它其实类似于我们之前Stack里面的STDistroy函数,栈的销毁。析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束,栈帧销毁,他就释放了不需要我们管。C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作

析构函数特点:

1.析构函数名是在类名前加~。

2.无参数,无返回值。(和构造函数一样,连void也不需要写)

3.一个类只能有一个析构函数,若未显示定义,系统会默认生成析构函数。

4.对象生命周期结束时,系统自动调用析构函数。

5.跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用他的析构函数。

6.自定义类型不管我们写不写析构函数,他都会自动调用析构函数。

7.如果类中没有申请资源时,析构函数可以不写。(如日期Date类)

我们还是以栈Stack为例,写一个析构函数。

	//析构函数代替Distroy
	~Stack() //函数名是在类名前加~,无返回值无参数
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}

如果有多个对象,后定义的先析构。来调试验证一下。

typedef int STDateType;
class Stack
{
public:
	Stack(int n = 4) //构造函数
	{
		_a = (STDateType*)malloc(n * sizeof(STDateType));
		if (nullptr == _a)
		{
			perror("malloc fail");
			return;
		}
		_top = 0;
		_capacity = n;
	}

	//...
	
	//析构函数代替Distroy
	~Stack()
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDateType* _a;
	int _top;
	int _capacity;
};
int main()
{
	Stack d1;
	Stack d2;

	return 0;
}

 

4.拷贝构造函数

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

4.1 拷贝构造特点

拷贝构造的特点:

1.拷贝构造函数就是构造函数的一个重载

2.拷贝构造函数的第一个参数必须是类类型对象的引用,如果有其他参数,必须是缺省参数。

3.使用传值传参方式编译器直接报错,因为语法逻辑上会引发无穷递归

4.C++规定自定义类型对象进行拷贝行为必须调用拷贝构造。

 比如说这里的日期类Date

class Date
{
public:
    //构造函数
	Date(int year = 2024, 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;
};
Date d1(2024, 8, 8);
d1.Print();
Date d2(d1);  //拷贝构造
d2.Print();

这里d2直接用d1初始化,就是拷贝构造。

 //两种写法一个意思,都是拷贝构造
    Date d2(d1); 
    Date d2 = d1; //这不是赋值

拷贝构造的实现如下。

	Date(const Date& d) //引用
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

加const是为了保护形参不被改变。这里解释一下为什么拷贝构造第一个参数传参方式必须是引用

首先我们要知道,C++规定传值传参要调用拷贝构造,没有为什么,就是规定。比如说下面这个f1函数。(下面的Date还是前面那个Date类)


void f1(Date d)
{
	cout << &d << endl;
	d.Print();
}

int main()
{
	
	Date d1(2024, 8, 8);
	f1(d1);
	return 0;
}

这就是一个完整的传值传参调用拷贝函数的过程。在C语言中实参传给形参就是直接拷贝过去,不会调用一个函数,在C++中传值传参要调用拷贝函数。

我们在直接调用拷贝构造函数时,因为是引用传参,就不会形成新的拷贝函数。

如果拷贝构造函数采用传值传参,就会形成无限递归

	Date(const Date d) //传值传参
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

这里的拷贝构造函数也是传值传参,拷贝构造函数还要调用拷贝构造函数。

因为是传值传参,又要调用拷贝构造函数。

如果个拷贝构造函数的参数传参方式不是引用,会发生无穷递归。所以构造函数的第一个参数必须是类类型的引用

所以自定义类型我们以后也建议引用传参。

5.若未显示定义拷贝构造,编译器会自动生成拷贝构造函数。而自动生成的拷贝构造函数对自定义类型和内置类型不同。

和前面说的构造函数有一点区别就是,拷贝构造对内置类型也会拷贝,前面说过的构造函数对内置类型是否初始化是不确定的,这里注意一下。

理解了这个点之后我们再来看日期类Date,Date里面都是内置类型,我们需要自己写拷贝构造函数吗?不需要了吧,编译器会自动生成。这也就是为什么我在这一小节的开头并没有自己写拷贝构造函数的代码但依然可以进行拷贝构造。

6.像Stack这样的类,虽然也都是内置类型,但是Stack里面的_a指向了资源,也就是我们向编译器申请了额外空间,编译器对Stack自动生成的拷贝构造函数完成的值拷贝不符合我们的需求,所以我们还是要自己实现深拷贝(对指向的资源也进行拷贝)。

 我们如果不自己实现Stack的拷贝构造函数,编译器会自己拷贝,但是只是值拷贝。

typedef int STDateType;
class Stack
{
public:
	Stack(int n = 4) //构造函数(初始化)
	{
		_a = (STDateType*)malloc(n * sizeof(STDateType));
		if (nullptr == _a)
		{
			perror("malloc fail");
			return;
		}
		_top = 0;
		_capacity = n;
	}
	//...
	~Stack() //析构函数(销毁)
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDateType* _a;
	int _top;
	int _capacity;
};
int main()
{
	Stack s1;
	Stack s2(s1); //拷贝构造
	return 0;
}

进行调试,我们可以看到,在我们没有自己写拷贝构造函数时,s1确实拷贝给了s2。

但是,程序再运行时就崩溃了。 

因为是值拷贝,s1和s2的_a指向了同一块空间。

当我们析构时,这块空间就被析构了两次,同一块空间是不能被释放两次的。所以这也说明了编译器自动生成的拷贝构造函数不符合我们要求,要自己写。

	Stack(const Stack& st)  //拷贝构造
	{
		_a = (STDateType*)malloc(st._capacity * sizeof(STDateType));
		if (nullptr == _a)
		{
			perror("malloc fail");
			return;
		}
		memcpy(_a, st._a, sizeof(STDateType) * st._top);
		_top = st._top;
		_capacity = st._capacity;
	}

(memcpy的内容在【C语言】内存函数-CSDN博客 )

所以这里有个技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示实现拷贝构造,否则就不需要。

4.2 传引用返回

先看传值返回。

//函数名:func2
//参数:无
//返回值类型:Stack
Stack func2()
{
	Stack st;
	return st;
}
int main()
{
    Stack ret = func2();

    return 0;
}

 这个函数传值返回的时候不会直接返回st,而是返回一个st的拷贝,这个拷贝的对象不受func2生命周期的影响。

传引用返回,没有产生拷贝。

//函数名:func2
//参数:无
//返回值类型:Stack&
Stack& func2()
{
	Stack st;
	return st;
}

 返回st的别名,但是此时st已经销毁了,就是返回了一个空引用。所以使用时要注意,确保返回的对象在当前函数结束后还在,再使用。

5.运算符重载 

运算符被用于类类型时,C++允许我们通过运算符重载的形式指定新的含义。

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

//运算符重载函数名类似于这样
operator<
operator==

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

经典的一元运算符就是++、--,二元的就太多了,像加、减等等。而*这个运算符,可以是一元的解引用运算符,也可以是二元的乘法运算符。

 比如我们要比较两个日期类是否相同

//返回类型:bool
//函数名:operator==
//参数:Date类
bool operator==(Date d1, Date d2)
{
    //函数体
}

 operator==这个函数如果放在类里面做成员函数,成员函数的第一个参数会默认传this指针,那么它的第一个运算对象就会默认传给隐式的this指针,函数体就像下面这样写。

//函数体里面,年和年比,月和月比,日和日比
//第一个参数是this指针
bool operator==(Date d2)
{
	return _year == d2._year
		&& _month == d2._month
		&& _day == d2._day;
}

 调用时就当成员函数调用。

Date d1, d2;
d1.operator==(d2);
d1 == d2; //这种写法也可以

重载成全局的就是像下面这样。

bool operator==(Date d1, Date d2)
{
	return d1._year == d2._year
		&& d1._month == d2._month
		&& d1._day == d2._day;
}

但是我们不可以在类外访问私有的成员,这些_year, _month, _day都是私有成员,那咋办?

 用友元函数。在类里面加上下面这句话。

friend bool operator==(Date d1, Date d2);

友元函数我们后面再详细说。运算符重载我们建议还是写成成员函数

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

4.不能用语法中没有的符号来创建新的操作符。(比如不可以operator@)

5.有5个操作符不能重载:域作用限定符(::)sizeof三目操作符(?:)点操作符(.) ,成员函数回调时一个操作符(.*) 

6.重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义。

7.要重载有意义的运算符。比如两个日期加就没有意义,两个日期减就有意义,两个日期减就是相差的天数。

5.1 赋值运算符重载

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

还是拿Date类举例,区分一下赋值运算符重载和拷贝构造。

Date d1(2024, 8, 10);
Date d2(2024, 8, 11);
d1 = d2; //已经存在的对象的拷贝赋值
Date d1(2024, 8, 10);
Date d2(2024, 8, 11);
Date d3(d2);    //拷贝构造
Date d4 = d2;   //拷贝构造,不是赋值!

这两个非常非常容易混淆,一定要分清!!

赋值运算符重载的特点:

1.赋值运算符重载是一个运算符重载,规定必须重载为成员函数。赋值运算符重载的参数建议写成 const 当前类类型的引用 ,可以减少传值传参的拷贝。

//赋值运算符重载函数
void operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

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

内置类型我们支持连续赋值,像下面这样。

int a, b, c;
a = b = c = 1; //连续赋值
//从右往左,1赋值给c,返回值为c,
//c赋值给b,返回值为b,b赋值给a,最后返回a

 我们重载类类型自定义运算符也想实现这个连续赋值效果。

Date d1(2024, 8, 10);
Date d2(2024, 8, 11);

d1 = d2; //赋值重载拷贝

//拷贝构造
Date d3(d2);
Date d4 = d2;

d4 = d3 = d1; //连续赋值

赋值运算符重载应该怎么实现?是不是重载函数就需要返回值啊。

//d3 = d1, 返回d3
Date& operator=(const Date& d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
	return *this;
}

 返回值是引用返回,你不用也可以,这只是一个建议,this现在相当于是d3的地址,*this就是d3,this指针在形参的位置不能显示写,没说不能在返回值的地方显示写。这里也是经典的this显示写的例子。

现在就能实现连续赋值了。先看连续赋值前。

 这是连续赋值后。

3.没有显示实现时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似。

4. 小技巧:如果一个类显示实现了析构并释放资源,那么他就需要显示实现写赋值运算符重载,否则就不需要。

5.2 取地址运算符重载

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

自己实现的话就是下面两种实现。两个都写上,编译器会调用最匹配的那一个。

Date* operator&() //参数是隐式的this指针
{
	return this;
	// return nullptr;
}
const Date* operator&()const
{
	return this;
	//return nullptr;
}

本篇就分享到这里,拜拜~

评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值