一.类和对象(上)
1.1 类的定义
1.1.1 类定义的格式
C++中提供了class关键字来定义类,class后跟类名,使用{ }包含类中的内容:
class typename
{
//...
};
- C++定义的类和C语言中的结构体的格式相似,同时C++也将结构体升级成了类,注意后面的分号不能省
- 类中可可以定义函数和变量,一般来说我们把类中的函数称为成员函数或类的方法,把类中的变量称为成员变量或者类的属性,类中定义的变量可以通过访问限定符进行修饰,对于核心的成员变量会设置为私有的(外界不能轻易访问),而且会将成员变量加上一些特殊标识符(常见的就有 _ ),主要是为了与形参的名字进行区分,并不是必须行为,全凭个人喜好
- 在类中定义的成员函数默认是内联函数,也就是说如果我们预期该成员函数会被频繁地访问,便可以直接在类中定义;否则就可以只在类中声明
- 类名就是类型,不需要再typedef
1.1.2 访问限定符
public为公有访问符,private、protected为私有访问限定符(都是在类外不可访问,具体区别在继承章节再说),整体而言,访问限定符给用户选择性地提供了不同的接口,对于使用或者数据的保护都是有很大的用处的
注意:class中的成员默认都是private,struct的成员默认都是public,所以对于class如果要提供给外界成员函数使用,一定要使用public进行修饰
假如要实现一个栈的类:
class stack
{
//成员函数,public限定符使外界可以进行访问
public:
void Inite(int n = 4)
{
_arr = (int*)malloc(sizeof(int) * n);
if (_arr == NULL)
{
perror("malloc fail");
exit(1);
}
_top = 0;
_capacity = n;
}
void push_back(int x)
{
if (_top > _capacity)
{
//扩容
//...
}
_arr[_top++] = x;
}
//成员变量
private:
int* _arr;
size_t _top;
size_t _capacity;
};
1.1.3 类域
在平时的一些使用场景中,我们通常会做文件的声明和定义分离,指定类域可以更改编译器的查找路径,比如在做成员函数的声明和定义分离时,就需要指定类域来向编译器进行声明,才会使编译链接过程不会出现找不到的情况,否则就会出现编译报错的情况,具体可以参见编译和链接,类外的成员需要用类作用限定符::来加以声明
class stack
{
//成员变量
public:
//成员函数声明
void Inite(int n = 4);
void push_back(int x)
{
if (_top > _capacity)
{
//扩容
}
_arr[_top++] = x;
}
//成员变量
private:
int* _arr;
size_t _top;
size_t _capacity;
};
//类外成员函数定义,需要指定类域
void stack::Inite(int n = 4)
{
_arr = (int*)malloc(sizeof(int) * n);
if (_arr == NULL)
{
perror("malloc fail");
exit(1);
}
_top = 0;
_capacity = n;
}
1.2 实例化
1.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(2009, 6, 9);
d1.Print();
//注意类不能这样实例化
//Date d3();error
//因为编译器会识别为一个返回值为Date的函数声明
return 0;
}
1.2.2 类的大小
这里又有一个问题值得我们思考:如果我们计算类的大小是否要包含成员函数的大小? 这里需要补充另外一个小知识——调用函数的本质,我们都知道函数名其实就是函数的地址,调用函数其实就是调用call指令本质是执行指令,而函数名的地址就是call指令的第一条指令,而如果对象的大小要包含成员函数其实存储的就是一条条call指令,再进一步思考:对象是否有存储call指令的必要?,我们通过同一个类实例化出了不同的对象,它们 如果调用同一个成员函数,是否为同一个? 接下来我们可以直接来看一下汇编代码:
int main()
{
//实例化出对象d1,d2
Date d1;
Date d2;
//进行函数调用
d1.Init(2009, 6, 9);
d1.Print();
d2.Init(2015, 4, 9);
d2.Print();
return 0;
}
如何看汇编指令:
进入调试,便会出现反汇编窗口
一个额外的小建议:有时候当我们调试看不出有任何错误的时候,就可以尝试参考汇编指令,汇编指令是会直接翻译成二进制指令的,而汇编指令是不会骗人的
查看结果:
这里就可以看到,其实对象d1,d2其实调用的两个成员函数都是同一个(函数名后面的便是它们的地址),那么从实际的角度来看,如果对象调用的函数都是用一个,再让实例化出的每个对象存储call指令,岂不是非常浪费?
实际存储对象的规则: 成员函数会存在一个公共的区域(代码段),并且函数指针(地址)其实也不用存,在编译器进行编译的时候就会确定函数的地址,而不是在运行时确定,对象的大小也遵从着结构体内存对齐的原则
内存对齐的原则:
- 第一个成员放在地址偏移量为0处
- 后面的每个变量都要对齐到最大对齐数(变量类型的长度与编译器默认对齐数的最小值)的整数倍偏移量处
- 计算出来的内存大小必须是最大对齐数的整数倍
- 如果存在嵌套,最大对齐数需要包含该嵌套的对齐数
#include<iostream>
using namespace std;
class A
{
};
class B
{
};
int main()
{
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
return 0;
}
上述计算出来的对象大小是多少呢?类中本身并没有任何成员,但是计算出来的结果是1,这个1起到了标识符的作用,如果没有标识符,又怎么能证明这个对象存在呢?在后期的仿函数中就会用到这种没有成员变量的类
1.3 this指针
既然实例化出的不同对象调用的是同一个成员函数,那么为什么这一个成员函数会产生不同的结果呢?我们可以看一下上述代码打印的结果:
这是因为编译器底层在隐含着一个this指针,做了特殊处理:
- this指针隐含在成员函数的第一个参数中,this的类型是当前类的指针类型,并且使用const修饰它(指向的类的内容不可被修改)
- C++规定不能在实参的位置显示调用this指针,但是可以在成员函数体内可以调用this指针
接下来我们来看两个题目:
这两段代码输出的结果是:( )
A.运行崩溃 B.编译报错 C.正常运行
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
对于这两段代码,应该先排除B选项,因为无论如何涉及到对空指针的解引用并不会编译报错,只会产生警告,这里创建了一个类类型的指针并初始化为空,调用成员函数print本质就是将传给了this指针,但是在成员函数中,并没有出现需要使用this解引用的操作,所以这里并不会造成对空指针的解引用,只是将空指针传过去仅此而已,该段代码则会正常运行
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
该段代码在成员函数print设计到访问成员变量_a(打印输出),底层会转化为this->a,但是这里的p为空,造成了对空指针的解引用,就会运行崩溃
二.类和对象(中)
1.类的默认成员函数
默认成员函数是编译器自动生成的成员函数,一个类一般含有6个默认成员函数,我们只需掌握好前4个默认成员函数即可,后面两个了解一下即可;其次就是C++11还新增了两个成员函数:移动构造和移动赋值,这个以后再进行扩展。默认成员函数是一个极其复杂的东西,对于默认成员函数,我们可以从以下两个角度进行分析:
- 编译器默认生成的成员函数的行为是怎样的,是否满足我们的需求?
- 对于不满足需求的成员函数,我们应该如何实现?
这里先给出默认成员函数作用的大致框架:
2.构造函数
构造函数的规则>:
- 构造函数在实例化出一个对象时,编译器就会自动调用
- 构造函数的函数名就是类名,并且没有返回值(这是C++规定的,不用深究)
- 构造函数分为全缺省、无参、编译器生成,这三种都被称为默认构造函数,但是这三个只能存在一个,对于前两种和后一种很好理解:因为我们显示写了,肯定就会调用显示的构造函数;对于第一种和第二种主要是因为在调用的时候含有歧义(在不传任何实参的时候,编译器就不知道要调用哪一个)
- 编译器默认生成的构造函数对于含有内置类型的成员变量的类的处理可以认为是不处理,这个具体行为需要看编译器实现,但是对于含有自定义类型(如果内置类型已经显示写了构造函数)的成员变量的类会自动调用内置类型的构造函数就可以不用写构造函数
- 构造函数不支持显示地调用,可以用特殊方式显示调用(具体场景再介绍)
小知识点:
- 内置类型就是编译器中原生的类型,比如int,double,char,short
- 自定义类型就是类、结构体、联合体……
针对规则要点的说明:
1.调用产生歧义:
#include<iostream>
using namespace std;
class Date
{
public:
Date()
{
_year = 0;
_month = 0;
_day = 0;
}
Date(int year = 2025,int month = 4,int day =4)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//该处调用是没问题的,没有歧义,后面的成员直接给缺省值
Date d1(2025);
//该处调用就会产生歧义
Date d2;
return 0;
}
2.编译器对内置类型的成员变量的类的构造函数处理:
可以看到在VS环境下,对内置类型进行随机值的处理,可以默认为不处理
3.自定义类型的构造函数处理:
场景:写一个栈的类和用两个栈实现一个队列的类
#include<iostream>
using namespace std;
class stack
{
public:
//显示写了构造函数
stack(int n = 4)
{
cout <<"stack(int n = 4)" << endl;
_a = new int[n];
_capacity = n;
_top = 0;
}
private:
int* _a;
size_t _top;
size_t _capacity;
};
class My_Quene
{
//没有显示写对应的构造函数
private:
stack pop;
stack push;
};
int main()
{
stack s1;
My_Quene q1;
return 0;
}
q1自动地调用了类型为stack的构造函数,并且编译器会自动调用构造函数,但是如果内置类型没有写构造函数呢?
q1的两个成员函数被随机值处理了,所以对于内置类型应该显示地写构造函数
3.析构函数
析构函数的功能和构造函数相反,用于完成对对象资源的清理,它有点像C语言阶段底层实现的Destory函数,C++规定,在对象完全销毁的时候会自动调用析构函数
析构函数的规则>:
- 编译器会在对象的生命周期结束时自动调用析构函数,析构函数的主要工作就是对资源的释放
- 析构函数的函数名是:~ + 类名,并且也没有函数返回值
- 如果用同一个类实例化来的两个对象,析构函数的调用顺序同数据结构栈一样,后实例化的先析构
- 析构函数可以被实例化的对象显示调用
- 编译器默认生成的析构函数对于内置类型不会处理,而对于含有资源的类就需要显示地写析构函数,否则就会造成内存泄漏的问题(自己申请的资源使用完了却没有归还给操作系统)
- 含有自定义类型的成员就会调用自定义类型自己的析构函数
#include<iostream>
using namespace std;
class stack
{
public:
//显示写了构造函数
stack(int n = 4)
{
cout <<"stack(int n = 4)" << endl;
_a = new int[n];
_capacity = n;
_top = 0;
}
//显示写了析构函数
~stack()
{
cout <<"~stack()" << endl;
delete[] _a;
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
size_t _top;
size_t _capacity;
};
class My_Quene
{
private:
stack pop;
stack push;
};
int main()
{
stack s1;
My_Quene q1;
return 0;
}
4.拷贝构造函数
拷贝构造的规则:
- 拷贝构造是用一个已经创建了的对象去初始化一个新创建的对象
- 拷贝构造函数是特殊的构造函数,它的参数必须是类类型,后面可以跟其他带缺省值的参数
- C++规定只要传值调用,传值返回都要调用拷贝构造,所以建议拷贝构造用引用传参,否则语法上会造成无穷递归
- 编译器默认生成的拷贝构造是浅拷贝(值拷贝),将对象对应的值一个字节一个字节的拷贝,对于不涉及资源的类并不会造成任何影响,但是如果含有资源就会出现对同一块资源的多次析构,所以需要显示地写深拷贝
- 对于含有自定义类型的成员变量的类的拷贝构造就需要成员变量显示地提供,并且只要含有资源的类都需要显示的写拷贝构造,对于内置类型编译器默认就够用了
- 对于是否要显示地写拷贝构造的判定,可以查看该类是否含有析构函数,如果有析构函数,就需要显示地写,拷贝构造和析构函数一般都是成对存在的
1.实例化时拷贝构造的两种写法:
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 2025,int month = 4,int day =4)
{
_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(d1);
d2.Print();
//拷贝构造
Date d3 = d2;
d2.Print();
return 0;
}
2.为什么浅拷贝对于自定义类型不行?
#include<iostream>
using namespace std;
class stack
{
public:
//显示写了构造函数
stack(int n = 4)
{
cout <<"stack(int n = 4)" << endl;
_a = new int[n];
_capacity = n;
_top = 0;
}
//没有显示的写拷贝构造
~stack()
{
cout <<"~stack()" << endl;
delete[] _a;
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
size_t _top;
size_t _capacity;
};
int main()
{
stack s1;
stack s2(s1);
return 0;
}
浅拷贝造成的问题:
对于_size和_capacity直接进行浅拷贝没有任何问题,但是_a就不行!!! 这里可以通过调试观察:
所以需要显示地写拷贝构造(深拷贝),重新开辟一块空间,并将对象对应资源中的值进行拷贝:
#include<iostream>
#include<string.h>
using namespace std;
class stack
{
public:
//显示写了构造函数
stack(int n = 4)
{
cout <<"stack(int n = 4)" << endl;
_a = new int[n];
_capacity = n;
_top = 0;
}
//显示的写拷贝构造
stack(const stack& s)
{
cout <<"stack(const stack& s)" << endl;
_a = new int[s._capacity];
//注意memcpy不能拷贝空指针和没有任何数据的空间
if (s._a && s._top)
{
memcpy(_a, s._a, s._top*sizeof(int));
}
_top = s._top;
_capacity = s._capacity;
}
~stack()
{
cout <<"~stack()" << endl;
delete[] _a;
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
size_t _top;
size_t _capacity;
};
int main()
{
stack s1;
stack s2(s1);
return 0;
}
析构的时候先析构后实例化的对象,s2指向的资源先被析构,s1此时指向的资源就是一块不属于我们管理的资源(类似于野指针的行为)
5.运算符重载
运算符重载规则:
- C++新增了operator关键字,使得自定义类型被赋予与内置类型一样的行为
- 运算符重载对应的运算符需要含有对应数量的操作数,比如+,-就需要两个操作数,++,–就只需要一个操作数
- 类进行运算符重载值得注意的是,成员函数本身含有this指针,所以实际的操作数要少一个,并且因为隐含this指针的缘故,类进行运算符重载的意义其实就是用类和其他的类型进行运算,但是对于要类重载哪些操作,具体由实际场景结合,比如要实现一个日期类,日期+日期就没有太大的意义,但日期-日期就具有意义
- 有五个运算符不支持重载:[ .] [ .*] [ :: ] [ ? : ] [ sizeof]
- 对于<<与>>的重载需要定义成全局函数,如果涉及到成员变量的访问,还需要借助一些特殊方式(友元、借助成员函数的返回值等)
- 对于前置++和后置++这种运算符进行区分,在后置++的形参中加以整型参数(给任意值)
- 有些运算符支持连续操作就需要给函数返回值
这里的.*运算符可能较陌生,它用于成员函数指针:
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 2025,int month = 4,int day =4)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
typedef void(Date::*pf)();
int main()
{
//C++规定成员函数必须需要&才能取到成员函数的地址
pf pd = &Date::Print;
Date d1;
//函数指针的使用
(d1.*pd)();
return 0;
}
这里可以浅浅地感受一下运算符重载的魅力:
#include<iostream>
#include<assert.h>
using namespace std;
class stack
{
public:
stack(int n = 4)
{
_a = new int[n];
_capacity = n;
_top = 0;
}
void push_back(int x)
{
if (_top > _capacity)
{
//扩容
//...
}
_a[_top++] = x;
}
stack(const stack& s)
{
_a = new int[s._capacity];
if (s._a && s._top)
{
memcpy(_a, s._a, s._top - 1);
}
_top = s._top;
_capacity = s._capacity;
}
const int& operator[](size_t i)
{
assert(i < _top);
return _a[i];
}
~stack()
{
delete[] _a;
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a;
size_t _top;
size_t _capacity;
};
int main()
{
stack s1;
s1.push_back(1);
s1.push_back(2);
s1.push_back(3);
for (size_t i = 0; i < 3; i++)
{
cout << s1[i] << " ";
}
cout << endl;
return 0;
}
成功遍历:
注意:我们这里的stack是一个类,并不是一个普通的数组类型,但是通过运算符重载使得它拥有了与数组一样的行为,使得类的使用更加的灵活,对于上层它们都具有同样的行为,对于底层的实现确是被封装起来,由程序员自己控制
并且此时我们就可以明白为什么cout使用起来就比printf舒服:
因为重载它自动的识别了输入数据的类型,就不需要指定格式,并且C++提供的流可以很好的支持自定义类型对象的输入和输出(进行运算符重载即可)
5.1赋值运算符重载
赋值重载规则:
- 赋值重载属于运算符重载之一,并且规定必须重载成成员函数
- 运算符重载是用两个已经创建了的对象,并用其中一个对象赋值给另一个对象,注意与拷贝构造的写法进行区分
- 同样的,对于编译器默认的赋值重载,只是简单的赋值(浅拷贝),对于含有自定义类型的类需要考虑到资源的问题,就需要显示的写赋值重载,否则只会调用编译器的赋值重载,对于含有资源的类会导致指向同一块区域,造成对同一块区域的多次析构
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 2025,int month = 4,int day =4)
{
_year = year;
_month = month;
_day = day;
}
//没有显示写拷贝构造...
//也没有显示写赋值运算符重载...
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2025,3,14);
//注意这是拷贝构造
Date d2 = d1;
//赋值重载
Date d3;
d3 = d2;
return 0;
}
对于只含有内置类型的成员的类,编译器默认生成的就够用,但是含有资源的成员的类就会出问题
赋值运算符重载的传统写法: 以实现栈功能的类举例
//s2 = s1
stack& operator=(const stack& s)
{
if (this != &s)
{
int* tmp = new int[s._capacity];
if (s._a && s._top)
{
memcpy(tmp, s._a, s._top*sizeof(int));
delete[] _a;
}
_a = tmp;
_top = s._top;
_capacity = s._capacity;
}
return *this;
}
赋值运算符重载的现代化写法:
void swap(stack& s)
{
std::swap(_a, s._a);
std::swap(_top, s._top);
std::swap(_capacity, s._capacity);
}
//s2 = s1
//注意这里是传值传参
stack& operator=(stack s)
{
swap(s);
return *this;
}
解析现代写法:
<1.带有资源的成员变量的类的交换:
C++官方库中提供了swap的模板函数,也就意味着我们可以让任意类型之间进行交换,但是对于含有资源的交换,是否会带来其他的额外问题?
官方库提供的swap:
//指定为官方库的swap
stack s1;
stack s2;
std::swap(s1,s2);//OK??
运行结果肯定是没有问题的,但是使用指向内容的交换的代价太大了 ,这里我们进行调试观察:
这里的两个对象中的资源都是需要重新开辟一块空间再释放旧的空间,但是成员变量_a本身存储的就是地址,这里我们直接交换地址不是更好吗?仍然使用官方库的swap,但是相互交换地址的代价更小:
<2.赋值重载为什么使用传值传参?
这里还是有一个问题,就是this指针原先指向的交给了临时对象,临时对象生命周期结束了,就会自动调用析构函数,就会造成内存泄漏的问题,这里最好的处理就是先在成员变量_a的声明处给一个nullptr缺省值
小结:
- 传统的赋值写法主要是具体的工作由我们亲自完成
- 现代的赋值写法用到了一个很重要的思想: 复用具体的工作主要交给编译器去完成
但是其实现代的写法本质上并没有提高多少性能,只是写法上对于程序员来说更加友好,这也就意味着以后只要我们显示写了拷贝构造,就可以进行复用 ,这里的拷贝构造其实也可以进行复用(借助于普通构造)
5.2 拷贝构造和赋值重载的深入理解
定义:
拷贝构造 | 赋值重载 |
---|---|
用一个已经存在的对象去初始化另外一个新创建的对象 | 用一个已经存在的对象去初始化另外一个已经存在的对象 |
结合实际:
如果将两种行为类比成我们上学时候的抄作业行为,拷贝构造就相当于你的作业是一片空白,什么都没有,可以直接将别人的作业内容完整地抄在你的作业上;赋值重载相当于你的作业已经写了一部分,你不一定非要全部擦了再抄别人的作业内容(该理解会应用在智能指针shared_ptr的赋值重载写法)
5.3 类型转换
类型转换 | 支持操作 |
---|---|
自定义类型转换为内置类型 | 运算符重载 |
自定义类型转换为自定义类型 | 构造函数 |
内置类型转换为自定义类型 | 构造函数 |
内置类型转换为内置类型 | 显示强制类型转换 |
该处介绍第一种和最后一种类型转换,第二、三种类型转换放到下篇幅中:
5.3.1 自定义类型转换为内置类型
在各种语句判断的逻辑中,例如:while、if…都会用到内置类型bool值,此时通过运算符重载就可以支持,假定内置类型为f,写法:operator f();重载后的行为由程序员自己定义
注意:
这里没有返回值,本来根据运算符重载的规则:如果要重载某个运算符就要用对应的运算符,C语言阶段支持的类型转换就是强制类型转换,使用的是(),但是()运算符被仿函数占了,作为函数调用的运算符,该写法是编译器特殊处理的
写一个栈类支持内置类型bool:
class stack
{
public:
stack(int n = 4)
{
_a = new int[n];
_capacity = n;
_top = 0;
}
operator bool()
{
//想要的行为是判断栈中的成员变量是否为空
return _a != nullptr;
}
~stack()
{
delete[] _a;
_a = nullptr;
_top = _capacity = 0;
}
private:
int* _a = nullptr;
size_t _top;
size_t _capacity;
};
int main()
{
stack s1;
if (s1)
{
cout << "s1 not nullptr" << endl;
}
else
{
cout << "s1 nullptr" << endl;
}
return 0;
}
5.3.1内置类型转换为内置类型
补充:C语言支持的类型转换有显示类型转换、隐式类型转换,但是需要支持两个类型间的转换有关联,例如:int和double,两个毫无联系的类型是不支持转换的,例如:int和int*
5.3.1.1 类型安全
- 类型安全是指编程语言在编译和运行时提供保护机制,避免非法的类型转换和操作,导致非法访问,例如:int强转为double再访问就会有越界访问的危险
- C和C++都不属于类型安全的语言,它们都支持类型转换
- C++提供了4个显示强制类型转换的运算:static_cast/reinterpret_cast/const_cast/dynamic_cast,就是为了使得类型转换的操作更加安全,让程序员知道其中的危害
引发危险的操作:
<1.const修饰的变量转换
<2.指向基类强制转换为派生类
5.3.1.2 C++中4个强制类型转换运算符
- static_cast用于两个类型相近的转换,这个转换是具有明确意义的,只要底层不包含const就可以使用static_cast
- reinterpret_cast用于两个类型不相近的转换,reinterpret就是重新解释的意思,该转换提醒程序员类型转换后不影响代码的安全性,并且存在一定的风险
- const_cast用于const类型为非const的类型的转换,去掉了const属性,同样也面临着安全问题,一样要谨慎使用,const_cast的类型必须是指针、引用或者指向当前类类型的指针
- dynamic_cast用于基类的指针或引用安全地转换为派生类的指针或者引用,如果基类的指针或者引用指向的是派生类对象,那么转换就是安全的;如果基类的指针或者引用指向的是基类对象,则转换失败,指针类型返回空指针,引用返回bad_cast异常
- dynamic_cast要求基类必须是多态类型,也就是说基类中必须含有虚函数。因为dynamic_cast是在运行时通过虚表中存储的type_info来判断基类指向的是基类对象还是派生类对象
注意:
C++提供的显示强制类型转换的写法不同于C语言的强制类型转换,具体写法参照代码
int main()
{
//类型相近的转换
double d = 13.32;
int a = static_cast<int>(d);
cout << a << endl;
//int转换成int*,此时意义已经完全发生了改变,需要注意使用
int* p1 = reinterpret_cast<int*>(a);
//对应强制类型转换中有⻛险的去掉const属性,注意加volatile
//被const修饰的变量,编译器会优化,会将该变量放到寄存器中或者像宏一样直接换成对应的值
//volatile关键字就是告诉编译器就去内存中去,就会真正改变常变量b
volatile const int b = 0;
int* p2 = const_cast<int*>(&b);
cout << *p2 << endl;
cout << b << endl;
return 0;
}
dynamic_cast:
class A
{
public:
//含有虚函数,构成多态行为
virtual void f() {}
int _a = 1;
};
//B继承了A
class B : public A
{
public:
int _b = 2;
};
void fun1(A* pa)
{
// 指向⽗类转换时有⻛险的,后续访问存在越界访问的⻛险
// 指向⼦类转换时安全
B* pb1 = (B*)pa;
cout << "pb1:" << pb1 << endl;
cout << pb1->_a << endl;
cout << pb1->_b << endl;
pb1->_a++;
pb1->_b++;
cout << pb1->_a << endl;
cout << pb1->_b << endl;
}
void fun2(A* pa)
{
// dynamic_cast会先检查是否能转换成功(指向⼦类对象),能成功则转换,
// (指向⽗类对象)转换失败则返回nullptr
B* pb1 = dynamic_cast<B*>(pa);
if (pb1)
{
cout << "pb1:" << pb1 << endl;
cout << pb1->_a << endl;
cout << pb1->_b << endl;
pb1->_a++;
pb1->_b++;
cout << pb1->_a << endl;
cout << pb1->_b << endl;
}
else
{
cout << "转换失败" << endl;
}
}
void fun3(A& pa)
{
// 转换失败,则抛出bad_cast异常
try
{
B& pb1 = dynamic_cast<B&>(pa);
cout << "转换成功" << endl;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}
int main()
{
A a;
B b;
//fun1(&a);
//fun1(&b);
fun2(&a);
fun2(&b);
fun3(a);
fun3(b);
return 0;
}
5.4 日期类的模拟实现
先做声明和定义的分离:
//Date.cpp
#include"Date.h"
void Date::print ()
{
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
bool Date::operator>(const Date& d) 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;
}
}
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);
}
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);
}
//用+复用+=
//Date& Date::operator+=(int day)
//{
// *this = *this + day;
// return *this;
//}
//
//
//Date Date::operator+(int day)
//{
// Date tmp(*this);
// tmp._day += day;
// while (tmp._day > Get_day(tmp._year, tmp._month))
// {
// tmp._day -= Get_day(tmp._year,tmp. _month);
// tmp._month += 1;
// while (tmp._month == 13)
// {
// tmp._year += 1;
// tmp._month = 1;
// }
// }
//
// return tmp;
//}
//3.11 += 100
//3.13 += -80
Date& Date::operator+=(int day)
{
if (day <= 0)
{
return *this -= -day;
}
_day += day;
while (_day >Get_day(_year,_month))
{
_day -= Get_day(_year, _month);
_month += 1;
while (_month == 13)
{
_year += 1;
_month = 1;
}
}
return *this;
}
//用+=复用+
//返回临时对象不能用引用
Date Date::operator+(int day) const
{
Date tmp(*this);
tmp += day;
return tmp;
}
//前置++
Date& Date::operator++()
{
*this += 1;
return *this;
}
//后置++
Date Date::operator++(int i)
{
Date tmp(*this);
*this += 1;
return tmp;
}
//3.13 -= -80
//3.13 -= 80 3.13 = 3.13 -80
Date& Date::operator-=(int day)
{
if (day < 0)
{
return *this += -day;
}
_day -= day;
while (_day <= 0)
{
_month -= 1;
_day += Get_day(_year, _month);
while ( _month == 1 &&_day <=0)
{
_year -= 1;
_day += Get_day(_year, 12);
_month = 12;
}
}
return *this;
}
Date Date::operator-(int day)const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
//前置--
Date& Date::operator--()
{
*this -= 1;
return *this;
}
//后置--
Date Date::operator--(int i)
{
Date tmp(*this);
*this -= 1;
return tmp;
}
// 日期-日期 返回天数
//d1-d2
int Date::operator-(const Date& d) const
{
int flag = 1;
Date max = *this;
Date min = d;
int n = 0;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
while (min != max)
{
n++;
min++;
}
return flag * n;
}
//Date.h
#include<iostream>
#include<assert.h>
#include<stdbool.h>
using namespace std;
class Date
{
public:
//友元声明
friend ostream& operator<< (ostream& out, const Date& d);
friend istream& operator>> (istream& in, Date& d);
void print() ;
//构造函数
Date(int year = 2023,int month = 6,int day = 23)
{
_year = year;
_month = month;
_day = day;
}
//用const成员函数的作用:不改变类内部的成员变量
//经常调用的小函数放在类中会被编译器处理成内联函数
int Get_day(int year,int month)
{
assert(month > 0 && month < 13);
static int day[13] = {-1,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;
}
else
{
return day[month];
}
}
//只进行逻辑关系比较,不改变成员变量的值
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;
Date& operator+=(int day);
Date operator+(int day) const;
Date& operator++();
Date operator++(int i);
Date& operator-=(int day);
Date operator-(int day) const;
Date& operator--();
Date operator--(int i);
// 日期-日期 返回天数
int operator-(const Date& d) const;
//隐含的this指针修饰的是指针变量本身不是指针变量指向的内容
//this = nullptr;
private:
int _year;
int _month;
int _day;
};
inline ostream& operator<< (ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "日" << d._day << "天" << endl;
return out;
}
//会引发递归所有控件路径
//inline istream& operator>> (istream& in, const Date& d)
inline istream& operator>> (istream& in, Date& d)
{
cout << "请输入日期:>";
in >> d._year >> d._month >> d._day;
return in;
}
这里对日期类的具体实现过程就不再赘述,但是值得一提的是实现的关系运算重载,这里关系运算符重载其实只实现了两个:> 和 == ,其他四个关系运算,本质都是复用,这就使得代码更加的灵活,以后对类的关系运算符重载都可以进行这样的写法
6.取地址重载
6.1 const成员函数
- 使用const修饰来修饰成员函数就称为const成员函数,const放在参数列表的后面
- 使用const进行修饰,使得日期类的this指针从 (Date* const this,…) 变成了 (const Date* const this,…) ,意味着类类型的指针指向的内容也不可以被修改了,如果以后我们设置的成员函数中的成员变量不希望被修改就可以使用const加以修饰
为什么使用const成员函数:
因为使用cons对成员函数进行修饰,可以传普通对象也可以传const对象,如下场景:
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 2025,int month = 4,int day =4)
{
_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();
const Date d2(2025,6,8);
d2.Print();
return 0;
}
d2不能调用,这里print并没有被const修饰,本质上就是对权限的放大:
因为这里this不能显示地让指向的内容不可修改,所以C++在语法上规定,在函数的参数列表后加上即可:
6.2 取地址运算符重载
取地址运算符重载成普通取地址运算符重载和const取地址运算符重载,一般而言,编译器生成的就够用了,不需要我们再显示地写,除非有时候不想让别人访问到这个地址,从而返回一个假地址(基本上不会这样写):
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 2025,int month = 4,int day =4)
{
_year = year;
_month = month;
_day = day;
}
void Print() const
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(2025, 6, 8);
//直接用即可
cout << &d1 << endl;
cout << &d2 << endl;
return 0;
}
三.类和对象(下)
1.再探构造函数
构造函数用于对类的成员变量进行初始化,但是有一些成员变量不能通过在构造函数的函数体内进行赋值的方式进行初始化:引用、const修饰的变量、没有默认构造的成员变量、基类成员
C++的构造函数分为两个阶段:1.初始化阶段(所有成员变量在该阶段初始化,包括const成员变量) 2.赋值阶段(在函数体内进行)
类型 | 对应类型解释 | 不能在函数体内初始化的原因 |
---|---|---|
const | 修饰后的变量不能被修改,也就意味着它只能初始化一次 | 在函数体内初始化cons相当于改变const变量,与修饰规则相悖 |
引用 | 对应类型的别名 | 引用类型不能直接通过某个常量进行赋值初始化,&后的变量对于常量相当于一个新的类型 |
class Date
{
public:
Date()
{
_year = 0;
_month = 0;
_min = 0;
_second = 1;
}
private:
int _year;
int _month;
const int _min;
int& _second;
};
int main()
{
Date d1;
return 0;
}
函数体内初始化:
对于不能在函数体内初始化的成员的解决方案:初始化列表初始化
< 1.初始化列表的使用规则:
- 初始化列表跟在含有函数形参括号的后面,第一个要初始化的变量以 : 开始 而后面的成员都用 , 开始;还使用含有指定变量初始化的括号跟在成员变量后面,与显示初始化一个类的写法一致
- 初始化列表的初始化顺序与在列表的顺序无关,只与声明顺序相关,一般而言为了结构清晰,声明顺序和初始化列表顺序保持一致
- 尽量使用初始化列表初始化,因为无论是否显示地给初始化列表,编译器都要走一遍初始化列表
class Date
{
public:
Date(const int min,int& second)
:_min(min)
,_second(second)
{
_year = 0;
_month = 0;
}
private:
int _year;
int _month;
const int _min;
int& _second;
};
int main()
{
int i = 0;
int& pi = i;
Date d1(2,pi);
return 0;
}
< 2.C++11还提供了在声明处给缺省值初始化,给没有初始化的成员变量使用的
注意:静态变量不能在声明处给缺省值初始化,但是const修饰的静态整型变量可以,后面C++针对这一块有所改进,可以自行下去查询相关规则
对于const的静态整型变量:
const成员变量也可以用该方法初始化:
< 3.继承基类的派生类初始化基类:
//基类定义
class Base
{
public:
Base(int basevalue = 0)
:_basevalue(basevalue)
{}
private:
int _basevalue;
};
//派生类定义
class Derived : Base
{
public:
//隐式调用
//基类自动调用自己的构造
Derived()
:_derivedvalue(0)
{}
//显示调用基类的构造
Derived(int derivedvalue,int basevalue)
:Base(basevalue)
, _derivedvalue(derivedvalue)
{}
private:
int _derivedvalue;
};
int main()
{
Derived d1;
return 0;
}
补充一个小知识点:委托构造
C++11支持的构造,具体如下:
class My_Class
{
public:
My_Class(int i1,int i2)
:_i1(i1)
,_i2(i2)
{}
//委托构造+指定成员初始化
My_Class(double d1, double d2)
:My_Class(1,1)
{
_d1 = d1;
_d2 = d2;
}
void Print() const
{
cout << "_i1 = " << _i1 << endl;
cout << "_i2 = " << _i2 << endl;
cout << "_d1 = " << _d1 << endl;
cout << "_d2 = " << _d2 << endl;
cout << "_ch1 = " << _ch1 << endl;
}
private:
int _i1;
int _i2;
double _d1;
double _d2;
char _ch1 = 'a';
};
int main()
{
//行为更加多样化,并且代码更加简洁
My_Class my1(4.1,6.1);
my1.Print();
return 0;
}
小结:
2.类型转换
- C++支持内置类型转换为自定义类型,需要支持有相应的构造,单参数的要有单参数的构造函数;多参数的就要有多参数的构造函数。加上关键字explicit就默认不支持类型转换
- 自定义类型也可以转换为自定义类型,也需要有相应的构造函数支持
class A
{
public:
//不支持类型转换
//explicit A(int a1)
A(int a1)
:_a1(a1)
{}
//不支持类型转换
//explicit A(int a1,int a2)
:_a1(a1)
,_a2(a2)
{}
int GetF() const
{
return _a1;
}
int GetS() const
{
return _a2;
}
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
B(const A& aa)
:_b1(aa.GetF())
, _b2(aa.GetS())
{}
private:
int _b1;
int _b2;
};
void FunC(const A& aa)
{
cout << "void FunC(const A& aa)" << endl;
}
int main()
{
A aa1(1);
//C++11以后支持的多参数的构造
//用{ }包含参数
A aa2({ 1,1 });
//强制类型转换,中间会先构造一个临时对象,再拷贝构造给对象
//构造+拷贝构造 ->(编译器优化) 直接构造
A aa3 = 1;
A aa4 = { 1,1 };
//支持A类转换为B类,提供相应的构造函数即可
B bb1 = aa1;
//未设计类型转换前的写法,函数传参还需要单独构造一个对象
A aa5(1, 1);
FunC(aa5);
//类型转换后,传参的时候更具有意义
FunC({1,1});
return 0;
}
3.static成员
- static成员变量是放在静态区的,它不属于任何一个实例化的类,而是所有类所共享的
- static修饰的成员函数可以访问其他任意static成员,但不能访问非static的成员,静态成员函数没有this指针
- 静态成员也是受访问限定符public、protected、private的限制
- 静态成员变量的初始化需要在类外初始化,不能在声明处给缺省值,也不能走初始化列表,const静态整型是例外
- 突破类域就可以访问静态成员,需要指定用类作用限定符,或者用实例化的类的点操作符
class Date
{
public:
Date()
{
_year = 0;
_month = 0;
}
static int GetTime()
{
//error:静态成员不能访问非静态成员变量
//_year++;
return _time;
}
private:
int _year;
int _month;
static int _time;
};
//类外中初始化
int Date::_time = 2;
int main()
{
Date d1;
//静态成员的访问方式
d1.GetTime();
Date::GetTime();
return 0;
}
应用:题目链接
class Solution
{
public:
class sum
{
public:
sum()
{
_ret += _i;
++_i;
}
};
//可以很好地用静态成员解决
static int _ret;
static int _i;
int Sum_Solution(int n)
{
sum arr[n];
return _ret;
}
};
int Solution::_ret = 0;
int Solution::_i = 1;
4.友元
有时候在类外需要访问私有成员,但是每次都要单独写一个Get…函数会显得有点麻烦,友元函数声明也是一种解决办法
class Date
{
public:
//友元函数声明
friend void Func(const Date& d);
Date()
{
_year = 0;
_month = 0;
}
private:
int _year;
int _month;
static int _time;
};
//类外中初始化
int Date::_time = 2;
void Func(const Date& d)
{
//支持在类外访问类中的私有成员
cout << d._year << d._month << d._time << endl;
}
int main()
{
Date d1;
Func(d1);
return 0;
}
- 使用关键字friend声明函数,函数就为友元函数,支持在类外访问私有成员变量
- 友元分为:友元函数和友元类,友元函数仅仅是一种声明,并不属于成员函数
- 友元不具有传递性,类A是类B的友元,类B是类C的友元,但是类A和类C并没有友元关系
- 友元也不具有交互性,类A是类B的友元,类B并不是类A的友元
- 友元的设计一定程度上破坏了封装(类的三大特性之一),增加了耦合度
- 友元函数可以在任意一个位置声明,它不受访问限定符的限制
- 一个友元函数可以是多个类的友元
5.内部类和匿名类
类还有一些特殊的分类,它们属于稍微边缘化的东西,但是还是具有一定的作用
5.1 内部类
- 内部类是设计在一个类中的类,大部分情况,如果要专门设计一个方法就会使用内部类
- 内部类默认是外部类的友元,可以访问其私有成员
- 内部类也体现了类的封装特性,它和普通类一样,只是受类域的影响而已
在上述静态成员的应用中,就可以用内部类单独设计成计算sum的方法:
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
5.2 匿名类
- 实例化出的对象分为:有名对象、匿名对象。我们之前实例化出的对象都是带有名字的,被称为有名对象,对象也可以不带名字,直接用实例化的类跟(),被称为匿名对象
- 匿名类的生命周期只在那一行,它就像一个一次性纸杯,即用即销毁
- 匿名类很适合用在类中有它专属的类方法,就不用单独实例化一个类,直接使用匿名类调用类中的成员函数,比如:STL中的仿函数
class A
{
public:
A()
{
cout << "A()" << endl;
}
~A()
{
cout << "~A()" << endl;
}
int A1_Add(int x)
{
return _a1 + x;
}
private:
int _a1 = 1;
};
int main()
{
A aa1;
aa1.A1_Add(3);
//匿名对象的调用
A().A1_Add(3);
cout << "****************************************" << endl;
return 0;
}
匿名函数的生命周期:
6.对象拷贝时编译器默认优化
- 现代编译器有时候为了提升效率,在传值传参和传值返回的场景下,如果出现了连续的构造编译器可能会减少实际的拷贝数进行优化
- C++11没有明确规定对于拷贝数的优化,具体实现由编译器厂商决定
- 查看C++中构造和拷贝构造、赋值的优化主要分为三种方式:vs2019(debug)、vs2019(release版本即以后)、Linux
- Linux环境下关闭默认优化的编译命令:g++ test.cpp -fno-elide-constructors
为什么会出现拷贝的过程?
在传值传参、隐式的强制类型转换、传值返回、表达式求值……,编译器都会产生一个临时对象
int main()
{
int i = 0;
//普通引用不能引用临时对象
//临时对象具有常性
int& q1 = i + 1;
//const引用就支持
const int& q1 = i + 1;
//所以一般建议函数形参的位置加上const
//这样既能引用普通类型,也能引用临时对象
return 0;
}
#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);
cout << "****************************************" << endl;
// ⼀个表达式中,连续构造+拷⻉构造->优化为⼀个构造
f1(A(2));
cout << "****************************************" << endl;
// 传值返回
// 不优化的情况下传值返回,编译器会⽣成⼀个拷⻉返回对象的临时对象作为函数调⽤表达式的返回值
// ⽆优化 (vs2019 debug)
// ⼀些编译器会优化得更厉害,将构造的局部对象和拷⻉构造的临时对象优化为直接构造(vs2022 debug)
f2();
cout << "****************************************" << endl;
// 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019 debug)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,将构造的局部对象aa和拷⻉的临时对象和接收返回值对象aa2优化为⼀个直接构造。(vs2022 debug)
A aa2 = f2();
cout << "****************************************" << endl;
// ⼀个表达式中,开始构造,中间拷⻉构造+赋值重载->⽆法优化(vs2019 debug)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,将构造的局部对象aa和拷⻉临时对象合并为⼀个直接构造(vs2022 debug)
aa1 = f2();
cout << "****************************************" << endl;
return 0;
}