面向对象(OOP)的进一步理解


首先来一段Scala 作者 Martin Odersky 的原话:

In principle, the motivation for object-oriented programming is very simple:
all but the most trivial programs need some sort of structure. 
The most straightforward way to do this is to put data and operations into
some form of containers. The great idea of object-oriented programming is to
make these containers fully general, so that they can contain operations
as well as data, and that they are themselves values that can be stored 
in other containers, or passed as parameters to operations. Such containers are 
called objects. Alan Kay, the inventor of Smalltalk, remarked that in this way
the simplest object has the same construction principle as a full computer: 
it combines data with operations under a formalized interface. So objects have
a lot to do with language scalability: the same techniques apply to the construction
of small as well as large programs.

再来一个非常NP(但却很形象)的图示:
在这里插入图片描述

为什么会有面向对象编程?

众所周知,面向对象三大思想:封装,继承,多态
但面向对象到底是怎么产生的,产生他的原因是什么呢?

先从结构化编程说起

很久以前,软件开发的世界还是一片浑浑噩噩,不管开发什么软件都面临着复杂性这个问题,代码里面到处是goto语句,程序的流程随意跳转。众生写代码时,越写到后面越不知道自己写的是什么。这时候出现一位巨人,它就是结构化编程。
结构化编程的基本思想是:

有序地控制流程,即把程序的执行顺序限制为顺序、分支和循环这三种;
把共通的处理归结为例程(函数)。

结构化编程的好处是:

  • 三大限制:大大降低了程序的自由度,减少了各种组合,使得程序不至于太过复杂。对于这一点,结构化编程的顺序、分支和循环可以实现一切算法,虽然降低了程序的复杂性和灵活性,但是程序的实现能力并没有降低。
  • 例程(函数):我们只需要知道过程(函数)的名字,而不需要知道过程的内部细节,即“黑盒化”。

虽然结构化解决了程序控制流的复杂问题,但程序里面不仅包括控制结构,还包括要处理的数据。随着处理数据的增加,程序的复杂性也会上升。这时候,面向对象编程来了!

面向对象的由来

“分别管理程序处理内容和处理数据对象所带来的复杂性”问题是,为了得到正确的结果,必须保持处理和数据的一致性,这在结构化编程中是非常困难的,解决这一问题的方案就是数据抽象技术。
数据抽象是数据和处理方法的结合。这便是最初“对象”一词的得来。面向对象编程也因此得名。
然后,从抽象原则来说,多个相同事物出现时,应该组合在一起,即DRY原则(Don’t Repeat Yourself),便又引出了类这一概念。
根据数据类型来进行合适的处理(调用合适的方法),本来就应该是编程语言这种工具应该完成的事。这便是多态的引出了。
而对于继承,大部分的观点是“继承是随着程序的结构化和抽象化自然进化而来的一种方式”。结构化和抽象化,意味着把共通部分提取出来生成父类的自底向上的方法。(如果继承是这样诞生的话,那么最初,有多个父类的多重继承就会成为主流,而实际上最初引入继承的Simula语言只提供单一继承。松本行弘认为继承的原本目的实际是逐步细化)
这就是面向对象编程的由来。

结构化程序设计与面向对象

结构化程序的概念首先是从以往编程过程中无限制地使用转移语句而提出的。转移语句可以使程序的控制流程强制性的转向程序的任一处,在传统流程图中,就是用“很随意”的流程线来描述这种转移功能。如果一个程序中多处出现这种转移情况,将会导致程序流程无序可寻,程序结构杂乱无章,这样的程序是令人难以理解和接受的,并且容易出错。尤其是在实际软件产品的开发中,更多的追求软件的可读性和可修改性,象这种结构和风格的程序是不允许出现的。比如:C,FORTRAN,PASCAL等等 。
结构化程序设计方法主要由以下三种逻辑结构组成:

  1. 顺序结构:顺序结构是一种线性、有序的结构,它依次执行各语句模块。
  2. 循环结构:循环结构是重复执行一个或几个模块,直到满足某一条件为止。
  3. 选择结构:选择结构是根据条件成立与否选择程序执行的通路。

采用结构化程序设计方法,程序结构清晰,易于阅读、测试、排错和修改。由于每个模块执行单一功能,模块间联系较少,使程序编制比过去更简单,程序更可靠,而且增加了可维护性,每个模块可以独立编制、测试。
  面向对象程序语言中支持的对象具有三个明显得特征: 封装、继承、多态。而Javascript在类别和副本上并没有明显地分界。事实上,Javascript语言中所有的变量数据类型在JS中都是对象。JS总共支持4种对象:内置对象、自定义对象、浏览器程序对象和ActiveX对象。 return this只是返回该对象,以便别处引用,但并不是多出一个对象。
  所以,面向过程的程序设计中:程序=算法+数据结构(数据和数据类型)。数据结构和算法相分离的,所以,系统庞大后,它的控制、移植、重用就成了问题。
而在面向对象的程序设计中:

  • 对象=数据结构+算法
  • 程序=对象+对象+…

结构化程序设计从系统的功能入手(有的用户也成为面向过程的设计),按照工程的标准和严格的规范将系统分解为若干功能模块,系统是实现模块功能的函数和过程的集合。
  面向对象程序设计从所处理的数据入手,以数据为中心而不是以服务(功能)为中心来描述系统。他把编程问题视为一个数据集合,数据相对于功能而言,具有更强的稳定性。面向对象程序设计是一种围绕真实世界的概念来组织模型的程序设计方法,它使用对象来描述问题空间的实例。

  • 对象:对象是包含现实世界物体特征的抽象实例,它反映了系统为之保存信息和与它交互的能力。对象=数据+作用于这些数据上的操作。
  • 类:类是对象集合的抽象,它规定了这些对象的公共属性和方法;对象为类的一个实例。关键字class不等同于类。使用关键字class定义不是唯一完成面向对象编程的方法。例如JavaScript就用function定义的语句块来完成面向对象编程。
    在这里插入图片描述

面向对象的三个基本特征:

封装

封装是对象和类概念的主要特性。封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。

继承

面向对象编程 (OOP) 语言的一个主要功能就是“继承”。继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“子类”或“派生类”,被继承的类称为“基类”、“父类”或“超类”。继承的过程,就是从一般到特殊的过程。
要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。在某些 OOP 语言中,一个子类可以继承多个基类。但是一般情况下,一个子类只能有一个基类,要实现多重继承,可以通过多级继承来实现。

继承概念的实现方式有三类:实现继承、接口继承和可视继承

实现继承是指使用基类的属性和方法而无需额外编码的能力;
接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
可视继承是指子窗体(类)使用基窗体(类)的外观和实现代码的能力。

在考虑使用继承时,有一点需要注意,那就是两个类之间的关系应该是“属于”关系。例如,Employee 是一个人,Manager 也是一个人,因此这两个类都可以继承 Person 类。但是 Leg 类却不能继承 Person 类,因为腿并不是一个人。
抽象类仅定义将由子类创建的一般属性和方法,OO开发范式大致为:划分对象→抽象类→将类组织成为层次化结构(继承和合成) →用类与实例进行设计和实现几个阶段。

多态

多态性(polymorphism)是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。

实现多态,有二种方式:覆盖,重载

覆盖(override),是指子类重新定义父类的虚函数的做法。
重载(overload),是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。
其实,重载的概念并不属于“面向对象编程”,重载的实现是:编译器根据函数不同的参数表,对同名函数的名称做修饰,然后这些同名函数就成了不同的函数(至少对于编译器来说是这样的)。比如有两个同名函数:function func(p:integer):integer;function func(p:string):integer;。那么编译器做过修饰后的函数名称可能是这样的:int_func、str_func。对于这两个函数的调用,在编译器间就已经确定了,是静态的(记住:是静态)。也就是说,它们的地址在编译期就绑定了(早绑定),因此,重载和多态无关!真正和多态相关的是“覆盖”。当子类重新定义了父类的虚函数后,父类指针根据赋给它的不同的子类指针,动态(记住:是动态!)的调用属于子类的该函数,这样的函数调用在编译期间是无法确定的(调用的子类的虚函数的地址无法给出)。因此,这样的函数地址是在运行期绑定的(晚邦定)。结论就是:重载只是一种语言特性,与多态无关,与面向对象也无关!引用一句Bruce Eckel的话:“不要犯傻,如果它不是晚绑定,它就不是多态。”
那么,多态的作用是什么呢?我们知道,封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类);它们的目的都是为了——代码重用。而多态则是为了实现另一个目的——接口重用!多态的作用,就是为了类在继承和派生的时候,保证使用“家谱”中任一类的实例的某一属性时的正确调用。

五大基本原则:

  1. 单一职责原则SRP(Single Responsibility Principle)
    类的功能要单一,不能包罗万象,跟杂货铺似的。
  2. 开放封闭原则OCP(Open-Close Principle)
    一个模块对于拓展是开放的,对于修改是封闭的,想要增加功能热烈欢迎,想要修改,哼,一万个不乐意。
  3. 里式替换原则LSP(the Liskov Substitution Principle LSP)
    子类可以替换父类出现在父类能够出现的任何地方。比如你能代表你爸去你姥姥家干活。哈哈~~
  4. 依赖倒置原则DIP(the Dependency Inversion Principle DIP)
    高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。就是你出国要说你是中国人,而不能说你是哪个村子的。比如说中国人是抽象的,下面有具体的xx省,xx市,xx县。你要依赖的是抽象的中国人,而不是你是xx村的。
  5. 接口分离原则ISP(the Interface Segregation Principle ISP)
    设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。就比如一个手机拥有打电话,看视频,玩游戏等功能,把这几个功能拆分成不同的接口,比在一个接口里要好的多。

图解面向对象中的聚合与耦合

在面向对象的设计中,我们经常会听到或用到聚合、耦合的概念。面向对象的目标就是设计出高聚合、低耦合的程序。然而,究竟什么是聚合、什么是耦合,恐怕每个人都有自己的答案,换句话说,大多数人对聚合和耦合的概念是模糊的。
因为聚合与耦合这两个概念一直都是以"高聚合、低耦合"的形式出现的,刚刚开始接触面向对象设计时,我一直认为聚合和耦合是一对相反的概念,也就是说:只要做到了高聚合,那么自然而然就做到了低耦合。虽然这样的理解并不是错误的,但我并没有思考过原因。
先来看看聚合的定义:聚合(Cohesion)是一个模块内部各成分之间相关联程度的度量。
这里有多个含义值得考虑。首先,聚合是对一个模块内部的度量,这也是许多情况下我们把聚合称之为内聚的原因。第二,这里出现的模块是广义的模块,它可能是子系统,可能是功能模块,也可能是功能模块中的某一个类。从不同的层次看,聚合的程度也会有所不同。至于为什么不同,后面会有解释。第三,模块的成分包括模块的行为和状态。要做到高聚合,那么模块内部的行为必须要与模块的内部状态紧密关联。通俗来讲,一个模块仅完成一个独立的功能,模块内部不存在与该功能无关的操作或状态。

举一个生活中的例子。
有两座城市SidtownFredborough,连接两座城市的公路一天到晚总是拥堵不堪。经过"有关部门"调查之后发现,这两座城市中有两家公司Better MousetrapZokko SodaBetter Mousetrap的工厂建造在Sidtown,而该工厂的员工都居住在Fredborough,所以每天早上大批员工从Fredborough出发前往Sidtown,并在傍晚返回;类似的,Zokko Soda公司的运输车在每天的工作时间都需要在制瓶工厂和灌装工厂穿梭来往。
在这里插入图片描述
很明显,如果Better Mousetrap的工厂和员工居住地都在同一城市,而Zokko Soda的两座工厂都建造在另一座城市,那么城市之间的交通状况将会明显改善。

在这里插入图片描述
对比两图,上面两座城市间之所以出现交通的问题,是因为每座城市的"聚合性"都比较低:不相关的两个公司出现在了同一座城市,使得城市内部交通的利用率比较低,而城市之间的交通出现了超负荷。

再来看看耦合的定义:耦合(Couping)是模块之间相关联程度的度量。相对于聚合的内向性,耦合关注的是某一模块和其他模块之间的关联性。其实从前面的例子里,我们已经不可避免的提到了耦合的问题:由于两座城市之间的相互联系过于紧密,导致了城市之间的交通拥堵。另外一个潜在的问题就是,如果其中一座城市内部的交通出现了问题,另一座城市也会受到影响。我们所追求的低耦合,就是将两个模块之间的关联尽可能的降低,一个模块发生变化对于其他模块的影响尽可能的小。

再讲一个生活中的例子,相信大部分的80后小的时候都玩过一种掌上游戏机,这种游戏机内含一个俄罗斯方块的游戏。这种游戏机虽然风靡一时,但是不多久就渐渐淡出了市场,因为这种游戏机只有俄罗斯方块可以玩儿,当我们玩儿腻了的时候,这个游戏机也就如同废物一个了。
同期,任天堂推出一款后来风靡了将近20年的红白机,这种游戏机市场寿命如此之长并非游戏机本身质量有多好,而是因为基于红白机开发的游戏层出不穷,经典无数。魂斗罗、超级玛丽在当时哪怕是现在也是无人不知。红白机的游戏本身并不存储在游戏机当中,每当有新游戏推出的时候,只需要购买新的卡带即可。正是这种游戏机和卡带相对独立的设计,使得游戏的设计厂商无需关心游戏机的实现细节,只要遵循游戏机提供的接口(插槽)。很多游戏的设计厂商也从红白机庞大的市场中分得一杯羹。大多数的玩家可能不知道,魂斗罗并非任天堂推出的产品,而是目前以《实况足球》系列闻名世界的KONAMI公司于1988年从街机移植到红白机上的。
回到耦合的话题上来,因为早先的掌上游戏机将游戏本身内置在机器当中,游戏和机器这两个模块之间的关系过于紧密,所以游戏玩儿腻了,游戏机就没用了,游戏机出问题了,游戏也再也不能玩儿了。而红白机的游戏和游戏机之间的关系是相对独立的,只要它们都遵循制定好的协议,就可以独立的发展和变化。游戏卡带摔坏了,其他的游戏一样可以在机器上运行;自己的游戏机坏了,把卡带拿到朋友家的游戏机上也能玩儿。红白机发展到后期,连游戏机的手柄也是可插拔的,如果手柄坏了,也只需要更换手柄即可。
讲到这里,大家对聚合和耦合应该也有了初步的认识。那么,我们如何看待聚合和耦合在实际当中的应用呢?我们的程序怎样才算是做到了高聚合和低耦合呢?
前面曾经提到,从不同的层次看,聚合和耦合的程度也会有所不同。Sidtown和Fredborough的例子当中,从城市的层次来看,第二种设计完全达到了高内聚和低耦合的目标,然而,如果从城市的不同区域来看,这样的设计内聚性还不够。如果我们一直追究下去,恐怕Better Mousetrap所有的员工都要住在生产线上了。一味的追求高内聚,必然会造成模块的功能过于单一,而模块的数量出现急剧膨胀。所以,我们在设计和实现程序时必须要斟酌模块间的聚合和耦合程度,有兴趣的朋友也可以去研究聚合性指标与耦合性指标。

面向对象设计的一些设计原则

“开—闭”原则

面向对象设计的基石是“开—闭”原则。“开一闭”原则讲的是:一个软件实体应当对扩展开放,对修改关闭。这个规则说的是,在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展。从另外一个角度讲,就是所谓的“对可变性封装原则”。
对可变性封装原则”意味着两点:

  1. 一种可变性不应当散落在代码的很多角落里,而应当被封装到一个对象里面。同一种可变性的不同表象意味着同一个继承等级结构中的具体子类。
  2. 一种可变性不应当与另一种可变性混合在一起。即类图的继承结构一般不应超过两层。

做到“开—闭”原则不是一件容易的事,但是也有很多规律可循,这些规律同样也是设计原则,它们是实现开—闭原则的工具。

里氏代换原则

如果一个软件实体使用的是基类的话那么也一定适用于子类。但反过来的代换不成立。
如果有两个具体类A和B之间的关系违反了里氏代换原则,可以在以下两种重构方案中选择一种:
创建一个新的抽象类C,作为两个具体类的超类,将A和B共同的行为移动到C中,从而解决A和B行为不完全一致的问题。从B到A的继承关系改写为委派关系。咋一看觉得这个怎么还是面向对象设计的原则呢?这个明明就是Java的语法规则。对,Java是提供了对里氏代换原则在语法上的支持。但是仅仅是语法上,在和现实世界的相符合程度上根本没有提供。所有常常会有不符合里氏代换原则的情况出现。

依赖倒转原则

依赖倒转原则讲的是:要依赖于抽象,不要依赖于具体。即针对接口编程,不要针对实现编程。针对接口编程的意思是,应当使用接口和抽象类进行变量的类型声明、参量的类型声明,方法的返还类型声明,以及数据类型的转换等。不要针对实现编程的意思就是说,不应当使用具体类进行变量的类型声明、参量的类型声明,方法的返还类型声明,以及数据类型的转换等。
依赖倒转原则虽然强大,但却不易实现,因为依赖倒转的缘故,对象的创建很可能要使用对象工厂,以避免对具体类的直接引用,此原则的使用还会导致大量的类。维护这样的系统需要较好的面向对象的设计知识。
此外,依赖倒转原则假定所有的具体类都是变化的,这也不总是正确的。有一些具体类可能是相当稳定、不会发生变化的,消费这个具体类实例的客户端完全可以依赖于这个具体类。

接口隔离原则

接口隔离原则讲的是:使用多个专门的接口比使用单一的接口要好。从客户的角度来说:一个类对另外一个类的依赖性应当是建立在最小的接口上的。如果客户端只需要某一些方法的话,那么就应当向客户端提供这些需要的方法,而不要提供不需要的方法。提供接口意味着向客户端作出承诺,过多的承诺会给系统的维护造成不必要的负担。

合成、聚合复用原则

合成、聚合复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部份,新的对象通过向这些对象的委派达到复用已有功能的目的。这个原则有一个简短的描述:要尽量使用合成、聚合,尽量不要使用继承。
合成、聚合有如下好处:新对象存取成分对象的唯一方法是通过成分对象的接口。这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不到的。这种复用可以在运行时间内动态进行,新对象可以动态的引用与成分对象类型相同的对象。合成、聚合可以应用到任何环境中去,而继承只能应用到一些有限环境中去。导致错误的使用合成、聚合与继承的一个常见原因是错误的把“Has-a”关系当作“Is-a”关系。如果两个类是“Has-a”关系那么应使用合成、聚合,如果是“Is-a”关系那么可使用继承。

迪米特法则

迪米特法则说的是一个对象应该对其它对象有尽可能少的了解。即只与你直接的朋友通信,不要跟陌生人说话。如果需要和陌生人通话,而你的朋友与陌生人是朋友,那么可以将你对陌生人的调用由你的朋友转发,使得某人只知道朋友,不知道陌生人。换言之,某人会认为他所调用的是朋友的方法。

以下条件称为朋友的条件:

当前对象本身。以参量的形式传入到当前对象方法中的对象。当前对象的实例变量直接引用的对象。
当前对象的实例变量如果是一个聚集,那么聚集中的元素也都是朋友。任何一个对象,
如果满足上面的条件之一,就是当前对象的朋友,否则就是陌生人。

迪米特法则的主要用意是控制信息的过载,在将其运用到系统设计中应注意以下几点:在类的划分上,应当创建有弱耦合的类。类之间的耦合越弱,就越有利于复用。在类的结构设计上,每一个类都应当尽量降低成员的访问权限。一个类不应当public自己的属性,而应当提供取值和赋值的方法让外界间接访问自己的属性。在类的设计上,只要有可能,一个类应当设计成不变类。在对其它对象的引用上,一个类对其它对象的引用应该降到最低。

参阅什么是面向对象(OOP)

http://www.nowamagic.net/librarys/veda/

推荐面向对象的葵花宝典

猜你喜欢

转载自blog.csdn.net/low5252/article/details/106274719