目录
"默认"成员函数 概念引入:
一、构造函数
问题引入:
1)构造函数的概念
2)构造函数实例
3)构造函数的特性
4)关于默认生成的构造函数 (默认构造函数)
默认构造函数未完成初始化工作实例:
二、析构函数
1)析构函数概念
2)析构函数特性
三、拷贝构造函数
1)拷贝构造函数概念
示例代码:
2)深拷贝
3)拷贝构造函数特性
四、赋值运算符重载
运算符重载
赋值运算符重载
"默认"成员函数 概念引入:
C++中的默认成员函数是系统自动生成的,如果没有手动编写该类的成员函数,编译器就会自动为该类生成默认成员函数。默认成员函数包括默认构造函数、默认析构函数和默认拷贝构造函数等。
- 默认构造函数:当创建对象时,如果没有显式地调用构造函数,系统会自动调用默认构造函数来初始化对象。默认构造函数不接受任何参数,也不返回任何值。
- 默认析构函数:当对象被销毁时,系统会自动调用析构函数来清理对象。默认析构函数不接受任何参数,也不返回任何值。
- 默认拷贝构造函数:当将一个对象赋值给另一个对象时,系统会自动调用拷贝构造函数来完成对象的复制。默认拷贝构造函数会将原对象的所有成员变量逐个复制给新对象。
除了以上三种默认成员函数外,还有默认赋值运算符、取地址运算符等。这些默认成员函数可以让我们更方便、更高效地使用C++语言进行面向对象编程。
以上有六个默认成员函数,但是我们今天只探讨前4个,后面两个实际操作中我们很少直接编写,一般都是使用默认生成式的。
一、构造函数
问题引入:
在C++编程语言中,构造函数和析构函数的出现与对象及对象的生命周期管理密切相关。在现实世界中,每个事物都有其生命周期,会在某个时候出现也会在另外一个时候消亡。类似地,程序是对现实世界的反映,其中的对象就代表了现实世界的各种事物,自然也就具有生命周期,也会被创建和销毁。
因此,为了恰当地管理对象的生命周期,特别是对象的初始化和清理工作,C++引入了构造函数和析构函数这两个特殊的成员函数。每一个类都有一个默认的构造函数和析构函数;构造函数在类定义时由系统自动调用,析构函数在类被销毁时由系统自动调用。
具体来说,构造函数主要用于完成对象的初始化工作,它的名字和类名相同,一个类可以有多个构造函数。如果程序员没有手动编写构造函数,编译器会默认生成一个构造函数。另一方面,析构函数则用于完成对象的清理工作,它的名字是类名前面加一个~符号。当对象的生命期结束时,会自动执行析构函数。
总的来说,构造函数和析构函数的出现,让程序员可以更加方便、准确地管理对象的生命周期,这是C++面向对象编程特性的一个重要体现。
1)构造函数的概念
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次(用一个已经存在的对象去初识另一个和对象)
2)构造函数实例
而且可以结合我们前面学过的知识,构造函数也是可以重载的:
//普通版
Date()//无参的
{
m_year = 2023;
m_month = 10;
m_day = 17;
}
Date(int year,int month,int day)//带参且无缺省参数
{
m_year = year;
m_month = month;
m_day = day;
}
//融合版
Date(int year = 2023,int month = 10,int day = 17)//全缺省的
{
m_year = year;
m_month = month;
m_day = day;
}
//普通版容易在使用时造成二义性问题,所以推荐后面的用法
3)构造函数的特性
不允许出现这正形式的调用:!!!
Date d1();
这种类型的调用,因为它可以被认为成函数的声明,就好比:
Date fun1();
所以,如果想定义一个日期类,则不能加括号,不然括号内需要加参数,
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
4)关于默认生成的构造函数 (默认构造函数)
关于编译器生成的默认成员函数,很多同学会有疑惑:不手动实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来它生成的默认构造函数又没什么用?我们在定义对象时调用了编译器生成的默认构造函数,但是对象的成员变量_year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数没有实质性作用吗?
默认构造函数未完成初始化工作实例:
class Date
{
public:
void Print()
{
std::cout << _year << _month << _day
<< std::endl;
}
private:
int _year;
int _month;
int _day;
};
int main(void)
{
Date date;
date.Print();
return 0;
}
对比手动添加构造函数的结果:
class Date
{
public:
void Print()
{
std::cout << _year << _month << _day
<< std::endl;
}
Date(int year = 2024, int month = 11, int day = 13)//全缺省的
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main(void)
{
Date date;
date.Print();
return 0;
}
解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型,编译器生成默认的构造函数会对自定类型成员调用的它的默认成员函数,而内置类型则不会,这就是为什么全是int类型的成员变量的日期类我们观察不到其初始化的原因。
如果我们用两个栈实现一个队列类就可以体会到默认构造的作用了:
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
注意:C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值,自动生成的构造函数会根据此初始化。
class Date
{
private:
int _year = 2000;//在类中定义的默认值可以用作构造函数初始化的参考
int _month = 1;
int _day = 1;
}
其实关于默认构造函数,只有在我们所有成员变量全是自定义类型的情况下,我们才会去省略掉自己编写的过程去使用编译器默认生成的,大部分情况下构造函数都是要我们自己编写的。
二、析构函数
1)析构函数概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么清除的呢?析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
~Date() {
// 在这里可以执行任何必要的清理工作
std::cout << "Date destructor called for "
<< _year << "-" << _month << "-" << _day
<< std::endl;
}
2)析构函数特性
- 析构函数名是在类名前加上字符 “~”。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
- 对象生命周期结束时,C++编译系统系统自动调用析构函数
- 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类
- 默认生成的析构函数不会对内置类型进行操作,不然释放掉不该释放的内容将会出问题。
析构函数是类的一种特殊的成员函数,其名称与类名相同但增加一个波浪线符号(~)。当对象超出范围或通过调用delete显式销毁对象时,析构函数会自动被调用。析构函数往往用来做“清理善后”的工作,例如在建立对象时用new开辟了一片内存空间,delete会自动调用析构函数后释放内存。此外,析构函数也可以有多个,如果没有手动写析构函数,编译器会生成一个默认的析构函数并自动调用。
析构函数的调用时机主要有以下几种:
- 对象生命周期结束:当对象超出作用域或被显式销毁时,析构函数会自动被调用,用来释放对象占用的内存空间。
- delete操作符:当使用delete删除指针类对象时,会直接调用析构函数来清理内存。
- 包含关系:如果对象Dog是对象Person的成员,那么在Person的析构函数被调用时,Dog对象的析构函数也会被自动调用。
三、拷贝构造函数
1)拷贝构造函数概念
拷贝构造函数,也称为复制构造函数,是一种特殊的构造函数。它是当创建新对象时,使用同一类中之前已创建的对象来初始化新创建的对象。这种构造函数由编译器自动调用,用于一些基于同一类的其他对象的构建及初始化。
拷贝构造函数的形式参数必须是引用,通常为const引用(可以防止反向拷贝的情况),以便能够处理常量对象和非常量对象的复制。这样,它既能以常量对象(初始化后值不能改变的对象)作为参数,也能以非常量对象作为参数。
在编程实践中,拷贝构造函数常常用于以下情况:通过使用另一个同类型的对象来初始化新创建的对象;复制对象把它作为参数传递给函数;以及从函数返回一个对象时复制该对象。
值得注意的是,如果程序员没有显式定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数会将原对象的成员变量值赋值给新对象的相应成员变量。
拷贝构造函数的使用加场景类似于:
//使用a创建b的过程
int a = 1;
int b = a;
示例代码:
#include <iostream>
#include <cstring>
class MyClass {
public:
char* data;
// 构造函数
MyClass(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 拷贝构造函数
MyClass(const MyClass& other) {
data = new char[strlen(other.data) + 1]; // 重新分配内存
strcpy(data, other.data); // 深拷贝
}
// 析构函数
~MyClass() {
delete[] data;
}
}
2)拷贝构造函数特性
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错, 因为会引发“无穷递归”调用。
- 拷贝构造函数典型调用场景:使用已存在对象创建新对象函数参数类型为类类型对象函数返回值类型为类类型对象
- 拷贝构造对应的默认的拷贝方式是浅拷贝,但是这样会引发“双重释放”的问题,所以在遇到一些指针成员时我们需要自定义拷贝构造函数以实现更安全的深拷贝,以确保每个对象都有独立的空间。
接下来我们探究一下为什么会出现上述情况,无穷递归和双重释放。
问题一:为什么会出现无穷递归?
在C++中,拷贝构造函数一般的形式为:
ClassName(const ClassName& other);
这里的参数是一个该类的常量引用。如果我们使用传值方式,即如下形式:
ClassName(ClassName other);
首先我们需要知道,在调用拷贝构造函数时,我们本身需要第一个任务就是“传参”,但是我们不要忘了传参本身也是一种拷贝,拷贝就需要调用拷贝构造,这句话就是程序的“思想钢印”,这种逻辑无法改变,所以就会出现这样一种情况,我们需要使用对象a去创建对象b,但是我需要拷贝对象a的内容到参数,既然是拷贝,于是就又去调用拷贝构造函数,然后这个新创建的拷贝构造函数也需要传参,如此往复,形成“无穷递归”,最终会导致栈溢出错误。而引用就不会出现这种情况了,因为引用本质是指针,在传参时调用一次拷贝构造后直接通过引用去获取内容就不会触发“传参就要通过拷贝构造传参”这条规则了,从而跳出我调用我自己的循环。
问题二:问什么会出现双重释放?
出现双重释放的场景一定是:传递内容中有指针,使用C++中的类,发生了值传递。
如果没有指针就不用谈及释放的问题,这是条件一;如果是C语言的话不会出现这种问题,因为C语言中不会有析构函数自动调用,我们人为释放只要不犯错不会出现双重释放的问题,这是条件而;我们想使用对象a创建对象b,针对指针,如果直接拷贝的话就会出现冲突,a的生命周期结束去释放指针指向的地址,b中指针和它的指向地址相同,在其生命周期结束时又去释放,所以就会造成双重释放,也成二次释放的情况。想要解决这个问题很简单,我们需要针对这种变量进行深拷贝,将其内容独立,在创建b的过程中开辟新的空间,然后再将a中指针指向空间里的内容拷贝到b中指向空间里去,这样就不会出现双重释放的问题了。
因此C++规定,自定义类型的传参就需要调用拷贝构造函数。
3)深拷贝--最终解决方案
深拷贝,是指源对象与拷贝对象互相独立,其中任何一个对象的改动都不会对另外一个对象造成影响。深拷贝会复制所有字段,并复制字段所指向的动态分配内存。深拷贝发生在对象及其引用的对象被复制时。对于基本数据类型,如预定义类型Int32,Double等,深拷贝复制所有基本数据类型的成员变量的值。对于引用数据类型的成员变量,深拷贝申请新的存储空间,并复制该引用对象所引用的对象。
深拷贝是一种特殊的拷贝方式,它不仅复制了对象的基本数据类型成员变量的值,还为引用类型的成员变量申请了新的存储空间,并递归复制了这些引用对象所引用的其他对象。这样,源对象与拷贝对象就完全独立,任一对象的修改都不会影响到另一个对象。
需要注意的是,在C++中,对于基本类型的数据以及简单的对象,它们之间的拷贝非常简单,通常是按位复制内存。但对于复杂对象和包含指针或动态内存分配的对象来说,需要进行深拷贝来确保两个对象不会相互影响。 默认情况下,基本数据类型(number,string,null,undefined,boolean)的操作都是深拷贝。
四、赋值运算符重载
运算符重载
- C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。函数名字为:关键字operator后面接需要重载的运算符符号。函数原型:返回值类型 operator操作符(参数列表)
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
- 当然有特殊情况,以下五个运算符不可以重载!!!!
* :: sizeof ? :
#include <iostream>
class Point {
public:
int x, y;
// 构造函数
Point(int x = 0, int y = 0) : x(x), y(y) {}
// 重载 + 运算符
Point operator+(const Point& other) {
return Point(x + other.x, y + other.y);
}
};
int main() {
Point p1(1, 2);
Point p2(3, 4);
Point p3 = p1 + p2; // 使用重载的 + 运算符
std::cout << "Result: (" << p3.x << ", " << p3.y << ")" << std::endl;
return 0;
}
在 C++ 中,操作符重载允许程序员为自定义类型定义操作符的行为。对于双操作数的操作符,在重载时通常需要两个操作数,但可以只传递一个参数,这背后有几个原因:
-
隐式对象:当重载二元操作符(如
+
、-
等)时,第一个操作数通常是一个类的实例(this
指针),因此只需要传递一个参数作为第二个操作数。这是因为在成员函数中,可以直接访问对象的成员。 -
符号语义:使用只传递一个参数的方式,可以保持操作符的原有语义。例如,
a + b
中,a
是隐含的成员对象,而b
是显式传入的参数。这样的设计简化了函数调用,同时确保了对对象内部状态的访问。 -
兼容性和一致性:在设计语言时,选择这种方式能够保持与其他语言操作符使用的一致性,尤其是那些不支持方法调用的语言。这种设计使得 C++ 的操作符重载更直观和易于使用。
赋值运算符重载
我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。
--《C++ Prime》
- 赋值运算符重载格式参数类型:const T&,传递引用可以提高传参效率返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值检测是否自己给自己赋值返回*this :要复合连续赋值的含义
- 赋值运算符只能重载成类的成员函数不能重载成全局函数//:原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
在实际编码中,建议在需要动态资源管理的类中实现拷贝构造函数、赋值操作符、析构函数,以遵循“遵循Rule of Three”的原则,确保资源管理正确无误。
如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。
且不能改变操作符的操作数。