概述
面向对象:
面向对象的方法是一种分析方法、设计方法和思维方法。使描述问题的问题空间和解决问题的方法空间在结构上尽可能一致。
面向对象基本思想:
- 对问题空间进行自然分割,以更接近人类思维的方式建立问题域模型,以便对客观实体进行结构模拟和行为模拟,从而使设计出的软件尽可能直接地描述现实世界。
- 构造出模块化的、可重用的、维护性好的软件,同时限制软件的复杂性和降低开发维护费用。
传统方法和面向对象方法的比较:
传统 | 面向对象 |
---|---|
数据结构+算法=程序设计 | 以对象为中心组织数据与操作 |
数据 | 对象的属性 |
操作 | 对象的服务 |
类型与变量 | 类与对象实例 |
函数(过程)调用 | 消息传送 |
类型与子类型 | 一般类与特殊类,继承 |
构造类型 | 整体-部分结构,聚合 |
指针 | 关联 |
不同点:
- 思想观念:从对象出发认识问题域
- 构造策略:以对象作为构成系统的基本单位,将对象的数据与操作紧密结合
- 保证机制:由支持封装、继承、多态的机制保证其原则的实现
设计模式:
模式化的设计使系统更稳定、易修改、易扩展、易理解、易测试。
- 创建型:与对象创建有关
- 结构型:处理类或对象组合
- 行为型:对象或类怎样交互和怎样分配职责
OOP的设计原则
OOP是基于递归设计的原则的:
- 一切都是对象。
- 计算通过对象间相互通信,请求其他对象执行动作来实现。对象间通过发送和接收消息来通信。
- 每个对象都有自己的内存,其中可能包括了其他的对象。
- 每一个对象都是某个类的实例。类就是一组相似的对象。
- 类是对象相关行为的储存库。也就是说,同一个类的所有对象都能执行同样的动作。
- 类被组织成有单个根节点的树状结构,被称为继承层次结构。与类实例相关的内存和行为都会被树结构中的后代自动继承。
抽象
抽象是指对于一个过程或者一件制品的某些细节有目的的隐藏,以便把其他方面、细节或者结构表达得更加清楚。(信息隐藏)
抽象的层次
- 团体、成员、对象
- 单元:只允许某些特定的名称暴露在外面,其他特征隐藏的单元内部
- 交互:关注一个对象(服务者, server)向另一个对象(客户, client)提供服务,二者之间以通信来交互。即对象间的信息传递,接口:只定义行为,不定义实现
- 服务实现方式:考虑、定义具体实现方式,不涉及实现(如链表的数组描述和指针描述)
- 具体实现:关注执行一个方法的具体操作实现。(写方法体内的代码)
抽象的形式
- 特化:汽车是由发动机、传动装置、车体、车轮组成;发动机是由 …..(有什么(has a)思想)
分而治之
- 优点:在某一个层次只关心该层次实现需要的细节即可(前提:设计好了各个配件之间的交互关系)(是什么(is a)思想)
- 服务:商品制造者关心实现,高层设计者和使用者不关心实现,所以对接口和实现的划分,不仅从高层的角度对设计易于理解,而且使软件组件的替换成为可能。
- 组合/复合法:由少量简单的形式,根据组合规则构建出新的形式。关键在于既可对初始形式进行组合,也可以对新形式进行组合。
- 分类:当系统中组件数量变大时,常用分类(Catalogs)来进行组织。
多视角:对一个制品进行不同视角的观察,每一种视角强调特定的细节,而忽略其他部分
类
基本概念:
类是具有相同属性和相同操作(服务)的对象的集合。它包括属性和操作。
- 属性:在类中表示对象或实体拥有的特性时称为属性
- 方法:对象执行的操作称为方法。
- 静态属性:多个对象都可以对静态属性进行操作, 实现同类多个对象间的数据共享。
- 静态方法:静态方法为类所有,可以通过对象来使用,也可以通过类来使用。 但一般提倡通过类名来使用,因为静态方法只要定义了类,不必建立类的实例就可使用。 静态方法只能调用静态变量;没有伪变量
this
。构造和析构函数不能为静态成员。
类和对象之间的关系:
类是概念模型,由对象抽象而来,定义对象的所有特性和所需的操作;对象是类的实例化
在响应消息时调用何种方法由类的接收器来决定。一个特定类的所有对象使用相同的方法来响应类似的消息。
对象是实际的实体,真实的模型,所有属于同一个类的对象都具有相同的特性和操作。
对象 = 状态(实例变量)+行为(方法)
接收器:
消息发送的对象。如果接收器接受了消息,那么同时它也接受了消息所包含的行为责任。然后,接受器响应消息,执行相应的“方法”以实现要求。
消息的解释由接收器决定,并且随着接收器的不同而不同。
责任:
用责任来描述行为。A对行为的要求仅表明所期望的结果,C可随意选择使用的方法来实现所期待的目标,并在此过程中不受A的干扰。
提高了抽象水平,对象更加独立。
不干预原则:允许对象以任何它认为合适的不干涉其他对象的方式来完成任务,而不要干预它。
类主题的变化
类是面向对象编程最基本的概念,一些语言在这个基本思想上进行了一些改变。
接口
接口是java等语言中的一个抽象类型。其不提供实现,只用于定义新类型以及方法和变量的声明,通过多态,类的实例可以赋值给接口类型变量。
提问:java中,为什么既要有抽象类又要有接口?
回答:
- 在Java中,使用抽象类表示通用属性存在这样一个问题:每个类只能扩展一个类,因为Java不支持多继承,导致不能再扩展其他类,因此用接口可以避免这个问题。
- 但接口中没有办法实现方法,因此如果有多个类实现同一个接口并实现相同的方法,这样的方式就不符合代码复用的规范了。抽象类可以多重继承,接口也可以多重实现,但是抽象类可以将具体的方法做成封装,这样就扩张了程序的功能。
向前定义
程序有时候需要两个相互引用的类,这种形式称为相互递归。如马载人,人骑马。这在不同语言中处理方式不同。
在java中,生成代码前会扫描整个文件,这样如果程序在声明前引用,就不会出现任何问题,这种方式不是向前定义。
在C/C++中会从前向后的扫描文件,这样导致名称在使用前需要部分定义。叫做向前定义。
内部类(嵌套类)
在一个类中定义另外一个类。
对象
基本概念:
- 对象是独立存在的客观事物,它由一组属性和一组操作构成。属性和操作是对象的两大要素。
- 属性是对象静态特征的描述;操作是对象动态特征的描述。
- 属性一般只能通过执行对象的操作来改变。
- 操作又称为方法或服务,它描述了对象执行的功能。
- 通过消息传递,还可以为其它对象使用。
对象性质:
- 封装性:信息隐藏
- 自治性:主动数据
- 通信性:并发
- 暂存性:作用域/期
- 永久性:文档串行化
对象外部看,客户只能看到对象的行为;对象内部看,方法通过修改对象的状态,以及和其他对象的相互作用,提供了适当的行为。
对象、代理与团体:
- 一个面向对象的程序是由一个相互作用的代理团体组成,这些代理被称作对象
- 每一个对象承担一个角色
- 每一个对象都提供一种服务或者执行一种动作,以便为团体中其他对象服务
消息
要求某个对象执行在它所在的那个类中定义的某个操作的规格说明。是对象间相互请求或相互协作的途径。
一个消息传递表达式由三部分组成:
- 接受消息的对象:接收器
- 接收对象要采取的方法:消息选择器
- 方法需要的:参数
A.b(c);//A是接收器,b是消息选择器,c是参数
消息和对象:
对象之间的相互作用是通过消息产生。消息由某个对象发出,请求其他某个对象执行某一处理或回答某些信息。
信息隐藏:
作为某对象提供服务的一个用户,只需要知道对象将接受的消息的名字,不需要知道要完成要求需要执行哪些动作。 对象在接收到消息后,会负责任务完成。
封装:
封装把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,从而避免重复的代码,并保护类受到不必要的修改。
消息传递与过程调用:
消息传递(message passing):表示请求对象执行一项特定行为的动态过程
- 每一条消息都有一个指定的接收器相对应,’接收器就是消息发送的对象。
- 过程调用没有指定的接收器。
- 消息总是传递给某个称为接收器的对象
- 响应消息所执行的行为不是固定不变的,它们根据接收器类的不同而不同
语言类型
根据语言是静态类型还是动态类型,可以把语言分为两类。
静态:把类型和变量联系在一起
如java,C,C++,Pascal
动态:把变量看作名称标识,把类型和数值联系在一起。
如Python,SmallTalk
Object-c介于两者之间。
在消息传递这方面,静态类型语言和动态类型语言之间存在显著的差异。
- 静态类型语言在编译时使用接收器的类型来检查选择器,以理解它所接收的消息。
- 动态类型语言在编译时无法核实这一消息。如果接收器不理解消息选择器,那么就有可能产生运行时错误(静态类型语言不会产生这种错误)
静态类型语言的优点:
- 编译时即作出内存分配决定,从而不必在运行时刻重新分配。
- 能够控制类型错误。
伪变量
大多数面向对象语言中,接收器并不出现在方法的参数列表中,而是隐藏于方法的定义之中。只有当必须从方法体内部去存取接收器的数值时,才会使用伪变量,其不需要声明,也不能被改变。
- Java,C++:
this
- Eiffel:
Current
- Smalltalk,object-c:
self
伪变量的用途:
- 在方法想把自身对象作为一个参数传递给另外一个函数的时候,必须要使用伪变量。在其他情况下可用可不用。
- 另外,在Java的函数中(尤其在构造函数中),可以使用
this
区分参数和数据成员。
继承
继承允许类被组织成有单个根节点的树状结构(继承层次结构),与类实例相关的内存和行为都会被树结构中的后代自动继承。
在类层次结构中与某层相联系的信息(数据、行为)都会自动地提供到该层次结构的较低层次中。
- 继承表达了对象的一般与特殊的关系。
- 特殊类的对象具有一般类的全部属性和服务。
抽象父类:指没有具体实例的类,只是用来产生子类。
继承的作用:
- 代码复用
- 概念复用。共享方法的定义。
子类型
指符合替换原则的子类关系,但要注意,子类型关系是通过行为这个术语描述的,与新类的定义或构造无关,只要支持相同的接口,也可以说是子类型。
子类关系是通过创建新类的声明语句来建立的,但它并未解释子类存在的意义和目的。子类型用于区别于一般的可能不符合替换原则的子类关系[TODO]
替换原则
指如果类B是类A的子类,那么在任何情况下都可以用类B来替换类A,而外界毫无察觉
可替换性:变量声明时指定的类型不必与它所容纳的值类型相一致。
如果说新类是已存在类的子类型,那么这个新类不仅要提供已存在类的所有操作,而且还要满足于这个已存在类相关的所有属性。
一般 / 特殊
对象之间存在着一般和特殊的结构关系,也就是说它们存在继承关系。很多时候也称作泛化和特化关系。
改写
子类有时为了避免继承父类的行为,需要对其进行改写
关于改写详细说明在之后会有。
遮蔽(隐藏)
是指父类变量接收子类类型,并调用方法或者使用变量时候,都是使用的父类的方法和变量,而不发生多态的现象。
继承的形式
特殊化(specialization)继承
- 在这种形式下,新类是基类的一种特定类型,它能满足基类的所有规范。 用这种方式创建的总是子类型,并明显符合可替换性原则。
- 与规范化继承一起,这两种方式构成了继承最理想的方式,也是一个好的设计所应追求的目标。
很多情况下,都是为了特殊化才使用继承。
规范化继承
- 规范化继承用于保证派生类和基类具有某个共同的接口,即所有的派生类实现了具有相同方法界面的方法。
- 基类中既有已实现的方法,也有只定义了方法接口、留待派生类去实现的方法。派生类只实现定义在基类中却又没有实现的方法。
- 派生类不重新定义已有的类型,而是去实现一个未完成的抽象规范。也就是说,基类定义了某些操作,但并没有去实现它。只有派生类才能实现这些操作。
在这种情况下,基类有时也被称为抽象规范类。
在Java中,关键字
abstract
确保了必须要构建派生类。声明为abstract
的类必须被派生类化,不可能用new运算符创建这种类的实例。除此之外,方法也能被声明为abstract
,同样在创建实例之前,必须覆盖类中所有的抽象方法。
规范化继承可以通过以下方式辨认:基类中只是提供了方法界面,并没有实现具体的行为,具体的行为必须在派生类中实现。
构造继承
- 一个类可以从其基类中继承几乎所有需要的功能,只是改变一些用作类接口的方法名,或是修改方法中的参数列表。
- 即使新类和基类之间并不存在抽象概念上的相关性,这种实现也是可行的。
- 树-独木舟
- 堆栈-队列
- 写二进制文件-写学生信息文件
- 当继承的目的只是用于代码复用时,新创建的子类通常都不是子类型。这称为构造子类化。
- 一般为了继承而继承,如利用一些工具类已有的方法。
构造子类化经常违反替换原则(形成的子类并不是子类型)
泛化子类化
- 与特化子类化相反,对基类已存在的功能进行修改或扩展
- 派生类扩展基类的行为,形成一种更泛化的抽象。
泛化子类化通常用于基于数据值的整体设计,其次才是基于行为的设计。
扩展继承
- 如果派生类只在基类的基础上添加新行为,但并不修改从基类继承来的任何属性,即是扩展继承。
- 由于基类的功能仍然可以使用,而且并没有被修改,因此扩展继承同样符合可替换性原则,用这种方式构建的派生类还是子类型 。
限制继承
- 如果派生类的行为比基类的少或是更严格时,就是限制继承。
- 常常出现于基类不应该、也不能被修改时。
- 限制继承可描述成这么一种技术:它先接收那些继承来的方法,然后使它们无效。
- 由于限制继承违反了可替换性原则,用它创建的派生类已不是派生类型,因此应该尽可能不用。
- 双向队列-〉堆栈
合并继承
- 可以通过合并两个或者更多的抽象特性来形成新的抽象。
- 一个类可以继承自多个基类的能力被称为多重继承 。
变体子类化
- 两个或多个类需要实现类似的功能,但他们的抽象概念之间似乎并不存在层次关系。
- 但是,在概念上,任何一个类作为另一个类的子类都不合适
- 因此,可以选择其中任何一个类作为父类,并改写与设备相关的代码
- 更好的方法是将两个类的公共代码提炼成一个抽象类,并且让这两个类都继承于这个抽象类。
- 与泛化子类化一样,但基于已经存在的类创建新类时,就不能使用这种方法了。
- 控制机械鼠标=控制轨迹球
元类
元类是描述类的类,在面向对象思想中,类也是对象,类一定是某个元类的实例
元类的实例化的结果为我们用class定义的类,正如类的实例为对象。
元类对象中存储的是关于类的信息(类的版本,名字,类方法等)。
元类所具有的行为:创建实例,返回类名称,返回类实例大小,返回类实例可识别消息列表。
提问:引入元类的优点有哪些?
回答:
- 概念上一致:只用一个概念——对象就可表述系统中所有成分
- 使类成为运行时刻一部分,有助于改善程序设计环境
- 继承的规范化:类与元类的继承采用双轨制
反射和内省
反射(reflection)和内省(introspection)是指程序在运行过程中“了解”自身的能力。
用于反射和内省的技术分为两大类:
- 获取理解当前计算状态的特征
- 用于修改特征,增加新的行为
反射工具都开始于一个对象,该对象是关于一个类的动态(运行时)体现。类对象是更一般的类(称为Class类)的实例。类对象通常都包括类名称、类实例所占用内存的大小以及创建新实例的能力。
反射的作用:可以用于程序中的错误方法。可以灵活的调用对象,可以在运行时检测或修改程序行为。
反射的缺点:
- 效率低,因此,能不用反射实现的功能就尽量不使用反射。
- 暴露内部私有变量和方法
提问:如果两个或更多的方法具有相同的名称和相同的参数数目,java编译器如何匹配?
回答:
- 按照调用此方法的对象进行匹配。
- 按照参数类型匹配。
检测对象类
对这种类型检测的不恰当使用是设计类结构不好的一个标志。
大多数情况下,都可以通过调用改写方法来代替显式检测(instanceof)。
静态行为和动态行为
静态:用来表示在编译时绑定于对象并且不允许以后对其进行修改的属性或特征。
动态:用来表示直到运行时绑定于对象的属性或特征。
静态类和动态类
- 变量的静态类是指用于声明变量的类。静态类在编译时就确定下来,并且再也不会改变。
- 变量的动态类指与变量所表示的当前数值相关的类。动态类在程序的执行过程中,当对变量赋新值时可以改变。
Object obj = new Dog();
在上述代码中,Object
是静态类,Dog
则是动态类
静态类型和动态类型的区别
对于静态类型面向对象编程语言,在编译时消息传递表达式的合法性不是基于接收器的当前动态数值,而是基于接收器的静态类来决定的。
示例参考《面向对象编程导论》P167
向下造型(反多态)
做出数值(变量)是否属于指定类(使用instanceof等)的决定之后,通常下一步就是将这一数值的类型由父类转换为子类。这一过程称为向下造型,或者反多态,因为这一操作所产生的效果恰好与多态赋值的效果相反。
在java中,由父类转换为子类前需要通过
instanceof
判断,否则如果对象不能转换为子类,则会报错
在其他一些语言中,由父类转换为子类的过程中会同时进行类型测试,如果转换成功则返回子类对象,否则返回空值。
向下造型是处理多态变量的过程,并且在某种意义上这个过程的取消操作就是替换。因为该操作取消了多态赋值的过程,也被称为反多态。
方法绑定
绑定指的是一个方法的调用与方法所在的类(方法主体)关联起来。
- 静态方法绑定:编译器在编译期间就能完成绑定的叫做静态绑定。
- 动态方法绑定:响应消息时对哪个方法进行绑定是由接收器当前所包含的动态数值来决定的。
静态绑定
在程序执行前方法已经被绑定(也就是说在编译过程中就已经知道这个方法到底是哪个类中的方法),此时由编译器或其它连接程序实现。例如:C。
针对java简单的可以理解为程序编译期的绑定;这里特别说明一点,java当中的方法只有final,static,private和构造方法是前期绑定
动态绑定
在运行时根据具体对象的类型进行绑定。
若一种语言实现了后期绑定,同时必须提供一些机制,可在运行期间判断对象的类型,并分别调用适当的方法。也就是说,编译器此时依然不知道对象的类型,但方法调用机制能自己去调查,找到正确的方法主体。
不同的语言对后期绑定的实现方法是有所区别的。但我们至少可以这样认为:它们都要在对象中安插某些特殊类型的信息。
动态绑定的过程:
- 虚拟机提取对象的实际类型的方法表;
- 虚拟机搜索方法签名;
- 调用方法。
更详细的关于静态绑定和动态绑定的介绍,可以看Java静态绑定与动态绑定
内存布局
分配方案
最小静态空间分配
- C++使用最小静态空间分配策略,运行高效。
- 只分配基类所需的存储空间。
为了防止采用这种策略时因为多态而引发的程序错误(具体参照P179),C++改变了虚拟方法的调用规则:
- 对于指针 / 引用变量:当信息 调用可能被改写的成员函数时,选择哪个函数取决于接收器的动态数值。
- 对于其他变量:调用虚拟成员函数的方式取决于静态类,而不取决于动态类。
最大静态空间分配
分配变量值可能使用的最大存储空间。
这一方案不合适,因为需要找到最大的对象,就需要对继承树上的所有对象都进行扫描,然后找到需要分配最大内存的对象才能。
动态内存分配
- 堆栈中不保存对象值。
- 堆栈通过指针大小空间来保存标识变量,数据值保存在堆中。
- 指针变量都具有恒定不变的大小,变量赋值时,不会有任何问题。
Smalltalk、Java都采用该方法。
内存分配策略
按照编译原理的观点,程序运行时的内存分配有三种策略,分别是静态的,栈式的,和堆式的。
静态存储分配
静态存储分配是指在编译时就能确定每个数据目标在运行时刻的存储空间需求,因而在编译时就可以给他们分配固定的内存空间。
这种分配策略要求程序代码中不允许有可变数据结构(比如可变数组)的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间需求。
动态存储分配
也被称为栈式存储分配,它是由一个类似于堆栈的运行栈来实现的。
和静态存储分配相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到运行的时候才能够知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能够为其分配内存。
栈式存储分配按照先进后出的原则进行分配。
堆式存储分配
堆式存储分配专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例。
堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
复制
- 浅复制(shallow copy):共享实例变量。
- 深复制(deep copy):建立实例变量的新的副本。
- 实现方法: C++:拷贝构造函数,Java:改写clone方法
克隆
一个对象A,在某一时刻A中已经包含了一些有效值,此时可能会需要一个和A完全相同新对象B,并且此后对B任何改动都不会影响到A中的值,也就是说,A与B是两个独立的对象,但B的初始值是由A对象确定的。这种过程便是克隆。
多重继承
指一个对象可以有两个或更多不同的父类,并可以继承每个父类的数据和行为。
多重继承的问题
名称歧义
解决方法:
- 使用重定义和重命名的结合
- 使用同名方法时附带相应父类的名称。
缺点:
- 语法上与其他的函数调用语法不同
- 程序员必须记住哪个方法来自于哪个类
对替换的影响
在使用重定义名称的时候,就会发现其违反了替换原则。
接口的多重继承
java和C#都支持接口的多重继承,由于接口只有方法名,没有代码体,因此接口的多重继承不存在名称歧义。
多态
多态就是同一操作(方法)作用于不同的对象时,可以有不同的解释,产生不同的执行结果。在面向对象语言中,接口的多种不同的实现方式即为多态。
多态性是指一般类中定义的属性和服务,在特殊类中不改变其名字,但通过各自不同的实现后,可以具有不同的数据类型或具有不同的行为
多态的形式
- 重载(专用多态,overloading):类型签名区分。
- 改写(包含多态,overriding):层次关系中,相同类型签名,是重载的一种特殊情况,但是只发生在有父类和子类关系的上下文中。
- 多态变量(赋值多态):声明与包含不同。
- 泛型(模板):创建通用工具。
重定义(总和重载和多态一起考):
当子类定义了一个与父类具有相同名称但类型签名不同的方法时。
重载
- 重载是在编译时执行的,而改写是在运行时选择的。
- 重载是多态的一种很强大的形式。
- 非面向对象语言也支持。
基于类型签名的重载
多个过程(或函数、方法)允许共享同一名称,且通过该过程所需的参数数目、顺序和类型来对它们进行区分。即使函数处于同一上下文,这也是合法的。
class Example{
//same name,three different methods
int sum(int a){return a;}
int sum(int a,int b){return a+b;}
int sum(int a,int b,int c){return a+b+c;}
}
关于重载的解析,是在编译时基于参数值的静态类型完成的。不涉及运行时机制。
签名
函数类型签名是关于函数参数类型、参数顺序和返回值类型的描述。
函数签名经常被用在函数重载,因为调用重载的方法从名字上是无法确定你调用的是哪一个方法,而要从你传入的参数和该函数的签名来进行匹配,这样才可以确定你调用的是哪一个函数。
基于范畴的重载
相同的名称可以在不引起歧义且不造成精度损失的情况下出现于多个不同的范畴。
并不一定语义要相关
范畴
范畴定义了能够使名称有效使用的一段程序,或者能够使名称有效使用的方式。
例如局部变量(其作用于在
{}
内),public
成员。
通过继承创建的新类将同时创建新的名称范畴,该范畴是对父类的名称范畴的扩展。
造型
强制是一种隐式的类型转换,它发生在无需显式引用的程序中。
int i=3;//隐式
int i = (int)2.8;//显式
转换表示程序员所进行的显式类型转换。在许多语言里这种转换操作称为造型。
- 向上造型:父类接收子类变量,相当于隐式造型
- 向下造型
提问:如果两个或更多的方法具有相同的名称和相同的参数数目,编译器如何匹配?
回答提示:看参数的类和传递参数的类的“距离”
重定义
- 当子类定义了一个与父类具有相同名称但类型签名不同的方法时,发生重定义。
- 类型签名的变化是重定义区别于改写的主要依据。
- 两种不同的技术解析重定义:融和模型和分级模型。
融合模型
Java使用融合模型,对所有可能的方法进行检测,选择最匹配的方案。
class Parent {
public void example (int a)
{System.out.println(“in parent method”);}
}
class Child extends Parent {
public void example (int a,int b)
{System.out.println(“in child method”);}
}
//main方法中:
Child aChild = new Child();
aChild.example(3);
分级模型
C++使用分级模型,即在名称定义所在的范畴内进行匹配。
上述逻辑的代码在C++中会编译出错,解决的方法是在
Child
类中增加一个一个参数的example()
方法
多态变量
如果方法所执行的消息绑定是由最近赋值给变量的数值的类型来决定的,那么就称这个变量是多态的。
在C++中,必须使用指针或者引用,并且对相关方法声明为
virtual
,才可以使用多态消息传递。
改写
如果子类的方法具有与父类的方法相同的名称和类型签名,称子类的方法改写了父类的方法。
- 语法上:子类定义一个与父类有着相同名称且类型签名相同的方法。
- 运行时:变量声明为一个类,它所包含的值来自于子类,与给定消息相对应的方法同时出现于父类和子类。
改写与替换原则结合时,想要执行的一般都是子类的方法。
改写可看成是重载的一种特殊情况
- 接收器搜索并执行相应的方法以响应给定的消息。
- 如果没有找到匹配的方法,搜索就会传导到此类的父类。搜索会在父类链上一直进行下去,直到找到匹配的方法,或者父类链结束。
- 如果能在更高类层次找到相同名称的方法,所执行的方法就称为改写了继承的行为。
改写和重载的对比
- 继承角度:对于改写来说,方法所在的类之间必须符合父类/子类继承关系,而对于简单的重载来说,并无此要求。
- 类型签名角度:如果发生改写,两个方法的类型签名必须匹配。
- 方法作用角度: 重载方法总是独立的,而对于改写的两个方法,有时会结合起来一起实现某种行为。
- 编译器角度: 重载通常是在编译时解析的,而改写则是一种运行时机制。对于任何给定的消息,都无法预言将会执行何种行为,而只有到程序实际运行的时候才能对其进行确定。
改写与遮蔽的对比
- 语法角度:改写与遮蔽存在着外在的语法相似性。
- 编译角度:类似于重载,改写区别于遮蔽的最重要的特征就是,遮蔽是在编译时基于静态类型解析的,并且不需要运行时机制。
- 关键字:几种语言需要对改写显式声明,如果不使用关键字,将产生遮蔽。
改写机制在不同语言中的区别
- Java、Smalltalk等面向对象语言,只要子类通过同一类型签名改写父类的方法,自然便会发生所期望的行为。
- C++中,需要父类中使用关键字
Virtual
来表明这一含义(否则会发生遮蔽)
两种不同的关于改写的解释方式
- 代替(replacement):在程序执行时,实现代替的方法完全覆盖父类的方法。即,当操作子类实例时,父类的代码完全不会执行。
- 改进(refinement):实现改进的方法将继承自父类的方法的执行作为其行为的一部分。这样父类的行为得以保留且扩充。
这两种形式的改写都很有用,并且经常在一种编程语言内同时出现
如:几乎所有的语言在构造函数中都使用改进语义。
改写、遮蔽和重定义的差异
- 改写:父类与子类的类型签名相同,并且在父类中将方法声明为虚拟的。
- 遮蔽:父类与子类的类型签名相同,但是在父类中并不将方法声明为虚拟的。
- 重定义:父类与子类的类型签名不同。
延迟方法
如果方法在父类中定义,但并没有对其进行实现,那么我们称这个方法为延迟方法。
优点:
可以使程序员在比实际对象的抽象层次更高的级别上考虑与之相关的活动。
实际意义:
在静态类型面向对象语言中,对于给定对象,只有当编译器可以确认与给定消息选择器相匹配的响应方法时,才允许程序员发送消息给这个对象。
延迟方法有时也称为抽象方法,并且在C++语言中通常称之为纯虚方法。
协方差与反协方差
- 很少有改变类型签名的需求,通常将类型在其继承层次上提升或降低。
- 当一个类型降低类型层次作为子类时,将使用协方差变化术语。
- 反之,当一个类型由子类化反向提升类型层次时,将使用反协方差变化术语。
//Parent
Test(covar:Mammal,contravar:Mammal):boolean
//Child
Test(covar:Cat,contravar:Animal):boolean
上述代码为协方差变化,与之相反的为反协方差变化。
非方差
大多数语言都通过使用一种称为非方差的技术来避免这类问题。即:子类不允许以任何方式改变关于改写方法的类型签名。
如java的
final
关键字
软件复用机制
最常用的软件复用机制:继承和组合。
组合(分层,layering)
提供了一种利用已存在的软件组件来创建新的应用程序的方法。通过组合复用已存在的数据抽象来创建新的数据类型。
如数据类型Set
类包含了一个List
类的名称为theData
的字段。其中Set
类的add
方法中通过include
方法判断是否存在相同元素来满足集合的定义。
不符合替换原则。对照替换原则的概念,想一下为什么。
继承
在继承中,子类同时也是子类型,这就要求两者的行为方式必须类似。
组合和继承的比较
组合是较为简单的一种技术,优点是在特定的数据结构中只需要考虑类需要执行哪些操作,而无需考虑类所定义的所有操作。
使用继承构建数据抽象的代码的简洁性更高。
继承无法知道一个方法是否可以合法地应用于集合。
继承无法防止用户使用父类的方法来操纵新的数据结构。
多态变量
- 多态变量是指可以引用多种对象类型的变量。
- 这种变量在程序执行过程可以包含不同类型的数值。
- 对于动态类型语言,所有的变量都可能是多态的。
- 对于静态类型语言,多态变量则是替换原则的具体表现。
多态变量形式
- 简单变量
- 接收器变量
- 向下造型(反多态)(见链接)
- 纯多态(多态方法)
简单多态变量
调用方法时调用变量的动态类型的方法而不是静态类型的方法
接收器变量
多态变量作为一个数值,表示正在执行的方法内部的接收器。包含接收器的变量没有被正常的声明,通常被称为伪变量。
多态变量在框架中的作用
- 多态接收器功能的强大之处表现在消息传递与改写相结合时。这种结合是软件框架开发的关键。
一般框架系统中的方法分为两大类:
- 在父类中定义基础方法,被多个子类所继承,但不被改写;
- 父类定义了关于多种活动的方法,但要延迟到子类才实现。
接收器变量多态性的展现:
- 当执行基础方法时,接收器实际上保存的是一个子类实例的数值。
- 当执行一个改写方法时,执行的是子类的方法,而不是父类的方法。
纯多态(多态方法)
- 支持可变参数的函数 / 一个名称对应多个函数
- 支持代码只编写一次(高级别的抽象 )
- 支持针对各种情况所需的代码裁剪。
- 通常是通过给方法的接收器发送延迟消息来实现这种代码裁剪的。
如java中的
toString()
是被延迟实现的
- 方法toString在子类中得以重定义。
- toString方法的各种不同版本产生不同的结果。
- 所以
StringBuffer
的append
方法也类似产生了各种不同的结果。append
:一个定义,多种结果。
泛型
泛型的主要目的之一就是用来指定容器要持有什么类型的对象,而且由编译器来保证类型的正确性。
- 泛型通过类型的使用提供了一种将类或者函数参数化的方法。
- 与通常的函数参数一样,泛型提供了一种无需识别特定数值的定义算法的方法。
泛型也可以应用于接口。例如生成器(generator),这是一种专门负责创建对象的类。
泛型的定义过程
- 泛型将名称定义为类型参数。
- 在编译器读取类描述时,无法知道类型的属性,但是该类型参数可以像类型一样用于类定义内部。
- 在将来的某一时刻,会通过具体的类型来匹配这一类型参数,这样就形成了类的完整声明。
框架
- 框架是对于一类相似问题的骨架解决方案。
- 框架通过类的集合形成,类之间紧密结合,共同实现对问题的可复用解决方案。体现了继承和改写的强大能力。
比较常见的框架有:Java中的GUI框架,Web开发中的Struts框架等
使用继承的两种方式:
- 代码复用:基本方法,对问题的现存的解决方案。
- 概念复用:特化方法,用于特定应用的解决方案。
提问:多态变量在框架中的作用
回答
框架改变了应用程序(开发者定义的代码)与库代码之间的关系(倒置库):
在传统的应用程序中 | 在框架中 |
---|---|
应用程序特定的代码定义了程序执行的总体流程 | 控制流是由框架决定的,并且随应用程序的不同而不同 |
新的应用程序的创建者只需改变供框架调用的例程即可,而无需改变总体结构 | |
框架占主导地位,而应用程序特定的代码处于次要位置 |
对象互连
可视性:描述了关于名称的特性,通过该名称句柄可以存取对象,如果对象的名称是合法的且代表该对象,那么在这个特定环境下该对象就是可见的。
描述可视性的相关术语还包括标识符的范畴
依赖性:将两个对象或者类联系起来,在不存在另外一个对象的条件下,如果一个对象的存在无任何意义,就说该对象依赖于另外那个对象。
例如: 子类几乎总是依赖于它的父类
耦合和内聚
耦合(coupling)和内聚(cohesion)的思想提供了一个框架,用于评价对象和类的应用是否有效。
- 耦合:描述类之间的关系。
- 内聚:描述类内部的关系。
类的内部内聚性是该结构中各个元素之间绑定程度的量度。
耦合的种类(从最差到最好)
内部数据耦合:发生在当一个类的实例直接修改另外一个类中的本地数据值(实例变量)时。
全局数据耦合:发生在两个或者更多个类型都依赖于公用的全局数据结构而绑定到一起的时候。
控制(或顺序)耦合:一个类必须以一种由任何位置控制的特定的顺序来执行操作。(如:创建类后必须先执行方法1,再执行方法2,否则就会出错)
被顺序化的类应确保自身能够以正确的顺序实现操作,不应仅依赖于调用者的正确处理。如写窗口时候调用
iniDate()
再调用iniComponent()
等。
组件耦合:发生在一个类包含的数据字段或数值为另外一个类的实例时。(如Set类的一种实现方法是将List作为其数据字段之一)
参数耦合:发生在一个类必须调用另外一个类的服务和例程时,此时两个类之间所发生的唯一关系就是一个类需要为另一个类提供参数数目、类型和返回值类型。
子类耦合:是面向对象编程所特有的,描述了一个类与其父类之间的关系。通过继承,子类的实例可以被看成父类的实例。
内聚的种类
- 随机内聚:对程序随意划分
- 逻辑内聚:算术函数库
- 时间内聚:如实现程序初始化的类
- 通信内聚:数据或者设备的manager
- 顺序内聚:避免顺序耦合
- 功能内聚:类中元素通过执行特定功能关联起来
- 数据内聚:数据结构
约束
如果一个类的实例必须使用另一个对象,而这个对象又属于一个特定的类,那么复用性会受到损害。
为避免类之间因彼此使用而造成的耦合,让它们通过接口间接使用。