这里从《Java编程思想》(下文简称I)和《设计模式-可复用面向对象软件的基础》(下文简称II)的一些文选中,结合本人理解,在概念层面解释一些与面向对象编程相关的特定概念,重新理解继承、多态和封装。
-
什么是对象
问题空间 - 问题出现的地方
解空间 - 问题建模的地方
问题空间中的元素及其在解空间中的表示成为“对象。其具有状态、行为和标识。
- 行为 —— 可以对对象施加哪些操作(方法)
- 状态 —— 施加操作后,对象如何响应
- 标识 —— 如果辨别具有相同行为和状态的不同对象
面向对象编程允许根据问题来描述问题,而不是根据解决问题的计算机描述问题。
C语言等在解决问题时,需要基于计算机的结构,而不是基于要解决的问题的结构来考虑
-
一、寻找合适的对象
面向对象程序由对象组成,对象包括数据和对数据进行操作的过程,过程通常称为方法或操作。对象在收到客户的请求或消息后,执行相应的操作。
形如
int a = obj.f();
的调用方法的行为,被称为发送消息给对象。其中f()为发送的消息,obj是接受消息的对象。
所以当谈到给对象发消息时,通常就是说在某个对象上调用某个方法。
客户请求是使对象执行操作的唯一方法,操作又是对象改变内容数据的唯一方法。
这里的表述中,唯一方法指的是way,可以理解为唯一途径,操作则是operate或method,用于给对象发送消息。
由此引出封装的概念,将功能和数据封装到一起,从而可以对问题空间的观念给出恰当的表示,而不用受制于使用底层机器语言来关联。
数据是隐藏的,可以通过对应的操作来改变对象的状态(数据)
1.封装的数据让客户端程序员无法接触 —— 这些数据对于内部操作是必须的,但不是用户解决问题所需关心的;
2.封装允许库或接口设计者可以改变内部的实现逻辑,而无需担心对外造成的影响。
抛开语言特性,从基本概念理解的封装如上。不同语言中所谓的访问权限,只是这个概念层面的延伸或扩展。
面向对象编程中设计得到的类通常在现实生活中是不存在的,有些是类似数组的底层类,有些则层次更高。
严格反应当前现实世界的模型并不能产生能反映未来世界的系统,就要求具备一定的抽象能力(可以通过学习设计模式等来提高)
-
二、对象的粒度
不同的抽象会造成面向对象系统中对象的大小和数量以及系统的复杂程度。
对象可以用来表示下至硬件或上至整个应用的任何事物,所以怎么设计对象至关重要。
-
三、接口
-
3.1.型构
对象声明的每一个操作都会指定操作的名称,作为参数的对象,以及返回值。这些被称为一个操作的型构。
-
3.2.接口
对象所能定义(执行)的所有操作型构的集合称为该对象的接口。接口描述了该对象所能接受的全部请求的集合,任何匹配对象接口中型构的请求(消息)都可以发送给该对象
Java中的关键字interface可以理解为Java本身对这一概念进一步的抽象,专门提出了一种语法糖,是有点特例化的,不要和概念层面的interface混淆。
根据上述接口的概念,每个对象有自己的一个接口(型构的集合),可用接口来操作对象的数据。这里说的是每个对象,而非某个类,这里不要带入Java等具体语言的概念,因为Java是强类型的语言,某些言语可能一个对象就是一个类(这里最后半句可能有问题,强调的是语言千奇百怪,不要用特定语言的点与概念类的点混淆)。
-
3.3.类型 - type
用来表示接口的名称,如果一个对象接受“Window”接口所定义的所有操作请求,那么这个对象就具有“Window”类型。
一个对象可以有多个类型,并且不同的对象可以共享一个类型。
前半句:即某个对象可能既符合Window类型,也可以符合Door类型,如一个车门对象;
后半句:即不同的对象可以具有相同的Window类型,如一个橱窗对象或一个房子窗对象
一个对象接口的某一部分可以用某个类型描述,其他部分则可以用其他类型描述;而两个对象具有相同的类型则表明他们共享部分或全部接口
当类型A的接口包含类型B的接口时,则说A类型继承了B类型,其中A为子类型,B为超类型。
这里的子和超是翻译的问题,直接是指sub和sup
-
3.4.动态绑定(后期绑定)
在面向对象的设计中,接口是基础的组成部分。由于封装,只能通过对象的接口对其进行操作,否则无法得知或操作对象的任何事物(anything)。但是对象的接口和实现是分离的,不同对象可以有不同的实现。
这里的实现指的是implement,但请区分Java关键字
举例:上文中的橱窗和房子窗,都有开窗和关窗两个操作,他们是同一个Window类型。但一般生活中,橱窗可能是左右滑动,而房子窗则是里外运动。
所以实际对某个对象发送了一个消息,产生怎么样的结果或副面作用,是和接受消息的具体对象相关的。
发送给对象的请求和它的相应操作的运行时刻的连接成为动态绑定。
延伸
前期绑定 - 一个非面向对象编程的编译器产生的函数调用在运行时会解析到将要被执行代码的绝对地址上。
-
3.5.多态
由于动态绑定,对于客户代码来,可以设计出一个一般程序,它只要求所调用的对象只需要满足某一个类型即可,而具体产生的效果是具体对象决定的,这样就简化了客户的定义。
同时,动态绑定允许在运行时替换掉某个对象,只要新对象满足特定接口(类型)即可。这种可替换性即为多态。
客户代码理解为,给某个对象发送消息的代码,比如某个简单demo中的main。
一般程序是指,客户所需要的只是对象所属的类型(type),可以不需要关心具体对象是哪一个,即不需要关心对象的实现方式。
-
3.5.1向上转型 - upcasting
即使同一个类的不同对象之间,同一个消息的处理逻辑也可能不同,不同的子类型对应点的对象,行为更可为千差万别。设计一般程序时,可以只关心父类型,无需关心具体对象是该父类型的对象还是子类型的对象。若是子类型的对象,那么就发生了向上转型。
向上转型使得客户端代码更具扩展性,因为不管运行时的是哪一个可用的对象,客户端代码都能很好的处理,产生预期效果。
-
3.6.对象提供服务 - 高内聚
程序本身提供服务,而程序运行则需要对象提供的服务。 在良好的面向对象设计中,每个对象都可以很好的完成一项任务,但是并不是试图做过多的事情。
-
四、类 - class
类描述了具有相同特性(数据元素)和行为(功能)的对象的集合。换言之,类指定了对象的内部数据和表示,同事指定了对象所能完成的操作。
类型(type)和类(class)有联系,但请注意:
类型决定了接口,而类是该接口的一个特定实现
-
4.1实例化
对象通过实例化类来创建,该对象被称为该类的实例(instance)。
实例化类是需要给对象的内部数据分配存储空间,并将操作与这些数据联系起来。
在很多情形中,对象=实例,有时甚至会有实例对象这样的称呼。
但个人认为,对象是个更为宽泛的概念,实例化了一个类后得到了一个实例。例如人类是类,而我们每个人是一个实例。每一个人(实例)是人类的一个具体对象。
-
4.2继承
可以通过继承(inheritance)从已有的类衍生出新的类。新的类称为子类。子类包含父类定义的所有数据和操作;而子类实例对象包含所有子类和父类定义的数据类型,同时可以完成所有在父类中定义的操作。
类型等价型 - 通过继承,发送给父类对象的消息同样也可以发送给子类对象,即子类和父类具有相同的类型(type)。
-
4.2.1 抽象类
类描述了具有相同特性(数据元素)和行为(功能)的对象的集合(见上)。那么抽象类就是某一些具体类抽象出来的父类,例如Shape在现实中只是一个概念,有Circle(圆)、Triangle(三角形)和Rectangle(矩形)等这样的子类,而每一个具体类有各种各样的对象。
从概念层面出发就可以发现,抽闲类无法被实例化,没有一种Shape对象。
抽象类将它的部分或全部操作的实现延迟到子类,即其定义的部分或全部方法没有方法体,只有型构,这些方法被称为抽象方法。更进一步说明抽象类无法实例化,因为某些行为还未被定义。
-
4.2.2 子类父类的差异
1.在子类中增加新的数据类型和方法(此时子类的type和父类不完全一致);
2.重新父类的方法,从而使得子类在运行时调用方法时,产生不一样的行为(功能)。
替代原则 - 子类仅仅重写父类的方法,只有子类对象完全可以替代父类对象,这是一种纯粹替代,即subInstance is supInstance
改变了子类的接口(即改变了类型),那么subInstance is like supInstance。
判断是否继承,就是确定是否可以用“is a”来描述,并使之具有实际意义。
-
4.2.3 类继承和接口继承
对象的类与对象的类型存在差异:
- “类”定义了对象是怎么实现的,包括对象的内部状态和操作的实现。
- “类型”只与接口相关,即对象能响应的请求的集合。一个对象可以有多个类型,不同类的对象可以有相同的类型。
但是两者有存在联系:
“类”定义了对象可以执行的操作,换言之,就是定义了对象的类型。如果一个对象是某个类的实例,就是指该对象支持类所定义的接口。
由于概念层面的区别和联系,在继承上也就存在差异:
- 类继承根据一个对象的实现定义了另一个对象的实现。它是代码和表示的共享机制。
- 类型(接口)继承描述的是一个对象什么时候可以被用来替代另一个对象 —— 这里涉及多态以及向上转型
- 注意:虽然两者有差异,但编程语言层面大多数不会明确区分两者的界限,类继承也可以是一种类型(接口)继承。
Java中继承的理解:
在《II》中提到,
1.C++的纯接口继承接近于共有继承纯抽象类,纯实现或纯类继承接近于私有继承;
2.Smalltalk的继承只是单一的实现继承
(Smalltalk是动态语言,编译器不进行编译类型检查,只检查某个对象是否实现了某个消息,伴随而来的就是只有实现继承。
—— 假设对象A和对象B,都具有方法f(),即使实际层面两个对象完全是不相关的对象,在语言层面仍旧认为可以在运行时互换,即是相同的类型。)
- Java中的interface和纯抽象类可以类比到C++的纯抽象类,所以在Java中实际是区分了类型继承和类继承的。interface一般都只有接口定义(类型),所以implement和interface间的extend关系,可以归于上述中的“接口继承”;
- Java的纯类继承可以理解为从一个具体类extend出一个新类 —— 见上,是代码和表示的共享机制。
-
4.3 针对接口编程,而不是针对实现编程
类继承是一个通过复用父类功能而扩展应用功能的基本机制,可以通过旧对象迅速定义出新对象。继承 所带来的另一个好处是,通过它可以定义出具有相同类型的对象族,这就是多态的本质。
通过上述的概念,充分理解接口和实现的概念,从而将这一重要原则正确理解,而不要局限在某一个语言的特定语法中。
这一原则是编写可复用的面向对象的一般程序的基本准则。 不将变量申明为某个特定的具体类的实例对象,而是让它遵从抽象类所定义的接口。
好处 —— 减少子系统实现之前的相互依赖关系。
- 1.客户无需知道使用的对象的特定类型,只需关心所期望的通用(common)接口;
- 2.客户无需关心实现,只需知道定义接口的抽象类
-
五、复用
-
5.1组合、聚合、相识关系
组合 —— 可以用任意数量、任意类型的现有对象以任何可以实现新类功能的方式组合
聚合 —— 动态发生的组合,意味着一个对象拥有另一个对象或对另一个对象负责。此时称一个对象包含另一个对象或者是另一个对象的一部分。关键在于被聚合的对象和所有者具有相同的声明周期
相识 —— 意味着一个对象知道另一个对象,相识的对象可能请求彼此的操作,但不会对方负责
理解:
组合 —— 在代码层面表示一个新类需要哪些现有旧类组合起来,例如Car所需要的Engine
聚合 —— 一种动态的组合,那么可以理解在程序运行时,一个具体的Engine对象作为一个具体Car的一部分,运行时可以动态的给Car对象替换引擎
相识 —— 彼此间不负责,那么例如Engine运行时,需要通过某个方法来启动和运行,其中都需要利用汽油,汽油对象可以有一个“burn()”方法,此时Engine需要使用汽油的burn,但两者是相识关系
-
5.2继承和组合比较
-
5.2.1继承
由于继承方式,父类的内部细节对子类可见,也称为“白箱复用”。
-
5.2.2组合
组合的对象之间不知道彼此细节,需要被组合的对象具有良好定义的接口,也称为“黑箱复用”。
-
5.2.3比较
继承提供了一个复用途径,其是在编译期静态定义的,可直接被使用,通过重写或新增可以很快的改变实现从而得到一个可用的类。但是由于继承的结构关系在静态代码怎么已经决定,无法在运行时动态的改变这种继承体系;另外,继承对子类揭露了其父类的实现细节,破坏了父类的“封装性”。此外,父类的任何变化会导致子类发生相应变化,而子类如果不满足问题的需要,可能需要重写父类或者派生新的子类。这种依赖关系一定程度上限制了复用性。 可行方案是只继承抽象类。
组合是通过获得对其他对象的引用而在运行时动态定义的。其要求对象遵守彼此的接口约定,这些接口不妨碍所用对象的替换。由于组合的对象之间只能通过接口访问,这样就不会破坏封装性,同时对象间可以动态替换,就减少了依赖性。
组合有助于保持每个类被封装,并被集中在单一的任务上。这样类和类层次结构会保持较小的规模,且不太可能变为不可控制的庞然大物。
基于组合,设计中会有更多的对象,且系统的运行依赖于对象间的关系而不是被定义在某个类中。
优先使用对象组合,而不是类继承。
一般情况下,应该考虑使用现有的构件来组合新的构件,通过组装现有的功能得到新的功能。
但实际构件并不够丰富,继承比组合更加简单。
所以组合和继承应结合使用,使之相得益彰。
-
5.2.4委托
委托是一种组合方式,它使组合具有和继承同样的复用能力。
这里的 同样的复用能力是指:
继承时,被继承的操作总能引用接受请求的对象。也就是说一个子类对象,在调用一个从父类继承过来的操作时(即给这个子类对象发送了一个继承过来的消息),实际执行的操作的子类对象,所以在重写发生时,多态就产生了。
而委托就是让被委托者得到委托者的引用。这里委托者就是客户可见的对象,该对象将“自身”传给被委托者,从而使被委托者的被委托操作中,可以获取到委托者对象。
举例:
Window类有一个Shape的构件,Shape指定了Window对应的形状,而Window的面积则委托给Shape计算。
这里Window的area()是客户请求的消息,而w则调用s的area(),同时w把自身作为参数传给s,伪码如下:
客户代码:w.area(); //客户请求面积
public class Window {
...
private Shape s;
public double area() {
s.area(this);
}
}
这里可以产生这样的类比,Window通过组合Shape得到,对应子类(Sub)继承父类(Sup),对于代码
Sup sup = new Sub();
sup.operate();
实际执行的是子类的对象,但看调用形式却是sup.operate(),这样的形式表现为调用了父类的接口,但实际引用的却是子类对象。
这样Window类比子类,Shape类比父类,在s.area()方法被执行时,需要得到w对象的引用,故通过参数传递,这样就可以在Shape中获取Window的信息。得到“与继承相同的复用能力”!
动态的、高度参数化的软件比静态软件更难理解,也存在一定的运行低效问题。所以只有当委托使设计比较简单而非更复杂时才是好的选择,并且其效率也是和实际上下文相关的。
-
5.3设计模式
更为深入的理解复用需要学习设计模式。
-
5.4参数化类型(parameterized type)
参数化类型就是类属(generic)(Ada,Eiffel)或模范(template)(C++)或泛型()(Java)
-
六、面向对象语言特点总结
1.万物皆是对象
直接将问题空间中的事物映射为解空间中
2.程序是对象的集合,通过发送消息来告知彼此所要做的
不同的对象配合起来,通过各种方式给彼此发送消息,以此来解决问题(模拟问题空间的解决方式)
3.可以通过现有的对象组成新的对象
复杂系统的复杂性会隐藏在对象简单性的背后
4.每个对象都有类型
5.某个特定类型对象的所有对象可以接受相同的消息。
这是继承和多态的核心