一、继承简要介绍
1、继承是什么
在Java中,继承是一种面向对象编程的重要特性,它允许一个类(子类或派生类)继承另一个类(父类或基类)的属性和方法。继承的目的是实现代码的重用和设计的层次化。
子类通常被称为派生类(Derived Class),而父类则被称为超类(Super Class)或基类(Base Class)。
2、继承语法
使用关键字 extends
来实现继承。子类可以继承父类的非私有成员(属性和方法)。、
class ChildClass extends ParentClass {// 子类可以添加自己的属性和方法,也可以重写父类的方法
}
3、为什么需要继承
我们可以先创建两个类,一个猫类,一个狗类:
class Cat {public String name;public int age;public double weight;public void meow() {System.out.println("Meow~");}}class Dog {public String name;public int age;public double weight;public void barking() {System.out.println("Woof~");}}
可以发现这两个类中除了两个方法不同,其他的属性都是相同的,这样就造成了大量重复代码。继承就是用来解决这个问题的。
下面我们将猫类和狗类都继承动物类:
class Animal {public String name;public int age;public double weight;public void showName() {System.out.println(name);}}class Cat extends Animal {public void meow() {System.out.println("Meow~");}
}class Dog extends Animal {public void barking() {System.out.println("Woof~");}
}
对于 Animal 的两个子类 Cat 和 Dog 都继承了父类的属性和方法,它们也可以有各自的特有的属性和方法。
4、继承的特点
-
子类继承父类的属性和方法: 在Java中,子类可以继承父类中所有的属性和方法。但私有属性和方法只能在父类内部访问,子类无法直接访问。如果需要,可以通过公共方法间接访问父类的私有成员。
-
子类的扩展: 子类不仅可以继承父类的成员,还可以定义自己的属性和方法。这允许子类在保持父类功能的基础上进行扩展,添加新的功能或修改已有功能的行为。
-
方法重写(Override): 子类可以重写(override)从父类继承的方法,以提供特定于子类的实现。重写的方法必须具有相同的签名(方法名、参数列表和返回类型),并且通常遵循里氏替换原则,即子类对象应该能够替换其父类对象而不影响程序的正确性。
-
单继承与多重继承: Java支持单继承,即一个类只能直接继承自一个父类。这与C++不同,C++支持多重继承,即一个类可以继承自多个直接父类。然而,Java通过接口(interface)实现了多重继承的某些方面,一个类可以实现多个接口。
-
耦合性: 继承确实可以增加类之间的耦合性,因为子类依赖于父类的结构和行为。这可能导致代码的灵活性和可维护性降低。为了减少这种耦合,设计时应该遵循良好的面向对象设计原则,如使用组合优于继承,以及遵循依赖倒置原则等。
5、单继承和多重继承
1)单继承
单继承是指一个类只能直接继承自另一个类。在Java中,这是支持的。
2)多层继承
多层继承是指一个类继承自另一个类,而后者又继承自另一个类,形成一个继承链。在Java中,这也是支持的。
3)多重继承
多重继承是指一个类可以同时继承自多个类。在Java中,类不支持多重继承,即一个类不能直接继承自两个或两个以上的类。
这样写就会报错:
这是为了避免潜在的复杂性和歧义,例如“钻石问题”(当两个父类有相同的方法时,子类应该继承哪一个?)。
然而,Java通过接口(Interface)支持了多重继承的概念。一个类可以实现多个接口,从而获得多重继承的某些好处,同时避免了多重继承的复杂性。
二、继承细节
1、子类继承父类所有属性和方法
子类继承父类的所有属性和方法,但是子类不能直接访问父类的私有属性和方法。
我们可以通过调试来观察,确定子类确实继承了父类的所有属性:
package com.pack1;public class Test {public static void main(String[] args) {Sub sub = new Sub();}
}class Base {public int a;protected int b;int c;private int d;}class Sub extends Base {}
这个例子中,父类 Base 有四种访问修饰符修饰的属性,Sub 子类继承自 Base 父类,然后我们在 main 函数中创建一个 Sub 类的对象 sub,然后进行调试,当我们调试到对象 sub 创建成功后,对 sub 对象进行监视,可以看到以下列表:
可以发现这里 Sub 这个子类的对象 sub 是有父类 Base 的四个属性的,即使 d 是 private 修饰的变量,这个子类对象也是继承了的,只不过这个子类对象不能直接访问这个 private 修饰的属性,需要通过公共的方法进行访问。
对于子类不能直接访问父类的 private 修饰的属性,这一点我们在之前的文章Java——访问修饰符中提到过,我们说到 private 修饰的属性和方法只能在同一个类中访问,是不能在其他类中直接访问的。
这里谈到的是在同一个包中的情况,因为在不同包中,父类中默认的访问修饰符修饰的属性和方法也是不能在子类中被访问的。
2、子类构造器必须调用父类构造器
当创建一个子类的对象时,子类的构造器会隐式或显式地调用父类的构造器来完成父类的初始化。这是Java继承机制的一部分,确保父类的状态在子类对象创建时得到正确初始化。
如果子类的构造器没有显式调用父类的构造器,Java编译器会自动插入对父类默认构造器(无参构造器)的调用,也就是 super(); 这个语句。如果父类没有定义默认构造器(无参构造器),或者你想调用父类的某个特定构造器,你需要在子类构造器中显式地使用super()
关键字来指定调用父类的某个构造器。
下面给出例子:
class Base {public int data;public Base() {System.out.println("父类构造器被调用");}
}class Sub extends Base {public Sub() {System.out.println("子类构造器被调用");}
}
这里我们给父类写了一个无参构造器,子类也写了一个无参构造器,但是子类中没有显式调用父类的构造器,这样我们创建一个子类对象
public class Test {public static void main(String[] args) {Sub sub = new Sub();}
}
运行后得到的结果为:
可以发现子类的构造器中虽然没有显式的调用父类的构造器,但是 Java 编译器自动插入了 super() ,以调用我们写的父类的无参构造器,这样在子类构造器被调用时,父类构造器也会被调用,以确保父类的状态在子类对象创建时得到正确初始化。
3、子类构造器默认调用父类无参构造器
当创建子类对象时,不管使用子类的那个构造器,默认情况下总会去调用父类的无参构造器,如果父类没有提供无参构造器,则必须在子类构造器中使用 super() 指定使用父类的那个构造器,否则就会编译不通过。
package com.pack1;public class Test {public static void main(String[] args) {System.out.println("sub1使用子类构造器Sub()创建");Sub sub1 = new Sub();System.out.println("sub2使用子类构造器Sub(double bar)创建");Sub sub2 = new Sub(2.2);}
}class Base {public int data;public Base() {System.out.println("父类构造器Base()被调用");}public Base(int data) {this.data = data;System.out.println("父类构造器Base(int data)被调用");}
}class Sub extends Base {public double bar;public Sub() {System.out.println("子类构造器Sub()被调用");}public Sub(double bar) {this.bar = bar;System.out.println("子类构造器Sub(double bar)被调用");}
}
运行结果:
可以发现我们的子类中的两个构造器都没有显式使用 super() 调用父类的构造器,虽然父类中有两个构造器,但是子类中的两个构造器都是默认调用了父类的无参构造器(默认构造器)。
如果我们将父类中的无参构造器去掉,这样运行就会报错:
package com.pack1;public class Test {public static void main(String[] args) {System.out.println("sub1使用子类构造器Sub()创建");Sub sub1 = new Sub();System.out.println("sub2使用子类构造器Sub(double bar)创建");Sub sub2 = new Sub(2.2);}
}class Base {public int data;/*public Base() {System.out.println("父类构造器Base()被调用");}*/public Base(int data) {this.data = data;System.out.println("父类构造器Base(int data)被调用");}
}class Sub extends Base {public double bar;public Sub() {System.out.println("子类构造器Sub()被调用");}public Sub(double bar) {this.bar = bar;System.out.println("子类构造器Sub(double bar)被调用");}
}
运行结果:
这是因为在子类构造器中如果没有显式使用 super() 指定调用父类的构造器,则子类构造器的第一行默认有一句话:
super();
也就是默认调用父类的无参构造器,但是如果父类没有无参构造器的话,这一句就是会报错的。
如果父类类没有无参构造器或者我们想在子类中调用指定的父类构造器,就可以使用 super() 来指定调用父类的构造器:
package com.pack1;public class Test {public static void main(String[] args) {Sub sub = new Sub(1);Sub sub1 = new Sub(1,2.2);}
}class Base {public int data;/*public Base() {System.out.println("父类构造器Base()被调用");}*/public Base(int data) {this.data = data;System.out.println("父类构造器Base(int data)被调用");}
}class Sub extends Base {public double bar;public Sub(int data) {super(data);System.out.println("子类构造器Sub(int data)被调用");}public Sub(int data, double bar) {super(data);this.bar = bar;System.out.println("子类构造器Sub(int data, double bar)被调用");}
}
运行结果:
4、所有类都是 Object 类的子类
在Java中,所有的类都直接或间接地继承自java.lang.Object
类。这里我们以 java.util.Arrays 类为例,可以发现 Arrays 这个类也是继承自 Object 类:
如果你在定义一个类时没有使用extends
关键字来指定它继承自哪个类,那么这个类将默认继承自Object
类。
也就是说如果我们定义了一个类:
public class MyClass {// 类的内容
}
就相当于:
public class MyClass extends Object {// 类的内容
}
一般情况下我们都是用上面的第一种的写法,不用显式指定某个类继承自 Object 类。
所有的类都直接或间接地继承自java.lang.Object
类,这意味着Object
类中定义的方法,如toString()
, equals(Object obj)
, hashCode()
, getClass()
, clone()
, finalize()
等,都可以被任何Java类所使用,因为它们是继承自Object
类的。
5、子类构造器对父类构造器的调用会追溯到 Object 类的构造器
对于 Sub 类是继承自 Base 类,Base 类是继承自 Object 类,所以当我们调用 Sub 类的构造器,必然会调用 Base 类的构造器,调用 Base 类的构造器必然会调用 Object 类的构造器。这种调用关系会一直追溯到 Object 类的构造器。
三、继承本质分析
1、创建一个子类对象在内存中的分布
用以下代码为例,分析创建了一个 C 类对象后,这个对象在内存中的分布(可以发现这几个类是在同一个包中的,访问修饰符是默认,在同一个包中都能被访问):
package com.pack1;public class Test {public static void main(String[] args) {C c = new C();}
}class A {String name = "AAA";double weight;
}class B extends A {String name = "BBB";int age;
}class C extends B {String name = "CCC";
}
可以看到继承关系为:
下面我们详细解释在内存中的布局。
首先在对象 c 还没有创建时,首先要加载类信息,因为是创建 C 类的对象,加载某个类信息前会查找这个类的父类,直到找到这个类的顶级父类 Object 才停止,然后从 Object 依次加载父类信息直到加载到本类信息。这个很像递归加载类信息。
这样说,也就是依次加载 Object 类信息,A 类信息,B 类信息,最后才是 C 类信息。所以在一个类信息加载完成后,它的父类信息也加载完成了:
然后就会对这个对象 c 进行创建:
对于对象 c 会拥有父类所有属性,这里的属性的访问修饰符都是默认,就算是父类中 private 修饰的属性也是会被子类继承的,只不过子类没法直接访问到 private 修饰的父类属性,上面我们已经对这个讲解过了。对象 c 有父类所有属性,而且它们在内存中是连续的。
在上图,可以看到对象 c 的属性被分为了三块,分别是 A 类属性,B 类属性,C 类属性。A 类属性中有父类 A 类中的所有属性,B 类属性中有父类 B 类中的所有属性,C 类属性中有这个子类 C 中所有的属性,这样对象 c 是拥有所有父类属性以及本类的所有属性的。
A 类部分:
name
= "AAA",weight
= 0.0 (默认值)
B 类部分:
name
= "BBB"(B 类重新定义了 name,隐藏了 A
类中的 name
),age
= 0(默认值)
C 类部分:
name
= "CCC"(C 类重新定义了 name,隐藏了 B
类中的 name
)
可以看到三个类中都有 name 这个变量,尽管 C
类中存在三个 name
属性,但每个类的 name
属性是独立的。如果在 C
类中访问 name
,将优先访问最近的 name
属性,即 C
类中的 name
。
我么可以对上面的代码进行调试,
可以发现:这三个 name 变量是互相独立的。
2、对属性的访问规则
那么从这个案例中,我们就要考虑一下,如果我们想要访问对应的 name,那么访问规则又是什么呢?
1)直接访问
(1)查看子类是否有该属性,如果子类有该属性且可以访问,则使用子类的这个属性。
(2)如果子类没有该属性,就看父类有没有该属性,如果父类有该属性且可以访问,则使用父类的这个属性。
(3)如果当前父类没有该属性,则接着向上面的父类按规则(2)寻找,直到 Object 类再停止。如果最终没有找到该属性,则报错。
如果一旦找到了这个属性,但这个属性无法访问,就不再向上寻找,会报错。例如:
package com.pack1;public class Test {public static void main(String[] args) {C c = new C();System.out.println(c.age);} }class A {String name = "AAA";double weight;int age; }class B extends A {String name = "BBB";private int age; }class C extends B {String name; }
就像这里我们通过子类对象访问 age 属性,但是子类 C 类中没有 age 属性,向上面的父类寻找,在父类 B 类中寻找到 private 修饰的 age 属性,这里显然无法访问,虽然上面的父类的父类即 A 类中有可访问的 age 属性,也不会向上找了,在 B 类这里遇到私有的 age 属性就会报错。
例子:
package com.pack1;public class Test {public static void main(String[] args) {C c = new C();System.out.println(c.name);}
}class A {String name = "AAA";double weight;
}class B extends A {String name = "BBB";int age;
}class C extends B {String name = "CCC";}
运行结果:
这个例子中,我们在 main 方法中使用对象 c 对属性 name 进行访问,在子类 C 中就能直接找到 name 属性,并且可以直接访问,则使用子类 C 的 name 属性,子类 C 的属性 name 的默认值为 "CCC",所以这里的运行结果是上面那样。
2)强制访问
通过强制类型转换访问 ((B)c).name
:
- 访问
B
类中的name
。 - 输出 "BBB"。
通过强制类型转换访问 ((A)c).name
:
- 访问
A
类中的name
。 - 输出 "AAA"。
package com.pack1;public class Test {public static void main(String[] args) {C c = new C();System.out.println("强制类型转换访问B类中的name属性:" + ((B)c).name);System.out.println("强制类型转换访问A类中的name属性:" + ((A)c).name);}
}class A {String name = "AAA";double weight;
}class B extends A {String name = "BBB";int age;
}class C extends B {String name = "CCC";}
运行结果:
这里的强制类型转换我们可以类比 C 语言中的将 int* 类型变量强制转换为 char* 从而可以只访问到 int* 指针变量指向的区域的第一个字节。
对于 main 方法中的 c 变量的本质是对象引用变量:
C c = new C();
它的本质也是类似于指针的,它指向的是一个在堆区的 C 类对象,因为 C 类对象应当拥有父类所有属性和本类所有属性,所以 C 类对象的结构应当是下面这样:
所以这个对象引用变量 c 是可以访问到 A 类、B 类和 C 类三个类(这里其实还有 Object 类,这里暂时忽略)的所有属性的,所以通过 c.name 访问 name 属性,则遵循的规则就是上面的直接访问的规则,从子类 C 中依次向上寻找这个属性。
对于 B 类对象,应当有它的父类所有属性和本类属性,所以 B 类对象的在堆区的结构应该是这样:
所以对于对象 c 强制转换成 B 类对象的引用变量后,(B)c 就只能访问到 A 类和 B 类两个类(这里其实还有 Object 类,这里暂时忽略)的所有属性,这样就可以实现访问到 B 类的 name 属性了,因为在这里使用:
((B)c).name
就只能从 B 类开始向上寻找 name 属性了,这里 B 类中就有可访问的 name 属性,所以就直接访问 B 类的 name 属性了,这里就是强制类型转换实现强制访问特定的类的属性的原理。
对于 A 类对象,应当有它的父类所有属性和本类属性,所以 A 类对象的在堆区的结构应该是这样:
所以对于对象 c 强制转换成 A 类对象的引用变量后,(A)c 就只能访问到 A 类一个类(这里其实还有 Object 类,这里暂时忽略)的所有属性,这样就可以实现访问到 A 类的 name 属性了,因为在这里使用:
((A)c).name
就只能从 A 类开始向上寻找 name 属性了,这里 A 类中就有可访问的 name 属性,所以就直接访问 A 类的 name 属性了。
可以看到这里是使用 c 对象的引用变量对对象 c 的多个不同类中的 name 属性进行访问,那如果我们在子类中想要访问父类中 name 属性,当然我们可以使用 super,那如果要访问父类的父类的 name 属性呢,就可以使用强制转换 this 的方法:
package com.pack1;public class Test {public static void main(String[] args) {C c = new C();c.printName();}
}class A {String name = "AAA";double weight;
}class B extends A {String name = "BBB";int age;
}class C extends B {String name = "CCC";public void printName() {System.out.println(name);//这里访问的是最近的name属性,C类的name属性System.out.println(((B)this).name);//这里访问的是B类的name属性System.out.println(((A)this).name);//这里访问的是A类的name属性}
}
运行结果:
以上谈论的基础是 C 类对这些属性有访问权限,如果父类的属性是 private 修饰的,使用强制类型转化也是访问不到的,只能通过公共方法获取。如果访问修饰符是默认,访问的地方与这个属性所在的类不在同一个包中也是无法访问的,也只能通过公共方法获取。
四、super
super 的语法与 this 很类似。
1、super 访问父类属性或方法
在Java中,super
关键字是一个引用变量,用于指向当前类的父类。super
可以用来访问父类的成员,包括属性和方法,这在子类中非常有用,尤其是当子类和父类中有同名的成员时。
1)super 访问父类属性
使用 super 关键字可以在同一个包中访问父类的非私有属性,在不同包中只能访问父类的 public 和 protected 修饰的属性。
package com.pack1;public class Test {public static void main(String[] args) {Sub sub = new Sub();sub.printName();}
}class Base {String name = "Base";
}class Sub extends Base {String name = "Sub";public void printName() {System.out.println("父类name:" + super.name);System.out.println("子类name:" + name);}
}
可以发现这里子类和父类都有 name 属性,我们可以使用 super.name 访问父类的 name 属性。
运行结果:
2)super 访问父类方法
使用 super 关键字可以在同一个包中访问父类的非私有方法,在不同包中只能访问父类的 public 和 protected 修饰的方法。
package com.pack1;public class Test {public static void main(String[] args) {Sub sub = new Sub();sub.greeting();}
}class Base {public void sayHi() {System.out.println("hi~");}
}class Sub extends Base {public void greeting() {super.sayHi();}
}
运行结果:
这里我们使用 super 关键字对父类方法进行调用。
2、super() 调用父类构造器
上面我们对此已经介绍的很详细了,这里就简单介绍一下。
子类中应当调用父类构造器以初始化继承至父类的属性,使用 super() 就是为了指定调用父类的构造器。如果子类中没有显式调用父类构造器,Java 编译器会自动在子类构造器的第一句加上:
super();
以默认调用父类的无参构造器,但是这样需要确保父类有无参构造器。如果父类没有无参构造器或者我们想自己指定调用特定的父类构造器,就需要使用 super() 传入特定的参数来调用特定的父类构造器。
super() 这个语句必须是子类构造器中的第一行非注释语句。
五、方法重写 / 覆盖(Override)
1、什么是方法重写
重写(Override)是指子类重新定义父类中已有的方法。重写是实现多态的一种方式,允许子类提供一个特定于自己的方法实现。
1. 基本概念
- 重写方法:子类中定义一个与父类中方法签名(方法名、参数列表)相同的方法。
- 目的:子类可以根据自己的需求重新实现父类的方法。
2. 重写的规则
- 方法签名必须相同:子类方法的名称和参数列表必须与父类方法完全相同。
- 返回类型:子类方法的返回类型可以与父类的返回类型一样,也可以是父类方法返回类型的子类型(例如父类返回 Object 类型,子类可以返回 String 类型)。
- 访问权限:子类方法的访问权限不能比父类方法更严格。例如,如果父类方法是
public
,子类方法也必须是public
。 - 异常:子类方法抛出的异常不能比父类方法抛出的异常更宽泛。子类方法可以不抛出异常,或者抛出父类方法抛出异常的子类异常。
3. 使用@Override
注解
- 注解:在子类方法上使用
@Override
注解可以明确表示这是一个重写方法。这有助于编译器检查方法签名是否正确,避免拼写错误等问题。
2、方法重写的示例
package com.pack1;public class Test {public static void main(String[] args) {Dog dog = new Dog();dog.makeSound();}
}class Animal {public void makeSound() {System.out.println("Animal sound...");}
}class Dog extends Animal {public void makeSound() {System.out.println("woof...");}
}
运行结果:
这里的 Dog 类中的 makeSound 方法就重写了父类 Animal 中的 makeSound 方法。
六、补充
1、this() 和 super() 的使用都必须是第一行
上面我们提到了 super() 必须是子类构造器的第一句非注释语句,在之前的文章中,我们提到在同一个类中调用其他构造器要使用 this() 语句,this() 语句也应该是构造器的第一句非注释语句。
但是如果我们在一个子类中的构造器中使用 this() 调用另一个构造器,从而使没有地方供 super() 调用,致使父类状态无法正常初始化呢。就像下面这样:
class Base {public Base() {}
}class Sub extends Base {public Sub() {}public Sub(int a) {this();}
}
当我们调用 Sub 的一个参数的构造器时,父类构造器是否是没有被调用呢?
实际上父类构造器是被调用了的,因为我们使用 this() 调用 Sub 的无参构造器时,Sub 的无参构造器中会被编译器默认加上 super(),所以这样就会间接调用父类构造器,完成父类状态初始化。
class Base {public Base() {}
}class Sub extends Base {public Sub() {//编译器默认加上 super();}public Sub(int a) {this();}
}
那有没有可能 Sub 子类中的两个构造器中都使用了 this() 语句呢,就像下面这样:
class Base {public Base() {}
}class Sub extends Base {public Sub() {this(2);}public Sub(int a) {this();}
}
这样就没有地方加 super() 来调用父类构造器了,这样就不能正常初始化父类的状态了。
实际上这里也是不可以的,因为构造器不能递归调用,这是错误的。
这里会报错:
上面说构造器不能递归调用,所以当我们只有一个构造器时,也不能使用 this() 语句来调用这个构造器。
class Base {public Base() {}
}class Sub extends Base {public Sub() {this();}
}
这里也会报错:
由上可知,当我们使用 this() 调用其他构造器时,必定有两个以上构造器,而且至少有一个构造器是没有使用 this() 语句的,这个构造器中就可以有 super() 来调用父类构造器(如果没有显式调用,编译器会自动加上 super(); 这个语句,用来调用父类无参构造器)。
其他构造器中最终就必定可以追溯到这个没有使用 this() 语句的构造器,所以这样所有的构造器都直接或间接的调用了父类构造器。
2、子类中对父类对象的访问补充
当子类有和父类属性和方法重名的属性或方法时,为了访问父类的成员,必须通过 super 关键字;如果子类中没有与父类重名的属性或方法,则使用 super、this或直接访问访问父类的属性或方法是一样的效果。
package com.pack1;public class Test {public static void main(String[] args) {Sub sub = new Sub();sub.show();}
}class Base {public String name = "Base";
}class Sub extends Base {public void show() {System.out.println(name);System.out.println(this.name);System.out.println(super.name);}
}
运行结果:
这里子类中使用了三种方法访问 name 这个属性,这个属性是从父类 Base 中继承的,子类 Sub 中没有与 name 重名的属性,所以第一种方法使用直接访问就是根据上面我们提到的“对属性的访问规则”中的直接访问的规则来从子类中寻找,因为子类 Sub 中没有 name 这个属性,然后向上面的父类中寻找,在父类中寻找到,而且可以访问,所以就是使用父类中的这个 name 属性;第二种方法是使用 this 访问,这样就是对 Sub 类的对象的属性进行访问,这样的访问规则与上面的直接访问的规则是一致的,最终也是在父类中找到 name 这个属性,然后使用这个属性;第三种方法是使用 super 访问,因为 name 这个属性本就是从父类中继承的,使用 super 访问是完全可以的,对于 super 的查找顺序是跳过本类直接从父类开始查找,如果父类查找不到,接着向上面找父类的父类,直到 Object 类。