一、前言
C++是面向对象的语言,本文将通过上、中、下三大部分,带你深入了解类与对象。
目录
[函数三: 拷贝构造函数](#函数三: 拷贝构造函数)
[结束语 :中](#结束语 :中)
[2. 再谈构造函数](#2. 再谈构造函数)
二、部分:上
本部分主要有以下内容:
1. 面向过程和面向对象初步认识
2. 类的引入
3. 类的定义
4. 类的访问限定符及封装
5. 类的作用域
6. 类的实例化
7. 类的对象大小的计算
8. 类成员函数的 this 指针
1.面向过程和面向对象初步认识
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
就拿洗衣服而言,C语言是下面的步骤:
C++则是面向的对象,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
特别注意一点,这里的面向对象,指的不是男女朋友的对象!
2.类的引入
在学习C语言时,我们学习了很多结构,eg:栈、堆、队列等等,在实现这些结构时,都是借助了struct,那在C++中,祖师爷为了让结构更好的使用,于是优化了结构,引入了类,因此C++的类就是优化过的结构体。
C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义成员变量,也可以定义成员函数。比如:
之前在数据结构初阶中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现,会发现struct中也可以定义函数。
下面来看一个Stack类的例子。
cpp
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;
}
可以看到在主函数中,我们直接使用Stack建立了一个对象s,而不是利用struct stack,这就是类的使用方法。Stack是类名,我们可以直接借助类名去建立对象。
当然,struct是C语言的写法,在C++中,上面结构体的定义,在C++中更喜欢用class来代替struct。
3.类的定义
上面已经初了解了class关键字,下面就在这一部分,对这一关键字进行更深入的学习。
cpp
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号(类比结构) 命名空间不可以有分号
class为定义类的关键字 ,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略(类比struct结构,分号不呢个省略,但是命名空间不是类,后面没有分号!)。
类体中内容称为类的成员:类中的变量称为类的属性或成员变量 ; 类中的函数称为类的方法或者成员函数。
类的两种定义方式:
- 声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理(内联函数后面讲解)。
其中showinfo函数就被定义在类中。
- 类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::
在一般情况下,是更建议采用第二种方式的!
成员变量命名规则的建议:
观察此代码,可以看到,我们将成员变量全部用 _ 修饰过了,为什么这么干呢?
cpp
// 我们看看这个函数,是不是很僵硬?
class Date
{
public:
void Init(int year)
{
// 这里的year到底是成员变量,还是函数形参?
year = year;
}
private:
int year;
};
// 所以一般都建议这样
cpp
class Date
{
public:
void Init(int year)
{
_year = year;
}
private:
int _year;
};
// 或者这样
class Date
{
public:
void Init(int year)
{
mYear = year;
}
private:
int mYear;
};
// 其他方式也可以的,主要看公司要求。一般都是加个前缀或者后缀标识区分就行
对于某些公司会采用 _,某些公司则会采用m修饰。
**4.**类的访问限定符及封装
对于上述的代码,或许有些地方让你感到疑惑:private是什么?public又是什么?
那就要先介绍以下什么是访问限定符
访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选
择性的将其接口提供给外部的用户使用
【访问限定符说明】
-
public修饰的成员在类外可以直接被访问
-
protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)一般内部成员变量是私有的
-
访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
-
如果后面没有访问限定符,作用域就到 } 即类结束。
-
class的默认访问权限为private ,struct为public(因为struct要兼容C)
注意:访问限定符只在编译时有用 ,当数据映射到内存后 ,没有任何访问限定符上的区别
public和private是共有、私有的意思。
并且,在类的内部,不受访问限定符的限制。
如何理解这句话呢?假设一个类是一块山地,这块山地是你家的,那山上的任何东西就都是你的,内部成员不受限制!
问题:C++中struct和class的区别是什么?
解答:C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是public,class定义的类默认访问权限是private。注意:在继承和模板参数列表位置,struct和class也有区别,后序给大家介绍。
**5.**类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时 ,需要使用 :: 即 作用域操作符指明成员属于哪个类域。
一般{}括起来的都是域(循环、函数....)。
6.类的实例化
我们写的一个个类其实都是一个个不占据空间的符号,如果给其分配空间,那就需要进行类的实例化。在C语言的结构中,也是类似这样的!
我们把类比作工程图纸,那么类的实例化,才是利用图纸盖大楼的过程。因此只有类是无法完成工作需求的,完成任务的是类的实例化过程!
其次,一个类可以多次进行实例化,得到多个对象,类就好比是对象的模板。实例化出的对象, 占用实际的物理空间,存储类成员变量。那次是如何占据空间的问题就来了,既然实例化会给类的对象分配空间,那空间是如何分配的呢?
**7.**类的对象大小的计算
cpp
class Person
{
public:
void ShowInfo();
private:
int _age;
char _name;
};
观察这个Person类,类的内部由两部分组成,分别是public的成员函数和private的两个成员变量,那计算这个类的大小,sizeof一下之后,大小是多少呢?
答案是 8
其实计算规则跟C语言的结构计算规则是一样的。
两大规则:
1.存在内存对齐
2.整体的大小是最大对齐数的整数倍。
结构体内存对齐规则
-
第一个成员在与结构体偏移量为0的地址处。
-
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8
-
结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取小)的整数倍。
-
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整
体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
那为什么函数没有算入类的大小呢?
这其实与存储的区域有关,函数并没有存储在类内部,而是存储在一个叫公共代码区的地方。
为什么这么说呢?这次不把类比作一个山头,将类比作你的家,那么函数就好比是小区里的一些公共设施(泳池、篮球场......),设想,如果家家户户都有一个篮球场,那是不是就会太占用面积了,这种公共设施只需要在小区有一个可供大家使用的就好了,没必要家家户户一个。
一次C++的类的大小计算就可以这样理解,既然函数是大家的,那为什么非得纳入某户家庭呢?这样就会太占内存。
当然,全局函数也是这么类比的。
结论 :一个类的大小,实际就是该类中**"成员变量"之和** ,当然要注意内存对齐
注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。
那我们来看这个类
cpp
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1(){}
private:
int _a;
};
这可以说一个是比较健全的类,既有函数,也有变量。
再看下面的类
cpp
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 类中什么都没有---空类
class A3
{};
这两个类,类的内部并没有存储内容,但是这两个也是确确实实存在的类,也可以进行实例化,那占多少字节呢?
其实只占1字节。占1字节只是为了一个占位,可以理解为"只是为了证明我存在过"。话说,这真的
像极了你的早恋:不存储数据,只是占位,表示对象存在过。
来看几个常考的题目:
-
结构体怎么对齐? 为什么要进行内存对齐?
-
如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?
-
什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景
对应的答案:
结构体内存对齐规则
-
第一个成员在与结构体偏移量为0的地址处。
-
其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8
-
结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取小)的整数倍。
-
如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整
体大小就是所有最大对齐数**(含嵌套结构体的对齐数)**的整数倍
为什么要内存对齐
结构成员内存对齐是计算机体系结构中的一个特性,它影响结构体和联合体在内存中的布局方式。存在结构成员内存对齐的原因主要有以下几点:
-
性能优化:许多处理器在访问内存时对内存地址的访问有对齐要求 。如果访问的地址是它所支持的数据宽度(例如,4字节、8字节)的整数倍,**那么访问效率会更高。**不对齐的访问可能会导致处理器需要多次内存访问来获取完整的数据,从而降低性能。(空间换时间)
-
兼容性:不同的硬件平台可能对数据的对齐方式有不同的要求。通过在编译时进行结构成员对齐,可以确保同一个程序在不同的硬件平台上都能正确运行。
-
简化内存管理:对齐可以使内存管理更加简单。例如,如果所有数据都是对齐的,那么在分配和释放内存时,就可以使用简单的指针算术,而不需要考虑复杂的数据边界问题。
-
填充(Padding):在某些情况下,为了满足对齐要求,编译器会在结构体的成员之间插入额外的未使用空间,这称为填充。虽然这会浪费一些内存,但通常这是为了提高访问效率而做出的权衡。
-
跨平台通信:在通过网络或文件进行数据交换时,如果发送和接收的双方都遵循相同的对齐规则,那么可以确保数据的兼容性和一致性。\
总之,结构成员内存对齐是为了在性能、兼容性和内存管理之间找到平衡点的一种设计选择。主要目的是为了用"空间换时间"。
对于CPU对数据的处理,并不是任意读取的,
假设这是一块内存单元,每一小块四个字节,既然CPU不是任意读取的,那我们假设每次读取4个字节,那么cpu在读取数据的时候,就可以很高效的读取每一部分的数据。假设不存在内存对齐
同样是上图,假设最开始存储了一个char变量,当再次存储int变量的时候,假设不存在内存对齐,那么int将会直接存在char的下面。当cpu读取的时候,第一次读取到图示位置
当读取int的时候,则需要读取两次,第一次读取上半部分,第二次读取下半部分,读取完之后,还需要进行一次整合,所以这样就大大降低了效率!
上面说到默认对齐数,笔者还想向大家介绍一些关于默认对齐数的知识
修改默认对齐数
在C和C++等编程语言中,结构体的成员变量会按照一定的对齐规则来分配内存,以提高访问效率。这个对齐规则通常由编译器的默认对齐数(default alignment)决定,但你可以通过特定的编译器扩展或属性来修改这个默认值。
以下是一些修改默认对齐数的方法:
- 使用#pragma pack指令(C/C++): 在C和C++中,你可以使用**#pragma pack**指令来设置或重置结构体的默认对齐值。例
cpp
#pragma pack(push, 1) // 将当前的对齐值设置为1字节
struct MyStruct {
char a; // 占用1字节
int b; // 占用4字节
char c; // 占用1字节
};
#pragma pack(pop) // 恢复之前的对齐值
2.使用__attribute__((aligned(n)))(C/C++,GNU编译器): 在GNU编译器(如GCC)中,你可以使用__attribute__((aligned(n)))属性来指定结构体或变量的对齐方式。例如:
cpp
struct MyStruct {
char a;
int b;
char c;
} __attribute__((aligned(1)));
3.使用alignas关键字(C++11及以上): C++11引入了alignas关键字,允许你指定变量或类型的对齐要求。例如:
cpp
struct alignas(1) MyStruct {
char a;
int b;
char c;
};
使用alignas可以确保MyStruct的实例按照指定的对齐要求来分配内存。
需要注意的是:
修改默认对齐数可能会影响性能 ,因为非对齐访问可能会降低内存访问速度,特别是在一些硬件平台上。因此,在性能敏感的应用中,应该谨慎地使用这些技术,并且只在必要时才修改默认对齐数。
为什么不能随意修改对齐数:
不能随意修改对齐数的原因主要与性能和兼容性有关:
-
性能:
-
内存访问效率:大多数现代处理器在访问对齐的数据时更加高效。当数据对齐时,处理器可以一次性读取或写入整个数据单元,而不需要对齐的数据可能需要多次内存访问。
-
缓存行利用:现代处理器通常以缓存行(cache line)为单位进行数据传输,一般为32字节或64字节。如果数据对齐到缓存行的大小,可以更有效地利用缓存,减少缓存未命中(cache miss)的次数。
-
-
兼容性:
-
跨平台兼容性:不同的硬件平台可能有不同的对齐要求。在一个平台上修改对齐数后运行良好的程序,在另一个平台上可能因为对齐问题而崩溃。
-
跨语言兼容性:不同的编程语言或编译器可能对数据对齐有不同的默认行为。修改对齐数可能会导致在不同语言或编译器间交互时出现问题。
-
-
标准遵从性:
- ISO C/C++标准:C和C++语言标准规定了默认的对齐规则,这些规则是为了保证程序的行为在所有符合标准的平台上都是一致的。修改对齐数可能会违反这些规则,导致不可预测的行为。
-
硬件限制:
- 硬件异常:某些硬件体系结构对对齐有严格要求,非对齐访问可能会导致硬件异常,如ARM和SPARC等。在这些体系结构上,非对齐访问可能会导致程序崩溃或性能严重下降。
-
调试困难:
- 难以追踪的bug:由于对齐问题导致的bug可能很难追踪和诊断,因为它们可能不会立即显现,而是在特定的条件下才会触发。
因此,除非有特定的需求,并且对性能和兼容性有充分的了解和测试,否则不应该随意修改对齐数。在实际开发中,通常只有在需要节省内存或者在特定的嵌入式系统中,才会在充分了解后果的情况下修改对齐数。
回归正题:
3. 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景
答案:
大小端(Endianess)是计算机系统中数据在存储和传输时字节序的表示方式 。大端模式(Big Endian)和小端模式(Little Endian)是两种常见的字节序。
-
大端模式:在这种模式下,数据的最高有效字节(即最重要的字节)存储在最小的内存地址 中,而最低有效字节存储在最大的内存地址中。简单来说,就是数字的高位在低地址。
-
小端模式:与小端模式相反,数据的最低有效字节存储在最小的内存地址中,而最高有效字节存储在最大的内存地址 中。即数字的低位在低地址。(体现着就是倒着存)
如何判断大小端呢?
主要有两种方法:
方法一:
cpp
int check_sys()
{
int i = 1;
return (*(char*)&i);
}
观察这段代码:
对于int类型的变量 i,先取地址&之后,得到 i 在内存中存储的地址。修改成(char*)类型之后,得到存储在内存中第一个字节的地址,再解引用,就得到对应的数据。
这种方法的关键就是从取地址入手,得到内存中第一个字节。
1的二进制码是 00000000 00000000 00000000 00000001,拿小端举例,第一个字节内存单元存储是00000001,得到这个数据就能判断是不是小端。
①取地址②改成char*③解引用
cpp
int main()
{
int ret = check_sys();
if (ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
方法二:
利用联合的特性
cpp
//代码2
int check_sys()
{
union
{
int i;
char c;
}un;
un.i = 1;
return un.c;
}
c在内存中,共用 i 的第一字节 的内容,如果c是00000000则是大端,如果c是00000001则是小端。
8.类成员函数的this****指针
this指针的引出:
cpp
//我们先来定义一个日期类 Date
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(2023,7,20);
d2.Init(2023, 7, 21);
d1.Print();
d2.Print();
return 0;
}
对于上述的类,有这样的一个问题:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函
数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++中通过引入this指针 解决该问题,即:C++编译器给每个"非静态的成员函数"增加了一个隐藏
的指针参数 ,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有"成员变量"的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
this是一个形参,不同的域可以定义相同名字的变量(名字相同,值也可以相同,变量却不是指的一个)
在上述的函数调用过程中,绿色的部分就是省略掉的部分,由编译器自动完成,因此,通过this指针,便可以找到对应的对象。
简而言之:this指针,存储的就是对象的地址!
注意:
this不能改名!是一个关键字
this在实参、形参位置不能显示的写出 ,但是却可以在类的内部使用。
cpp
void Init(int year, int month, int day)
{
cout << this << endl;_
this->year = year; //可以写可以不写
_month = month;
_day = day;
this->Print();
}
正是由于this的存在,类的内部才可以任意调用成员变量与成员函数。
同时也可以认为"纸老虎"C++的成员函数与普通函数没啥区别,想要修改类的内部成员,依然得传指针,只不过省略了,由编译器自动完成。
this指针的特性
-
this指针的类型:与类同类型,即成员函数中,不能给this指针赋值
-
只能在"成员函数"的内部使用
-
this指针本质上是"成员函数"的形参,当对象调用成员函数时,将对象地址作为实参传递给
this形参。所以对象中不存储this指针。(this指针存储在栈区,是一个形参)
- this指针是"成员函数"第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。
有了this指针的相关知识,便可以解决下面的问题。
cpp
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
答案是C
A:编译阶段无法检测空指针问题。
B:前面已经知道,函数存储在公共代码区,因此调用Print函数的过程中不存在解引用的问题,所以,答案是C。
注意:不要看到->就以为是解引用(不要去看形态),重点是看调用的内容存储在什么区域。
p->Print();改成
(*p).Print();
依然不会报错。不要只去看形态,不要把编译器看的太傻。(一定不会崩溃)
两者对于编译器而言,在底层执行的时候是一样的。根本原因还是Print在公共代码区域。
可以看到,此处直接调用(call)Print函数就好,不需要解引用。
同时,也可以解释this指针可以为空的问题。
在看下面这段代码
cpp
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void PrintA()
{
cout<<_a<<endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
可以看到由于在PrintA()函数中调用了类的内部成员_a,所以必须要有解引用(this指针),所以会执行崩溃。会出现空指针的借用因为问题。
结束语:上
学完上半部分,可以说已经初步了解了类和对象了。对于**C++面向对象的三大特性:封装 、继承 、 多态。**就可以对封装进行很好的解释了。
封装主要是体现在类的出现。
对比C而言:
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据
的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出
错。
对于C++而言:
①数据(变量)和方法(函数)封装在一起,严格管控。公共、私密分明。
②严格管控:自由不好(不同于文学)工程庞大,严格管控好。
学完上半部分,下面进入类和对象"中"的学习。
三、部分:中
对于部分:上,已经初步介绍了类的相关知识,但是对于类的内部,我们只是大体从成员函数和成员变量进行了初步认识。实际上类的内部还存在着大量的"地下工作人员 "----默认成员函数。
本部分主要有以下内容:
1. 类的 6 个默认成员函数
2. 构造函数
3. 析构函数
4. 拷贝构造函数
5. 赋值运算符重载
6. const 成员函数
7. 取地址及 const 取地址操作符重载
类的6个默认成员函数
如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员
函数。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
上图便是六大默认成员函数。
那问题来了,为什么要搞出这六大默认成员函数呢?这是用来干什么的呢?下面就从其功能入手,一 一解答。
函数一:构造函数
我们在C语言已经学过,如何利用C语言实现一些简单的数据结构(栈)等,对于栈等,需要我们人为的写出初始化函数,并且每次建立栈都需要人为的去调用Init函数,但是很多小伙伴会忘记调用,导致使用时,会出现一些意料之外的错误。当然,咱们C++的祖师爷也是深受这种困扰,于是祖师爷们想到了一个方法:我不去调用,每次利用类去实例化对象的时候,让这个过程自动调用初始化函数,来完成对象的初始化。于是,有了指导思想,构造函数便问世了。
我们来看下面的Date类
cpp
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;
}
对于Date类,可以通过 Init 公有方法给对象设置日期,但如果每次创建对象时都调用该方法设置
信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?
交给构造函数就好了!
构造函数 是一个 特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证
每个数据成员都有 一 个合适的初始值 ,并且 在对象整个生命周期内只调用一次。
话不多说,那就写一个构造函数,来让大家先看一看其特点有哪些。
cpp
Date(int year = 2024, int month = 5, int day = 26)
{
_year = year;
_month = month;
_day = day;
}
这便是Date类的一个构造函数。我们给定了一些缺省值。
需要注意,给定缺省值时,应该声明给,定义不给(如果声明定义分离的话)
名字与类型相同,在内部给出成员变量一个初始值。
cpp
class Date
{
public:
Date(int year = 2024, int month = 5, int day = 26)
{
_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.Print();
Date d2;
d2.Print();
return 0;
}
给定初始值之后,进行实例化,便可以打印出给定的初始值。
当我们半缺省时,则需要人为传入一些参数。(这种传参方式,或许对初学者有些陌生,我们下面马上讲解)
cpp
Date(int year, int month = 5, int day = 26)
{
_year = year;
_month = month;
_day = day;
}
cpp
int main()
{
Date d1(1111);
d1.Print();
return 0;
}
由此可以得到传入的参数。
当然我们也可以全部传参。
全部传参之后,便可以得到我们需要的参数结果。
对于构造函数的传参方式:
在对象实例化的同时,利用括号,在括号内传参。当调用默认构造函数时,不能写括号。
(只能这么写,没有为什么,祖师爷们就是这么规定的,不按规定来就是竹笋炒肉(●'◡'●))
构造函数的特性:
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任
务 并不是开空间创建对象,而是给对象一些合适的初始值。
其特征如下:
- 函数名与类名相同。
- 无返回值。(不需要写void,)(至于为什么,祖师爷定的规矩 ,这就是一个特殊的成员函数,没有为什么,再问就屁股打烂)
- 对象实例化时编译器 自动调用对应的构造函数。
- 构造函数可以重载。(可以写多个构造函数,提供多种初始化方式)
无参的默认构造函数:
前面已经说过,构造函数是默认构造函数,就算我们不显式的写出,系统也会生成一个默认的构造函数,那什么是默认构造函数呢?
①我们不写,系统生成的②全缺省的③无参数的
对于②和③前面已经提及,下面重点讲解①
对于系统默认生成的构造函数,有一个特点,即一旦我们显式的写出,系统就不会默认生成,我们不写,系统才会默认生成。
可以看到,我们并没有显式的写出构造函数,但是系统会生成,结果是什么呢?
int main()
{
Date d1;
d1.Print();
return 0;
}
在我们打印到时候,会打印出一串随机数,这便是系统默认生成的构造函数的特点之一: 对于内置类型,不进行处理。
后来C++的祖师爷也是发现了这一弊端,于是便进行了修改,系统默认生成的构造函数,也允许有一些缺省值。(这也是在C++11才给出的)
cpp
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
可以看到,在私有成员中,我们给出了一些缺省值,这样就会在默认构造函数中,优先使用这些缺省值。
值得注意的是:
上述给出缺省值,并不是定义各个成员变量,而是只是给出一些合理的初始值。
cpp
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;
}
观察Date类,没有给出构造函数,因此会使用系统默认生成的构造函数,对于内置类型,可以给出缺省值;对于自定义类型,则是自动去调用Time类对应的构造函数。
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。(存在多个会出现调用的二义性)
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。( 不传参就可以调用的构造函数,就是默认构造函数)
此时你不会觉得构造函数就完了吧/😰。当然不是!(⊙﹏⊙)硬菜的才刚来。
上述的只是一个简单的Date类,并未涉及资源的申请,一旦涉及资源申请,便必须显式定义!
对于Stack类:
在创建栈的时候,我们总是要给出一个合理的初始空间,这就得需要malloc,这就涉及了资源申请。因此我们需要显式的定义构造函数。
cpp
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 4) //传入需要开辟的空间大小
{
_array = (DataType*)malloc(sizeof(int) * capacity);
if (_array == nullptr)
{
perror("malloc fail");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
private:
DataType* _array;
int _capacity;
int _size;
};
void TestStack()
{
Stack s;
s.Push(1);
s.Push(2);
}
int main()
{
TestStack();
return 0;
}
可以看到,通过显式定义的构造函数,完成了对s对象的初始化。
exit(-1) 是一个 C/C++ 函数,用于终止程序的执行。其参数 -1 表示程序以错误方式退出。在 C/C++ 中,当程序调用 exit() 函数时,它会立即停止执行,并退出进程。通常,exit() 函数会调用系统默认的退出处理程序,该程序可能会显示任何未处理的异常或消息,然后关闭所有打开的文件和资源,最后终止进程。
为什么说上述的内容是一个硬菜呢?主要还是异地扩容的问题。
异地扩容是代价很大的,
我们先用C++粗略实现以下栈Stack,当存储大量数据的时候,就需要不断扩容。
原地扩容:tmp == a 指针空间地址不变
异地扩容:1.找空间 2.拷贝数据过去 3.释放旧 空间(代价大)
是否异地扩容主要是:看你申请的空间后面有没有足够的空间。
可以看到,当我们需要大量的使用空间的时候,可以直接传一个比较大的空间,就防止了多次扩容的问题。
总结一些编译器默认生成构造函数
1.不写才会生成,一旦写了任意一个不会生成
官方一点的说法就是 : 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
2.默认生成的构造函数对于内置类型(int、double、......)的成员不会处理,会是随机值,即生成了默认构造函数,但是没啥用 。
(灵魂拷问,date* 是不是内置类型?------- 是,指针类型,都是内置类型!!)
3.自定义类型的成员,会去调用这个成员的默认构造函数(全缺省、系统给出、无参)。 没有就会报错**。**
4.对于一个既含有内置又含有自定义时,当不写构造函数,进行默认构造时(不传参数的构造函数就是默认构造函数)的处理方式:1.要么内置类型不处理,只处理自定义2.要么给定内置类型一些缺省值
再例如,当我们用两个栈去实现一个队列的时候
cpp
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
当我们使用MyQueue类时,就不需要写构造函数,对于两个自定义类型(Stack)的成员变量,系统会自动调用属于他们的构造函数。
总结一下:一般情况下都需要自己写,决定初始化方式。一种情况除外:当某个类的成员变量全是自定义类型,可以不需要考虑去写。
如此,构造函数算是初步学完了!下面进入构造函数的对应函数 --- 析构函数。
函数二:析构函数
构造函数是对象实例化时,自动调用用来初始化对象的,那么析构函数就是对象生命周期结束时,自动调用去销毁、清理对象内部资源的函数。如:动态内存释放(malloc)文件关闭(fopen打开的)等等。
3.2 特性
析构函数 是特殊的成员函数,其 特征如下:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意: 析构
函数不能重载 - 对象生命周期结束时,C++编译系统系统自动调用析构函数。
对于第一条,~操作符是C语言中按位取反的意思,在此处,表示与构造函数相对,用于资源的清理。
对于我们之前给出的Date类
cpp
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
就可以给出对应的析构函数。对于Date类,内部有三个成员变量,其析构函数便可以写为
cpp
~Date()
{
_year = _month = _day = 0;
}
我们并没有显示的在主函数调用~Date函数,但是却是在调试过程中确实出现了调用析构函数的过程。
但是需要注意的是:
一些简单的类:日期类,不需要析构函数。
年、月、日是属于date的对象内部的变量,对象在栈帧里面出了栈帧自动销毁,写不写析构函数没有意义。
但是栈,就需要我们显式的去写析构函数。
cpp
class Stack
{
public:
Stack(size_t capacity = 4) //传入需要开辟的空间大小
{
_array = (DataType*)malloc(sizeof(int) * capacity);
if (_array == nullptr)
{
perror("malloc fail");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
~Stack()
{
cout << "析构";
free(_array);
_array = nullptr;
_capacity = _size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
void TestStack()
{
Stack s(1000);
s.Push(1);
s.Push(2);
}
在上述的析构函数中,我们将空间释放,变量进行了归零处理。
但或许你会疑问,变量有必要进行置空和置零嘛?
有必要对top、a、capacity这些成员变量在析构函数中进行处理吗?
对于top、capacity而言:
在C++中,析构函数的主要目的是释放对象在生命周期内分配的资源,并执行任何必要的清理操作。对于您提供的Stack类,析构函数确实需要释放动态分配的内存,但是对top和capacity成员变量的处理则不是必需的,因为这些变量在对象被销毁后不会再被使用。
在Stack类的析构函数中,您已经正确地释放了动态分配的数组a。这是必要的,因为如果不释放,将会导致内存泄漏。然而,top和capacity是内置类型的成员变量,它们在对象销毁时会自动被清理,所以不需要在析构函数中显式地将它们设置为0。
如果您希望在对象销毁后,top和capacity也显示为"已清理"的状态,可以在析构函数中设置它们为0,但这并不是必须的,因为它们的值在对象销毁后不会有任何影响。
最后,关于Destroy成员函数,它看起来是一个用于手动释放资源的函数。在C++中,通常不需要这样的函数,因为析构函数会自动处理这些事情。如果Destroy函数是用于在对象生命周期内提前释放资源,那么应该确保在释放资源后,对象不再被使用,以避免悬挂指针或使用已释放的资源。
对于a而言:
在C++中,当一个对象被销毁时,其析构函数会被调用,以便执行任何必要的清理工作。对于动态分配的内存,析构函数应该负责释放这些资源。在您提供的Stack类中,动态分配的内存是通过malloc和realloc分配的,并且应该使用free来释放。
在析构函数中,您已经正确地使用了free(a)来释放动态分配的内存。这是一个必要的步骤,因为它防止了内存泄漏。然而,置空指针a(即a = nullptr)是一个可选的步骤,它可以在对象析构后提供额外的安全性。
置空指针的目的是防止悬挂指针的出现,即指向已释放内存的指针。如果指针在释放内存后不被置空,那么它可能仍然包含一个地址,该地址曾经是有效的内存。如果这个指针后来被错误地使用,比如尝试解引用,它可能会导致未定义行为,包括程序崩溃。
在实际应用中,置空指针主要是为了防御性编程,以避免可能的错误。一旦对象被销毁,其成员函数不应该被调用,因此理论上不应该有悬挂指针的问题。但是,置空指针是一种良好的实践,因为它可以提供一层额外的保护,以防万一有代码错误地尝试使用已销毁对象的指针。
总结来说,虽然在析构函数中置空指针a不是严格必要的,但这是一个好的编程习惯,可以增加代码的健壮性和安全性。
int main()
{
Stack s;
return 0;
}
对于主函数,我们只建立了一个s,但是当s的生命周期结束的时候,确实是调用了析构函数
此时是当碰到了return 0 ;才发生了析构,只有当碰到return 0;时,才会发生析构吗?
答案:
在C++中,析构函数的调用与return 0;语句没有直接关系。析构函数的调用是由对象的生存期结束所触发的,而不是由函数的返回语句触发的。以下是析构函数调用的几种常见情况:
-
局部对象:当局部对象(例如函数内部创建的对象)离开其作用域时,其析构函数会被调用。这发生在函数执行完毕,控制流返回到调用函数的地方,与return语句的具体值无关。
-
动态分配的对象:使用new关键字动态分配的对象,在使用delete关键字释放时,其析构函数会被调用。
-
容器中的对象:当容器(如std::vector、std::map等)被销毁或者其元素被删除时,容器中包含的对象的析构函数会被调用。
-
异常处理:如果在函数执行过程中发生异常,并且该函数内部有局部对象,那么在异常被抛出并且控制流离开函数时,这些局部对象的析构函数会被调用。
-
继承链中的对象:在继承体系中,当派生类的对象被销毁时,首先调用派生类的析构函数,然后调用基类的析构函数。
析构函数的调用是为了确保对象在销毁时能够正确地释放资源和管理状态 ,这是C++中资源管理的重要方面。因此,析构函数的调用与return 0;或其他返回语句没有直接关系,而是与对象的生存期管理紧密相关。
此时只需要理解好第一种就好,其余的往后会学到。
作为默认构造函数:
前面已经略有提及,析构函数可以作为默认构造函数,对于Date这种简单的类,可以不用显式定义析构函数,那对于Stack类,则必须人为写出析构函数。
当一个类的成员,既有自定义成员,又有内置类型时,则会调用对应自定义类型的析构函数。
cpp
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;
}
程序运行结束后输出: ~Time()
在 main 方法中根本没有直接创建 Time 类的对象,为什么最后会调用 Time 类的析构函数?
因为: main 方法中创建了 Date 对象 d ,而 d中包含4个成员变量,其中_year, _month,
_day三个是 **内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;**而 _t 是 Time 类对象,所以在 d 销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用 **Time类的析构函数。**但是: main函数 中不能直接调用 Time 类的析构函数,实际要释放的是 Date类对象,所以编译器会调用Date 类的析构函 数,而 Date 没有显式提供,则编译器会给 Date类生成一个默认的析构函数,目的是在其内部 调用Time 类的析构函数,即当 Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁 main 函数中并没有直接调用 Time 类析构函数,而是显式调用编译器为Date类生成的默认析 构函数 注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数。
同样,对于MyQueue类,也不需要人为写出析构函数,只需要调用对应Stack类的析构函数即可。
cpp
class MyQueue
{
private:
Stack _pushst;
Stack _popst;
};
总结:
在项目中,绝大多数析构函数还是得自己写,只有像如上例myqueue类不需要写。
总结一下构造函数和析构函数最大的优势还是自动调用!构造函数是在对象创建时调用,析构是在对象生命周期结束时调用。
**函数三:**拷贝构造函数
先观察这个函数的名字--拷贝构造函数,或许你会觉得:哎?这不是碰瓷构造函数吗?
当然不是! 取这个名字不是为了碰瓷,而是它本身就是构造函数的"亲兄弟 ",是构造函数的函数重载。
那为什么要引入拷贝构造呢?在工程中,我们除了对对象有初始化需求,对对象有时也有拷贝复制 的需求,此时便需要调用拷贝构造函数。
那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
拷贝构造函数 : 只有单个形参 ,该形参是对 本类类型对象的引用(一般常用const修饰) ,在用 已存
在的类类型对象创建新对象时由编译器自动调用。
话不多说,下面就写一个Date类的拷贝构造函数。
cpp
class Date
{
public:
Date(int year = 1, int month = 2, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << "拷贝Date" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
~Date()
{
_year = _month = _day = 0;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
Date(const Date& d)
{
cout << "拷贝Date" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
这就是一个拷贝构造函数,需要注意的是,永远都是类的内部成员_year;_month;_day 在前面,因此,为了防止d被修改,常常在拷贝构造中给d加一个const,防止"误伤"d的成员。
特征
拷贝构造函数也是特殊的成员函数,其 特征如下:
- 拷贝构造函数 是构造函数的一个重载形式。
- 拷贝构造函数的 参数只有一个 且 必须是类类型对象的引用 ,使用 传值方式编译器直接报错,
因为会引发无穷递归调用。 - 若未显式定义, 编译器会生成默认的拷贝构造函数 。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅 拷贝,或者值拷贝。
那我们来好好解释一下第二条。
为什么传值会引发无穷递归调用呢?主要还是因为在使用拷贝构造时,会先走传参的过程,而传参中出现的传值会被认为成新的拷贝构造,因此会出现无穷递归调用!
关于第三条:对于Date这种只有数值的简单类,可以不需要进行显式定义拷贝构造。
cpp
class Date
{
public:
Date(int year = 1, int month = 2, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << "拷贝Date" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
~Date()
{
_year = _month = _day = 0;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
//Stack s;
Date d1(2020,20,19);
Date d2(d1);
d2.Print();
return 0;
}
当我们把拷贝构造屏蔽之后,
cpp
class Date
{
public:
Date(int year = 1, int month = 2, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
/*Date(const Date& d)
{
cout << "拷贝Date" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}*/
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
~Date()
{
_year = _month = _day = 0;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
//Stack s;
Date d1(2020,20,19);
Date d2(d1);
d2.Print();
return 0;
}
依然可以完成拷贝构造,这是系统默认生成的拷贝构造!
上述也体现了拷贝构造的书写格式
格式:
类型 + 变量名(模板) 模板是已经存在的同类型的对象
Date d2(d1);
Date d3 = d1; //也是拷贝构造,两种书写格式等价
对于上述的Date类,利用系统默认生成的拷贝构造就可以完成任务,但是对于Stack类,内部存在资源的申请,就必须人为写出拷贝构造 。这是为什么呢?主要还是因为Stack进行了资源的申请,开辟了一块空间,因此拷贝构造必须将这块空间也拷贝出来------这便是深拷贝。
为什么需要深拷贝?
都知道,对象在销毁时,会调用析构函数,析构函数会完成资源的销毁,对于对象的浅拷贝,也会完成资源的销毁,因此就造成了对同一块地区的资源,进行了两次销毁,因此,我们需要申请一块额外的资源。
话不多说,下面就对Stack完成一次深拷贝。
cpp
Stack(const Stack& st) //将st中的数据,拷贝构造到新的对象中,st就是本对象的别名
{
_array = (DataType*)malloc(sizeof(DataType) * st._capacity); //开辟与st对象大小相同的空间
if (_array == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_array, st._array, sizeof(DataType) * st._capacity);//利用mrmcpy将原空间的数据,拷贝到新开辟的空间
_capacity = st._capacity;
_size = st._size;
}
这便是stack类的一个拷贝构造。完成了空间的开辟、资源的拷贝。
cpp
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 4) //传入需要开辟的空间大小
{
_array = (DataType*)malloc(sizeof(int) * capacity);
if (_array == nullptr)
{
perror("malloc fail");
return;
}
_size = 0;
_capacity = capacity;
}
Stack(const Stack& st) //将st中的数据,拷贝构造到新的对象中,st就是本对象的别名
{
cout << "拷贝构造" << endl;
_array = (DataType*)malloc(sizeof(DataType) * st._capacity); //开辟与st对象大小相同的空间
if (_array == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_array, st._array, sizeof(DataType) * st._capacity);//利用mrmcpy将原空间的数据,拷贝到新开辟的空间
_capacity = st._capacity;
_size = st._size;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
~Stack()
{
//cout << "析构";
free(_array);
_array = nullptr;
_capacity = _size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
void TestStack()
{
Stack s(1000);
s.Push(1);
s.Push(2);
}
int main()
{
Stack s1(10);
s1.Push(0);
s1.Push(0);
s1.Push(0);
Stack s2(s1);
return 0;
}
拷贝构造的传参方式类似构造函数,因为两者本身就是函数重载的关系。
通过打断点去检测,确实也是进行了拷贝构造。
总结:
①默认生成的拷贝构造
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
cpp
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;
}
注意:在编译器生成的默认拷贝构造函数中 ,内置类型是按照字节方式直接拷贝的 ,而自定义类型 是调用其拷贝构造函数完成拷贝的。
②注意:类(Date)中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请(Stack)时,则拷贝构造函数是一定要写的,否则就是浅拷贝 ;对于默认生成的拷贝构造(MyQueue),内置类型浅拷贝,自定义类型去调用对应类型的拷贝构造。
同样在此提及的MyQueue类,只需要调用对应Stack类的拷贝构造就可以了。
总结下拷贝构造的小知识点:
1.构造函数的重载
2.有且只有一个同类型的引用类型参数(注意不要搞混参数)
3.需要完成相对的拷贝需求
拷贝构造函数典型调用场景:
1.使用已存在对象创建新对象
2.采用传值传参,函数参数类型为类类型对象
3.采用传值返回,函数返回值类型为类类型对象
下面就是一个调用构造函数的典型场景。
cpp
class Date
{
public:
Date(int year, int minute, int day)
{
cout << "Date(int,int,int):" << this << endl;
}
Date(const Date& d)
{
cout << "Date(const Date& d):" << this << endl;
}
~Date()
{
cout << "~Date():" << this << endl;
}
private:
int _year;
int _month;
int _day;
};
Date Test(Date d)
{
Date temp(d);
return temp;
}
int main()
{
Date d1(2022,1,13);
Test(d1);
return 0;
}
此段代码可以分为以下几个步骤,
先利用构造函数创建d1
传参时,利用拷贝构造创建d
函数内部,利用拷贝构造,用d创建temp
返回时,利用值返回,返回临时变量temp。
(假设用变量a接收返回值,由于temp会出函数栈帧就销毁,先给临时对象,存储temp,临时对象再给接收返回值的变量a)
因此,为了防止不必要的过多调用拷贝构造,为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
函数四:赋值运算符重载
我们前面已经知道了函数重载,函数重载大体可以理解为:相同的函数执行不同的功能。
赋值运算符重载则是,对于繁多的复制运算符,我们人为了给出了新的功能,去执行对应的任务。
因此,在介绍赋值运算符重载的时候,先介绍一下一些简单的运算符重载。
运算符重载:
C++为了增强代码的可读性引入了运算符重载 , 运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字 operator后面接需要重载的运算符符号。
函数原型: 返回值类型 operator操作符(参数列表)
对于一些简单的内置类型而言,比如
int a = 10; int b = 20; int c = a + b;
其中的 + 就是一个简单的运算符。这些运算符只能对系统的内置类型进行识别处理,但是对于我们的自定义类型。形如Date类,这些简单的运算符就没法执行对应的功能。往往,这些Date类的计算在现实生活中是很有必要的,比如要计算从国庆节到现在一共还有几天、、、、、此时便需要我们认为的 将这些简单的运算符赋予特殊的含义。
下面我们就来先实现一些简单的运算符重载。
(一)判断日期的大小
此时我们需要 <运算符,但是 <只能判断简单的内置类型,想要判断自定义类型,就需要将<改成operator<,相当于将 <赋予了特殊的含义。
cpp
bool operator<(const Date& d)
{
if (_year < d._year)
return true;
else if (_year > d._year)
return false;
else
{
if (_month >= d._month)
return false;
else
{
if (_day >= d._day)
return false;
else
return true;
}
}
}
可以观察到,内部并没有什么复杂度逻辑,只是要简单格式注意点就好了。
1.注意返回类型 2.注意函数名的形式 3.注意参数
在使用时,就可以之间使用 <了。
cpp
class Date
{
public:
bool operator<(const Date& d)
{
if (_year < d._year)
return true;
else if (_year > d._year)
return false;
else
{
if (_month >= d._month)
return false;
else
{
if (_day >= d._day)
return false;
else
return true;
}
}
}
Date(int year = 1, int month = 2, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
/*Date(const Date& d)
{
cout << "拷贝Date" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}*/
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
~Date()
{
_year = _month = _day = 0;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(1, 2, 3);
Date d2(3,2,1);
cout << (d1 < d2) << endl;
cout << d1.operator<(d2) << endl;
return 0;
}
cout << (d1 < d2) << endl;
cout << d1.operator<(d2) << endl;
需要注意的是,这两种使用方式是等价的,只不过第二种是显式的写出了函数的全名。同时,d1 < d2的原型是d1.operator<(d2),因此在函数的实现的时候,应该注意函数的参数顺序不能搞反!
否则会与函数内部的逻辑实现相反。
运算符重载作为成员函数时,
作为类成员函数重载时,其形参看起来比操作数数目少1 ,因为成员函数的第一个参数为隐藏的this指针。因此显式定义的时,只需要写出一个参数就可以(this被省略)
运算符重载的注意点:
①不能通过连接其他符号来创 建新的操作符:比如operator@
②重载操作符必须有一个类类型参数
③用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义(比如 + 不能定义成 - )
④作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐
藏的this
⑤
:: 域作用限定符
sizeof
**? :**三目运算符
. 解引用
.* //(但是*可以重载)
注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
⑥·不能改变操作符的操作数个数。一个操作符有几个操作数,那么重载就有几个参数(包含this)
在知道上述特点即形式之后,就再写几个练练手。
== (相等)
cpp
bool operator==(const Date& d)
{
if (_year == d._year
&& _month == d._month
&& _day == d._day);
return true;
return false;
}
当然我们也可以做出简化,因为 && 本身就是一种判断逻辑的操作符。
cpp
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
<= (小于等于)
cpp
bool operator<= (const Date& d)
{
return *this < d || *this == d;
}
这是一种怎样的用法呢?
我们已经知道,在类的内部可以直接调用成员函数,因此也可以直接调用运算符重载。
d1对应 this,d2对应d,但是this是指针,不是对象,因此*this就是对象。
this指针虽然不能出现在参数(实参、形参)列表,但是却可以在类的内部使用。this就是该类(实例化的对象)的地址,因此*this就是该类实例化的对象。
> 大于
cpp
bool operator>(const Date& d)
{
return !(*this < d);
}
>= 大于等于 、 !=不等于
cpp
bool operator>=(const Date& d)
{
return !(*this < d);
}
bool operator!=(const Date& d)
{
return !(*this == d);
}
不难观察出,上述的函数实现只需要一些取反操作。在成员方法的内部,一般都是*this代替本对象。
+ 日期 + 天数
这个就相对比较复杂了,因为日期 + 天数的结果还是日期类型,并且需要注意月份、年份是否需要变化。
日期 + 天数(本质还是进制的进位法)
此时我们需要先写一个函数,判断每月的天数。
cpp
//四年一闰,百年不闰;四百年一闰
int GetMonthDay(int y, int m)
{
int arr[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 }; //多以顶一个空间,保证1月就是下标1
if (m == 2
&& ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0))
{
arr[m]++;
return arr[m];
}
return arr[m];
}
其次,写一个+=,利用+=再实现+
cpp
Date& operator+=(int day)
{
_day = _day + day;
//大于则进位
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;
}
this指向的对象除了函数还在,因此最好引用返回(如果用传值返回,那会形成一份数据的临时拷贝,还要调用拷贝构造)
再实现+
注意:实现+的时候,自身并没有发生改变,因此不能对自身元素做出修改,此时就需要利用拷贝构造,完成一份副本的拷贝,对副本进行修改。而不是对自身进行修改。
cpp
Date operator+(int day)
{
Date tmp(*this); //得到拷贝
tmp += day; //直接在此处调用运算符重载(+=也是成员函数)
return tmp;
}
tmp也是Date类 ,出作用域也会调用析构函数。
因此需要注意的是,C++作为面向对象的语言,一定要跳出C的局限思维 ,要去利用C++这些用起来很方便的语法。
当然既然能用+=实现+;自然也能用+实现+=。
不过需要注意的是,当我们用+实现+=之后,就会发现,会额外调用几次拷贝构造,这就会大大降低效率。
在以后的代码好与不好的考虑中,还需要考虑"赋值" 与 "拷贝(构造)"的次数。设想,如果拷贝二叉树,那消耗将会十分巨大!
再从-= 和 - 举例子
这主要是考察日期的借位。
有了上面的经验,那我们为了效率,就可以先实现 -= 再实现 -
cpp
Date& Date::operator-=(int day)
{
if (day < 0)
{
return *this += (-day);
}
_day -= day; //已经声明,函数在Date域,因此可以直接使用私有成员。
while (_day <= 0) //天数可以小于最大值,但是不能出现负数
{
_month--;
if (_month == 0)
{
_month = 12;
_year--;
}
_day += GetMonthDay(_year, _month); //应该是从下个月补,而不是从本月补(借位是从下一位开始补)
}
return *this;
}
cpp
Date& Date::operator-(int day)
{
Date d2(*this);
d2 -= day; //永远是this对象为左操作数
return d2;
}
赋值运算符重载
前面铺垫了那么久,就是为了对于运算符重载现有一个初步的认识,等了解运算符重载之后,便可以对赋值运算符重载进行对应的实现。
赋值运算符重载格式
参数类型 : const T&,传递引用可以提高传参效率
返回值类型 : T&,返回引用可以提高返回的效率, 有返回值目的是为了支持连续赋值
检测是否自己给自己赋值
返回*this:要复合连续赋值的含义
实现:
注意此处写的时*this != d
不能写d != *this。这是因为,我们在实现operator!=时,左操作数就是this。如果倒置,那么d本来被const修饰,但是this的类型却是没有被const修饰,此时便会造成权限的放大!!!!
因此,重载的操作符,一般都是this作为左操作数!
当然,为了运行的效率,还可以写作if (this != &d),直接使用内置类型,肯定比外置类型效率高!
cpp
Date& Date::operator=(const Date& d)
{
if (this != &d) //防止自身赋值给自身
{
_day = d._day;
_month = d._month;
_year = d._year;
}
return *this;
}
当然,我们的返回类型选择的是Date&类型,而不是void类型,这就保证了等号赋值的连续性。
需要注意一点的是,不要将赋值运算符重载与拷贝构造搞混。
拷贝构造强调的是创先对象的同时初始化。
赋值重载则是强调对象创建好之后,再赋值相同的内容。
这便是拷贝构造。
这便是赋值重载。
赋值重载的第二个注意点:
赋值运算符只能重载成类的成员函数不能重载成全局函数。
cpp
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
// 编译失败:
// error C2801: "operator ="必须是非静态成员
原因: 赋值运算符 如果不显式实现, 编译器会生成一个默认的。此时用户再在类外自己实现
一个全局的赋值运算符重载,就和编译器 在类中生成的默认赋值运算符重载冲突了,故赋值
运算符重载 只能是类的成员函数。
第三个注意点:
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注
意: 内置类型成员变量是直接赋值的,而 自定义类型成员变量需要 调用对应类的赋值运算符
重载完成赋值。
cpp
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time& operator=(const Time& t)
{
if (this != &t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
return *this;
}
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;
Date d2;
d1 = d2;
return 0;
}
这一点类似于拷贝构造,对于简单的Date类,默认生成的便可以完成任务。但是对于Stcak这种需要完成深拷贝的类,则需要自己去写。
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必
须要实现。
这主要还是跟拷贝构造的浅拷贝一样的问题,只要是浅拷贝,如果存在资源申请,再调用析构函数的时候,会对同一块空间释放两次,那么一定会程序崩溃!
cpp
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;
s2 = s1;
return 0;
}
对于赋值重载,就可以参照构造函数去写。
下面多讲一个常见的运算符: 前置++与后置++
在前面的学习阶段,我们已经知道了前置++是先++后使用,后置++是先使用后++。
但是对于实现而言,两者都是operator++,那该如何区分呢?
祖师爷这时候就站出来说话了。
规定:
operator++( )默认为前置++
operator++(int)在参数列表中多传一个int类型的形参,作为区分,与前置++形成函数重载,作为后置++
(可以写一个i接收,也可以不写,形参不用,只是作为区分)
编译器会进行如下处理
d1++->d1.operator++()
++d1->d1.operator++(0) //只需要传一个整型的变量即可,任意值,仅作为区分
前置++的实现
前置++:返回++之后的结果
cpp
Date& Date::operator++()
{
*this += 1;
return *this;
}
后置++:返回++之前的结果,但是需要完成*this += 1的任务。
cpp
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
返回的是临时对象,不能用引用返回。
由此可以看出,同是++,前置++比后置++少了两次拷贝构造,因此以后要优先使用前置++。
--操作符亦是如此。
写到这里,日期Date类的实现只剩下最后的日期 - 日期了。日期 - 日期的意义是计算日期的间隔即返回类型是int(国庆到现在几天)。
日期 - 日期主要有两种方法:
直接法:效率高
①用d1的年份 减去 d2的年份,得到gap年,计算其中的平年与闰年。
②将d1与d2的月与日统一划算成日数 r1 , r2。
方法二:效率低,好理解
类似于count计数,不断++,计数日期的差值。
下面完成日期类的实现。同样是减法,其与 日期 - day 由于形参不同,构成函数重载。
cpp
int Date::operator-(const Date& d)
{
int count = 0;
Date max(*this);
Date min(d);
if (*this < d)
{
max = d;
min = *this;
}
while (max > min)
{
max -= 1;
count++;
}
return count;
}
其实仔细算一下,时间真的经不住计算。某些动画片动不动一万年以后.....说起来还是很可怕的。
同时,我们前面还顺便提及了一下权限放大的问题 ,这主要是const从中作祟。对象被const修饰之后,权限就低于正常对象。因此正常对象可以被const对象接收,但是const对象不能被正常对象接收(否则就是权限的放大)。
下面就借此契机,好好讲一下const成员。
const 成员
将const修饰的"成员函数"称之为const成员函数,const修饰类成员函数,实际修饰该成员函数
隐含的this指针 ,表明在该成员函数中 不能对类的任何成员进行修改。
我们通过一个问题,来引入下面的学习。
当我们建立一个const类型的对象,发现d1无法调用类内部的Print()函数,其 直接原因就是d1被const修饰,但是函数Print()没有被const修饰,因此d1在调用函数的时候,就会出现权限的放大,从而报错。
那 根本原因是什么呢?
根本原因是:d1被const修饰之后,本质修饰的是d1的各个成员,是其不能修改, 即修饰的是d1的this指针,使其this指针变成const Date*类型 。 对于Print函数中的this指针,是Date*类型。
用const Date* 去调用 const Date,这就是牵扯的权限的放大!
如何解决问题呢?
还是得从权限入手,我们则是需要想办法,将Print函数的this指针用const修饰一下。但是但是但是!!!this指针不允许显式的写出,那怎么办呢?
于是祖师爷发明了这样的办法。将Print函数用const修饰,使之成为const成员函数。
可以发现,这样代码就编译通过了。
我们用const修饰的是函数,但是本质上修饰的还是this指针。
只是要注意const成员的书写形式: 在函数名字的后面进行const修饰(定义和声明)。
这时问题就来了,函数被const修饰,变成const成员之后,普通对象还能调用嘛》?》
答案是能! 普通对象this指针是Date*类型,调用const对象是一种权限的缩小,权限允许缩小,但是不允许放大!
同时,const成员修饰的本质还是this指针--形参,因此上述两种函数构成了函数重载,并且允许同时存在。
这时对象调用Print函数的时候, 如果是const对象,则会调用const成员,普通对象调用普通成员,会选择走最匹配的路径。
那这时候一问就来了,命名const成员既可以被普通对象调用,又可以被const对象调用,那 为什么不把所有成员都用const修饰呢?
其实这时候就会有坑了。这里的坑主要是函数的权限问题:①读②写
就【】解引用而言,我们将它重载一下
观察下面的代码
发现代码编译报错了。
主要原因还得从运算符重载的返回类型入手。a[4]的本质就是调用了一次函数,函数的返回类型设置的是int类型,我们对a[4]进行修改的时候,本质是对返回值进行修改,而不是对这个元素进行修改,因此出现报错。
( 返回值:一般会拷贝给一个临时对象,而这个临时对象具有常性,因此不允许修改!)
因此,想要修改, 必须采用引用返回!
这样就找到了那块空间,就可以进行修改。
但问题又来了,对于数据,我们总是会出现两种需求:读、写。对于不想写的数据,我们只读就好,不允许别人修改,因此我们需要对这个函数额外写一个函数重载,来完成只读的内容。
完成只读:
1.const修饰返回值
如果要完成只读,那么返回值必须不可修改,即被const修饰
2.将成员函数改为const成员
成员函数被修改为const成员之后,那么this指针就会被const修饰,this指针被修饰的本质就是 this指针指向的成员不允许被修改。因此作为类的内部成员,就无法被修改。
cpp
int& operator[](int i) // 读 / 写
{
return _arr[i];
}
const int& operator[](int i) const //只读
{
return _arr[i];
}
经过这样修改,就做到了按需调用,更加合理
cpp
int main()
{
A a;
cout << a[4] << endl;
const A b;
const int& ret = b[2];
cout << b[2] << endl;
return 0;
}
因此,对于解引用的重载,我们一般会重载两次,都采用引用返回,一个用来读取,一个用来写。
同时,由于a没有被const修饰,cout << a[4] << endl;在此举代码中,也会优先调用不被const修饰的成员函数。
总结一下:当调用成员方法时,对象被const修饰,只能找this指针被const修饰的成员函数;对象没有const修饰,优先找最匹配的,找不到再降级,找this指针被const修饰的。
请思考下面的几个问题:
- const 对象可以调用非 const 成员函数吗?
- 非 const 对象可以调用 const 成员函数吗?
- const 成员函数内可以调用其它的非 const 成员函数吗?
- 非 const 成员函数内可以调用其它的 const 成员函数吗?
(注:非const成员函数及const成员函数,仅指的是this指针有没有被const修饰)
1.不可以 2.可以 3.不可以 4.可以
3.主要还是因为this指针的问题,外部函数的this指针被const修饰,当内部调用其它的非const成员函数时,此时的this指针就会权限放大,因此不可以调用!
最好加上const的成员函数总结(指this指针):
1.只读函数都加const。
2.内部不修改成员,最好都用const成员。
这样并不会影响普通对象调用这些成员函数,反而还会因为是const成员,做到保护对象的作用。
函数五函数六:取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。一般来说,用编译器自动生成就够用了。
cpp
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};
取地址操作符返回this。const取地址,则是被const修饰过的取地址。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需
要重载,比如 想让别人获取到指定的内容!
结束语 :中
到这里,中算是学完了,这一部分主要是介绍了六大默认成有函数及其显式定义的特性。
四:部分下
学完上和中,算是对于类和对象就算是入门了。在下半部分,主要是类和对象的一些补充知识。
本部分的内容有:
1.对流插入和流提取的重载
2. 再谈构造函数
3. Static成员
4. 友元
5. 内部类
6. 再次理解封装
1.对流插入和流提取的重载
对于C++的cout,实际上本身就是一种函数重载。当我们使用cout的时候,会发现cout能够自动识别变量的类型,从而完成打印,是因为cout是一个将
这些函数都重载的函数。
由此我们便得到启发,能不能重载流插入操作符,实现自定义类型的打印呢?需要知道的是cout是ostream类的一个成员。
ostream是std类的一个成员。我们使用的时候,需要说明一下权限。
上方代码的实现,out其实就是cout的一个别称。但是我们使用的时候,问题就来了。
系统报错了,这是为什么呢?
不难发现,在之前我们实现的所有操作符的重载中,都是自定义对象d位于操作数的左边。
这与形参的顺序有关**。对于参数列表中的形参,this被省略,总是被默认为第一个型擦**。
当我们写出cout << d;这句代码的时候,通过传参给operator<<函数 ,此时this指针将接收cout。
我们需要注意形参的接受顺序
因此,我们需要解决的就是,让this指针出现在形参的第二个位置。但是在类的内部不能显式传参,因此我们需要将流插入<<操作符重载在类的外部(不同于赋值重载,赋值重载只能定义在内部)。
但此时问题又来了,_year等元素是受保护的,不允许在类的外部直接访问 ,这该怎么办呢?
于是便引入了一个需要补充的新知识点:函数的友元声明。
何为友元声明?可以这样理解。假如你们家有个大泳池,只有你们家的人能去用。但是你有一个非常好的朋友---小明,这天你就对小明说:我们家一个大泳池,你想去玩随便去,你给小明发了一张你们家门禁的VIP卡,有了这张卡,便可以直接进入你们家的泳池。
同理,在类的内部对函数完成友元声明之后,便可以任意的访问类的私有成员。
在类的内部,对于函数的声明加一个friend就可以完成友元声明。
此函数需要有三部分:1.友元声明2.函数声明(.h)3.函数定义(.cpp)。
此时就能很好的使用流插入操作符了。
由此,便大大提高了打印效率,但是也存在未解决的问题。流插入应该支持连续的输出。
当我们想同时输出d1和d2的时候,出现了错误,这主要好事cout<<d1这个表达式返回值是void类型的问题,不支持下一次流插入。为了支持下一次的流插入,应该返回值设置为cout对应的类型。
由此便可以实现多次流插入。
对于流插入的重载,我们完成了以下的关键操作:
1.将this改为第二个形参
2.完成友元声明
3.修改返回类型为ostream
那对于流提取,便十分简单了。
cin是istream的一个成员。
cpp
std::istream& operator>>(std::istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
在提取时,依次提取(用空格或者换行分割)。同时d需要修改,不能被const修饰
有些操作符,因为参数顺序,不能重载成成员函数,只能重载全局,可以考虑友元解决问题。
2. 再谈构造函数
构造函数体赋值
在创建对象时,编译器通过调用构造函数,给对象中各个成员变量一个合适的初始值。
虽然构造函数调用之后,对象中已经有了一个初始值,但是 不能将其称为对对象中成员变量的初始化 , 构造函数体中的语句 只能将其称为赋初值 ,而不能称作初始化。因为 初始化只能初始化一次, 而构造函数体内可以多次赋值。
那真正完成初始化靠的是什么呢?靠的是 初始化列表。
初始化列表
初始化列表可以看成 是构造函数的一部分。对对象完成初始化的确确实实是构造函数,更确切的说是构造函数中的初始化列表的功劳。
初始化列表:以一个 冒号开始 ,接着是一个以 逗号分隔的数据成员列表 ,每个 "成员变量" 后面跟一个 放在括 号中的初始值或表达式。(后面不跟分号)
对于Date类这种简单的类,只需要初始化列表就能搞定工作。
这是之前的方式
cpp
Date::Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
这是利用初始化列表的方式
cpp
Date::Date(int year, int month, int day)
:_year(year)
, _month(month)
, _day(day)
{}
此时我们只需要用初始化列表就可以完成初始化,{}内的内容自然就不需要了。
因此没有什么特殊点,只不过是在原先学习的构造函数的基础上,多了一个由 :,引领的列表。
当然,我们也可以结合使用。
cpp
Date::Date(int year, int month, int day, int i, const int mem)
:_year(year)
, _month(month)
,_i(i)
,_mem(mem)
{
_day = day;
}
重点:
值得一提的是,通过调试,我们会发现,
①数据在调用构造函数的时候,会优先走初始化列表,然后才会走{}括号部分
②走初始化列表部分的时候,初始化的顺序并不是列表中列出的顺序进行初始化,而是按照私有成员的声明顺序去初始化。
当我们使用的时候
这是传入的缺省值
这是函数的实现
这是使用。
当我们把拷贝构造、构造函数的声明和定义全都屏蔽之后,此时编译器只能使用我们的缺省值去使用系统生成的默认构造
此时会打印缺省值。
那这些缺省值是怎么使用的呢?其实本质就是缺省值传给了初始化列表。
初始化列表作为初始化的工具,有什么需要注意的呢?
- 每个成员变量在初始化列表中 只能出现一次 ( 初始化只能初始化一次)
- 类中包含以下成员,必须放在初始化列表位置进行初始化:
①引用成员变量
②const成员变量
③自定义类型成员(且该类没有默认构造函数时)
( 没有默认构造函数是指,我们显式定义了构造函数,但是既不是全缺省,也不是无参类型)
下面就对这三种情况进行一一讲解。
①
在成员的私有成员,允许包括引用成员。而引用有一个十分重要的注意点: 引用必须在定义的地方初始化(对象在实例化(定义)时会调用初始化列表,初始化这些对象)。
因此,我们必须在初始化列表对引用对象进行初始化。
此时引用变量_i只能在初始化列表进行初始化。
②const成员变量
为什么必须在初始化列表进行初始化呢?我们已经知道,在初始化中,占绝大多功劳的是初始化列表,这是因为构造函数的{}域部分允许多次赋值,不是初始化,而 const成员变量初始化之后就不允许随便修改。同时const变量只允许在定义的地方初始化,此时更贴合初始化列表。
③自定义类型(且该类没有默认构造函数时)
我们已经知道,自定义类型的成员,当我们使用系统默认生成的默认构造时,会调用类成员对应的默认构造方法,但是对应的构造方法不存在时,只能在初始化列表进行自定义类型成员的初始化。
(当不使用系统默认的构造方法时,即当我们显式定义构造方法时,则只会调用我们显示定义的构造方法。也就是说,一旦我们在初始化列表去初始化自定义类型的时候 ,系统就不会生成默认构造函数 ,同样不会去调用自定义类型的默认构造函数。即只使用我们显式定义的构造函数)
由于调用初始化列表时 ,我们可以认为此处时每个成员定义的地方,因此可以在此处进行自定义类型的初始化。
官方一点的说法:
在C++等编程语言中,自定义类型(例如类或结构体)通常需要在初始化列表处进行初始化,而不是在构造函数体内进行赋值,这主要是由成员变量的初始化顺序决定的。
在C++中,类的成员变量是按照它们在类中声明的顺序进行初始化的 ,而不是按照它们在构造函数中出现的顺序。当使用初始化列表 时,你可以确保成员变量按照声明的顺序被正确初始化 。如果在构造函数体内进行赋值,则可能导致成员变量的初始化顺序与预期不符,从而引发潜在的错误。
此外,使用初始化列表还可以提高效率。当使用初始化列表时,成员变量可以直接在构造函数调用时被初始化 ,而不需要在构造函数体内进行额外的赋值操作。这可以减少复制构造函数的调用,从而提高性能。
总之,使用初始化列表进行自定义类型的初始化可以确保成员变量的正确初始化顺序,并提高效率。
cpp
class A
{
A(int a, int b)
:_a(a)
,_b(b)
{}
private :
int _a;
int _b;
};
观察A类,A类虽然有初始化列表,但是没有默认构造函数。因此其他类使用A类创建的对象时,只能通过初始化列表进行初始化。
对于自定义类型的初始化,可以参考这种形式。
Date d1(2020, 12, 21, i, 8);
在定义的时候(初始化列表处),对象名字的后面紧跟参数,便可完成初始化。
在初始化列表中,我们采用惯常的方式,在定义处,利用**对象名(参数列表)**的形式,完成对自定义类型对象的初始化。
我们打一个断点,去调试发现,自定义对象_a确实完成了初始化。
同时有一点需要注意:
如果自定义类型对象a存在默认构造函数,那么包含a的对象b在初始化的时候,如果没有在b的初始化列表中显式地给a初始化,编译器会自动调用a的默认构造函数吗?
是的,如果自定义类型对象 a
存在默认构造函数,并且你在包含 a
的对象 b
的构造函数中没有显式地在初始化列表中初始化 a
,那么编译器会自动调用 a
的默认构造函数来初始化 b
的成员 a
。
这是C++语言的规定,每个类成员在对象构造时都必须被初始化 (所有成员都会走初始化列表,无论你是否显式的写出)。如果你没有提供初始化,编译器会尝试使用成员的默认构造函数来初始化它。如果成员没有默认构造函数,编译器将会报错。
同时,如果既有默认构造,我们又显式地写出了初始化,那么会用我们显式地写出的初始化内容。
(类比在主函数中完成类的实例化时,进行的传参)
即使 b
的构造函数的初始化列表只为其他成员变量提供了初始化,而没有为成员变量 a
提供显式的初始化,编译器仍然会自动调用 a
的默认构造函数。
因此,如果存在a的默认构造函数,就不需要在b中显式地为a进行初始化。
同时
那对于自定义类型_a,如果不在自定义列表中显示定义,会得到处理吗?
结果是会的,如果我们给a传了缺省值,那么初始化列表会使用a的缺省值,去完成a的初始化(a的构造方法不可以显式定义才能使用缺省值,显式定义的构造方法不能使用缺省值),否则就会报错!
虽然我们给出了缺省值,但是由于显式定义,还是会报错。
而我们屏蔽这些代码之后,就会运行正常。
结论:在构造列表的初始化阶段,内置类型随机值初始化,自定义类型去调用其对应的默认构造。(所有成员都会走初始化列表,无论你是否显式的写出)
当我们执行上述代码的时候。
通过调试可以发现,出了初始化列表,所有成员都完成了初始化,为显式写出的成员则是用的缺省值。
当走到这一步之后,我们给_day传入的参数,便可以修改_day的值。
故:初始化列表是所有成员定义的地方!
对于自定义类型,如果既有默认构造,也显式地写了,优先走显式写出的初始化列表,在此处通过调试过程也可以发现。
因此:const成员、自定义类型成员、引用成员,必须在初始化列表中完成初始化。
对于Stack类的初始化列表实现
cpp
class Stack
{
public:
Stack(size_t capacity = 4) //传入需要开辟的空间大小
: _capacity(capacity)
, _size(0)
,_array(nullptr)
{
_array = (DataType*)malloc(sizeof(int) * capacity);
if (_array == nullptr)
{
perror("malloc fail");
return;
}
}
可以看到,我们既采用了初始化列表,也使用了函数体进行初始化,因此两者需要结合使用。
回归正题:
初始化列表需要注意的第三点及第四点
- 尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。
- 成员变量 在类中 声明次序 就是其在初始化列表中的 初始化顺序,与其在初始化列表中的先后次序无关。
3.explicit****关键字
下面我们用一个特殊的构造函数来引入这个关键字的学习。我们已经知道构造函数存在参数列表,那如果参数列表只有一个参数(出了this指针),那我们就称之为单参数构造。
cpp
class A
{
public:
A(int a)
:_a(a)
{}
private:
int _a;
};
这是一个简单的单参数构造,构造函数只有一个参数。
当我们使用的时候,可以用这种基本的形式完成对象的实例化。
int main()
{
A aa(2);
return 0;
}
那现在用2去创建aa的过程中,采用()的形式去调用构造函数,就可以很好的说清了。因为初始化列表也是用()去完成定义的。做到了形式上的统一。
当然,对于这种特殊的单参数构造,我们还有一种特殊的传参方式。
A a = 1;直接用等号传参。这其实是一种隐式类型转换。其具体过程为
先用1去调用构造函数,生成一个临时对象;再用这个临时对象去拷贝构造a。
当然,编译器会对这个复杂的过程进行一次优化。
会直接用1去调用构造函数,生成a,省去了一次拷贝构造。
为什么这么说呢?从引用的角度就可以回答。
此时报错了,主要就是隐式类型转换过程的原因。由于1参与隐式类型转换,生成的临时对象具有常性,此处用不带const的引用去引用a,会发生权限的放大。
但是我们加上const修饰之后,此时就是权限的平移,而不是权限的放大。语法就允许了。同时,这时候也不会存在优化的现象。
但如果我们不想让隐式类型转换发生怎么办?此时便需要我们的新知识登场了-----explicit关键字。
explicit在英文中就是坦率的、率真的意思。
explicit
构造函数不仅可以构造与初始化对象, 对于接收单个参数的构造函数,还具有类型转换的作用。接收单个参 数的构造函数具体表现:
- 构造函数 只有一个参数
- 构造函数有多个参数, 除第一个参数没有默认值外,其余参数都有默认值
- 全缺省构造函数
当我们用explicit关键字修饰构造函数之后,就禁止了类型转换的发生。
在这里关于类型转化,可以多提一嘴
类型的转换,中间都会生成一个临时对象
上述的代码是可以编译通过的。主要原因就是d会先给一个临时对象,再通过临时对象传给了 i, 临时对象具有常性,生命周期极短,不可修改。
对于上述的单参数的隐式类型转换,在C++98就支持了,但是对于多参数的隐式类型转化该如何操作呢? 这就有了新的"玩法",到了 C++11,就支持了多参数的隐式类型转换。
类比数组的初始化,当有多个数据时, 我们一般使用{}花括号将初始化的数据括起来。
cpp
class A
{
public:
A(int a, int b)
:_a(a)
,_b(b)
{}
private:
int _a;
int _b;
};
int main()
{
A a = {1, 2};
return 0;
}
同样的,如果直接采用引用的方法去初始化,也必须用const修饰才可以。
同样,explicit修饰构造函数之后,不允许隐式类型转换的发生。
下面可以对explicit关键字进行详细的总结了。
构造函数不仅可以构造与初始化对象, 对于接收单个参数的构造函数,还具有类型转换的作用。接收单个参数的构造函数具体表现:
- 构造函数只有一个参数
- 构造函数有多个参数,除第一个参数没有默认值外,其余参数都有默认值
- 全缺省构造函数
(C++11支持多参数的隐式类型转换)
4.static****成员
在上述的学习中,我们已经学习了大量的默认成员函数,对于某个类,我们该 如何统计共用这个类创建了多少对象,而又有多少对象正在使用呢?
此时可能会想到:用全局变量!我们新建两个全局变量,m,n 用m记录总共创建了多少对象;用n记录总共有多少对象正在使用。但其实,这并不是一种好方法。最大的弊端就是全局,既然是全局,那么任何人都有使用的权限,因此数据的安全性就无法保证。
这是又可能会想到:将m n放到类的内部,用私有访问限定符修饰,将他们保护起来。这时候麻烦就又来了:既然是类的成员变量,那每次进行类的创建都会生成一个独立的,m和n。这个独立的m n并不能统计总共的数目,这该怎么办呢?
这时static关键字就登场了,static表示静态的。它会将修饰的变量和函数放在静态区,静态区并不是存储在类的内部,因此此时的m n就是所有类共有的。同时还受到了访问限定符的保护。
static 成员
声明为 static的类成员 称为 类的静态成员 ,用 static 修饰的 成员变量 ,称之为 静态成员变量 ;用 static修饰 的 成员函数 ,称之为 静态成员函数 。 静态成员变量一定要在类外进行初始化。
要想统计一共创建了多少对象,只需要在函数构造、拷贝构造的函数内部进行m++,n++就好。因为所有对象的创建都是由这两步的得来的。(不需要在赋值重载中进行++,在调用赋值重载之前,对象就已经创建好了)要想统计还有多少对象正在使用,只需要在析构函数中让n--就好。
当然不能采取这种写法。原因是被static修饰之后, 改变量就不只是属于某个对象 ,而是属于所有对象,是一个全局的。此时便不能给出缺省值。缺省值是给初始化列表用的,但是 静态成员不走初始化列表 。 **因为静态变量属于所有对象,而初始化列表只能对某个对象进行初始化。**所以不能给缺省值。
因此静态成员变量在类的内部也只是一个生命, 需要在类的外部定义 。 定义的时候不需要加上static ,但是由于这是成员变量, 定义的时候需要解释在哪个域。
在C++中,静态成员变量应该在类的定义内部声明,但在类的外部定义。通常,声明放在头文件(.h文件)中,而定义则放在实现文件(.cpp文件)中。
这样做的原因是,静态成员变量属于类,而不是类的某个特定实例。因此,它们需要在类的外部定义,以便分配内存空间,并且这个定义在整个程序中只能有一次。如果在.h文件中定义,那么文件展开的时候,将会出现重复定义。
既然是定义,那就应该有类型、域、名字;域紧跟在名字的前面,在类型的后面。虽然是在外部定义,但这仍然是类的私有成员,只不过类似于存在公共代码区的类成员方法,允许在类的外部定义,类的内部只不过是一个声明。由于_m与_n是类的内部成员,在定义完成之后,外部便不随意修改。当我们多次实例化对象之后,访问的m n都是存储在静态区的同一个n m。
这时就可以统计成员了。
前面已经提及了,不仅有静态成员变量的存在,还存在静态成员函数。静态成员变量往往是与静态成员函数配套使用的。静态成员函数的一大特点是:没有this指针 。类比静态成员变量,在C++中,静态成员函数在类的外部定义时不需要再次使用static
关键字 。静态成员函数的声明中已经包含了static
关键字,因此在定义时不需要重复。
这便是两个静态成员函数的定义。
我们前面已经先说明了一下,静态成员函数没有this指针,因此在访问静态成员函数的时候就不需要新建一个新对象去给函数提供this指针。我们只需要声明这个函数在哪个域,就可以直接使用这个函数。
我们使用时,可以直接采用类域 :: 函数名的格式去访问静态成员方法。
但是静态函数正是因为没有this指针,也带来了一个缺陷:没法访问其他非静态成员变量。
x++的本质还是this->x++;没有this,也就意味之没法访问x。
对于静态成员而言,其存储的位置不是类的内部,因此如果将访问限定符号改为公有,以下代码并不牵扯解引用,也不会报错。
(扩展一个知识点:java中,常常用Get方法访问内部属性,此时不可以用引用返回,引用返回的本质还是给这个变量起了一个别名,可以在外部修改这个变量。public与private只是防止在类外面不能直接访问,并没有太强的作用。我们在此处访问的是别名!!!)
总结一下静态成员的特性:
-
静态成员为所有类对象所共享 ,不属于某个具体的对象 ,存放在静态区
-
静态成员必须在类外定义,定义时不添加static关键字,类中只是声明
-
类静态成员即可用 类名::静态成员 或者 对象.静态成员(对象也可以访问) 来访问
-
静态成员函数没有隐藏的this指针,不能访问任何非静态成员
-
静态成员也是类的成员,受public、protected、private 访问限定符的限制
【问题】
-
静态成员函数可以调用非静态成员函数吗? NO!
-
非静态成员函数可以调用类的静态成员函数吗? YES!
5.匿名对象
我们前面学的对象的实例化的形式是:类型 + 名字 + ()。在()中传入参数列表。
而匿名对象就是省去了名字。实例化的格式是:类型 + (参数列表);
cpp
class A
{
public:
A(int a, int b = 2)
:_a(a)
,_b(b)
{}
private:
int _a;
int _b;
};
int main()
{
A(1);
return 0;
}
A(1)就是一个匿名对象。
匿名对象的特点:
匿名对象的生命周期极短。出了本行接着结束,接着调用析构函数。
有名对象的特点:
生命周期在当前的局部域。
当我们不想造成额外创建一个对象带来的消耗时,便可以使用匿名对象。
6.友元
我们前面已经学习过了友元函数,但其实除了友元函数,还存在友元类的说法。
友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
友元分为: 友元函数 和 友元类
友元函数
友元函数 可以 直接访问 类的 私有 成员,它是 定义在类外部 的 普通函数 ,不属于任何类,但需要在类的内部声明,声明时需要加 friend关键字。
友元函数 可访问类的私有和保护成员,但 不是类的成员函数
友元函数 不能用const修饰(静态函数也不可以,因为都没有this指针)
友元函数 可以在类定义的任何地方声明, 不受类访问限定符限制
一个函数可以是多个类的友元函数
友元函数的调用与普通函数的调用原理相同
友元类
(不是一个人,你们一家人都是我我们家的好朋友,我直接给你们加都发一张家庭好人卡,整个家都可以进来)
友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。
类比友元函数。
假设A类声明了Func()函数是A的友元,那么Func() 函数便可以随便使用A的成员变量。
假设A类的内部声明了B类是A的友元,那么B类就可以随便使用A的成员变量(不要搞反)。
cpp
class A
{
friend class B;
public:
A(int a = 1, int b = 2)
:_a(a)
,_b(b)
{}
private:
int _a;
int _b;
};
class B
{
public:
void Print()
{
cout << aa._a << endl;
}
private:
A aa;
int _c;
int _d;
};
可以看到,我们在A的内部声明了B为A的友元,因此在B中创建的aa对象,就可以直接在B的内部访问该类的私有成员。同样也可以对aa的私有成员做出修改。
cpp
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;
};
当然,内部类也有一定的限制
1.友元关系是单向的,不具有交换性。
比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。
2.友元关系不能传递
如果B是A的友元,C是B的友元,则不能说明C时A的友元。
3.友元关系不能继承,在继承位置再给大家详细介绍。
说到这里,就槽点满满了 (讽刺的是,在上述的解释中,虽然B可以访问A,但是A不可以访问B;你跟兄弟心比心,兄弟跟你动脑筋O.o 所以不要搞反哦!) 所以不到万不得已,不要定义友元,其实友元也是非常少用的功能。 最好还是Get方法。
7.内部类
顾名思义:类的内部定义一个类
概念:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。
这个就像极了古代的藩属国,虽然小国要向大国供奉,但是小国仍要是一个独立的政权。
注意:内部类就是外部类的友元类 ,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员 。但是外部类不是内部类的友元。
(可以理解为外部类将内部类看成了自己的朋友,声明了内部类为自己的友元)
好比是小国派了一个小间谍,寄生在大国,小国知道大国的信息,但是大国不知道小国的信息。
特性:
-
内部类可以定义在外部类的public、protected、private都是可以的。如果B是A的内部类。B类受A类的类域及访问限定符的限制。
-
注意内部类可以直接访问外部类中的static成员 ,不需要外部类的对象/类名。
(之前我们说的,访问static成员,可以采用类名::函数名 的格式去访问。在内部类,可以直接访问所有函数名)
- sizeof(外部类)=外部类,和内部类没有任何关系。
五:再次理解类和对象
现实生活中的实体计算机并不认识,计算机只认识二进制格式的数据。如果想要让计算机认识现实生活中的实体,用户必须通过某种面向对象的语言,对实体进行描述,然后通过编写程序,创建对象后计算机才可以认识。比如想要让计算机认识洗衣机,就需要:
- 用户先要对现实中洗衣机实体进行抽象---即在人为思想层面对洗衣机进行认识,洗衣机有什么属性,有那些功能,即对洗衣机进行抽象认知的一个过程
- 经过1之后,在人的头脑中已经对洗衣机有了一个清醒的认识,只不过此时计算机还不清楚,想要让计算机识别人想象中的洗衣机,就需要人通过某种面相对象的语言(比如:C++、Java、Python等)将洗衣机用类来进行描述,并输入到计算机中
- 经过2之后,在计算机中就有了一个洗衣机类,但是洗衣机类只是站在计算机的角度对洗衣机对象进行描述的,通过洗衣机类,可以实例化出一个个具体的洗衣机对象,此时计算机才能洗衣机是什么东西。
- 用户就可以借助计算机中洗衣机对象,来模拟现实中的洗衣机实体了。
在类和对象阶段,大家一定要体会到, 类是对某一类实体(对象)来进行描述的,描述该对象具有哪些属性,哪些 方法,描述完成后就形成了一种新的自定义类型,才用该自定义类型就可以实例化具体的对象。