Java面向对象系列[v1.0.0][继承和组合详解]

继承是面向对象的三大特征之一,也是实现软件复用的重要手段,Java的继承具有单继承的特点即每个子类只有一个父类

继承的特点和用法

Java的继承通过extends关键字来实现,父类和子类是一种【一般-特殊】的关系,例如水果和苹果的关系,苹果继承了水果,苹果是水果的子类,换种说法苹果是一中特殊的水果。
Java里子类继承父类的语法如下:

修饰符 calss SubClass extends SuperClass
{
    //类定义部分
}
  • 从语法上看很有意思,Java使用extends关键字来表明SubClass(子类)继承SuperClass(父类/基类/超类),而extends的意思是扩展而非继承,但扩展更能表达其真实意义,即子类是对父类的扩展,子类是一种特殊的父类
  • 国内将extends翻译为继承,其理由是子类虽然扩展了父类,同时也获得了父类的全部成员变量和方法,在汉语中更类似晚辈继承长辈财富的意思
  • 必须指出的是子类不能获取父类的构造器
public class Fruit
{
    public double weight;
    public void info()
    {
        System.out.println("我是一个水果!重" + weight + "g!");
    }
}
public class Apple extends Fruit
{
    public static void main(String[] args)
    {
        // 创建Apple对象
        var a = new Apple();
        // Apple对象本身没有weight成员变量
        // 因为Apple的父类有weight成员变量,也可以访问Apple对象的weight成员变量
        a.weight = 56;
        // 调用Apple对象的info()方法
        a.info();
    }
}
  • 子类Apple基本上是一个空类,只有一个main方法,但创建了Apple对象之后,便可以访问该Apple对象的weight实例变量和info()方法,这便是继承的作用
  • Java摒弃了C++的多继承即一个子类可以继承多个父类的特征,也就是说每个类最多只能有一个直接父类,直接父类这是一个比较严谨的说法,实际上Java类可以有无限多个间接父类
class Fruit extends PlantP{...}
class Apple extends Fruit{...}
  • 如果定义一个Java类时并未显示的指定直接父类,则这个类默认扩展java.lang.Object类,因此java.lang.Object是所有类的直接父类或者间接父类,因此所有的Java对象可以调用java.lang.Object类所定义的实例方法
  • 从子类角度来看,子类扩展了父类,从父类角度来说,父类派生了子类,这实际是同一个动作

重写父类方法

通常情况下,子类扩展父类,子类就成了一个特殊的父类,大部分时候子类总是以父类为基础,额外再增加新的成员变量和方法,但这并不能完全满足我们,例如鸟类都包含了飞的方法,其中鸵鸟是一种特殊的鸟类,因此鸵鸟应该是鸟的子类,因此它继承了了鸟类飞的方法,但这个方法明显不适合鸵鸟,因此鸵鸟类就需要重写鸟类的方法

public class Bird
{
    // Bird类的fly()方法
    public void fly()
    {
        System.out.println("我在天空里自由自在地飞翔...");
    }
}
public class Ostrich extends Bird
{
    // 重写Bird类的fly()方法
    public void fly()
    {
        System.out.println("我只能在地上奔跑...");
    }
    public void callOverridedMethod()
    {
        // 在子类方法中通过super来显式调用父类被覆盖的方法。
        super.fly();
    }

    public static void main(String[] args)
    {
        // 创建Ostrich对象
        var os = new Ostrich();
        // 执行Ostrich对象的fly()方法,将输出"我只能在地上奔跑..."
        os.fly();
    }
}

这种子类包含与父类中同名方法的现象被称为方法重写(Override),也可以成为方法覆盖,方法的重写要遵循“两同两小一大”原则,即方法名相同,形参列表相同;子类方法返回值类型应比父类方法返回值类型更小或者相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或者相等;子类方法的访问权限应比父类方法的访问权限更大或相等。
尤其需要指出的是:覆盖方法和被覆盖方法要么都是类方法,要么都是实例方法,不能一个是类方法一个是实例方法,错误的例子:

class BaseClass
{
    public static void test(){...}
}
class SubClass extends BaseClass
{
    public void test(){...}
}
  • 当子类覆盖了父类方法后,子类的对象将无法访问父类中被覆盖的方法,但可以在子类方法中调用父类中被覆盖的方法
  • 如果需要在子类方法中调用父类中被覆盖的方法,则可以使用super(被覆盖的是实例方法)或者父类类名(被覆盖的是类方法)作为调用者来调用父类中被覆盖的方法
  • 如果父类方法具有private访问权限,则该方法对其子类是隐藏的,子类无法访问该方法也就无法重写它,即便子类中定义了个一个与父类private方法相同的方法名、形参列表、相同的返回值类型的方法,依然不是重写,只是在子类中重新定义了一个新方法而已
class BaseClass
{
    //test()方法是private访问权限
    private void test(){...}
}
class SubClass extends BaseClass
{
    //此处并不是重写父类test方法,所以可以增加一个static关键字
    public static void test(){...}
}

overload和override比较

将这两个比较虽然没有太多意义,但是仍需要说明一下用于增强概念,重载主要发生在同一个类的多个同名方法之间,除了要求方法名相同外并没有太大的相似之处,当然父类方法和子类方法之间也可能发生重载,子类获得父类方法,如果这时候子类定义一个与父类方法名相同但形参列表不同的方法,就形成了子类方法重载父类方法的情况。

super关键字的用法

如果需要在子类方法中调用父类被覆盖的实例方法,则可使用super限定来调用父类被覆盖的实例方法

public void callOverridedMethod()
{
    //在子类方法中通过super显示调用父类中被覆盖的实例方法
    super.fly();
}

如此便实现了子类调用父类中被自己重写的fly()方法,同时自己还重写了该方法,两个方法就都可以使用了

  • super是Java提供的一个关键字,用于限定该对象调用它从父类继承得到的实例变量和方法,正如this不能出现在static修饰的方法中一样,super也不能出现在static修饰的方法中
  • static修饰的方法属于类,该方法的调用者是一个类而不是对象,因此super限定也就失去了意义
  • 如果在构造器中使用super,则super用于限定该构造器初始化的是该对象从父类继承得到的实例变量,而不是该类自己的定义的实例变量
  • 如果子类定义了和父类同名的实例变量,则子类实例变量会隐藏父类实例变量,正常情况下,子类里定义的方法直接访问该实例变量默认会访问到子类中定义的实例变量无法访问到父类中被隐藏的实例变量
  • 在子类定义的实例方法中可以通过super来访问父类中被隐藏的实例变量
class BaseClass
{
    public int a = 5;
}
public class SubClass extends BaseClass
{
    public int a = 7;
    public void accessOwner()
    {
        System.out.println(a);
    }
    public void accessBase()
    {
        // 通过super来限定访问从父类继承得到的a实例变量
        System.out.println(super.a);
    }
    public static void main(String[] args)
    {
        var sc = new SubClass();
        sc.accessOwner(); // 输出7
        sc.accessBase(); // 输出5
    }
}

代码中能够看到,父类和子类都定义了实例变量a,子类中的a会隐藏父类中的a,当为子类创建对象的时候,实际上会为子类分配两块内存,一块用于存储子类中定义的a,一块用于存储从父类中继承得到的a,程序中使用super.a来限定访问从父类得到的a,而不是当前类中ding定义的a。
如果在某个方法中访问成员变量a,但没有显示的指定调用者,则系统查找a的顺序为:

  • 查找该方法中是否有局部变量a
  • 查找当前类中是否包成员变量a
  • 查找a的直接父类中是否包含成员变量a,依次上溯a的所有父类,知道java.lang.Object类,如果最终找不到则出现编译错误

如果被覆盖的是类变量,在子类的方法中则可以通过父类名作为调用者来访问被覆盖的类变量
当系统创建一个Java对象时,如果该Java类有两个父类,父类A定义了2个实例变量,父类B定义了3个实例变量,当前类中定义了2个实例变量,那么这个Java对象将会保存2+3+2个实例变量,系统不仅会为该类中定义的实例变量分配内存,也会为它从父类继承得到的所有实例变量分配内存,即使子类定义了与父类中同名的实例变量。

class Parent
{
    public String tag = "疯狂Java讲义";         // I
}
class Derived extends Parent
{
    // 定义一个私有的tag实例变量来隐藏父类的tag实例变量
    private String tag = "轻量级Java EE企业应用实战";         // II
}
public class HideTest
{
    public static void main(String[] args)
    {
        var d = new Derived();
        // 程序不可访问d的私有变量tag,所以下面语句将引起编译错误
        // System.out.println(d.tag);         // III
        // 将d变量显式地向上转型为Parent后,即可访问tag实例变量
        // 程序将输出:“疯狂Java讲义”
        System.out.println(((Parent) d).tag);         // IV
    }
}

I:为父类Parent定义了一个tag实例变量
II:为子类定义了一个private的tag实例变量,子类中定义的这个实例变量将会隐藏父类中定义的tag实例变量
mian()方法创建了Derived对象,如图所示内存分配:
Java面向对象系列[v1.0.0][继承和组合详解]_第1张图片
III:试图通过d来访问子类的实例变量tag,程序将会提示访问权限不允许
IV:先将d变量强制向上转型为Parent类型,再通过它来访问父类实例变量tag

调用父类构造器

子类不会获得父类的构造器,但子类的构造器里可以调用父类构造器的初始化代码,类似于一个构造器调用另一个重载的构造器。
在一个构造器中调用另一个重载的构造器使用this来完成,在子类构造器中调用父类构造器用super来完成调用。

class Base
{
    public double size;
    public String name;
    public Base(double size, String name)
    {
        this.size = size;
        this.name = name;
    }
}
public class Sub extends Base
{
    public String color;
    public Sub(double size, String name, String color)
    {
        // 通过super调用来调用父类构造器的初始化过程
        super(size, name);
        this.color = color;
    }
    public static void main(String[] args)
    {
        var s = new Sub(5.6, "测试对象", "红色");
        // 输出Sub对象的三个实例变量
        System.out.println(s.size + "--" + s.name
            + "--" + s.color);
    }
}

使用super调用和使用this调用很像,区别在于super调用的是其父类的构造器,this调用的是同一个类中重载的构造器,使用super调用父类的构造器必须出现在子类构造器执行体的第一行,因此this调用和super调用不会同时出现
不管是否使用super调用来执行父类构造器的初始化代码,子类构造器总会调用父类构造器一次,子类构造器调用父类构造器分为如下几种情况:

  • 子类构造器执行体第一行使用super显示的调用父类构造器,系统将根据super调用里传入的实参列表调用父类对应的构造器
  • 子类构造器执行体的第一行代码使用this显示调用本类中重载的构造器,系统将根据this调用里传入的实参列表调用本来中的另一个构造器,执行本类中另一个构造器时即会调用父类构造器
  • 子类构造器执行体中既没有super调用,也没有this调用,系统将会在执行子类构造器之前,隐式的调用父类无参数的构造器

无论上面哪种情况,当调用子类构造器初始化子类对象时,父类构造器总会在子类构造器之前执行,不仅如此,执行父类构造器时,系统会再次上溯执行其父类构造器…依此类推,创建任何java对象,最先执行的总是java.lang.Object类的构造器
Java面向对象系列[v1.0.0][继承和组合详解]_第2张图片

class Creature
{
    public Creature()
    {
        System.out.println("Creature无参数的构造器");
    }
}
class Animal extends Creature
{
    public Animal(String name)
    {
        System.out.println("Animal带一个参数的构造器,"
            + "该动物的name为" + name);
    }
    public Animal(String name, int age)
    {
        // 使用this调用同一个重载的构造器
        this(name);
        System.out.println("Animal带两个参数的构造器,"
            + "其age为" + age);
    }
}
public class Wolf extends Animal
{
    public Wolf()
    {
        // 显式调用父类有两个参数的构造器
        super("灰太狼", 3);
        System.out.println("Wolf无参数的构造器");
    }
    public static void main(String[] args)
    {
        new Wolf();
    }
}

执行结果:

Creature无参数的构造器
Animal带一个参数的构造器,该动物的name为灰太狼
Animal带两个参数的构造器,其age为3
Wolf无参数的构造器
  • 创建任何对象,总是从该类所在继承树最顶层类的构造器开始执行,然后一次向下,最后才执行本类的构造器
  • 如果父类通过this调用了同类中重载的构造器,就会依次执行此父类的多个构造器

继承和组合的关系

继承是实现类复用的重要手段,但继承带来了一个最大的坏处是破坏了封装,相比之下组合也是实现类复用的重要方式,而采用组合方式来实现类复用则能提供更好的封装性

使用继承须知

子类扩展父类时,子类可以从父类继承得到成员变量和方法,如果访问权限允许,子类可以直接访问父类的成员变量和方法,确实带来了相当的便利。
每个类都应该封装它内部信息和实现细节,而只暴露必要的方法给其他类使用,这是封装的规范性,但在继承关系中,子类可以直接访问父类的成员变量(内部信息)和方法,从而造成子类和父类的严重耦合。
从这个角度分析,父类的实现细节对子类不再透明,子类可以访问父类的成员变量和方法,并可以改变父类方法的实现细节(例如通过方法重写的方式),从而导致子类可以修改父类的方法。

Bird b = new Ostrich();
b.fly();

如代码所示,Bird引用变量,实际引用的是Ostrich对象,所以调用b的fly()方法时执行的不再是Bird类提供的fly()方法,而是Ostrich类重写的fly()方法

为了保证父类有良好的封装性,不会被子类随意改变,设计父类通常应该遵循如下规则:

  • 尽量隐藏父类的内部数据,尽量把父类的所有成员变量都设置成private访问类型,不要让子类直接访问父类的成员变量
  • 不要让子类可以随意访问、修改父类的方法。父类中那些仅为辅助其他的工具类方法,应该使用private访问控制符,让子类无法访问该方法;如果父类中的方法需要被外部调用,则必须是public,但又不希望被重写,可以使用final修饰符来修饰;如果希望父类的某个方法被子类重写,但不希望被其他类自由访问,则可以使用protected
class Base
{
    public Base()
    {
        test();
    }
    public void test()           // ①号test()方法
    {
        System.out.println("将被子类重写的方法");
    }
}
public class Sub extends Base
{
    private String name;
    public void test()         // ②号test()方法
    {
        System.out.println("子类重写父类的方法,"
            + "其name字符串长度" + name.length());
    }
    public static void main(String[] args)
    {
        // 下面代码会引发空指针异常
        var s = new Sub();
    }
}
  • 如代码所示,因为子类重写了test方法,父类的构造器Base调用了test方法,这时候调用的不是父类里的test,而是子类里的test,在这个时候调用子类的test,name变量还是null,就会直接引发空指针异常
  • 如果想把某些类设置成最终类,即不能被当成父类,则可以使用final修饰这个类,例如java.lang.String类和java.lang.System类
  • 使用private修饰这个类的所有构造器,从而保证子类无法调用该类的构造器,也就无法继承该类
  • 对于把所有构造器都修饰成private的父类而言,可以另外提供一个静态方法,用于创建该类的实例

何时派生子类

  • 子类需要额外增加属性,而不仅仅是属性值的改变
  • 子类需要增加自己独有的行为方式(包括增加新的方法或重写父类的方法)

使用组合来实现复用

如果需要复用一个类,除了继承,还可以把该类当成另一个类的组合成分,从而允许新类直接复用该类的public方法,无论是继承还是组合都允许在新类(子类)中直接复用旧类的方法。
对于继承而言,子类可以直接获得父类的public方法,程序使用子类的时候,可以直接访问该子类继承来的方法;而组合则是把旧类对象作为新类的成员变量组合进来,用以实现新类的功能,用户看到的是新类的方法,而不能看到被组合对象的方法,通常情况下需要在新类里使用private修饰被组合的旧类对象。

  • 从类复用的角度看,不难发现父类的功能等同于被组合的类,都将自身的方法提供给新类使用
  • 子类和组合关系里的整体类都可以复用原有类的方法,用于实现自身功能
    Java面向对象系列[v1.0.0][继承和组合详解]_第3张图片
class Animal
{
    private void beat()
    {
        System.out.println("心脏跳动...");
    }
    public void breathe()
    {
        beat();
        System.out.println("吸一口气,吐一口气,呼吸中...");
    }
}
// 继承Animal,直接复用父类的breathe()方法
class Bird extends Animal
{
    public void fly()
    {
        System.out.println("我在天空自在的飞翔...");
    }
}
// 继承Animal,直接复用父类的breathe()方法
class Wolf extends Animal
{
    public void run()
    {
        System.out.println("我在陆地上的快速奔跑...");
    }
}
public class InheritTest
{
    public static void main(String[] args)
    {
        var b = new Bird();
        b.breathe();
        b.fly();
        var w = new Wolf();
        w.breathe();
        w.run();
    }
}

Java面向对象系列[v1.0.0][继承和组合详解]_第4张图片

class Animal
{
    private void beat()
    {
        System.out.println("心脏跳动...");
    }
    public void breathe()
    {
        beat();
        System.out.println("吸一口气,吐一口气,呼吸中...");
    }
}
class Bird
{
    // 将原来的父类组合到原来的子类,作为子类的一个组合成分
    private Animal a;
    public Bird(Animal a)
    {
        this.a = a;
    }
    // 重新定义一个自己的breathe()方法
    public void breathe()
    {
        // 直接复用Animal提供的breathe()方法来实现Bird的breathe()方法。
        a.breathe();
    }
    public void fly()
    {
        System.out.println("我在天空自在的飞翔...");
    }
}
class Wolf
{
    // 将原来的父类组合到原来的子类,作为子类的一个组合成分
    private Animal a;
    public Wolf(Animal a)
    {
        this.a = a;
    }
    // 重新定义一个自己的breathe()方法
    public void breathe()
    {
        // 直接复用Animal提供的breathe()方法来实现Wolf的breathe()方法。
        a.breathe();
    }
    public void run()
    {
        System.out.println("我在陆地上的快速奔跑...");
    }
}
public class CompositeTest
{
    public static void main(String[] args)
    {
        // 此时需要显式创建被组合的对象
        var a1 = new Animal();
        var b = new Bird(a1);
        b.breathe();
        b.fly();
        // 此时需要显式创建被组合的对象
        var a2 = new Animal();
        var w = new Wolf(a2);
        w.breathe();
        w.run();
    }
}
  • Wolf对象和Bird对象由Animal对象组合而成,因此在程序中创建Wolf对象和Bird对象之前先创建Animal对象
  • 利用这个Animal对象来创建Wolf对象和Bird对象

区分继承和组合

继承关系中从多个子类里抽象出共有父类的过程和组合关系中从多个整体类里提取被组合类的过程很类似,同时继承关系中从父类派生子类的过程也类似于组合关系中把被组合类组合到整体类的过程。

  • 继承:是对已有的类做一番改造,而获得一个特殊的版本,换句话说就是将一个较为抽象的类改造成能适用于某些特定需求的类,因此Wolf和Animal的关系使用继承更能表达其现实意义,用一个动物来合成一匹狼毫无意义,狼并不是由动物组成的
  • 组合:如果两个类之间有明确的整体和部分的关系,例如Person类需要复用Arm类的方法即Person对象由Arm对象组合而成,这种情况应该采用组合关系来实现复用,把Arm作为Person类的组合成员变量,借助于Arm的方法来实现Person的方法
  • 继承要表达的是(is-a)的关系,而组合表达的是(has-a)的关系

你可能感兴趣的:(Java基础即高端)