总言
主要内容:默认的成员函数讲解,构造函数、析构函数、拷贝构造函数、赋值运算符重载、const成员函数、 取地址及const取地址操作符重载等。
0、概述
如果一个类中什么成员都没有,简称为空类。任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数: 用户没有显式实现,编译器会生成的成员函数,称为默认成员函数。
1、构造函数
1.1、是什么
📌什么是构造函数?
📌它在C++中的用途是什么?
1)、什么是构造函数
说明:构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2)、为什么要有构造函数
以下为一个例子:
1、在之前,使用Stack、Queue等各种相对完整的项目时,调用项目接口容易忘记初始化,导致崩溃或出现随机值。
2、为了解决每次使用都要调用Init先完成初始化,于是有了构造函数。
如下述的日期类Date,其中含有一个Init初始化函数,每次创建对象后,需要调用Init初始化。
class Date
{
public:
void Init(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 d1;
d1.Init(2022, 11, 11);//每次使用时都需要调用Init初始化
d1.Print();//否则直接打印为随机值
Date d2;
d2.Init(2022, 12, 12);
d2.Print();
return 0;
}
从封装角度: 构造函数允许将对象的初始化细节隐藏在类内部,而不是暴露给外部代码。这有助于实现面向对象编程中的封装原则。
从初始化对象角度: 构造函数的主要目的是在创建对象时为其成员变量赋值。这确保了对象在开始使用之前处于已知和预期的状态。
此外,构造函数还可以设置默认值: 如果没有提供构造函数参数,编译器会提供一个默认构造函数,该函数将所有成员变量设置为默认值。这使得用户可以不必明确地初始化每个变量。
1.2、使用说明
1.1.1、构造函数的基本特性与使用举例
1)、基本说明
基本说明:构造函数是特殊的成员函数,不能以普通函数的定义和调用规则去理解。需要注意,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
1. 函数名与类名相同。
2. 无返回值。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。
2)、细节理解:构造函数不是用于开辟空间的,而是用于初始化对象
说明:在创建对象时,首先需要分配一块足以容纳对象内容的内存空间,这一步实际上是由编译器自动完成的,与构造函数无关。 一旦内存空间分配完毕,接下来就需要将对象的成员变量初始化为合适的值,这个步骤才是构造函数的主要任务。
如下述代码,此处的d1
、i
、s
,其空间开辟是编译器提前计算好的。d1
,i
在main函数中,函数在建立栈帧时,就为这些变量开辟了属于自己的空间,s
为堆区malloc出来的空间。
int main()
{
Date d1;
int i;
char* s = (char*)malloc(sizeof(char) * 4);
}
3)、构造函数的使用举例:
有了构造函数,就可以省去日期类Date中的Init,直接在构造函数中初始化。
class Date
{
public:
//void Init(int year, int month, int day)
//{
// _year = year;
// _month = month;
// _day = day;
//}
Date(int year, int month, int day) //当给了参数时
{
_year = year;
_month = month;
_day = day;
}
Date() //不给参数时
{
_year = 1;
_month =1;
_day = 1;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 7, 5);
//d1.Init(2022, 7, 5);
d1.Print();
Date d2(2022, 7, 6);
//d2.Init(2022, 7, 6);
d2.Print();
return 0;
}
以下是关于构造函数特性在其中的具体体现:
需要注意的是,创建类类型对象时,编译器会自动调用构造函数,若没有相应匹配的构造函数,则编译器会报错。
4)、在构造函数中使用全缺省的情况举例:注意全缺省的相关语法
构造函数可以有缺省参数,如果调用构造函数时没有显示提供参数的值,那么将使用默认的缺省参数值。
class Date
{
public:
Date(int year=1, int month=1, int day=1)
{
_year = year;
_month = month;
_day = day;
}
//注意:此处使用了全缺省,虽然也和下述无参时的Data构成函数重载,
//但实际运用中,对d4编译器该调用哪一函数会形成冲突。
//Date()
//{
// _year = 1;
// _month =1;
// _day = 1;
//}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 7, 5);
d1.Print();
Date d2(2022, 7, 6);
d2.Print();
Date d3(2022);
d3.Print();
Date d4;
d4.Print();
return 0;
}
如下图,可体现缺省参数在构造函数中的优势。
5)、练习:对栈Stack类,将其初始化改为构成函数
修改前:
typedef int DataType;
class Stack
{
public:
void Init(size_t capacity = 4)
{
_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++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack st;
st.Init();//需要先调用初始化函数
st.Push(9);
st.Push(0);
return 0;
}
修改后:
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType)*capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack st;
//st.Init();//可直接使用对象
st.Push(9);
st.Push(0);
return 0;
}
1.1.2、默认生成的构造函数
1)、默认构造函数基本情况说明
说明: 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。PS:默认构造函数只能有一个。
默认构造函数的三类型:
1、我们不写,编译器自动生成的无参构造函数。
2、我们自己写的,全缺省构造函数。
3、我们自己写的,无参的构造函数。
默认构造函数的特点:不传参数就可以调用。
2)、非显示写,编译器默认生成无参构造函数,相关演示(解释其职能作用)
以下述代码为例:可看到Stack和Date两个类中,我们都没有显示写构造函数,也没有init用于初始化的函数。
typedef int DataType;
class Stack
{
public:
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Stack st;
return 0;
}
现象:其结果是能正常运行,但我们发现默认生成的构造函数似乎什么事都没做。
因此,这里就有一个问题:编译器生成的默认构造函数具有什么特点?/它做了什么事情?
1、一个前提认知: C++把类型分成内置类型(基本类型)和自定义类型。
①内置类型/基本类型: C++语言本身支持的数据类型,包括整数类型(int/long int/…)、浮点数类型(double/float)、字符类型(char)、布尔类型(bool)、指针类型等。这些类型可以直接在程序中使用,无需额外定义。
②自定义类型: 通常是指用户根据需要自己定义的类型的集合,包括类类型(class)、结构体类型(struct)、联合体类型(union)等。这些类型可以包含成员变量、成员函数等,可以用来封装数据和行为。
2、相关解释: 编译器生成的默认构造函数,对内置类型成员不做处理,对自定义类型成员会去调用它的默认构造函数。(此处要注意只能是默认构造,无法调用它的其它构造)。
根据上述可知,编译器默认生成的构造函数,其最大的优势体现在以自定义类型为类成员的类中:
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)//注意此处栈仍旧是自定义类型,如果我们没做处理,编译器默认生成无参构造,其结果仍旧是不做处理。
{
cout << "Stack(int capacity = 4)" << endl;
_array = (DataType*)malloc(sizeof(DataType)*capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
class MyQueue
{
private:
Stack _st1;
Stack _st2;
};
int main()
{
Date d1;
MyQueue q;
return 0;
}
其它说明:C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
3)、什么情况下的构造函数才是非默认构造函数?
1、后续将学到,一般而言,比如我们自己写的,非无参、非全缺省。
2、析构函数
2.1、析构函数的概念及相关特性
1)、基本说明
析构函数: 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象有其生命周期,销毁工作是由编译器完成的,而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
析构函数是特殊的成员函数,其特征如下:
1. 析构函数名是在类名前加上字符 ~
(其含义就是功能与构造函数相反
)。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。 若未显式定义,系统会自动生成默认的析构函数。注意:析构函数没有参数,不能进行函数重载。
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
2)、析构函数的使用演示·一
此处我们编写了一个日期类的析构函数:
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()//析构函数:析构函数无参无返回值
{
//日期内中没有需要特殊清理的数据
cout << "~Date()" << endl;//此处打印为验证类确实调用了析构函数
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void func1()
{
Date d1;
}
int main()
{
func1();
return 0;
}
如图所示,①~Date()
被打印显示,说明对象被销毁前,确实调用了析构函数。②但是在日期类中,析构函数的作用意义实际并不大,因为并没有什么值得我们需要清理的东西。
那么,什么样的类才能体现析构函数的价值?
3)、析构函数的使用演示·二
比如下述栈中,就需要我们手动写析构函数。由于我们在创建栈时,使用了动态开辟函数,因此,当对象释放时,需要对开辟的内存空间进行释放,否则容易造成内存泄漏。而默认生成的析构函数并不会自动帮我们销毁栈空间。(此处还涉及默认生成的析构函数做了哪些事项的问题,将在后续谈论到)。
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)//构造函数
{
cout << "Stack(int capacity = 4)" << endl;
_array = (DataType*)malloc(sizeof(DataType)*capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_size = 0;
_capacity = capacity;
}
~Stack()
{
free(_array);
//下述内容可以不写:
//按理说,结束了函数生命周期,这些局部变量就会销毁,不需要我们手动处理。
//但为了严谨起见以及代码规范性,我们最好还是写一下。
_capacity = _size = 0;
_array = nullptr;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
2.2、使用说明
2.2.1、默认生成的析构函数
1)、默认生成的析构函数的特点
与构造函数类似,编译器生成的默认构造函数,对内置类型不处理(包括malloc或new等出来的指针),对自定义类型成员会去调用属于自身的析构函数。
演示代码如下:MyQueue类中成员是由 Stack类组成,在MyQueue中我们没有写析构,这是因为其内置类型_size没有清理必要,而自定义stack类类型_st1、_st2会去调用自身的析构函数。
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 4)//构造函数
{
cout << "Stack(int capacity = 4)" << endl;
_array = (DataType*)malloc(sizeof(DataType)*capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_size = 0;
_capacity = capacity;
}
~Stack()
{
cout << "~Stack()->" << _array << endl;//为了方便验证默认生成的析构函数被调用
free(_array);
_capacity = _size = 0;
_array = nullptr;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
class MyQueue { // MyQueue这个类就不需要我们自己写析构函数
public:
void push(int x) {}
//...
private:
size_t _size = 0;
Stack _st1; //因为其成员函数属于自定义类型,我们只需要完善Stack该类的析构即可。
Stack _st2;
};
void func2()
{
MyQueue q;
}
int main()
{
func2();
return 0;
}
2)、关于何时需要显示写析构函数相关总结
析构函数是类本身默认的成员函数,只是其是显示定义还是默认生成看我们的需求。
根据析构函数的特性,通常情况下有动态申请空间需要我们手动释放资源时,我们可以写析构函数(显示)。而对于类中的内置类型变量,其销毁权是由对应的函数栈来完成的,析构函数对其不起作用。
2.2.2、关于析构函数被调用时的顺序问题
1)、为什么存在顺序问题
回答:涉及进程地址空间问题。类创建的对象在函数中被调用,函数创建存在栈帧。
2)、析构顺序演示
演示实例一:
class A
{
public:
A(int a = 0)//构造
{
_a = a;
cout << "A(int a = 0)->" <<_a<< endl;
}
~A()//析构
{
cout << "~A()->" <<_a<<endl;
}
private:
int _a;
};
void f()
{
A aa1(1);
A aa2(2);
}
int main()
{
f();
return 0;
}
aa1、aa2是对象,其在f()函数中,属于f()函数的变量。在栈中定义的变量,先定义的先构造,后定义的后构造。
栈帧和栈里面的对象,都要符合先进后出的特点, 也就是说后定义的先销毁/析构(清除数据)
演示实例二:
class A
{
public:
A(int a = 0)//构造
{
_a = a;
cout << "A(int a = 0)->" <<_a<< endl;
}
~A()//析构
{
cout << "~A()->" <<_a<<endl;
}
private:
int _a;
};
A aa3(3);//全局对象
void f()
{
static int i = 0;
static A aa0(0);//局部静态对象
A aa1(1);//局部对象
A aa2(2);
static A aa4(4);
}
// 构造顺序:3 0 1 2 4 1 2
// 析构顺序:~2 ~1 ~2 ~1 ~4 ~0 ~3
int main()
{
//调两次!
f();
f();
return 0;
}
此处aa3、aa0、aa4全局变量和静态的局部变量都存储在静态区。
对构建:全局变量先构建,而后才是main函数中的几个变量,按照程序执行顺序依次构建,只不过存储空间不同。
对析构:main函数属于栈区,其内部变量仍旧遵循先创建的先析构的规律,只不过被static修饰的变量其生命周期比局部变量长,最后再析构全局变量。
3、拷贝构造函数
3.1、拷贝构造函数的概念及相关特性
3.1.1、基本概述
1)、基本说明
在一些情况下需要对类进行拷贝,这时候就需要拷贝构造函数。
拷贝构造函数: 在C++中,拷贝构造函数是一种特殊的构造函数,用于创建对象的副本。当一个对象被另一个同类型的对象初始化或者通过赋值操作符将一个对象的值赋给另一个同类型的对象时,拷贝构造函数就会被编译器自动调用。
拷贝构造函数也是特殊的成员函数,其特征如下:
1. 拷贝构造函数是构造函数的一个重载形式。(也就意味着拷贝构造函数的函数名称与构造函数函数名相同。只是拷贝构造函数的形参是同类类型的引用。)
2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。(后续会解释)
2)、为什么需要拷贝构造函数?举例说明
如下述func1
函数中所示,普通变量我们可以直接赋值,那么对类类型的对象,我们能否直接赋值呢?
为了达成这个目的,因此产生了拷贝构造函数。
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()//析构函数:析构函数无参无返回值
{
//日期内中没有需要特殊清理的数据
cout << "~Date()" << endl;//此处打印为验证类确实调用了析构函数
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void func1()
{
int i = 10;
int j = i;//普通函数的赋值
Date d1(2022, 8, 31);//两种类类型赋值的方式
Date d2(d1);
Date d3 = d1;
}
3.1.2、拷贝构造函数的参数说明
1)、关于拷贝构造函数传参的一种错误写法
根据上述特性一,我们先来尝试写一个拷贝构造函数:Date(Date d)
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()//析构函数
{
//日期内中没有需要特殊清理的数据
cout << "~Date()" << endl;
}
Date(Date d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void func()
{
int i = 10;
int j = i;//普通函数的赋值
Date d1(2022, 8, 31);
Date d2(d1);
Date d3 = d1;
}
int main()
{
func();
return 0;
}
以下是它的拷贝构造函数,运行演示,发现报错。
Date(Date d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
这是为什么呢?(涉及到拷贝构造的第二条特性,后续进一步探索)
2)、关于拷贝构造函数传参的一种错误写法
阶段理解一:理解传值传参和传引用传参与类类型结合
先来看看如下一段代码:下述的get1()
、get2()
在传参时分别做了哪些事?
void get1(Date d)//传值传参,传递的是整个类
{}
void get2(Date& d)//传引用传参
{}
void func()
{
Date d0;
Date d1(2022, 8, 31);
get1(d1);
get2(d1);
}
int main()
{
func();
return 0;
}
对传值传参get1()
,形参Date d
,开辟新的空间,创造一个同样的类。其会以d1
为模板,调用构造函数初始化,由于初始化的对象为相同的类类型,这种调用属于拷贝构造。
3)、关于拷贝构造函数传值传参导致无穷递归的理解与解决方案
根据上述提及,拷贝构造函数的参数只有一个,且必须是类类型对象的引用,使用传值传参会引发无穷递归。为什么这么说呢?
既然如此,如何解决上述问题?
解决方案一:使用传引用传参
Date(Date& d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()//析构函数:析构函数无参无返回值
{
//日期内中没有需要特殊清理的数据
cout << "~Date()" << endl;//此处打印为验证类确实调用了析构函数
}
Date(Date& d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
解决方案二:使用指针
需要注意的是,使用指针来解决,此时就不是拷贝构造了。
Date(Date* d)
{
_year = d->_year;
_month = d->_month;
_day = d->_day;
}
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()//析构函数:析构函数无参无返回值
{
//日期内中没有需要特殊清理的数据
cout << "~Date()" << endl;//此处打印为验证类确实调用了析构函数
}
Date(Date* d)
{
_year = d->_year;
_month = d->_month;
_day = d->_day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
另外,还需注意在使用引用和指针时二者对类的成员访问操作符的区别:
4)、拷贝构造函数传引用为什么需要加const的原因
1、为了防止下述情况发生:赋值与被赋值变量混淆。
Date(Date& d)//拷贝构造
{
d._year = _year;
d._month = _month;
d._day = _day;
}
上述情况其结果是实参被修改:即我是要为d2这个类拷贝d1中的数据的,结果反倒是我的d1被修改了。
因此,为参数加了一个const,在引用传参下,实则就是为实参加const,表示实参不能被修改:
Date(const Date& d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
3.2、使用说明
3.2.1、默认生成的拷贝构造函数
1)、默认生成的拷贝构造函数的特点
说明: 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数,对内置类型是按内存存储按字节序完成拷贝(这种拷贝叫做浅拷贝,或者值拷贝),对自定义类型是调用其拷贝构造函数完成拷贝的。
细节理解: 虽然编译器默认生成的拷贝构造函数对内置类型和自定义类型都做了处理,好像没什么需要我们自己写的场景,但实际上这里涉及一个深浅拷贝的问题,类中如果没有涉及资源申请时,拷贝构造函数是否显示写都可以;一旦涉及到资源申请时,则拷贝构造函数需要我们自己显示写,否则编译器生成的就是浅拷贝。
2)、类中涉及资源申请时使用浅拷贝的结果演示
浅拷贝中,下述Stack的两个s1、s2指向地址相同,在析构时,free将对同一块空间释放两次。
说明: 对某一块内存空间同时使用 free() 两次或以上可能会导致未定义的行为。这是因为 free() 函数用于释放之前通过 malloc(), calloc() 或 realloc() 分配的内存。一旦内存被释放,它就不再属于你的程序,并且不应再次被释放。
一个区别: 在C或C++中,free(null)
是安全的。 当你试图释放一个指针时,如果指针是 null,则释放操作不会产生任何效果。这是因为指针本身不指向任何已分配的内存,所以没有内存可以被释放。)
解决这个问题,就需要使用深拷贝:深拷贝如何实现后面学习(介绍STL时)。
3.2.2、关于类对象作为函数形参/函数返回值时,传值、传引用问题说明
1)、类对象作为形参:对比传值传参和引用传参
演示代码如下:有一个A类,将其作为函数参数,分别进行传值传参和传引用传参,验证其调用了哪些当前所学的默认成员函数。
class A
{
public:
A(int a = 0)
{
_a = a;
cout << "A(int a = 0)->" << _a << endl;
}
A(const A& aa)
{
_a = aa._a;//注意:类里面能访问对应类类型私有成员
cout << "A(const A& aa)->" << _a << endl;
}
~A()
{
cout << "~A()->" << _a << endl;
}
private:
int _a;
};
void func1(A aa)//类对象:传值传参
{
}
void func2(A& aa)//类对象:传引用传参
{
}
int main()
{
//验证传值传参
A aa1(11);
func1(aa1);
cout << endl;
//验证传引用传参
A aa2(22);
func2(aa2);
return 0;
}
观察结果如下: 可以看到传值对象被复制,调用了拷贝构造,函数接收的是对象的副本,这会带来一个问题,如果对象很大,复制操作可能会很昂贵。
内置类型和自定义类型作为函数参数说明:
1、对内置类型,若非输出型参数,从底层角度引用和传值二者而言没有很大区别,引用的底层也是指针,指针也需要创建变量。
2、对自定义类型, 引用在传参时相对价值更大:
- ①对于大型对象,如果通过值传递,会涉及到对象的复制操作,这可能会导致时间和空间上的开销。通过引用传递可以避免这种复制操作,从而提高性能。
- ②自定义类型传值传参时会涉及到拷贝构造函数和拷贝赋值运算符的调用,这可能会引发额外的开销或导致不希望的行为,通过引用传递可以避免这些操作。
- ③此外还可以避免深拷贝和浅拷贝的问题: 对于包含指针或动态分配资源的对象,通过值传递可能会导致深拷贝或浅拷贝的问题。通过引用传递可以避免这些问题
2)、类对象作为返回值:对比传值返回和引用返回
对传参问题如此,对返回值问题也适用。
class A
{
public:
A(int a = 0)
{
_a = a;
cout << "A(int a = 0)->" << _a << endl;
}
A(const A& aa)
{
_a = aa._a;//注意:类里面能访问对应类类型私有成员
cout << "A(const A& aa)->" << _a << endl;
}
~A()
{
cout << "~A()->" << _a << endl;
}
private:
int _a;
};
A func3()//传值返回
{
static A aa(33);
return aa;
}
A& func4()//传引用返回
{
static A aa(44);
return aa;
}
int main()
{
//验证传值返回
func3( );
cout << endl << endl;
//验证传引用返回
func4( );
return 0;
}
需要注意如果aa出了函数作用域就销毁,那么引用返回是有问题的。
4、赋值运算符重载
4.1、运算符重载
4.1.1、为什么
1)、为什么需要运算符重载?
对于内置类型,我们可以直接使用运算符运算,因为编译器知道要如何运算;
对于自定义类型,由于这些运算符可能没有直观的意义,编译器也不知道要如何运算。因此,设置通过运算符重载,可让程序员为自定义类型提供有意义且符合直觉的运算符行为。
以下为一个演示例子:日期类中,日期大小的判断、日期加减天数后的日期,等等,这些运算与我们实际需求相关,而编译器无法直接提供运算,因此就需要运算符重载,我们手动自拟完成需求。
class Date//日期类:析构、拷贝构造,默认生成够用。
{
public:
Date(int year=1, 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;
};
int main()
{
Date d1(2022, 7, 23);
Date d2(2022, 7, 24);
//如何实现以下运算功能?
d1 < d2;
d1++;
d1 + 100;
Date d3(20202, 10, 1);
d3 - d2;
return 0;
}
4.1.2、运算符重载的概念及相关特性
运算符重载是具有特殊函数名的函数,也具有返回值类型、函数名字以及参数列表。其返回值类型与参数列表与普通的函数类似。
函数名字为:operator关键字 后面接需要重载的运算符符号
函数原型:返回值类型 operator 操作符 (参数列表)
//以下为简单举例:
bool operator =(const int tmp);//赋值运算符重载
bool operator <(int a, int b);//小于运算符重载
……
注意事项:
1、不能通过连接其他符号来创建新的操作符: 比如operator@
,当前已有的运算符中没有@
操作符。C++中预定义了一组有效的运算符,包括算术运算符(如+、-、*、/
)、比较运算符(如==、!=、<、>
)、逻辑运算符(如&&、||
)、位运算符(如&、|、^、~、<<、>>
)等。这些运算符都有固定的含义和行为,不能通过简单的组合来创建新的运算符。
2、重载操作符必须有一个类类型参数: C++中规定,重载运算符必须至少有一个类类型参数,这是因为运算符是用来操作类对象的,而至少需要一个类类型参数才能完成这个操作。 例如,当我们定义一个叫做“myClass
”的类,并向其添加一个“+
”运算符时,需要至少传递一个“myClass”
类型的参数来执行加法运算。如果我们定义的运算符没有类类型参数,编译器将无法识别该运算符。
3、若作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this。
4、5个不能重载的运算符: .*
、 ::
、sizeof
、 ? :
、 .
,经常在笔试选择题中出现。
.
(成员访问运算符): 不能重载是为了保证访问成员的功能不被改变。
.*
(成员指针访问运算符): 不能重载是因为指向类成员的指针引用不具备重载的特征。
::
(域运算符): 不能重载是因为该运算符在编译时进行域解析,不具备重载的特征。
sizeof
(长度运算符): 不能重载是因为内部许多指针都依赖它。
?:
(条件运算符): 不能重载是因为该运算符的含义是执行exp2和exp3中的一个,如果重载了,就不可以保证执行一个还是两个,该运算符的跳转性质就不复存在了。
4.1.3、如何在类外使用运算符重载(以判等运算符重载为例)
1)、场景引入与问题说明
我们以实现判断两个日期类时间是否相同为例,基本代码如下:
class Date
{
public:
Date(int year=1, 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;
};
在此基础上来实现日期类的判等:
class Date
{
public:
Date(int year=1, 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;
};
bool operator==(Date d1, Date d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
这是我们首次写出的代码:这里面临一个问题,_year
、_month
、_day
为类中私有成员变量,在类外面不能直接访问。
bool operator==(Date d1, Date d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
那么,该如何解决这个问题呢?
2)写在类外的运算符重载:针对访问私有成员的问题
方案一:
解决方案一: 直接将类中的成员变量设置为公有。
存在问题:但这样事实上正式场合中不太实际(我们之前封装就白学了)。
class Date
{
public:
Date(int year=1, 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;
};
bool operator==(Date x1, Date x2)
{
return x1._year == x2._year
&& x1._month == x2._month
&& x1._day == x2._day;
}
虽然这样,但不妨碍我们先来完善这个运算符重载:
需要完善点一: 传值传参和传引用传参
上述代码中我们使用的是传值传参,因为它是一个类,相当于此处需要拷贝构造,如此大的开销,不如直接使用传引用传参:
bool operator==(Date& x1, Date& x2)
{
return x1._year == x2._year
&& x1._month == x2._month
&& x1._day == x2._day;
}
需要完善点二: const修饰
根据之前所学,此处最好加上const修饰,以防止值被更改或者写失误。(就算不是==运算符重载,其它运算符呢?)
bool operator==(const Date& x1, const Date& x2)
{
return x1._year == x2._year
&& x1._month == x2._month
&& x1._day == x2._day;
}
方案二
解决方案二: 在类中创建一个用于返回私有成员的函数,这样_year、_month、_day就可以保持private了。
存在问题:只是此法过于冗杂, 一般不推荐使用。
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
int GetYear()
{
return _year;
}
int GetMonth()
{
return _month;
}
int GetDay()
{
return _day;
}
private:
int _year;
int _month;
int _day;
};
bool operator==(Date& d1, Date& d2)
{
return ((d1.GetYear()) == (d2.GetYear()))
&& ((d1.GetMonth()) == (d2.GetMonth()))
&& ((d1.GetDay()) == (d2.GetDay()));
}
方案三:
解决方案三: 如果仍旧需要放在类外面,我们还可以使用友元函数,该知识将在类和对象(四)中学到。
3)、关于为什么要使用运算符重载的意义价值体现
上述几个方案的实际调用如下:
int main()
{
Date d1(2022, 7, 23);
Date d2(2022, 7, 24);
cout << (d1 == d2) << endl;
//cout<< operator==(d1,d2) <<endl;
Date d3(2022, 7, 22);
Date d4(2022, 7, 22);
cout << (d3 == d4) << endl;
//cout<< operator==(d3,d4) <<endl;
return 0;
}
注意事项:
1、cout << (d1 == d2) << endl;
要加()是因为流插入流提取也能构成运算符重载。
2、cout << (d1 == d2) << endl;
实际上是被当作cout<< operator==(d1,d2) <<endl;
处理。
根据注意事项2,能知晓的是,运算符重载所做的功能,我们也可以通过实现一个函数来达成。为什么反而还需要去这么大费周章写它?
一个主要目的是为了能让自定义类型像内置类型一样使用这些运算符。
4.1.3、如何在类内使用运算符重载(以判等运算符重载为例)
方案四:
解决方案四: 上述我们说明的都是类外运算符重载,实际上我们可以把运算符重载放入到类里。
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& x1, const Date& x2)
{
return x1._year == x2._year
&& x1._month == x2._month
&& x1._day == x2._day;
}
private:
int _year;
int _month;
int _day;
};
上述写法运行结果如下:
结果显示报错,这是因为类中我们有this指针,因此使用运算符重载需要稍作修改:
class Date
{
public:
Date(int year=1, int month=1, int day=1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& x2)//虽然我们只传了一个参数,实际上==的左参数是this指针指向的类
{
return _year == x2._year
&& _month == x2._month
&& _day == x2._day;
}
private:
int _year;
int _month;
int _day;
};
相关调用:
int main()
{
Date d1(2022, 7, 23);
Date d2(2022, 7, 24);
Date d3(2022, 7, 22);
Date d4(2022, 7, 22);
cout << d1.operator==(d2) << endl; // -> d1.operator==(&d1, d2)
cout << (d3 == d4) << endl;
return 0;
}
4.2、赋值运算符重载
4.2.1、为什么
1)、什么场景下需要使用赋值运算符重载
仍旧以日期类为例:
#include"Date.h"
void Test1()
{
//对普通变量:
int i = 10;
int j = 20;//我们可以在初始化时赋值
int k;
k = i;//也能在后续为其赋值修改。
//疑惑:我们能否像使用普通变量一样来完成对类的赋值使用?
//对类:
Date d1(2022, 7, 21);//这里调用的是构造函数
Date d2(d1);//这里是拷贝构造
Date d3(2022, 8, 24);//构造
d3 = d2;//这里就是赋值:已经存在的类
}
int main()
{
Test1();
return 0;
}
如上述,d3 = d2;
就是我们要实现的赋值。
4.2.2、赋值运算符使用说明
1)、演示一:单项赋值
Date.h文件:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)//构造函数
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数:默认生成的也能用
void operator=(const Date& d)//赋值运算符重载:传引用传参、const修饰
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
Text.cpp文件:
#include"Date.h"
void Test1()
{
Date d1(2022, 7, 21);
Date d3(2022, 8, 24);
d3 = d1;//这里就是赋值:已经存在的类
// d1.operator=(&d1, d3)
}
int main()
{
Test1();
return 0;
}
1)、演示二:改进,连续性赋值
原因:
void Test1()
{
//对普通变量:
int i = 10;
int j = 20;//我们可以在初始化时赋值
int k;
k = i = j = 111;//此处支持连续赋值。
//对类:
Date d1(2022, 7, 21);//这里调用的是构造函数
Date d2(d1);//这里是拷贝构造
Date d3(2022, 8, 24);//构造
d3 = d2 = d1;//现在我们要实现此处的连续赋值
}
若直接使用上述演示一中的赋值运算符重载:结果报错,原因是我们返回的是void。
因此,此处我们需要对赋值运算符重载的返回类型做修改。
注意:
1、赋值运算符的顺序:d2=d1 ,所得返回值再赋值给d3。
2、关于赋值运算符重载中使用引用传参/返回、传值传参/返回的区别:需要额外调用拷贝构造函数,因此我们使用传引用相对较佳。
3、*this
:由于传参时我们使用了传引用传参,故此处可使用 *this
。出了函数对象不会销毁。
4、若是自己给自己赋值,实际没必要,因此需要做处理:假如使用*this != d
作为判断,此处存在两个问题,①涉及!=
运算符重载;②如果是两个地址不相同而数值相同的对象呢?这就不是它自己本身了。
4.2.3、默认生成的赋值运算符重载
5、const成员函数
5.1、为什么及怎么用
1)、问题引入
以日期类的实现来举例:
定义一个Print类成员函数,实际其类型为Date * const this
(const
加在*
之后,修饰的是 this指针本身不能被修改)。
void Date::Print()
{
cout << _year << "\\" << _month << "\\" << _day << endl;
}
定义日期类,其中d2被const修饰,让二者调用Print函数,报错。
void Test1()
{
Date d1(2022, 11, 11);
const Date d2(2022, 12, 12);
d1.Print();
d2.Print();//error
d1<d2;//error
}
原因:我们实现的函数中,this指针的类型为Date * const this
,而 被const 修饰的类,其this指针类型为 const Date* const this
;
因此需要让this指针也能拥有被const修饰的权力。而根据之前的语法学习,我们不能直接对this指针修饰,故而采用了这样折中的写法。
void Date::Print() const
{
cout << _year << "\\" << _month << "\\" << _day << endl;
}
2)、const成员
将const
修饰的“成员函数”称之为const成员函数。const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明 在该成员函数中,不能对类的任何成员进行修改。
事实上此处也可以写两个Print成员函数,它们实际上构成函数重载。
调用原理:会根据传递的类对象,走最匹配它的Print函数。
void Date::Print()
{
cout << _year << "\\" << _month << "\\" << _day << endl;
}
void Date::Print()const
{
cout << _year << "\\" << _month << "\\" << _day << endl;
}
问题:什么时候需要像这样写两个const运算符重载?
回答:在下述的取地址和const取地址运算符重载中需要这样做。
5.2、细节问题
📌1、 const对象可以调用非const成员函数吗?
📌2、 非const对象可以调用const成员函数吗?
📌3、 const成员函数内可以调用其它的非const成员函数吗?
📌4、非const成员函数内可以调用其它的const成员函数吗?
1)、 const对象可以调用非const成员函数吗?
不可以。一个const对象只能调用其类中的const成员函数。尝试从一个const对象调用非const成员函数会导致编译错误(权限放大)。这是因为非const成员函数可能会修改对象的状态,而const对象则承诺其状态在对象的生命周期内不会被修改。
2)、非const对象可以调用const成员函数吗?
可以。非const对象可以调用其类中的任何成员函数,包括const成员函数。这是因为const成员函数保证不会修改对象的状态,所以即使对象不是const的,调用这样的函数也是安全的。
3)、 const成员函数内可以调用其它的非const成员函数吗?
不可以。const成员函数内部不能调用同一类中的非const成员函数。这是因为如果非const成员函数修改了对象的状态,那么const成员函数就违反了其承诺——即不会修改对象的状态。
4)、非const对象可以调用const成员函数吗?
可以。非const成员函数可以调用其类中的任何成员函数,包括const成员函数。这是因为const成员函数不会修改对象的状态,所以即使在一个非const的成员函数内部调用,也不会导致任何问题。
6、取地址及const取地址操作符重载
6.1、介绍
1)、为什么取地址、const取地址运算符需要构成重载?
对普通变量取地址,需要返回的是普通变量对应的类型指针。
对const变量取地址,需要返回的是带const的对应类型指针。
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};
因为需求的类型不同,所以需要针对它们单独考虑。
6.2、const成员函数和取地址操作符显示写说明
Fin.共勉。