类与对象
1.面向过程和面向对象初步认识
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成
2.类的引入
C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数。比如:之前在数据结构初阶中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现,会发现struct中也可以定义函数。
//C语言版
typedef int DataType;
typedef struct Stack
{
DataType* _array;
size_t _capacity;
size_t _size;
};
struct Stack s1;//声明一个结构体变量
结构体中只能定义变量,并且声明栈类型的变量时,必须写全struct Stack。
//c++版
typedef int DataType;
struct Stack
{
void Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(const DataType& data)
{
// 扩容
_array[_size] = data;
++_size;
}
DataType Top()
{
return _array[_size - 1];
}
void Destroy()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
DataType* _array;
size_t _capacity;
size_t _size;
};
int main()
{
Stack s;
s.Init(10);
s.Push(1);
s.Push(2);
s.Push(3);
cout << s.Top() << endl;
s.Destroy();
return 0;
}
在C++中struct更喜欢用class来代替
- C++兼容c语言struct的所有用法
- struct同时升级成了类,类名就是类型
- Stack就是类型,不需要加struct
3.类的定义
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
C++中可以用struct来定义一个类(把C语言中的结构体升级了),但更多的是使用class关键字来定义类。class后面跟类名,{}中的是类的主体。注意:类定义结束时后面分号不能省略。
类体中的内容称为类的成员:类中的变量称为:类的属性或成员变量;
类中的函数称为类的方法或者成员函数。
类的两种定义方式:
- 声明和定义全部放在类体中。
//定义一个类
class Person
{
//成员函数--显示基本信息
void showInfo()
{
cout << _name << "-" << _sex << "-" << _age << endl;
}
//成员变量
char* _name;//姓名
char* _sex;//性别;
int _age;//年龄
};
注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理,最终是否真的是内联,还是由编译器说了算。
- 类的声明放在.h文件中,成员函数的定义放在.cpp文件中。
//Person.h
//定义一个人的类
class Person
{
//成员函数--显示基本信息
void showInfo();
//成员变量
char* _name;//姓名
char* _sex;//性别;
int _age;//年龄
};
//Person.cpp
#include"Person.h"
void Person::showInfo()
{
cout << _name << "-" << _sex << "-" << _age << endl;
}
注意:成员函数名前需要加类名::,告诉编译器这个函数属于哪个类域,否则编译器不知道成员函数里面的成员变量是哪里的。
一般情况下,更期望采用第二种方式。
// 我们看看这个函数,是不是很僵硬?
class Date
{
public:
void Init(int year)
{
// 这里的year到底是成员变量,还是函数形参?
year = year;//
}
private:
int year;
};
// 所以一般都建议这样
class Date
{
public:
void Init(int year)
{
_year = year;
}
private:
int _year;
};
// 或者这样
class Date
{
public:
void Init(int year)
{
mYear = year;
}
private:
int mYear;
};
4.类的访问限定符及封装
4.1 访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用
【访问限定符说明】
- public修饰的成员在类外可以直接被访问
- protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
- 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
- 如果后面没有访问限定符,作用域就到 } 即类结束。
- class的默认访问权限为private,struct为public(因为struct要兼容C)
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
【面试题】
问题:C++中struct和class的区别是什么?
解答:C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是public,class定义的类默认访问权限是private。注意:在继承和模板参数列表位置,struct和class也有区别,后序给大家介绍。
4.2 封装
面向对象的三大特性:封装、继承、多态。
在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
5.类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 ::作用域操作符指明成员属于哪个类域。不同的作用域可以定义同名变量。
Person.h文件
//定义一个人的类
class Person
{
//成员函数——显式基本信息
void showInfo();
//成员变量
char* _name;//姓名
char* _sex;//性别
int _age;//年龄
};
Person.cpp文件
#include "Person.h"
void Person::showInfo()
{
cout << _name << "-" << _sex << "-" << _age << "-" << endl;
}
如上面的成员函数showInfo,对于函数体中出现的变量_name等,编译器会先在当前函数的局部域中搜索,如果没有找到,接下来会到对应的类域里面去搜索,当类域里面也没有的时候,最后回到全局区搜索,如果全局也没有,编译就会报错。
注意:所有的域都会影响访问,但是只有全局域和局部域会影响生命周期,而类域和命名空间域不会影响声明周期。
6.类的实例化
用类类型创建对象的过程,称为类的实例化
-
类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息。
-
一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量
-
做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间
class Person
{
public:
void showInfo()
{
cout << _name << "-" << _sex << "-" << _age << endl;
}
public:
const char* _name;//姓名
const char* _sex;//性别;
int _age;//年龄
};
int main()
{
Person p1;//实例化对象p1
p1._name = "小芳";
p1._age = 10;
//Person._name="小明";//错误,相当于往图纸里放人
}
7.类对象模型
7.1 如何计算类对象的大小
// 类中既有成员变量,又有成员函数
class A1
{
public:
void f1() {}
private:
int _a;
};
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 类中什么都没有---空类
class A3
{};
int main()
{
cout << "A1的大小" << sizeof(A1) << endl;
cout << "A2的大小" << sizeof(A2) << endl;
cout << "A3的大小" << sizeof(A3) << endl;
return 0;
}
结论:一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐
注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象
sizeof(类)和sizeof(对象)计算出来的结果是一样
7.2 结构体内存对齐规则
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8 - 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
【面试题】
-
结构体怎么对齐? 为什么要进行内存对齐?
计算机在访问数据的时候,并不是想访问哪个字节就访问哪个字节。而是按倍数进行访问,固定一次访问多少,具体和硬件电路有关。内存对齐会减少计算机读取数据的访问次数,提高数据的读取效率。修改默认对齐数,本质上是用时间换空间,因为硬件电路一次访问多少个字节是固定的。修改默认对齐数只是改变了数据的存储方式。 -
如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?
-
什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景
自定义类型:结构体,枚举,联合
深度刨析数据在内存中的储存
上两篇文章中有做回答
8.this指针
8.1 this指针的引出
//定义一个日期类
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, d2;//定义两个日期类
d1.Init(2022, 1, 11);//给d1初始化化
d2.Init(2022, 1, 12);//给d2初始化
d1.Print();//调用Print函数
d2.Print();//调用Print函数
return 0;
}
对于上述类,有这样的一个问题:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
8.2 this指针的特性
- this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器(vs)通过ecx寄存器自动传递,不需要用户传递
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
//void Print(Date* const this)
//{
// cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
//}
private:
int _year;
int _month;
int _day;
};
class A
{
private:
char _ch;
int _a;
};
int main()
{
Date d1;
Date d2;
d1.Init(2023, 10, 7);
d2.Init(2022, 10, 7);
// 不能显示写this相关实参和形参
d1.Print();
d2.Print();
//d1.Print(&d1);
//d2.Print(&d2);
return 0;
}
【面试题】
- this指针存在哪里?
vs下存到ecx寄存器 - this指针可以为空吗?
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
上面这段代码,定义了一个A类型的指针p,并把它置为空,然后用这个指针p去调用成员函数,不会发生解引用,因为Print函数的地址不在对象中(要看转换成汇编指令,都干了些啥,这里直接去call成员函数的地址)。p会作为实参传递给this指针。传递空指针不会报错,所以此时成员函数中的隐藏参数this指针,是拷贝的p指针的值,所以此时的形参this指针是nullptr。针对这个题目,首先可以排除掉A选项,因为空指针的问题是属于运行时错误,不可能是编译时错误。这道题目选C,代码可以正常运行,因为,虽然this指针是空,但是在Print成员函数中,我们并没有去访问任何类中的其他成员,这就意味着,我们根本就没有使用这个this指针,所以代码可以正常运行。
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
和上面的代码一样,这段代码的this指针也是nullptr,但是这段代码会运行崩溃,因为在成员函数Print中使用了类中的其他成员_a,这就相当于this->_a,而this是一个空指针,这就成了解引用空指针,所以会运行崩溃。
9.类的6个默认成员函数
👊默认成员函数
用户没有显式实现,编译器会自动生成的成员函数,称为默认成员函数。
构造函数:完成对象的初始化工作。
析构函数:完成对象空间的清理工作。
拷贝构造:使用同类对象初始化创建对象。
赋值重载:把一个对象赋值给另外一个对象(该对象已存在)。
取地址重载:获取对象的地址,这两个很少自己实现。
注意:构造和析构函数,不是创建对象和销毁对象。对象的创建和销毁都是编译器做的工作。
11.构造函数
📖为什么要有构造函数?
为了避免每次创建对象后,都要去调用专门的成员函数设置对象的信息,这样很麻烦,并且容易遗忘,那就想着能否在创建对象的同时,就将信息设置进去。因此,就有了构造函数。以日期类为例:
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, 7, 5);//调用初始化函数
d1.Print();
Date d2;
d2.Init(2022, 7, 6);
d2.Print();
return 0;
}
如上面的代码,每次创建一个日期类对象后,都要手动的去调用Init函数,完成对象的初始化,整个过程繁琐,而且容易遗忘,为此,提出了构造函数的概念。
📖定义
构造函数是一个特殊的成员函数,名字与类名相同,创建对象的时候由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象的整个生命周期中只调用一次。
📖构造函数的特性
- 函数名与类名相同。
- 无返回值。(无需void)
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
class Date
{
public:
// 1.无参构造函数
Date()
{}
// 2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1; // 调用无参构造函数
Date d2(2015, 1, 1); // 调用带参的构造函数
Date d3();
}
注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。即Date d3();是声明了一个d3函数,该函数无参,返回一个日期类对象,并不是创建了一个日期类对象d3。
构造函数在语法上可以是私有的,但是在创建对象的时候就调不动了。在单例模式中,会把构造函数搞成私有,具体的我们以后再说。
📖编译器生成的构造函数
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义,编译器将不在生成。
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 d1;
return 0;
}
将Date类中的构造函数注释掉后,代码可以编译通过,因为编译器生成了一个无参的默认构造函数。将Date类中的构造函数放开后,代码编译失败,因为一旦显式定义了任何构造函数,编译器将不再生成默认构造函数。而此时Date中的构造函数需要三个参数,Date d1;会去调用无参的构造函数,但是当前类中没有无参的构造函数,所以编译会报错。
📖编译器生成的构造函数干了什么?
C++中把类型分为内置类型和自定义类型。内置类型就是语言提供的数据类型,如:int、char……自定义类型就是我们使用class、struct、union等自己定义的类型。(所有类型的指针都属于内置类型)。
编译器生成的默认构造函数,对内置类型不做处理,对自定义类型,会去调用它的默认构造函数。
//先定义一个时间类
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
//再定义一个日期类
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
上面代码,先定义了一个时间类Time,它的成员变量都是内置类型,给这个类写了一个无参的构造函数,接下来,定义了一个日期类Date,他有四个成员变量,其中_year、_month、 _day都是内置类型,_t是自定义类型,并且,我们没有写日期类的构造函数,这意味着,在创建对象的时候,会去使用编译器生成的无参默认构造函数。
📖内置类型给默认值
C++11中针对内置类型成员不初始化的缺陷,打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
🎊示例:
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
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;
}
📖默认构造函数
我们没写编译器自动生成的构造函数、无参构造函数、全缺省构造函数,这三种都叫做默认构造函数,它们都有一个共同的特点:可以不用传参。默认构造函数只能有一个,后面俩,在语法上可以构成函数重载,但是在无参调用的时候,会发生歧义,出现调用不明确。
注意:要把默认构造函数和默认成员函数区分清楚,默认成员函数是我们不写编译器会自动生成的,默认构造函数是不需要传参的构造函数。编译器生成的构造函数,既是默认构造函数,同时也是默认成员函数。
📖总结
一般情况下,都需要我们自己写构造函数。如果满足以下情况,即:内置类型的成员变量都有默认值,且初始化符合我们的要求,自定义类型都定义了默认构造,此时可以考虑不写构造函数,使用编译器自动生成的默认构造函数。自定义类型如果没有对应的构造函数,那就意味着初始化自定义类型需要传参,此时必须自己写构造函数,并且还会用到初始化列表。
12.析构函数
📖定义
与构造函数的功能相反,析构函数不是完成对对象本身的销毁,局部对象的销毁工作是由编译器完成的。而对象在销毁的时候,会自动调用析构函数,完成对象中资源的清理工作。
📖特性
析构函数名是在类名前加上~。
无参数无返回值类型。
一个类只能有一个析构函数,若未显式定义,系统会自动生成默认的析构函数。
对象生命周期结束时,C++编译器自动调用析构函数。
小Tips:析构函数不能重载。
🎊示例:
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()//析构函数
{
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);
}
Stack中的成员变量_array、_capacity、_size都是内置类型,所以在对象s生命周期结束要销毁的时候,不需要资源清理,最后系统直接将其内存回收即可,而_array指向的空间是在堆区上申请的,这块空间不会随着对象生命周期的结束而自动释放(归还给操作系统),所以_array被回收后,就找不到动态申请的那块空间,会造成内存泄漏,因此在对象销毁前,要通过析构函数去释放成员变量_array指向的空间,这就是析构函数的作用。
📖编译器生成的析构函数干了什么?
我们不写,编译器会自动生成一个析构函数。该析构函数对内置类型不做处理,对自定义类型会去调用它的析构函数。
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;
}
main方法中创建了Date对象d,而d中包含4个成员变量,其中_year、_month、_day、三个是内置类型成员,对象销毁时不需要资源清理,最后系统直接将其内存回收即可,而_t时Time类对象,在d销毁时,要将器内部包含的Time类的_t对象销毁,所以要去调用Time类的析构函数。但是main函数中不能直接调用Time类的析构函数,实际销毁的是Date类对象d,所以编译器会调用Date类的析构函数,而Date类没有显示提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数。
📖总结
一般情况下,有动态申请资源,就需要显式的写析构函数来释放资源,没有动态申请的资源,可以不写析构函数,需要释放资源的成员都是自定义类型,也不需要写析构函数。
13.拷贝构造函数
📖定义
拷贝构造函数是构造函数的一个重载,它的本质还是构造函数,那就意味着,只有在创建对象的时候,编译器才会自动调用它,那他和普通的构造函数有什么区别呢?
拷贝构造函数,是创建对象的时候,用一个已存在的对象,去初始化待创建的对象。简单来说,就是在我们创建对象的时候,希望创建出来的对象,和一个已存在的对象一模一样,此时就应该用拷贝构造函数,而不是普通的构造函数。拷贝构造函数有一点类似于克隆技术。
Data d1(2023, 7, 20);//定义一个日期类对象d1
Data d2(d1);//会去调用拷贝构造函数
int a = 10;
int b = a;//不会调用拷贝构造
上面代码,首先定义了一个日期类对象d1,接着想创建第二个日期类对象d2,并且希望d2和d1一模一样,也就是用d1去克隆出d2,d2相当于是d1的一份拷贝。所以在创建d2对象的时候,参数列表直接传递了d1。
小Tips:拷贝构造函数是针对自定义类型的,自定义类型的对象在拷贝的时候,C++规定必须要调用拷贝构造函数。内置类型不涉及拷贝构造函数,如上,用a去创建b,是由编译器直接把a所表示的空间中的内容直接拷贝到b所表示的空间,并不涉及拷贝构造函数。
📖拷贝构造函数的错误写法
有了上面的分析,可能很多朋友会觉得,那我直接在类里面再写一个构造函数,把它的形参设置成日期类对象,不就行了嘛,于是便得到了下面的代码:
Data(Data d)//错误的拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
是不是觉得很简单?创建d2对象的时候,实参把d1传过来,然后用d接收,最后再把d的所有值赋值给this指针(当前this指针就指向d2),这一切堪称完美,但是我想告诉你,这种写法是大错特错的。
形参d在接收实参d1的时候,又要去调用拷贝构造来创建d,这次调用拷贝构造,又会有一个形参d,这个形参d又需要调用拷贝构造才能创建,相信到这里,小伙伴们已经看出问题所在了———无穷递归,形参在接收的时候,会无穷无尽的去调用拷贝构造函数,就像套娃一样。
为了避免出现这种无穷递归,编译器会自行检查,如果拷贝构造函数的形参是值传递,编译时会直接报错。
📖必须是引用
为了打破上面的魔咒,拷贝构造函数的形参只能有一个,并且必须是类类型对象的引用。下面才是正确的拷贝构造函数:
Data(Data& d)//正确的拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Data d1(2023, 7, 20);//定义一个日期类对象d1
Data d2(d1);//
📖建议加const
因为存在用一个const对象去初始化创建一个新对象这种场景,所以建议在拷贝构造函数的形参前面加上const,此时普通的对象能用,const对象也能用。
Data(const Data& d)//正确的拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
const Data d1(2023, 7, 20);//定义一个日期类对象d1
Data d2(d1);
📖编译器生成的拷贝构造干了什么?
上一节提到,拷贝构造是一种默认成员函数,我们不写编译器会自动生成。编译器生成的默认拷贝构造函数,对内置类型按照字节方式直接拷贝(也叫值拷贝或浅拷贝),对自定义类型是调用其拷贝构造函数完成拷贝。
class Time//定义时间类
{
public:
Time()//普通构造函数
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)//拷贝构造函数
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const 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 d1;
// 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
// 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数
Date d2(d1);
return 0;
}
📖什么是浅拷贝
上面提到,编译器生成的拷贝构造函数,会对内置类型完成浅拷贝,浅拷贝就是以字节的方式,把一个字节里的内容直接拷贝到另一个字节中。
📖拷贝构造函数可以不写嘛?
通过上面的分析可以得出:编译器自己生成的构造函数对内置类型和自定义类型都做了处理。那是不是意味着我们就可以不写拷贝构造函数了呢?答案是否定的,对于日期类,我们确实可以不写,用编译器自己生成的,但是对于一些需要深拷贝的对象,构造函数是非写不可的。栈就是一个典型的需要我们自己写构造函数的例子
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType *_array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
上面定义了一个栈类Stack,我们没有写它的拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数,栈中的成员变量都是内置类型,默认的拷贝构造函数会对这三个成员变量都完成值拷贝(浅拷贝)。
此时浅拷贝的问题在于:对象s1和对象s2中的_array存的是同一块空间的地址,他俩指向了同一块空间,当程序退出,往s1或s2中的任意一个对象push值,另一个也会跟着改变。s1和s2要销毁,s2先销毁,s2销毁时调用析构函数,已经将0X11223344这块空间释放了,但是s1并不知道,到s1销毁的时候,会将0X11223344这块空间再释放一次,一块内存空间多次释放,最终就会导致程序崩溃。
📖深拷贝
通过上面的分析可以看出,简单的浅拷贝不能满足栈的需求,因此,对于栈,我们需要自己写一个拷贝构造函数,来实现深拷贝,深拷贝就是去堆上重新申请一块空间,把s1中_array指向的空间中的内容,拷贝到新申请的空间,再让s2中的_array指向该空间。
//自己写的拷贝构造函数,实现深拷贝
Stack(const Stack& st)
{
DataType* tmp = (DataType*)malloc(sizeof(DataType) * st._capacity);
if (nullptr == tmp)
{
perror("malloc申请空间失败");
return;
}
memcpy(tmp, st._array, sizeof(DataType) * st._size);
_array = tmp;
_size = st._size;
_capacity = st._capacity;
}
📖总结:
类中如果没有涉及资源申请时,拷贝构造函数写不写都可以;一旦涉及到资源申请时,拷贝构造函数是一定要写的,否则就是浅拷贝,最终析构的时候,就会释放多次,造成程序崩溃。
📖拷贝构造函数典型的调用场景:
- 使用已存在对象创建新对象。
- 函数参数类型为类类型对象。
- 函数返回值为类类型对象。
class Data
{
public:
Data(int year = 1, int month = 1, int day = 1)
{
cout << "调用构造函数:" << this << endl;
cout << endl;
_year = year;
_month = month;
_day = day;
}
Data(const Data& d)
{
cout << "调用拷贝构造:" << this << endl;
cout << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
~Data()
{
cout << "~Data()" << this << endl;
cout << endl;
}
private:
int _year;
int _month;
int _day;
//可以不用写析构,因为全是自定义类型,并且没有动态申请的空间,这三个成员变量会随着对象生命周期的结束而自动销毁
};
Data Text(Data x)
{
Data tmp;
return tmp;
}
int main()
{
Data d1(2023, 4, 29);
Text(d1);
return 0;
}
📖总结:
自定义类型在传参的时候,形参最好用引用来接收,这样可以避免调用拷贝构造函数,尤其是深拷贝的时候,会大大的提高效率,函数返回时,如果返回的对象在函数栈帧销毁后还在,最好也用引用返回。
14.赋值运算符重载
将以日期类为基础,去探寻运算符重载的特性与使用方法,下面先给出日期类的基础定义:
class Date
{
public:
Date::Date(int year, int month, int day)
{
if (month > 0 && month <= 12
&& day > 0 && day <= GetDay(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
assert(false);
}
}
private:
int _year;//年
int _month;//月
int _day;//日
};
备注:拷贝构造函数和析构函数,均可以不写,因为当前日期类的三个成员变量都是内置类型,没有动态申请空间,使用浅拷贝就可以。
14.1运算符重载
📖如何比较两个日期的大小?
int main()
{
Date d1(2023, 7, 21);
Date d2(2023, 6, 21);
return 0;
}
现如今,定义了两个日期类的对象d1和d2,该如何比较这两个对现象的大小呢?首先想到的是,写一个函数来比较他俩的大小,向下面这样:
//以小于比较为例
bool Less(const Date& x, const Date& y)
{
if (x._year > y._year)
{
return false;
}
else if (x._year == y._year && x._month > y._month)
{
return false;
}
else if (x._year == y._year && x._month == y._month && x._day > y._day)
{
return false;
}
else
{
return true;
}
}
存在的问题:首先这个函数是写在类外面的,意味着,日期类的成员变量如果是private私有的话,在类外面就无法访问,所以在这个函数里面是访问不到对象的年、月、日这三个成员变量,即x._year等都是非法的,要想实现该函数的功能,日期类的成员变量必须是public公有。
其次,在比较两个日期类对象大小的时候,需要写成Less(d1, d2),这和我们平时直接用<符号比较大小,比起来不够直观。
📖为什么日期类不能直接使用<
因为日期类是我们自己定义的,属于一种自定义类型,它的大小比较方式,只有定义它的人知道,而像int、double等内置类型,是祖师爷创造C++语言时就定好的,祖师爷当然知道该如何比较两个内置类型变量的大小,所以提前帮我们设置好了,我们可以直接用<去比较两个内置类型变量的大小,而至于祖师爷是怎么设置的,这里先埋一个伏笔。
📖运算符重载
为了解决上面Less函数存在的问题,C++引入了运算符重载,它可以让我们直接使用<来比较两个日期类的大小。
运算符重载是具有特殊函数名的函数,也具有返回值类型,函数名字、参数列表、返回值类型都和普通函数类似。
- 函数名字:关键字operator后面接需要重载的运算符符号。
- 函数原型:返回值类型 operator操作符(参数列表)
bool operator<(const Date& x, const Date& y)
{
if (x._year > y._year)
{
return false;
}
else if (x._year == y._year && x._month > y._month)
{
return false;
}
else if (x._year == y._year && x._month == y._month && x._day > y._day)
{
return false;
}
else
{
return true;
}
}
上面就是对<运算符的一个重载,它的两个形参是Data类型的引用,此时两个日期类对象就可以直接用<来比较大小啦,d1 < d2本质上就是调用运算符重载函数,但是由于上面的运算符重载函数还是写在类外面,所以当日期类的成员变量是private私有的时候,该运算符重载函数还是用不了。
//下面两条语句是等价的本质都是调用运算符重载函数
d1 < d2;
operator<(d1, d2);//d1 < d2的本质
📖将运算符重载函数写成成员函数
为了解决上面的私有成员变量在类外面无法访问的问题,可以把运算符重载函数写成类的成员函数或者友元,这样就能访问到私有的成员变量,但是友元一般不建议使用,因为友元会破坏封装。
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;
}
}
上面就是把<运算符重载成类的成员函数,此时参数只有一个,因为<是一个双目运算符,类的非静态成员函数有一个隐藏的形参this指针,所以形参就只需要一个。
//它们俩是等价的
d1 < d2;
d1.operator<(d2);//d1 < d2的本质
小Tips:一个双目运算符如果重载成类的成员函数,会把它的左操作数传给第一个形参,把右操作数传给第二个形参。以上面为例,this指针接收的是d1的地址,d接收的是d2。
📖注意事项:
- 不能通过连接其他符号来创建新的运算符:比如operator@。
- 重载操作符必须有一个类类型参数。
- 用于内置类型的运算符,其含义不能改变,例如:内置的+,不能改变其含义。
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this。
- .*、::、sizeof、? :、.这五个运算符不能重载。
14.2赋值运算符重载
📖区分赋值运算符重载和拷贝构造
Date d1(2020, 5, 21);
Date d2(2023, 6, 21);
d1 = d2;//需要调用赋值运算符重载
Date d3 = d1;//这里是调用拷贝构造函数
//Date d3(d1);//和上一行等价调用拷贝构造
要区分赋值运算符重载和拷贝构造,前者是针对两个已存在的对象,将一个对象的值,赋值给另一个,而后者是用一个已存在的对象去初始化创建一个新对象。
赋值运算符重载格式:
- 参数类型:const T&(T是类型),传引用返回可以提高效率。
- 返回值类型:T&,返回引用可以提高效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this:要符合连续赋值的含义。
Date& operator=(const Data& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;//出了作用域*this还在,所以可以用引用返回
}
📖只能是类的成员函数
上面的<运算符,最开始我们是在类外面把它重载成全局的,后来为了保证类的封装性,才把它重载成类的成员函数,而赋值运算符天生只能重载成类的成员函数,因为赋值运算符重载属于类的默认成员函数,我们不写,编译器会自动生成,所以,如果我们把赋值运算符重载写在类外面,就会和编译器生成的默认赋值运算符重载发生冲突。
📖编译器生成的干了些什么工作?
用户没有显式实现时,编译器生成的默认赋值运算符重载,对内置类型的成员变量是以值的方式逐字节进行拷贝(浅拷贝),对自定义类型的成员变量,调用其对应类的赋值运算符重载。
14.3完善日期类
有了上面的基础,接下来完善一下日期类,重载其他的运算符。
关系运算符有<、>、== 、<=、>=、!=,由于它们之间存在的逻辑关系,可以通过复用来实现,即:要想知道a是否大于b,可以通过判断a是否小于等于b来实现。因此,我们只要写一个<和==的比较逻辑,其他的直接复用即可。
📖重载<
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 Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
📖重载<
bool Date::operator<=(const Date& d)
{
return *this < d || *this == d;
}
📖重载>
bool Date::operator>(const Date& d)
{
return !(*this <= d);
}
📖重载>
bool Date::operator>=(const Date& d)
{
return !(*this < d);
}
📖重载!
bool Date::operator!=(const Date& d)
{
return !(*this == d);
}
📖重载+、+=
有时我们需要知道几天之后的日期,比如我想知道100天后的日期,此时就需要用当前的日期加上100,但是一个日期类型和一个整型可以相加嘛?答案是肯定的,可以通过重载+来实现。运算符重载只规定必须有一个类类型参数,并没有说重载双目操作符必须要两个类型一样的参数。
📖获取某月的天数
日期加天数,要实现日期的进位,即:当当前日期是这个月的最后一天时,再加一天月份就要进一,当当前的日期是12月31日时,再加一天年份就要进一,因此可以先实现一个函数,用来获取当前月份的天数,在每加一天后,判断月份是否需要进位。
int GetDay(int year, int month)//获取某一月的天数
{
static int arr[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
if (month == 2 && (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)))
{
return 29;
}
return arr[month];
}
除了2月,每个月的天数都是固定的,因此可以设置一个数组来存放每个月的天数,并且以月份作为下标,对应存储该月的天数,这种方法类似于哈希映射。这里还有两个小细节,第一个:把数组设置成静态,因为这个函数会重复调用多次,把数组设置成静态,它第一次创建之后,一直到程序结束都还在,可以避免函数调用时重复的创建数组。第二点:把month == 2放在前面判断,因为只有当2月的时候才需要判断是否是闰年,如果不是2月就不用判断是不是闰年。
📖重载+
Date Date::operator+(int x)
{
if(x < 0)//天数为负的时候
{
return *this - (-x);//复用-
}
/Date tmp = *this;
//Date tmp(*this);//和上面等价,都是调用拷贝构造函数
tmp._day = _day + x;
while (tmp._day > GetDay(tmp._year, tmp._month))
{
tmp._day = tmp._day - GetDay(tmp._year, tmp._month);
tmp._month++;
if (tmp._month == 13)
{
tmp._year++;
tmp._month = 1;
}
}
return tmp;//
}
**注意:**要计算a+b的结果,a是不能改变的,因此一个日期加天数,不能改变原本的日期,也就是不能修改this指针指向的内容,所以我们要先利用拷贝构造函数创建一个和*this一模一样的对象,对应上面代码中的tmp,在该对象的基础上去加天数。出了作用域tmp对象会销毁,所以不能传引用返回。
📖重载+=
+=和+很像,区别在于+=是在原来是日期上进行修改,即直接对this指针指向的日期做修改,所以我们对上面的代码稍作修改就可以得到+=。
Date& Date::operator+=(int x)
{
if (x < 0)//当天数为负
{
return *this -= -x;//复用-=
}
_day += x;
while (_day > GetDay(_year, _month))
{
_day = _day - GetDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
小Tips:加一个负的天数,就是算多少天以前的日期,所以,当天数为负的时候,可以复用下面的-=。
📖+和+=之间的复用
可以发现,+和+=的实现方法十分相似,那是否可以考虑复用呢?答案是肯定的,他俩其中的一方都可以去复用另一方。
+去复用+=:
Date Date::operator+(int x)
{
Date tmp = *this;
//Date tmp(*this);//和上面等价,都是调用拷贝构造函数
tmp += x;
return tmp;//
}
+=去复用+:
Date& Date::operator+=(int x)
{
*this = *this + x;//这里是调用赋值运算符重载
return *this;
}
注意:上面的两种复用,只能存在一个,不能同时都去复用,同时存在会出现你调用我,我调用你的死穴。
既然只能存在一个,那到底该让谁去复用呢?答案是:让+去复用+=。因为,+=原本的实现过程中并没有调用拷贝构造去创建新的对象,而+原本的实现过程中,会去调用拷贝构造函数创建新的对象,并且是以值传递的方式返回的,期间又会调用拷贝构造。如果让+=去复用+,原本还无需调用拷贝构造,复用后反而还要调用拷贝构造创建新对象,造成了没必要的浪费。
📖重载-、
有时我们也需要知道,多少天以前的日期,此时就需要重载-,它的两个操作数分别是日期和天数,其次,我们有时还想知道两个日期之间隔了多少天,这也需要重载-,但此时的两个操作数都是日期。两个-重载构成了函数重载。
📖重载日期-天数
有了上面的经验,我们可以先重载-=,再让-去复用-=即可,日期减天数,就是要实现日期的借位。
Date Date::operator-(int x)
{
Date tmp(*this);
return tmp -= x;//复用-=
}
📖重载-
Date& operator-=(int x)
{
if (x < 0)//天数天数小于0
{
return *this += -x;//复用+=
}
_day -= x;
while (_day <= 0)
{
_month--;
if (_month == 0)
{
_month = 12;
_year--;
}
_day += GetDay(_year, _month);
}
return *this;
}
📖重载日期-日期
日期-日期,它的形参是一个日期对象,计算的结果是两个日期之间的天数,所以返回值是int,要像知道两个日期之间相隔的天数,可以设置一个计数器,让小日期一直加到大日期,就可以知道两个日期之间相隔的天数。
int operator-(const Date& d)
{
Date max = *this;//存放大日期
Date min = d;//存放小日期
int flag = 1;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (max != min)
{
--max;
++n;
}
return n * flag;
}
📖重载++、
++、–操作符,无论前置还是后置,都是一元运算符,为了让前置和后置形成正确的重载,C++规定:后置重载的时候多增加一个int类型的参数,但是当使用后置,调用运算符重载函数时该参数不用传递,编译器自动传递。
前置++
//前置++,返回++之后的值
Date& Date::operator++()
{
return *this += 1;//直接复用+=
}
后置++
//后置++,返回加之前的值
Date Date::operator++(int)//编译器会把有int的视为后置++
{
Date tmp(*this);
*this += 1;//复用+=
return tmp;
}
前置–
Date& operator--()
{
return *this -= 1;//复用了-=
}
后置–
Date operator--(int)
{
Date tmp(*this);
*this -= 1;//复用了-=
return tmp;
}
对比前置和后置可以发现,后置会调用两次拷贝构造函数,一次在创建tmp的时候,另一次在函数返回的时候。而前置则没有调用拷贝构造,所以前置的效率相比后置会高那么一点。
📖重载<<、>>
同理,对于自定义类型,编译器仍然不知道如何打印,所以要想通过<<去直接打印日期类对象,需要我们对<<运算符进行重载。
📖重识cout、cin
我们在使用C++进行输入输出的时候,会用到cin和cout,它们俩本质上都是对象,cin是istream类实例化的对象,cout是ostream类实例化的对象。
内置类型可以直接使用<<、>>,本质上是因为库中进行运算符重载。而<<、>>不用像C语言的printf和scanf那样,int对应%d,float对应%f,是因为运算符重载本质上是函数,对这些不同的内置类型,分别进行了封装,在运算符重载的基础上又实现了函数重载,所以<<、>>支持自动识别类型。
📖<<为什么不能重载成成员函数
要实现对日期类的<<,要对<<进行重载。但是<<和其他的运算符有所不同,上面重载的所有运算符,为了保证类的封装性,都重载成了类的成员函数,但是<<不行,因为我们平时的使用习惯是cout << d1,前面说过,对于一个双目运算符的重载,它的左操作数会传递给运算符重载函数的第一个形参,右操作数会传递给运算符重载函数的第二个形参,也就是说cout会传递给第一个形参,日期类对象d2会传递给第二个形参,如果运算符重载函数是类的成员函数的话,那么它的第一个形参是默认的this指针,该指针是日期类类型的指针,和cout的类型不匹配,当然也有解决办法,那就是输出一个日期类对象的时候,写成d1 << cout,此时就相当于d1.operator(cout),会把d1的地址传给this指针,形参再用一个ostream类型的对象来接收cout即可,但是这样的使用方式,显然是不合常理的。
📖将<<重载成全局函数
正确的做法是,把<<重载成全局函数,此时函数形参就没有默认的this指针,我们可以根据需要来设置形参的顺序,第一个形参用ostream类对象来接收cout,第二个形参用Date日期类对象来接收d1。
//重载成全局的
ostream& operator<< (ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
注意:形参out不能加const修饰,因为我们就是要往out里面写东西,加了const意味着out不能修改。其次为了实现连续的输出,返回值是ostream类型的对象out,因为此时出了作用域out还在,所以可以用引用返回。
因为该运算符重载函数写在全局,默认情况下,在该函数内部是无法访问到日期类的私有成员变量,为了解决这个问题,可以把该运算符重载函数设置成友元函数,或者在类里面写私有成员变量的Get方法(Java常用)。
friend ostream& operator<< (ostream& out, Date& d);
友元函数只需要配合上friend关键字,在日期类里面加上一条声明即可,此时在该函数体就可以使用对象中的私有成员变量。该声明不受类中访问限定符的限制。
📖重载>>
同理,>>也应该重载成全局的。
istream& operator>> (istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
注意:两个形参in和d都不能用const修饰,前者是因为in本质上是一个对象,在进行流插入的时候,会改变对象里面的一些状态值,而后者是因为,我们就是希望通过流插入往d里面写入数据,所以也不能加const修饰。
小Tips:C++中的流插入和流提取可以完美的支持自定义类型的输入输出,而C语言的scanf和printf只能支持内置类型,这就是C++相较于C语言的一个优势。
15.const成员
将const修饰的成员函数称为const成员函数,const修饰类的成员函数,实际上修饰的是该成员函数隐含的this,表明该成员函数中不能修改调用该函数的对象中的任何成员。这样一来,不仅普通对象可以调用该成员函数(权限的缩小),const对象也能调用该成员函数(权限的平移)。经过const修饰的成员函数,它的形参this的类型就是:const T const this。
bool Date::operator<(const Date& d) const//用const修饰
{
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;
}
}
对于所有的关系运算符重载函数,都应该加const修饰,因为它们不会改变对象本身。
📖总结:
并不是所有的成员函数都要加const修饰,要修改对象成员变量的函数,是不能加const修饰的,例如:重载的+=、-=等,而成员函数中如果没有修改对象的成员变量,可以考虑加上const修饰,这样不仅普通对象可以调用该成员函数(权限的缩小),const对象也能调用该成员函数(权限的平移)。
16.取地址及const取地址操作符重载
Date* operator&()
{
cout << "Date* operator&()" << endl;
return this;
}
const Date* operator&() const
{
cout << "const Date* operator&() const" << endl;
return this;
}
int main()
{
Date d1(2023, 7, 22);
const Date d2(2023, 7, 22);
cout << &d1 << endl;
cout << "--------" << endl;
cout << &d2 << endl;
return 0;
}
这俩取地址运算符重载函数,又构成函数重载,因为它们的默认形参this指针的类型不同,一个用const修饰了,另一个没有。const对象会去调用const修饰的取地址运算符重载函数。
小Tips:这两个&重载,属于类的默认成员函数,我们不写编译器会自动生成,所以这两个运算符重载一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容。
17.再谈构造函数
17.1构造函数体赋值
在创建对象的时候,编译器通过调用构造函数,在构造函数体中,给对象中的各个成员变量一个合适的初值。
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
虽然上述构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。
17.2初始化列表
📖定义:
初始化列表:以一个冒号开始,接着是以一个逗号分割的数据成员列表,每个成员变量后面跟一个放在括号中的初始值或表达式,初始化列表是构造函数的一部分。
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
小Tips: 每个成员变量在初始化列表中最多只能出现一次(初始化只能初始化一次)。出了下面提到的三个类型的成员变量外,其他的成员变量可以不出现在初始化列表中,此时编译器对内置类型(没有默认值的情况下)不做处理(一般是随机值),对自定义类型会调用它的默认构造,内置类型如果给了默认值,则编译器会使用这个默认值。
📖必须经过初始化列表
类中包含以下成员,必须放在初始化列表进行初始化:
- 引用成员变量
- const成员变量
- 自定义类型成员(且该类没有默认构造函数)
其中引用成员变量和const成员变量,都有一个共同的特征:必须在定义的时候初始化。初始化列表就是对象中成员变量定义的位置。
class A
{//A类中没有默认构造函数
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
class B
{
public:
B(int a, int& ref)
:_aobj(a)
,_ref(ref)
,_n(10)
{}
private:
A _aobj; // 没有默认构造函数
int& _ref; // 引用
const int _n; // const
};
小Tips:尽量使用初始化列表初始化,因为不管是否使用初始化列表,对于自定义类型的成员变量,一定会先使用初始化列表初始化。
📖初始化列表能代替函数体内赋值嘛?
class Stack
{
public:
Stack(int capacity = 10)
:_top(0)
, _capacity(capacity)
, _a((int*)malloc(_capacity*sizeof(int)))
{
//下面这些功能都是初始化列标无法完成的,因此需要用到构造函数的函数体
if (_a == nullptr)`在这里插入代码片`
{
perror("malloc fail");
exit(-1);
}
cout << 11111111111111111111 << endl;
memset(_a, 0, _capacity * sizeof(int));//把空间中的数据全部设置为0
}
private:
int* _a;
int _top;
int _capacity;
};
如上面的代码所示,初始化列表并不能完成所有工作,有时候对于成员变量不仅要完成初始化,还要对初始化的结果进行合理性检查等操作,因此初始化列表不能代替函数体内赋值。
📖初始化顺序
成员变量在类中的声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print() {
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
上面代码中,因为A类中成员变量的声明顺序是_a2、_a1,所以在初始化列表中先去初始化_a2,但是_a2是用_a1来初始化的,_a1此时还没有被初始化,所以是随机值,接下来再去用a初始化_a1,所以最终打印出来的结果_a1是1,而_a2是随机值。
17.3explicit关键字
构造函数不仅可以构造与初始化对象,对于单个参数或除第一个参数无缺省值其余均有默认值的构造函数,还有类型转换的作用。
class A
{
public:
A(int x)
:_a(x)
{
cout << "A(int x)" << endl;
}
A(const A& x)
:_a(x._a)
{
cout << "A(const A& x)" << endl;
}
private:
int _a;
};
int main()
{
A a2 = 2;
return 0;
}
上面代码中,A类只有一个单参数的构造函数,因此该构造函数是支持隐式类型转换的,A a2 = 2;本质上就是隐式类型转换,把一个整型2,转换成自定义类型A。具体过程是:首先在隐式转换过程中会产生一个临时的中间变量,这里就是用2去调用构造函数,得到一个A类型的临时中间变量,然后再用这个A类型的中间变量去调用拷贝构造,最终完成a2的创建。一般比较新的编译器,对这种连续的调用构造、拷贝构造进行了优化,会用2去调用构造函数完成a2的创建。
打印的结果确实说明只调用了构造函数。此时可能会有朋友产生怀疑了,觉得A a2 = 2;本来就是直接用2去调用构造函数创建a2,压根不存在什么先创建临时的中间变量,别急,可以通过引用来验证。
这里我们把一个整型3,赋值给一个A类型的引用,起初我们没加const程序报错了,后面加上const程序没有报错。为什么?就是因为这里会首先用3去调用构造函数,创建一个A类型的临时中间变量,前面的文章说过,临时的中间变量具有常性,这里的a3就是这个临时中间变量的别名,所以要在a3的前面加上const进行修饰。
📖使用场景
//string是字符串类
string name1("张三");
//直接构造
string name2 = "张三";
//构造+拷贝构造,优化成构造
class list
{
public:
void push_back(const string& str)
{}
};
int main()
{
list l1;
string name2("李四");
l1.push_back(name2);
l1.push_back("李四");
return 0;
}
如上面的代码,我们在插入值的时候,因为push_back函数的参数是string类型的对象引用,意味着要插入一个string类型的对象,如果不支持隐式类型转化,在插入string对象的过程中,我们就要先创建一个string类型的对象,然后再去插入,支持隐式类型转换的话,我们就无需创建string类型的对象,而是直接把一个字符串插入,就像l1.push_back(“李四”);这样,先用"李四"创建一个临时的中间变量,临时中间变量具有常性。此时就体现出了,在不修改对象的情况下,给形参加上const的优越性。push_back函数的形参用引用,是为了避免调用拷贝构造,一旦碰到形参是引用的,就要仔细考虑要不要加const进行修饰,引用和权限问题永远是并存的。
📖explicit关键字
如果想要禁止上面提到的隐式类型转换,可以在构造函数的前面加上explicit关键字进行修饰
class A
{
public:
explicit A(int x)
:_a(x)
{
cout << "A(int x)" << endl;
}
private:
int _a;
};
此时就不能把一个整型赋值给A类型的对象。智能指针就不希望发生这种隐式类型转换,具体的我们后面再说。
18.static成员
📖先看一个场景
有一个A类,现在要统计程序中正在使用的A类型的对象有多少个,即当前程序中,创建后没有被销毁的A对象的个数。
很多朋友第一时间想到的就是定义一个全局的整型变量并初始化为0,然后在构造函数中++,在析构函数中–,像下面这样:
int _scount = 0;//全局的变量用来统计个数
class A
{
public:
A()
{
cout << "A()" << endl;
++_scount;
}
A(const A& t)
{
cout << "A(const A& t)" << endl;
++_scount;
}
~A()
{
cout << "~A()" << endl;
--_scount;
}
public:
int _a = 10;
};
A a1;//第一个,调用的普通构造
A Func(A aa)//形参是类对象,则需要调用拷贝构造//第四个
{
cout << __LINE__ << ":" << _scount << endl;
return aa;
}
int main()
{
cout << __LINE__ << ":" << _scount << endl;
A a2;//第二个,调用的普通构造
static A a3;//第三个,调用的普通构造
Func(a3);
//函数传值返回会创建一个临时的中间变量,也是调用拷贝构造,用现有的aa对象去创建一个新的对象,所以是拷贝构造//第五个
cout << __LINE__ << ":" << _scount << endl;
return 0;
}
注意:全局对象先于局部对象进行构造,局部对象按照出现的顺序进行构造,无论是否为static,析构的顺序是按照析构造的相反顺序析构,只需注意static会延长对象的生命周期,所以会放在局部对象之后进行析构。
上面这种方法可以帮我们统计出当前程序中“存活”的A类对象,但是也有一个缺陷,这里的计数器变量_scount 是一个全局的,意味着我们可以在程序中的任何地方对它进行修改,这样就会导致我们统计出来的数量不准确。
为了解决上面的问题,我们可以考虑用C++的封装性,即把这个计数器变量_scount 变成A类的静态成员变量。
class A
{
public:
A()
{
cout << "A()" << endl;
++_scount;
}
A(const A& t)
{
cout << "A(const A& t)" << endl;
++_scount;
}
~A()
{
cout << "~A()" << endl;
--_scount;
}
public:
int _a = 10;
static int _scount;
};
📖概念
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称为静态成员变量;用static修饰的成员函数,称之为静态成员函数。
📖静态成员变量的特点
- 静态成员变量为所有类对象所共享,不属于某一个具体的对象,存放在静态区。
- 静态成员变量必须在类外面定义,定义时不加static关键字,类中只是声明。
- 公有的静态成员变量可以通过类名::静态成员变量或者对象.静态成员变量来访问。
- 静态成员变量在类中声明的时候不能给默认值,因为这个默认值本质上是给初始化列表来使用的,而静态成员变量是不走初始化列表的。
注意:静态成员变量是不走初始化列表的,初始化列表是对象中每个成员变量初始化的地方,而静态成员变量是属于这个类的,不属于某一个具体的对象。
//静态成员变量在类外面定义
int A::_static = 0;
//访问静态成员变量的两种方法
A num;//声明一个A类型的对象
num._static;//通过对象去访问
A::_static//通过类直接去访问
📖静态成员函数的特点
- 静态成员函数没有隐藏的this指针,在静态成员函数中不能访问任何非静态的成员,即在静态成员函数中只能访问静态成员。
- 公有的静态成员函数可以通过类名::静态成员函数或者对象.静态成员函数来访问。
📖静态成员函数的经典应用场景
一般在不加限制的情况下,我们用自定义类型去创建对象,可以在静态区创建,也可以在栈区创建,还可以在堆区创建,那我现在就希望创建出来的对象在栈区或堆区该怎么做呢?
class A
{
public:
static A GetStackObj()
{
A a1;
return a1;
}
static A* GetHeapObj()
{
return new A;
}
private:
A()
{}
private:
int _a1 = 1;
int _a2 = 2;
};
int main()
{
//static A aa1;//在静态区
//A aa2;//在栈区
//A* pa3 = new A;//在堆区
A aa1 = A::GetStackObj();
A* pa2 = A::GetHeapObj();
}
以A类为例,首先我们可以把它的构造函数设置成private私有,此时在类外面就无法直接创建A类对象,然后我们在A类里面写两个成员函数GetStackObj()和GetHeapObj(),分别在栈区和堆区创建对象,因为在类里面是不受类域和访问限定符的限制,经过这样一番操作后,我们调用GetStackObj()函数就是在栈区创建对象,调用GetHeapObj()就是在堆区创建对象。但是,问题来了,如果这两个成员函数是非静态的,那想要调用这两个成员函数,必须通过对象.成员函数才能去调用,可是现在构造函数是私有的,我们无法在类外面创建对象,那就意味着我们无法调用这两个成员函数,此时静态成员函数的优势就体现出来了,我们可以把这两个成员函数设置成静态成员函数,这样我们就可以通过类名::静态成员函数去调用这两个函数。
19.友元
友元提供了一种突破封装的方式,有时会提供便利,但是友元会增加耦合度,破坏封装,友元就像是走后门,只要你是我的朋友,你就能访问我的私有成员,这显然是不太公平的,所以友元不易多用。友元分为:友元函数和友元类。
19.1友元函数
重载的<<和>>函数就是友元函数,利用了友元突破封装的特性。
📖友元函数的特点
- 友元函数可以访问类的私有和保护成员,但不是类的成员函数。
- 友元函数不能用const修饰。
- 友元函数可以在类定义的任何地方声明,不受访问限定符限制。
- 一个函数可以是多个类的友元函数。
- 友元函数的调用与普通函数的调用原理相同。
19.2友元类
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员。
📖友元类的特点
- 友元关系是单向的,不具有交换性。
- 友元关系不能传递,即如果B是A的友元,C是B的友元,则不能说明C是A的友元。
- 友元关系不能继承。
class Time
{
friend class Date;// 声明日期类为时间类的友元类,则在日期类中就直接访问Time类中的私有成员变量
public:
Time(int hour = 0, int minute = 0, int second = 0)
: _hour(hour)
, _minute(minute)
, _second(second)
{}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{}
void SetTimeOfDate(int hour, int minute, int second)
{
// 直接访问时间类私有的成员变量
_t._hour = hour;
_t._minute = minute;
_t._second = second;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
如上面的Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但是想在Time类中访问Date类中的私有成员变量则不行。
20.内部类
定义:如果一个类定义在另一个类的内部,这个类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。
注意:内部类天生就是外部类的友元,内部类可以通过外部类的对象来访问外部类中的所有成员。
特性:
- 内部类受访问限定符的限制,内部类可以定义在外部类的public、protected、private都是可以的。
- 内部类中可以直接访问外部类中的static成员,不需要外部类的对象/类名。
- sizeof(外部类)等于外部类的大小,即大小只和外部类的非静态成员变量有关,和内部类没有任何关系。
class A
{
private:
static int k;
int h = 0;
public:
class B // B天生就是A的友元
{
public:
void foo(const A& a)
{
cout << "直接访问A类中的静态成员k:" << k << endl;//ok
//cout << h << endl;不ok
cout << "通过域作用限定符访问A中的静态成员k:" << A::k << endl;//OK
cout << "通过A类对象访问A中的静态成员k:" << a.k << endl;//OK
cout << "通过A类对象访问A中的非静态成员h:" << a.h << endl;//OK
}
};
};
int A::k = 1;
int main()
{
A a;
A::B b;//如果B类是私有的那这里就不能访问。
b.foo(a);
cout << "A类的大小:" << sizeof(A) << endl;
return 0;
}
21.匿名对象
匿名对象简单理解就是创建一个没有名字的对象,普通对象的创建过程是:类名后面跟要创建的对象名再跟参数列表,就像A aa(1),创建一个名叫aa的A类型对象。而匿名对象的创建则是,类名后面直接跟参数列表,像A(1)这样。
📖匿名对象的特点
- 普通匿名对象的生命周期只有一行,即用即销毁。
- 创建匿名对象无参时要带括号。
- 匿名对象具有常性。
- 引用可以延长匿名对象的生命周期,生命周期在当前函数的局部域。(注意:因为匿名对象具有常性,所以一个匿名对象的引用必须时常引用),引用相当于给这个匿名对象取了个名字,使得它可以存活的更久一点。
📖匿名对象的使用场景 - 利用匿名对象去调用函数
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
};
int main()
{
//利用匿名对象去调用函数
Solution().Sum_Solution(10);
return 0;
}
在Solution类中定义了一个函数Sum_Solution,该函数是一个非静态成员函数,此时如果想要调用这个函数就必须通过对象.才能去调用,因此整个过程分两步,首先要创建一个Solution类型的对象,再去用这个对象调用类里面的函数,而有了匿名对象这个概念之后,可以简化前面的操作,我们就可以在创建匿名对象的同时,通过这个匿名对象去调用类里面的函数。
- 匿名对象做实参
void Push_Back(const string& str)
{
}
int main()
{
//普通做法
string s1("1111111");
Push_Back(s1);
//匿名对象做实参
Push_Back(string("222222"));
//利用隐式类型转换
Push_Back("2222222");
return 0;
}
22.拷贝对象的一些编译器优化
在函数调用传值和返回的过程中,一般编译器会做一些优化,减少对象的拷贝。
📖先给出A类的定义
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
:_a(aa._a)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a = aa._a;
}
return *this;
}
~A()
{
//cout << "~A()" << endl;
}
private:
int _a;
};
📖案例一
void f1(A aa)
{
cout << "void f1(A aa)" << endl;
}
void f1(const A aa)
{
cout << "void f1(const A aa)" << endl;
}
上面的两个f1函数不会构成重载。
📖案例二
void f1(A& aa)
{
cout << "void f1(A& aa)" << endl;
}
void f1(const A& aa)
{
cout << "void f1(const A& aa)" << endl;
}
int main()
{
A a;
f1(a);
const A b;
f1(b);
return 0;
}
上面两个f1函数构成重载,并且在调用的时候不会出现歧义。
📖案例三
void f1(A aa)
{
cout << "void f1(A aa)" << endl;
}
void f1(A& aa)
{
cout << "void f1(const A aa)" << endl;
}
int main()
{
A a;
f1(a);
return 0;
}
上面的两个f1函数在语法上构成重载,但是在调用的时候会出现歧义。
📖案例四
void f1(A aa)
{
cout << "void f1(A aa)" << endl;
}
void f1(const A& aa)
{
cout << "void f1(const A aa)" << endl;
}
int main()
{
A a;
f1(a);
const A b;
f1(b);
return 0;
}
上面的两个f1函数在语法上构成重载,但是在调用的时候会出现歧义。
📖案例五
A Func3()
{
A aa;
return aa;
}
int main()
{
Func3();
return 0;
}
上面代码虽然只在函数Func3中显式的创建了一个A类型的对象,但是通过打印结果可以看出,他调用了两次构造函数,并且析构了两次。这是因为Func3函数是传值返回,会生成一个临时的中间变量,这里就是用aa去调用拷贝构造,生成一个和aa一模一样的临时中间变量。
📖案例六
A& Func3()
{
static A aa;
return aa;
}
int main()
{
Func3();
return 0;
}
案例六由于函数Func3是传引用返回,这里返回的就是aa对象的别名,不会生成临时的中间变量,所以和案例五相比,少调用了一次拷贝构造函数,但前提是:Func3函数返回的对象,在该函数栈帧销毁后还存在。
📖案例七
A Func5()
{
A aa;
return aa;
}
int main()
{
A ra = Func5();
return 0;
}
案例六由于函数Func3是传引用返回,这里返回的就是aa对象的别名,不会生成临时的中间变量,所以和案例五相比,少调用了一次拷贝构造函数,但前提是:Func3函数返回的对象,在该函数栈帧销毁后还存在。
结合案例五来分析,单纯的调用Func5函数应该就会去调用两次构造函数,分别是创建aa时调用构造函数和创建函数返回过程中产生的临时中间变量时调用拷贝构造函数,接着还要用函数的返回值去调用拷贝构造函数创建ra对象,但是从打印结果中可以看出,实际上只调用了一次构造和一次拷贝构造,和我们分析的有所不同,其实呀,这就是编译器对这种连续的拷贝构造进行了优化,优化后直接用aa对象去调用拷贝构造函数创建ra。
结合案例五来分析,单纯的调用Func5函数应该就会去调用两次构造函数,分别是创建aa时调用构造函数和创建函数返回过程中产生的临时中间变量时调用拷贝构造函数,接着还要用函数的返回值去调用拷贝构造函数创建ra对象,但是从打印结果中可以看出,实际上只调用了一次构造和一次拷贝构造,和我们分析的有所不同,其实呀,这就是编译器对这种连续的拷贝构造进行了优化,优化后直接用aa对象去调用拷贝构造函数创建ra。
📖案例八
void Func1(A aa)
{}
int main()
{
A a1;
Func1(a1);
cout << "========" << endl;
Func1(A(2));
cout << "========" << endl;
Func1(5);
cout << "========" << endl;
A a2 = 6;
return 0;
}
📖案例九
A Func5()
{
A aa;
return aa;
}
int main()
{
A a1 = Func5();
cout << "========" << endl;
A a2;
a2 = Func5();
return 0;
}
对比两次函数调用,第二次函数调用编译器没有进行任何优化,效率大打折扣,我们平时要尽量避免这种函数调用方式。
23.一图理解类和对象
类是对某一类实体(对象)进行描述的,描述该实体(对象)具有哪些属性,哪些功能(方法),描述完成后就形成了一种新的自定义类型,用该自定义类型就可以实例化具体的对象。
💘不知不觉,【C++初阶】一篇手撕类与对象 学习告一段落。通读全文的你肯定收获满满,让我们继续为C++学习共同奋进!!!