目录
一、类的定义
1.1.类定义格式
1.2.访问限定符
1.3.类域
二、实例化
2.1.实例化概念
2.2.对象大小
三、this指针
附加题:(增进对this指针的理解)
1.下面程序编译运行结果是()
2.下面程序编译运行结果是()
3. this指针存在内存哪个区域的 ()
四、C++和C语言实现Stack对比
4.1.C实现Stack代码
4.2.C++实现Stack代码
五、类的默认成员函数
六、构造函数
构造函数的特点:
七、析构函数
析构函数的特点:
八、拷贝构造函数
拷贝构造的特点:
九、赋值运算符重载
9.1.运算符重载
.*符号说明
9.2.赋值运算符重载
赋值运算符重载的特点:
附加练习:日期类实现
补充说明:
十、取地址运算符重载
10.1.const成员函数
10.2.取地址运算符重载
十一、再探构造函数
初始化列表总结:
附加题:
下面程序的运行结果是什么()
十二、类型转换
十三、static成员
附加题:
附加题:
设已经有A,B,C,D 4个类的定义,程序中A,B,C,D构造函数调用顺序为?()设已经有A,B,C,D 4个类的定义,程序中A,B,C,D析构函数调用顺序为?()
十四、友元
十五、内部类
十六、匿名对象
十七、对象拷贝时的编译器优化
一、类的定义
1.1.类定义格式
•在下端代码中, class为定义类的关键字,Stack为类的名字,{}中为类的主体,也是一个新的类型,可以定义出对象,以对象.函数名或者对象.对象名的形式调用内部成员,注意类定义结束时后面分号不能省略。类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。
#include<iostream>
using namespace std;
class Stack
{
public:
// 成员函数
void Init(int n = 4)
{
array = (int*)malloc(sizeof(int) * n);
if (nullptr == array)
{
perror("malloc申请空间失败");
return;
}
capacity = n;
top = 0;
}
void Push(int x)
{
// ...扩容
array[top++] = x;
}
int Top()
{
assert(top > 0);
return array[top - 1];
}
void Destroy()
{
free(array);
array = nullptr;
top = capacity = 0;
}
private:
// 成员变量
int* array;
size_t capacity;
size_t top;
}; // 分号不能省略
int main()
{
Stack st;
st.Pop();//调用成员函数
st.Push(1);//调用成员函数并传参
//st.a;//调用成员变量
return 0;
}
我们知道C++是一门面向对象的语言,对象是现实世界事物的抽象表示。以去银行取钱为例,我们就需要考虑人的个人信息、带卡、取钱、不同银行机器的信息,取钱的多少、操作等,我们通过通过函数一步一步实现,但是这样的做法对于大型复杂事件非常不好,往往会出现一件事改变就需要大面积修改的情况;对于面向对象来说,我们发现整个过程就是人与机器的交互,人与机器就可以视作C++中的对象,这时比如人来说,人的姓名、银行卡密码等都是人所用的基础信息值即属性,取钱等行为就是人可以支持实现的行为即方法,机器的设备信息等是机器的属性,提供的操作就是机器的方法。我们发现面向对象将一系列的过程因素归纳为不同对象之间的交互。
过去,我们通过C语言struct定义结构最多将一系列的数据封装在一起,但是实现功能的函数还是分散的,而在C++中,class定义的类将数据与其相关的函数封装在一起,我们把这些数据叫做类的属性或成员变量,把函数叫做方法或者成员函数。这样类中就封装了相关的属性与方法,更好的达到了高内聚低耦合的效果。
• 为了区分成员变量,一般习惯上成员变量会加一个特殊标识,如成员变量前面或者后面加_ 或m开头,注意C++中这个并不是强制的,只是一些惯例,具体看公司的要求。
class Date
{
public:
void Init(int year, int month, int day)
{
year = year;
month = month;
day = day;
}
//void Init(int y, int m, int d)//修改后的代码
//{
// year = y;
// month = m;
// day = d;
//}
private:
int year;
int month;
int day;
};
以上段日期类为例,当我们初始化需要传递参数时,依据我们以前的C语言中的习惯,我们的参数的名称会与日期类成员变量的名称冲突,因此我们可以将参数修改成只有首字母的形式,但是这样的形式损害了程序的可读写,因此一般为了区分成员变量,习惯上成员变量我们会加一个特殊标识,如_ 或者 m开头或者加载后面或者使用驼峰法,这个C++没有具体规定,看具体公司要求
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
// 为了区分成员变量,一般习惯上成员变量
// 会加一个特殊标识,如_ 或者 m开头
int _year; // year_ m_year
int _month;//或者year_ m_year mYear
int _day;
};
int main()
{
Date d;
d.Init(2024, 3, 31);
return 0;
}
• C++中struct也可以定义类,C++兼容C中struct的用法,同时struct升级成了类,struct名称就可以代表类型,明显的变化是struct中可以定义函数,一般情况下我们还是推荐用class定义类,只有类的成员可以公开的时候我们会用struct定义。
#include<iostream>
using namespace std;
// C++升级struct升级成了类
// 1、类里面可以定义函数
// 2、struct名称就可以代表类型
// C++兼容C中struct的用法
typedef struct ListNodeC//C语言中链表节点定义
{
struct ListNodeC* next;
int val;
}LTNode;
// 不再需要typedef,ListNodeCPP就可以代表类型
struct ListNodeCPP
{
void Init(int x)
{
next = nullptr;
val = x;
}
ListNodeCPP* next;//指向同类型节点的指针
int val;
};
int main()
{
return 0;
}
• 定义在类面的成员函数默认为inline。在类中声明,类外定义就不是inline。
1.2.访问限定符
• C++一种实现封装的方式是用类将对象的属性与方法结合在一块,让对象更加完善,通过设置访问权限选择性的将其接口提供给外部的用户使用。
• public修饰的成员在类外可以直接被访问;protected和private修饰的成员在类外不能直接被访问protected和private现阶段可以视为一样的,后续继承才能体现出他们的区别(本文在此不做过多介绍)。• 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止,如果后面没有访问限定符,作用域就到 }即类结束。
• class定义成员没有被访问限定符修饰时默认为private,struct默认为public。
• 一般成员变量都会被限制为private/protected,需要给别人使用的成员函数会放为public。
如果我们想要使用者可以从外部访问类内部的数据或者函数,我们可以将其定义为公有,如果不想外部的人访问,可以定义为私有(定义为私有也可以防止外部的人修改内部的数据)。
同时访问限定符的是可以同时定义多个的,同时访问限定符定义顺序不固定,不过更普遍来说,我们一般public\private等访问限定符一种定义一个就好(避免太过杂乱),同时一般来说我们将定义的数据(一般用private)放在类下方,方法(一般用public)放在上方。
#include<iostream>
using namespace std;
class Stack//类定义在全局位置
{
void Destory(int x)//class中未写访问限定符,默认私有
{}
public://访问限定符可可以定义多个
void Push(int x)
{}
private://访问限定符定义顺序不一定
int* a;
int top;
int capacity;
public:
void Pop()
{}
private:
int Top()
{
return 0;
}
};
int main()
{
Stack st;//Stack是新类型,可以定义对象
st.Pop();//可以访问公有的
st.Push(1);
//st.a;//不能访问私有的
return 0;
}
1.3.类域
• 类定义了一个新的作用域,类的所有成员都在类的作用域中,在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域。(类域没有命名空间域using展开的概念)
• 类域影响的是编译的查找规则,下面程序中Init如果不指定类域Stack,那么编译器就把Init当成全局函数,那么编译时,找不到array等成员的声明/定义在哪里,就会报错。指定类域Stack,就是知道Init是成员函数,当前域找不到的array等成员,就会到类域中去查找。
#include<iostream>
using namespace std;
class Stack
{
public:
// 成员函数
void Init(int n = 4);
private:
// 成员变量
int* array;
size_t capacity;
size_t top;
};
// 声明和定义分离,需要指定类域
void Stack::Init(int n)
{
array = (int*)malloc(sizeof(int) * n);
if (nullptr == array)
{
perror("malloc申请空间失败");
return;
}
capacity = n;
top = 0;
}
int main()
{
Stack st;
st.Init();
return 0;
}
二、实例化
2.1.实例化概念
• 用类类型在物理内存中创建对象的过程,称为类实例化出对象。
• 类是对象进行一种抽象描述,是一个模型一样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,用类实例化出对象时,才会分配空间。
• 一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量。
打个比方,类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,设计图规划了有多少个房间,房间大小功能等,但是并没有实体的建筑存在,也不能住人,用设计图修建出房子,房子才能住人。同样类就像设计图一样,不能存储数据,因此,我们也不能通过指定类域的方式访问成员变量,只有实例化出的对象才会分配物理内存存储数据,才可以访问成员变量。
#include<iostream>
using namespace std;
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
Date d1;
Date d2;
d1.Init(2024, 3, 31);
d1.Print();
d2.Init(2024, 7, 5);
d2.Print();
return 0;
}
2.2.对象大小
分析一下类对象中哪些成员呢?类实例化出的每个对象,都有独立的数据空间,所以对象中肯定包含成员变量用来存储数据。那么成员函数是否包含呢?不同对象要存储的数据不同,对象中存储成员变量很有必要,但是成员函数是一摸一样的,如果存储,我们难道每一个对象中都存一份相同的函数吗?
首先函数被编译后是一段指令,对象中没办法存储,这些指令存储在一个单独的区域(代码段),那么对象中非要存储的话,只能是成员函数的指针。再分析一下,对象中是否有存储指针的必要呢?
我们以日期类为例,Date实例化d1和d2两个对象,d1和d2都有各自独立的成员变量_year/_month/_day存储各自的数据,但是d1和d2的成员函数Init/Print指针却是一样的,存储在对象中就浪费了。如果用Date实例化100个对象,那么成员函数指针就重复存储100次,太浪费了。
这里需要再额外补充一下,其实函数指针是不需要存储的,函数指针是一个地址,调用函数时被编译成汇编指令[call 地址],再jump跳转到函数存储的地方,调用函数。
(编译器在编译链接时,就要找到函数的地址,不是在运行时找,只有动态多态是在运行时找,就需要存储函数地址,这个本文不深入讨论。)
所以总的来说,成员函数的指令都是存储在一个单独的区域,对象调用成员函数时,底层是直接call函数的地址再jump跳转到函数存储的地方,对象本身既不存储函数的指令,也不存储函数指针。我们计算对象内存大小时只计算成员变量。
上面我们分析了对象中只存储成员变量,C++规定类实例化的对象也要符合内存对齐的规则。(注:C++的内存对齐规则与C语言结构体内存对齐规则完全一致)
C/C++内存对齐规则
• 第一个成员在与类/结构体偏移量为0的地址处。
• 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
• 注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
• VS中默认的对齐数为8
• 类/结构体总大小需要保持为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
• 如果嵌套了类/结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,类/结构体的整体大小就是所有最大对齐数(含嵌套类/结构体的对齐数)的整数倍。
计算类A实例化对象内存大小,由上文知成员函数不存在对象内部,我们只计算成员变量。char _ch占1字节小于8字节,_char从0开始占一个字节,int _i占4字节小于8字节,但是前面_ch存储后内存对齐到1,不是4的整数倍,我们空三个字节从相对0偏移4个字节处开始存储,最终存储内存为8,是最大对齐数4的整数倍,内存计算结束。
(由于C++与C内存对齐规则完全一致,笔者此处未进行详细介绍,笔者对于阅读本文章前未学习过C语言的读者造成的不便致以诚挚的歉意。)
#include<iostream>
using namespace std;
// 计算一下A/B/C实例化的对象是多大?
class A
{
public:
void Print()
{
cout << _ch << endl;
}
private:
char _ch;
int _i;
};
class B
{
public:
void Print()
{
//...
}
};
class C
{};
int main()
{
A a;
B b;
C c;
cout << sizeof(a) << endl;
cout << sizeof(b) << endl;
cout << sizeof(c) << endl;
return 0;
}
上面的程序运行后,我们看到没有成员变量的B和C类对象的大小是1,为什么没有成员变量还要给1个字节呢?因为如果一个字节都不给,怎么表示对象存在过呢!所以这里给1字节,纯粹是为了占位标识对象存在。这样我们进行取地址等操作也好操作一点,(补充一下,这类我们叫做空类,在后续的仿函数中会常常使用。)
三、this指针
Date类中有 Init 与 Print 两个成员函数,上文我们说过函数不存在对象中,存储在公共区域内,不同对象调用的实际上是同一个函数,那么导致读者就应该会疑惑函数体中没有关于不同对象的区分,那当d1、d2调用Init和Print函数时,并传递参数时,该函数是如何知道应该访问的是d1对象还是d2对象呢?应该接收的d1传递的参数还是d2传递的参数呢?那么这里就要看到C++给了一个隐含的this指针解决这里的问题。
• 编译器编译后,类的成员函数默认都会在形参第一个位置,增加一个当前类类型的指针,叫做this指针。比如Date类的Init的真实原型为, void Init(Date* const this, int year,int month, int day),这里的const修饰this本身,也就是说我们不能在函数内部改变this指向。
• 类的成员函数中访问成员变量,本质都是通过this指针访问的,如Init函数中给_year赋值, _year = year实际上是this->_year = year;
• C++规定不能在实参和形参的位置显式的写this指针(编译时编译器会自动处理处理不需要程序员处理),但是可以在函数体内显式使用this指针。
#include<iostream>
using namespace std;
// 计算一下A/B/C实例化的对象是多大?
class A
{
public:
void Print()
{
cout << _ch << endl;
}
private:
char _ch;
int _i;
};
class B
{
public:
void Print()
{
//...
}
};
class C
{};
int main()
{
A a;
B b;
C c;
cout << sizeof(a) << endl;
cout << sizeof(b) << endl;
cout << sizeof(c) << endl;
return 0;
}
附加题:(增进对this指针的理解)
1.下面程序编译运行结果是()
A、编译报错 B、运行崩溃 C、正常运行
答案:C
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << this << endl;
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
解析:首先上段代码,没有语法错误不会出现编译错误。以C语言的角度来看上一段代码对空指针解应用的,程序应该崩溃啊,为什么会是正常运行呢?上文我们已经说过对象中不存储成员函数,p->Print这一步实质上是call Print函数的地址,并jump跳转过去调用,并将p的地址作为this指针传递给Print,同时我们看到Print只是打印字符串和this的地址,并不会访问成员变量,不会解引用this指针,所以程序可以正常运行。
void Print()
{
cout << "A::Print()" << endl;
}所以总的来说p->Print()这一步底层只是调用函数并传递对象地址,还没有发生解引用,因为Printf是p的成员函数,这里这么写是为了调用函数,符合语法,不至于发生编译错误
2.下面程序编译运行结果是()
A、编译报错 B、运行崩溃 C、正常运行
答案:B
3. this指针存在内存哪个区域的 ()
A. 栈 B.堆 C.静态区 D.常量区 E.对象里面
答案:A
解析:this指针是在调用成员函数时作为参数传递的,因此与参数一样存储在栈上,补充说明的是因为this需要经常作为参数传入函数,所以有的编译器会对此进行优化,将this存放在寄存器上,减少重复创建的消耗。
四、C++和C语言实现Stack对比
面向对象三大特性:封装、继承、多态,下面的对比我们可以初步了解一下封装。
4.1.C实现Stack代码
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
assert(ps);
// 满了, 扩容
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
int main()
{
ST s;
STInit(&s);
STPush(&s, 1);
STPush(&s, 2);
STPush(&s, 3);
STPush(&s, 4);
while (!STEmpty(&s))
{
printf("%d\n", STTop(&s));
STPop(&s);
}
STDestroy(&s);
return 0;
}
4.2.C++实现Stack代码
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
assert(ps);
// 满了, 扩容
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
int main()
{
ST s;
STInit(&s);
STPush(&s, 1);
STPush(&s, 2);
STPush(&s, 3);
STPush(&s, 4);
while (!STEmpty(&s))
{
printf("%d\n", STTop(&s));
STPop(&s);
}
STDestroy(&s);
return 0;
}
通过上面两份代码对比,我们发现C++实现Stack形态上还是发生了挺多的变化,但是底层和逻辑上没啥变化。
• C++中数据和函数都放到了类里面,通过访问限定符进行了限制,不能再随意通过对象直接修改数据,这是C++封装的一种体现,这个是最重要的变化。这里的封装的本质是一种更严格规范的管理,避免出现乱访问修改的问题。当然封装不仅仅是这样的,我们后面还需要不断的去学习。
• C++中有一些相对方便的语法,比如Init给的缺省参数会方便很多,成员函数每次不需要传对象地址,因为this指针隐含的传递了,方便了很多,使用类型不再需要typedef用类名就很方便
• 在目前这个C++入门阶段实现的Stack看起来变了很多,但是实质上变化不大。后面看STL中的用适配器实现的Stack,大家可以更多的感受C++的魅力。
五、类的默认成员函数
默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。一个类,我们不写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解一下即可。(其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值,这个笔者后续后面再讲解,本文不做过多涉及。)默认成员函数很重要,也比较复杂,我们要从两个方面去学习:
• 第一:我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求。
• 第二:编译器默认生成的函数不满足我们的需求,我们需要自己实现,那么如何自己实现?
六、构造函数
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象(我们常使用的局部对象是栈帧创建时,空间就开好了),而是对象实例化时初始化对象。构造函数的本质是要替代我们以前Stack和Date类中写的Init函数的功能,构造函数自动调用的特点就完美的替代的了Init。
构造函数的特点:
1. 函数名与类名相同。
2. 无返回值。 (返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)
3. 对象实例化时系统会自动调用对应的构造函数。
4. 构造函数可以重载。
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void STDestroy(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
assert(ps);
// 满了, 扩容
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
bool STEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void STPop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
ps->top--;
}
STDataType STTop(ST* ps)
{
assert(ps);
assert(!STEmpty(ps));
return ps->a[ps->top - 1];
}
int STSize(ST* ps)
{
assert(ps);
return ps->top;
}
int main()
{
ST s;
STInit(&s);
STPush(&s, 1);
STPush(&s, 2);
STPush(&s, 3);
STPush(&s, 4);
while (!STEmpty(&s))
{
printf("%d\n", STTop(&s));
STPop(&s);
}
STDestroy(&s);
return 0;
}
之前我们调用成员函数都是通过对象.函数名的形式,现在构造函数在创建对象时会自动调用,完成对象初始化,如果是需要传参来进行初始化,那么我们需要在对象后面加括号并输入参数。如果不传参数就不加(),否则无法与函数声明区分会编译报错。
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数(因为不清楚具体参数个数只能生成无参的),一旦用户显式定义编译器将不再生成。
6. 无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数,都叫做默认构造函数。但是这三个函数有且只有一个存在,不能同时存在。无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。要注意很多人会认为默认构造函数是编译器默认生成那个叫默认构造,实际上无参构造函数、全缺省构造函数也是默认构造,总结一下就是不传实参就可以调用的构造就叫默认构造。
7. 我们不写,编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器(因为不同的编译器行为不一致,即使当前平台初始化内置类型,为了程序的可移植性,我们最好都要自己写构造函数)。对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要用初始化列表才能解决,初始化列表下文再细细讲解。
说明:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:int/char/double/指针等,自定义类型就是我们使用class/struct等关键字自己定义的类型。
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// ...
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
//编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq;
return 0;
}
补充一下:上段代码中Stack类因为编译器对于内置类型不做处理,对于指针这些需要申请资源的地方,我们需要自己写构造函数;但是对于MyQueue(通过两个栈模拟实现的队列)这种通过自定义类型组成的自定义类型,编译器自动生成的构造函数在初始化它的自定义类型成员会去调用该成员的构造函数即调用StacK中我们写的默认构造函数。
需要注意的是只有不传参的构造函数是默认构造,如果我们将Stack类中构造改为需要传参的,那Myqueue就会因为没有可用的默认构造报错。对于这种需要传参数的构造函数,我们只能通过初始化列表解决。
总结:大多数情况,构造函数都需要我们自己去实现,少数情况类似Myqueue且Stack有默认构造时,Myqueue自动生成就可以用,应写尽写。
七、析构函数
析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们在C语言中Stack实现的Destroy功能,而像Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。
析构函数的特点:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值。 (这里跟构造类似,也不需要加void)
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
4. 对象生命周期结束时,系统会自动调用析构函数。
5. 跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定类型成员会调用他的析构函数。
6. 还需要注意的是我们显示写析构函数,对于自定义类型成员也会调用他的析构,也就是说自定义类型成员无论什么情况都会自动调用析构函数。
7. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如Date;如果默认生成的析构就可以用,也就不需要显示写析构,如MyQueue;但是有资源申请时,一定要自己写析构,否则会造成资源泄漏,如Stack。
8. 一个局部域的多个对象,C++规定后定义的先析构。(存储在栈桢内,跟栈桢后进先出的特性有关,减少内存空隙)
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
~Stack()//Stack的析构函数
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
//编译器默认生成MyQueue的析构函数调用了Stack的析构,释放的Stack内部的资源
/*~MyQueue()// 显示写析构,也会自动调用Stack的析构
{}*/
private:
Stack pushst;
Stack popst;
};
int main()
{
Stack st;
MyQueue mq;
return 0;
}
八、拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,可以没有其他参数,但是如果有其他参数,那任何额外的参数都要有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是一个特殊的构造函数。
拷贝构造的特点:
1. 拷贝构造函数是构造函数的一个重载。
2. 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。 拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。
3. C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成。
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 编译报错:error C2652: “Date”: 非法的复制构造函数: 第一个参数不应是“Date”
//Date(Date d)
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
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 Func1(Date d)
{
cout << &d << endl;
d.Print();
}
int main()
{
Date d1(2024, 7, 5);
// C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里传值传参要调用拷贝
//构造,所以这里的d1传值传参给d要调用拷贝构造完成拷贝,传引用传参可以较少这里的拷贝
Func1(d1);
cout << &d1 << endl;
// 这里可以完成拷贝,但是不是拷贝构造,只是一个普通的构造
Date d2(&d1);
d1.Print();
d2.Print();
//这样写才是拷贝构造,通过同类型的对象初始化构造,而不是指针
Date d3(d1);
d2.Print();
// 也可以这样写,这里也是拷贝构造
Date d4 = d1;
d2.Print();
return 0;
}
补充说明:为什么拷贝构造的第一个参数必须是引用呢?因为C++规定自定义类型传值传参会调用拷贝构造,这样当我们用已初始化的类初始化新对象时,自定义的类作为参数传递,这时就需要调用拷贝构造将类拷贝到形参,但是由于我们拷贝构造的参数写的是类类型的对象不是引用,这样调用拷贝构造时,就需要先将类对象拷贝到拷贝对象形参,而拷贝这一行为又会调用拷贝参数………所以如果拷贝参数第一个参数必须是类类型对象的引用。
总结一下,既然这种传参数会调用拷贝,有消耗,建议以后传参数可以直接传引用。不想改变类对象就是用const。
4. 若未显式定义拷贝构造,编译器会生成自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造。
5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现MyQueue的拷贝构造。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写拷贝构造,否则就不需要。
#include<iostream>
using namespace std;
typedef int STDataType;
class Stack
{
public:
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
Stack(const Stack& st)
{
// 需要对_a指向资源创建同样大的资源再拷贝值
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败!!!");
return;
}
memcpy(_a, st._a, sizeof(STDataType) * st._top);
_top = st._top;
_capacity = st._capacity;
}
void Push(STDataType x)
{
if (_top == _capacity)
{
int newcapacity = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
// 两个Stack实现队列
class MyQueue
{
public:
private:
Stack pushst;
Stack popst;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(2);
// Stack不显示实现拷贝构造,用自动生成的拷贝构造完成浅拷贝
// 会导致st1和st2里面的_a指针指向同一块资源,析构时会析构两次,程序崩溃
Stack st2 = st1;
MyQueue mq1;
// MyQueue自动生成的拷贝构造,会自动调用Stack拷贝构造完成pushst/popst
// 的拷贝,只要Stack拷贝构造自己实现了深拷贝,他就没问题
MyQueue mq2 = mq1;
return 0;
}
如果我们未显式写拷贝构造,编译器自动生成的拷贝构造对内置类型会进行浅拷贝(一个字节一个字节的拷贝),对自定义类型会调用它的拷贝构造,看上去编译器自己的行为做的非常的好了,我们似乎并不用做什么。
但是浅拷贝只是简单的按照字节进行拷贝,对于像指针这种指向资源的变量,指针本身存储的只是地址,进行浅拷贝只是将指针本身存储的地址拷贝到新的指针,这样新旧指针指向的是同一块地址。
同时我们知道类的析构函数是自动调用的,这样就会出现新对象再次析构旧有的对象指向的资源第二次,这样同一块空间析构两次,程序崩溃。
6. 传值返回会产生一个临时对象调用拷贝构造,传值引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回。
九、赋值运算符重载
9.1.运算符重载
• 当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错(编译时会先去类内部看是否有重载函数,找不到再去全局找)。
• 运算符重载是具有特殊名字的函数,他的名字是由operator和后面要定义的运算符共同构成。和其他函数一样,它也具有其返回类型和参数列表以及函数体。
• 重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。
• 如果一个重载运算符函数是成员函数,则它的第一个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。
• 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。
• 不能通过连接语法中没有的符号来创建新的操作符:比如operator@。
#include<iostream>
using namespace std;
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 Getyeat() {
return _year;
}
int Getmonth() {
return _month;
}
int Getday() {
return _day;
}
private:
int _year;
int _month;
int _day;
};
// 重载为全局的面临对象访问私有成员变量的问题
// 有几种方法可以解决:
// 1、成员放公有
// 2、Date提供getxxx函数
// 3、友元函数
// 4、重载为成员函数
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
int main()
{
Date d1(2024, 7, 5);
Date d2(2024, 7, 6);
// 运算符重载函数可以显示调用
operator==(d1, d2);
// 编译器会转换成 operator==(d1, d2);
d1 == d2;
return 0;
}
补充说明的是当我们将运算符重载为全局函数时,函数是无法直接访问类的私有成员,这时候有三种解决方式,第一种直接将成员变量设置为公有,不过这种方式外部成员可以修改数据,这种凡是不推荐;第二种在类中定义用来返回数据的函数,这种方式在java中非常常见;第三种是使用友元,不过这种方式不太推荐,具体下文详细介绍。
• .* :: sizeof ?: . 注意以上5个运算符不能重载。(选择题里面常考,大家可以记一下) 重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如: intoperator+(int x, int y)
.*符号说明
#include<iostream>
using namespace std;
// 编译报错:“operator +”必须至少有一个类类型的形参
int operator+(int x, int y)
{
return x - y;
}
class A
{
public:
void func()
{
cout << "A::func()" << endl;
}
};
//typedef void(* PF)();普通函数指针类型写法
typedef void(A::* PF)(); //成员函数指针类型,加上A::指明域
int main()
{
// C++规定成员函数要加&才能取到函数指针
PF pf = &A::func;
A obj;//定义ob类对象temp
// 对象调用成员函数指针时,使用.*运算符
(obj.*pf)();
return 0;
}
补充说明一下之前我们回调成员函数时是以(*pf)();这种形式进行回调的,但是成员函数是会默认传递this指针的,而且this指针不能在参数位置显式写,所以我们只能通过(obj.*pf)();形式回调。
需要说明的是之所以取成员函数的指针需要额外加上&,是因为对象会默认向成员函数传递this指针,需要通过加上&来明确告诉编译器这是成员函数,以此帮助编译器正确处理函数与this的关系
• 一个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如日常生活中日期间比较相差多少天有意义,所以Date类重载operator-就有意义,但是重载operator+就没有意义。
• 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。C++规定,后置++重载时,增加一个int形参,跟前置++构成函数重载,方便区分。
#include<iostream>
using namespace std;
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;
}
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
Date& operator++()
{
cout << "前置++" << endl;
//...
return *this;
}
Date operator++(int)
{
Date tmp;
cout << "后置++" << endl;
//...
return tmp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 7, 5);
Date d2(2024, 7, 6);
// 运算符重载函数可以显示调用
d1.operator==(d2);
// 编译器会转换成 d1.operator==(d2);
d1 == d2;
// 编译器会转换成 d1.operator++();
++d1;
// 编译器会转换成 d1.operator++(0);
d1++;
return 0;
}
补充说明一下,Date operator++(int)后写不写变量名都没有关系,写d1.operator++(int i)也是可以的;并且括号中的int是用来区分前置++和后置++,调用函数时写具体的数值也没有关系,如d1.operator++(0),这里不会接收值。
• 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了 对象<<cout,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第一个形参位置就可以了,第二个形参位置当类类型对象。
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d)
{
cout << "请依次输入年月日:>";
in >> d._year >> d._month >> d._day;
if (!d.CheckDate())
{
cout << "日期非法" << endl;
}
return in;
}
补充说明:重载<<或>>,我们要将自定义类型内部的数值输出到标准输出流或或从标准输入流中提取输入,为了能支持cout<<d1这种格式输出,cout在左边是作为第一个参数传入重载函数的,所以我们必须<<或>>函数重载为全局函数,而不是成员函数(成员函数默认第一个参数传入this,d1<<的调用形式不符合使用习惯)。cout是ostream类的对象,cin是istream类的对象,在作为参数时,cout、cin规定不支持拷贝,必须使用引用。为了避免与系统冲突,重载函数实现内部不要直接写cout、cin。
因为我们是要将数据输入到cout上所以参数类型ostream不能使用const修饰。从cin上提取数据,接收的对象不能使用const修饰。
9.2.赋值运算符重载
赋值运算符重载是一个默认成员函数,用于完成两个已经存在的对象直接的拷贝赋值,这里要注意跟拷贝构造区分,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象。
赋值运算符重载的特点:
1. 赋值运算符重载是一个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成const 当前类类型引用,否则会传值传参会有拷贝
2. 有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。
3. 没有显式实现时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。
4. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载,也不需要我们显示实现MyQueue的赋值运算符重载。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写赋值运算符重载,否则就不需要。
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << " Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
// 传引用返回减少拷贝
// d1 = d2;
Date& operator=(const Date& d)
{
// 不要检查自己给自己赋值的情况
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
// d1 = d2表达式的返回对象应该为d1,也就是*this
return *this;//内置类型=支持a3 = a2 = a1的连续赋值形式
} //因此这里我们返回赋值后的结果来支持连续赋值
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 需要注意这里是拷贝构造,不是赋值重载
// 请牢牢记住赋值重载完成两个已经存在的对象直接的拷贝赋值
Date d1(2024, 7, 5);//拷贝构造
Date d2(d1);
Date d3(2024, 7, 6);
Date d4 = d1;
// 拷贝构造用于一个对象拷贝初始化给另一个要创建的对象
d1 = d3;//赋值重载
return 0;
}
附加练习:日期类实现
#pragma once
#include<iostream>
using namespace std;
#include<assert.h>
class Date
{
// 友元函数声明
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1);
void Print() const;
// 日期获取频繁调,用直接定义类里面,他默认是inline,减少栈桢的消耗
int GetMonthDay(int year, int month)
{
assert(month > 0 && month < 13);
static int monthDayArray[13] = { -1, 31, 28, 31, 30, 31, 30,
31, 31, 30, 31, 30, 31 };
// 365天 5h +
if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year
% 400 == 0))
{
return 29;
}
else
{
return monthDayArray[month];
}
}
bool CheckDate();
bool operator<(const Date& d) const;
bool operator<=(const Date& d) const;
bool operator>(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator==(const Date& d) const;
bool operator!=(const Date& d) const;
// d1 += 天数
Date& operator+=(int day);
Date operator+(int day) const;
// d1 -= 天数
Date& operator-=(int day);
Date operator-(int day) const;
// d1 - d2
int operator-(const Date& d) const;
// ++d1 -> d1.operator++()
Date& operator++();
// d1++ -> d1.operator++(0)
// 为了区分,构成重载,给后置++,强行增加了一个int形参
// 这里不需要写形参名,因为接收值是多少不重要,也不需要用
// 这个参数仅仅是为了跟前置++构成重载区分
Date operator++(int);
Date& operator--();
Date operator--(int);
// 流插入
// 不建议定义成成员函数,因为Date* this占据了一个参数位置,使用d<<cout不符合习惯
//void operator<<(ostream& out);
private:
int _year;
int _month;
int _day;
};
// 重载
ostream& operator<<(ostream& out, const Date& d);
istream& operator>>(istream& in, Date& d);
// Date.cpp
#include"Date.h"
bool Date::CheckDate()
{
if (_month < 1 || _month > 12
|| _day < 1 || _day > GetMonthDay(_year, _month))
{
return false;
}
else
{
return true;
}
}
Date::Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
if (!CheckDate())
{
cout << "日期非法" << endl;
}
}
void Date::Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
// d1 < d2
bool Date::operator<(const Date& d) const
{
if (_year < d._year)
{
return true;
}
else if (_year == d._year)
{
if (_month < d._month)
{
return true;
}
else if (_month == d._month)
{
return _day < d._day;
}
}
return false;
}
// d1 <= d2
//重载<>=,我们不需要全部实现,只需要重载其中的两个判断符,其他符号可以直接复用已经实现的符号实现
bool Date::operator<=(const Date& d) const
{
return *this < d || *this == d;
}
bool Date::operator>(const Date& d) const
{
return !(*this <= d);
}
bool Date::operator>=(const Date& d) const
{
return !(*this < d);
}
bool Date::operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
// d1 += 50
// d1 += -50
Date& Date::operator+=(int day)
{
if (day < 0)//对于输入负的天数做特殊处理
{
return *this -= -day;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
++_month;
if (_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
Date Date::operator+(int day) const
{
Date tmp = *this;
tmp += day;
return tmp;
}
// d1 -= 100
Date& Date::operator-=(int day)
{
if (day < 0)//对于输入负的天数做特殊处理
{
return *this += -day;
}
_day -= day;
while (_day <= 0)
{
--_month;
if (_month == 0)
{
_month = 12;
_year--;
}
// 借上一个月的天数
_day += GetMonthDay(_year, _month);
}
return *this;
}
Date Date::operator-(int day) const
{
Date tmp = *this;
tmp -= day;
return tmp;
}
//++d1
Date& Date::operator++()
{
*this += 1;
return *this;
}
// d1++
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
Date& Date::operator--()
{
*this -= 1;
return *this;
}
Date Date::operator--(int)
{
Date tmp = *this;
*this -= 1;
return tmp;
}
// d1 - d2
int Date::operator-(const Date& d) const
{
Date max = *this;
Date min = d;
int flag = 1;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++min;
++n;
}
return n * flag;
}
ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
istream& operator>>(istream& in, Date& d)
{
cout << "请依次输入年月日:>";
in >> d._year >> d._month >> d._day;
if (!d.CheckDate())
{
cout << "日期非法" << endl;
}
return in;
}
// Test.cpp
#include"Date.h"
void TestDate1()
{
// 这里需要测试一下大的数据+和-
Date d1(2024, 4, 14);
Date d2 = d1 + 30000;
d1.Print();
d2.Print();
Date d3(2024, 4, 14);
Date d4 = d3 - 5000;
d3.Print();
d4.Print();
Date d5(2024, 4, 14);
d5 += -5000;
d5.Print();
}
void TestDate2()
{
Date d1(2024, 4, 14);
Date d2 = ++d1;
d1.Print();
d2.Print();
Date d3 = d1++;
d1.Print();
d3.Print();
/*d1.operator++(1);
d1.operator++(100);
d1.operator++(0);
d1.Print();*/
}
void TestDate3()
{
Date d1(2024, 4, 14);
Date d2(2034, 4, 14);
int n = d1 - d2;
cout << n << endl;
n = d2 - d1;
}
void TestDate4()
{
Date d1(2024, 4, 14);
Date d2 = d1 + 30000;
// operator<<(cout, d1)
cout << d1;
cout << d2;
cin >> d1 >> d2;
cout << d1 << d2;
}
void TestDate5()
{
const Date d1(2024, 4, 14);
d1.Print();
//d1 += 100;
d1 + 100;
Date d2(2024, 4, 25);
d2.Print();
d2 += 100;
d1 < d2;
d2 < d1;
}
int main()
{
return 0;
}
补充说明:
+=这边实现的是进位逻辑,将天数加到day上,然后取当前月份天数判断,如果大于,day减去当前月份数,月份++,如果月份+到13,那么年++,月份置为1;不大于循环结束。
-=的函数重载我们这边实现的是借位逻辑,将day减去天数,如果为负数说明往前天数较多,要查询的天数肯定在前某一个月份,这时候先需要向month借它前一个月的天数加到day,month--,如果month减到0,那么month置为0,年减去一,如果day借到天数后依然是<=0,说明我们需要继续借,反之循环结束。
对与复用实现的+=或者-=实现+和-,有的读者应该会疑惑,可不可以反过来呢?可以反过来实现,但是相对来说消耗较大,不推荐,如上图,我们发现在代码逻辑一致的情况下,不管是实现还是复用的情况下,-或者+都有存储与返回原值的构造消耗,这无法避免,也就是说上图中第一段的代码消耗不变,但是当我们复用-的代码取构造-=的时候,先经历-,进行两次构造,再将值赋this,又是一次构造。
总结来说对比后我们发现使用先写-再复用-的逻辑写-=,会多出三次拷贝的消耗,因此更推荐先写-=,再复用代码写-。
十、取地址运算符重载
10.1.const成员函数
• 将const修饰的成员函数称之为const成员函数,const修饰成员函数放到成员函数参数列表的后面。
• const实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。const 修饰Date类的Print成员函数,Print隐含的this指针由Date* const this 变为const Date* const this
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// void Print(const Date* const this) const
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 这里非const对象也可以调用const成员函数是一种权限的缩小
Date d1(2024, 7, 5);
d1.Print();
const Date d2(2024, 8, 5);
d2.Print();
return 0;
}
10.2.取地址运算符重载
取地址运算符重载分为普通取地址运算符重载和const取地址运算符重载,一般这两个函数编译器自动生成的就可以够我们用了,不需要去显示实现。除非一些很特殊的场景,比如我们不想让别人取到当前类对象的地址,就可以自己实现一份,胡乱返回一个地址。
class Date
{
public:
Date* operator&()
{
return this;
// return nullptr;
}
const Date* operator&()const
{
return this;
// return nullptr;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
十一、再探构造函数
• 之前我们实现构造函数时,初始化成员变量主要使用函数体内赋值,构造函数初始化还有一种方式,就是初始化列表,初始化列表的使用方式是以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
• 每个成员变量在初始化列表中只能出现一次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地方。
• 引用成员变量,const成员变量,没有默认构造的类类型变量,必须放在初始化列表位置进行初始化,否则会编译报错。
class Time
{
public:
Time(int hour)//需要传参,不是默认构造
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date(int& xx, int year, int month, int day)
:_year(year)
,_month(month + 1)
,_day(day)
,_n(1)
,_ref(xx)
,_t(1)
,_ptr((int*)malloc(12))
{
if (_ptr == nullptr)
{
perror("malloc fail");
}
else
{
memset(_ptr, 0, 12);
}
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
// 声明
int _year;
int _month;
int _day;
// error C2512: “Time”: 没有合适的默认构造函数可用
// error C2530 : “Date::_ref” : 必须初始化引用
// error C2789 : “Date::_n” : 必须初始化常量限定类型的对象
const int _n;
int& _ref;
Time _t;
int* _ptr;
};
int main()
{
int x = 0;
// 对象定义
Date d1(x, 2024, 7, 14);
d1.Print();
//const int x = 1;
// x = 1;
//int& rx;
return 0;
}
之前我们说过类中的变量只是声明,而定义对象时只是对象整体定义,那成员变量在哪单独定义哪?C++规定成员变量的定义都在初始化列表完成。使用初始化列表初始化是可以使用表达式的,如果我们向成员函数内部传值,我们可以使用,也可以不使用,也可以以表达式的形式初始化包括申请资源的表达式。而我们原来常用的函数大括号内的区域进行的初始化,就相当于如下形式
之所以成员变量在初始化列表中只能出现一次实质上是因为变量只能定义一次。引用成员变量,const成员变量,没有默认构造的类类型变量之所以必须使用初始化列表初始化是因为这些变量或对象只能在定义的地方完成初始化。
初始化列表跟函数体内赋值可以混着使用,两者可以打配合。对于像下面这种申请资源的情况,我们就可以在函数体内进行检查。
Date(int& xx, int year, int month, int day)
:_year(year)
,_month(month + 1)
,_day(day)
,_n(1)
,_ref(xx)
,_t(1)
,_ptr((int*)malloc(12))
{
if (_ptr == nullptr)
{
perror("malloc fail");
}
else
{
memset(_ptr, 0, 12);
}
}
由于初始化列表是成员变量定义的地方,所以每一个成员变量都默认过一次初始化列表,所以学过初始化列表之后,我们需要尽量在初始化列表内完成初始化。
• C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使用的。
• 尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定。对于没有显示在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造会编译错误。
#include<iostream>
using namespace std;
class Time
{
public:
Time(int hour)
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
class Date
{
public:
Date()
:_month(2)
{
cout << "Date()" << endl;
}
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
// 注意这里不是初始化,这里给的是缺省值,这个缺省值是给初始化列表的
// 如果初始化列表没有显示初始化,默认就会用这个缺省值初始化
int _year = 1;
int _month = 1;
int _day;
Time _t = 1;
const int _n = 1;
int* _ptr = (int*)malloc(12);
};
int main()
{
Date d1;
d1.Print();
return 0;
}
C++11之后允许在成员变量声明处使用缺省值(值或者表达式都可以)(给了缺省值,仍然还是声明),缺省值是用于初始化列表没有初始化,这里就会使用缺省值,再在初始化列表完成初始化。
初始化列表总结:
无论是否显示写初始化列表,每个构造函数都有初始化列表;
无论是否在初始化列表显示初始化,每个成员变量都要走初始化列表初始化;
• 初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。
附加题:
下面程序的运行结果是什么()
A. 输出 1 1
B. 输出 2 2
C. 编译报错
D. 输出 1 随机值
E. 输出 1 2
F. 输出 2 1答案:D
#include<iostream>
using namespace std;
class A
{
public:
A(int a)
:_a1(a)
, _a2(_a1)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2 = 2;
int _a1 = 2;
};
int main()
{
A aa(1);
aa.Print();
}
解析:初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的的先后顺序无关,所以A的初始化列表中先是_a2(_a1),再是_a1(a),由于A中初始化列表进行初始化,不会使用缺省值,参数a是由_a1接收的,但是_a2先用_a1初始化,_a1还未初始化,_a2为随机值,之后_a1用接收的1完成初始化,答案选D.
十二、类型转换
• C++支持内置类型隐式类型转换为类类型对象,需要有相关内置类型为参数的构造函数。
• 构造函数前面加explicit就不再支持隐式类型转换。
• 类类型的对象之间也可以隐式转换,需要相应的构造函数支持。
#include<iostream>
using namespace std;
class A
{
public:
构造函数加了explicit就不再支持隐式类型转换
// explicit A(int a = 0)
A(int a = 0)
{
_a1 = a;
}
A(const A& aa)
{
_a1 = aa._a1;
}
A(int a1, int a2)
:_a1(a1)
, _a2(a2)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a1;
int _a2;
};
class Stack
{
public:
void Push(const A& aa)
{
//...
}
private:
A _arr[10];
int _top;
};
int main()
{
A aa1(1);
aa1.Print();
// 隐式类型转换
// 2构造一个A的临时对象,再用这个临时对象拷贝构造aa2
// 编译器遇到连续构造+拷贝构造->优化为直接构造
A aa2 = 2;
aa2.Print();
A& raa1 = aa2;
const A& raa2 = 2;
int i = 1;
double d = i;
const double& rd = i;
Stack st;
A aa3(3);
st.Push(aa3);
st.Push(3);
// C++11之后才支持多参数转化
A aa5 = { 1, 1 };
const A& raa6 = { 2,2 };
st.Push(aa5);
st.Push({ 2,2 });
return 0;
}
当我们给自定义类型传内置类型的值时,我们发现没有报错,这是因为C++支持类型转换,直接将内置类型赋给自定义内型时,编译器会去调用对应的构造函数,将值作为参数传给构造函数,先构造一个对应的自定义内型,然后再将自定义类型的值赋给对象。(底层上会出现编译器遇到连续构造+拷贝构造->优化为直接构造,这个下文再详细解释。)
而C++一支持这样为自定义类型给值,以Stack为例,当我们向栈中压入值,我们不需要先创建对象,再压,我们直接传一个内置类型的数据,就可以自动压一个自定义类型的数据。
C++之后才支持如const A& raa6 = { 2,2 }这种加{}的形式一次性给多个内置类型。
十三、static成员
• 用static修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外进行初始化。
• 静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。
• 用static修饰的成员函数,称之为静态成员函数,静态成员函数没有this指针。
• 静态成员函数中可以访问其他的静态成员,但是不能访问非静态的,因为没有this指针。
• 非静态的成员函数,可以访问任意的静态成员变量和静态成员函数。
• 突破类域就可以访问静态成员,可以通过类名::静态成员 或者 对象.静态成员 来访问静态成员变量和静态成员函数。
• 静态成员也是类的成员,受public、protected、private 访问限定符的限制。
• 静态成员变量不能在声明位置给缺省值初始化,因为缺省值是个构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表。
class A
{
public:
A()
{
++_scount;
}
A(const A& t)
{
++_scount;
}
~A()
{
--_scount;
}
static int GetACount()
{
//_a++;没有this指针,不能访问
return _scount;
}
void func()//非静态的成员函数,可以访问任意的静态成员变量和静态成员函数
{
cout << _scount << endl;
cout << GetACount() << endl;
}
private:
// 类里面声明
static int _scount;
int _a = 1;
};
// 类外面初始化
int A::_scount = 0;
int main()
{
//cout << A::_scount << endl;
//cout << sizeof(A) << endl;
//a1、a2、a3创建调用构造函数对静态成员++,验证静态_scount为整个类共有
cout << A::GetACount() << endl;
A a1, a2;
{//代码块构成局部域,a3出了作用域就销毁
A a3(a1);
cout << A::GetACount() << endl;
}
// 突破类域就可以访问静态成员,可以通过类名::静态成员 或者 对象.
// 静态成员 来访问静态成员变量和静态成员函数
cout << A::GetACount() << endl;
cout << a1.GetACount() << endl;
// 编译报错:error C2248: “A::_scount”: 无法访问 private 成员(在“A”类中声明)
//cout << A::_scount << endl;
return 0;
}
附加题:
1+2+3+...+n_牛客题霸_牛客网
class Sum
{
public:
Sum()
{
_ret += _i;
++_i;
}
static int GetRet()
{
return _ret;
}
private:
static int _i;
static int _ret;
};
int Sum::_i = 1;
int Sum::_ret = 0;
class Solution {
public:
int Sum_Solution(int n) {
// 变长数组
Sum arr[n];
return Sum::GetRet();
}
};
解析:上面这一题麻烦的地方在于循环关键字限制,无法使用循环;if、else等判断关键字无法使用,我们不可以使用递归;乘除无法使用,意味着我们无法使用数学递归公式。常见的方法统统不可以使用。
笔者提供的方法是利用构造函数自动调用的特性来进行加法运算。我们利用变长数组,需要加到n,我们就创建n个大小的数组arr,数组arr的成员每一个就是一个Sum类对象,这样创建数组就会自动调用n次Sum构造函数。
我们创建静态变量i、ret(注意一定要创建成静态变量,否则每个对象自己存自己的数据,无法实现累加的效果)在Sum类的构造函数中,每调用一次,就_i++,i记录每次要加的数,_ret+=i,进行累加求和。最后将累加结果返回就行。
附加题:
设已经有A,B,C,D 4个类的定义,程序中A,B,C,D构造函数调用顺序为?()
设已经有A,B,C,D 4个类的定义,程序中A,B,C,D析构函数调用顺序为?()A:D B A C
B:B A D C
C:C D B A
D:A B D C
E:C A B D
F:C D A B答案:E 、B
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
解析:类构造函数调用顺序跟类创建的顺序一致。析构函数的调用按照先定义的后析构。
上图中a、b都是存储在栈上,c、d都是存储在数据段,析构函数的调用顺序比较是建立在统一存储区域而言的,a、b存在栈上,b又是后定义的,先析构,a、b析构完,c、d中的先创建的,d先析构,c再析构。所以答案是E 、B
十四、友元
有的时候我们需要在函数或者类中去访问另一类中私有或者保护的数据,这时候我们就可以使用友元突破访问限定符限制。
• 友元提供了一种突破类访问限定符封装的方式,友元分为:友元函数和友元类,在函数声明或者类声明的前面加friend,并且把友元声明放到一个类的里面。
• 外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,他不是类的成员函数。
• 友元函数可以在类定义的任何地方声明(但是为了美观,一般都在类开头加上),不受类访问限定符限制。
• 一个函数可以是多个类的友元函数。
• 友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员。
• 友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元。
• 友元类关系不能传递,如果A是B的友元, B是C的友元,但是A不是C的友元。
• 有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
#include<iostream>
using namespace std;
// 前置声明,否则A的友元函数声明编译器不认识B
class B;
class A
{
// 友元声明可以出现在任意位置
friend void func(const A& aa, const B& bb);
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
// 友元声明
friend void func(const A& aa, const B& bb);
private:
int _b1 = 3;
int _b2 = 4;
};
void func(const A& aa, const B& bb)
{
cout << aa._a1 << endl;
cout << bb._b1 << endl;
}
int main()
{
A aa;
B bb;
func(aa, bb);
return 0;
}
#include<iostream>
using namespace std;
class A
{
// 友元声明
friend class B;
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
void func1(const A& aa)
{
cout << aa._a1 << endl;
cout << _b1 << endl;
}
void func2(const A& aa)
{
cout << aa._a2 << endl;
cout << _b2 << endl;
}
private:
int _b1 = 3;
int _b2 = 4;
};
int main()
{
A aa;
B bb;
bb.func1(aa);
bb.func1(aa);
return 0;
}
十五、内部类
• 如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,跟定义在全局相比,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类。
• 内部类默认是外部类的友元类。
• 内部类本质也是一种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使用,那么可以考虑把A类设计为B的内部类,如果放到private/protected位置,那么A类就是B类的专属内部类,其他地方都用不了。
#include<iostream>
using namespace std;
class A
{
private:
static int _k;
int _h = 1;
class C{};
public:
class B // B默认就是A的友元,可以访问A私有或者保护的数据
{
public:
void foo(const A& a)
{
cout << _k << endl; //OK
cout << a._h << endl; //OK
}
};
};
int A::_k = 1;
int main()
{
cout << sizeof(A) << endl;
A::B b;
A aa;
b.foo(aa);
//A::C c;受到A访问限定符限制,C类无法从外部访问
return 0;
}
求1+2+3+...+n_牛客题霸_牛客网
class Solution {
// 内部类
class Sum
{
public:
Sum()
{
_ret += _i;
++_i;
}
};
static int _i;
static int _ret;
public:
int Sum_Solution(int n) {
// 变长数组
Sum arr[n];
return _ret;
}
};
int Solution::_i = 1;
int Solution::_ret = 0;
对于上文提到过的这一题,在学习过内部类后,我们发现Sum就是专为Solution使用的,因此我们可以采用类部内的写法进行改写,这样在访问数据时就会方便很多,封装性也更好。
十六、匿名对象
• 用类型(实参)定义出来的对象叫做匿名对象,相比之前我们定义的类型对象名(实参) 定义出来的叫有名对象
• 匿名对象生命周期只在当前一行,一般我们需要临时在某一行当前用一下对象或者它的成员变量或函数,就可以定义匿名对象,用完就自动析构(匿名对象的生命周期就在当前定义的一行),非常方便。
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
};
int main()
{
A aa1;
// 不能这么定义对象,因为编译器无法识别下面是一个函数声明,还是对象定义
//A aa1();
A();// 但是我们可以这么定义匿名对象,匿名对象的特点不用取名字
A(1);
// 但是他的生命周期只有这一行,我们可以看到下一行他就会自动调用析构函数
A aa2(2);
// 匿名对象在这样场景下就很好用
Solution().Sum_Solution(10);
return 0;
}
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
};
bool myfunction(int i, int j) { return (i > j); }
int main()
{
A aa1;
// 不能这么定义对象,因为编译器无法识别下面是一个函数声明,还是对象定义
//A aa1();
A();// 但是我们可以这么定义匿名对象,匿名对象的特点不用取名字
A(1);
// 但是他的生命周期只有这一行,我们可以看到下一行他就会自动调用析构函数
A aa2(2);
// 匿名对象在这样场景下就很好用
//第一种情形
Solution st;
cout << st.Sum_Solution(10) << endl;
//使用更方便
cout << Solution().Sum_Solution(10) << endl;
//第二种情形
int a[] = { 32,71,12,45,26,80,53,33 };
// < 升序
sort(a, a + 8);
// > 降序
//sort(a, a + 8, myfunction);//传递函数指针
/*greater<int> gt;
sort(a, a + 8, gt);*/
sort(a, a + 8, greater<int>());//传递仿函数
return 0;
}
匿名对象应用情形除了为了临时调用对象中某个成员,还有一种常见的就是第二种了。C++中实现sort排序默认是升序,如果为了改变顺序,我们需要创建一个比较器,可以创建一个函数,然后传函数指针,但是这种方式既麻烦又不适用于自定义类型对象。
还用一种方式是创建一个仿函数,匿名对象的意义就在于我们不再需要先创建一个对象,我们可以直接向sort中传仿函数,使用非常方便。(有些名词不懂,可以继续深入学习,本段仅为说明匿名对象的使用意义。)
十七、对象拷贝时的编译器优化
• 现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下会尽可能减少一些传参和传返回值的过程中可以省略的拷贝,如返回时一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造,一些编译器会优化得更厉害,进行跨行合并优化,直接变为构造。
• 如何优化C++标准并没有严格规定,各个编译器会根据情况自行处理。当前主流的相对新一点的编译器对于连续一个表达式步骤中的连续拷贝会进行合并优化,有些更新更"激进"的编译器还会进行跨行跨表达式的合并优化。
为了能够很好的展示优化效果,以下例子均为VS2019版本下运行(注:Release的优化比Debug大)。
#include<iostream>
using namespace std;
class A
{
public:
A(int a = 0)
:_a1(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
:_a1(aa._a1)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a1 = aa._a1;
}
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a1 = 1;
};
void f1(A aa)
{}
A f2()
{
A aa;
return aa;
}
int main()
{
// 传值传参
A aa1;
f1(aa1);
cout << endl;
// 隐式类型,连续构造+拷贝构造->优化为直接构造
f1(1);
// 一个表达式中,连续构造+拷贝构造->优化为一个构造
f1(A(2));
cout << endl;
cout << "***********************************************" << endl;
// 传值返回
// 返回时一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造 (vs2019 debug)
// 一些编译器会优化得更厉害,进行跨行合并优化,直接变为构造。(vs2022 debug)
f2();
cout << endl;
// 返回时一个表达式中,连续拷贝构造+拷贝构造->优化一个拷贝构造 (vs2019 debug)
// 一些编译器会优化得更厉害,进行跨行合并优化,直接变为构造。(vs2022 debug)
A aa2 = f2();
cout << endl;
// 一个表达式中,连续拷贝构造+赋值重载->无法优化
aa1 = f2();
cout << endl;
return 0;
}
class A
{
public:
A(int a1 = 0, int a2 = 0)
:_a1(a1)
,_a2(a2)
{
cout << "A(int a1 = 0, int a2 = 0)" << endl;
}
A(const A& aa)
:_a1(aa._a1)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a1 = aa._a1;
}
return *this;
}
~A()
{
//delete _ptr;
cout << "~A()" << endl;
}
void Print()
{
cout << "A::Print->" << _a1 << endl;
}
A& operator++()
{
_a1 += 100;
return *this;
}
private:
int _a1 = 1;
int _a2 = 1;
};
上图中后两个是常见的优化类型,第二种情形,按照我们之前所讲的来说,应该是先利用1构造一个匿名对象,匿名对象作为参数传递再拷贝构造,然后既然匿名对象也是用来传值构造的,那么这里匿名对象实际上就是没有必要产生的,因此这里编译器会进行优化,直接用1作为传给A构造函数的值,构造出函数func内的对象。同理对于第三种情况不会再用1类型转换构造出一个对象,再拷贝构造,而是直接将1作为参数,来构造函数func内的对象。需要注意的是对于1,我们发现这里并没有发生优化,但是对于一些激进的编译器来说,会看到aa1创建后用来拷贝构造后不再使用,那么编译器就会不构造aa1,直接拿传给aa1的值来构造函数func内的对象。
对于上面这个例子,VS2019Debug模式下aa构造后,函数返回值会用aa拷贝构造一个临时对象,出了函数,aa销毁,临时对象再去调用Print。
现在VS2019Release下,我们将优化加大,我们发现编译器进行了优化,因为aa在栈上构造出来,出来函数就销毁,但是我们依然可以调用Printf(),所以这里我们可以判断编译器不在生成aa,直接将1,作为参数拿去构造了临时对象,然后再拿临时对象调用Print
对于上图这一段代码,release下,比起上文多了++,aa在构造完后进行了使用,但是我们发现编译器依然进行了优化,在进行了语义等复杂分析后,编译器推断出这里实际上有用的依然是临时对象,并且分析出有效的值是传递值1加上1重载函数++中的100,然后编译器用101,直接去构造了临时对象。
Debug下,现在我们来看看这一段代码,原本1传给aa构造,aa返回构造临时对象,临时对象再拷贝构造ret,但是观看实际效果,我们发现这一段代码,编译器的优化是不在生成拷贝构造,传1构造aa,再用aa直接拷贝构造ret.
Release下,我们发现编译器的优化行为更加激进,这一次连aa都不构造,直接通过将1传给ret构造函数构造ret
及时我们在函数中进行++,编译器也直接为我们算好了值,用最终的值直接去构造出ret。
Debug下,在看过了前几段优化后,需要总结的是编译器优化的是连续构造+拷贝构造->优化为一个构造,总的来说都是构造。
对于上图我们发现,ret = f2()加了这一步赋值重载后,编译器的优化行为被打断了,编译器无法再进行任何的优化行为。
Release下,加大优化,我们发现编译器继续选择优化,不管有没有++,编译器都选择不再构造aa,直接构造临时对象,再用临时对象拷贝构造出ret.
上面这几段我们看到了编译器为了提升效率,所做出的优化,这些知识仅需要了解即可,目前上段所讲优化措施为VS2019版本下,随着编译器更新,编译器会采取一些更加激进的优化,VS2022下的编译器Debug下就可以达到2019版本的Release的优化效果。