组合
和继承类似, 组合也是一种表达类之间关系的方式, 也是能够达到代码重用的效果.
public class Student { ...
} public class Teacher { ...
} public class School { public Student[] students; public Teacher[] teachers;
}
组合并没有涉及到特殊的语法(诸如 extends 这样的关键字), 仅仅是将一个类的实例作为另外一个类的字段. 这是我们设计类的一种常用方式之一.
- 组合表示 has - a 语义
- 在刚才的例子中, 我们可以理解成一个学校中 "包含" 若干学生和教师.
- 继承表示 is - a 语义
- 在上面的 "动物和猫" 的例子中, 我们可以理解成一只猫也 "是" 一种动物.
多态
向上转型
在刚才的例子中, 我们写了形如下面的代码
Bird bird = new Bird("圆圆");
这个代码也可以写成这个样子
Bird bird = new Bird("圆圆");
Animal bird2 = bird; // 或者写成下面的方式
Animal bird2 = new Bird("圆圆");
此时 bird2 是一个父类 (Animal) 的引用, 指向一个子类 (Bird) 的实例. 这种写法称为 向上转型.
为啥叫 "向上转型"?
在面向对象程序设计中, 针对一些复杂的场景(很多类, 很复杂的继承关系), 我们会画一种 UML 图的方式来表 示类之间的关系. 此时父类通常画在子类的上方. 所以我们就称为 "向上转型" , 表示往父类的方向转.
向上转型发生的时机:
直接赋值 方法传参 方法返回
直接赋值的方式我们已经演示了. 另外两种方式和直接赋值没有本质区别.
方法传参
public class Test { public static void main(String[] args) { Bird bird = new Bird("圆圆"); feed(bird); } public static void feed(Animal animal) { animal.eat("谷子"); }
} // 执行结果
圆圆正在吃谷子
此时形参 animal 的类型是 Animal (基类), 实际上对应到 Bird (父类) 的实例.
方法返回
public class Test { public static void main(String[] args) { Animal animal = findMyAnimal(); } public static Animal findMyAnimal() { Bird bird = new Bird("圆圆"); return bird; }
}
此时方法 findMyAnimal 返回的是一个 Animal 类型的引用, 但是实际上对应到 Bird 的实例.
动态绑定
当子类和父类中出现同名方法的时候, 再去调用会出现什么情况呢?
// Animal.java
public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat(String food) { System.out.println("我是一只小动物"); System.out.println(this.name + "正在吃" + food); }
}
// Bird.java
public class Bird extends Animal { public Bird(String name) { super(name); } public void eat(String food) { System.out.println("我是一只小鸟"); System.out.println(this.name + "正在吃" + food); }
} // Test.java
public class Test { public static void main(String[] args) { Animal animal1 = new Animal("圆圆"); animal1.eat("谷子"); Animal animal2 = new Bird("扁扁"); animal2.eat("谷子"); }
} // 执行结果
我是一只小动物
圆圆正在吃谷子
我是一只小鸟
扁扁正在吃谷子
- animal1 和 animal2 虽然都是 Animal 类型的引用, 但是 animal1 指向 Animal 类型的实例, animal2 指向 Bird 类型的实例.
- 针对 animal1 和 animal2 分别调用 eat 方法, 发现 animal1.eat() 实际调用了父类的方法, 而 animal2.eat() 实际调用了子类的方法.
因此, 在 Java 中, 调用某个类的方法, 究竟执行了哪段代码 (是父类方法的代码还是子类方法的代码) , 要看究竟这个引 用指向的是父类对象还是子类对象.
这个过程是程序运行时决定的(而不是编译期), 因此称为 动态绑定.
方法重写
针对刚才的 eat 方法来说: 子类实现父类的同名方法, 并且参数的类型和个数完全相同, 这种情况称为 覆写/重写/覆盖
1. 重写和重载完全不一样. 不要混淆
2. 普通方法可以重写, static 修饰的静态方法不能重写.
3. 重写中子类的方法的访问权限不能低于父类的方法访问权限.
4. 重写的方法返回值类型不一定和父类的方法相同(但是建议最好写成相同, 特殊情况除外).
如果将子类的 eat 改成 private
// 编译出错
Error:(8, 10) java: com.csdn.Bird中的eat(java.lang.String)无法覆盖com.bit.Animal中的
eat(java.lang.String) 正在尝试分配更低的访问权限; 以前为public
另外, 针对重写的方法, 可以使用 @Override 注解来显式指定.
// Bird.java
public class Bird extends Animal { @Override private void eat(String food) { ... }
}
有了这个注解能帮我们进行一些合法性校验. 例如不小心将方法名字拼写错了 (比如写成 aet), 那么此时编译器就会发 现父类中没有 aet 方法, 就会编译报错, 提示无法构成重写.
我们推荐在代码中进行重写方法时显式加上 @Override 注解.
事实上, 方法重写是 Java 语法层次上的规则, 而动态绑定是方法重写这个语法规则的底层实现. 两者本质上描述 的是相同的事情, 只是侧重点不同.
理解多态
有了面的向上转型, 动态绑定, 方法重写之后, 我们就可以使用 多态的形式来设计程序了. 我们可以写一些只关注父类的代码, 就能够同时兼容各种子类的情况.
class Shape { public void draw() { // 啥都不用干 }
} class Cycle extends Shape { @Override public void draw() { System.out.println("○"); }
} class Rect extends Shape { @Override public void draw() { System.out.println("□"); }
} class Flower extends Shape { @Override public void draw() { System.out.println("♣"); }
} /我是分割线// // Test.java
public class Test { public static void main(String[] args) { Shape shape1 = new Flower(); Shape shape2 = new Cycle(); Shape shape3 = new Rect(); drawMap(shape1); drawMap(shape2); drawMap(shape3); } // 打印单个图形 public static void drawShape(Shape shape) { shape.draw(); }
}
在这个代码中, 分割线上方的代码是 类的实现者 编写的, 分割线下方的代码是 类的调用者 编写的.
当类的调用者在编写 drawMap 这个方法的时候, 参数类型为 Shape (父类), 此时在该方法内部并不知道, 也不关注当 前的 shape 引用指向的是哪个类型(哪个子类)的实例.
此时 shape 这个引用调用 draw 方法可能会有多种不同的表现 (和 shape 对应的实例相关), 这种行为就称为 多态.
使用多态的好处是什么?
1) 类调用者对类的使用成本进一步降低.
- 封装是让类的调用者不需要知道类的实现细节.
- 态能让类的调用者连这个类的类型是什么都不必知道, 只需要知道这个对象具有某个方法即可.
2) 能够降低代码的 "圈复杂度", 避免使用大量的 if - else
例如我们现在需要打印的不是一个形状了, 而是多个形状. 如果不基于多态, 实现代码如下:
public static void drawShapes() { Rect rect = new Rect(); Cycle cycle = new Cycle(); Flower flower = new Flower(); String[] shapes = {"cycle", "rect", "cycle", "rect", "flower"}; for (String shape : shapes) { if (shape.equals("cycle")) { cycle.draw(); } else if (shape.equals("rect")) { rect.draw(); } else if (shape.equals("flower")) { flower.draw(); } }
}
如果使用使用多态, 则不必写这么多的 if - else 分支语句, 代码更简单.
public static void drawShapes() { // 我们创建了一个 Shape 对象的数组. Shape[] shapes = {new Cycle(), new Rect(), new Cycle(), new Rect(), new Flower()}; for (Shape shape : shapes) { shape.draw(); }
}
3) 可扩展能力更强.
如果要新增一种新的形状, 使用多态的方式代码改动成本也比较低.
class Triangle extends Shape { @Override public void draw() { System.out.println("△"); }
}
对于类的调用者来说(drawShapes方法), 只要创建一个新类的实例就可以了, 改动成本很低.
而对于不用多态的情况, 就要把 drawShapes 中的 if - else 进行一定的修改, 改动成本更高.
向下转型
向上转型是子类对象转成父类对象, 向下转型就是父类对象转成子类对象.
相比于向上转型来说, 向下转型没那么常见, 但是也有一定的用途.
// Animal.java
public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat(String food) { System.out.println("我是一只小动物"); System.out.println(this.name + "正在吃" + food); }
} // Bird.java
public class Bird extends Animal { public Bird(String name) { super(name); } public void eat(String food) { System.out.println("我是一只小鸟"); System.out.println(this.name + "正在吃" + food); } public void fly() { System.out.println(this.name + "正在飞"); }
}
Animal animal = new Bird("圆圆");
animal.eat("谷子"); // 执行结果
圆圆正在吃谷子
接下来我们尝试让圆圆飞起来
animal.fly(); // 编译出错
找不到 fly 方法
编译过程中, animal 的类型是 Animal, 此时编译器只知道这个类中有一个 eat 方法, 没有 fly 方法.
虽然 animal 实际引用的是一个 Bird 对象, 但是编译器是以 animal 的类型来查看有哪些方法的.
对于 Animal animal = new Bird("圆圆") 这样的代码,
编译器检查有哪些方法存在, 看的是 Animal 这个类型
执行时究竟执行父类的方法还是子类的方法, 看的是 Bird 这个类型.
那么想实现刚才的效果, 就需要向下转型.
// (Bird) 表示强制类型转换
Bird bird = (Bird)animal;
bird.fly(); // 执行结果
圆圆正在飞
但是这样的向下转型有时是不太可靠的. 例如
Animal animal = new Cat("小猫");
Bird bird = (Bird)animal;
bird.fly(); // 执行结果, 抛出异常
Exception in thread "main" java.lang.ClassCastException: Cat cannot be cast to Bird at Test.main(Test.java:35)
animal 本质上引用的是一个 Cat 对象, 是不能转成 Bird 对象的. 运行时就会抛出异常.
所以, 为了让向下转型更安全, 我们可以先判定一下看看 animal 本质上是不是一个 Bird 实例, 再来转换
Animal animal = new Cat("小猫");
if (animal instanceof Bird) { Bird bird = (Bird)animal; bird.fly();
}
instanceof 可以判定一个引用是否是某个类的实例. 如果是, 则返回 true. 这时再进行向下转型就比较安全了.
super 关键字
前面的代码中由于使用了重写机制, 调用到的是子类的方法. 如果需要在子类内部调用父类方法怎么办? 可以使用 super 关键字.
super 表示获取到父类实例的引用.
涉及到两种常见用法.
1) 使用了 super 来调用父类的构造器
public Bird(String name) { super(name);
}
2) 使用 super 来调用父类的普通方法
public class Bird extends Animal { public Bird(String name) { super(name); } @Override public void eat(String food) { // 修改代码, 让子调用父类的接口. super.eat(food); System.out.println("我是一只小鸟"); System.out.println(this.name + "正在吃" + food); }
}
在这个代码中, 如果在子类的 eat 方法中直接调用 eat (不加super), 那么此时就认为是调用子类自己的 eat (也就是递 归了). 而加上 super 关键字, 才是调用父类的方法.
总结
多态是面向对象程序设计中比较难理解的部分. 我们会在后面的抽象类和接口中进一步体会多态的使用. 重点是多态带 来的编码上的好处.
另一方面, 如果抛开 Java, 多态其实是一个更广泛的概念, 和 "继承" 这样的语法并没有必然的联系.
- C++ 中的 "动态多态" 和 Java 的多态类似. 但是 C++ 还有一种 "静态多态"(模板), 就和继承体系没有关系了.
- Python 中的多态体现的是 "鸭子类型", 也和继承体系没有关系.
- Go 语言中没有 "继承" 这样的概念, 同样也能表示多态.
无论是哪种编程语言, 多态的核心都是让调用者不必关注对象的具体类型. 这是降低用户使用成本的一种重要方式.