面向对象与面向过程
在传统的c里,我们设计程序时是面向过程的,比如我要写实现一个点外卖软件,我需要依次实现以下几个功能
这些功能是按照发生的顺序依次运行的,也就是按照整个程序执行的过程来依次发生的。
关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
而c++是面向对象的,比如我们还要实现上的外面软件,我们可以把他划分出几个对象:
这些对象相互作用,交互共同完成需求。
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
c++面向对象概述
c++11目前有一套成体系的面向对象思路,但想要跟深入的理解,必须从类讲起
c++类的引入
在传统的c语言中,为了封装一些数据,我们诞生了结构体
struct Student
{
char name[10];
int age;
char sex[5];
}
但是结构体中不能有函数,只能有编译器设置好的数据类型。因此我们在c++中队结构体进行了升级,使它的内部能有函数,这样子提高代码的逻辑性。
struct Data {
int _year;
int _month;
int _day;
void printData() {
cout << _year << "年" << _month<<"月" << _day<<"日"<<endl;
}
void initData(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
}
在定义中可以添加函数,并且既能访问成员,又能访问函数。
但是struct毕竟是c语言的东西,在c++纯正的面向对象中我们更喜欢用class关键字来声明一个类
class 关键字
上面用struct关键字定义的类,可以等价为下面这个class关键字定义的类。
class Data {
public:
int _year;
int _month;
int _day;
public:
void initData(int year, int month, int day) {
_year = year;
_month = month;
_day = day;
}
void printData() {
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
};
在struct中,无论是函数还是变量都可以在外界被访问到但是在实际开发中,我们往往不希望有些函数或变量被随意调用,因此诞生了访问限定符。
访问限定符
访问限定符简述
访问限定符分为;private,protect public,三种。
其中private限定只能在本类中访问,protect限定只能在本类和他的子类中访问,public随时可以访问。
默认访问限定符
在一个类中,如果没有访问限定符,那么它默认都是private。
访问限定符作用域
一个访问限定符的作用范围是从上一个开始到下一个开始。
print函数被private修饰,而init函数被public修饰可以外界访问。
c++ struct和 class的区别是什么
- c++兼容c,因此struct可以当结构体用
- c++可以用struct创建类,也可以用class创建类。
- c++用struct创建的类成员默认访问方式是public。而class默认是private
封装
封装就是将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
比如上述的Data类中,如果外界可以轻易的访问内部存储的日期,那么日期会被任意修改,失去了这个类的意义。
如果像C语言中函数和变量分离的话,每次调用需要人为传参,会极大不安全。
然而我们一旦封装之后,**用访问限定符控制一些东西不让外界访问,从而根本上保证安全性。**为此我们在设计类的时候,成员变量一般是私有的,方法根据设计
这种不关注内部实现,仅通过接口调用的特点也是面向对象的关键之一。
类的细节实现
类的作用域
类本质上是新建了一个作用域,因此不同类之间可以有同名函数,且不构成重载**(重载要求在同一作用域下)**
类中函数的声明和定义
类中的函数可以在类的中声明同时定义
也可以在类中声明,类外实现。但是在实现的时候要指明函数的作用域,不然不知道是那个哪个类的函数。
注意:在类中实现的函数默认是内联函数,因此我们往往推荐将短小的且频繁调用的函数在类中实现
类中变量的声明和定义
对于一个变量来说,区别声明和定义的唯一标准就是是否开辟了空间。开辟空间的叫做定义。
因此在类中的变量都是声明,只有当实例化类的时候,才开闭空间,进行定义操作。
类的实例化
类是一个图纸一样的东西,里面存放的只是声明,不能存储东西,只有将类实例化对象的时候才开辟空间存储东西。
对象的大小
类里面既有函数,又有变量,那么类实例化出的对象的大小是多少呢?
对于这个类实例化出的对象d1,我们打印他的大小。
结果是12,者正好是三个 三个int类型的变量按照原则对齐后的结果,也就是说,对象内部不存放函数,更不存放函数的指针。
因为每个对象里的内容都是不同的,所以变量必须私有,而函数是共同调用的,因此函数可以是共有的,单独放在一区域中。
得到这个结论后,我们再来看下面几个练习题
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1(){}
private:
int _a;
char _ch;
};
根据前面讲的对象里只有成员变量这一特点,第一个元素是int大小为4个字节,第二个元素是char要从对齐数的整数倍开始放,对齐数是默认对其数(8)和上一个元素中最小的那个,因此对齐数是4,要从4开始放,放完是5。最后要进行内存对齐,变成4的整数倍,得到结果8。
// 类中仅有成员函数
class A2 {
public:
void f2() {}
};
// 类中什么都没有---空类
class A3
{
};
这两者都是没有成员变量的类,那么他们实例化的对象大小是多少呢?
结果是1,所以对于没有成员变量的类,实例化的对象会被赋值为1,表示这个对象存在。
this指针
this指针的引入
在上述代码中,明明printData()函数是共有的,且里面没有传参,编译器是怎么知道我打印的是d1,还是d2呢?
这时因为编译器会给添加一个this指针,用于指明访问的是哪个对象。
void Data::printData(Data* this) {
cout << this->_year << "年" <<this-> _month << "月" <<this-> _day << "日" << endl;
}
在编译后printData()就被修改为带有this指针,同时调用的过程中,也会传入调用对象的指针。
注意:我们不能抢编译器的功能,this不能显示的写出来但是我们却可以在类中调用这个this
this指针的特点
this指针是const 类型,不可以被修改
前面讲过,编译器会自动生成一个this指针,表明调用这个函数的对象,但是我们能自己定义this,却可是使用它。
可以看到,我们在类中没定义this,却能正常使用他。
但是this不可以被修改
可以用const修饰的引用来对他起别名,也证明了this是const修饰的常量。
上图就是被编译器修改后的标准函数。
this指针存在函数的栈帧中
因为this指针实际上是一个形参,他在函数调用产生的栈帧里。因此this指针和正常函数的形参一样,都存储在函数栈帧中
在某些编译器中,this指针也可能放在寄存器里。总而言之,this指针是编译器帮你添加的,我们可以调用它,总之this指针不在对象里存储。
this只能在成员函数内部使用
this与空指针
将这个之前,我们要先补充一个东西,当我们有一个对象的地址的时候,也可以像c语言一样用->来访问函数
Data d1;
d1.Init(2022,4,13);
Data* d2 = &d1;
d1.print();
d2->print();
有了这个知识后,我们来看下面这串代码。
在编译的时候,编译器会自动补齐this指针,shou()函数被传入一个NULL指针,随后输出一个"show()"字符串。
但是这个代码则会报空指针异常,因为打印_a本质上是打印this->_a,而我们传入的this指针是空。
六个默认成员函数
所谓默认成员函数,就是哪怕我们不写,编译器也会自动生成的成员函数,下面我们依次来学习这些函数
构造函数
前面我们都是手动进行初始化,但是如果一旦忘记初始化,就会出现随机值压栈报错。为此我们引入了构造函数,使其在对象的实例化就完成初始化。
注意:构造函数所谓构造是对属性的初始化过程,对象空间的开辟是由编译器完成的。
构造函数的特征
- 函数名与类名相同
- 没有返回值1
- 对象实例化时自动化完成
- 构造函数可以重载(同时实现含参构造与无参构造)
全缺省和无参构造不能同时存在
这样子虽然编译会通过,但是调用时会报错。
这是因为编译器分不清我们调用的是全缺省还是无参构造。
一旦显式定义,编译器就不生成构造函数
可以看到我们没有写构造函数,编译器就给我们生成了一个默认的构造函数,但是这些属性都是随机值,那这个函数有什么用呢?
原始类型不初始化,自定义类型初始化
我们在Data类中的一个属性是Day类的对象,我们在创建d1对象时会发现,原始数据类型int没有被初始化,而我们自定义的Day类的构造函数被调用了,完成了初始化。
只有自定义类型day完成了舒适化,其他的没有初始化。
所有对于普通成员变量,不初始化,对于自定义类型,调用他的默认构造函数。
默认构造函数有三种:
- 啥都没写编译器自己生成的
- 自己写的无参构造函数
- 自己写的全缺省构造函数
注意:3,2,1就不能同时出现。
总结:如果一个类中的成员全是自定义类型,我们就可以用默认生成的构造函数,否则都要自己实现构造函数。 但如果我的自定义类型没有构造函数,也不行。
缺省值为默认构造函数使用
前面讲过,默认构造函数只能初始化自定义类型,为解决这种问题,c++11补充了普通变量的缺省值。
缺省值解决了这个问题。
析构函数
有构造函数就一定有析构函数。构造函数解决了生成对象后的初始化问题,而析构函数完成了对象清理的过程。
析构函数与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作
资源清理就是把对象中malloc的空间进行消除,防止栈溢出,相当于以前写的destory()函数
析构函数的特点
以~号开头,我们可以自己写析构函数,但如果自己没写,就会默认生成。
析构函数发生顺序
对于上面这个Stack类,先调用s1的构造函数,再调用s2的构造函数。
但是先调用谁的析构函数呢?这个问题的本质就是,s1和s2谁先被销毁。
我们认为的写一个析构函数,并且输出他的capacity
可以看到输出的结果是
证明:析构的顺序和构造的顺序是相反的后定义的先析构。
默认类型不处理,自定义类型会调用对应的析构函数
这个和构造函数一样。
一口气出来的四个,证明析构函数会调用自定义类型对应的析构函数。
析构函数面试题
设已经有A,B,C,D4个类的定义,程序中A,B,C,D析构函数调用顺序为?( )
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
首先我们要明确一点: 全局变量最先构造,最后析构
其次 局部对象按照出现的顺序进行构造,无论是否为static
最后 析构的顺序按照构造的相反顺序析构,只需注意static改变对象的生存作用域之后,会放在局部 对象之后进行析构
所有构造顺序为 C A B D
析构顺序为 B A D C
拷贝构造
Date d1(2022,3,4);
Date d2 = d1; //拷贝构造
fun(d1); //创建形参时拷贝构造
void fun(Date d) {
}
拷贝构造函数是一个特殊的构造函数,是构造函数的一种重载。
他的形参是同名的对象。
拷贝构造必须用引用调用,不然会导致无穷递归。
为什么产生无穷递归
这就要从传参讲起,当一个传值传参的过程中,本质上是进行了一次实例化对象的过程。
要先新建一个d,新建d是拿d1创建的,要拷贝构造,拷贝构造又要拿d1创建,于是就不断进行拷贝,最终无限的递归。
总结:内置类型可以直接拷贝,自定义类型要调用拷贝构造
这里d的改变不会影响d1,因为d是d1的一个拷贝
为什么要加const
因为不加const的话可能会导致反向赋值这种操作发生。
默认拷贝构造的本质
默认的拷贝构造是浅拷贝,就是直接把待拷贝的对象里的属性给复制到新的对象里,浅拷贝会发生各种问题。
浅拷贝就是直接把这块内存的东西直接放到新的对象里。
注意:默认的拷贝构造会对默认类型进行浅拷贝,但是浅拷贝不一定是错的,比如前面的日期类。
深拷贝
有些对象的拷贝构造不能随便写,要根据实际情况去写
比如这种类,他就不能用默认的拷贝函数,需要用自己写的深拷贝来解决这种问题。
因为假如按照默认的拷贝构造,则会使两个对象同时指向被拷贝的对象的初始化时malloc的空间,两个都指向了malloc。而对象销毁时析构会被析构两次,以及两者对象操纵同一块空间,会产生各种问题。
自定义类型与普通类型同时存在
自定义类型与普通类型同时存在时,编译器会将普通类型进行浅拷贝,然后调用自定义类型的拷贝构造。
我们写了一个Stack类,他的拷贝构造我们来打印一个deepcopy。
这是队列类,他由两个自定义类型栈构成,对q2进行q1的拷贝构造。
输出两次 deep copy证明确实调用了自定义类型的拷贝构造。
拷贝构造有关面试题
以下代码共调用多少次拷贝构造函数: ( )
Widget f(Widget u)
{
Widget v(u);
Widget w=v;
return w;
}
main(){
Widget x;
Widget y=f(f(x));
}
逐行分析代码,先实例化一个对象x,然后调用f(x),f(x)的返回值为f()的参数。
进入f,f的形参是一个Widget对象,进行一次拷贝构造。
然后用u生成v,进行一次拷贝构造
用v生成w,再进行一次拷贝构造。
并返回w
再进入f(w)
生成形参 u ,进行一次拷贝构造
u生成v进行一次拷贝构造
v生成w,进行一次拷贝构造
返回w。
用w生成y,进行最后一次拷贝构造
最终结果为7次
总结
- 如果不写拷贝构造的话,编译器会默认生成
- 编译器默认生成的拷贝构造分类讨论:对内置类型直接进行浅拷贝,对自定义类型会调用自定义类型的拷贝构造
- 如果编译器默认生成的拷贝构造够用,那就不需要自己写,否则需要自己写。
- 拷贝构造一定是引用传参,否则会递归调用。
运算符重载
运算符重载就是为运算符赋予新的作用效果。
int a = 10;
a = a+ 100;
内置类型可以直接用运算符
Data d1;
d1 + 100;
自定义类型不能用各种运算符,需要我们自己定义
而为了解决这种问题,并且提高代码的逻辑性,c++提出了运算符重载
运算符重载函数标准模板
operate +要重载的运算符。
参数是:运算符的操作数
返回值,认为定义的运算结果
bool operator==(Data d1, Data d2)
return d1.year == d2.year && d1.month ==d2.month && d1.day == d2.day;
上面就是一个标准的运算符重载函数
注意:正常情况下,Data类中的year属性应该是私有的,所以不能直接用d1.year来访问,为此我们要提供get或set方法。
但是注意,我们往往不会用上面的这种写法
编译器的自动处理
自定义类型要引用传参
引用传参可以减少拷贝的次数
注意
三目运算符 、 sizeof, ::域指定运算符, .运算符,.*运算符不能重载。