前言:向对象编程(OO,ObjectOriented,面向对象)是以对象为中心,以类和继承为构造机制的软件开发系统方法,是20世纪90年代软件开发方法的主流。到了现在,他依然是我们学习Java路上的一个必经之路。
在上一篇博客中,包和继承已经讲完了,本篇我们来进行介绍组合、多态、抽象类、接口。
每文一图:
一.组合
上一篇博客中我们介绍到了继承,而这一篇我们的开篇便是组合。和继承类似, 组合也是一种表达类之间关系的方式, 也是能够达到代码重用的效果。
什么是组合?
组合: a part of(一部分),也就是表达式之间的一种关系,比如是一个学校,有学生,有老师,有教师等等,而组合就是将他们做得一个字段来使用。又或者是柜子里面有茶叶架子等等的东西。
在代码比如:
public class Student {
...
}
public class Teacher {
...
}
public class School {
public Student[] students;
public Teacher[] teachers;
}
组合并没有涉及到特殊的语法(诸如 extends 这样的关键字),仅仅是将一个类的实例作为另外一个类的字段。这是我们设计类的一种常用方式之一。
组合表示 has - a 语义: 在刚才的例子中, 我们可以理解成一个学校中 “包含” 若干学生和教师。
二.多态
面试官:什么是多态? 面试生:多态就是一种事物多种状态。 面试官:有请下一位面试的进来。
虽然多态从字面上的意思是一种事物多种状态,就是语文角度上是这个意思,但是咱不能直接这样讲个面试官听。想要了解什么是多态,先要了解向上转型。
1.向上转型
什么是向上转型呢,我们看一下这个代码:
class Animal {
}
class Dog extends Animal{
}
public static void main(String[] args) {
Dog dog = new Dog("haha",19);
Animal animal = dog;
}
这种写法当然是没有问题的,那么在 Animal animal = dog; 这段代码中,animal也是引用,dog也是引用,那可不可以把这俩个合起来,当然是可以的:
public static void main(String[] args) {
Animal animal = new Dog("haha",19);
}
此时 dog 是一个父类 (Animal) 的引用, 指向一个子类 (Dog) 的实例. 这种写法称为 向上转型,也就是说:
把一个父类引用指向一个子类,这种写法就称为是向上转型。
其实对于向上转型这样的写法可以结合 is - a 语义来理解:
比如说我家养了一只鹦鹉,我在打游戏没空,我就跟我弟说:快去喂鹦鹉,我也可以跟我表弟说:快去喂小鸟。其实小鸟和鹦鹉在我口中指的是同一个东西。但是小鸟相当于父类,鹦鹉是子类。
为啥叫 “向上转型”? 在面向对象程序设计中,针对一些复杂的场景(很多类,很复杂的继承关系), 程序猿会画一种 UML 图的方式来表示类之间的关系。此时父类通常画在子类的上方。所以我们就称为 “向上转型” , 表示往父类的方向转。
那个向上转型有哪几种情况呢?我们来看一下: 1.直接赋值 2.方法传参 3.方法返回
1.父类引用 引用 子类对象,也就是刚刚上面的那一种。
比如:
public static void main(String[] args) {
Animal animal = new Dog("haha",19);
}
2.接收子类传参,也就是父类的方法调用的时候传参传过去的是子类参数。
比如:
public static void func(Animal animal){
}
public static void main(String[] args) {
Dog dog = new Dog("haha",19);
Animal animal = dog;
func(dog);
}
3.调用方法返回的是子类对象。
public static Animal func2(){
Dog dog = new Dog("haha",19);
return dog;
}
2.动态绑定
当子类和父类中出现同名方法的时候, 再去调用会出现什么情况呢?
也就是说,我们的父类Animal中的方法eat是正在吃,但是我有一些子类就不服了,有一个子类说它太饿了,它想狼吞虎咽的吃,那咋办,就在子类的定义中写一个方法,狼吞虎咽,那么这时候,子类能不能狼吞虎咽呢?我们看一下:
class Animal {
public String name = "hello";
public int age;
public Animal(String name,int age) {
this.name = name;
this.age = age;
}
public void eat() {
System.out.println(name+"eat()");
}
}
class Dog extends Animal{
public Dog(String name,int age) {
super(name,age);
}
public void eat(){
System.out.println(name+"正在狼吞虎咽的eat()");
}
}
public class TestDemo {
public static void main(String[] args) {
Dog dog = new Dog("haha",19);
Animal animal = dog;
animal.eat();
}
然后我们运行,发现子类dog真的是狼吞虎咽的吃,所以,我们得知当我们在子类中重新写一个相同的方法的时候,优先的是子类的方法,这就是重写。
所以动态绑定其实就是:
1.父类引用 引用 子类对象(向上转型) 2.通过这个父类引用 调用父类和子类 同名的方法覆盖。
而这个同名的方法覆盖其实就是重写,重写中要满足几个条件:
1.父子类的方法名相同 2.参数列表相同(个数+类型) 3.返回值相同 4.是父子类的情况下
动态绑定也是我们的多态中的一种。因为在编译代码的时候不能确定到底调用的是谁的方法,只有运行的时候,才知道调用的是哪个方法,就是运行时再绑定,所以就是动态绑定。
对于动态绑定中的重写,我们还需要注意:
关于重写的注意事项:
1.重写和重载完全不一样。
2.普通方法可以重写,static 修饰的静态方法不能重写(static说明它是多态的,这里说好的是动态绑定呢)。
3.重写中子类的方法的访问权限不能低于父类的方法访问权限。
4.private 修饰的方法,final 修饰的方法都不能被重写。
5.重写的方法返回值类型不一定和父类的方法相同(但是建议最好写成相同, 特殊情况除外)。
方法权限示例: 将子类的 eat 改成 private:
public class Animal {
public void eat(String food) {
...
}
}
public class Bird extends Animal {
private void eat(String food) {
...
}
}
另外, 针对重写的方法, 可以使用 @Override 注解来显式指定:
有了这个注解能帮我们进行一些合法性校验. 例如不小心将方法名字拼写错了 (比如写成 aet), 那么此时编译器就会发现父类中没有 aet 方法, 就会编译报错, 提示无法构成重写。
例子:
public class Bird extends Animal {
@Override
private void eat(String food) {
...
}
}
动态绑定和方法重写:
事实上, 方法重写是 Java 语法层次上的规则, 而动态绑定是方法重写这个语法规则的底层实现. 两者本质上描述的是相同的事情, 只是侧重点不同。
3.向下转型
有向上转型,那么对于这里我们还有向下转型,向上转型是子类对象转成父类对象,向下转型就是父类对象转成子类对象。相比于向上转型来说,向下转型没那么常见。
public static void main(String[] args) {
Animal animal2 = new Bird("www",29,"我会飞啊");
Bird bird = (Bird)animal2;
bird.fly();
}
编译过程中,animal 的类型是 Animal,此时编译器只知道这个类中有一个 eat 方法,没有 fly 方法。虽然 animal 实际引用的是一个 Bird 对象,但是编译器是以 animal 的类型来查看有哪些方法的。
但是!!!
我们类似于强制类型转换可以实现向下转型,但是向下转型是很不安全的,我们虽然可以让animal向下转型然后调用子类方法,但是所有的动物都是鸟吗,所有的动物都会飞吗,显然不是,这时候就会有一种错误的代码出现:
public static void main(String[] args) {
Animal animal2 = new Dog("www",19);
Bird bird = (Bird)animal2;
bird.fly();
}
所以, 为了让向下转型更安全, 我们可以先判定一下看看 animal 本质上是不是一个 Bird 实例, 再来转换:
instanceof 可以判定一个引用是否是某个类的实例。如果是,则返回 true。这时再进行向下转型就比较安全了。
public static void main(String[] args) {
Animal animal = new Dog("小狗",19);
if (animal instanceof Bird) {
Bird bird = (Bird)animal;
bird.fly();
}
}
4.理解多态
有了上面的向上转型, 动态绑定, 方法重写,向下转型之后, 我们就可以使用 多态(polypeptide) 的形式来设计程序了,我们可以写一些只关注父类的代码, 就能够同时兼容各种子类的情况:
代码示例: 打印多种形状
当我们想打印多种形状的时候,我们可以写一个父类是打印形状的,但是父类中的方法并不重要,因为我们的多个形状以多个子类去写,会重写方法,这就展示出多态的概念了。
class Shape {
public void draw() {
System.out.println("Shape::draw()");
}
}
子类继承父类的draw方法并进行重写,在不同的子类中实现由重写的方法不同,打印的形状不同。
class Rect extends Shape{
@Override
public void draw() {
System.out.println("?");
}
}
class Flower extends Shape {
@Override
public void draw() {
System.out.println("?");
}
}
class Triangle extends Shape{
@Override
public void draw() {
System.out.println("△");
}
}
class Cycle extends Shape {
@Override
public void draw() {
System.out.println("●");
}
}
在这个代码中, 上方的代码是 类的实现者 编写的, 下方的代码是 类的调用者 编写的。
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
什么叫 “圈复杂度” ?
圈复杂度是一种描述一段代码复杂程度的方式。一段代码如果平铺直叙, 那么就比较简单容易理解。而如果有很多的条件分支或者循环语句,就认为理解起来更复杂。因此我们可以简单粗暴的计算一段代码中条件语句和循环语句出现的个数,这个个数就称为 “圈复杂度”。如果一个方法的圈复杂度太高, 就需要考虑重构。不同公司对于代码的圈复杂度的规范不一样. 一般不会超过 10。
例如我们上面需要打印的不是一个形状了,而是多个形状。如果不基于多态,就需要大量的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[] 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 进行一定的修改,改动成本更高。
这就是多态的优势。
三.抽象类
在刚才的打印图形例子中,我们发现,父类 Shape 中的 draw 方法好像并没有什么实际工作,主要的绘制图形都是由Shape 的各种子类的 draw 方法来完成的. 像这种没有实际工作的方法,我们可以把它设计成一个 抽象方法(abstract method)。
包含抽象方法的类我们称为 抽象类(abstract class)。
比如说,在刚刚的Shape类前面,我们加上abstract 修饰,他就是抽象类,抽象类里面包含着抽象方法。抽象方法就是一个没有被具体实现的方法,被abstract 修饰。
abstract class Shape {
abstract public void draw();
}
那么抽象类有什么想要注意的呢?
1.抽象类不可以被实例化。(不可以被new)
名字就叫做抽象类,抽象的东西怎么可以被实例化呢。所以,当我们去实例化抽象类的时候,他就会报错了。
比如:
abstract class Shape {
public int a;
abstract public void draw();
}
public class TestDemo {
public static void main(String[] args) {
Shape shape = new Shape();
}
}
2.因为不能被实例化,所以抽象类其实只能被继承。且抽象类中,也可以包含和普通类一样的成员和方法。
不能实例化,所以抽象类是只能被继承的一个类。但是抽象类中,也可以包含和普通类一样的成员和方法,比如在里面写一些变量,写一些方法,然后用于继承。
abstract class Shape {
public int a;
public void func(){
System.out.println("测试来了!");
}
abstract public void draw();
}
class A extends Shape{
@Override
public void draw() {
System.out.println("?");
}
}
3.抽象类最大的作用,就是被继承。一个普通类,继承一个抽象类,那么普通类中要重写抽象类中所有的抽象方法。
就好像我们上面的代码一样,我们在用普通类继承抽象类的时候,需要将抽象类中的抽象方法重写,而且是全部抽象方法都要重写。
abstract class Shape {
public int a;
public void func(){
System.out.println("测试来了!");
}
abstract public void draw();
}
class A extends Shape{
@Override
public void draw() {
System.out.println("?");
}
}
4.一个抽象类x,如果继承了一个抽象类y,那么这个抽象类x可以不实现抽象父类y中的抽象方法。
到这里,我们找到继承抽象类的时候,要么我们用的是抽象类去继承抽象类,要么我们就去重写抽象方法。其实IDEA中也有提示,当我们屏蔽重写抽象方法的时候:
例子:
abstract class Shape {
public int a;
public void func(){
System.out.println("测试来了!");
}
abstract public void draw();
}
class A extends Shape{
@Override
public void draw() {
System.out.println("?");
}
}
abstract class B extends Shape{
}
5.结合第4点,当A类再一次被普通类继承后,那么A和B这两个抽象类当中的抽象方法,必须被重写。
什么意思呢,其实简单来说就是假设有一个抽象父类A,一个抽象子类B,一个普通类C,那么B继承A的时候,在第4点我们知道,可以不重写抽象方法,然后当普通类C想去继承抽象类B的时候,还是得重写抽象方法,也就是“父债子还”。
代码:
abstract class Shape {
public int a;
public void func(){
System.out.println("测试来了!");
}
abstract public void draw();
}
abstract class A extends Shape{
}
class B extends A{
@Override
public void draw() {
System.out.println("?");
}
}
6.抽象类不能被final修饰
这一点也很好理解,我们刚刚已经说明了抽象类其实最大的用处就是去被继承,那你现在还用一个final封我后路,我不是啥用没用了?
所以,abstract和final相当于死对头,不能一起使用。像下面就报错为:修饰符的非法组合:“抽象”和“最终”。
这就是抽象类的一些需要注意的点。
抽象类的意义:
有些同学可能会说了, 普通的类也可以被继承呀, 普通的方法也可以被重写呀, 为啥非得用抽象类和抽象方法呢?
确实如此,但是使用抽象类相当于多了一重编译器的校验!
使用抽象类的场景就如上面的代码, 实际工作不应该由父类完成,而应由子类完成。那么此时如果不小心误用成父类了,使用普通类编译器是不会报错的。但是父类是抽象类就会在实例化的时候提示错误,让我们尽早发现问题。
这就是本篇Java中的面向对象编程1的全部内容啦,关于面向对象的学习,学下一篇就是接口了!欢迎关注。一起学习,共同努力!也可以期待这个系列接下来的博客噢。
链接:都在这里! Java SE 带你从零到一系列
还有一件事:
|