5. 多态(Polymorphism)
5.1 多态的概念
面向对象的三大特性:封装、继承、多态。从一定角度来看,封装和继承几乎都是为多态而准备的。这是我们最后一个概念,也是最重要的知识点。
多态的定义:指允许不同类的对象对同一消息做出响应。即同一消息可以根据发送对象的不同而采用多种不同的行为方式。(发送消息就是函数调用)
实现多态的技术称为:动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。
多态的作用:消除类型之间的耦合关系。
现实中,关于多态的例子不胜枚举。比方说按下 F1 键这个动作,如果当前在 Flash 界面下弹出的就是 AS 3 的帮助文档;如果当前在 Word 下弹出的就是 Word 帮助;在 Windows 下弹出的就是 Windows 帮助和支持。同一个事件发生在不同的对象上会产生不同的结果。
下面是多态存在的三个必要条件,要求大家做梦时都能背出来!
5.2 多态存在的三个必要条件
一、要有继承;
二、要有重写;
三、父类引用指向子类对象。
5.3 TestPolymoph.as —— 多态的应用,体会多态带来的好处
package {
public class TestPolymoph {
public function TestPolymoph() {
var cat:Cat = new Cat("MiMi");
var lily:Lady = new Lady(cat);
// var dog:Dog = new Dog("DouDou");
// var lucy:Lady = new Lady(dog);
lady.myPetEnjoy();
}
}
}
class Animal {
private var name:String;
function Animal(name:String) {
this.name = name;
}
public function enjoy():void {
trace("call...");
}
}
class Cat extends Animal {
function Cat(name:String) {
super(name);
}
override public function enjoy():void {
trace("Miao Miao...");
}
}
class Dog extends Animal {
function Dog(name:String) {
super(name);
}
override public function enjoy():void {
trace("Wang Wang...");
}
}
// 假设又添加了一个新的类 Bird
class Bird extends Animal {
function Bird(name:String) {
super(name);
}
override public function enjoy():void {
trace("JiJi ZhaZha");
}
}
class Lady {
private var pet:Animal;
function Lady(pet:Animal) {
this.pet = pet;
}
public function myPetEnjoy():void {
// 试想如果没有多态
//if (pet is Cat) { Cat.enjoy() }
//if (pet is Dog) { Dog.enjoy() }
//if (pet is Bird) { Bird.enjoy() }
pet.enjoy();
}
}
首先,定义 Animal 类包括:一个 name 属性(动物的名字),一个 enjoy() 方法(小动物玩儿高兴了就会叫)。接下来,定义 Cat, Dog 类它们都继承了 Animal 这个类,通过在构造函数中调用父类的构造函数可以设置 name 这个属性。猫应该是“喵喵”叫的,因此对于父类的 enjoy() 方法进行重写(override),打印出的叫声为 “Miao Maio…”。Dog 也是如此,重写 enjoy 方法,叫声为 “Wang Wang…”。
再定义一个 Lady 类,设置一个情节:假设这个 Lady 是一个小女孩儿,她可以去养一只宠物,这个小动物可能是 Cat, Dog,或是 Animal 的子类。在 Lady 类中设计一个成员变量 pet,存放着宠物的引用。具体是哪类动物不清楚,但肯定是 Animal 的子类,因此 pet 的类型为 Animal,即 pet:Animal。注意这是父类引用,用它来指向子类对象。
最后在 Lady 类里面有一个成员函数 myPetEnjoy(),这个方法中只有一句 pet.enjoy(),调用 pet 的 enjoy() 方法。
现在来看测试类。new 出来一只 Cat,new 出来一个 Lady,将 Cat 的对象传给 Lady。现在 Lady 中的成员变量应该是 pet:Animal = new Cat(“MiMi”)。下面,调用 lady.myPetEnjoy() 方法,实际就是在调用 pet.enjoy(),打印出 Miao Miao。pet 的类型明明是 Animal,但被调的方法却是 Cat 的 enjoy(),而非 Animal 的 enjoy(),这就叫动态绑定——“在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法”。
想象一下,如果没有多态的话,myPetEnjoy() 中方法可能要做这样的一些判断:
if (pet is Cat) { new Cat(“c”).enjoy() }
if (pet is Dog) { new Dog(“d”).enjoy() }
判断如果 pet 是Cat 类型的话调用 new Cat().enjoy(),如果是 Dog 的话调用 new Dog().enjoy()。假设有一天我要传入一个 Bird,那还得手动加上:
if (pet is Bird) { new Bird (“b”).enjoy() }
新加入什么类型的都要重新修改这个方法,这样的程序可扩展性差。但是现在我们运用了多态,可以随意地加入任何类型的对象,只要是 Animal 的子类就可以。例如,var lily:Lady = new Lady(new Bird(“dudu”)),直接添加进去就可以了,不需要修改其它任何地方。这样就大大提升的代码的可扩展性,通过这个例子好好体会一下多态带来的好处。
最后再补充一点,在使用父类引用指向子类对象时,父类型的对象只能调用是在父类中定义的,如果子类有新的方法,对于父类来说是看不到的。拿我们这个例子来说,如果 Animal 类不变,在 Cat 和 Dog 中都新定义出一个 run() 方法,这个方法是父类中没有的。那么这时要使用父类型的对象去调用子类新添加的方法就不行了。
下面看一个这个例子的内存图。
5.4 TestPolymoph 内存分析
在内存中,一个个方法就是一段段代码,因此它们被存放在代码段中。上例中的 pet 是 Animal 类型的成员变量,但是它指向的是一个 Cat 类型的具体对象,同时 Cat 又是它的子类,并且重写了 enjoy() 方法,满足了多态存在的三个必要条件。那么当调用 pet.enjoy() 的时候,调用的就是实际对象 Cat 的 enjoy() 方法,而非引用类型 Animal 的 enjoy() 方法。
5.5 多态的好处
多态提升了代码的可扩展性,我们可以在少量修改甚至不修改原有代码的基础上,轻松加入新的功能,使代码更加健壮,易于维护。
在设计模式中对于多态的应用比比皆是,面向对象设计(OOD)中有一个最根本的原则叫做“开放 – 关闭”原则(Open-Closed Principle OCP),意思是指对添加开放,对修改关闭。看看上面的例子,运用了多态以后我们要添加一个 Bird 只需要再写一个 Bird 类,让它继承自 Animal,然后 new 出来一个对象把它传给 lily 即可。
我们所做的就是添加新的类,而对原来的结构没有做任何的修改,这样代码的可扩展性就非常好了!因为我们遵循了“开放-关闭”原则 —— 添加而不是修改。
前面这个例子中还有一个地方需要说明,Animal 这个类,实际上应该定义为一个抽象类,里面的 enjoy() 方法,事实上不需要实现,也没法实现。想一想,Animal 的叫声?!你能想象出 Animal 是怎么叫的吗?显然,这个方法应该定义为一个抽象方法,留给它的子类去实现,它自己不需要实现,那么一旦这个类中有一个方法抽象的,那么这个类就应该定义为抽象类。但是很遗憾 AS 3 不支持抽象类,因为它没有 abstract 关键字。但是抽象类也是一个比较重要的概念,因此下面还要给大家补充一下。
5.6 抽象类的概念
一个类如果只声明方法而没有方法的实现,则称为抽象类。
含有抽象方法的类必须被声明为抽象类,抽象类必须被继承,抽象方法必须被重写。如果重写不了,应该声明自己为抽象。
抽象类不能被实例化。
抽象方法只需声明,而不需实现。
ActionScript 3.0 不支持抽象类(abstract),以后肯定会支持的,相信我,那只是时间问题。因此这里只介绍一下抽象类的概念。
5.7 对象转型(Casting)
一个基类类型变量可以“指向”其子类的对象。
一个基类的引用不可以访问其子类对象新增加的成员(属性和方法)。
可以使用“变量 is 类名”来判断该引用型变量所“指向”的对象是否属于该类或该类的子类。
子类的对象可以当作基类的对象来使用称作向上转型(upcasting),反之称为向下转型(downcasting)。
每说到转型,就不得不提到“里氏代换原则(LSP)”。里氏代换原则说,任何基类可以出现的地方,子类一定可以出现。里氏代换原则是对“开放—关闭”原则的补充。
里氏代换原则准确的描述:在一个程序中,将所有类型为 A 的对象都转型为 B 的对象,而程序的行为没有变化,那么类型 B 是类型 A 的子类型。
比如,假设有两个类:Base 和 Extender,其中 Extender 是 Base 的子类。如果一个方法可以接受基类对象 b 的话: method(b:Base) 那么它必然可以接受一个子类对象 e,即有 method(e)。注意,里氏代换原则反过来不能成立。使用子类对象的地方,不一定能替换成父类对象。
向上转型是安全的,可以放心去做。但是在做向下转型,并且对象的具体类型不明确时通常需要用 instanceof 判断类型。下面看一个例子 TestPolymoph.as:
package {
public class TestCast {
public function TestCast() {
// -------------- UpCasting --------------
var cat:Cat = new Cat();
var dog:Dog = new Dog();
var animal:Animal = Animal(cat);
animal.call();
animal.sleep();
//animal.eat(); // 不能调用父类中没有定义的方法
// ------------- DownCasting -------------
if (animal is Cat) {
cat = Cat(animal);
cat.eat();
} else if (animal is Dog) {
dog = Dog(animal);
dog.eat();
}
}
}
}
class Animal {
public function call():void{};
public function sleep():void{};
}
class Cat extends Animal {
override public function call():void {
trace("Cat Call");
}
override public function sleep():void {
trace("Cat Sleep");
}
public function eat():void {
trace("Cat Eat");
}
}
class Dog extends Animal {
override public function call():void {
trace("Dog Call");
}
override public function sleep():void {
trace("Dog Sleep");
}
public function eat():void {
trace("Dog Eat");
}
}
首先创建 Animal 类,定义两个方法 call() 和 sleep(),它的子类 Cat 和 Dog 分别重写了这两个方法,并且都扩展了出了一个新的方法 eat()。
来看测试类,new 出来一个 cat,再将它向上转型 animal:Animal = Animal(cat)。由于向上转型是安全的,所以这样做没有问题,但是当它转型成了父类对象后,就不能再调用 eat() 方法了,因为在父类中只有call() 和 sleep() 方法,父类对象不能调用子类扩展出的新方法。
接下来一段代码是在进行向下转型,animal 这个对象可以是一个放一个 dog 也可以放一个 cat,当这两种情况都有可能时,进行向下转型就要判断一下当然对象到底是哪个类型的,使用“is”进行判断,看看该对象是不是一个 Cat 或 Dog,如果是 Cat 就将它向下转型为一个 Cat,这样就可以安全地调用 Cat 的 eat() 方法了。
最后再举一个现实中的例子 TestEventCast.as :
package {
import flash.display.Sprite;
import flash.events.Event;
public class TestEventCast extends Sprite {
public function TestEventCast() {
var ball:Sprite = new Sprite();
ball.graphics.beginFill(0xff0000);
ball.graphics.drawCircle(0,0,50);
ball.graphics.endFill();
ball.y = 150;
ball.x = 150;
addChild(ball);
ball.addEventListener(Event.ENTER_FRAME, onEnterFrame);
}
private function onEnterFrame(evt:Event):void {
// evt.target 是 Object 类型的,需要转型成为实际类型才能使用 x 属性
var ball:Sprite = Sprite(evt.target);
ball.x += 5;
}
}
}
构造函数中创建一个 Sprite 类的对象,并在里面绘制一个圆,加入 ENTER_FRAME 侦听,在 onEnterFrame 函数中,var ball:Sprite = Sprite(evt.target) 这里我们必须做向上转型,如果不做的话系统会报错,为什么呢?
查看一下帮助文档,Event 类 target 属性的实现:public function get target():Object。这是一个只读属性,它返回的是一个 Object 类型的对象。由于 AS 3 是单根继承的,因此任何一个对象都可以向上转型成 Object 类型的。因此每次要拿到这个 evt.target 的时候都要将它向下转型成为该对象的实际类型才能放心使用。
6. 接口(Interface)
6.1 接口的概念
每次说到接口,我都会想到现在很流行的一句话 —— “三流的企业卖产品,二流的企业卖服务,一流的企业卖标准”。接口就是在“卖标准”。
接口是方法声明的集合,让不相关的对象能够彼此通信。接口是实现“多继承”的一种手段。因此这一节非常重要。
接口仅包含一组方法声明,没有具体的代码实现。实现接口的类必须按照接口的定义实现这些方法,因此,实现同一个接口的类都具有这个接口的特征。
接口与类的区别:接口中只能定义成员方法,不能定义成员变量。接口中的方法都是抽象方法(没有具体实现)。
6.2 依赖倒转原则(Dependence Inversion Principle)
如果说“开放—关闭”原则是面对对象设计的目标,那么依赖倒转原则就是这个面向对象设计的主要机制。
依赖倒转原则讲的是:要依赖于抽象,不要依赖于具体。
依赖倒转原则的另一种表述是:要针对接口编程,不要针对实现编程。针对接口编程意思就是说,应当使用接口或抽象类来编程。它强调一个系统内实体间关系的灵活性。如果设计者要遵守“开放—关闭”原则,那么依赖倒转原则便是达到此要求的途径,它是面向对象设计的核心原则,设计模式的研究和应用均以该原则为指导原则。
6.3 实现接口的原则
在实现接口的类中,实现的方法必须(选自帮助文档):
(1)使用 public 访问控制标识符。
(2)使用与接口方法相同的名称。
(3)拥有相同数量的参数,每一个参数的数据类型都要与接口方法参数的数据类型相匹配。
(4)使用相同的返回类型。
6.4 TestInterFaceAccess.as —— 实现多个接口
package {
public class TestInterFaceAccess {
public function TestInterFaceAccess() {
var duck:Duck = new Duck();
duck.run(56);
duck.fly();
}
}
}
interface Runnable {
function run(meter:uint):void;
}
interface Flyable {
function fly():void;
}
class Duck implements Runnable, Flyable {
public function run(meter:uint):void {
trace("I can run " + meter + " meters");
}
public function fly():void {
trace("I can fly");
}
}
这个例子很简单,首先定义两个接口 Runnable 和 Flyable。Runnable 中定义了一个抽象的 run() 方法,Flyable 中定义了一个抽象的 fly() 方法。我们知道,接口是在定义标准,它自己不需要实现,具体的实现交给实现该接口的类去完成。
Duck 类实现(implements)了 Runnable 和 Flyable,因此它必需去实现这两个接口中定义的所有方法。并且方法名,参数类型,返回值类型要与接口中定义的完全一致,权限修饰符必需是 public。大家可以试一试其它的访问权限,例如,private, internal 看看能不能测试通过。结论是不能,请查看 6.3 节实现接口的原则。
其实这些结论大家通过动手实验就能得出结论。比如说如果实现了该接口的类的方法权限不是 public 或者方法返回值、参数类型、参数个数与接口中定义的不同,是否可以测试通过呢?如果在定义接口时在 function 前面加入了访问权限修饰符,可以不可以以呢?类似这些问题不需要查书或去问别人,自己动手做实验是最快最高效的学习方法,编译器会告诉你,行还是不行,直接问它就可以了!以上做法都行不通。
为了更好地保证接口的实现不出差错,通常最保险的做法就将该方法复制(ctrl + c)过来,并在前面加上 public,再去实现。
6.5 接口用法总结
通过接口可以实现不相关类的相同行为,而不需要考虑这些类之间的层次关系。(就像人拥有一项本领)。
通过接口可以指明多个类需要实现的方法。(描述这项本领的共同接口)。
通过接口可以了解对象的交互界面,而不需要了解对象所对应的类。
我们通常所说的“继承”广义来讲,它不仅是指 extends ,还包括 implements,回想 5.2 节中说到多态存在的三个必要条件,这里面所说的就广义的继承。说得更明确一点就是:要有继承(或实现相同接口),要有重写(或实现接口),父类引用指向子类对象(或接口类型指向实现类的对象)。下面来看最后一个知识点,使用接口实现多态。
6.6 TestInterFacePoly.as —— 接口实现多态
package {
public class TestInterFacePoly {
public function TestInterFacePoly() {
var cat:Cat = new Cat();
var duck:Duck = new Duck();
var racing:Racing = new Racing(cat);
racing.go();
}
}
}
interface Runnable {
function run():void;
}
interface Swimmable {
function swim():void;
}
interface Flyable {
function fly():void;
}
class Cat implements Runnable, Swimmable {
public function run():void {
trace("Cat run");
}
public function swim():void {
trace("Cat swim");
}
public function climb():void {
trace("Cat Climb");
}
}
class Duck implements Runnable, Flyable {
public function run():void {
trace("Duck run");
}
public function fly():void {
trace("Duck fly");
}
}
class Racing {
var runner:Runnable;
public function Racing(r:Runnable) {
runner = r;
}
public function go():void {
runner.run();
}
}
使用接口实现多态,和前面通过继承实现多态几乎是相同的,只不过这次是把父类引用改成了接口类型的引用。实现了同一接口的类的对象表示它们都具有这一项相同的能力,当我们只关心某些这项能力,而并不关心具体对象的类型时,使用多态可以更好地保证代码的灵活性,这就是向上抽象的作用。下面来解释一下这个例子。
首先,定义三个接口 Runnable(会跑的),Swimmable(会游的),Flyable(会飞的)。让 Cat 类实现Runnable, Swimmable,让 Duck 类实现Runnable, Flyable。实现了某个接口就代表拥有了某项(或几项)技能。Cat 类中除了实现这两个接口之外,它还有自己的 climb() 方法。
在测试类中,创建一个 Cat 类的对象 cat,一个 Dog 类的对象 dog。
接下来,加入一个 Racing(跑步比赛)类的实例,将 cat 传进去,赋给 runner 变量(Runnable 接口类型的引用),Racing 类的 go() 方法中调用了实现了 Runnable 接口的对象,并调用它的 run() 方法,这里就有了多态,动态绑定到实际对象的 run() 方法。
成员变量 runner 是 Runnable 接口类型的,说明我们只关心它是能跑的对象(拥有run() 方法),具体它是怎么跑的我不管,反正你实现了 Runnable 接口,就肯定有 run() 方法,我只要你的 run() 方法,其它的我不管。
注意,在 Runnable 中只定义 run() 方法,对于 runner 来说只能看到 Runnable 接口里定义的方法,在此接口以外的方法一律看不到,如果要让 runner 调用 Cat 对象的 climb() 或 swim() 方法是行不通的,与 5.3 节最后一段说明的道理是一样的。
接口在设计模式中应用广泛,下面请出策略模式。
6.7 策略模式(Strategy Pattern)
同样,不直接给出最终的答案,先看下面这个例子:
package {
public class TestStrategy {
public function TestStrategy() {
var rabbit:Rabbit = new Rabbit();
rabbit.run();
rabbit.jump();
}
}
}
interface Runnable {
function run():void;
}
interface Jumpable {
function jump():void;
}
class Rabbit implements Runnable, Jumpable {
public function run():void {
trace("I can run fast");
}
public function jump():void {
trace("I can jump 5m");
}
}
这个例子很简单,让 Rabbit 实现 Runnable, Jumpable 接口,让它能跑能跳。
现在如果要让 Rabbit 跳不起来,那么就要修改它的 jump() 方法,打印出 “I can’t jump”。如果要让它能跑 1000 m,并且还能跨栏,那么还要修改 run() 方法的实现。还记得 OO 设计的最根本原则吗?“开放—关闭”原则 —— 对添加开放,对修改关闭。下面来看看策略模式是怎样做到“开放—关闭”原则的。以下是 TestStrategy.as:
interface Runnable {
function run():void;
}
interface Jumpable {
function jump():void;
}
class FastRun implements Runnable {
public function run():void {
trace("I can run fast");
}
}
class JumpHigh implements Jumpable {
public function jump():void {
trace("I can jump 5m");
}
}
class JumpNoWay implements Jumpable {
public function jump():void {
trace("I can't jump");
}
}
class Rabbit {
var runBehavior:Runnable = new FastRun();
var jumpBehavior:Jumpable = new JumpHigh(); // new JumpNoWay();
public function run() {
runBehavior.run();
}
public function jump() {
jumpBehavior.jump();
}
}
现在 Rabbit 中加入了两个成员变量 runBehavior,jumpBehavior 分别是 Runnable 和 Jumpable 类型的引用,又是父类引用(接口)指向子类对象。Rabbit 的 run 和 jump 直接调用了 runBehavior.run() 和 jumpBehavior.jump()。而 runBehavior,jumpBehavior 指向的是两个实现了 Runnable 和 Jumpable 接口的类 FastRun 类和 JumpHigh 类。而在这两个类中分别实现了 Runnable 和 Jumpable 接口,run() 和 jump() 的具体实现被放到 FastRun 和 JumpHigh 这两个类中去了。
这样做有什么好处呢?首先,如果将来的策略发生了变化让兔子跳不起来,那么只需要添加一个新的类(策略):JumpNoWay 同样让它实现 Jumpable 接口,jump 方法中打印出 "I can't jump",然后将 Rabbit 类中的new JumpHigh() 改为 new JumpNoWay() 即可,这样就实现了“添加而不是修改”的原则,我们只添加了一个新的策略(类),对原来的策略没有任何修改,最后只是替换了一个策略而以(当然这种修改是必要的)。另一个好处是,将来如果要修改 JumpHigh 的算法,让它可以跳 150 米,那么直接去修改 JumpHigh 里的 jump() 就可以,而不会影响到 Rabbit,从而降低了耦合度,这是封装算法所带来的好处。
这一切的灵活性都是多态所带来了,因此在很多的设计模式中都会用到多态,为的就是降低耦合度,增加程序的灵活性以及提高扩展性。以下是该模式的 UML 类图:
策略模式(Strategy Pattern)属于对象行为型模式,体现了两个非常基本的面向对象设计的基本原则:封装变化的概念;编程中使用接口,而不是对接口实现。策略模式的定义如下:
定义一组算法,将每个算法都封装起来,并且使它们之间可以互换。每一个算法封装到具有共同接口的独立的类中,策略模式使这些算法在客户端调用它们的时候能够互不影响地变化。
策略模式使开发人员能够开发出由许多可替换的部分组成的软件,并且各个部分之间是弱连接的关系。弱连接的特性使软件具有更强的可扩展性,易于维护;
策略模式中有三个对象:
(1)环境对象:该类中实现了对抽象策略中定义的接口或者抽象类的引用。
(2)抽象策略对象:它可由接口或抽象类来实现。
(3)具体策略对象:它封装了实现不同功能的不同算法。
7.浅谈设计模式
前面我们已经介绍了两个设计模式以及一些面向对象设计(OOD)的原则,那么到底还有多少种原则呢?下面我们一起简单地了解一下:
1. 单一职责原则:一个类,最好只做一件事,只有一个引起它变化得原因。
2. 开放封闭原则:软件实体应当对修改关闭,对扩展开放。
3. 依赖倒置原则:依赖于抽象,而不要依赖于具体,因为抽象相对稳定。
4. 接口隔离原则:尽量应用专门的接口,而不是单一得总接口,接口应该面向用户,将依赖建立在最小得接口上。
5. 里氏替换原则:子类必须能够替换其基类。
6. 合成/聚合复用原则:在新对象中聚合已有对象,使之成为新对象的成员,从而通过操作这些对象达到复用得目的。合成方式较继承方式耦合更松散,所以应该少继承,多聚合。
7. 迪米特法则(又叫最少知识原则):软件实体应该尽可能少的和其他软件实体发生相互作用。
设计原则是基本的工具,应用这些规则可使代码更加灵活、更容易维护,更容易扩展。基本原则:封装变化;面向接口变成而不是实现;优先使用组合而非继承。
对于开发人员而言,学习使用设计模式是很有必要的,而且越早学越好。尽早地了解它,能让我们脑子里先有这个概念,在日后写程序时或许就能联系上某个模式,可以对比一下人家的设计比我的设计强在哪儿,从而形成条件反射。
我在论坛上看到有人说“设计模式不用学,自己平时写写程序就会了”,楼下居然还有人跟帖表示赞同。设计模式最早由 GoF 提出,集结了这四位专家多年的心血才提炼出了 30 多种设计模式,难道说光凭你一个人的力量能够超越这些位专家经过多少年得到的成就?
作为一名知识的传播者,我认为讲授知识点是必要的,但更重要的是讲授学习的方法。比讲授学习方法更重要的是传授正确思考问题的方法。