JDK17 |java17学习 第 1 章 Java 17 入门
Chapter 2: Java Object-Oriented Programming (OOP)
面向对象编程(OOP)的诞生是为了更好地控制共享数据的并发修改,这是预 OOP 编程的诅咒。这个想法的核心是不允许直接访问数据,而是只通过专用的代码层来实现。由于需要在过程中传递和修改数据,因此构想了对象的概念。在最一般的意义上,object 是一组数据,只能通过传递的方法集传递和访问。据说这些数据构成了对象状态,而方法构成了对象行为。对象状态被隐藏(封装),无法直接访问。
每个对象都是基于称为 class 的特定模板构建的。换句话说,一个类定义了一类对象。每个对象都有一个特定的接口,这是对其他对象与其交互方式的正式定义。最初,一个对象会通过调用其方法向另一个对象发送消息。但是这个术语并不成立,尤其是在引入了实际的基于消息的协议和系统之后。
为了避免代码重复,引入了对象之间的父子关系——一个类可以从另一个类继承行为。在这种关系中,第一个类称为子类,或子类,而第二个类称为父类、基类或超类。
在类和接口之间定义了另一种形式的关系——一个类可以实现一个接口。由于接口描述了您如何与对象交互,而不是对象如何响应交互,因此不同的对象在实现相同的接口时可以表现不同。
在 Java 中,一个类只能有一个直接父级,但可以实现多个接口。
表现得像它的任何祖先并遵守多个接口的能力称为多态性。
在本章中,我们将了解这些 OOP 概念以及它们是如何在 Java 中实现的。讨论的主题包括:
- 面向对象的概念
- 班级
- 界面
- 重载、覆盖和隐藏
- final 变量、方法和类
- 记录和密封类
- 多态性在行动
Technical requirements
为了能够执行本章提供的代码示例,您将需要以下内容:
- 装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机
- Java SE 版本 17 或更高版本
- 您喜欢的 IDE 或代码编辑器
本书第 1 章,Java 17 入门。本章的代码示例文件可在 GitHub 存储库中获得,地址为 https:// /github.com/PacktPublishing/Learn-Java-17-Programming.git 在 examples/src/main/java/com/packt/learnjava/ch02_oop
文件夹中.
OOP concepts
正如我们在介绍中已经说过的,主要的 OOP 概念如下:
- 类:定义对象的属性和行为(方法)是基于这个类创建的。
- 对象:这将状态(数据)定义为其属性的值,添加所采取的行为(方法) 来自一个类,并将它们保持在一起。
- 继承:这会将行为沿通过父级连接的类的链传播孩子的关系。
- 接口:这描述了如何 访问对象数据和行为。它将对象的外观 与其实现(行为)隔离(抽象)。
- 封装:隐藏状态和实现的细节。
- 多态性:这允许对象呈现 已实现 接口的外观和表现得像任何祖先类。
Object/class
原则上,您可以使用最少的类和对象创建一个非常强大的应用程序。在函数式编程 被添加到 Java 8 和 JDK 之后,它变得 变得更容易了,它允许您四处传递行为 作为函数。然而传递数据(状态)仍然需要类/对象。这意味着 Java 作为 OOP 语言的地位保持不变。
一个类定义了保持对象状态的所有内部对象属性的类型。一个类还定义了由方法代码表达的对象行为。可以有一个没有状态或行为的类/对象。 Java 还提供了一种使行为可静态访问的规定——无需创建对象。但是这些可能性只不过是对对象/类概念的补充,该概念是为了将状态和行为保持在一起而引入的。
为了说明这一概念,例如,Vehicle
类在原则上定义了车辆的属性和行为。让我们简化模型并假设车辆只有两个属性——重量和一定功率的发动机。它也可以有一定的行为——它可以在一定的时间内达到一定的速度,这取决于它的两个属性的值。这种行为可以用一种计算车辆在特定时间段内可以达到的速度的方法来表达。 Vehicle
类的每个对象都会有一个特定的状态(其属性的值),并且速度计算将导致同一时间段内的不同速度。
所有 Java 代码 都包含在方法中。 方法是一组语句,具有(可选)输入参数并返回一个值(也是可选的)。此外,每个 方法都可能有副作用——例如,它可以显示 一条消息或将数据写入数据库。类/对象行为在方法中实现。
例如,按照我们的示例,速度计算可以驻留在 double calculateSpeed(float seconds)
方法中。你可以猜到,这个方法的名字是calculateSpeed
。它接受秒数(带有小数部分)作为参数,并以 double
形式返回速度值。
Inheritance
正如我们已经提到的,对象可以建立父子关系并共享属性 和这种方式的行为。例如,我们可以创建一个 Car
类,该类继承 Vehicle
类。此外,child
类可以有自己的属性(例如乘客数量)和特定于汽车的行为(例如软减震)。但是,如果我们创建一个 Truck
类作为车辆的子类,它的附加卡车特定属性(例如有效载荷)和行为(硬减震)将会有所不同。
据说 Car
或 Truck
类的每个对象都有一个 的父对象车辆
类。但是 Car
和 Truck
类的对象不共享特定的 Vehicle
对象(每次创建子对象时,都会先创建一个新的父对象)。他们只分享父母的行为。这就是为什么所有子对象可以具有相同的行为但不同的状态。这是实现代码可重用性的一种方法,但当对象行为必须动态更改时,它可能不够灵活。在这种情况下,对象组合(从其他类引入行为)或函数式编程更合适(参见 第 13 章,函数式编程)。
有可能使孩子的行为与继承的行为不同。为了实现它,可以在 child
类中重新实现捕获行为的方法。据说孩子可以覆盖继承的行为。我们将很快解释如何做到这一点(参见重载、覆盖和隐藏部分)。例如,如果Car
类有自己的速度计算方法,则Vehicle
父类的对应方法 没有被继承,新的速度计算,在child
类中实现,改为使用。
父类的属性也可以被继承(但不能被覆盖)。然而,类属性通常被声明为私有的;它们不能被继承——这就是封装的重点。查看各种访问级别的描述 - public
、protected
、default
、和 private
- 在 访问修饰符 部分Programming/9781803241432/4" linkend="ch04lvl1sec24">第 3 章,Java 基础知识。
如果父类从另一个类继承了某些行为,那么 child
类也会获取(继承)这种行为,当然,除非父类覆盖它。继承链的长度没有限制。
Java 中的父子关系使用 extends
关键字表示:
在此代码中,A
、B
、C
和 D
类有如下关系:
D
类继承自A
、B
和C
类。C
类继承自A
和B
类。B
类继承自A
类。
A
类的所有非私有方法都由 B
、C
和 D
类。
B
类的所有非私有方法都由 C
和 D
类。
C
类的所有非私有方法都由 D
类继承(如果没有被覆盖)。
Abstraction/interface
方法的名称 及其参数类型列表称为方法签名。它描述了一个对象(Car
或 Truck
的行为,在我们的示例中)可以访问。这样的描述与 return
类型一起作为接口呈现。它没有说明执行计算的代码 - 仅说明方法名称、参数类型、它们在参数列表中的位置以及结果类型。所有实现细节都隐藏(封装)在实现此接口的类中。
正如我们已经提到的,一个类可以实现许多不同的接口。但是两个不同的类(和它们的对象)即使实现了相同的接口也可以表现不同。
与类类似,接口也可以使用 extends
关键字建立父子关系:
在此代码中,A
、B
、C
和 D
接口有如下关系:
D
接口继承自A
、B
和C
接口。C
接口继承自A
和B
接口。B
接口继承自A
接口。
A
接口的所有非私有方法都被B
、C
和
D
接口。
B
接口的所有非私有方法都被 C
和 D
继承代码>接口。
C
接口的所有非私有方法都由 D
接口继承。
抽象/接口还减少了代码不同部分之间的依赖关系,从而提高了它的可维护性。只要接口保持不变,每个类都可以更改,而无需与其客户协调。
Encapsulation
封装通常被定义为数据隐藏或一组可公开访问的方法和私有可访问的数据。从广义上讲,封装是控制 对对象属性的访问。
对象 属性值的快照称为对象状态。这是被封装的数据。因此,封装解决了推动创建面向对象编程的主要问题——更好地管理对共享数据的并发访问,例如:
如您所见,要读取或修改 prop
属性的值,我们无法直接访问它,因为 private
访问修饰符。相反,我们只能通过 setProp(String value)
和 getProp()
方法来完成。
Polymorphism
多态性是 对象作为不同 类的对象或作为不同接口的实现的能力。它的存在归功于前面提到的所有概念——继承、接口和封装。没有它们,多态性是不可能的。
继承允许对象获取或覆盖其所有祖先的行为。接口对客户端代码隐藏了实现它的类的名称。封装防止暴露对象状态。
在接下来的部分中,我们将演示所有这些概念的实际应用,并查看 的具体用法="italic">作用中的多态性部分。
Class
Java 程序 是表示可执行操作的语句序列。语句按方法组织,方法按类组织。一个或多个类存储在 .java
文件中。它们可以由 javac
Java 编译器编译(从 Java 语言转换为字节码)并存储在 .class
文件中。每个.class
文件只包含一个编译好的类,可以被JVM执行。
java
命令启动 JVM 并告诉它哪个类是 main
类,具有称为 main()
。 main
方法有一个特定的声明——它必须是 public static
,必须返回 void
,名称为 main
,并接受 String
类型数组的单个参数。
JVM 将主类加载到内存中,找到 main()
方法,并开始逐句执行它。 java
命令还可以传递 main()
方法作为 字符串
值。如果 JVM 遇到需要执行另一个类的方法的语句,该类(它的 .class
文件)也会被加载到内存中并执行相应的方法。因此,Java 程序流程就是加载类并执行它们的方法。
以下是 main
类的示例:
它表示一个非常简单的 应用程序,它接收任意数量的参数并将它们一个一个地传递到 display()
方法中AnotherClass
类。当 JVM 启动时,它首先从 MyApp.class
文件加载 MyApp
类。然后,它从 AnotherClass.class
文件中加载 AnotherClass
类,使用 new
运算符(我们稍后会谈到),并在其上调用 display()
方法。
这是 AnotherClass
类:
如您所见,display()
方法仅用于其副作用——它打印出传入的值并且不返回任何内容(无效
)。 AnotherClass
类还有另外两种方法:
process()
方法将输入整数加倍,将其存储在其result
属性中,并将值返回给调用者。getResult()
方法允许您在以后的任何时间从对象中获取结果。
这两个方法 在我们的演示应用程序中没有使用。我们展示它们只是为了表明一个类可以具有属性(在本例中为 result
)和许多其他方法。
private
关键字使值只能从类内部,从其方法访问。 public
关键字使任何其他类都可以访问属性或方法。
Method
我们已经看到了一些例子。方法具有名称、一组输入参数或根本没有参数、{}
括号内的主体和返回类型或 void
关键字,表示该方法不返回任何值。
方法名称和参数列表 类型一起称为方法签名。输入 参数的数量称为arity。
重要的提示
如果两个方法在输入参数列表中具有相同的名称、相同的数量和相同的类型序列,则它们具有相同的 签名。
以下两个方法具有相同的签名:
以下两种方法具有不同的签名:
即使方法名称保持不变,只是参数序列的变化会使签名不同。
Varargs
一种特定类型的参数 需要提及,因为它与所有其他参数完全不同。它被声明为 一个后跟三个点的类型。它被称为varargs,代表可变参数。但是,首先,让我们简要定义一下 Java 中的数组是什么。
数组是一种数据结构,其中包含 相同类型的元素。元素由数字索引引用。这就是我们现在需要知道的。我们将在 第 6 章中更详细地讨论数组 、数据结构、泛型和流行实用程序。
让我们从一个例子开始。让我们使用 varargs
声明方法参数:
当调用 someMethod
方法时,Java 编译器会将参数从左 匹配到右。一旦它到达最后一个 varargs
参数,它就会创建一个由剩余的 参数组成的数组并将其传递给方法。这是一个演示代码:
如您所见,varargs
参数的作用类似于指定类型的数组。它可以列为方法的最后一个或唯一的参数。这就是为什么有时您可以看到声明的 main
方法,如前面的示例所示。
Constructor
创建对象 时,JVM 使用构造函数。构造函数的目的是初始化 object 状态以将值分配给所有声明的属性。如果类中没有声明构造函数,JVM 只是为属性分配默认值。我们已经讨论过原始类型的默认值——整数类型是 0
,浮点类型是 0.0
,以及false
用于布尔类型。对于其他 Java 引用类型(请参阅 第 3 章 , Java Fundamentals),默认值为null
,表示引用类型的属性不是分配任何值。
重要的提示
当一个类中没有声明构造函数时,就说这个类有一个JVM提供的不带参数的默认构造函数。
如有必要,可以显式声明任意数量的构造函数,每个构造函数采用一组不同的参数来设置初始状态.这是一个例子:
如果构造函数未设置属性,则将自动为其分配相应类型的默认值。
当多个类 沿同一条继承线相关时,首先创建父对象。如果父对象需要为其属性设置非默认初始值,则必须使用 super
关键字将其构造函数作为子构造函数的第一行调用,如下所示:
在前面的代码 示例中,我们向 TheChildClass
添加了两个构造函数——一个总是通过 42
到 TheParentClass
的构造函数,另一个接受两个参数的。请注意,x
属性已声明但未显式初始化。 0
类型的默认值 int
"literal">TheChildClass 被创建。另外,请注意 anotherProp
属性被显式初始化为 "abc"
的值。否则,它将被初始化为 null
值,即任何引用类型的默认值,包括 String
。
从逻辑上讲,有三种情况不需要在类中显式定义构造函数:
- 当对象及其任何父对象都没有需要初始化的属性时
- 当每个属性与类型声明一起初始化时(例如,
int x = 42
) - 当属性初始化的默认值足够好时
尽管如此,即使满足所有三个条件(在列表中提到),仍然可能实现构造函数。例如,您可能希望执行一些语句来初始化一些外部资源(文件或另一个数据库),一旦创建对象就会需要这些资源。
一旦添加了显式构造函数,就不会提供默认构造函数,并且以下代码会生成错误:
为避免该错误,请向 TheParentClass
添加不带参数的构造函数 或调用显式< /a> 父类的构造函数作为子类构造函数的第一条语句。以下代码不会产生错误:
需要注意的一个重要方面是构造函数,虽然它们看起来像方法,但不是方法,甚至不是类的成员。构造函数没有返回类型,并且总是与类同名。它的唯一目的是在创建类的新实例时调用。
The new operator
new
操作符创建一个类的对象(也可以说它实例化一个类或创建一个类的实例),通过为新对象的属性分配内存并返回对该记忆的引用。此内存引用分配给与用于创建对象的类或其父对象的类型相同类型的变量:
这是一个有趣的观察。在代码中,ref1
和 ref2
对象引用都提供了对 TheChildClass 方法的访问
和 TheParentClass
。例如,我们可以为这些类添加方法,如下所示:
请注意,要使用父类的类型引用访问子类的方法,我们必须将其强制转换为子类的类型。否则,编译器会产生错误。这是可能的,因为我们已经将子对象的引用分配给了父对象的类型引用。这就是多态的力量。我们将在 Polymorphism in action 部分详细讨论它。
自然,如果我们将父对象分配给父对象类型的变量,即使进行强制转换,我们也无法访问子对象的方法,如下例所示:
为新的 对象分配内存的区域称为heap。 JVM 有一个名为 garbage collection 的进程,它会监视该区域的使用情况 并在有对象时释放内存以供使用不再需要。例如,看下面的方法:
someMethod()
方法执行完成后,SomeClass
的对象不可访问 了。这就是垃圾收集器注意到的, 它释放了这个对象占用的内存。我们将在 Chapter 9
,JVM 结构和垃圾收集。
Class java.lang.Object
在 Java 中,所有的类 默认都是 Object
类的子类,即使你< /a> 不要隐式指定它。 Object
类在标准 JDK 库的 java.lang
包中声明。我们将在 Packages, importing, and access 部分定义 package 是什么,并在 第 7 章, Java 标准和外部库。
让我们回顾一下我们在 Inheritance 部分提供的示例:
所有类,A
、B
、C
和 D
是 Object
类的子类,每个类都继承了 10 个方法:
public String toString()
public int hashCode()
public boolean equals (Object obj)
public Class getClass()
受保护对象 clone()
public void notify()
public void notifyAll()
public void wait()
public void wait(long timeout)
public void wait(long timeout, int nanos)
前三个,toString()
、hashCode()
和equals()
是最常用的方法,并且经常被重新实现(覆盖)。
toString()
方法通常 用于打印状态目的。它在 JDK 中的默认实现如下所示:
如果我们在 TheChildClass
类的对象上使用它,结果会如下:
顺便说一句,在将对象传递给 System.out.println()
toString() > 方法和类似的输出方法,因为无论如何它们都是在方法内部执行的,并且在我们的例子中,System.out.println(ref1)
会产生相同的结果。
因此,如您所见,这样的输出对人类不友好,因此最好重写 toString()
方法。最简单的 方法是使用 IDE。例如,在 IntelliJ IDEA 中,在 TheChildClass
代码内单击鼠标右键,如下图所示:
选择并点击Generate...,然后选择并点击toString(),如下图所示:
新的弹出窗口将使您能够选择您希望包含在 toString()
方法中的属性。只选择TheChildClass
的属性,如下:
点击OK按钮后,会生成如下代码:
如果类中有更多属性并且您已选择它们,则方法输出中将包含更多属性及其值。如果我们现在打印对象,结果将是这样的:
这就是为什么 toString()
方法经常被覆盖甚至包含在 IDE 的服务中的原因。
hashCode()
和 equals()
方法将在 第 6 章,数据结构,泛型和流行的实用程序。
getClass()
和 clone()
方法不常用。 getClass()
方法返回 Class
类的对象,该对象具有许多提供各种系统信息的方法。最常用的方法是返回当前对象的类名的方法。 clone()
方法可用于复制当前的 对象。只要当前对象 的所有属性都是原始类型,它就可以正常工作。但是,如果存在引用类型属性,则必须重新实现 clone()
方法,以便正确完成引用类型的复制。否则,只会复制引用,而不是对象本身。这样的副本称为浅副本,在某些情况下可能已经足够了。 protected
关键字表示只有类的子级可以访问它。请参阅包、导入和访问部分。
Object
类的最后五个方法用于线程之间的通信——用于并发处理的轻量级进程。它们通常不会重新实现。
Instance and static properties and methods
到目前为止,我们已经见过大部分方法 只能在 对象上调用 (实例) 一个类。此类方法称为实例方法。它们通常使用对象属性(对象状态)的值。否则,如果它们不使用对象状态,则可以将它们设置为 static
并在不创建对象的情况下调用它们。这种方法的一个例子是 main()
方法。这是另一个例子:
该方法可以如下调用:
重要的提示
静态方法也可以在对象上调用,但它被认为是不好的做法,因为它隐藏了方法的静态特性,不让人们试图理解代码。此外,它会引发编译器警告,并且根据编译器的实现,甚至可能会产生编译器错误。
同样,属性 可以声明为静态 并因此 可访问 没有 创建 对象,例如 作为 如下:
该属性也可以通过类直接访问,如下:
拥有这样一个静态属性违背了状态封装的思想,并且可能导致并发数据修改的所有问题,因为它作为一个副本存在于 JVM 内存中,并且所有使用它的方法共享相同的值。这就是为什么静态属性通常用于两个目的:
常量的一个典型示例是资源的名称:
注意静态属性前面的 final
关键字。它告诉编译器和 JVM,这个值一旦分配就不能改变。尝试这样做会产生错误。它有助于保护该值并清楚地表达将该值作为常数的意图。当人们试图理解代码是如何工作的时,这些看似很小的细节会让代码更容易理解。
也就是说,考虑 将接口用于这样的 目的。自 Java 1.8 起,接口 中声明的所有字段 都是隐式静态的 和 final,因此您不太可能 忘记将值声明为 final。稍后我们将讨论 接口。
当一个对象被声明为静态最终类属性时,并不意味着它的所有属性都自动成为最终的。它只保护属性不分配另一个相同类型的对象。我们将在 第 8 章,多线程和并发处理。然而,程序员经常使用静态最终对象来存储只读值,这些值只是在应用程序中使用的方式。一个典型的例子是应用程序配置信息。从磁盘读取后创建后,即使可以更改,也不会更改。此外,数据的缓存是从外部资源获得的。
同样,在为此目的使用此类属性之前,请考虑使用提供更多支持只读功能的默认行为的接口。
与静态属性类似,可以在不创建类实例的情况下调用静态方法。例如,考虑以下类:
我们可以调用前面的< /a> 方法 使用 只是 一个类 名称:
Interface
在 Abstraction/interface 部分,我们用一般的 术语讨论了接口。在本节中,我们将描述一种表达它的 Java 语言结构。
接口呈现了对象的期望。它隐藏了实现并且只公开了带有返回值的方法签名。例如,这是一个声明两个抽象方法的接口:
这是一个实现它的类:
无法实例化接口。只能通过创建实现此接口的类的对象来创建接口类型的对象:
如果没有实现接口的所有抽象方法,则该类必须声明为抽象的,不能实例化。请参阅接口与抽象类部分。
接口不描述如何创建类的对象。要发现这一点,您必须查看该类并查看它具有哪些构造函数。接口也没有描述静态类方法。因此,接口只是类实例(对象)的公共面。
在 Java 8 中,接口获得了不仅具有抽象方法(没有主体)而且具有真正实现的能力的能力。根据 Java 语言规范,“接口的主体可以声明接口的成员,即字段、方法、类和接口。”如此宽泛的陈述提出了一个问题,接口和类之间有什么区别?我们已经指出的一个主要区别是——接口不能被实例化;只能实例化一个类。
另一个区别是在接口中实现的非静态方法被声明为 default
或 private
。相比之下,default
声明不适用于类方法。
此外,接口中的字段隐含地是公共的、静态的和最终的。相比之下,类属性和方法默认不是静态的或最终的。类本身的隐式(默认)访问修饰符、其字段、方法和构造函数是包私有的,这意味着它仅在其自己的包中可见。
Default methods
要了解接口中默认方法 的功能,让我们看一下 接口和类的示例实现它,如下:
我们现在可以创建 SomeClass
类的对象并进行以下调用:
如您所见, method3()
没有在 SomeClass
类中实现,但看起来该类有它。这是一种 向现有类添加新方法而不 更改它的方法 - 通过将默认方法添加到接口类实现。
现在让我们将 method3()
实现也添加到类中,如下所示:
现在,method3()
的接口实现将被忽略:
重要的提示
接口中默认方法 的目的是为类(实现此接口的)提供一个新方法,而无需更改他们。但是一旦类实现了新方法,接口实现就会被忽略。
Private methods
如果一个接口中有多个默认方法,则可以创建私有方法只能被接口的默认方法访问.它们可用于包含通用功能,而不是在每个默认方法中重复它:
私有方法的概念与类中的私有方法没有什么不同(请参阅包、导入和访问部分)。无法从接口外部访问私有 方法。
Static fields and methods
从 Java 8 开始,接口中声明的所有字段 都是隐式的公共、静态和最终 常量。这就是为什么接口 是常量的首选位置。您不需要将 public static final
添加到他们的声明中。
至于静态方法,它们在接口中的作用与在类中的作用相同:
请注意,无需将接口方法标记为public
。默认情况下,所有非私有接口方法都是公共的。
我们可以只使用一个接口名称来调用前面的方法:
Interface versus abstract class
我们已经提到一个类可以被声明为abstract
。它可能是一个常规类, 我们不想被实例化,或者它可能 是一个包含(或继承) 抽象方法。在最后一种情况下,我们必须将这样的类声明为 abstract
以避免编译错误。
在许多方面,抽象类与接口非常相似。它强制每个扩展它的 child
类来实现抽象方法。否则,子不能被实例化并且必须被声明为抽象本身。
然而,接口和抽象类之间的一些主要区别使得它们在不同的情况下都很有用:
- abstract 类可以有构造函数,而接口 不能。
- 抽象类可以有状态,而接口不能。
- 抽象类的字段可以是
public
、private
或protected
,static
与否,以及final
与否,而在接口中,字段始终是public
、static
和final
。 - 抽象类中的方法可以是
public
、private
或protected
,而接口方法只能是public
或private
。 - 如果您要修改的类已经扩展了另一个类,则不能使用抽象类,但可以实现接口,因为一个类只能扩展另一个类,但可以实现多个接口。
您将在 Polymorphism in action 部分中看到抽象用法的示例。
Overloading, overriding, and hiding
我们已经在 Inheritance 和 Abstraction/interface 部分提到了覆盖。它是在父类 中实现的非静态方法 替换为
子 类。接口的默认
方法也可以在扩展它的接口中被覆盖。隐藏类似于覆盖,但仅适用于静态方法和静态,以及实例的属性。
重载是在同一个类或接口中创建多个具有相同名称和不同参数(因此,不同的签名)的方法。
在本节中,我们将讨论所有这些概念并演示它们如何用于类和接口。
Overloading
在同一个接口或具有相同签名的类中不可能有两个方法。要具有不同的签名,新方法必须具有新名称或不同的参数类型列表(并且类型的顺序确实很重要)。拥有两个名称相同但参数类型列表不同的方法构成重载。以下是在接口中重载的合法方法的几个示例:
请注意,前面的任何两个方法都没有相同的签名,包括默认方法和静态方法。否则,会产生编译器错误。指定为默认值和静态都不会在重载中起任何作用。返回类型也不影响重载。我们在任何地方都使用 int
作为返回类型,只是为了让示例不那么混乱。
方法重载在类中以类似方式完成:
并且在哪里声明具有相同名称的方法并不重要。下面的方法重载和前面的例子没有区别,如下:
重要的提示
当方法具有相同的名称但参数类型列表不同并且属于相同的接口(或类)或不同的接口(或类)时,就会发生重载,其中一个是另一个的祖先。私有方法只能由同一类中的方法重载。
Overriding
与 静态和非静态方法发生的重载相比,方法覆盖仅发生在非静态方法中并且仅在它们具有 时发生完全相同的签名和属于不同的接口(或类),其中一个是另一个的祖先。
重要的提示
重写方法驻留在子接口(或类)中,而重写方法具有相同的签名并属于祖先接口(或类)之一。不能覆盖私有方法。
如果我们使用 C
类实例调用 method()
,结果如下:
请注意 @Override
注释的用法。它告诉编译器程序员认为带注释的方法覆盖了祖先接口之一的方法。这样,编译器可以确保覆盖确实发生,如果没有发生则生成错误。例如,程序员可能会拼错方法的名称,如下所示:
如果发生这种情况,编译器 会生成错误,因为没有要覆盖的 metod()
方法。如果没有 @Overrride
注释,程序员可能不会注意到这个错误,结果会完全不同:
相同的覆盖规则适用于类实例方法。在以下示例中,C2
类覆盖了 C1
类的方法:
结果如下:
在具有覆盖方法的类或接口与具有覆盖方法的类 或接口之间有多少祖先并不重要:
上述方法覆盖的结果仍然是相同的。
Hiding
隐藏被许多认为是一个复杂的话题,但它不应该是,我们会尽量让它看起来简单.
hiding 这个名字来源于类和接口的静态属性和方法的行为。每个静态属性或方法在 JVM 内存中作为单个副本存在,因为它们与接口或类相关联,而不是与对象相关联。接口或类作为单个副本存在。这就是为什么我们不能说子类的静态属性或方法会覆盖父类的同名静态属性或方法。所有静态属性 和方法仅在加载类或接口时加载到内存中并保留在那里,不会被复制到任何地方。让我们看一个例子。
让我们创建两个具有父子关系的接口以及具有相同名称的静态字段和方法:
请注意接口字段标识符的大写。这是常用于表示常量的约定,无论它是在接口还是类中声明。提醒您一下,Java 中的常量是一个变量,一旦初始化,就不能重新分配另一个值。默认情况下,接口字段是常量,因为接口中的任何字段都是 final(请参阅 Final 属性、方法和类部分)。
如果我们从 B
接口打印 NAME
并执行它的 method()
,我们得到以下结果:
它看起来很像覆盖,但实际上,我们只是调用了一个特定的属性或与这个特定接口相关联的 方法。
同样,考虑以下类:
如果我们尝试使用类本身访问 D
类的静态成员,我们将得到我们所要求的:
只有在使用对象访问属性或静态方法时才会出现混淆:
obj
变量引用 D
类的对象,铸造证明了这一点,正如您在前面的示例中看到的那样。但是,即使我们使用对象,尝试访问静态属性或方法也会为我们带来 我们用作声明变量类型的类的成员。至于例子最后两行的实例属性,Java中的属性不符合多态行为,我们得到父name1
属性"literal">C 类,而不是子 D
类的预期属性。
重要的提示
为避免与类的静态成员混淆,请始终使用类而不是对象来访问它们。为避免与实例属性混淆,请始终将它们声明为私有并通过方法访问它们。
为了说明最后一个技巧,请考虑以下类:
如果我们对 instance 属性运行与 C
和 D 相同的测试
类,结果将是这样的:
现在,我们使用方法访问实例属性,这些方法是覆盖效果的主体,不再有意外结果。
为了结束对 Java 中隐藏的讨论,我们想提一下另一种隐藏类型,即当局部变量隐藏同名的实例或静态属性时。这是一个这样做的类:
可以看到,name1
局部变量隐藏了同名的静态属性,而name2
局部变量隐藏了实例财产。仍然可以使用类名访问静态属性(参见HidingProperty.name1
)。请注意,尽管被声明为 private
,它仍可从类内部访问。
始终可以使用 this
关键字 访问实例属性,其中表示当前对象。
The final variable, method, and classes
关于 Java 中常量的概念,我们已经多次提到 final
属性,但 只是一种情况使用 final
关键字。它通常可以应用于任何变量。此外,可以将类似的约束 应用于方法甚至类,从而防止方法被覆盖和类被扩展。
The final variable
final
关键字放在变量声明前面 使这个变量在初始化后不可变,例如:
初始化甚至可以延迟:
对于 object
属性,这种延迟只能持续到对象被创建。这意味着可以在构造函数中初始化属性,例如:
请注意,即使在对象构造期间,也不可能两次初始化属性——在声明期间和在构造函数中。值得注意的是,最终属性必须显式初始化。从前面的示例可以看出,编译器不允许将 final 属性初始化为默认值。
也可以在初始化块中初始化 final
属性:
对于 static
属性,不可能 在构造函数中对其进行初始化,因此必须对其进行初始化在其声明期间或在静态初始化块中:
在接口中,所有字段始终是最终字段,即使它们没有被声明为最终字段。由于接口中既不允许构造函数也不允许初始化块,因此初始化接口字段的唯一方法是在声明期间。不这样做会导致编译错误:
Final method
声明为 final
的方法不能在 child
类中被覆盖或在 static 方法。例如,java.lang.Object
类,它是 Java 中所有类的祖先,它的一些方法声明为 final
:
final
类的所有私有方法和未继承方法实际上都是最终方法,因为您无法覆盖它们。
Final class
final
类不能扩展。它不能有孩子,这使得类的所有方法 也有效地final
。此功能用于安全性或当程序员希望确保类功能不会因为其他一些设计考虑而被覆盖、重载或隐藏时。
The record class
record
类 在 Java 16 中添加到 SDK。这是一个期待已久的 Java 功能。它允许您在需要不可变类(仅使用 getter)时避免编写样板代码,该类类似于以下 Person
类(参见 ch02_oop
文件夹中的 "literal">Record 类):
请注意,上面的 getter 没有 get
前缀。这样做是故意的,因为在不可变类的情况下,没有必要区分 getter 和 setter,因为如果我们想让类真正不可变,setter 不存在也不应该存在。这就是此类和 JavaBeans 之间的主要区别,JavaBeans 是可变的并且同时具有 setter 和 getter。
record
类只允许您将前面的实现替换为以下一行:
除了 final
(不可扩展)和不可变之外,record
不能扩展另一个类,因为 它已经扩展了 java.lang.Record
,但它可以实现另一个接口,如下例所示:
可以在 record
中添加 static
方法,如以下代码片段所示:
static
方法不会也不能访问实例属性,并且只能使用作为参数传递给它的值。
record
可以有另一个构造函数,可以添加,例如如下:
您可能已经注意到,不可能向 record
添加另一个属性或 setter,而所有额外的 getter 都必须仅使用 record
已经提供的 getter。
Sealed classes and interfaces
final
类不能扩展,而非公共类或接口的访问权限有限。然而,有时 需要从任何地方访问类或接口 但只能由某个类或接口扩展,或者,在接口的情况下,仅由某些类实现。这就是在 Java 17 中将 sealed
类和接口添加到 SDK 的动机。
sealed
类或接口与 final
的区别在于 sealed
类或接口总是有一个
permits
关键字,后面是允许扩展 sealed
类或接口,或者,在接口的情况下,实现它。请注意现有这个词。 permits
关键字之后列出的子类型必须在编译时与密封类存在于同一模块中,如果在默认(未命名)模块中,则必须存在于同一包中。
sealed
类的子类型必须标记为 sealed
、final
、或 非密封
。 sealed
接口的子类型必须标记为 sealed
或 非密封
,因为接口不能是final
。
我们先来看一个sealed
接口的例子:
如您所见,EngineBrand
接口 扩展了 Engine
接口并允许(允许)Vehicle
实现。或者,我们可以让 Vehicle
类 直接实现 Engine
接口,如下例所示:
以下是 Car
和 Truck
允许的 Vehicle
密封
类:
在支持 sealed
类中,Java 17 中的 Java Reflections API 有两个新的 方法,isSealed()
和 getPermittedSubclasses()
。以下是它们的用法示例:
sealed
接口与 record
集成得很好,因为 record
是 final
并且可以被列为 作为允许的 实现。
Polymorphism in action
多态性是 OOP 最强大的 和有用的特性。它使用了我们迄今为止介绍的所有其他 OOP 概念和特性。这是掌握 Java 编程的最高概念点。讨论完之后,本书的其余部分将主要介绍 Java 语言语法和 JVM 功能。
正如我们在OOP 概念部分所述,多态性是对象作为不同类的对象或作为不同接口的实现的能力。如果你在网上搜索多态性这个词,你会发现它是以多种不同形式出现的条件。变形是通过自然或超自然的方式将事物或人的形式或性质转变为完全不同的形式或性质。所以,Java polymorphism 是一个对象 表现得好像经历了变态并且在不同的情况下表现出完全不同的行为的能力。条件。
我们将使用对象工厂——具体的编程 工厂的实现,这是一个返回不同原型或类的对象的方法 (https://en.wikipedia.org/wiki/Factory_(object-oriented_programming)。
The object factory
对象工厂 背后的想法是创建一个在特定条件下返回特定类型的新对象 的方法。例如,查看 CalcUsingAlg1
和 CalcUsingAlg2
类:
如您所见,它们都实现了相同的接口,CalcSomething
,但使用不同的算法。现在,假设我们决定在 property
文件中选择所使用的算法。然后,我们可以创建以下对象工厂:
工厂根据 getAlgValueFromPropertyFile()
方法返回的值选择要使用的算法。在第二种算法的情况下,它也使用了getAlg2Prop1FromPropertyFile()
方法和getAlg2Prop2FromPropertyFile ()
获取算法的输入参数。但是这种复杂性对客户是隐藏的:
我们可以添加新的算法变体,更改算法参数的来源或算法选择的过程,但客户端不需要更改代码。这就是多态性的力量。
或者,我们可以使用继承来实现多态行为。考虑以下类:
但是客户端代码没有改变:
如果有选择,经验丰富的 程序员会使用一个通用接口来实现。它允许更灵活的设计,因为 Java 中的类可以实现多个接口,但可以扩展(继承)一个类。
The instanceof operator
不幸的是,生活并不总是那么容易,有时,程序员不得不处理由不相关的类组装而成的代码,甚至是 来自不同的框架。在这种情况下,使用多态可能不是一种选择。但是,您可以隐藏算法选择的复杂性,甚至可以使用 instanceof
运算符来模拟多态行为,该运算符返回 true
对象是某个类的实例。
假设我们有两个不相关的类:
每个类都期望某种类型的对象作为输入:
我们在这里仍然使用多态性,因为我们将输入描述为 Object
类型。我们可以这样做,因为 Object
类是所有 Java 类的基类。
现在,让我们看看 Calculator
类是如何实现的:
如您所见,它使用 instanceof
运算符来选择合适的算法。通过使用 Object
类作为输入类型,Calculator
类利用了多态性< /a> 也是如此,但它的大部分实现与 无关。然而,从外部看,它看起来是多态的,而且确实如此,但只是在一定程度上。
Summary
本章向您介绍了 OOP 的概念以及它们是如何在 Java 中实现的。它提供了对每个概念的解释,并演示了如何在特定的代码示例中使用它。详细讨论了class
和interface
的Java语言结构。您还了解了什么是重载、覆盖和隐藏,以及如何使用 final
关键字来保护方法不被覆盖。
在实战中的多态部分,您了解了强大的 Java 多态特性。本节将所有介绍的材料放在一起,并展示了多态性如何保持在 OOP 的中心。
在下一章中,您将熟悉 Java 语言语法,包括包、导入、访问修饰符、保留和限制关键字,以及 Java 引用类型的某些方面。您还将学习如何使用 this
和 super
关键字,原始类型的扩展和收缩转换,装箱和拆箱、原始类型和引用类型赋值,以及引用类型的 equals()
方法如何工作。
Quiz
- 从以下列表中选择所有正确的 OOP 概念:
- 封装
- 隔离
- 授粉
- 继承
- 从以下列表中选择所有正确的陈述:
- Java 对象具有状态。
- Java 对象具有行为。
- Java 对象具有状态。
- Java 对象具有方法。
- 从以下列表中选择所有正确的陈述:
- 可以继承 Java 对象的行为。
- 可以覆盖 Java 对象的行为。
- 可以重载 Java 对象行为。
- Java 对象的行为可能会不堪重负。
- 从以下列表中选择所有正确的陈述:
- 不同类的 Java 对象可以具有相同的行为。
- 不同类的 Java 对象共享一个父对象状态。
- 不同类的 Java 对象有一个相同类的对象作为父对象。
- 不同类的 Java 对象可以共享行为。
- 从以下列表中选择所有正确的陈述:
- 方法签名包含返回类型。
- 如果返回类型不同,则方法签名不同。
- 如果两个相同类型的参数切换位置,方法签名会发生变化。
- 如果两个不同类型的参数切换位置,方法签名会发生变化。
- 从以下列表中选择所有正确的陈述:
- 封装隐藏了类名。
- 封装隐藏了行为。
- 封装只允许通过方法访问数据。
- 封装不允许直接访问状态。
- 从以下列表中选择所有正确的陈述:
- 该类在
.java
文件中声明。 - 类字节码存储在
.class
文件中。 - 父类存储在
.base
文件中。 child
类存储在.sub
文件中。
- 该类在
- 从以下列表中选择所有正确的陈述:
- 方法定义对象状态。
- 方法定义对象行为。
- 没有参数的方法被标记为
void
。 - 一个方法可以有很多
return
语句。
- 从以下列表中选择所有正确的陈述:
Varargs
被声明为var
类型。Varargs
代表各种参数。Varargs
是一个String
数组。Varargs
可以作为指定类型的数组。
- 从以下列表中选择所有正确的陈述:
- 构造函数是一种创建状态的方法。
- 构造函数的主要职责是初始化状态。
- JVM 总是提供一个默认的构造函数。
- 可以使用
parent
关键字调用父类构造函数。
- 从以下列表中选择所有正确的陈述:
new
运算符为对象分配内存。new
运算符为对象属性分配默认值。new
操作符首先创建一个父对象。new
运算符首先创建一个子对象。
- 从以下列表中选择所有正确的陈述:
Object
类属于java.base
包。Object
类属于java.lang
包。Object
类属于 Java 类库的一个包。- 会自动导入一个
Object
类。
- 从以下列表中选择所有正确的陈述:
- 使用对象调用实例方法。
- 使用类调用静态方法。
- 使用类调用实例方法。
- 使用对象调用静态方法。
- 从以下列表中选择所有正确的陈述:
- 接口中的方法隐式是
public
、static
和final
. - 接口可以具有无需在类中实现即可调用的方法。
- 接口可以包含可以在没有任何类的情况下使用的字段。
- 可以实例化接口。
- 接口中的方法隐式是
- 从以下列表中选择所有正确的陈述:
- 接口的默认方法总是被默认调用。
- 接口的私有方法只能被默认方法调用。
- 无需在类中实现即可调用接口静态方法。
- 默认方法可以增强实现接口的类。
- 从以下列表中选择所有正确的陈述:
Abstract
类可以有默认方法。- 可以在没有
abstract
方法的情况下声明Abstract
类。 - 任何类都可以声明为抽象类。
- 接口是没有构造函数的
抽象
类。
- 从以下列表中选择所有正确的陈述:
- 只能在界面中进行重载。
- 只有在一个类扩展另一个类时才能进行重载。
- 重载可以在任何类中完成。
- 重载的方法必须具有相同的签名。
- 从以下列表中选择所有正确的陈述:
- 只能在
child
类中进行覆盖。 - 可以在界面中进行覆盖。
- 被覆盖的方法必须具有相同的名称。
Object
类的任何方法都不能被覆盖。
- 只能在
- 从以下列表中选择所有正确的陈述:
- 任何方法都可以隐藏。
- 变量可以隐藏属性。
- 可以隐藏静态方法。
- 可以隐藏公共实例属性。
- 从以下列表中选择所有正确的陈述:
- 任何变量都可以声明为final。
- 不能将公共方法声明为 final。
- 可以将受保护的方法声明为 final。
- 可以将类声明为受保护的。
- 从以下列表中选择所有正确的陈述:
- 多态行为可以基于继承。
- 多态行为可以基于重载。
- 多态行为可以基于覆盖。
- 多态行为可以基于接口。