【Java面向对象】5-2 继承
§5-2 继承
本节内容将会介绍 OOP 中另一个重要概念——继承、Object
类、初识 native
关键字和泛型,以及 super
关键字。
5-2.1 继承的定义和用法
5-2.1.1 定义和用法
简而言之,继承的本质是对某一批类的抽象,从而实现对世界更好的建模,是一个 "is a" 的关系。它指定了子类如何特化父类的所有特征和行为[^ 注1]。
公共成员继承:
下面我们来定义几个类,并用 extends
关键字实现继承关系:
public class Person {
public void say() {
System.out.println("说了话。");
}
}
public class Teacher extends Person {
//留空
}
然后在 main
函数中尝试实例化对象,有:
可以发现,Teacher
类继承了 Person
类中的 public void say()
方法。
现在,我们在 Person
类中定义更多属性:
public class Person {
private String name;
private int age;
public void say() {
System.out.println("说了话。");
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
这时,尽管 name
, age
具有私有访问权限,但仍能够在子类中通过公共方法读取和修改这些数据。
protected
关键字:
在 5-3.5 中,我们有提及到 protected
访问修饰符,该修饰符可以使得成员可以被所有子类访问,也可以被同一包中的所有类访问。
例如,在上述已经定义好的 Person
类中,我们在 Teacher
类中添加相同方法:
public class Teacher extends Person {
public void setName(String name) {
this.name = name;
}
}
会产生错误:
但是,若将父类中的 private
更改为 protected
,那么就使得成员也可以在子类中被访问。
但由于 Teacher
类继承了 Person
类,因此没有必要在 Teacher
类中添加这样的方法,也没有必要更改父类中数据的访问权限。这里只是为了说明而做如上处理。
5-2.1.2 有关继承的注意事项
-
在 Java 中,用
extends
关键字来实现子类对父类的继承。且注意:Java 中类的继承只有单继承,没有多继承。即一个子类只能有一个父类,但是一个父类(基类)可以有多个子类(派生类),构成一个非线性的树状结构。且值得注意的是,构造器是不能够被继承的。
-
继承是类与类之间的一种关系。除此之外,类与类之间的关系还有实现(Realization)、关联(Association)、依赖(Dependency)、组合(Composition)、聚合(Aggregation)等[^ 注1][^ 注2]。
-
另外,若在父类中存在一个方法,而子类中并没有将这个方法重写,那么调用时会在父类中调用方法,从而导致意外的数据不匹配。有关方法重写,请见方法重写 例如:
//Creature.java public class Creature { String name = "生物"; //成员变量初始化 public Creature() { System.out.println("已调用父类构造器。") } public String getName() { return name; //若只进行了初始化,那么将默认输出:生物 } public void setName(String newName) { this.name = newName; } } //Animal.java public class Animal extends Creature { String name = "动物"; public Animal() { super(); //super关键字,将在下文中提到 System.out.println("已调用子类构造器。") } //子类缺省父类的 getName() 和 setName() 方法 }
那么,我们在主方法中执行:
//Main.java public class Main { public static void main(String[] args) { Animal animal = new Animal(); System.out.println(animal.getName()); animal.setName("小猫咪"); System.out.println(animal.getName()); } public String getName }
编译后运行,我们会得到:
生物 小猫咪
但是,倘若我们在子类中将方法重写,即:
//Animal.java public class Animal extends Creature { String name = "动物"; public Animal() { super(); //super关键字,将在下文中提到 System.out.println("已调用子类构造器。") } public String getName() { return name; } public void setName(String newName) { this.name = newName; } }
再次编译运行,得到预期结果:
动物
小猫咪
5-2.1.3 四种访问修饰符的区别
按权限高低自上而下降序排列:
修饰符 | 说明 | 备注 |
---|---|---|
public |
可以被所有类访问 | 权限最高,开放程度最高 |
protected |
可以被所有子类和同一包内的所有类(子类和无关类)访问 | / |
default |
可以被同一类和同一包内的所有类(子类和无关类)访问 | 缺省时,默认为这种访问权限 |
private |
只可以被同一类访问 | 权限最低,开放程度最低 |
在编写程序时,常常建议这样选择:
- 成员变量使用
private
,隐藏数据; - 构造方法使用
public
,方便创建对象。 - 成员方法使用
public
,方便调用方法。
注意到,在类的名字前,也可以有一个访问修饰符修饰。每一个 .java
文件,有且仅有一个类被 public
修饰符修饰,且这个类名称必须与文件名称一致,此时,main()
方法就必须放在 public
类中。这些修饰符的作用仍然适用于限制其访问权限,具体作用同上。
同样地,对于类的成员方法,其前面的访问修饰符也起到了同样的作用。
但是,若源文件中没有 public
类,那么这个类的名称可以不与文件名相同,main()
方法也可以放入其中。但是,编译的时候产生的 .class
文件的命名就不再是文件名,而是和 class
声明的类名完全一致的 .class
文件。[^ 注3]源文件中,有多少个类,编译时就会生成多少个同名的 .class
文件。
5-2.2 Object
类[^ 注4]
若有仔细留意,在 5-2.1.1 中的图我们会发现,除了我们自己定义的一个父类中的 say()
方法,菜单中还有很多不属于父类中的方法。这些方法全部来自于 Object
类,隶属于软件包 java.lang
,是所有类的父类。每一个类,无论是否显式继承,都默认直接或间接地继承 Object
类,从而能够调用该类中的方法。
5-2.2.1 拷贝对象
protected native Object clone() throws CloneNotSupportedException;
描述:用于创建并返回一个对象的拷贝。
建议用法:类实现 Cloneable
接口的同时重写 clone()
方法。
细节:成功时,该方法会返回一个该对象的拷贝。但是,Object
类并未实现 Cloneable
接口。在一个未实现 Cloneable
接口的实例上调用 clone()
方法会抛出 CloneNotSupportedException
异常。一般而言,实现了 Cloneable
接口的类应当重写 clone()
方法为一个公共方法防止异常抛出。
另外,在调用该方法进行拷贝时,所得新的字段内容并非是原字段的复制品,而是用原对象实例中对应字段的值类对新的对象进行初始化。因此,当复制字段含有引用成员时,拷贝所得的引用变量和原对象中的引用变量指向相同的地址,而不是新建一个引用变量。clone()
方法执行的是浅拷贝(shallow copy)操作,而不是深拷贝(deep copy)。[^ 注5] 若使用的是浅拷贝,那么当原对象中的引用成员发生变动时,复制所得对象中的引用成员同步发生变动。
提示:
- 比较两个引用属性是否相同,可以通过
==
比较二者地址查看二者是否指向共同的内存空间; - 对象克隆等同于复制构造,对象属于引用数据类型,参数传递类型仍然属于值传递,但传进来的是指向对象的引用变量,实参和形参共同指向相同的内存空间。
例子:深拷贝
先定义一个类:
public class Dog {
private String name = null;
public Dog(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
然后定义一个实现了 Cloneable
接口的类,并重写 clone()
方法:
public class Person implements Cloneable {
private String name = null;
private int age = 0;
private Dog dog = null;
public Person(String name, int age, Dog dog) {
this.name = name;
this.age = age;
this.dog = Dog;
}
public void gets() {
System.out.println("Name: " + name);
System.out.println("Age: " + age);
System.out.println("Dog: " + dog.getName());
}
public void setName(String name) {
this.name = name;
}
//重写父类 clone() 方法
@Override
protected Object clone() throws CloneNotSupportedException {
String name = this.name;
int age = this.age;
Dog clonedDog = new Dog(dog.getName()); //为引用成员开辟新的内存空间
Person clonedPerson = new Person(name,age,clonedDog);
return clonedPerson;
}
}
在主方法中测试:
public class Application {
public static void main(String[] args) throws CloneNotSupportedException{
Dog samoyed = new Dog("Sam");
Person src = new Person("Zebt",18,samoyed);
src.gets();
System.out.println("=================");
//复制
Person copy = (Person) src.clone();
copy.gets();
//尝试修改值
System.out.println("===== 尝试修改值 ======");
src.setName("Li Hua");
src.gets();
System.out.println("=================");
copy.gets();
}
}
运行结果:
Name: Zebt
Age: 18
Dog: Sam
=================
Name: Zebt
Age: 18
Dog: Sam
=================
===== 尝试修改值 ======
Name: Li Hua
Age: 18
Dog: Sam
=================
Name: Zebt
Age: 18
Dog: Sam
为方便起见,可以考虑使用第三方的库,更方便地克隆对象。此处推荐使用 [Gson
](#5-2.A 附录:脚注):
Gson gson = new Gson(); //新建对象
String str = gson.toJson(srcObj); //将源对象变为一个字符串(JSON)
gson.fromJson(dest, Person.class); //将字符串变回对象即可
5-2.2.2 判断相等对象
public boolean equals(Object obj) {
return (this == obj)
}
描述:用于比较两个对象是否相等。
细节:比较两个对象是否相等,实际上就是比较两个对象的地址是否一致。也可以不使用该方法,直接使用 ==
写成条件判断式即可。
提示:子类若重写了 equals()
方法,则需要重写 hashCode()
方法(例如 String
类中重写了这两种方法)。
5-2.2.3 获得哈希值
public native int hashCode();
描述:获取对象的哈希值。
细节:在官方的 JavaDoc 中提到
- 若两个对象相等(
equals()
),那么这两个对象的哈希值也相等; - 但若两个对象不相等,对这两个对象分别调用
hashCode()
方法也并不一定会得到不同的哈希值。但是,不相等对象产生不同的哈希值可以提升哈希表的性能表现。
提示:JDK 中有些方法由 native
修饰,是本地化方法[^ 注6] ,可以看到该方法的方法体为空,其实现位于用其他语言(如 C 和 C++)所写的文件中。Java 并不能对操作系统的底层进行访问和操作,但是可以通过 JNI 接口(Java Native Interface)调用其它语言来实现对底层的访问。而 Java 源码中的 native
方法是不能够直接在 JDK 中看到的,因为 JDK 不是开源的。如需查看[^ 注7],可以下载完整的 OpenJDK 完整包。
5-2.2.4 对象的字符串表示
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
描述:返回对象的字符串表现形式。形如:对象的 class 名称 + @ + hashCode 的十六进制字符串
。
5-2.2.5 获取对象的类
public final native Class<?> getClass();
描述:获取对象运行时对象的类。
细节:其中,Class<?>
使用了泛型(generics)[^ 注8],用于确保类型安全。在这一方法中,Class<?>
指的是类型不确定的类。
5-2.3 super
关键字
在前面的章节中,我们有提到过 this
关键字用于指代实例方法中的当前对象,这个关键字可以在任何语境下使用。若一个类继承了另一个类,若想要在子类中调用父类的成员方法、成员变量,我们可以使用关键字 super
。
5-2.3.1 利用 super
调用父类成员
举个例子 - 调用参数:
//Creature.java
public class Creature {
protected String name = "生物"; //使得子类有权访问该成员
}
//Animal.java
public class Animal extends Creature{
protected String name = "动物";
public void printName(String name) {
System.out.println(name); //传入参数
System.out.println(this.name); //当前类的实例化对象的成员
System.out.println(super.name); //父类的实例化对象的成员
}
}
//Application.java
public class Application {
public static void main(String[] args) {
Animal animal = new Animal();
animal.printName("Human");
}
}
运行结果:
Human
动物
生物
除了可以使用 super
调用父类的成员变量,还可以调用父类的成员方法:
//Creature.java
public class Creature {
//...
public void print() {
System.out.println("Creature");
}
}
//Animal.java
public class Animal extends Creature{
//...
public void print() {
System.out.println("Animal");
}
public void test() {
print(); //调用当前类的方法
this.print(); //调用当前类的方法
super.print(); //调用父类的同名方法
}
}
//Application.java
public class Application {
public static void main(String[] args) {
animal.test();
}
}
运行结果:
Animal
Animal
Creature
但是,这种情况下应到注意被调用的成员方法和成员变量的访问权限,若父类中的被调用成员访问权限都为 private
,在子类中无法直接调用父类的成员。
5-2.3.2 对父类构造器的调用
现在思考一个问题:两个具有继承关系的类,创建子类对象时,这两个类的构造器会如何被调用呢?
不放在两个类中显式定义一个无参构造器:
//Creature.java
public Creature() {
System.out.println("已调用父类构造器。");
}
//Animal.java
public Animal() {
System.out.println("已调用子类构造器。");
}
//Application.java
public class Application {
public static void main(String[] args) {
Animal animal = new Animal();
}
}
运行结果:
已调用父类构造器。
已调用子类构造器。
可见,尽管并没有显式创建父类对象,但是在创建子类对象时,会首先调用父类的无参构造器创建父类对象。若把子类构造器中这句隐藏构造语句补全,即为:
public Animal() {
super(); //隐藏代码:调用父类的无参构造。可以显式调用。
System.out.println("已调用子类构造器。");
}
且注意:调用父类构造器的语句必须要放在子类构造器的首句。同样地,this();
调用子类构造器的语句也必须放在子类构造器的第一行。因此,super();
和 this();
只能二选一。
由于在缺省 / 默认条件下,子类构造器首句会自动补全一句调用父类无参构造器的语句。若父类中已经显式定义了一个有参构造器而并没有显式定义无参构造器,子类构造器就会因为调用了一个不存在的构造器而报错:
//Creature.java
//Creature.java
public Creature(String type) {
System.out.println("已调用父类构造器。");
}
//Animal.java
public Animal() {
//super(); 若缺省,默认为调用父类的默认无参构造器。
System.out.println("已调用子类构造器。");
}
//Application.java
public class Application {
public static void main(String[] args) {
Animal animal = new Animal();
}
}
报错:'com.oop.xxx.Creature.java' 中没有可用的默认构造函数
。因此,为避免上述情况的发生,当我们已经显式定义了一个有参构造器,还应当显式定义一个无参构造器。
总结:
使用 super
关键字时
super
调用父类的构造方法,必须放在构造器的首句;super
和this
的构造方法不能同时使用;super
构造方法只能用在子类的方法或构造器中;- 若已经显式定义了父类的有参构造,还必须显式定义其无参构造器(子类构造器会默认调用父类的无参构造器);
使用 this
关键字时:
this
可以在无继承关系下使用,super
只能在有继承关系下(且必须为子类)才能使用;- 都是指向某一对象的引用地址,
this
指代当前对象,super
指代父类对象;
5-2.4 final
关键字[^ 注9]
在第一章中,我们提及到了 final
关键字,用于限定变量的访问权限,将其限定为只读。实际上,final
关键字能够修饰的不只是基本类型变量,还能修饰方法、类、接口和对象。
一般来说,由 final
修饰的变量,其名字应由全大写 + 下划线组成。
5-2.4.1 修饰基本数据类型[^ 注10]
final
所修饰的基本数据类型就是常量,但只能够赋值一次,赋值后该变量的值不可改变。
//final 修饰基本数据类型
public class Constants {
//类变量:静态变量,随类加载,保存在堆中,由所有线程共享。速度慢,容量大。
//Java 会为很多基本类型的包装类和字符串建立常量池,相同值只存储一份,节省空间。但浮点类型除外。
final static double GOLDEN_RATIO = 0.618;
//实例变量:隶属于对象,保存在堆中,为线程私有。
final int TIME = 2023_01_01;
public void setTIME(int newTime) {
//报错。由final修饰的基本数据类型赋值后不可修改。
this.TIME = newTime;
}
public static void main(String[] args) {
//局部变量:保存在栈中。速度快,容量小。
final double PI = 3.1415; //声明的同时初始化。
final double E; //空白 final 变量
E = 2.7183; //稍后赋值,但赋值后不可改变。
}
}
对于由 public static final
修饰的变量,它是隶属于类的静态常量,是全局变量。
5-2.4.2 修饰引用数据类型
与基本类型不同,修饰引用数据类型时,只能够保证被修饰的引用变量的引用不会改变,即这个引用变量所指向的地址不会改变,始终指向同一个对象。但是它所指的对象完全可以改变。
class Person {
private String name = "Name";
public Person(String name, int age) {
this.name = name;
}
//获取、修改数据
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class Reference {
public static void main(String[] args) {
//实例化类的对象,用final修饰
final Person person = new Person("Zebt",18);
//以下操作均合法:修改引用变量所指对象的内容
System.out.println(person.getName() + ", " + person.getAge());
person.setName("Zebt Fish");
person.setAge(19);
System.out.println(person.getName() + ", " + person.getAge());
//以下操作不合法:尝试改变该final引用变量的所指地址
//person = new Person("Li Hua", 18);
//person = null;
//数组也是对象,对于以下数组:
final int[] arr = {5,0,3,1,4}; //arr是该数组对象的引用变量
System.out.println(Arrays.toString(arr));
//以下操作均合法:更改对象内容
Arrays.sort(arr);
System.out.println(Arrays.toString(arr));
arr[1] = 2;
System.out.println(Arrays.toString(arr));
//以下操作不合法:尝试更改final引用变量的所指地址
//arr = null;
}
}
5-2.4.3 修饰方法
由 final
修饰的方法不可被重写。若出于某些原因,不希望子类重写父类的某些方法,可以添加 final
修饰该方法。
例如,Java 的 Object
类中有个 getClass()
方法,就被 final
修饰,但由于该类是所有类的父类,无法在子类重写 getClass()
方法,任何在子类重写的尝试都会导致编译错误。
class Parent {
public final void test() {
//空方法
}
}
class Sub extends Parent {
//尝试在子类重写父类final方法,会导致编译错误。
public void test() {
//空方法
}
}
方法重写的明显特征是发生在具有继承关系的子类当中,且方法的访问权限、返回值类型、方法名、形参列表相同。因此,当父类方法被 final
修饰但和子类的方法在上述条件中只有访问权限不一致时,相当于在子类中定义了一个新方法,不会发生冲突。
而且,final
只是不允许子类重写方法,但是允许重载,例如:
class Parent {
public final void test() {
//空
}
public final void test(int a) {
//空
}
}
5-2.4.4 修饰类
final
修饰的类不可以被继承。当子类继承父类时,子类将可以访问到父类的内部成员,并可以通过方法重写从而改变父类方法中的实现细节,可能会导致不安全因素。同时,若一个类被认为已经是完美而不需要修改、扩展时,也可以使用 final
修饰。
class final Parent {
//空
}
class Sub extends Parent {
//编译失败:无法继承父类
}
常量类里面的成员方法隐式为常量方法,因此其成员方法不可以被重写。
5-2.4.5 final
传值[^ 注11]
由上面的例子可以推知,final
用于限定变量的访问权限为只读。若修饰的是基本数据类型,则其值在被赋值后不可修改;若为引用数据类型,则其所指引用不可改变,但对象的内容仍可以改变。
同样地,若用作方法的参数传值,final
关键字的效果同上。
class Reference {
private int = 10;
}
public static void basics(final int i, final Reference reference) {
//错误,final修饰的变量值不可被改变
//i = 10;
//错误,final变量的引用不可被改变
//reference = new Reference();
}
5-2.5 封闭类
自 JDK 17 起,Java 引入了一个新的机制:封闭类。封闭类和封闭接口限制了哪些其他类和接口可以继承和实现这些封闭类。
封闭类可用关键字 sealed
修饰在类名和接口名前,以声明它是一个封闭类或封闭接口。封闭类和封闭接口可以使用关键字 permits
,严格限制允许继承和实现的类的范围(授权类)。通常 sealed
和 permits
关键字会联合使用。
授权类必须使用 final
, sealed
或 non-sealed
指明子类将如何实现封闭性。使用 sealed
关键字延续其扩展性,也可以使用 final
关键字禁用扩展性,还可以使用 non-sealed
关键字,取消扩展性限制。
使用 final
关键字,只能够禁止类的继承,而无法实现对接口的实现封闭。而封闭类和封闭接口提供了一种更为灵活、更细粒度的可扩展性,更好地控制代码的安全性和健壮性。
语言更新文档中对封闭类的介绍:Sealed Classes (oracle.com)
封闭类和封闭接口的提案:JEP 409: Sealed Classes (openjdk.org)
封闭类示例:以下示例均来自官方语言更新文档中的示例程序。
以下是一个封闭父类的声明:
public sealed class Shape
permits Circle, Square, Rectangle {
}
在相同模块或相同包中,定义以下三个授权子类:
public final class Circle extends Shape {
private float radius;
}
public non-sealed class Square extends Shape {
private double side;
}
public sealed class Rectangle extends Shape permits FilledRectangle {
private double length, width;
}
类 Rectangle
作为封闭子类,还有一个层次更深的子类:
public final class FilledRectangle {
private int red, green, blue;
}
若授权子类和封闭父类位于同一个文件中时,可以省略 permits
语句:
package com.example.geometry;
public sealed class Figure
// permits 语句已省略
// 由于授权类都已在相同文件中定义
{}
final class Circle extends Figure {
float radius;
}
non-sealed class Square extends Figure {
float side;
}
sealed class Rectangle extends Figure {
float length, width;
}
final class FilledRectangle extends Rectangle {
int red, green, blue;
}
授权类的约束:
- 在编译时期封闭类必须能够访问其授权类;
- 授权类必须直接继承封闭类;
- 授权类必须声明其封闭性,使用
final
,sealed
或non-sealed
关键字; - 授权类必须与封闭类位于同一个模块或包中。
5-2.A 附录:脚注
[^ 注1]: 类的关系(泛化, 实现,关联,聚合,组合,依赖)_泛化关系_一鲸落.万物生的博客-CSDN博客
[^ 注2]: JAVA类与类之间的全部关系简述+代码详解 - 代祖华 - 博客园 (cnblogs.com)
[^ 注3]: JAVA中类的public class与class的区别详解_区块链之美的博客-CSDN博客
[^ 注4]: Java Object 类 | 菜鸟教程 (runoob.com)
[^ 注5]: Cloneable接口基础详解 - 只会写error - 博客园 (cnblogs.com)
[^ 注6]: java中native的用法 - 不止吧 - 博客园 (cnblogs.com)
[^ 注7]: 如何查看java源码中的native方法源码_jvm的native源码在哪里_99zhenzhen的博客-CSDN博客
[^ 注8]: Java泛型详解,史上最全图文详解_Java程序员-张凯的博客-CSDN博客
[^ 注9]: Java final修饰符详解 (biancheng.net)
[^ 注10]: Java——常量设计_java 常量设计_拿云️的博客-CSDN博客
Gson:[Download gson.jar - @com.google.code.gson (mavenlibs.com)](