写在前面
笔者的C++计算机二级刚出分,评级是优秀。笔者认为自己已经明白了面向对象是什么,但是在Qt开发app的时候,笔者做了50%的时候意识到自己写的代码是屎山,实质上还是函数套函数,面向对象的知识根本没有用到。笔者深刻反思之后知道自己其实不懂面向对象,只是刚达到能认出面向对象的水平。
学长推荐我去看设计模式,笔者在看了一部分之后豁然开朗,对面向对象的理解又深入了一大层,在掌握新知识的欣喜同时,又对自己的浅薄哭笑不得。
学的越多,就越觉得自己什么都不会
言归正传,笔者在这篇博客里写一些自己对面向对象的理解,既是分享见解,也是对自己是否真正理解面向对象的考察
细说理解
(1)什么是面向对象
我认为面向对象的本质就是分工协作,一个类只完成一个功能,通过类和类的连接实现完整的功能。在分工协作之上又有面向对象的特性,这些特性使得代码的复用性大大提升。
面向对象的思想是分治和抽象,分治就是把一个大的任务拆成各个部件,抽象就是实现部件的复用和统一。
一个比喻总结面向对象: 面向对象就是战争中的海陆空军(抽象类),每个军种(派生类)既符合所处军种的战斗形式(基类属性),又有自己的战斗方式和风格(基类声明的纯虚函数),三大军种的每个部队各司其职,最后获得了战争的胜利。这里的海陆空完全可以扩展数量,但本质就是这样。
(2)面向对象的目的
面向对象是当前很常见的一种编程范式,java、python、go、c++都可以面向对象编程,java更是一门纯粹的面向对象语言,那为什么要面向对象编程呢?既然大多数人认可面向对象,就说明它是有优势的。
我认为面向对象的目的就是:
-
简化思路,让一个从前到后的需求实现思路变成分配任务的实现思路
-
代码复用,这个功能做完不止在这里可以用,其他地方也可以用
-
提高抗逆性,若是需求要改动,只需改实现这个需求的类即可,其他地方不用动
-
增强扩展性,若是要加入某个功能,只需要把它和其他类连接起来即可
-
提高协作效率,一个好项目必然是团队开发的,团队成员只需完成各自类并留出接口即可整合
(3)面向对象的特性
面向对象的特性就是艺术,我个人认为特性的思维高度超越了把一切看作对象这一思想的思维高度。
面向对象的特性有封装、继承、多态。读起来很容易,读两遍就顺口了,能背下来了,但是真正要理解这三个特性,却需要更多思考。
封装:每个类要把自己的属性和方法装起来,“我就要做这个,我就是有这个”,这就是封装。这个特性可以实现信息隐藏和保护,只有有必要漏出来的东西才漏出来被使用
继承:我认为这一特性正是面向对象的魅力所在,它是面向对象发挥出自己优势的基础。一个类可以继承另一个类,被继承的类(基类)里的东西就可以被继承它的类(子类)使用,子类也可以扩展自己的属性和方法
多态:只能用优雅来形容这个特性。基类和子类有同名的方法怎么办呢?可以把基类的同名方法声明为虚函数,这样在使用子类时就用到了子类的方法。我知道这个东西需要实现,但是我现在实现不了,我希望后续有人能实现它怎么办呢?在基类里把这个方法声明为纯虚函数,它就变成了“抽象类”,这个词正是面向对象的巧妙。子类必须重写这个方法,否则它也变成抽象类,反正这个功能是需要实现,那子类实现不就好了。
用(1)中举的例子来解释这三个特性:
封装就是每个军种旗下的部队具有的战斗风格和武器装备等等特性
继承就是由军种到具体的部队,部队的战斗形式自然是要符合自己所属的军种,所以同一个军种的部队是有共性的
多态就是:军种是个抽象的东西,他不能具体到什么部队什么战斗,但是它确实有某些特性,例如闪击、轰炸、侦察,它自己实现不了,所以它就把这些特性声明成纯虚函数,它旗下的部队再重写这些纯虚函数
(4)面向对象的基本原则
面向对象一共有八大原则,接下来依次说明:
- 单一职责原则:
一个类应该仅有一个引起它变化的原因
变化的方向隐含着类的责任
我们来理解一下这个原则:
这两句话连在一起其实就可以,一个类的应该只有一个任务,只有这个任务可以引起这个类的变化
这一原则事实上要求实现极致的分工合作。再拿军队举例子的话就是:每个部队只负责服从命令,上边让干什么就干什么,指哪打哪,除此之外不需要思考,不会做分内之外的事情。
- 开放封闭原则:
对扩展开放,对更改封闭
类模块应该是可扩展的,但是不可修改
这个原则里重要的是:已经完成的类不可修改。这是面向对象很关键的一个地方,你可以加新的联系人,但是你就是不能改动它本身。
为什么?这里其实涉及到“牵一发而动全身”,每个类肯定都不是孤立的,它既然与其他类有联系,那你动了它,其他地方是不是要跟着动,这就大大增大了出错的概率和工作量,人总是会疏忽的,你不可能记得住所有和这个类相关的地方,那你必然会出问题
- 依赖倒转原则:
高层模块不应该依赖于低层模块,二者都应该依赖于抽象
抽象不应该依赖于实现细节,实现细节应该依赖于抽象
怎么理解这个原则呢?
先理解一下第一句话:高层模块和低层模块中间应该接一个抽象层,这里的抽象可以理解为纯虚函数与虚函数。高层模块就是最终的实现程序,低层模块就是一个一个类。那这句话就可以翻译出来了:实现程序不应该依赖于一个个类,反而它和类都应该依赖于抽象类的纯虚函数或虚函数。
这是为什么呢?笔者刚才不是说面向对象就是一个一个类通过连接实现功能吗,为啥现在又说实现程序不依赖类,这不是自相矛盾吗?实则不然。“抽象”在这里就发挥了作用。实现程序要包含的内容不应该是类的实例,而应该是抽象类的指针,然后通过指针调用对应的类。类通过继承抽象类来重写它的方法,完成独立的任务。 再拿(1)的例子来说明一下:战争的胜利不应该依靠一个个部队的战斗,而应该依靠军种指挥部的指挥调度。
那这样又有什么好处呢?我们来假设一个情景:你辛辛苦苦敲了一组功能,这个功能是不同类结合起来的,现在有个新需求,让你加一个新类,你怎么半?你只能再开刀改已有的类,但是如果使用了抽象类,那再给抽象类整一个派生类不就好了?可见,这样做的话程序的抗逆性将会大大增强
接下来再理解第二句话:抽象类的方法声明不应该是派生类需要什么就声明什么,反而应该是抽象类提前想好有什么方法,然后派生类重写这些方法。
那这个又是为什么呢?再试想一个场景:对于一个抽象类你已经声明了一百个派生类,这些派生类都很完美地契合了抽象类,现在突然你一拍脑门,想到了个新类,只是这个类与抽象类不太契合,你需要在抽象类加点方法,你会这样干吗?如果你真的动了抽象类,那这一百个完美的派生类你是动还是不动呢?
总结一下这个原则:使用面向对象的时候应该是:提前想好一个实现程序应该有哪些功能,需要怎么分工,然后声明一个或多个抽象类,这些抽象类声明各自可能会用到的方法,然后派生类再重写这些虚函数或纯虚函数,抽象类写好就不要动了,可以再加类,但是不要动已经做好的。
- Liskov替换原则
子类必须能够替换它们的基类
继承表达类型抽象
这两句话应该拆开来理解:
第一句话的意思是“青出于蓝而胜于蓝”,基类能干的事子类一定能干,而且说不定能干得更好。但是这句话并不是强调派生类比基类更强更有用,而是强调派生类一定要完全符合基类的设计理念。
通俗解释就是,我造了你不是让你自己玩出花的,而是我给你规定了一些东西,你必须要把这些东西做完。如果派生类不能替换基类的话,那很遗憾,说明这个继承实际上是失败的,这个派生类生错地方了,它不属于这个基类。
第二句话的意思是:继承的作用就是“完成父辈没有完成的任务”。基类是抽象类,它需要完成这个方法,但是因为没定方向,他就让派生类定一个方向去完成这个方法。
这个原则实际上重点解释了继承,总结一下就是:继承是为了让派生类严格遵守抽象类定下的基调,然后完成抽象类想要完成但是没完成的方法
- 接口隔离原则
不应该强迫客户程序依赖它们不用的方法
接口应该小而完备
这个原则主要关注的是一个类内部的成员分布。
翻译成大白话就是:有必要的方法和变量才开放成公共成员,如果是没必要的就不要把它开放,直接做成私有成员就行。
为什么要这样呢?只要你把一个方法开放成公共的,那它会不会被外部调用就不是你能掌控的了。即使说你现在很清醒,这个方法不会被其他地方调用,那以后呢?别人用的时候呢?它可能就被调用了。一个类被调用的东西越多,那这个类需要的稳定性就越高,一旦这个类出了点什么事,调用它的地方就完蛋了。
总结一下就是:非必要不调用,不调用就不公开
- 优先使用对象组合,而不是类继承
继承在某种程度上破坏了封装性,子类父类耦合度高
对象组合只要求被组合的对象具有良好定义的接口,耦合度低
继承多好,可以重写虚函数,还可以继承抽象类,又灵活又通用抗逆性还强,看来这个原则是错误的?
实则不然。面向对象强调“高内聚,低耦合”。抽象类离了子类还能发挥作用吗?子类离了抽象类还能站得住脚吗?类继承使用越多,系统整体的耦合性就越高,独立性越差,对应的可复用性可维护性可扩展性就越低。一直用继承看似很灵活,其实是一种短视,它没有考虑到更多的程序开发,其他地方的复用,只着眼于当前需求的实现。而且继承很容易被误用。可能没有完美的继承关系还是用到了继承,这样做是会造成浪费的
反观对象组合,你没了我你还能正常发挥作用,我没了你我也能正常发挥作用,只是咱们没有联系了,我的接口其他类的对象照样能用。而且不需要考虑继承错误的问题
- 封装变化点
使用封装来创建对象之间的分界层,让设计者可以在分界层一侧进行修改,而不会对另一侧产生不良的影响,从而实现层次间的松耦合
这个原则比较吃手法吃意识,需要很多项目经验之后才能熟练运用。它要求开发者提前预判系统的可能会发生变化的地方,把这些潜在变化点封装起来,不至于会影响到稳定的模块。而这些潜在变化点需要提供稳定的接口,在它真的发生变化的时候就改变封装的内部实现方法
- 针对接口编程,而不是针对实现编程
不将变量类型声明为某个特定的具体类,而是声明为某个接口
客户程序无需获知对象的具体类型,只需知道对象所具有的接口
减少系统中各部分的依赖关系,从而实现“高内聚低耦合”
这一原则并不绝对,它只是强调面向对象的重点是接口。
让我按这三句话的顺序来解释一下这个原则:
在程序开发的时候,抽象类的纯虚函数和虚函数只负责规定方法,具体实现有一个、十个、一百个派生类都无所谓,开发抽象类的时候不关心这个。
在真正要实现功能的时候,功能部分调用的时候也不调用具体的派生类,而是通过抽象类的指针直接指向对应的抽象类,显式表达就是这个抽象类,功能部分不关心是谁来完成这个功能的
这样的话,彼此间有联系但是又相互隔离,可维护性大大提高
(5)类与类的关系
类和类可不止继承这一个关系,它的灵活性令人惊叹
继承关系:
继承关系用于描述父子类之间的关系,由于本篇博客是基于C++计算机二级的标准上写的,所以继承怎么实现在这里不细讲了。
只要明白继承的形式就是:常规情况下子类可以使用父类的非私有成员,如果父类有纯虚函数则子类必须重写实现,如果父类有虚函数,子类这个函数被调用时不会指向父类
关联关系:
构成关联关系的两个类是平等的,表示一个对象与另一个对象之间有联系。
C++中会这样体现:一个类的对象作为另一个类的成员变量
关联关系分为单向关联、双向关联、自关联
需要注意的是:双向关联可能出现“先有鸡还是先有蛋”的问题,这个问题可以使用前置声明和指针定义的方法避免
聚合关系:
这是一种类似“树倒猢狲散”的关系,几个类同时构成一个“主类”,这个“主类”的依靠构成它的类发挥作用,但是如果“主类”被析构了,构成它的类并不受影响,它们还可以继续完成自己的功能
组合关系:
这是一种“一荣俱荣一损俱损”的关系,也是一个类构成一个“主类”,但是在这个主类析构的时候,构成它的类会跟着它一起被析构,这样做是为了方便内存管理
依赖关系:
特定事物的改变有可能会影响到使用该事物的其他事物。使用场景是:在表示一个事物的时候需要使用另一个事物来表示,就像是一个类的方法需要另一个类的对象作为参数
总结: 继承关系是类与类之间直接基于类型定义的关系,而关联、聚合、组合、依赖等关系往往通过类的实例或部分静态成员 / 方法来建立联系
(6)UML类图的画法
笔者这里使用的画图软件是WPS,下面的表述都直接上图了
单个类的画法:
类分为上中下三部分,最上边是类名,中间是属性即类的成员变量,最下边是方法即成员函数
+、-、#是可见性,+是public,-是private,#是protected,_是static
格式如图,
属性:可见性 成员名称 : 数据类型 = 可有可无的默认值
方法:可见性 成员名称(参数名 :参数类型)返回值类型
如果定义的类是抽象类,类名需要用斜体表示,虚函数表示也是斜体,纯虚函数要在最后加一个"=0"
类的关系画法:
继承:用带空心三角头的实线表示,箭头从子类指向父类
编辑
关联:用带箭头的实线表示,箭头从次类指向主类,双向关联就是两个箭头
聚合:用带空心菱形的实线表示,空心菱形指向整体类
组合:用带实心菱形的实线表示,实心菱形指向整体类
依赖:用带箭头的虚线表示,箭头指向被依赖的类
篇末总结
这篇记录笔者对面向对象的博客知识方面的内容已经讲完了。算是笔者对面向对象的一种学习吧,因为笔者在看设计模式网课的时候一直有一种冲动,好像是我已经抓住了面向对象的本质,又没抓住一样,像吃了苍蝇一样难受,所以笔者在学了设计模式的冰山一角时就迫不及待地写下了这篇博客。
面向对象是艺术,这是我作为初学者由衷的赞叹,如果能真正掌握面向对象,那敲代码一定是一件很愉悦轻松的事情。