现在,您已经对 Java 及其相关术语和工具有了大致的了解,我们将开始讨论 Java 作为一种编程语言。
本章将介绍 Java 作为一种面向对象编程(OOP语言)的基本概念。您将了解类、接口和对象及其关系。您还将学习 OOP 的概念和功能。
在本章中,我们将介绍以下主题:
- Java 编程中的基本术语
- 类和对象(实例)
- 类(静态)和对象(实例)成员
- 接口、实现和继承
- 面向对象的概念和特性
- 练习-接口与抽象类
我们称之为基础,因为它们是 Java 作为一种语言的基本原则,在您开始专业编程之前,还有很多东西需要学习。对于那些第一次学习 Java 的人来说,学习 Java 的基础知识是一个很难攀登的陡坡,但之后的道路会变得更容易。
Java 编程基础的概念有很多解释。一些教程假定任何面向对象语言的基础都是相同的。其他人则讨论语法和基本语言元素以及语法规则。还有一些人将基础简化为允许计算的值类型、运算符、语句和表达式。
我们对 Java 基础知识的看法包含了前面每种方法中的一些元素。我们选择的唯一标准是实用性和复杂性的逐渐增加。我们将在本节中从简单的定义开始,然后在后续章节中深入探讨这些定义。
用最广义的术语来说,Java 程序(或任何计算机程序)意味着计算机的一组顺序指令,它们告诉计算机该做什么。在计算机上执行之前,必须将程序从人类可读的高级编程语言编译成机器可读的二进制代码。
在 Java 的情况下,称为源代码的人类可读文本存储在一个.java
文件中,可由 Java 编译器javac
编译成字节码。Java 字节码是 JVM 的指令集。字节码存储在.class
文件中,可由 JVM 或更具体地说,由 JVM 使用的实时(JIT编译器)解释并编译为二进制代码。然后由微处理器执行二进制代码。
字节码的一个重要特性是,它可以从一台机器复制并在另一台机器的 JVM 上执行。这就是 Java 可移植性的含义。
bug一词早在 19 世纪就存在了,意思是小错误和小困难。这个词的起源不得而知,但它看起来好像动词to bug在某种意义上to 烦扰来自一种昆虫发出的烦人的烦扰感——一种嗡嗡作响并威胁要咬你或什么的昆虫。计算机一问世,这个词就被用来指编程缺陷。
缺陷的严重程度不同——缺陷对程序执行或结果的影响程度不同。有些缺陷是非常无关紧要的,比如为人提供数据的格式。如果不能处理以这种格式呈现的数据的其他系统必须使用相同的数据,那将是另一回事。然后,这种缺陷可能被认定为关键缺陷,因为它不允许系统完成数据处理。
缺陷的严重性取决于它对程序的影响,而不是修复它的难度。
某些缺陷可能会迫使程序在达到预期结果之前退出。例如,缺陷可能导致内存或其他资源耗尽,并导致 JVM 关闭。
缺陷优先级,即缺陷在待办事项列表上的优先级,通常与严重性相对应。但是,由于客户的看法,一些严重性较低的缺陷可能会被优先考虑。例如,网站上的语法错误,或可能被视为冒犯性的打字错误。
缺陷的优先级通常与其严重程度相对应,但有时,优先级可能会根据客户的感知而增加。
我们还提到,一个程序可能需要使用已经编译成字节码的其他程序和过程。为了让 JVM 找到它们,您必须使用-classpath
选项在java
命令中列出相应的.class
文件。几个程序和过程组成了一个 Java 应用程序。
应用程序用于其任务的其他程序和过程称为应用程序依赖项。
注意,JVM 不会读取.class
文件,直到其他类代码请求它。因此,如果在应用程序执行过程中不出现需要它们的条件,那么类路径上列出的一些.class
文件可能永远不会被使用。
语句是一种语言结构,可以编译成一组计算机指令。从日常生活到 Java 语句,最接近的类比是英语中的句子,这是表达完整思想的基本语言单位。Java 中的每个语句都必须以;
(分号)结尾。
下面是一个声明语句的示例:
int i;
前面的语句中的是什么类型的变量。
下面是一个表达式语句:
i + 2;
前面的语句将现有变量i
的值加上 2。声明时,int
变量默认赋值为 0,因此此表达式的结果为2
,但不存储。这就是为什么它经常与声明和赋值语句结合在一起:
int j = i + 2;
这告诉处理器创建类型为int
的变量j
,并为其分配一个值,该值等于分配给变量i
的当前值增加2
。在第 9 章、运算符、表达式和语句中,我们将更详细地讨论语句和表达式。
Java 方法是一组语句,它们总是一起执行,目的是生成特定结果以响应特定输入。一个方法有一个名称,一组输入参数或根本没有参数,在{}
括号内有一个主体,还有一个返回类型或void
关键字,指示消息不返回任何值。下面是一个方法示例:
int multiplyByTwo(int i){
int j = i * 2;
return j;
}
在前面的代码片段中,方法名称为multiplyByTwo
。它有一个类型为int
的输入参数。方法名称和参数类型列表一起称为方法签名。输入参数的数量称为arity。如果两个方法在输入参数列表中具有相同的名称、相同的算术数和相同的类型序列,则它们具有相同的签名。
这是对 Java 规范第*8.4.2 节“方法签名”*中方法签名定义的改写。另一方面,在相同的规范中,可能会遇到这样的短语:具有相同名称和签名的多个方法、方法getNumberOfScales
在类Tuna
中具有名称、签名和返回类型等等。所以,当心;即使是规范的作者有时也不将方法名称包含在方法签名的概念中,如果其他程序员也这样做,也不要感到困惑。
上述相同的方法可以以多种样式重新编写,并具有相同的结果:
int multiplyByTwo(int i){
return i * 2;
}
另一种风格如下:
int multiplyByTwo(int i){ return i * 2; }
一些程序员更喜欢最简洁的风格,以便能够在屏幕上看到尽可能多的代码。但这可能会降低其他程序员理解代码的能力,从而导致编程缺陷。
另一个例子是没有输入参数的方法:
int giveMeFour(){ return 4; }
这是毫无用处的。实际上,一个没有参数的方法将从数据库或其他源读取数据。我们展示这个示例只是为了演示语法。
下面是一个不执行任何操作的代码示例:
void multiplyByTwo(){ }
前面的方法不执行任何操作,也不返回任何内容。该语法要求使用关键字void
指示不存在返回值。实际上,通常使用没有返回值的方法将数据记录到数据库,或将数据发送到打印机、电子邮件服务器、另一个应用程序(例如,使用 web 服务),等等。
下面是一个具有多个参数的方法示例,仅供全面概述:
String doSomething(int i, String s, double a){
double result = Math.round(Math.sqrt(a)) * i;
return s + Double.toString(result);
}
上述方法从第三个参数中提取平方根,将其乘以第一个参数,将结果转换为字符串,并将结果附加(连接)到第二个参数。所使用的类Math
中的类型和方法将在第 5 章、Java 语言元素和类型中介绍。这些计算没有多大意义,仅用于说明目的。
Java 中的所有方法都在名为类的结构中声明。类在括号{}
中有名称和主体,其中声明了方法:
class MyClass {
int multiplyByTwo(int i){ return i * 2; }
int giveMeFour(){ return 4;}
}
类也有字段,通常称为属性;我们将在下一节讨论它们。
一个类作为 Java 应用程序的入口。启动应用程序时,必须在java
命令中指定:
java -cp <location of all .class files> MyGreatApplication
在前面的命令中,MyGreatApplication
是作为应用程序起点的类的名称。当 JVM 找到文件MyGreatApplication.class
时,它会将其读入内存并在其中查找名为main()
的方法。此方法具有固定签名:
public static void main(String[] args) {
// statements go here
}
让我们将前面的代码片段分成几部分:
-
public
表示任何外部程序都可以访问此方法(参见第 7 章、包和可访问性(可见性)) -
static
表示所有内存中只存在该方法的一个副本(见下节) -
void
表示不返回任何内容 -
main
是方法名称 -
String[] args
表示接受字符串值数组作为输入参数(参见第 5 章、Java 语言元素和类型) -
//
表示它是一条注释,被 JVM 忽略,只放在这里供人类使用(参见第 5 章、Java 语言元素和类型)
前面的main()
方法不起任何作用。如果运行,它将成功执行,但不会产生任何结果。
您还可以看到输入参数如下所示:
public static void main(String... args) {
//body that does something
}
它看起来像一个不同的签名,但事实上,它是相同的。自 JDK5 以来,Java 允许将方法签名的最后一个参数声明为具有相同类型的变量 arity 的参数序列。这被称为varargs。在方法内部,可以将最后一个输入参数视为数组String[]
,无论它是显式声明为数组还是 varargs。如果你在生活中从不使用 varargs,你会很好的。我们告诉你这件事只是为了让你在阅读别人的代码时避免混淆。
最后,main()
方法的一个重要特征是其输入参数的来源。没有其他代码调用它。它由 JVM 本身调用。那么,这些参数从何而来?可以猜测,命令行是参数值的来源。在java
命令中,到目前为止,我们假设没有向主类传递任何参数。但是如果 main 方法需要一些参数,我们可以按如下方式构造命令行:
java -cp <location of all .class files> MyGreatApplication 1 2
这意味着在main()
方法中,输入数组args[0]
的第一个元素的值将是1
,而输入数组args[1]
的第二个元素的值将是2
。是的,您注意到了,数组中的元素计数以0
开始。我们将在第 5 章、Java 语言元素和类型中进一步讨论这一点。无论是使用数组String[] args
显式描述main()
方法签名,还是使用 varargsString... args
描述main()
方法签名,结果都是相同的。
然后,main()
方法中的代码调用同一主.class
文件中的方法,或者调用与-classpath
选项一起列出的其他.class
文件中的方法。在下一节中,我们将了解如何进行此类调用。
类用作创建对象的模板。创建对象时,类中声明的所有字段和方法都会复制到对象中。对象中字段值的组合称为对象状态。这些方法提供对象行为。对象也称为类的实例。
每个对象都是通过使用操作符new
和一个看起来像一种特殊方法的构造函数来创建的。构造函数的主要职责是设置初始对象状态。
现在让我们更仔细地看看 Java 类和对象。
Java 类存储在.java
文件中。每个.java
文件可能包含几个类。它们由 Java 编译器javac
编译并存储在.class
文件中。每个.class
文件只包含一个编译类。
每个.java
文件只包含一个public
类。类名前面的关键字public
使它可以从其他文件中的类访问。文件名必须与公共类名匹配。该文件也可以包含其他类,它们被编译成自己的.class
文件,但它们只能由命名为.java
文件的公共类访问。
文件MyClass.java
的内容可能是这样的:
public class MyClass {
private int field1;
private String field2;
public String method1(int i){
//statements, including return statement
}
private void method2(String s){
//statements without return statement
}
}
它有两个字段。关键字private
使它们只能从类内部及其方法访问。前面的类有两个方法——一个是 public,一个是 private。公共方法可由任何其他类访问,而私有方法只能从同一类的其他方法访问。
这个类似乎没有构造函数。那么,基于这个类的对象的状态将如何初始化呢?答案是,事实上,每个类都没有显式定义其构造函数,而是得到一个没有参数的默认构造函数。这里有两个显式添加构造函数的示例,一个没有参数,另一个有参数:
public class SomeClass {
private int field1;
public MyClass(){
this.field1 = 42;
}
//... other content of the class - methods
// that define object behavior
}
public class MyClass {
private int field1;
private String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
//... methods here
}
在前面的代码段中,关键字this
表示当前对象。它的用法是可选的。我们可以编写field1 = val1;
并获得相同的结果。但最好使用关键字this
以避免混淆,尤其是当(程序员经常这样做)参数名称与字段名称相同时,例如在以下构造函数中:
public MyClass(int field1, String field1){
field1 = field1;
field2 = field2;
}
添加关键字this
使代码对人眼更友好。有时候,这是必要的。我们将在第 6 章、接口、类和对象构造中讨论此类情况。
构造函数还可以调用此类或任何其他可访问类的方法:
public class MyClass {
private int field1;
private String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
method1(33);
method2(val2);
}
public String method1(int i){
//statements, including return statement
}
private void method2(String s){
//statements without return statement
}
}
如果类没有显式定义构造函数,它将从默认基类java.lang.Object
获取默认构造函数。我们将在即将到来的继承部分中解释它的含义。
一个类可以有几个具有不同签名的构造函数,如果应用程序逻辑需要,可以使用这些构造函数创建具有不同状态的对象。将带有参数的显式构造函数添加到类后,除非也显式添加默认构造函数,否则无法访问该构造函数。为了澄清,此类只有一个默认构造函数:
public class MyClass {
private int field1;
private String field2;
//... other methods here
}
该类也只有一个构造函数,但没有默认构造函数:
public class MyClass {
private int field1;
private String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
//... other methods here
}
此类有两个带参数和不带参数的构造函数:
public class MyClass {
private int field1;
private String field2;
public MyClass(){ }
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
//... other methods here
}
前面没有参数的构造函数不执行任何操作。提供它只是为了方便需要创建此类对象的客户机代码,而不关心对象的特定初始状态。在这种情况下,JVM 会创建默认的初始对象状态。我们将在第 6 章、接口、类和对象构造中解释默认状态是什么。
由任何构造函数创建的同一类的每个对象都具有相同的方法(相同的行为),即使其状态(分配给字段的值)不同。
这些关于 Java 类的信息对于初学者来说已经足够了。尽管如此,我们还想描述一些可以包含在同一.java
文件中的其他类,以便您在其他人的代码中识别它们。这些其他类称为嵌套类。只能从同一文件中的类访问它们。
我们前面描述的类,.java
文件中唯一的一个公共类,也称为顶级类。它可以包括一个称为内部类的嵌套类:
public class MyClass { // top-level class
class MyOtherClass { // inner class
//inner class content here
}
}
顶级类还可以包括静态(下一节将详细介绍静态成员)嵌套类。static
类不是内部类,只是嵌套类:
public class MyClass { // top-level class
static class MyYetAnotherClass { // nested class
// nested class content here
}
}
任何方法都可以包含一个只能在该方法中访问的类。它被称为本地类:
public class MyClass { // top-level class
void someMethod() {
class MyInaccessibleAnywhereElseClass { // local class
// local class content here
}
}
}
本地类不经常使用,但不是因为它没有用处。程序员只是不记得如何创建一个只在一个方法内部需要的类,而是创建一个外部或内部类。
最后一种但并非最不重要的类,可以包含在与公共类相同的文件中,称为匿名类。它是一个没有名称的类,允许就地创建一个对象,该对象可以重写现有方法或实现接口。假设我们有以下接口InterfaceA
和类MyClass
:
public interface InterfaceA{
void doSomething();
}
public class MyClass {
void someMethod1() {
System.out.println("1\. Regular is called");
}
void someMethod2(InterfaceA interfaceA) {
interfaceA.doSomething();
}
}
我们可以执行以下代码:
MyClass myClass = new MyClass();
myClass.someMethod1();
myClass = new MyClass() { //Anonymous class extends class MyClass
public void someMethod1(){ // and overrides someMethod1()
System.out.println("2\. Anonymous is called");
}
};
myClass.someMethod1();
myClass.someMethod2(new InterfaceA() { //Anonymous class implements
public void doSomething(){ // InterfaceA
System.out.println("3\. Anonymous is called");
}
});
结果将是:
1\. Regular is called
2\. Anonymous is called
3\. Anonymous is called
我们并不期望读者能够完全理解前面的代码。我们希望读者在读完这本书后能够做到这一点。
这是一个很长的部分,有很多信息。大部分只是供参考,所以如果你不记得所有的事情,不要感到难过。在读完本书并获得了一些 Java 编程的实践经验之后,只需重新阅读本部分。
接下来还有几个介绍性的部分。然后第 3 章您的开发环境设置将引导您完成计算机上开发工具的配置,并且在第 4 章您的第一个 Java 项目中,您将开始编写代码并执行它—每个软件开发人员都记得的那一刻。
再多几个步骤,您就可以称自己为 Java 程序员了。
人们经常阅读 Oracle 文档,对象用于对现实世界的对象建模也不例外。这种观点起源于面向对象编程之前。当时,程序有一个公共或全局区域来存储中间结果。如果不小心管理,不同的子程序和程序(即调用的方法)会修改这些值,相互影响,使跟踪缺陷变得非常困难。当然,程序员试图规范对数据的访问,并使中间结果只能通过某些方法访问。一组只有他们才能访问的方法和数据开始被称为对象。
这种结构也被视为真实世界对象的模型。我们周围的所有对象可能都有某种内部状态,但我们无法访问它,只知道对象的行为。也就是说,我们可以预测他们对这个或那个输入的反应。在类(对象)中创建只能从同一类(对象)的方法访问的私有字段似乎是隐藏对象状态的解决方案。这样一来,对真实世界对象进行建模的原始想法就得以实现。
但是,经过多年的面向对象编程,许多程序员意识到,当人们试图将这种观点一致地应用于各种软件对象时,这种观点可能会产生误导,实际上是非常有害的。例如,对象可以携带用作算法参数的值,该算法与任何真实对象无关,但与计算效率有关。或者,作为另一个例子,返回计算结果的对象。程序员通常称之为数据传输对象(DTO。它与真实世界的对象无关,除非拉伸真实世界对象的定义,但这将是拉伸。
软件对象只是计算机内存中存储实际值的数据结构。内存是真实世界的对象吗?物理存储单元是,但它们携带的信息并不代表这些单元。它表示软件对象的值和方法。关于对象的信息甚至不存储在连续内存区域中:对象状态存储在称为 heap 的区域中,而方法存储在 method 区域中,取决于 JVM 实现,method 区域可能是堆的一部分,也可能不是堆的一部分。
根据我们的经验,一个对象是计算过程中不可或缺的一部分,而计算过程往往不在现实世界对象的模型上运行。对象用于传递值和方法,这些值和方法有时相关,有时不相关。出于方便或任何其他考虑,可以将一组方法和值分组到一个类中。
公平地说,有时软件对象确实代表了真实世界对象的模型。但问题是,情况并非总是如此。因此,让我们不要把软件对象看作是真实世界对象的模型,除非它们真的是。相反,让我们看看对象是如何创建和使用的,以及它们如何帮助我们构建有用的功能——应用程序。
如前一节所述,使用关键字new
和构造函数(默认值或显式声明的构造函数)基于类创建对象。例如,考虑下面的类:
public class MyClass {
private int field1;
private String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
public String method1(int i){
//statements, including return statement
}
//... other methods are here
}
如果我们有这个类,我们可以用其他类的方法编写以下内容:
public AnotherClass {
...
public void someMethod(){
MyClass myClass = new MyClass(3, "some string");
String result = myClass.method1(2);
}
...
}
在前面的代码中,语句MyClass myClass = new MyClass(3, "some string");
使用其构造函数和关键字new
创建了一个类为MyClass
的对象,并将新创建的对象的引用分配给变量myClass
。我们选择了一个对象引用的标识符,该标识符将类的名称与小写的第一个字母匹配。这只是一个约定,我们可以选择另一个具有相同结果的标识符(例如boo
)。在第 5 章Java 语言元素和类型中,我们更详细地讨论了标识符和变量。正如您在前面示例的下一行中所看到的,一旦创建了引用,我们就可以使用它访问新创建对象的公共成员。
任何 Java 对象都只有一种创建方式:使用关键字(operator)new
和构造函数。这个过程也称为类实例化。对该对象的引用可以作为任何其他值(变量、参数或返回值)传递,并且每个访问该引用的代码都可以使用它访问该对象的公共成员。我们将在下一节解释什么是公共成员。
我们提到了与对象相关的“公共成员”一词。在谈到main()
方法时,我们也使用了关键字static
。我们还声明,声明为static
的成员在 JVM 内存中只能有一个副本。现在,我们将定义所有这些,以及更多。
关键词private
和public
称为访问修饰符。还有默认和protected
访问修饰符,但我们将在第 7 章、*包和可访问性(可见性)*中讨论它们。它们之所以称为访问修饰符,是因为它们调节了类、方法和字段从类外部的可访问性(有时也称为可见性),还因为它们修改了相应类、方法或字段的声明。
仅当类是嵌套类时,它可以是私有的。在前面的Java 类部分中,我们没有对嵌套类使用显式访问修饰符(因此,我们使用了默认的修饰符),但是如果我们希望只允许从顶级类和同级访问这些类,我们可以将它们设为私有。
私有方法或私有字段只能从声明它的类(对象)中访问。
相反,公共类、方法或字段可以从任何其他类访问。请注意,如果封闭类是私有的,则方法或字段不能是公共的。这很有道理,不是吗?如果类本身是公开不可访问的,那么它的成员怎么可能是公开的?
只有当类是嵌套类时,才能将其声明为静态。类成员、方法和字段也可以是静态的,只要类不是匿名的或本地的。任何代码都可以访问类的静态成员,而无需创建类实例(对象)。在前面的部分中,我们在其中一个代码片段中使用了类Math
时看到了这样一个示例。静态类成员在字段中也称为类变量,在方法中称为类方法。请注意,这些名称包含作为形容词的单词class
。这是因为静态成员与类关联,而不是与类实例关联。这意味着 JVM 内存中只能存在静态成员的一个副本,尽管可以随时创建并驻留该类的许多实例(对象)。
这里是另一个例子。假设我们有以下类:
public class MyClass {
private int field1;
public static String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
public String method1(int i){
//statements, including return statement
}
public static void method2(){
//statements
}
//... other methods are here
}
从任何其他类的任何方法中,可以访问前面MyClass
类的公共静态成员,如下所示:
MyClass.field2 = "any string";
String s = MyClass.field2 + " and another string";
上述操作的结果将被分配给值any string and another string
的变量s
。String
类将在第 5 章、Java 语言元素和类型中进一步讨论。
类似地,可以访问MyClass
类的公共静态方法method2()
,如下所示:
MyClass.method2();
MyClass
类的其他方法仍然可以通过实例(对象)访问:
MyClass mc = new MyClass(3, "any string");
String someResult = mc.method1(42);
显然,如果MyClass
类的所有成员都是静态的,则不需要创建该类的对象。
然而,有时可以使用对象引用访问静态成员。下面的代码可能会工作——这取决于javac
编译器的实现。如果有效,则产生与前面代码相同的结果:
MyClass mc = new MyClass(3, "any string");
mc.field2 = "Some other string";
mc.method2();
一些编译器提供了一个警告,比如静态成员被实例引用访问,但它们仍然允许您这样做。其他人产生错误无法对非静态方法/字段进行静态引用并强制您更正代码。Java 规范没有规定这种情况。但是,通过对对象的引用来访问静态类成员并不是一个好的做法,因为这会使代码对人类读者来说不明确。因此,即使编译器更宽容,也最好避免这样做。
非静态类成员在字段中也称为实例变量,在方法中称为实例方法。它只能通过引用后跟一个点“.
”的对象来访问。我们已经看到了几个这样的示例。
根据由来已久的传统,对象的字段通常被声明为私有。如有必要,提供方法set()
和/或get()
来访问这些私有值。它们通常被称为 setter 和 getter,因为它们设置并获取私有字段的值。以下是一个例子:
public class MyClass {
private int field1;
private String field2;
public void setField1(String val){
this.field1 = val;
}
public String getField1(){
return this.field1;
}
public void setField2(String val){
this.field2 = val;
}
public String getField2(){
return this.field2;
}
//... other methods are here
}
有时,必须确保对象状态不能更改。为了支持这种情况,程序员使用构造函数设置状态并删除 setter:
public class MyClass {
private int field1;
private String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
public String getField1(){
return this.field1;
}
public String getField2(){
return this.field2;
}
//... other non-setting methods are here
}
这样的对象称为不可变的。
两个名称相同但签名不同的方法表示方法重载。以下是一个例子:
public class MyClass {
public String method(int i){
//statements
}
public int method(int i, String v){
//statements
}
}
不允许出现以下情况,因为返回值不是方法签名的一部分,如果它们具有相同的签名,则不能用于区分一个方法和另一个方法,这将导致编译错误:
public class MyClass {
public String method(int i){
//statements
}
public int method(int i){ //error
//statements
}
}
但是,这是允许的,因为这些方法具有不同的签名:
public String method(String v, int i){
//statements
}
public String method(int i, String v){
//statements
}
现在,我们进入了 Java 编程最重要的领域——大量使用的接口、实现和继承的 Java 编程术语。
在日常生活中,界面这个词相当流行。它的含义非常接近 Java 接口在编程中所起的作用。它定义对象的公共面。它描述了如何与对象交互,以及对对象的期望。它隐藏内部类工作,并仅公开带有返回值和访问修饰符的方法签名。无法实例化接口。接口类型的对象只能通过创建实现该接口的类的对象来创建(接口实现将在下一节中详细介绍)。
例如,查看以下类:
public class MyClass {
private int field1;
private String field2;
public MyClass(int val1, String val2){
this.field1 = val1;
this.field2 = val2;
}
public String method(int i){
//statements
}
public int method(int i, String v){
//statements
}
}
其界面如下:
public interface MyClassInterface {
String method(int i);
int method(int i, String v);
}
所以,我们可以写public class MyClass implements MyClassInterface {...}
。我们将在下一节讨论它。
由于接口是公共面,因此默认情况下假设方法访问修饰符public
,可以省略。
接口不描述如何创建类的对象。要发现这一点,必须查看该类,并查看其构造函数的签名。您还可以检查并查看是否存在可以在不创建对象的情况下访问的公共静态类成员。因此,接口只是类实例的公共面。
让我们来介绍其余的接口功能。根据 Java 规范,*接口主体可以声明接口的成员,即字段、方法、类和接口。*如果您感到困惑,并询问接口和类之间的区别是什么,那么您有一个合理的问题,我们现在要解决这个问题。
接口中的字段是隐式公共、静态和最终字段。修饰符final
表示其值不能更改。相反,在类中,类本身、其字段、方法和构造函数的隐式(默认)访问修饰符是包私有的,这意味着它仅在自己的包中可见。包是相关类的命名组。您将在第 7 章、*包和可访问性(可见性)*中了解它们。
接口主体中的方法可以声明为默认、静态或私有。默认方法的用途将在下一节中解释。静态方法可以通过接口名称和点“.
”从任何地方访问。私有方法只能由同一接口内的其他方法访问。相反,类中方法的默认访问修饰符是包私有的。
至于在接口内声明的类,它们是隐式静态的。它们也是公共的,可以在没有接口实例的情况下访问,而接口实例无论如何都无法创建。我们不打算更多地讨论此类类,因为它们用于超出本书范围的非常特殊的领域。
与类类似,接口允许在其主体内声明内部接口或嵌套接口。它可以像任何静态成员一样从外部访问,使用带有点“.
的顶级接口。我们想提醒您,接口在默认情况下是公共的,不能实例化,因此在默认情况下是静态的。
还有最后一个与接口相关的非常重要的术语。没有实现的接口中列出的方法签名称为抽象方法,接口本身称为抽象,因为它抽象、汇总并从实现中删除签名。抽象不能被实例化。例如,如果您将关键字abstract
放在任何类的前面,并尝试创建其对象,则即使该类中的所有方法都不是抽象的,编译器也会抛出错误。在这种情况下,该类仅作为默认方法的接口。然而,它们的用法有很大的不同,在阅读本章即将到来的继承一节后,您将看到这一点。
我们将在第 6 章、接口、类和对象构造中详细介绍接口,并在第 7 章、*包和可访问性(可见性)*中介绍它们的访问修饰符。
它意味着每个类的抽象体中都有一个类可以实现的方法。以下是一个例子:
interface Car {
double getWeightInPounds();
double getMaxSpeedInMilesPerHour();
}
public class CarImpl implements Car{
public double getWeightInPounds(){
return 2000d;
}
public double getMaxSpeedInMilesPerHour(){
return 100d;
}
}
我们将类命名为CarImpl
,以表明它是接口Car
的实现。但是我们可以用我们喜欢的任何其他方式来命名它。
接口及其类实现也可以有其他方法,而不会导致编译器错误。接口中额外方法的唯一要求是它必须是默认的并且有一个主体。向类添加任何其他方法都不会干扰接口实现。例如:
interface Car {
double getWeightInPounds();
double getMaxSpeedInMilesPerHour();
default int getPassengersCount(){
return 4;
}
}
public class CarImpl implements Car{
private int doors;
private double weight, speed;
public CarImpl(double weight, double speed, int doors){
this.weight = weight;
this.speed = speed;
this.dooes = doors;
}
public double getWeightInPounds(){
return this.weight;
}
public double getMaxSpeedInMilesPerHour(){
return this.speed;
}
public int getNumberOfDoors(){
return this.doors;
}
}
如果我们现在创建一个类CarImpl
的实例,我们可以调用我们在该类中声明的所有方法:
CarImpl car = new CarImpl(500d, 50d, 3);
car.getWeightInPounds(); //Will return 500.0
car.getMaxSpeedInMilesPerHour(); //Will return 50.0
car.getNumberOfDoors(); //Will return 3
这并不奇怪。
但是,这里有一些你可能没有预料到的事情:
car.getPassengersCount(); //Will return 4
这意味着,通过实现接口类,可以获得接口拥有的所有默认方法。这就是默认方法的目的:向实现接口的所有类添加功能。如果没有它,如果我们向旧接口添加抽象方法,所有当前接口实现都将触发编译器错误。但是,如果我们添加一个带有修饰符 default 的新方法,现有的实现将继续正常工作。
现在,另一个好把戏。如果一个类实现了一个与默认方法具有相同签名的方法,它将override
(一个技术术语)描述接口的行为。以下是一个例子:
interface Car {
double getWeightInPounds();
double getMaxSpeedInMilesPerHour();
default int getPassengersCount(){
return 4;
}
}
public class CarImpl implements Car{
private int doors;
private double weight, speed;
public CarImpl(double weight, double speed, int doors){
this.weight = weight;
this.speed = speed;
this.dooes = doors;
}
public double getWeightInPounds(){
return this.weight;
}
public double getMaxSpeedInMilesPerHour(){
return this.speed;
}
public int getNumberOfDoors(){
return this.doors;
}
public int getPassengersCount(){
return 3;
}
}
如果我们使用本例中描述的接口和类,我们可以编写以下代码:
CarImpl car = new CarImpl(500d, 50d, 3);
car.getPassengersCount(); //Will return 3 now !!!!
如果没有实现接口的所有抽象方法,则必须将该类声明为抽象类,并且不能实例化。
接口的目的是表示它的实现——实现它的所有类的所有对象。例如,我们可以创建另一个实现Car
接口的类:
public class AnotherCarImpl implements Car{
public double getWeightInPounds(){
return 2d;
}
public double getMaxSpeedInMilesPerHour(){
return 3d;
}
public int getNumberOfDoors(){
return 4;
}
public int getPassengersCount(){
return 5;
}
}
然后我们可以让Car
接口代表它们中的每一个:
Car car = new CarImpl(500d, 50d, 3);
car.getWeightInPounds(); //Will return 500.0
car.getMaxSpeedInMilesPerHour(); //Will return 50.0
car.getNumberOfDoors(); //Will produce compiler error
car.getPassengersCount(); //Still returns 3 !!!!
car = new AnotherCarImpl();
car.getWeightInPounds(); //Will return 2.0
car.getMaxSpeedInMilesPerHour(); //Will return 3.0
car.getNumberOfDoors(); //Will produce compiler error
car.getPassengersCount(); //Will return 5
通过查看前面的代码片段,可以得出一些有趣的观察结果。首先,当变量car
被声明为接口类型(而不是上例中的类类型)时,不能调用接口中未声明的方法。
第二,car.getPassengersCount()
方法第一次返回3
。人们可能期望它返回4
,因为car
被声明为接口类型,并且人们可能期望默认方法能够工作。但是,实际上,变量car
引用了类CarImpl
的对象,这就是为什么要执行car.getPassengersCount()
方法的类实现。
使用接口时,您应该记住签名来自接口,但实现来自类,或者如果类未实现它,则来自默认接口方法。这里展示了默认方法的另一个特性。它们既是可以实现的签名,又是类未实现的实现。
如果一个接口中有多个默认方法,则可以创建只能由该接口的默认方法访问的私有方法。它们可以用来包含公共功能,而不是在每个默认方法中重复。无法从接口外部访问私有方法。
有了它,我们现在可以达到 Java 基础复杂性的顶峰。在这之后,一直到本书的结尾,我们将只添加一些细节并培养您的编程技能。这将是在高海拔的高原上行走——你走得越久,感觉就越舒服。但是,要达到这个高度,我们需要爬上最后一个上坡;遗产
一个类可以获取(继承)所有非私有的非静态成员,因此当我们使用这个类的对象时,我们无法知道这些成员实际驻留在什么地方——在这个类中还是在继承它们的类中。为了表示继承,使用关键字extends
。例如,考虑下面的类:
class A {
private void m1(){...}
public void m2(){...}
}
class B extends class A {
public void m3(){...}
}
class C extends class B {
}
在本例中,类B
和C
的对象的行为就好像它们每个都有方法m2()
和m3()
。唯一的限制是一个类只能扩展一个类。类别A
是类别B
和类别C
的基本(或父)类别。类B
仅是类C
的基类。而且,正如我们已经提到的,它们每个都有默认的基类java.lang.Object
。类B
和C
是类A
的子类。类C
也是类B
的一个子类。
相反,一个接口可以同时扩展到许多其他接口。如果AI
、BI
、CI
、DI
、EI
、FI
为接口,则允许:
interface AI extends BI, CI, DI {
//the interface body
}
interface DI extends EI, FI {
//the interface body
}
在上例中,接口AI
继承了接口BI
、CI
、DI
、EI
和FI
以及作为接口BI
、CI
、DI
、EI
和FI
基础接口的任何其他接口的所有非私有非静态签名。
回到上一节的主题实现,一个类可以实现很多接口:
class A extends B implements AI, BI, CI, DI {
//the class body
}
这意味着类A
继承类B
的所有非私有非静态成员,并实现接口AI
、BI
、CI
、DI
及其基本接口。实现多个接口的能力来自这样一个事实:如果像这样重新编写,前面的示例将具有完全相同的结果:
interface AI extends BI, CI, DI {
//the interface body
}
class A extends B implements AI {
//the class body
}
extended
接口(类)也称为超级接口(超类)或父接口(父类)。扩展接口(类)称为子接口(子类)或子接口(子类)。
让我们用例子来说明这一点。我们从接口继承开始:
interface Vehicle {
double getWeightInPounds();
}
interface Car extends Vehicle {
int getPassengersCount();
}
public class CarImpl implements Car {
public double getWeightInPounds(){
return 2000d;
}
public int getPassengersCount(){
return 4;
}
}
前面代码中的类CarImpl
必须实现两个签名(在接口Vehicle
和接口Car
中列出),因为从其角度来看,它们都属于接口Car
。否则,编译器会抱怨,或者类CarImpl
必须声明为抽象类(并且不能实例化)。
现在,让我们看另一个例子:
interface Vehicle {
double getWeightInPounds();
}
public class VehicleImpl implements Vehicle {
public double getWeightInPounds(){
return 2000d;
}
}
interface Car extends Vehicle {
int getPassengersCount();
}
public class CarImpl extends VehicleImpl implements Car {
public int getPassengersCount(){
return 4;
}
}
在本例中,类CarImpl
不需要实现抽象方法getWeightInPounds()
,因为它继承了基类VehicleImpl
的实现。
对于初学者来说,所描述的类继承的一个结果通常不是直观的。为了演示它,让我们将方法getWeightInPounds()
添加到类CarImpl
:
public class VehicleImpl {
public double getWeightInPounds(){
return 2000d;
}
}
public class CarImpl extends VehicleImpl {
public double getWeightInPounds(){
return 3000d;
}
public int getPassengersCount(){
return 4;
}
}
在本例中,为了简单起见,我们不使用接口。因为类CarImpl
是类VehicleImpl
的子类,所以它可以作为类VehicleImpl
的对象,这段代码可以很好地编译:
VehicleImpl vehicle = new CarImpl();
vehicle.getWeightInPounds();
问题是,您希望在前面代码段的第二行中返回什么值?如果你猜 3000,你是对的。如果没有,不要感到尴尬。习惯它需要时间。规则是基类类型的引用可以引用其任何子类的对象。它广泛用于重写基类行为。
峰会即将结束。只剩下一步了,尽管它带来了一些在阅读本书之前对 Java 一无所知的情况下您可能不会想到的东西。
所以,这里有一个惊喜。默认情况下,每个 Java 类(没有显式声明)都扩展了类Object
。确切地说,它是java.lang.Object
,但我们还没有介绍包,只在第 7 章、*包和可访问性(可见性)*中讨论。
所有 Java 对象都继承它的所有方法。其中有十个:
public boolean equals (Object obj)
public int hashCode()
public Class getClass()
public String toString()
protected Object clone()
public void wait()
public void wait(long timeout)
public void wait(long timeout, int nanos)
public void notify()
public void notifyAll()
让我们简要地介绍一下这些方法。
在此之前,我们想提到的是,您可以在类中重写它们的默认行为,并以您需要的任何方式重新实现它们,这是程序员经常做的。我们将在第 6 章、接口、类和对象构造中解释如何做到这一点。
java.lang.Object
类的equals()
方法如下:
public boolean equals(Object obj) {
//compares references of the current object
//and the reference obj
}
以下是其用法示例:
Car car1 = new CarImpl();
Car car2 = car1;
Car car3 = new CarImpl();
car1.equals(car2); //returns true
car1.equals(car3); //returns false
从前面的示例中可以看到,默认方法equals()
的实现只比较指向对象存储地址的内存引用。这就是为什么引用car1
和car2
是相等的——因为它们指向相同的对象(相同的内存区域,相同的地址),而car3
引用指向另一个对象。
equals()
方法的典型重新实现使用对象的状态进行比较。我们将在第 6 章、接口、类和对象构造中解释如何实现这一点。
java.lang.Object
类的hashCode()
方法如下:
public int hashCode(){
//returns a hash code value for the object
//based on the integer representation of the memory address
}
Oracle 文档指出,如果根据前面描述的equals()
方法的默认行为,两个方法是相同的,那么它们具有相同的hashCode()
返回值。太棒了!但不幸的是,同一文档指出两个不同的(根据equals()
方法)对象可能具有相同的hasCode()
返回值。这就是为什么程序员喜欢重新实现hashCode()
方法,并在重新实现equals()
方法时使用它,而不是使用对象状态。不过,这样做的必要性并不经常出现,我们也不打算详细讨论这样一种实施方式。如果感兴趣,你可以在网上找到关于它的好文章。
java.lang.Object
类的getClass()
方法如下:
public Class getClass(){
//returns object of class Class that has
//many methods that provide useful information
}
此方法中最常用的信息是作为当前对象模板的类的名称。我们将在第 6 章*接口、类和对象构造**中讨论为什么需要它。*通过该方法返回的类Class
的对象可以访问该类的名称。
java.lang.Object
类的toString()
方法如下:
public String toString(){
//return string representation of the object
}
此方法通常用于打印对象的内容。其默认实现如下所示:
public String toString() {
return getClass().getName()+"@"+Integer.toHexString(hashCode());
}
如您所见,它的信息量不大,因此程序员在他们的类中重新实现了它。这是类Object
中最常见的重新实现方法。程序员实际上为他们的每个类都这样做。我们将在第 9 章、运算符、表达式和语句中详细解释String
类及其方法。
java.lang.Object
类的clone()
方法如下:
protected Object clone(){
//creates copy of the object
}
此方法的默认结果按原样返回对象字段的副本,如果值不是对象引用,则可以这样做。这些值称为基元类型,我们将在第 5 章、*Java 语言元素和类型中对其进行精确定义。*但如果一个对象字段包含对另一个对象的引用,则只复制引用本身,而不复制引用对象本身。这就是为什么这样的拷贝被称为浅拷贝。要获得深度拷贝,必须重新实现clone()
方法,并遵循对象树的所有引用,这些引用可能非常广泛。幸运的是,clone()
方法并不经常使用。事实上,您可能永远不会遇到使用它的需要。
在阅读本文时,您可能会想,当对象用作方法参数时,它会发生什么情况。是否使用clone()
方法在方法中作为副本传递?如果是,它是作为浅拷贝还是深拷贝传递的?答案是,两者都不是。只有对对象的引用作为参数值传入,因此所有接收相同对象引用的方法都可以访问存储对象状态的内存的相同区域。
这带来了意外数据修改和后续数据损坏的潜在风险,使它们处于不一致的状态。这就是为什么在传递对象时,程序员必须始终意识到他们正在访问可能在其他方法和类之间共享的值。我们将在第 5 章、Java 语言元素和类型中详细介绍这一点,并在第 11 章、JVM 进程和垃圾收集中对其进行扩展,同时一般讨论线程和并发处理。
wait(),
和notify()
方法及其重载版本用于线程之间的通信,轻量级进程用于并发处理。程序员不会重新实现这些方法。他们只是使用它们来提高应用程序的吞吐量和性能。我们将在第 11 章、JVM 进程和垃圾收集中详细介绍wait()
和notify()
方法。
现在,恭喜你。您已经踏上了 Java 基础复杂性的顶峰,现在将继续横向行走,添加细节并练习所获得的知识。在阅读前两章时,您已经在头脑中构建了 Java 知识框架。如果你忘记了什么,如果不是一切都清楚,不要感到沮丧。继续阅读,你将有很多机会更新你的知识,扩展它,并长期保留它。这将是一次有趣的旅行,在最终目的地会有一个不错的奖励。
现在,我们可以讨论对您更有意义的概念,而不是在您学习主要术语和查看代码示例之前介绍这些概念。这些概念是:
- 对象/类:将状态和行为保持在一起
- 封装:它隐藏了实现的状态和细节
- 继承:它沿着类/接口扩展链传播行为/签名
- 接口:它将签名与其实现隔离开来
- 多态性:这允许一个对象由多个实现的接口和任何基类表示,包括
java.lang.Object
。
到目前为止,您已经熟悉以上所有内容,因此本文将主要是一个总结,只添加一些细节。这就是我们学习的方式——通过观察具体事实,构建更大的图景,并随着新的观察结果的出现而改进图景。我们总是这么做,不是吗?
可以编写 Java 程序和整个应用程序,而无需创建单个对象。只需在您正在创建的类的每个方法和每个字段前面使用static
关键字,并从静态main()
方法调用它们。您的编程能力将受到限制。例如,您将无法创建一组对象,这些对象可以并行工作,在它们自己的数据副本上执行类似的工作。但是您的应用程序仍然可以工作。
此外,在 Java8 中,添加了函数编程特性,允许我们以传递对象的相同方式传递函数。因此,您的无对象应用程序可能非常有能力。一些没有对象创建功能的语言被非常有效地使用。然而,在面向对象的语言被证明是有用的并且变得流行之后,首先是 Smalltalk,几个传统的过程语言 PHP、Perl、Visual Basic、COBOL 2002、Fortran 2003 和 Pascal,仅举几个附加的面向对象功能。
正如我们刚才提到的,Java 还将其功能扩展到了函数式编程,从而模糊了过程语言、面向对象语言和函数式语言之间的界限。然而,类的存在和使用它们创建对象的能力是编程语言必须支持的第一个概念,才能被归类为面向对象的。
封装使数据和函数(方法)从外部无法访问或具有受控访问的能力是创建面向对象语言的主要驱动因素之一。Smalltalk 是基于在对象之间传递消息的思想创建的,这在 Smalltalk 和 Java 中都是在一个对象调用另一个对象上的方法时完成的。
封装允许调用对象的服务,而不知道这些服务是如何实现的。它降低了软件的系统复杂性,提高了软件的可维护性。每个对象都执行其工作,而不需要与其客户机协调实现中的更改,只要它不违反接口中捕获的约定。
我们将在第 7 章、*包和可访问性(可见性)*中进一步详细讨论封装。
继承是每个面向对象语言都支持的另一个 OOP 概念。它通常被描述为重用代码的能力,这是一个真实但经常被误解的陈述。一些程序员认为继承声称能够在应用程序之间重用代码*。根据我们的经验,应用程序之间的代码可重用性可以在没有继承的情况下实现,并且更依赖于应用程序之间的功能相似性,而不是特定的编程语言特性。它和将公共代码提取到共享的可重用库中的技巧比其他任何东西都更相关。*
在 Java 或任何其他面向对象语言中,继承允许在基类中实现的通用功能在其子类中的重用。它可以通过将基类组装到一个公共共享库中来实现模块化并提高应用程序之间的代码重用性。但在实践中,很少使用这种方法,因为每个应用程序通常都有特定的需求,以至于公共基类要么过于简单,实际上毫无用处,要么包含许多特定于每个应用程序的方法。此外,在第 6 章接口、类和对象构造中,我们将展示使用聚合更容易实现可重用性,聚合基于使用独立对象而不是继承。
继承与接口一起使多态性成为可能。
有时,接口的 OOP 概念也称为抽象,因为接口从其实现的细节中总结(抽象)对象行为的公共描述,并隐藏(抽象)它。接口是封装和多态性不可分割的一部分,但其重要性足以作为一个单独的概念来表述。在第 8 章、面向对象设计(OOD)P原则中,当我们讨论从项目理念和愿景到特定编程解决方案的过渡时,其意义将变得尤为明显。
接口和继承为多态性提供了基础。
从我们提供的代码示例中,您可能已经意识到一个对象具有实现接口中列出的所有方法及其基类的所有非私有非静态方法,包括java.lang.Object
。像拥有许多公民身份的人一样,它可以作为任何基类或实现接口的对象传递。这种语言能力被称为多态性(来自poly–many 和morphos–form)。
请注意,从广义上讲,当具有相同名称的方法根据其签名具有不同行为时,方法重载也会表现出多态行为。
接口和抽象类之间的区别是什么?我们没有谈论它,所以你需要做一些研究。
在 Java8 中引入默认的接口方法之后,这种差异显著缩小,在许多情况下可以忽略不计。
抽象类可以有构造函数,而接口不能。
抽象类可以有状态,而接口不能。抽象类的字段可以是私有的和受保护的,而在接口中,字段是公共的、静态的和最终的。
抽象类可以使用任何访问修饰符实现方法,而接口中实现的默认方法仅为公共方法。
如果要修改的类已经扩展到另一个类,则不能使用抽象类,但可以实现接口,因为一个类只能扩展到另一个类,但可以实现多个接口。
在本章中,您学习了 Java 和任何面向对象编程语言的基本概念。现在,您已经了解了类和对象作为 Java 的基本构建块,了解了什么是静态成员和实例成员,并了解了接口、实现和继承。这是本初学者章节中最复杂、最具挑战性的练习,它让读者了解了 Java 语言的核心,并介绍了我们将在本书其余部分中使用的语言框架。这个练习让读者能够接触到关于接口和抽象类之间差异的讨论,在 Java8 发布后,这一差异变得更加狭窄。
在下一章中,我们将转向编程的实际问题。读者将被引导完成在他们的计算机上安装必要工具和配置开发环境的具体步骤。之后,将演示所有新的想法和软件解决方案,并提供具体的代码示例。