什么是面向对象设计
面向对象规范为对象编程定义了基本的规范,是面向对象编程的主要思想。换一种说法,面向对象规范就是基本的语法,定义了你写词造句的方式,将一个个单独的词语组合成一句完整的话语。
而面向对象设计教你如何正确地使用类,怎么把一些属性和方法封装在一个类中,怎样封装才是正确的,怎么使用继承关系。
在软件的开发中,面向对象编程就是从全局考虑问题,是一种工程化的模范。例如如果软件设计中需要实现一个新的功能,可以通过创建一个(或者使用现有的对象)可以实现你想要功能的对象,然后调用这个对象来实现想要的功能。
面向对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及修改对象相关联的数据。
为什么需要面向对象设计
上文已经提到面向对象设计可以提高软件的重用性、灵活性和扩展性。众所周知,C和C++最重要的本质区别是C++是面向对象编程,而C的编程思想是面向过程。
面向过程程序设计的思想是最早的编程语言的思想。因为它比较符合我们解决问题的方法,
即提出问题、分析问题、解决问题的处理流程,将大问题分解为小问题,
如果小问题比较复杂,就继续划分小问题为更小的问题,然后通过小模块一一解决小问题,
最后再根据整个业务流程将这些小问题串在一起(调用函数),这样就达到了解决所有问题的目的。
但是这种设计存在明显的缺点,在面向过程设计时,数据和操作往往是分离的,这就导致如果数据的结构发生了变化,那么操作数据的函数不得不重新改写。
同时,数据往往不具有封装性,很多变量会暴露在全局,加大了被任意修改的风险。
另外,一旦涉及到代码的重新划分,往往需要修改原来写好的功能模块,旧有的程序模块由于与原来应用环境结合的紧密性难以在新的程序中得到复用。
所以,这些固有的缺点使它难以适应大型的软件项目的开发。
于是,面向对象设计应运而生。
面向对象通过封装、继承和多态避免了上述的这些问题。
封装即将数据和操作封装在一起,并避免了局部变量的暴露,而只提供接口,继承可以在原来的基础上很快的产生新的对象,多态是同一个方法调用,针对不同的对象有不同的反应。
我们可以将现实世界中的数据和对数据进行操作的动作捆绑在一起形成类,通过类定义对象,很好地实现了对现实世界的抽象和描述。
通过继承,可以在旧类型的基础上快速派生得到新的类型,很好地实现了设计和代码的复用。同时多态机制保证了在继承的同时,还有机会对已有行为进行重新定义,满足了不断出现的新需求的需要。
什么是耦合
简单地说,软件工程中对象之间的耦合度就是对象之间的依赖性。指导使用和维护对象的主要问题是对象之间的多重依赖性。对象之间的耦合越高,维护成本越高。因此对象的设计应使类和构件之间的耦合最小。
有软硬件之间的耦合,还有软件各模块之间的耦合。耦合性是程序结构中各个模块之间相互关联的度量。它取决于各个模块之间的接口的复杂程度,调用模块的方式以及哪些信息通过接口。
耦合可以分为以下几种,它们之间的耦合度由高到低排列如下:
- 内容耦合。当一个模块直接修改或操作另一个模块的数据时,或一个模块不通过正常入口而转入另一个模块时,这样的耦合被称为内容耦合。内容耦合是最高程度的耦合,应该避免使用之。
- 公共耦合。两个或两个以上的模块共同引用一个全局数据项,这种耦合被称为公共耦合。在具有大量公共耦合的结构中,确定究竟是哪个模块给全局变量赋了一个特定的值是十分困难的。
- 外部耦合 。一组模块都访问同一全局简单变量而不是同一全局数据结构,而且不是通过参数表传递该全局变量的信息,则称之为外部耦合。
- 控制耦合 。一个模块通过接口向另一个模块传递一个控制信号,接受信号的模块根据信号值而进行适当的动作,这种耦合被称为控制耦合。
- 标记耦合 。若一个模块A通过接口向两个模块B和C传递一个公共参数,那么称模块B和C之间存在一个标记耦合。
- 数据耦合。模块之间通过参数来传递数据,那么被称为数据耦合。数据耦合是最低的一种耦合形式,系统中一般都存在这种类型的耦合,因为为了完成一些有意义的功能,往往需要将某些模块的输出数据作为另一些模块的输入数据。
- 非直接耦合 。两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的。
面向对象设计原则
单一职责原则(Single Responsibility Principle)
类的功能要单一,不能给一个类分配太多的功能实现。导致类变化的因素永远不要多于一个,如果有多于一个原因会导致你的类(或者它的职责多于一个),你就需要根据其职责把这个类拆分成多个类。
Rectangle类干了两件事情,计算面积和绘制图形。有两个程序使用了这个类,Computational Geometry Application用这个类计算面积,Rectangle 类干了俩不相干的事。
一个方法它计算了面积,另外一个它返回一个表示矩形的 GUI 资源。Graphical Application用这个类在GUI界面上绘制图形。也就是说,如果我们这样设计这个类,就需要在计算面积的程序中引用GUI库,而这两者之间没有任何联系。按照SRP原则,这个类可以分成两个类,一个用来计算面积,另一个可以继承并且定义绘制图形的Draw()方法。从本质上来说,SPR 就是把东西分到不能再分了,再集中化管理和复用。
开放封闭原则OCP(Open-Close Principle)
软件实体(类、模块、函数)应该对扩展开放,对修改关闭。
例如:一个网络模块,原来只服务端功能,而现在要加入客户端功能,那么应当在不用修改服务端功能代码的前提下,就能够增加客户端功能的实现代码,这要求在设计之初,就应当将服务端和客户端分开,公共部分抽象出来。
新添加了一个抽象的Server类, 并且客户端保持了抽象类的引用, 具体的Server类实现了这个抽象Server类。
所以, 由于某种原因Server的实现类发生了改变, 客户端不需要做任何改变.这里的抽象的Server类对修改关闭, 具体的Server实现类对扩展开放.
里式替换原则LSP(the Liskov Substitution Principle)
子类型必须能够替换他们的基类,即使用基类引用的函数必须能够使用派生类而无需了解派生类。
里氏替换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
Bird类中定义了Fly方法,KingFisher类继承了Bird类,也继承了Fly方法,这是符合里氏替换原则的。
同样Ostrich(鸵鸟)也继承了Bird类,但是很明显鸵鸟并不会飞,却继承了Fly方法,这个设计就违反了里氏替换原则。
我们可以从Bird类中派生了一个没有Fly方法的类有Ostrich继承。由这个例子可以知道,如果不遵循里氏替换原则,类的继承就会混乱,派生类可能无法正确执行从基类继承的方法。
依赖倒置原则DIP(the Dependency Inversion Principle)
高层次的模块不应该依赖于低层次的模块,而是都应该依赖于抽象。
面向过程的开发,上层调用下层,上层依赖于下层,当下层剧烈变化时,上层也要跟着变化,这就会导致模块的复用性降低而且大大提高了开发的成本。
面向对象的开发很好的解决了这个问题,一般的情况下抽象的变化概率很小,让用户程序依赖于抽象,实现的细节也依赖于抽象。
即使实现细节不断变化,只要抽象不变,客户程序就不需要变化。这大大降低了客户程序域实现细节的耦合度。
上面的Car类,有两个属性,分别是引擎engine和车轮wheels,这两个属性都是抽象类型(接口)而非实体的。
我们可以在其他地方实现这个抽象接口,汽车应该依赖于抽象的引擎或轮子的规格,这样只要是符合这个抽象规格的引擎或轮子,这样汽车能接受任何实现了声明接口的对象,而Car类无需任何改动,有效地提高了代码的复用性。
接口分离原则ISP(the Interface Segregation Principle)
用户不应该被迫依赖他们不使用的接口。模块间要通过抽象接口隔离开,而不是通过具体的类强耦合起来;使用多个专门的接口比使用单个接口好的多,这样在实现和维护接口上将省去很多精力;一个接口最好只提供一类对外功能。
类通过接口暴露了类的功能,这样外部就知道类中可用的功能,客户端也可以根据接口来设计。前提是接口不应该过大,或者暴露的方法太多,这样会降低可复用性,包含无用方法太多的接口会增加类的耦合。
上面定义了一个巨大的接口(胖接口)AbstractService来连接所有的客户端。
很明显客户端A,B,C分别使用了其中的对应的接口,当客户端A连接时,只需要operatorA()接口,却会实现其余两个不必要的接口,这种行为违反接口分离原则。
可以选择分割接口将AbstractService分割成多个接口。
这样做既满足了接口分离原则,又满足了单一职责原则。但是这样做会增加类的类型,可能会带来很多不便。
合成复用原则CRP(Composite Reuse Principle)
尽量使用对象组合,而不是继承来达到复用的目的。
合成复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新的对象通过向这些对象的委派达到复用已有功能的目的。
简而言之,就是要尽量使用组合/聚合关系,少用继承。
聚合(Aggregation)表示一种弱的拥有关系,体现的是A对象可以包含B对象但是B对象不是A对象的一部分。
合成(Composition)则是一种强的拥有关系,体现了严格的部分和整体关系,部分和整体的生命周期一样。
在面向对象设计中,可以通过两种基本方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或者通过继承。
只有当以下条件全部被满足,才应当使用继承关系:
- 子类是超类的一个特殊种类,而不是超类的一个角色,也就是区分“Has-A”和“Is-A”。只有“Is-A”关系才符合继承关系,“Has-A”关系应当使用聚合来描述。
- 永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。
- 子类具有扩展超类的责任,而不是具有置换掉或者注销掉超类的责任。如果一个子类需要大量的置换掉超类的行为,那么这个类就不应该是这个超类的子类。
来看下面这个银行卡的类,银行卡类默认拥有了取款、存款、透支的功能,我们需要实现自己的银行卡类,很显然,这是一个超类的特殊种类。
我们的银行卡都将具有这些功能,此时应该使用继承关系。
假设我的银行卡有很多种类。
例如储蓄卡、借记卡、信用卡等等。分别有各种不同的功能,为了灵活地使用,此时可以分别设立储蓄卡和信用卡,并有银行卡对它们进行聚合使用。
迪米特原则LOD(Law of Demeter)
迪米特原则又叫最少知识原则,该原则规定一个对象应当对其他对象有尽可能少的了解。
对面向对象来说,一个软件实体应当尽可能少的与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,只是与当前单元紧密联系的单元“交谈”。
简单来举个例子,你打的去某个地方,你只需要把目的地告诉出租车师傅,然后等着到达目的地,而不应该直接指挥师傅怎么开,一般来说这样才是比较合理的。
面向对象的程序设计中,对象与对象之间尽量相互独立,具体对象的行为由具体的对象去完成,而不是由某个对象去指定另一个对象去实施行为而且是具体的行为。
迪米特法则,核心的思想就是,要求我们在设计的时候,尽量避免类与类之间的耦合,弱化耦合关系可以提升复用率,但是这样的话,会产生中间的跳转类等,导致系统复杂。
实际使用的过程中尽量在保证可读性与复杂性较低的情况下,按照迪米特法则去弱化类与类之间的耦合关系(高内聚、低耦合)。