Java-SE7-编程学习指南-全-

Java SE7 编程学习指南(全)

原文:zh.annas-archive.org/md5/F72094373E33408AE85D942CB0C47C3B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

无论你是在追求 Java 认证还是想要丰富你的知识并在使用 Java 时获得更多的信心,你都会发现这本书很有用。这本书采用了一种不同的方法来为你准备认证。它旨在为你提供考试中的主题内容,并为你提供对 Java 的使用和 Java 应用程序开发的额外见解。通过提供更广泛的覆盖范围,它超越了直接的认证焦点,并提供了更全面的语言覆盖。

对于那些追求 Java 认证的人,本书围绕 Java 的主要方面进行组织,并涵盖了 Java SE 7 程序员 I(1Z0-803)考试涵盖的认证主题。每一章都涉及特定的认证主题,尽管有些主题在多个章节中涵盖。每章末尾都有认证问题,这将让你了解你可能在考试中遇到的问题的性质。本书的目的不是提供一套详尽的问题,而是解决那些重要的 Java 概念,以便你准备回答认证问题。

对于那些想要提高他们的 Java 知识的人,这本书提供了对 Java 的深入了解,可能是你以前没有见过的。特别是,图表将有望增强和巩固你对 Java 工作原理的理解,特别是那些描述程序堆栈和堆使用的图表。书中提供了许多示例,涵盖了开发 Java 应用程序中常见的许多陷阱。

无论你阅读这本书的原因是什么,我希望你会觉得这本书是有益的和令人满足的。

本书内容

第一章《开始学习 Java》使用一个简单的 Java 应用程序概述了 Java 的主要方面。演示了创建customer类,以及使用 getter 和 setter 方法。还讨论了开发过程、支持的 Java 应用程序类型、Java 中的文档过程以及注解的使用,这些注解大大增强了 Java 的表现力。

第二章《Java 数据类型及其用法》介绍了 Java 中可用的基本数据类型及其对应的运算符。使用图表解释了程序堆栈和堆如何相互关联以及它们如何影响变量的范围和生命周期。此外,还说明了StringStringBuilder类的使用,以及类和对象之间的区别。

第三章《决策结构》专注于 Java 中用于做出决策的结构,包括 if 和 switch 语句。由于这些结构依赖于逻辑表达式,因此涵盖了这些类型的表达式。还演示了 Java 7 中可用的基于字符串的 switch 语句。正确使用决策结构是通过理解和避免各种陷阱来实现的,例如未使用块语句和在比较中使用浮点数时可能出现的多种问题。

第四章《使用数组和集合》专注于数组的使用,以及ArraysArrayList类。单维和多维数组都有例子。介绍了Arrays类,因为它具有许多重要的方法,用于操作数组,如填充和排序数组。ArrayList类很重要,因为它为许多问题提供了比数组更灵活的容器。

第五章,循环结构,演示了 Java 中迭代的概念,通过 while 和 for 循环等结构。这些内容与在使用它们时可能出现的常见错误一起讨论。介绍了 for-each 语句和迭代器的使用,以及无限循环、break 和 continue 语句的覆盖。

第六章,类、构造函数和方法,涉及对象的创建和使用,并使用堆栈/堆来解释这个过程。讨论了重载构造函数和方法,以及签名、实例/静态类成员和不可变对象的概念。数据封装贯穿整个章节。

第七章,继承和多态,涵盖了继承和多态的关键主题,并增强了对构造函数和方法的讨论。当使用覆盖时,签名的使用再次变得重要。解释了super关键字在构造函数和方法中的作用。重新审视了作用域,并探讨了 final 和 abstract 类的概念。还介绍了始终存在的Object类。

第八章,应用程序中的异常处理,涵盖了异常处理,包括使用新的 try-with-resource 块和|操作符在 catch 块中的使用。提供了几条指南和处理异常的示例,以帮助读者避免在使用中常见的错误。

第九章,Java 应用程序,研究了 Java 应用程序中包的使用。这包括讨论包和导入语句的使用,包括静态导入语句。还讨论了使用资源包支持需要面向国际社区的应用程序以及如何使用 JDBC 连接和使用数据库。

您需要为本书做好准备

要使用本书中的示例,您需要访问 Java 7 SE。可以从www.oracle.com/technetwork/java/javase/downloads/index.html下载。读者可能更喜欢使用支持 Java 7 的集成开发环境IDE),如 NetBeans、Eclipse 或类似的环境。

这本书是为谁准备的

本书适用于那些准备参加 Java SE 7 程序员 I(1Z0-803)考试和/或希望扩展其对 Java 的知识的人。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码词如下所示:“例如,一个person对象和一个square对象都可以有一个draw方法。”

代码块设置如下:

public class Application {
   public static void main(String[] args) {
      // Body of method
   }
}

任何命令行输入或输出都是这样写的:

set path= C:\Program Files\Java\jdk1.7.0_02\bin;%path%

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧如下所示。

读者反馈

我们的读者的反馈总是受欢迎的。请告诉我们您对本书的看法——您喜欢或可能不喜欢的地方。读者的反馈对我们开发真正能让您受益的标题非常重要。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在消息主题中提到书名。

如果您在某个专题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书的自豪所有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您在www.PacktPub.com账户中购买的所有 Packt 图书中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问www.PacktPub.com/support注册,直接将文件发送到您的邮箱。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误是难免的。如果您在我们的书中发现错误——可能是文本或代码中的错误——我们将不胜感激地向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/support报告,选择您的书,点击勘误提交表链接,并输入您的勘误详情。一旦您的勘误经过验证,您的提交将被接受,并且勘误将被上传到我们的网站上,或者添加到该标题的现有勘误列表中的勘误部分。您可以通过从www.packtpub.com/support选择您的标题来查看任何现有的勘误。

盗版

互联网上盗版版权材料是所有媒体都面临的持续问题。在 Packt,我们非常重视保护我们的版权和许可。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。

请通过链接到涉嫌盗版材料的邮箱<copyright@packtpub.com>与我们联系。

我们感谢您在保护我们的作者和为您带来有价值的内容方面的帮助。

问题

如果您在书的任何方面遇到问题,请通过<questions@packtpub.com>与我们联系,我们将尽力解决。

第一章:Java 入门

本章介绍了 Java 的基本元素以及如何编写简单的 Java 程序。通过简单的解释应用程序开发过程,可以全面了解 Java 开发环境。提供了一个作为起点和讨论参考的 Java 控制台程序。

在本章中,我们将研究:

  • Java 是什么

  • 面向对象的开发过程

  • Java 应用程序的类型

  • 创建一个简单的程序

  • 类和接口的定义

  • Java 应用程序开发

  • Java 环境

  • Java 文档技术

  • Java 中的注释使用

  • 核心 Java 包

理解 Java 作为一种技术

Sun Microsystems 在 1990 年代中期开发了该语言的原始规范。Patrick Naughton、Mike Sheridan 和 James Gosling 是 Java 的原始发明者,该语言最初被称为 Oak。

Java 是一种完整的面向对象的编程语言。它是平台无关的,通常是解释性的,而不是像 C/C++那样编译性的。它在语法和结构上模仿了 C/C++,并执行各种编译时和运行时的检查操作。Java 执行自动内存管理,有助于大大减少其他语言和动态分配内存的库中发现的内存泄漏问题。

Java 支持许多功能,在其概念产生时,其他语言中并没有直接找到。这些功能包括线程、网络、安全和图形用户界面(GUI)开发。其他语言可以用来支持这些功能,但它们没有像 Java 那样集成在语言中。

Java 使用独立的字节码,这是与体系结构无关的。也就是说,它被设计为与机器无关。字节码由 Java 虚拟机(JVM)解释和执行。正如我们将在第三章 决策结构中看到的那样,它的所有原始数据类型都是完全指定的。Java 开发工具包(JDK)的各个版本和其他重要时刻如下时间线图所示:

理解 Java 作为一种技术

面向对象的软件开发

让我们暂时离题一下,考虑为什么我们要使用 Java。Java 最重要的一个方面是它是一种面向对象(OO)语言。OO 技术是一种流行的开发应用程序的范式。这种方法围绕一系列真实世界的对象建模应用程序,比如员工或船只。为了解决问题,有必要考虑构成问题域的真实世界对象。

OO 方法基于三个不同的活动:

  • 面向对象分析(OOA):这涉及确定系统的功能,即应用程序应该做什么

  • 面向对象设计(OOD):这涉及架构如何支持应用程序的功能

  • 面向对象编程(OOP):这涉及应用程序的实际实现

分析和设计步骤的产物通常被称为分析和设计工件。虽然可能会产生几种不同类型的工件,但对 OOP 步骤最感兴趣的是称为类图的工件。下图显示了一个部分类 UML 图,描述了两个类:CustomerCustomerDriver。在一个简单的 Java 应用程序部分,我们将研究这些类的代码。统一建模语言(UML)是一种广泛使用的 OO 技术,用于设计和记录应用程序。类图是该技术的最终产品之一,程序员用它来创建应用程序:

面向对象软件开发

每个方框代表一个类,分为三个部分:

  • 方框顶部的第一部分是类的名称

  • 第二部分列出了构成类的变量

  • 最后一部分列出了类的方法

在变量和方法名称之前的符号指定了这些类成员的可见性。以下是类图中使用的符号:

  • -: 私有

  • +: 公共

  • #: 受保护的(与继承一起使用)

通常,类图由许多类组成,并通过带注释的线条相互连接,显示类之间的关系。

类图旨在清楚地显示系统中包含哪些对象以及它们如何相互作用。一旦类图完成,就可以使用 Java 等面向对象编程语言来实现它。

注意

面向对象的方法通常用于中等规模到大规模的项目,其中许多开发人员必须进行沟通和合作,以创建一个应用程序。对于只涉及少数程序员的较小项目,例如大多数编程课程中处理的项目,通常不使用面向对象的方法。

面向对象编程原则

虽然关于什么才真正使编程语言成为面向对象的编程语言存在一些分歧,但通常面向对象编程语言必须支持三个基本原则:

  • 数据封装

  • 继承

  • 多态性

数据封装关注于从类的用户那里隐藏不相关的信息,并暴露相关的信息。数据封装的主要目的是降低软件开发的复杂性。通过隐藏执行操作所需的细节,使用该操作变得更简单。如何在 Java 中实现数据封装将在本章后面的访问修饰符部分中解释。

数据封装也用于保护对象的内部状态。通过隐藏表示对象状态的变量,可以通过方法来控制对对象的修改。方法中的代码验证状态的任何更改。此外,通过隐藏变量,消除了类之间信息的共享。这减少了应用程序中可能出现的耦合量。

继承描述了两个类之间的关系,使一个类重用另一个类的功能。这样可以实现软件的重用,从而提高开发人员的生产力。继承在第七章中有详细介绍,继承和多态性

第三个原则是多态性,其主要关注点是使应用程序更易于维护和扩展。多态行为是指一个或多个相同方法的行为取决于执行该方法的对象。例如,person对象和square对象都可以有一个draw方法。它所绘制的内容取决于执行该方法的对象。多态性在第七章中讨论,继承和多态性

这些原则总结在以下表中:

原则 是什么 为什么使用它 如何做到
数据封装 从类的用户隐藏信息的技术 降低软件开发复杂性的级别 使用publicprivateprotected等访问修饰符
继承 允许派生或子类使用基类或父类的部分的技术 促进软件的重用 使用extends关键字
多态性 支持方法的不同行为,取决于执行该方法的对象 使应用程序更易于维护 Java 语言的固有特性

implements关键字用于支持多态行为,如第七章继承和多态中所解释的。

检查 Java 应用程序的类型

有几种类型的 Java 应用程序。这些类型使 Java 得以在许多不同领域蓬勃发展,并促使 Java 成为一种非常流行的编程语言。Java 用于开发以下内容:

  • 控制台和窗口应用程序

  • 由 Servlet、JSP、JSF 和其他 JEE 标准支持的基于服务器的 Web 应用程序

  • 在浏览器中执行的小程序

  • 嵌入式应用程序

  • 使用 JavaBeans 的组件化构建块

虽然对 Java 应用程序类型的基本理解有助于将 Java 置于上下文中,但也有助于能够识别这些应用程序的基本代码。您可能不完全理解这些应用程序类型的所有细节,但看到简单的代码示例是有用的。

阅读代码对于理解一种语言和特定程序有很大帮助。在整本书中,我们将使用许多示例来说明和解释 Java 的各个方面。以下通过呈现对应用程序类型至关重要的简短代码片段来展示 Java 应用程序的基本类型。

一个简单的控制台应用程序由一个带有main方法的单个类组成,如下面的代码片段所示:

public class Application {
   public static void main(String[] args) {
      // Body of method
   }
}

我们将更深入地研究这种类型的应用程序。

小程序通常嵌入在 HTML 页面中,并提供了一种实现客户端执行代码的方法。它没有main方法,而是使用浏览器用来管理应用程序的一系列回调方法。以下代码提供了小程序的一般外观:

import java.applet.*;
import java.awt.Graphics;

public class SimpleApplet extends Applet {

   @Override
   public void init() {
      // Initialization code
   }

   @Override
   public void paint( Graphics g ) {
      // Display graphics
   }
}

@Override注解用于确保接下来的方法实际上是被覆盖的。这在本章的注解部分中有更详细的讨论。

Servlet 是一个在服务器端运行的应用程序,它呈现给客户端一个 HTML 页面。doGetdoPut方法响应客户端请求。以下示例中的out变量代表 HTML 页面。println方法用于编写 HTML 代码,如下面的代码片段所示:

class Application extends HttpServlet {
   public void doGet(HttpServletRequest req,
            HttpServletResponse res)
            throws ServletException, IOException {
      res.setContentType("text/html");

      // then get the writer and write the response data
      PrintWriter out = res.getWriter();
      out.println(
         "<HEAD><TITLE> Simple Servlet</TITLE></HEAD><BODY>");
      out.println("<h1> Hello World! </h1>");
      out.println(
         "<P>This is output is from a Simple Servlet.");
      out.println("</BODY>");
      out.close();
   }
}

JavaServer Page(JSP)实际上是一个伪装的 Servlet。它提供了一种更方便的开发网页的方式。以下示例使用一个 JavaBean 在网页上显示“Hello World”。JavaBean 在以下示例中有详细说明:

<html>
<head>
   <title>A Simple JSP Page</title>
</head>
<body>
Hello World!<br/>

<%
   // This is a scriptlet that can contain Java code
%>
<hr>
<jsp:useBean id="namebean" class="packt.NameBean" scope="session" >
<jsp:setProperty name="namebean" property="name" value=" Hello world"" />
</jsp:useBean>
<h1> <jsp:getProperty name="namebean" property="name" /></h1>
</body>
</html>

JavaBean 是共享应用程序功能的构建块。它们经常被设计用于多个应用程序,并遵循标准的命名约定。以下是一个简单的 JavaBean,用于保存一个名称(它在前面的 JSP 页面中使用):

package packt;
public class NameBean {

  private String name= "Default Name"";

  public String getName() {
     return this.name;
  }
  public void setName(String name) {
     this.name = name;
  }
}

企业 JavaBean(EJB)是设计用于在 Web 服务器上的客户端/服务器配置中使用的组件。这是一个相当专业化的主题,与认证的副级别无关。

还有其他几种 Java 技术,如 JSF 和 Facelets,它们是 JEE 的一部分。这些是对用于开发网页的旧 Servlet 和 JSP 技术的改进。

在本书中,我们只会使用简单的 Java 控制台应用程序。这种类型的应用程序已经足够解释 Java 的本质。

探索 Java 控制台程序的结构

让我们从一个简单的 Java 程序开始,然后使用它来探索 Java 的许多基本方面。首先,Java 应用程序由一个或多个文件组成,这些文件位于文件系统的某个位置。文件的名称和位置都很重要,我们很快就会看到。

提示

您可以从您在www.PacktPub.com的帐户中购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.PacktPub.com/support并注册,以便直接通过电子邮件接收文件。

一个简单的 Java 应用程序

我们的简单程序定义了一个Customer类,然后在CustomerDriver类中使用它,如下所示:

package com.company.customer;

import java.math.BigDecimal;
import java.util.Locale;

public class Customer {
  private String name;
  private int accountNumber;
  private Locale locale;
  private BigDecimal balance;

  public Customer() {
    this.name = "Default Customer";
    this.accountNumber = 12345;
    this.locale = Locale.ITALY;
    this.balance = new BigDecimal("0");
  }

  public String getName() {
    return name;
  }
  public void setName(String name) throws Exception {
    if(name == null) {
         throw new IllegalArgumentException(
            "Names must not be null");
    } else {
      this.name = name;
    }
  }
  public int getAccountNumber() {
    return accountNumber;
  }

  public void setAccountNumber(int accountNumber) {
    this.accountNumber = accountNumber;
  }

  public BigDecimal getBalance() {
    return balance;
  }

  public void setBalance(float balance) {
    this.balance = new BigDecimal(balance);
  }

   public String toString() {
      java.text.NumberFormat format =
         java.text.NumberFormat.getCurrencyInstance(locale);
      StringBuilder value = new StringBuilder();
      value.append(String.format("Name: %s%n", this.name));
      value.append(String.format("Account Number: %d%n", 
            this.accountNumber));
      value.append(String.format("Balance: %s%n",
            format.format(this.balance)));
      return value.toString();
    }  
}

package com.company.customer;

public class CustomerDriver {

  public static void main(String[] args) {
      // Define a reference and creates a new Customer object
    Customer customer;      
    customer = new Customer();
    customer.setBalance(12506.45f);
    System.out.println(customer.toString());
  }

如何编译和执行此应用程序的详细信息在在没有 IDE 的情况下开发 Java 应用程序部分提供。执行此应用程序时,您将获得以下输出:

Name: Default Customer
Account number: 12345
Balance: € 12.506,45

详细了解应用程序 以下部分详细介绍了示例程序的重要方面。这些将在接下来的章节中更详细地阐述。请注意,此应用程序中有两个类。CustomerDriver类包含main方法,并首先执行。在main方法中创建并使用了Customer类的一个实例。

包语句指定了类的com.company.customer包。包提供了一种将相似的类、接口、枚举和异常分组的方法。它们在第九章的部分中更深入地讨论了Java 应用程序

导入

import语句指示类使用了哪些包和类。这允许编译器确定包的成员是否被正确使用。所有类都需要导入包,但以下类除外:

  • java.lang包中找到

  • 位于当前包(在本例中为com.company.customer

  • 显式标记,如在Customer类的toString方法中使用的java.text.NumberFormat

注意

import语句通知编译器应用程序使用了哪些包和类以及如何使用它们。

Customer 类

类定义的第一个单词是关键字public,这是 Java 为面向对象软件开发提供的支持的一部分。在这种情况下,它指定类在包外可见。虽然不是必需的,但大多数类经常使用它,并引出了第二个关键字class,它标识了一个 Java 类。

实例变量

接下来声明了四个私有实例变量。使用private关键字将它们隐藏在类的用户之外。Locale类支持可以在国际上透明工作的应用程序。BigDecimal是在 Java 中表示货币的最佳方式。

方法

通过将这些实例变量设为私有,设计者限制了对变量的访问。然后只能通过公共方法访问它们。私有变量和公共方法的组合是数据封装的一个例子。如果将实例变量改为公共的,其他用户可以直接访问变量。这将提高程序的效率,但可能会阻碍未来的维护工作。更改这些变量并对其进行任何验证检查将更加困难。

一系列的 getter 和 setter 方法用于返回和设置与私有实例变量相关的值。这以受控的方式暴露它们。使用 getter 和 setter 方法是实现封装的标准方法。例如,尝试将空值分配给名称将引发IllegalArmumentException异常。这些类型的方法在方法声明部分中讨论。

toString方法返回表示客户实例的字符串。在这种情况下,返回名称、帐号和余额的本地化版本。StringBuilder类的使用在第二章中讨论,Java 数据类型及其使用

注意

方法在类中找到,类在包中找到。

CustomerDriver 类的 main 方法

CustomerDriver类被称为驱动程序或控制器类。它的目的是拥有一个将创建和使用其他类的main方法。

在 Java 应用程序中,main方法是要执行的第一个方法。如果应用程序由多个类组成,通常只有一个类有main方法。Java 应用程序通常只需要一个main方法。

main方法中,创建一个新的客户,设置余额,然后显示客户。在语句中添加了 C++风格的注释,以记录客户的声明和创建。这是以双斜杠(//)开头的行。注释在注释部分详细解释。

注意

在 Java 控制台应用程序中执行的第一种方法是main方法。

探索类的结构

编程可以被认为是代码操作数据。在 Java 中,代码围绕以下内容组织:

  • 方法

包是具有类似功能的类的集合。类由支持类功能的方法组成。这种组织为应用程序提供了结构。类将始终在一个包中,方法将始终在一个类中。

注意

如果类定义中没有包语句,则该类将成为默认包的一部分,该默认包包括同一目录中没有包语句的所有类。

类、接口和对象

类是面向对象程序的基本构建块。它通常代表现实世界的对象。Java 中的类定义包括成员变量声明和方法声明。它以class关键字开始。类的主体用大括号括起来,包含所有实例变量和方法:

  class classname {
    // define class level variables
    // define methods
  }

注意

一对开放和关闭大括号构成一个块语句。这在 Java 的许多其他部分中使用。

类和对象

类是用于创建具有相似特征的多个对象的模式或模板。它定义了类的变量和方法。它声明了类的功能。但是,在使用这些功能之前,必须创建一个对象。对象是类的实例化。也就是说,对象由为类的成员变量分配的内存组成。每个对象都有自己的一组成员变量。

提示

创建新对象时发生以下情况:

  • 使用 new 关键字创建类的实例

  • 为类的新实例物理分配内存

  • 执行任何静态初始化程序(如第六章中Java 初始化顺序部分所述),类、构造函数和方法

  • 调用构造函数进行初始化

  • 返回对对象的引用

对象的状态通常对对象的用户隐藏,并反映在其实例变量的值中。对象的行为由它拥有的方法决定。这是数据封装的一个例子。

注意

对象是类的实例化。每个类的实例都有自己独特的一组实例变量。

Java 中的对象总是分配在堆上。堆是用于动态分配内存(如对象)的内存区域。在 Java 中,对象在程序中分配,然后由 JVM 释放。这种内存释放称为垃圾回收,由 JVM 自动执行。应用程序对此过程的控制很少。这种技术的主要好处是最大限度地减少内存泄漏。

注意

当动态分配内存但从未释放时,就会发生内存泄漏。这在诸如 C 和 C++等语言中是一个常见问题,程序员有责任管理堆。

在 Java 中,如果分配了一个对象但在不再需要该对象时没有释放对该对象的引用,就可能发生内存泄漏。

构造函数

构造函数用于初始化对象。每当创建一个对象时,都会执行构造函数。默认构造函数是没有参数的构造函数,对所有类都会自动提供。这个构造函数将把所有实例变量初始化为默认值。

然而,如果开发人员提供了构造函数,编译器就不会再添加默认构造函数。开发人员需要显式添加一个默认构造函数。始终具有一个默认的无参数构造函数是一个良好的实践。

接口

接口类似于抽象类。它使用interface关键字声明,只包含抽象方法和最终变量。抽象类通常有一个或多个抽象方法。抽象方法是没有实现的方法。它旨在支持多态行为,如第七章中讨论的,继承和多态。以下代码定义了一个用于指定类能够被绘制的接口:

  interface Drawable {
    final int unit = 1;
    public void draw();
  }

方法

所有可执行代码都在初始化程序列表或方法中执行。在这里,我们将研究方法的定义和用法。初始化程序列表在第六章中讨论,类,构造函数和方法。方法将始终包含在类中。方法的可见性由其访问修饰符控制,详细信息请参阅访问修饰符部分。方法可以是静态的或实例的。在这里,我们将考虑实例方法。正如我们将在第六章中看到的,类,构造函数和方法,静态方法通常访问类的对象之间共享的静态变量。

无论方法的类型如何,方法只有一个副本。也就是说,虽然一个类可能有零个、一个或多个方法,但类的每个实例(对象)都使用方法的相同定义。

方法声明

一个典型的方法包括:

  • 一个可选的修饰符

  • 返回类型

  • 方法名称

  • 括在括号中的参数列表

  • 可选的 throws 子句

  • 包含方法语句的块语句

以下setName方法说明了方法的这些部分:

  public void setName(String name) throws Exception {
    if(name == null) {
      throw new Exception("Names must not be null");
    } else {
      this.name = name;
    }
  }

虽然在这个例子中 else 子句在技术上不是必需的,但始终使用 else 子句是一个良好的实践,因为它代表了可能的执行顺序。在这个例子中,如果 if 语句的逻辑表达式求值为 true,那么异常将被抛出,方法的其余部分将被跳过。异常处理在第八章中有详细介绍,应用程序中的异常处理

方法经常操作实例变量以定义对象的新状态。在设计良好的类中,实例变量通常只能由类的方法更改。它们对类是私有的。因此,实现了数据封装。

方法通常是可见的,并允许对象的用户操作该对象。有两种方法对方法进行分类:

  • Getter 方法:这些方法返回对象的状态(也称为访问器方法

  • Setter 方法:这些方法可以改变对象的状态(也称为变异方法

Customer类中,为所有实例变量提供了 setter 和 getter 方法,除了 locale 变量。我们本可以很容易地为这个变量包括一个 get 和 set 方法,但为了节省空间,我们没有这样做。

注意

具有获取方法但没有其他可见的设置方法的变量被称为只读成员变量。类的设计者决定限制对变量的直接访问。

具有设置方法但没有其他可见的获取方法的变量被称为只写成员变量。虽然您可能会遇到这样的变量,但它们很少见。

方法签名

方法的签名由以下组成:

  • 方法的名称

  • 参数的数量

  • 参数的类型

  • 参数的顺序

签名是一个重要的概念,用于方法和构造函数的重载/覆盖,如第七章中所讨论的,继承和多态。构造函数也将有一个签名。请注意,签名的定义不包括返回类型。

主方法

书中使用的示例将是控制台程序应用。这些程序通常从键盘读取并在控制台上显示输出。当操作系统执行控制台应用程序时,首先执行main方法。然后可能执行其他方法。

main方法可以用于从命令行传递信息。这些信息传递给main方法的参数。它由代表程序参数的字符串数组组成。我们将在第四章中看到这一点,使用数组和集合

在 Java 中只有一种main方法的形式,如下所示:

    public static void main(String[] args) {
       // Body of method
    }

以下表格显示了main方法的元素:

元素 意义
public 方法在类外可见。
static 该方法可以在不创建类类型对象的情况下调用。
void 该方法不返回任何内容。
args 代表传递的参数的字符串数组。

从应用程序返回一个值

main方法返回void,这意味着在正常的方法调用序列中无法将值返回给操作系统。但是,有时将返回一个值以指示程序是否成功终止是有用的。当程序用于批处理类型操作时,返回这些信息是有用的。如果在执行序列中一个程序失败,那么序列可能会被改变。可以使用System.exit方法从应用程序返回信息。以下方法的使用将终止应用程序并返回零给操作系统:

    System.exit(0);

注意

exit方法:

  • 强制终止应用程序的所有线程

  • 是极端的,应该避免

  • 不提供优雅终止程序的机会

访问修饰符

变量和方法可以声明为以下四种类型之一,如下表所示:

访问类型 关键字 意义
公共的 public 提供给类外用户的访问。
私有的 private 限制对类成员的访问。
受保护的 protected 提供给继承类或同一包中成员的访问。
包范围 提供对同一包中成员的访问。

大多数情况下,成员变量声明为私有,方法声明为公共。但是,其他访问类型的存在意味着控制成员可见性的其他潜在方法。这些用法将在第七章继承和多态中进行检查。

Customer类中,所有类变量都声明为私有,所有方法都声明为公共。在CustomerDriver类中,我们看到了setBalancetoString方法的使用:

    customer.setBalance(12506.45f);
    System.out.println(customer.toString());

由于这些方法被声明为 public,它们可以与Customer对象一起使用。不可能直接访问 balance 实例变量。以下语句尝试这样做:

    customer.balance = new BigDecimal(12506.45f);

编译器将发出类似以下的编译时错误:

balance 在 com.company.customer.Customer 中具有私有访问权限

注意

访问修饰符用于控制应用程序元素的可见性。

文档

程序的文档是软件开发过程中的重要部分。它向其他开发人员解释代码,并提醒开发人员他们为什么这样做。

文档是通过几种技术实现的。在这里,我们将讨论三种常见的技术:

  • 注释:这是嵌入在应用程序中的文档

  • 命名约定:遵循标准的 Java 命名约定可以使应用程序更易读

  • Javadoc:这是一种用于生成 HTML 文件形式的应用程序文档的工具

注释

注释用于记录程序。它们不可执行,编译器会忽略它们。良好的注释可以大大提高程序的可读性和可维护性。注释可以分为三种类型——C 样式、C++样式和 Java 样式,如下表所总结:

注释类型 描述
例子
---
C 样式 C 样式注释在注释的开头和结尾使用两个字符序列。这种类型的注释可以跨越多行。开始字符序列是/*,而结束序列由*/组成。

|

  /* A multi-line comment
     …
  */

  /* A single line comment */

|

C++样式 C++样式注释以两个斜杠开头,注释一直持续到行尾。实质上,从//到行尾的所有内容都被视为注释。

|

  // The entire line is a comment
  int total;	// Comment used to clarify variable
  area = height*width; 	// This computes the area of a rectangle

|

Java 样式 Java 样式与 C 样式注释的语法相同,只是它以/**开头,而不是/*。此外,可以在 Java 样式注释中添加特殊标签以进行文档目的。一个名为javadoc的程序将读取使用这些类型注释的源文件,并生成一系列 HTML 文件来记录程序。有关更多详细信息,请参阅使用 Javadocs部分。

|

    /**
     * This method computes the area of a rectangle
     *
     * @param height	The height of the rectangle
     * @param width	The width of the rectangle
     * @return		The method returns the area of a rectangle
     *
     */
   public int computeArea(int height, int width)  {
      return height * width;
   }

|

Java 命名约定

Java 使用一系列命名约定来使程序更易读。建议您始终遵循这些命名约定。通过这样做:

  • 使您的代码更易读

  • 它支持 JavaBeans 的使用

注意

有关命名约定的更多细节,请访问www.oracle.com/technetwork/java/codeconvtoc-136057.html

Java 命名约定的规则和示例显示在以下表中:

元素 约定 例子
所有字母都小写。 com.company.customer
每个单词的第一个字母大写。 CustomerDriver
接口 每个单词的第一个字母大写。 Drawable
变量 第一个单词不大写,但后续单词大写 grandTotal
方法 第一个单词不大写,但后续单词大写。方法应该是动词。 computePay
常量 每个字母都大写。 LIMIT

注意

遵循 Java 命名约定对于保持程序可读性和支持 JavaBeans 很重要。

使用 Javadocs

Javadoc 工具基于源代码和源代码中嵌入的 Javadoc 标签生成一系列 HTML 文件。该工具也随 JDK 一起分发。虽然以下示例并不试图提供 Javadocs 的完整处理,但它应该能给你一个关于 Javadocs 能为你做什么的好主意:

public class SuperMath {
   /**
    * Compute PI - Returns a value for PI.
    *    Able to compute pi to an infinite number of decimal 
    *    places in a single machine cycle.
    * @return A double number representing PI
   */

   public static double computePI() {
      //
   }
}

javadoc命令与此类一起使用时,会生成多个 HTML 文件。index.html文件的一部分如下截图所示:

使用 Javadocs

注意

有关 Javadoc 文件的使用和创建的更多信息可以在www.oracle.com/technetwork/java/javase/documentation/index-137868.html找到。

调查 Java 应用程序开发过程

Java 源代码被编译为中间字节码。然后在任何安装有Java 虚拟机JVM)的平台上运行时解释这些字节码。然而,这个说法有些误导,因为 Java 技术通常会直接将字节码编译为机器码。已经有了许多即时编译器的改进,加快了 Java 应用程序的执行速度,通常会运行得几乎和本地编译的 C 或 C++应用程序一样快,有时甚至更快。

Java 源代码位于以.java扩展名结尾的文件中。Java 编译器将源代码编译为字节码表示,并将这些字节码存储在以.class扩展名结尾的文件中。

有几种集成开发环境IDE)用于支持 Java 应用程序的开发。也可以使用Java 开发工具包JDK)中的基本工具从命令行开发 Java 应用程序。

生产 Java 应用程序通常在一个平台上开发,然后部署到另一个平台。目标平台需要安装Java 运行环境JRE)才能执行 Java 应用程序。有几种工具可以协助这个部署过程。通常,Java 应用程序会被压缩成一个Java 存档JAR)文件,然后部署。JAR 文件只是一个嵌入有清单文档的 ZIP 文件。清单文档通常详细说明了正在创建的 JAR 文件的内容和类型。

编译 Java 应用程序

用于开发 Java 应用程序的一般步骤包括:

  • 使用编辑器创建应用程序

  • 使用 Java 编译器(javac)编译它

  • 使用 Java 解释器(java)执行它

  • 根据需要使用 Java 调试器可选择地调试应用程序

这个过程总结在下图中:

编译 Java 应用程序

Java 源代码文件被编译为字节码文件。这些字节码文件具有.class扩展名。当 Java 包被分发时,源代码文件通常不会存储在与.class文件相同的位置。

SDK 文件结构

Java 软件开发工具包SDK)可下载并用于创建和执行许多类型的 Java 应用程序。Java 企业版JEE)是一个不同的 SDK,用于开发以 Web 应用程序为特征的企业应用程序。该 SDK 也被称为Java 2 企业版J2EE),你可能会看到它被引用为 J2EE。在这里,我们只处理 Java SDK。

虽然 SDK 分发的实际结构会因版本而异,但典型的 SDK 由一系列目录组成,如下所列:

  • bin:这包含用于开发 Java 应用程序的工具,包括编译器和 JVM

  • db:这是 Apache Derby 关系数据库

  • demo:这包含一系列演示应用程序

  • include:这包含用于与 C 应用程序交互的头文件

  • jre:这是 JDK 使用的 JRE

  • sample:这个目录包含 Java 各种特性的示例代码

SDK 可能包括核心类的实际源代码。这通常可以在位于JAVA_HOME根目录下的src.zip文件中找到。

IDE 文件结构

每个 IDE 都有一种首选的组织应用程序文件的方式。这些组织方案并不总是固定的,但这里介绍的是常见的文件排列方式。

例如,在 Eclipse IDE 中,一个简单的应用程序由两个项目文件和三个子目录组成。这些文件和目录列举如下:

  • .classpath:这是包含与类路径相关信息的 XML 文件

  • .project:这是描述项目的 XML 文档

  • .settings:这是一个包含org.eclipse.jdt.core.prefs文件的目录,该文件指定了编译器的偏好设置。

  • bin:这个目录用于包含包文件结构和应用程序的类文件

  • src:这个目录用于包含包文件结构和应用程序的源文件

这种组织方案是由开发工具使用的。这些工具通常包括编辑器、编译器、链接器、调试器等。这些语言经常使用 Make 工具来确定需要编译或以其他方式处理的文件。

在没有 IDE 的情况下开发 Java 应用程序

在本节中,我们将演示如何在 Windows 平台上使用 Java 7 编译和执行 Java 应用程序。这种方法与其他操作系统的方法非常相似。

在我们编译和执行示例程序之前,我们需要:

  • 安装 JDK

  • 为应用程序创建适当的文件结构

  • 创建用于保存我们的类的文件

JDK 的最新版本可以在www.oracle.com/technetwork/java/javase/downloads/index.html找到。下载并安装符合您需求的版本。注意安装位置,因为我们很快将会用到这些信息。

如前所述,Java 类必须位于特定的文件结构中,与其包名称相对应。在文件系统的某个地方创建一个文件结构,其中有一个名为com的顶级目录,该目录下有一个名为company的目录,然后在company目录下有一个名为customer的目录。

customer目录中创建两个文件,分别命名为Customer.javaCustomerDriver.java。使用在一个简单的 Java 应用程序部分中找到的相应类。

JDK 工具位于 JDK 目录中。当安装 JDK 时,通常会设置环境变量以允许成功执行 JDK 工具。然而,需要指定这些工具的位置。这可以通过set命令来实现。在下面的命令中,我们将path环境变量设置为引用C:\Program Files\Java\jdk1.7.0_02\bin目录,这是本章撰写时的最新版本:

set path= C:\Program Files\Java\jdk1.7.0_02\bin;%path%

这个命令在之前分配的路径前面加上了bin目录的路径。path环境变量被操作系统用来查找在命令提示符下执行的命令。没有这些信息,操作系统将不知道 JDK 命令的位置。

要使用 JDK 编译程序,导航到com目录的上一级目录。由于作为该应用程序一部分的类属于com.company.customer包,我们需要:

  • javac命令中指定路径

  • com目录的上一级目录执行该命令

由于这个应用程序由两个文件组成,我们需要编译它们两个。可以使用以下两个单独的命令来完成:

javac com.company.customer.Customer.java
javac com.company.customer.CustomerDriver.java

或者,可以使用单个命令和星号通配符来完成:

javac com.company.customer.*.java

编译器的输出是一个名为CustomerDriver.class的字节码文件。要执行程序,使用 Java 解释器和你的类文件,如下命令所示。类扩展名不包括在内,如果包含在文件名中会导致错误:

java com.company.customer.CustomerDriver

你的程序的输出应该如下:

Name: Default Customer
Account number: 12345
Balance: € 12.506,45

Java 环境

Java 环境是用于开发和执行 Java 应用程序的操作系统和文件结构。之前,我们已经检查了 JDK 的结构,这些都是 Java 环境的一部分。与这个环境相关的是一系列的环境变量,它们被用来在不同时间进行各种操作。在这里,我们将更详细地检查其中的一些:

  • CLASSPATH

  • PATH

  • JAVA_VERSION

  • JAVA_HOME

  • OS_NAME

  • OS_VERSION

  • OS_ARCH

这些变量在下表中总结:

名称 目的 示例
CLASSPATH 指定类的根目录。 .;C:\Program Files (x86)\Java\jre7\lib\ext\QTJava.zip
PATH 命令的位置。
JAVA_VERSION 要使用的 Java 版本。 <param name="java_version" value="1.5.0_11">
JAVA_HOME Java 目录的位置。 C:\Program Files (x86)\Java\jre6\bin
OS_NAME 操作系统的名称。 Windows 7
OS_VERSION 操作系统的版本 6.1
OS_ARCH 操作系统架构 AMD64

CLASSPATH环境变量用于标识包的根目录。设置如下:

 c:>set CLASSPATH=d:\development\increment1;%CLASSPATH%

CLASSPATH变量只需要设置非标准包。Java 编译器将始终隐式地将系统的类目录附加到CLASSPATH。默认的CLASSPATH是当前目录和系统的类目录。

与应用程序相关的还有许多其他环境变量。以下代码序列可用于显示这些变量的列表:

    java.util.Properties properties = System.getProperties();
    properties.list(System.out);

这段代码序列的部分输出如下:

-- listing properties --
java.runtime.name=Java(TM) SE Runtime Environment
sun.boot.library.path=C:\Program Files\Java\jre7\bin
java.vm.version=22.0-b10
java.vm.vendor=Oracle Corporation
java.vendor.url=http://java.oracle.com/
path.separator=;
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
…

注解

注解提供关于程序的信息。这些信息不驻留在程序中,也不会影响其执行。注解用于支持诸如编译器和程序执行期间的工具。例如,@Override注解通知编译器一个方法正在覆盖基类的方法。如果该方法实际上没有覆盖基类的方法,因为拼写错误,编译器将生成一个错误。

注解应用于应用程序的元素,如类、方法或字段。它以 at 符号@开头,后面跟着注解的名称,可选地跟着一组括号括起来的值的列表。

常见的编译器注解在下表中详细说明:

注解 用法
@Deprecated 编译器用来指示不应该使用该元素
@Override 该方法覆盖了基类的方法
@SuppressWarnings 用于抑制特定的编译器警告

注解可以添加到应用程序中,并由第三方工具用于特定目的。在需要时也可以编写自己的注解。

注意

注解对于向工具和运行时环境传达关于应用程序的信息非常有用

Java 类库

Java 包括许多支持应用程序开发的类库。其中包括以下内容:

  • java.lang

  • java.io

  • java.net

  • java.util

  • java.awt

这些库是按包组织的。每个包包含一组类。包的结构反映在其底层文件系统中。CLASSPATH环境变量保存了包的位置。

有一组核心的包是 JDK 的一部分。这些包通过提供对一组标准功能的简单访问,为 Java 的成功提供了至关重要的元素,这些功能在其他语言中并不容易获得。

以下表格显示了一些常用包的列表:

用法
java.lang 这是基本语言类型的集合。它包括根类ObjectClass,以及线程,异常,包装器和其他基本类等其他项目。
java.io 包括流和随机访问文件。
java.net 支持套接字,telnet 接口和 URL。
java.util 支持容器和实用类,如DictionaryHashTableStack。编码器和解码器技术,如DateTime,也可以在此库中找到。
java.awt 包含抽象窗口工具包AWT),其中包含支持图形用户界面GUI)的类和方法。它包括用于事件,颜色,字体和控件的类。

摘要

在本章中,我们研究了 Java 的基本方面和一个简单的 Java 控制台应用程序。从认证的角度来看,我们研究了一个使用main方法的类和 Java 应用程序的结构。

我们还介绍了一些将在后续章节中更详细讨论的其他主题。这包括对象的创建和操作,字符串和StringBuilder类的使用,类的实例和静态成员,以及在方法的重载和重写中使用签名。

有了这个基础,我们准备继续第二章,Java 数据类型及其用法,在那里我们将研究变量的性质以及它们的用法。

认证目标涵盖

在本章中,我们介绍了一些将在后续章节中更详细讨论的认证主题。在这里,我们深入讨论了以下主题:

  • 定义 Java 类的结构(在探索类的结构部分)

  • 创建一个带有主方法的可执行的 Java 应用程序(在探索 Java 控制台程序结构部分)

测试你的知识

  1. 如果以下代码使用java SomeClass hello world命令运行,会打印出什么?
public class SomeClass{
    public static void main(String argv[])
    {
  System.out.println(argv[1]);
    }
}

a. world

b. hello

c. hello world

d. 抛出ArrayIndexOutOfBoundsException

  1. 考虑以下代码序列:
public class SomeClass{
   public int i;
   public static void main(String argv[]){
      SomeClass sc = new SomeClass();
      // Comment line
   }
}

如果它们替换注释行,以下哪个语句将在不会出现语法或运行时错误的情况下编译?

a. sc.i = 5;

b. int j = sc.i;

c. sc.i = 5.0;

d. System.out.println(sc.i);

第二章:Java 数据类型及其使用

在本章中,我们将更多地了解 Java 如何组织和操作数据,特别是基本数据类型和字符串。除此之外,我们还将探讨各种相关概念,如作用域和变量的生命周期。虽然字符串在 Java 中不是基本数据类型,但它们是许多应用程序的重要组成部分,我们将探讨 Java 提供了什么。

在本章中,我们将重点关注:

  • 基本数据类型的声明和使用

  • 使用StringStringBuilder

  • 程序堆栈和堆如何相互关联

  • 类和对象之间的区别

  • Java 中的常量和文字

  • 变量的作用域和生命周期

  • 运算符、操作数和表达式

理解 Java 如何处理数据

编程的核心是操作数据的代码。作为程序员,我们对数据和代码的组织感兴趣。数据的组织被称为数据结构。这些结构可以是静态的或动态的。例如,人口的年龄可以存储在数据结构中连续的位置,这种数据结构称为数组。虽然数组数据结构具有固定的大小,但内容可能会改变或不改变。数组在第四章中有详细讨论,使用数组和集合

在本节中,我们将研究变量的几个不同方面,包括:

  • 它们是如何声明的

  • 基本数据类型与对象

  • 它们在内存中的位置

  • 它们是如何初始化的

  • 它们的作用域和生命周期

Java 标识符、对象和内存

变量被定义为特定类型,并分配内存。当创建对象时,构成对象的实例变量被分配在堆上。对象的静态变量被分配到内存的特殊区域。当变量被声明为方法的一部分时,变量的内存被分配在程序堆栈上。

堆栈和堆

对堆栈/堆和其他问题的彻底理解对于理解程序如何工作以及开发人员如何使用 Java 等语言来完成工作至关重要。这些概念为理解应用程序的工作方式提供了一个框架,并且是 Java 使用的运行时系统的实现的基础,更不用说几乎所有其他编程语言的实现了。

话虽如此,堆栈和堆的概念相当简单。堆栈 是每次调用方法时存储方法的参数和局部变量的区域。 是在调用new关键字时分配对象的内存区域。方法的参数和局部变量构成一个激活记录,也称为堆栈帧。激活记录在方法调用时被推送到堆栈上,并在方法返回时从堆栈上弹出。这些变量的临时存在决定了变量的生命周期。

堆栈和堆

当调用方法时,堆栈向堆增长,并在方法返回时收缩。堆不会按可预测的顺序增长,并且可能变得分散。由于它们共享相同的内存空间,如果堆和堆栈发生碰撞,程序将终止。

注意

理解堆栈和堆的概念很重要,因为:

  • 它提供了一个基础,用于理解应用程序中数据的组织方式

  • 它有助于解释变量的作用域和生命周期

  • 它有助于解释递归的工作原理

我们将重复使用第一章中演示的程序,Java 入门,以演示堆栈和堆的使用。该程序已经在此处复制以方便您使用:

package com.company.customer;
import java.math.BigDecimal;
import java.util.Locale;

public class Customer {
  private String name;
  private int accountNumber;
  private Locale locale;
  private BigDecimal balance;

  public Customer() {
    this.name = "Default Customer";
    this.accountNumber = 12345;
    this.locale = Locale.ITALY;
    this.balance = new BigDecimal("0");
  }

  public String getName() {
    return name;
  }
  public void setName(String name) throws Exception {
    if(name == null) {
      throw new Exception("Names must not be null");
    } else {
      this.name = name;
    }
  }

  public int getAccountNumber() {
    return accountNumber;
  }

  public void setAccountNumber(int accountNumber) {
    this.accountNumber = accountNumber;
  }

  public BigDecimal getBalance() {
    return balance;
  }

  public void setBalance(float balance) {
    this.balance = new BigDecimal(balance);
  }

  public String toString() {
    java.text.NumberFormat format;
    format = java.text.NumberFormat.getCurrencyInstance(locale);
    return format.format(balance);
  }
 }

package com.company.customer;
public class CustomerDriver {
  public static void main(String[] args) {
    Customer customer;      // defines a reference to a Customer
    customer = new Customer();  // Creates a new Customer object
    customer.setBalance(12506.45f);
    System.out.println(customer.toString());
  }

当执行main方法时,激活记录被推送到程序堆栈上。如下图所示,它的激活记录仅包括单个args参数和customer引用变量。当创建Customer类的实例时,将在堆上创建并分配一个对象。在此示例中反映的堆栈和堆的状态是在Customer构造函数执行后发生的。args引用变量指向一个数组。数组的每个元素引用表示应用程序命令行参数的字符串。在下图所示的示例中,我们假设有两个命令行参数,参数 1 和参数 2:

堆栈和堆

当执行setBalance方法时,它的激活记录被推送到程序堆栈上,如下所示。setBalance方法有一个参数balance,它被分配给balance实例变量。但首先,它被用作BigDecimal构造函数的参数。this关键字引用当前对象。

堆是为对象动态分配的内存。堆管理器控制这些内存的组织方式。当对象不再需要时,将执行垃圾收集例程以释放内存以便重新使用。在对象被处理之前,将执行对象的finalize方法。但是,不能保证该方法将执行,因为程序可能在不需要运行垃圾收集例程的情况下终止。原始的BigDecimal对象最终将被销毁。

堆栈和堆

注意

在 C++中,当对象即将被销毁时,其析构函数将被执行。Java 最接近的是finalize方法,当对象被垃圾收集器处理时将执行。但是,垃圾收集器可能不会运行,因此finalize方法可能永远不会执行。这种范式转变导致了我们在资源管理方面的重要差异。第八章中介绍的“try-with-resources”块,应用程序中的异常处理,提供了一种处理这种情况的技术。

声明变量

变量也称为标识符。术语“变量”意味着它的值可以更改。这通常是这样的。但是,如果标识符被声明为常量,如常量部分所讨论的那样,那么它实际上不是一个变量。尽管如此,变量和标识符这两个术语通常被认为是同义词。

变量的声明以数据类型开头,然后是变量名,然后是分号。数据类型可以是原始数据类型或类。当数据类型是类时,变量是对象引用变量。也就是说,它是对对象的引用。

注意

引用变量实际上是一个伪装的 C 指针。

变量可以分为以下三类:

  • 实例变量

  • 静态变量

  • 局部变量

实例变量用于反映对象的状态。静态变量是所有实例共有的变量。局部变量在方法内声明,只在声明它们的块中可见。

标识符区分大小写,只能由以下内容组成:

  • 字母、数字、下划线(_)和美元符号($)

  • 标识符只能以字母、下划线或美元符号开头

有效的变量名示例包括:

  • numberWheels

  • ownerName

  • mileage

  • _byline

  • numberCylinders

  • $newValue

  • _engineOn

按照惯例,标识符和方法以小写字母开头,后续单词大写,如第一章中的Java 命名约定部分所讨论的那样。常规声明的示例包括以下内容:

  • int numberWheels;

  • int numberCylinders;

  • float mileage;

  • boolean engineOn;

  • int $newValue;

  • String ownerName;

  • String _byline;

在前面的示例中,除了最后两个变量外,每个变量都声明为原始数据类型。最后一个声明为对String对象的引用。引用变量可以引用String对象,但在这个示例中,它被赋予了一个null值,这意味着它当前没有引用字符串。字符串在String 类部分中有更详细的介绍。以下代码片段声明了三个整数类型的变量:

int i;
int j;
int k;

也可以在一行上声明所有三个变量,如下所示:

int i, j, k;

原始数据类型

Java 中定义了八种原始数据类型,如下表所示。在 Java 中,每种数据类型的大小对所有机器来说都是相同的:

数据类型 字节大小 内部表示 范围
boolean -- 没有精确定义 truefalse
byte 1 8 位二进制补码 -128+127
char 2 Unicode \u0000\uffff
short 2 16 位二进制补码 -3276832767
int 4 32 位二进制补码 -2,147,483,6482,147,483,647
long 8 64 位二进制补码 -9,223,372,036,854,775,8089,223,372,036,854,775,807
float 4 32 位 IEEE 754 浮点数 3.4e +/- 38(7 位数字)
double 8 64 位 IEEE 754 浮点数 1.7e +/- 308(15 位数字)

String数据类型也是 Java 的一部分。虽然它不是原始数据类型,但它是一个类,并且在String 类部分中有详细讨论。

另一种常见的数据类型是货币。在 Java 中,有几种表示货币的方式,如下表所述。然而,推荐的方法是使用BigDecimal类。

数据类型 优点 缺点
整数 适用于简单的货币单位,如一分钱。 它不使用小数点,如美元和美分中使用的那样。
浮点数 使用小数点 舍入误差非常常见。
BigDecimal
  • 处理大数字。

  • 使用小数点。

  • 具有内置的舍入模式。

更难使用。

在使用BigDecimal时,重要的是要注意以下几点:

  • 使用带有String参数的构造函数,因为它在放置小数点时做得更好

  • BigDecimal是不可变的

  • ROUND_HALF_EVEN舍入模式引入了最小偏差

Currency类用于控制货币的格式。

提示

关于货币表示的另一个建议是基于使用的数字位数。

数字位数 推荐的数据类型

小于 10 的整数或BigDecimal

小于 19 的长整型或BigDecimal

大于 19 BigDecimal

在大多数语言中,浮点数可能是问题的重要来源。考虑以下片段,我们在尝试获得值1.0时添加0.1

float f = 0.1f;
for(int i = 0; i<9; i++) {
   f += 0.1f;
}
System.out.println(f);

输出如下:

1.0000001

它反映了十进制值0.1无法在二进制中准确表示的事实。这意味着我们在使用浮点数时必须时刻保持警惕。

包装类和自动装箱

包装类用于将原始数据类型值封装在对象中。在装箱可用之前,通常需要显式使用包装类,如IntegerFloat类。这是为了能够将原始数据类型添加到java.util包中经常出现的集合中,包括ArrayList类,因为这些数据类的方法使用对象作为参数。包装类包括以下数据类型:

  • 布尔

  • 字节

  • 字符

  • 整数

  • 浮点

这些包装类的对象是不可变的。也就是说,它们的值不能被改变。

自动装箱是将原始数据类型自动转换为其对应的包装类的过程。这是根据需要执行的,以消除在原始数据类型和其对应的包装类之间执行琐碎的显式转换的需要。取消装箱是指将包装对象自动转换为其等效的原始数据类型。实际上,在大多数情况下,原始数据类型被视为对象。

在处理原始值和对象时有一些需要记住的事情。首先,对象可以是null,而原始值不能被赋予null值。这有时可能会带来问题。例如,取消装箱一个空对象将导致NullPointerException。此外,在比较原始值和对象时要小心,当装箱不发生时,如下表所示:

比较 两个原始值 两个对象 一个原始值和一个对象
a == b 简单比较 比较引用值 被视为两个原始值
a.equals(b) 不会编译 比较值的相等性 如果 a 是原始值,否则它们的值将被比较

初始化标识符

Java 变量的初始化实际上是一个复杂的过程。Java 支持四种初始化变量的方式:

  • 默认初始值

  • 实例变量初始化程序

  • 实例初始化程序

  • 构造函数

在本章中,我们将研究前两种方法。后两种技术在第六章中进行了介绍,类,构造函数和方法,在那里整个初始化过程被整合在一起。

当未提供显式值时,对象创建时使用初始默认值。一般来说,当对象的字段被分配时,它会被初始化为零值,如下表所述:

数据类型 默认值(对于字段)
boolean false
byte 0
char '`u0000'`
short 0
int 0
long 0L
float 0.0f
double 0.0d
String(或任何对象) null

例如,在以下类中,name被赋予nullage的值为0

class Person {
  String name;
  int age;
  …
}

实例变量初始化程序的运算符可以用来显式地为变量分配一个值。考虑Person类的以下变化:

class Person {
  String name = "John Doe";
  int age = 23;
  …
}

当创建Person类型的对象时,nameage字段分别被赋予值John Doe23

然而,当声明一个局部变量时,它不会被初始化。因此,重要的是要在声明变量时使用初始化运算符,或者在为其分配值之前不使用该变量。否则,将导致语法错误。

Java 常量,字面量和枚举

常量和字面量在不能被更改方面是相似的。变量可以使用final关键字声明为不能更改的原始数据类型,因此被称为常量。字面量是表示值的标记,例如35'C'。显然,它也不能被修改。与此概念相关的是不可变对象——不能被修改的对象。虽然对象不能被修改,但指向对象的引用变量可以被更改。

枚举在本质上也是常量。它们用于提供一种方便的方式来处理值的集合作为列表。例如,可以创建一个枚举来表示一副牌的花色。

字面量

字面常量是表示数量的简单数字、字符和字符串。有三种基本类型:

  • 数字

  • 字符

  • 字符串

数字字面量

数字常量由一系列数字组成,可选的符号和可选的小数点。包含小数点的数字字面量默认为double常量。数字常量也可以以0x为前缀表示为十六进制数(基数 16)。以0开头的数字是八进制数(基数 8)。后缀fF可以用来声明浮点字面量的类型为float

数字字面量 基数 数据类型 十进制等价
25 10 int 25
-235 10 int -235
073 8 int 59
0x3F 16 int 63
23.5 10 double 23.5
23.5f 10 float 23.5
23.5F 10 float 23.5
35.05E13 10 double 350500000000.00

整数字面量很常见。通常它们以十进制表示,但可以使用适当的前缀创建八进制和十六进制字面量。整数字面量默认为int类型。可以通过在字面量的末尾添加 L 来指定字面量的类型为long。下表说明了字面量及其对应的数据类型:

字面量 类型
45 int
012 以八进制数表示的整数。
0x2FFC 以十六进制数表示的整数。
10L long
0x10L 以十六进制数表示的长整型。

注意

可以使用小写或大写的 L 来指定整数的长整型类型。但最好使用大写的 L,以避免将字母与数字 1 混淆。在下面的例子中,一个不小心的读者可能会将字面量看作是一百零一,而不是整数 10:

10l10L

浮点字面量是包含小数点的数字,或者使用科学计数法写成的数字。

字面量 类型
3.14 double
10e6 double
0.042F float

Java 7 增加了在数字字面量中使用下划线字符(_)的能力。这通过在字面量的重要部分之间添加可视间距来增强代码的可读性。下划线可以几乎添加到数字字面量的任何位置。它可以与浮点数和任何整数基数(二进制、八进制、十六进制或十进制)一起使用。此外,还支持基数 2 字面量。

下表说明了在各种数字字面量中使用下划线的情况:

示例 用法
111_22_3333 社会安全号码
1234_5678_9012_3456 信用卡号码
0b0110_00_1 代表一个字节的二进制字面量
3._14_15F 圆周率
0xE_44C5_BC_5 32 位数量的十六进制字面量
0450_123_12 24 位八进制字面量

在代码中使用字面量对数字的内部表示或显示方式没有影响。例如,如果我们使用长整型字面量表示社会安全号码,该数字在内部以二进制补码表示,并显示为整数:

long ssn = 111_22_3333L;
System.out.println(ssn);

输出如下:

111223333

如果需要以社会安全号码的格式显示数字,需要在代码中进行。以下是其中一种方法:

long ssn = 111_22_3333L;
String formattedSsn = Long.toString(ssn);
for (int i = 0; i < formattedSsn.length(); i++) {
    System.out.print(formattedSsn.charAt(i));
    if (i == 2 || i == 4) {
        System.out.print('-');
    }
}
System.out.println();

执行时,我们得到以下输出:

111-22-3333

下划线的使用是为了使代码对开发人员更易读,但编译器会忽略它。

在使用文字中的下划线时,还有一些其他要考虑的事情。首先,连续的下划线被视为一个,并且也被编译器忽略。此外,下划线不能放置在:

  • 在数字的开头或结尾

  • 紧邻小数点

  • DFL后缀之前

以下表格说明了下划线的无效用法。这些将生成语法错误:非法下划线

例子 问题
_123_6776_54321L 不能以下划线开头
0b0011_1100_ 不能以下划线结尾
3._14_15F 不能紧邻小数点
987_654_321_L 不能紧邻L后缀

一些应用程序需要操作值的位。以下示例将对一个值使用掩码执行位 AND 操作。掩码是一系列用于隔离另一个值的一部分的位。在这个例子中,value代表一个希望隔离最后四位的位序列。二进制文字代表掩码:

value & 0b0000_11111;

当与包含零的掩码进行 AND 操作时,AND 操作将返回零。在前面的例子中,表达式的前四位将是零。最后四位与一进行 AND 操作,结果是结果的最后四位与值的最后四位相同。因此,最后四位已被隔离。

通过执行以下代码序列来说明:

byte value = (byte) 0b0111_1010;
byte result = (byte) (value & 0b0000_1111);
System.out.println("result: " + Integer.toBinaryString(result));

执行时,我们得到以下输出:

result: 1010

以下图表说明了这个 AND 操作:

数字文字

字符文字

字符文字是用单引号括起来的单个字符。

char letter = 'a';
letter = 'F';

然而,一个或多个符号可以用来表示一个字符。反斜杠字符用于“转义”或赋予字母特殊含义。例如,'\n'代表回车换行字符。这些特殊的转义序列代表特定的特殊值。这些转义序列也可以在字符串文字中使用。转义序列字符列在下表中:

转义序列字符 含义
\a 警报(响铃)
\b 退格
\f 换页
\n 换行
\r 回车
\t 水平制表符
\v 垂直制表符
\\ 反斜杠
\? 问号
\' 单引号
\" 双引号
\ooo 八进制数
\xhh 十六进制数

字符串文字

字符串文字是一系列用双引号括起来的字符。字符串文字不能跨两行分割:

String errorMessage = "Error – bad input file name";
String columnHeader = "\tColumn 1\tColumn2\n";

常量

常量是其值不能改变的标识符。它们用于情况,其中应该使用更易读的名称而不是使用文字。在 Java 中,常量是通过在变量声明前加上final关键字来声明的。

在下面的例子中,声明了三个常量——PINUMSHIPSRATEOFRETURN。根据标准Java 命名约定第第一章的开始使用 Java部分,每个常量都是大写的,并赋予一个值。这些值不能被改变:

final double PI = 3.14159;
final int NUMSHIPS = 120;
final float RATEOFRETURN = 0.125F;

在下面的语句中,试图改变 PI 的值:

PI = 3.14;

根据编译器的不同,将生成类似以下的错误消息:

cannot assign a value to final variable PI

这意味着您不能改变常量变量的值。

注意

常量除了始终具有相同的值之外,还提供其他好处。常量数字或对象可以更有效地处理和优化。这使得使用它们的应用程序更有效和更易于理解。我们可以简单地使用PI而不是在需要的每个地方使用 3.14159。

final 关键字

虽然final关键字用于声明常量,但它还有其他用途,如下表所述。我们将在后面的章节中介绍它在方法和类中的用法:

应用于 意义
原始数据声明 分配给变量的值无法更改。
引用变量 无法更改变量以引用不同的变量。但是,可能可以更改变量引用的对象。
方法 该方法无法被覆盖。
该类无法被扩展。

枚举

枚举实际上是java.lang.Enum类的子类。在本节中,我们将看一下简单枚举的创建。有关此主题的更完整处理,请参阅第六章中的类,构造函数和方法

以下示例声明了一个名为Directions的枚举。此枚举表示四个基本点。

public enum Directions {NORTH, SOUTH, EAST, WEST}

我们可以声明此类型的变量,然后为其分配值。以下代码序列说明了这一点:

Directions direction;
direction = Directions.EAST;
System.out.println(direction);

此序列的输出如下:

EAST

enum调用也可以作为 switch 语句的一部分,如下所示:

switch(direction) {
case NORTH:
  System.out.println("Going North");
  break;
case SOUTH:
  System.out.println("Going South");
  break;
case EAST:
  System.out.println("Going East");
  break;
case WEST:
  System.out.println("Going West");
  break;
}

在与前面的代码一起执行时,我们得到以下输出:

Going East

不可变对象

不可变对象是其字段无法修改的对象。在 Java 核心 SDK 中有几个类的对象是不可变的,包括String类。也许令人惊讶的是,final关键字并未用于此目的。这些将在第六章中详细讨论,类,构造函数和方法

实例与静态数据

类中有两种不同类型的变量(数据):实例和静态。当实例化对象(使用类名的new关键字)时,每个对象由组成该类的实例变量组成。但是,为每个类分配了静态变量的唯一副本。虽然每个类都有其自己的实例变量副本,但所有类共享静态变量的单个副本。这些静态变量分配到内存的一个单独区域,并且存在于类的生命周期内。

考虑添加一个可以选择性地应用于某些客户的常见折扣百分比,但不是所有客户。无论是否应用,百分比始终相同。基于这些假设,我们可以将静态变量添加到类中,如下所示:

private static float discountPercentage;

静态方法和字段在第六章中有更详细的介绍,类,构造函数和方法

范围和生命周期

范围指的是程序中特定变量可以使用的位置。一般来说,变量在其声明的块语句内可见,但在其外部不可见。块语句是由花括号封装的代码序列。

如果变量在范围内,则对代码可见并且可以访问。如果不在范围内,则无法访问变量,并且任何尝试这样做都将导致编译时错误。

变量的生命周期是指其分配了内存的时间段。当变量声明为方法的局部变量时,分配给变量的内存位于激活记录中。只要方法尚未返回,激活记录就存在,并且为变量分配内存。一旦方法返回,激活记录就从堆栈中移除,变量就不再存在,也无法使用。

从堆中分配的对象的生命周期始于分配内存时,终止于释放内存时。在 Java 中,使用new关键字为对象分配内存。当对象不再被引用时,对象及其内存被标记为释放。实际上,如果对象没有被回收,它将在未来的某个不确定的时间点被释放,如果有的话。如果一个对象没有引用,即使垃圾收集器尚未回收它,它也可以被使用或访问。

作用域规则

作用域规则对于理解诸如 Java 之类的块结构语言的工作方式至关重要。这些规则解释了变量何时可以使用,以及在命名冲突发生时将使用哪一个。

作用域规则围绕着块的概念。块由开放和闭合的大括号界定。这些块用于将代码分组在一起,并定义变量的范围。以下图表显示了三个变量ijk的范围:

作用域规则

访问修饰符

在声明实例和静态变量和方法时,可以使用访问修饰符作为前缀。修饰符以各种组合应用以提供特定的行为。修饰符的顺序并不总是重要的,但一致的风格会导致更可读的代码。所有修饰符都是可选的,尽管有一些默认修饰符。访问修饰符包括:

  • public:公共对象对其自身类内外的所有方法可见。

  • protected:这允许在当前类和子类之间进行保护。受保护的对象在类外是不可见的,对子类完全可见。

  • private:私有变量只能被定义它的类(包括子类)看到。

  • :这种可见性是默认保护。只有包内的类才有访问权限(包内公共)。

要解释变量的作用域,请考虑以下图表中显示的包/类组织,箭头表示继承:

访问修饰符

假设 A 类定义如下:

public class A{
   public int  publicInt;
   private int privateInt;
   protected int  protectedInt;
   int defaultInt;  // default (package)
} 

所有变量都是int类型。publicInt变量是公共变量。它可以被这个类内外的所有方法看到。privateInt变量只在这个类内可见。protectedInt变量只对这个包内的类可见。protectedInt变量对这个类、它的子类和同一个包内的其他类可见。在其他地方是不可见的。以下表格显示了每种声明类型对每个类的可见性:

A B C D E
publicInt 可见 可见 可见 可见 可见
privateInt 可见 不可见 不可见 不可见 不可见
protectedInt 可见 可见 可见 不可见 可见
defaultInt 可见 可见 可见 不可见 不可见

数据摘要

以下表格总结了变量类型及其与 Java 编译时和运行时元素的关系:

程序元素 变量类型 的一部分 分配给
实例 对象
静态 内存的特殊区域
方法 参数 激活记录 栈的激活记录
本地

使用操作数和运算符构建表达式

表达式由操作数和运算符组成。操作数通常是变量名或文字,而运算符作用于操作数。以下是表达式的示例:

int numberWheels = 4;
System.out.println("Hello");
numberWheels = numberWheels + 1;

有几种分类运算符的方法:

  • 算术

  • 赋值

  • 关系

  • 逻辑补码

  • 逻辑

  • 条件

  • 按位

表达式可以被认为是程序的构建块。它们用于表达程序的逻辑。

优先级和结合性

Java 运算符总结如下优先级和结合性表。这些运算符中的大多数都很简单:

优先级 运算符 结合性 运算符
1 ++ 前/后增量
-- 前/后减量
+,- 一元加或减
~ 位补
! 逻辑补
(cast) 强制转换
2 *, /, 和 % 乘法、除法和取模
3 +- 加法和减法
+ 字符串连接
4 << 左移
>> 右移和符号填充
>>> 右移和零填充
5 <, <=, >, >= 逻辑
Instanceof 类型比较
6 ==!= 相等和不相等
7 & 位和布尔与
8 ^ 位和布尔异或
9 ` `
10 && 布尔与
11 ` `
12 ?: 条件
13 = 赋值
+=, -=, *=, /=, 和 %= 复合

虽然大多数这些运算符的使用是直接的,但它们的更详细的用法示例将在后面的章节中提供。但请记住,在 Java 中没有其他变体和其他可用的运算符。例如,+=是一个有效的运算符,而=+不是。但是,它可能会带来意想不到的后果。考虑以下情况:

total = 0;
total += 2;  // Increments total by 2
total =+ 2;  // Valid but simply assigns a 2 to total!

最后一条语句似乎使用了一个=+运算符。实际上,它是赋值运算符后面跟着的一元加运算符。一个+2被赋给total。请记住,Java 会忽略除了字符串文字之外的空格。

强制转换

当一种类型的数据被分配给另一种类型的数据时,可能会丢失信息。如果数据从更精确的数据类型分配到不太精确的数据类型,就可能会发生缩小。例如,如果浮点数45.607被分配给整数,小数部分.607就会丢失。

在进行此类分配时,应使用强制转换运算符。强制转换运算符只是您要转换为的数据类型,括在括号中。以下显示了几个显式转换操作:

int i;
float f = 1.0F;
double d = 2.0;

i = (int) f;  // Cast a float to an int
i = (int) d;  // Cast a double to an int
f = (float) d;  // Cast a double to a float

在这种情况下,如果没有使用强制转换运算符,编译器将发出警告。警告是为了建议您更仔细地查看分配情况。精度的丢失可能是一个问题,也可能不是,这取决于应用程序中数据的使用。没有强制转换运算符,当代码执行时会进行隐式转换。

处理字符和字符串

主要类包括StringStringBufferStringBuilderCharacter类。还有几个与字符串和字符操作相关的其他类和接口,列举如下,您应该知道。但并非所有这些类都将在此处详细说明。

  • Character:这涉及到字符数据的操作

  • Charset:这定义了 Unicode 字符和字节序列之间的映射

  • CharSequence:在这里,一个接口由StringStringBufferStringBuilder类实现,定义了公共方法

  • StringTokenizer:这用于对文本进行标记化

  • StreamTokenizer:这用于对文本进行标记化

  • Collator:这用于支持特定区域设置字符串的操作

String、StringBuffer 和 StringBuilder 类

对于 Java 程序员,有几个与字符串相关的类可用。在本节中,我们将研究 Java 中用于操作此类数据的类和技术。

在 JDK 中用于字符串操作的三个主要类是StringStringBufferStringBuilderString类是这些类中最广泛使用的。StringBufferStringBuilder类是在 Java 5 中引入的,以解决String类的效率问题。String类是不可变的,需要频繁更改字符串的应用程序将承受创建新的不可变对象的开销。StringBufferStringBuilder类是可变对象,当字符串需要频繁修改时可以更有效地使用。StringBufferStringBuilder的区别在于它的方法是同步的。

在类支持的方法方面,StringBufferStringBuilder的方法是相同的。它们只在方法是否同步上有所不同。

可变 同步
String
StringBuffer
StringBuilder

当处理使用多个线程的应用程序时,同步方法是有用的。线程是一个独立执行的代码序列。它将与同一应用程序中的其他线程同时运行。并发线程不会造成问题,除非它们共享数据。当这种情况发生时,数据可能会变得损坏。同步方法的使用解决了这个问题,并防止数据由于线程的交互而变得损坏。

同步方法的使用包括一些开销。因此,如果字符串不被多个线程共享,则不需要StringBuffer类引入的开销。当不需要同步时,大多数情况下应该使用StringBuilder类。

注意

使用字符串类的标准

如果字符串不会改变,请使用String类:

  • 由于它是不可变的,因此可以安全地在多个线程之间共享

  • 线程只会读取它们,这通常是一个线程安全的操作。

如果字符串将要改变并且将在线程之间共享,则使用StringBuffer类:

  • 这个类是专门为这种情况设计的

  • 在这种情况下使用这个类将确保字符串被正确更新

  • 主要缺点是方法可能执行得更慢

如果字符串要改变但不会在线程之间共享,请使用StringBuilder类:

  • 它允许修改字符串,但不会产生同步的开销

  • 这个类的方法将执行得和StringBuffer类一样快,甚至更快

Unicode 字符

Java 使用 Unicode 标准来定义字符。然而,这个标准已经发展和改变,而 Java 已经适应了它的变化。最初,Unicode 标准将字符定义为一个 2 字节 16 位值,可以使用可打印字符或U+0000U+FFFF来表示。无论可打印与否,十六进制数字都可以用来编码 Unicode 字符。

然而,2 字节编码对于所有语言来说都不够。因此,Unicode 标准的第 4 版引入了新的字符,位于U+FFFF以上,称为UTF-1616 位 Unicode 转换格式)。为了支持新标准,Java 使用了代理对的概念——16 位字符对。这些对用于表示从U+10000U+10FFFF的值。代理对的前导或高值范围从U+D800U+DBFF。对的尾部或低值范围从U+DC00U+DFFF。这些范围内的字符称为补充字符。这两个特殊范围用于将任何 Unicode 字符映射到代理对。从 JDK 5.0 开始,一个字符使用 UTF-16 表示。

字符类

Character类是char原始数据类型的包装类。该数据类型支持 Unicode 标准版本 4.0。字符被定义为固定宽度的 16 位数量。

字符类-方法

字符串类

方法
String类是 Java 中用于表示字符串的常见类。它是不可变的,这使得它是线程安全的。也就是说,多个线程可以访问同一个字符串,而不用担心破坏字符串。不可变还意味着它是固定大小的。
如果字符是数字,则返回 true
如果字符是字母,则返回 true
如果字符是字母或数字,则返回 true
如果字符是小写字母,则返回 true
如果字符是空格,则返回 true
如果字符是大写字母,则返回 true
返回字符的小写等价物
描述

返回字符的大写等价物

Character类具有处理字符的多种方法。许多Character方法都是重载的,可以接受char或 Unicode 代码点参数。代码点是用于字符的抽象,对于我们的目的是 Unicode 字符。以下表列出了您可能会遇到的几种Character方法:

String类被设计为不可变的一个原因是出于安全考虑。如果一个字符串用于标识受保护的资源,一旦为该资源授予权限,可能会修改字符串然后获取对用户没有权限的另一个资源的访问权限。通过使其不可变,可以避免这种漏洞。

虽然String类是不可变的,但它可能看起来是可变的。考虑以下示例:

String s = "Constant";
s = s + " and unchangeable";
System.out.println(s);

输出这个序列的结果是字符串"Constant and unchangeable"。由于s被定义为String类型,因此由s标识符引用的对象不能改变。当进行第二个赋值语句时,将创建一个新对象,将Constantand unchangeable组合在一起,生成一个新的字符串Constant and unchangeable。在这个过程中创建了三个String对象:

  • 常量

  • 不可改变

  • 不可改变

标识符s现在引用新的字符串Constant and unchangeable

虽然我们可以访问这些对象,但我们无法更改它们。我们可以访问和读取它们,但不能修改它们。

我们本可以使用String类的concat方法,但这并不那么直接:

s = "Constant";
s = s.concat(" and unchangeable");
System.out.println(s);

以下代码演示了创建String对象的几种技术。第一个构造函数只会产生一个空字符串。除非应用程序需要在堆上找到一个空的不可变字符串,否则这对于立即价值不大。

String firstString = new String();
String secondString = new String("The second string");
String thirdString = "The third string";

此外,还有两个使用StringBufferStringBuilder类的构造函数。从这些对象创建了新的String对象,如下代码序列所示:

StringBuffer stringBuffer =new StringBuffer("A StringBuffer string");
StringBuilder stringBuilder =new StringBuilder("A StringBuilder string");
String stringBufferBasedString = new String(stringBuffer);
String stringBuilderBasedString = new String(stringBuilder);

注意

在内部,String类的字符串表示为char数组。

字符串比较

字符串比较并不像最初看起来那么直接。如果我们想要比较两个整数,我们可能会使用如下语句:

if (count == max) {
  // Do something
}

然而,对于两个字符串的比较,比如s1s2,以下通常会评估为false

String s1 = "street";
String s2;

s2 = new String("street");

if (s1 == s2) {
  // False
}

问题在于变量s1s2可能引用内存中的不同对象。if 语句比较字符串引用变量而不是实际的字符串。由于它们引用不同的对象,比较返回false。这完全取决于编译器和运行时系统如何在内部处理字符串。

当使用new关键字时,内存是从堆中分配并分配给新对象。但是,在字符串文字的情况下,这个内存不是来自堆,而是来自文字池,更具体地说,是字符串内部池。在 Java 中,内部化的字符串被放置在 JVM 的永久代区域中。该区域还存储 Java 类声明和类静态变量等内容。

内部化字符串仅存储每个不同字符串的一个副本。这是为了改善某些字符串方法的执行并减少用于表示相同字符串的空间量。此区域中的字符串会受到垃圾回收的影响。

例如,如果我们创建两个字符串文字和一个使用new关键字的String对象:

String firstLiteral = "Albacore Tuna";
String secondLiteral = "Albacore Tuna";
String firstObject = new String("Albacore Tuna");

if(firstLiteral == secondLiteral) {
  System.out.println(
     "firstLiteral and secondLiteral are the same object");
} else {
  System.out.println(
     "firstLiteral and secondLiteral are not the same object");
}
if(firstLiteral == firstObject) {
  System.out.println(
     "firstLiteral and firstObject are the same object");
} else {
  System.out.println(
     "firstLiteral and firstObject are not the same object");
}

输出如下:

firstLiteral and secondLiteral are the same object
firstLiteral and firstObject are not the same object

String类的intern方法可用于对字符串进行内部化。对于所有常量字符串,内部化是自动执行的。在比较内部化的字符串时,可以使用等号运算符,而不必使用equals方法。这可以节省对字符串密集型应用程序的时间。很容易忘记对字符串进行内部化,因此在使用等号运算符时要小心。除此之外,intern方法可能是一个昂贵的方法。

注意

Java 还会对String类型之外的其他对象进行内部化。这些包括包装对象和小整数值。当使用原始类型的字符串连接运算符时,可能会产生包装对象。有关更多详细信息,请访问docs.oracle.com/javase/specs/jls/se7/jls7.pdf,并参考 5.1.7 和 12.5 节。

要执行String比较,可以使用一系列String方法,包括但不限于以下内容:

方法 目的
equals 比较两个字符串,如果它们等效,则返回true
equalsIgnoreCase 忽略字母大小写比较两个字符串,如果它们等效,则返回true
startsWith 如果字符串以指定的字符序列开头,则返回true
endsWith 如果字符串以指定的字符序列结尾,则返回true
compareTo 如果第一个字符串在第二个字符串之前,则返回-1,如果它们相等,则返回0,如果第一个字符串在第二个字符串之后,则返回1

注意

记住字符串从索引0开始。

以下是使用各种字符串比较的示例:

String location = "Iceberg City";
if (location.equals("iceberg city"))
  System.out.println(location + " equals ' city'!");
else
  System.out.println(location +" does not equal 'iceberg city'");

if (location.equals("Iceberg City"))
  System.out.println(location + " equals 'Iceberg City'!");
else
  System.out.println(location +" does not equal 'Iceberg City'!");

if (location.endsWith("City"))
  System.out.println(location + " ends with 'City'!");
else
  System.out.println(location + " does not end with 'City'!");

输出如下所示:

Iceberg City does not equal 'iceberg city'
Iceberg City equals 'Iceberg City'!
Iceberg City ends with 'City'!

在使用此方法时有几件事情需要考虑。首先,大写字母在小写字母之前。这是它们在 Unicode 中的排序结果。ASCII 也适用相同的排序。

一个字符串可以有多个内部表示。许多语言使用重音来区分或强调字符。例如,法国名字 Irène 使用重音,可以表示为I r è n e或序列I r e java `n` `e`. The second sequence combines the `e` and 以形成字符è。如果使用equals方法比较这两种不同的内部表示,该方法将返回false。在这个例子中,\u0300将重音与字母e组合在一起。

String firstIrene = "Irène";

String secondIrene = "Ire\u0300ne";

if (firstIrene.equals(secondIrene)) {
    System.out.println("The strings are equal.");
} else {
    System.out.println("The strings are not equal.");
}

此代码序列的输出如下:

The strings are not equal.

Collator类可用于以特定于区域设置的方式操作字符串,消除了不同内部字符串表示的问题。

基本字符串方法

您可能会遇到几种String方法。这些在下表中有所说明:

方法 目的
length 返回字符串的长度。
charAt 返回字符串中给定索引的字符的位置。
substring 此方法是重载的,返回字符串的部分。
indexOf 返回字符或字符串的第一次出现的位置。
lastIndexOf 返回字符或字符串的最后一次出现的位置。

以下示例说明了这些方法的使用:

String sample = "catalog";
System.out.println(sample.length());
System.out.println(sample.charAt(0));
System.out.println(sample.charAt(sample.length()-1));
System.out.println(sample.substring(0,3));
System.out.println(sample.substring(4));

执行此代码时,我们得到以下输出:

7
c
g
cat
log

在许多应用程序中,搜索字符串以查找字符或字符序列是常见的需求。indexOflastIndex方法执行此类操作:

String location = "Irene";
System.out.println(location.indexOf('I'));
System.out.println(location.lastIndexOf('e'));
System.out.println(location.indexOf('e'));

这些语句的结果如下:

0
4
2

您可以将字符串中的位置视为字符之前的位置。这些位置或索引从0开始,如下图所示:

基本字符串方法

字符串长度

字符串长度的计算可能比简单使用length方法所建议的要复杂一些。它取决于正在计数的内容以及字符串在内部的表示方式。

用于确定字符串长度的方法包括:

  • length:标准方法

  • codePointCount:与补充字符一起使用

  • 字节数组的length方法:用于确定用于保存字符串的实际字节数

在存储字符串时,字符串的实际长度(以字节为单位)可能很重要。数据库表中分配的空间量可能需要比字符串中的字符数更长。

数字/字符串转换

将数字转换为字符串的过程很重要。我们可以使用两种方法。第一种方法使用静态方法,如下代码序列所示。valueOf方法将数字转换为字符串:

String s1 = String.valueOf(304);
String s2 = String.valueOf(778.204);

intValuedoubleValue方法接受valueOf静态方法返回的对象,并分别返回整数或双精度数:

int  num1 = Integer.valueOf("540").intValue();
double  num2 = Double.valueOf("3.0654").doubleValue();

第二种方法是使用各自的包装类的parseIntparseDouble方法。它们的使用如下所示:

num1 = Integer.parseInt("540");
num2 = Double.parseDouble("3.0654");

杂项字符串方法

有几种杂项方法可能会有用:

  • replace:这将字符串的一部分替换为另一个字符串

  • toLowerCase:将字符串中的所有字符转换为小写

  • toUpperCase:将字符串中的所有字符转换为大写

  • trim:删除前导和尾随空格

以下是这些方法的使用示例:

String oldString = " The gray fox ";
String newString;

newString = oldString.replace(' ','.');
System.out.println(newString);

newString = oldString.toLowerCase();
System.out.println(newString);

newString = oldString.toUpperCase();
System.out.println(newString);

newString = oldString.trim();
System.out.println("[" + newString +"]" );

结果如下所示:

.The.gray.fox.
 the gray fox
 THE GRAY FOX
[The gray fox]

StringBuffer 和 StringBuilder 类

StringBufferStringBuilder类提供了String类的替代方法。与String类不同,它们是可变的。这在使程序更有效时有时是有帮助的。有几种常用的方法可用于操作StringBufferStringBuilder对象。以下示例中演示了其中几种。虽然示例使用StringBuffer类,但StringBuilder方法的工作方式相同。

经常需要将一个字符串附加到另一个字符串。可以使用append方法来实现这一点:

StringBuffer buffer = new StringBuffer();
buffer.append("World class");
buffer.append(" buffering mechanism!");

以下是将字符串插入缓冲区的示例:

buffer.insert(6,"C");

更详细的示例:

StringBuffer buffer;
buffer = new StringBuffer();
buffer.append("World lass");
buffer.append(" buffering mechanism!");
buffer.insert(6,"C");
System.out.println(buffer.toString());

结果如下:

World Class buffering mechanism!

摘要

在本章中,我们已经研究了 Java 如何处理数据。堆栈和堆的使用是重要的编程概念,可以很好地解释变量的作用域和生命周期等概念。介绍了对象和原始数据类型之间的区别以及变量的初始化。初始化过程将在第六章类,构造函数和方法中更详细地介绍。列出了 Java 中可用的运算符以及优先级和结合性规则。此外,还介绍了字符和字符串数据的操作。

在下一章中,我们将探讨 Java 中可用的决策结构以及它们如何有效地使用。这将建立在此处介绍的数据类型之上。

涵盖的认证目标

在本章中,我们涵盖了以下内容:

  • 了解 Java 如何处理数据

  • 调查标识符、Java 类和内存之间的关系

  • 定义变量的范围

  • 初始化标识符

  • 使用运算符和操作数构建表达式

  • 处理字符串

  • 理解对象和原始数据类型之间的区别

测试你的知识

  1. 当编译和运行以下代码时会发生什么?
public class ScopeClass{
   private int i = 35;
   public static void main(String argv[]){
      int i = 45;
      ScopeClass s = new ScopeClass ();
      s.someMethod();
   }
   public static void someMethod(){
      System.out.println(i);
   }
}

a. 35 将被打印出来

b. 45 将被打印出来

c. 将生成编译时错误

d. 将抛出异常

  1. 以下哪行将会编译而不会产生警告或错误?

a. char d="d";

b. float f=3.1415;

c. int i=34;

d. byte b=257;

e. boolean isPresent=true;

  1. 给出以下声明:
public class SomeClass{
   public int i;
   public static void main(String argv[]){
      SomeClass sc = new SomeClass();
      // Comment line
   }
}

如果它们替换注释行,以下哪些陈述是正确的?

a. System.out.println(i);

b. System.out.println(sc.i);

c. System.out.println(SomeClass.i);

d. System.out.println((new SomeClass()).i);

  1. 给出以下声明:
StringBuilder sb = new StringBuilder;

以下哪些是sb变量的有效用法?

a. sb.append(34.5);

b. sb.deleteCharAt(34.5);

c. sb.toInteger (3);

d. sb.toString();

  1. 以下哪个将返回字符串 s 中包含“banana”的第一个字母a的位置?

a. lastIndexOf(2,s);

b. s.indexOf('a');

c. s.charAt( 2);

d. indexOf(s,'v');

  1. 给出以下代码,哪个表达式显示单词“Equal”?
String s1="Java";
String s2="java";
if(expression) {
   System.out.println("Equal");
} else {
   System.out.println("Not equal");
}

a. s1==s2

b. s1.matchCase(s2)

c. s1.equalsIgnoreCase(s2)

d. s1.equals(s2)

第三章:决策结构

每个应用程序都会做出某种决定。在 Java 中,有几种编程构造可以用来做出这些决定。这些包括逻辑表达式、if 语句和 switch 语句。本章的目的是向您介绍这些工具,并说明它们如何使用。

我们将从逻辑表达式的讨论开始,因为它们对做决定至关重要。逻辑表达式是返回布尔值的表达式。

接下来,我们将研究逻辑表达式如何与if语句和条件运算符一起使用。if语句的结构有许多变化,我们将看看它们的优缺点。

接下来将讨论switch语句。在 Java 7 之前,switch语句基于整数或枚举值。在 Java 7 中,我们现在可以使用String值。我们将研究字符串的使用及其潜在的问题。

最后一节讨论了一般的控制结构问题,以及在做决定、比较对象时浮点数的影响,以及组织代码的有用方法的讨论。

在本章中,我们将:

  • 研究决策结构的性质

  • 研究逻辑表达式的基础知识

  • 学习如何使用if语句,并查看其变体

  • 了解条件运算符以及何时应该使用它

  • 探索switch语句和 Java 7 在该语句中使用字符串的方式

  • 确定浮点数比较如何影响控制

  • 研究与比较对象相关的潜在问题

控制流

在任何应用程序中,程序内的控制流由语句执行的顺序决定。方便起见,可以将语句组视为由决策语句控制执行顺序的块。块可以被视为一个单独的语句或包含在块语句中的多个语句。在 Java 中,块语句是一组用大括号括起来的语句。

控制语句 - 概述

控制结构是语言中确定个别语句执行顺序的部分。没有控制结构,语句将按顺序执行,如下面的代码片段所示:

hours ==35;
payRate = 8.55;
pay = hours * payRate;
System.out.println(pay);

为了改变语句的执行顺序,使用控制语句。在 Java 中,这些语句包括:

  • if语句:这个语句经常用于决定采取哪个分支

  • 条件运算符:这个语句是if语句的简化和有限形式

  • switch语句:这个语句用于决定采取哪个分支

switch语句使用整数、枚举或字符串值来做出决定。要理解if语句需要理解逻辑表达式。这将在下一节中介绍。

逻辑表达式

与所有表达式一样,逻辑表达式由运算符和操作数组成。在 Java 中,有限数量的逻辑运算符如下表所总结的那样。它是第二章中列出的运算符的子集,Java 数据类型及其用法

优先级 运算符 结合性 意义
1
! 逻辑补
5 <, <=, >, 和 >= 逻辑
instanceof 类型比较
6 ==!= 相等和不相等
10 && 逻辑与
11 ` `
12 ?: 条件

逻辑表达式的操作数可以是任何数据类型,但逻辑表达式总是评估为truefalse值。

注意

不要将按位运算符&^|与相应的逻辑运算符&&||混淆。按位运算符执行与逻辑运算符类似的操作,但是逐位执行。

布尔变量

truefalse是 Java 中的关键字。它们的名称对应它们的值,并且可以赋给布尔变量。布尔变量可以用boolean关键字声明,后面跟着变量名和可选的初始值:

boolean isComplete;
boolean isReady = true;  // Initialized to true
boolean errorPresent;

当逻辑表达式被评估时,它将返回truefalse值。逻辑表达式的示例包括以下内容:

age > 45
age > 45 && departmentNumber == 200
((flowRate > minFlowRate) || ((flowRate > maxFlowRate) && (valveA == off)))

给布尔变量一个反映truefalse状态的名称是一个好习惯。isComplete变量意味着一个操作已经完成。如果isReady变量设置为 true,则表示某物已经准备好。

等于运算符

等于运算符由两个等号组成,当评估时将返回truefalse值。赋值运算符使用单个等号,并将修改其左操作数。为了说明这些运算符,考虑以下示例。如果rate变量的值等于100,我们可以假设存在错误。为了反映这种错误条件,我们可以将true值赋给errorPresent变量。这可以使用赋值和等于运算符来执行。

int rate;
rate = 100;
boolean errorPresent = rate==100;
System.out.println(errorPresent);

当执行前面的代码片段时,我们得到以下输出:

true

逻辑表达式rate==100比较存储在rate中的值与整数文字100。如果它们相等,这里就是这种情况,表达式返回true。然后将true值赋给errorPresent。如果存储在rate中的值不是100,则表达式将返回false值。我们将更深入地研究等于运算符在比较浮点数比较对象部分的使用。

关系运算符

关系运算符用于确定两个操作数之间的关系或相对顺序。这些运算符经常使用两个符号。例如,大于或等于使用>=运算符表示。符号的顺序很重要。使用=>是不合法的。

关系运算符列在下表中:

操作符 意义 简单示例
--- --- ---
小于 < age<35
小于或等于 <= age<=35
大于 > age>35
大于或等于 >= age>=35
等于 == age==35

如果我们希望确定一个年龄是否大于 25 且小于 35,我们将不得不两次使用age变量,并与&&运算符结合使用,如下所示:

age > 25 && age < 35

虽然以下表达式对我们可能有意义,但在 Java 中是不合法的。

25 < age < 35

之所以在前面的示例中变量age必须使用两次,是因为关系运算符是二元运算符。也就是说,每个二元运算符作用于两个操作数。在前面的表达式中,我们比较25,看它是否小于age。操作将返回truefalse值。接下来,将 true 或 false 结果与35进行比较,这是没有意义的,也是不合法的。

这些是语言的规则。我们不能违反这些规则,因此重要的是我们理解这些规则。

逻辑运算符

当我们考虑如何做决定时,我们经常使用逻辑结构,比如 AND 和 OR。如果两个条件都为真,我们可能会做出决定,或者如果两个条件中的任何一个为真,我们可能会决定做某事。AND 运算符意味着两个条件都必须为真,而 OR 意味着两个条件中只有一个需要为真。

这两个操作是大多数逻辑表达式的基础。我们经常会决定在某些条件不成立时做一些事情。如果下雨,我们可能决定不遛狗。NOT 也是用于做决定的运算符。使用时,它将 true 变为 false,false 变为 true。

在 Java 中有三个逻辑运算符来实现这些逻辑结构。它们总结如下表:

运算符 含义 简单示例
&& AND age > 35 && height < 67
&#124;&#124; OR age > 35 &#124;&#124; height < 67
! NOT !(age > 35)

AND、OR 和 NOT 运算符基于以下真值表:

逻辑运算符

一些决策可能更加复杂,我们使用&&||!运算符的更复杂的组合来表达这些决策评估。如果下雨,我们可能决定去看电影,如果我们有足够的钱或朋友将付我们的路费。

如果(不下雨)并且

((我们有足够的钱)或(朋友会付我们的路费))然后

我们将去看电影

括号可以用来控制逻辑运算符的评估顺序,就像它们控制算术运算符的评估顺序一样。在下面的代码序列中,错误的存在是由rateperiod变量中存储的值决定的。这些语句是等价的,但在使用括号的方式上有所不同。在第二个语句中使用括号并不是严格需要的,但它确实使其更清晰:

errorPresent = rate == 100 || period > 50;
errorPresent = (rate == 100) || (period > 50);

在下面的语句中,使用一组括号来强制执行||运算符先于&&运算符。由于&&运算符的优先级高于||运算符,我们需要使用括号来改变评估顺序:

errorPresent = ((period>50) || (rate==100)) && (yaw>56);

括号始终优先于其他运算符。

短路评估

短路是一种在结果变得明显后不完全评估逻辑表达式的过程。在 Java 中有两个运算符可以进行短路——逻辑&&||运算符。

使用&&运算符

首先考虑逻辑&&运算符。在下面的例子中,我们试图确定sum是否大于1200amount是否小于500。为了逻辑表达式返回 true,必须满足两个条件:

if (sum > 1200 && amount <500)...

然而,如果第一个条件为 false,则没有理由评估表达式的其余部分。无论第二个条件的值如何,&&运算符都将返回 false。通过短路,第二个条件不会被评估,尤其是在操作耗时的情况下,可以节省一些处理时间。

我们可以通过使用以下两个函数来验证这种行为。它们都返回false值并在执行时显示消息:

private static boolean evaluateThis() {
    System.out.println("evaluateThis executed");
    return false;
}
private static boolean evaluateThat() {
    System.out.println("evaluateThat executed");
    return false;
}

接下来,我们在if语句中使用它们,如下所示:

if(evaluateThis() && evaluateThat()) {
    System.out.println("The result is true");
} else {
    System.out.println("The result is false");
}

当我们执行前面的代码序列时,我们得到以下输出:

evaluateThis executed
The result is false

evaluateThis方法执行并返回false。由于它返回falseevaluateThat方法没有被执行。

使用||运算符

逻辑||运算符的工作方式类似。如果第一个条件评估为true,就没有理由评估第二个条件。这在下面的代码序列中得到了证明,其中evaluateThis方法已被修改为返回true

private static boolean evaluateThis() {
    System.out.println("evaluateThis executed");
    return true;
}

    ...

if(evaluateThis() || evaluateThat()) {
    System.out.println("The result is true");
} else {
    System.out.println("The result is false");
}

执行此代码序列将产生以下输出:

evaluateThis executed
The result is true

避免短路评估

通常,短路表达式是一种有效的技术。然而,如果我们像在上一个例子中那样调用一个方法,并且程序依赖于第二个方法的执行,这可能会导致意想不到的问题。假设我们已经编写了evaluateThat方法如下:

private static boolean evaluateThat() {
   System.out.println("evaluateThat executed");
   state = 10;
   return false;
}

当逻辑表达式被短路时,state变量将不会被改变。如果程序员错误地假设evaluateThat方法总是会被执行,那么当分配给state的值不正确时,这可能导致逻辑错误。

据说evaluateThat方法具有副作用。人们可以争论是否使用具有副作用的方法是一种良好的实践。无论如何,您可能会遇到使用副作用的代码,您需要了解其行为。

避免逻辑表达式短路的一种替代方法是使用按位与(&)和按位或(|)运算符。这些按位运算符对操作数的每个位执行&&||运算。由于关键字truefalse的内部表示使用单个位,因此结果应该与相应的逻辑运算符返回的结果相同。不同之处在于不执行短路操作。

使用前面的例子,如果我们使用&运算符而不是&&运算符,如下面的代码片段所示:

if (evaluateThis() & evaluateThat()) {
   System.out.println("The result is true");
} else {
   System.out.println("The result is false");
}

当我们执行代码时,我们将得到以下输出,显示两种方法都被执行:

evaluateThis executed
evaluateThat executed
The result is false

if 语句

if语句用于根据布尔表达式控制执行流程。有两种基本形式可以使用,还有几种变体。if语句由if关键字组成,后面跟着用括号括起来的逻辑表达式,然后是一个语句。在下图中,呈现了一个简单if语句的图形描述:

if 语句

以下说明了if语句的这种形式,我们比较rate100,如果它等于100,我们显示相应的消息:

if (rate==100) System.out.println("rate is equal to 100");

然而,这不如下面的等效示例易读,我们将if语句分成两行:

if (rate == 100) 
   System.out.println("rate is equal to 100");

我们将在后面看到,最好总是在if语句中使用块语句。以下逻辑上等同于先前的if语句,但更易读和易维护:

if (rate == 100) {
    System.out.println("rate is equal to 100");
}

if语句的第二种形式使用else关键字来指定逻辑表达式评估为false时要执行的语句。以下图表形象地说明了if语句的这个版本:

if 语句

使用前面的例子,if语句如下所示:

if (rate == 100) {
   System.out.println("rate is equal to 100");
} else {
   System.out.println("rate is not equal to 100");
}

如果表达式评估为true,则执行第一个块,然后控制传递到if语句的末尾。如果表达式评估为false,则执行第二个块。在这个例子中,每个块都包含一个语句,但不一定非要这样。块内可以使用多个语句。选择使用多少语句取决于我们要做什么。

if语句的简化形式消除了else子句。假设我们想在超过某个限制时显示错误消息,否则什么也不做。这可以通过不使用else子句来实现,如下面的代码片段所示:

if (amount > limit) {
  System.out.println("Your limit has been exceeded");
}

只有在超过限制时才显示消息。请注意块语句的使用。即使它只包含一个语句,使用它仍然是一个良好的实践。如果我们决定需要做的事情不仅仅是显示错误消息,比如更改限制或重置金额,那么我们将需要一个块语句。最好做好准备:

一些开发人员不喜欢这种简化形式,他们总是使用 else 子句。

if (amount > limit) {
  System.out.println("Your limit has been exceeded");
} else {
  // Do nothing
}

Do nothing注释用于记录else子句。如果我们决定实际做一些事情,比如下订单,那么这就是我们将添加代码的地方。通过使用显式的else子句,我们至少需要考虑可能或应该在那里做什么。

您还可能遇到空语句。这个语句由一个分号组成。执行时,它什么也不做。它通常用作占位符,表示不需要做任何事情。在下面的代码片段中,修改了前一个if语句,以使用空语句:

if (amount > limit) {
   System.out.println("Your limit has been exceeded");
} else {
   ;    // Do nothing
}

这并没有给if语句增加任何东西,这里使用它也不是问题。在第五章中,循环结构,我们将研究如何粗心地使用空语句可能会导致问题。

嵌套 if 语句

在彼此内部嵌套if语句提供了另一种决策技术。如果if语句被包含在另一个if语句的thenelse子句中,则称为嵌套if语句。在下面的例子中,一个if语句在第一个if语句的then子句中找到:

if (limitIsNotExceeded) {
   System.out.println("Ready");
   if (variationIsAcceptable) {
      System.out.println(" to go!");
   } else {
      System.out.println(" – Not!");
   }
   // Additional processing
} else {
   System.out.println("Not Ok");
}

嵌套if的使用没有限制。它可以在thenelse子句中。此外,它们可以嵌套的深度也没有限制。我们可以把if放在if的内部,依此类推。

else-if 变体

在一些编程语言中,有一个elseif关键字,提供了一种实现多选择if语句的方法。从图形上看,这个语句的逻辑如下图所示:

else-if 变体

Java 没有elseif关键字,但可以使用嵌套的 if 语句来实现相同的效果。假设我们想要计算一个取决于我们要运送到国家的四个地区中的哪一个而定的运费——东部、北部中部、南部中部或西部。我们可以使用一系列if语句来实现这一点,其中每个if语句实际上都嵌套在前一个if语句的else子句中。第一个评估为 true 的if语句将执行其主体,其他if语句将被忽略:

if (zone.equals("East")) {
   shippingCost = weight * 0.23f;
} else if (zone.equals("NorthCentral")) {
   shippingCost = weight * 0.35f;
} else if (zone.equals("SouthCentral")) {
   shippingCost = weight * 0.17f;
} else {
   shippingCost = weight * 0.25f;
}

这个代码序列等同于以下内容:

if (zone.equals("East")) {
   shippingCost = weight * 0.23f;
} else 
   if (zone.equals("NorthCentral")) {
      shippingCost = weight * 0.35f;
   } else 
      if (zone.equals("SouthCentral")) {
         shippingCost = weight * 0.17f;
      } else {
         shippingCost = weight * 0.25f;
      }

第二个例子实现了与第一个相同的结果,但需要更多的缩进。在switch 语句部分,我们将演示如何使用 switch 语句实现相同的结果。

if 语句-使用问题

在处理if语句时,有几个问题需要记住。在本节中,我们将讨论以下问题:

  • 滥用等号运算符

  • 使用布尔变量而不是逻辑表达式

  • 在逻辑表达式中使用 true 或 false

  • 不使用块语句的危险

  • 悬空 else 问题

滥用等号运算符

Java 语言的一个很好的特性是,无法编写意外使用赋值运算符而意味着等号运算符的代码。这在 C 编程语言中经常发生,代码可以编译,但会导致逻辑错误,或者更糟糕的是在运行时异常终止。

例如,下面的代码片段比较rate,看它是否等于100

if(rate == 100) {
   …
}

然而,如果我们使用赋值运算符,如下面的代码片段所示,将会生成语法错误:

  if(rate = 100) {
…
}

将生成类似以下的语法错误:

incompatible types
 required: boolean
 found:    int

这种类型的错误在 Java 中被消除。使用等号运算符与浮点数的比较在比较浮点数部分中有所涉及。

提示

请注意,错误消息说它找到了一个int值。这是因为赋值运算符返回了一个剩余值。赋值运算符将修改其左边的操作数,并返回分配给该操作数的值。这个值是剩余值。它是操作的剩余部分。

理解剩余值的概念解释了错误消息。它还解释了为什么以下表达式有效:

i = j = k = 10;

表达式的效果是将10赋给每个变量。赋值的结合性是从右到左。也就是说,当表达式中有多个赋值运算符时,它们会从右到左进行评估。值10被赋给k,并且赋值运算符返回了一个剩余值10。然后将剩余值赋给j,依此类推。

使用逆操作

在使用关系运算符时,通常有多种编写表达式的方法。例如,以下代码序列确定某人是否达到法定年龄:

final int LEGAL_AGE = 21;
int age = 12;

if(age >= LEGAL_AGE) {
   // Process
} else {
   // Do not process
}

然而,这个代码序列也可以这样写:

if(age < LEGAL_AGE) {
   // Do not process
} else {
   // Process
}

哪种方法更好?在这个例子中,可以说任何一种方法都可以。然而,最好使用最符合问题的形式。

请注意,下表中显示的操作是逆操作:

操作 逆操作
< >=
> <=

注意常量LEGAL_AGE的使用。尽可能使用标识符来表示诸如法定年龄之类的值是更好的。如果我们没有这样做,并且该值在多个地方使用,那么只需在一个地方更改该值即可。此外,这样做可以避免在其某个出现中意外使用错误的数字。此外,将数字设为常量可以消除在程序运行时意外修改不应修改的值的可能性。

使用布尔变量而不是逻辑表达式

正如我们在布尔变量部分看到的,我们可以声明一个布尔变量,然后将其用作逻辑表达式的一部分。我们可以使用布尔变量来保存逻辑表达式的结果,如下面的代码片段所示:

boolean isLegalAge = age >= LEGAL_AGE;

if (isLegalAge) {
   // Process
} else {
   // Do not process
}

这有两个优点:

  • 如果需要,它允许我们稍后重复使用结果

  • 如果我们使用一个有意义的布尔变量名,代码会更易读

我们还可以使用否定运算符来改变thenelse子句的顺序,如下所示:

if (!isLegalAge) {
   // Do not process
} else {
   // Process
}

这个例子通常会比前一个更令人困惑。我们可以通过使用一个用词不当的布尔变量来使它变得更加令人困惑:

if (!isNotLegalAge) {
   // Process
} else {
   // Do not process
}

虽然这是可读的和有效的,但一个一般规则是要避免双重否定,就像我们在英语中尝试做的那样。

在逻辑表达式中使用 true 或 false

truefalse关键字可以用在逻辑表达式中。但它们并不是必需的,是多余的,并且使代码变得混乱,增加了很少的价值。请注意在以下逻辑if语句中使用true关键字:

if (isLegalAge == true) {
   // Process
} else {
   // Do not process
}

显式使用子表达式== true是不必要的。当使用false关键字时也是如此。使用布尔变量本身更清晰、更简单,就像前面的例子中使用的那样。

不使用块语句的危险

由于块语句被视为一个语句,这允许在if语句的任一部分中包含多个语句,如下面的代码片段所示:

if (isLegalAge) {
   System.out.println("Of legal age");
   System.out.println("Also of legal age");
} else {
   System.out.println("Not of legal age");
} 

thenelse子句只需要一个语句时,块语句实际上并不是必需的,但是是被鼓励的。类似但无效的if语句如下所示:

if (isLegalAge) 
   System.out.println("Of legal age");
   System.out.println("Also of legal age");
else {
   System.out.println("Not of legal age");
}

块语句用于将代码组合在一起。打印语句的缩进不会将代码分组。虽然这可能暗示前两个println语句是if语句的一部分,但实际上,if语句将导致编译时错误。

这里呈现了相同的代码,但缩进不同。if语句只有一个if子句和一个println语句。第二个println语句紧随其后,无论逻辑表达式的值如何都会执行。然后是独立的 else 子句。编译器将此视为语法错误:

if (isLegalAge) 
   System.out.println("Of legal age");
System.out.println("Also of legal age");
else {
   System.out.println("Not of legal age");
}

生成的语法错误将如下所示:

'else' without 'if'

注意

一个经验法则是,对于if语句的thenelse部分,始终使用块语句。

如果else子句中有额外的语句,可能会出现更隐匿的问题。考虑以下示例:

if (isLegalAge) 
   System.out.println("Of legal age");
else
   System.out.println("Not of legal age");
   System.out.println("Also not of legal age");

第三个println语句不是 else 子句的一部分。它的缩进是误导性的。使用正确缩进的等效代码如下:

if (isLegalAge) 
   System.out.println("Of legal age");
else 
   System.out.println("Not of legal age");
System.out.println("Also not of legal age");

很明显,第三个println语句将始终被执行。正确的写法如下:

if (isLegalAge) {
   System.out.println("Of legal age");
} else {
   System.out.println("Not of legal age");
   System.out.println("Also not of legal age");
}

悬挂 else 问题

不使用块语句的另一个问题是悬挂 else 问题。考虑以下一系列测试,我们需要做出一些决定:

  • 如果limit大于100stateCode等于45,我们需要将limit增加10

  • 如果limit不大于100,我们需要将limit减少10

以下是实现这个逻辑的方式:

if (limit > 100) 
   if (stateCode == 45) 
      limit = limit+10;
else
   limit = limit-10;

然而,这个例子没有正确实现决策。这个例子至少有两个问题。首先,else关键字的缩进与语句的评估无关,且具有误导性。else关键字总是与最近的if关键字配对,这在这种情况下是第二个if关键字。编译器不关心我们如何缩进我们的代码。这意味着代码等同于以下内容:

if (limit > 100) 
   if (stateCode == 45) 
      limit = limit+10;
   else
      limit = limit-10;

在这里,只有在限制超过100时才测试stateCode,然后limit要么增加要么减少10

请记住,编译器会忽略任何语句中的空白(空格、制表符、换行等)。代码序列可以不带空白地编写,但这样会使其更难阅读:

if (limit > 100) if (stateCode == 45) limit = limit+10; else limit = limit-10;

这个例子的第二个问题是没有使用块语句。块语句不仅提供了一种分组语句的方式,还提供了一种更清晰地传达应用程序逻辑的方式。问题可以通过以下代码解决:

if (limit > 100) {
   if (stateCode == 45) {
      limit = limit+10;
   }
} else {
   limit = limit-10;
}

这样更清晰,实现了预期的效果。这样可以更容易地调试程序,代码更易读,更易维护。

条件运算符

条件运算符是if语句的一种简化、有限形式。它是简化的,因为决策仅限于单个表达式。它是有限的,因为thenelse子句中不能包含多个语句。有时它被称为三元运算符,因为它有三个组成部分。

运算符的基本形式如下:

LogicalExpression ? ThenExpression : ElseExpression

如果LogicalExpression评估为 true,则返回ThenExpression的结果。否则返回ElseExpression的结果。

以下是一个简单的例子,用于测试一个数字是否小于 10。如果是,返回 1,否则返回 2。示例中的thenelse表达式是琐碎的整数文字。

result = (num < 10) ? 1 : 2;

这等同于以下的if语句:

if (num < 10) {
   result = 1;
} else {
   result = 2;
}

考虑计算加班的过程。如果员工工作时间不超过 40 小时,工资按照工作时间乘以他的工资率计算。如果工作时间超过 40 小时,那么员工将为超过 40 小时的时间支付加班费。

float hoursWorked;
float payRate;
float pay;

if (hoursWorked <= 40) {
   pay = hoursWorked * payRate;
} else {
   pay = 40 * payRate + (hoursWorked - 40) * payRate;
}

这个操作可以使用条件运算符来执行,如下所示:

payRate = (hoursWorked <= 40) ? 
   hoursWorked * payRate : 
   40 * payRate + (hoursWorked - 40) * payRate;

虽然这种解决方案更加紧凑,但可读性不如前者。此外,thenelse子句需要是返回某个值的表达式。这个值不一定是一个数字,但除非调用包含这些语句的方法,否则不能包含多个语句。

注意

除了在琐碎的情况下,不鼓励使用条件运算符,主要是因为它的可读性问题。通常更重要的是拥有可读性强、可维护的代码,而不是节省几行代码。

switch 语句

switch语句的目的是提供一种方便和简单的方法,根据整数、枚举或String表达式进行多分支选择。switch语句的基本形式如下:

switch ( expression ) {
  //case clauses
}

通常在语句块中有多个case子句。case子句的基本形式使用case关键字后跟一个冒号,零个或多个语句,通常是一个break语句。break语句由一个关键字break组成,如下所示:

case <constant-expression>:
  //statements
break;

还可以使用一个可选的 default 子句。这将捕获任何未被case子句捕获的值。如下所示:

default:
  //statements
break;  // Optional

switch语句的基本形式如下所示:

switch (expression) {
  case value: statements
  case value: statements
  …
  default: statements
}

switch语句中的两个情况不能具有相同的值。break关键字用于有效地结束代码序列并退出switch语句。

当表达式被评估时,控制将传递给与相应常量表达式匹配的 case 表达式。如果没有任何 case 与表达式的值匹配,则控制将传递给default子句(如果存在)。如果没有default前缀,则switch的任何语句都不会被执行。

我们将说明switch语句用于整数、枚举和String表达式。在 Java 7 中,switch语句中使用字符串是新的。

基于整数的 switch 语句

if语句可以用于在多个整数值之间进行选择。考虑以下示例。一系列if语句可以用于根据整数zone值计算运费,如下所示:

private static float computeShippingCost(
         int zone, float weight) {
   float shippingCost;

   if (zone == 5) {
      shippingCost = weight * 0.23f;
   } else if (zone == 6) {
      shippingCost = weight * 0.23f;
   } else if (zone == 15) {
      shippingCost = weight * 0.35f;
   } else if (zone == 18) {
      shippingCost = weight * 0.17f;
   } else {
      shippingCost = weight * 0.25f;
   }

   return shippingCost;
}

switch语句可以用于相同的目的,如下所示:

switch (zone) {
   case 5:
      shippingCost = weight * 0.23f;
      break;
   case 6:
      shippingCost = weight * 0.23f;
      break;
   case 15:
      shippingCost = weight * 0.35f;
      break;
   case 18:
      shippingCost = weight * 0.17f;
      break;
   default:
      shippingCost = weight * 0.25f;
}

注意

不要忘记整数数据类型包括bytecharshortint。这些数据类型中的任何一个都可以与整数switch语句一起使用。不允许使用数据类型long

case 和 default 前缀的顺序并不重要。唯一的限制是常量表达式必须是唯一的。如果break语句不是最后一个 case 子句,那么它可能需要一个break语句,否则控制将传递给它后面的case子句:

switch (zone) {
   case 15:
      shippingCost = weight * 0.35f;
      break;
   default:
      shippingCost = weight * 0.25f;
      break; // Only needed if default is not
             // the last case clause
   case 5:
      shippingCost = weight * 0.23f;
      break;
   case 18:
      shippingCost = weight * 0.17f;
      break;
   case 6:
      shippingCost = weight * 0.23f;
      break;
}

注意

出于可读性的目的,通常会保持自然顺序,这通常是顺序的。使用这个顺序可以更容易地找到case子句,并确保不会意外地遗漏情况。

case 和 default 前缀不会改变控制流。控制将从一个 case 流向下一个后续 case,除非使用了 break 语句。由于区域 5 和 6 使用相同的公式来计算运费,我们可以在不使用 break 语句的情况下使用连续的 case 语句:

switch (zone) {
   case 5:
   case 6:
      shippingCost = weight * 0.23f;
      break;
   case 15:
      shippingCost = weight * 0.35f;
      break;
   case 18:
      shippingCost = weight * 0.17f;
      break;
   default:
      shippingCost = weight * 0.25f;
}

需要使用break语句来确保只有与case相关的语句被执行。在default子句的末尾不一定需要break,因为控制通常会流出switch语句。然而,出于完整性的目的,通常会包括它,如果default子句不是switch语句中的最后一个情况,则是必需的。

基于枚举的 switch 语句

枚举也可以与switch语句一起使用。这可以使代码更易读和易维护。以下内容是从第二章中复制的,Java 数据类型及其用法。变量direction用于控制switch语句的行为,如下所示:

private static enum Directions {
    NORTH, SOUTH, EAST, WEST
};

Directions direction = Directions.NORTH;

switch (direction) {
    case NORTH:
        System.out.println("Going North");
        break;
    case SOUTH:
        System.out.println("Going South");
        break;
    case EAST:
        System.out.println("Going East");
        break;
    case WEST:
        System.out.println("Going West");
        break;
}

当执行此操作时,我们得到以下输出:

Going North

基于字符串的 switch 语句

为了说明在switch语句中使用字符串,我们将演示根据区域计算运费的实现,如else-if variation部分所示。该实现如下所示,供您参考:

if (zone.equals("East")) {
   shippingCost = weight * 0.23f;
} else if (zone.equals("NorthCentral")) {
   shippingCost = weight * 0.35f;
} else if (zone.equals("SouthCentral")) {
   shippingCost = weight * 0.17f;
} else {
   shippingCost = weight * 0.25f;
}

在 Java 7 之前,只有整数变量可以与switch语句一起使用。通过允许使用字符串,程序可以包含更易读的代码。

以下代码片段说明了如何在case语句中使用String变量。该示例提供了前一个嵌套if语句的替代实现:

switch (zone) {
   case "East":
      shippingCost = weight * 0.23f;
      break;
   case "NorthCentral":
      shippingCost = weight * 0.35f;
      break;
   case "SouthCentral":
      shippingCost = weight * 0.17f;
      break;
   default:
      shippingCost = weight * 0.25f;
}

使用 switch 语句的字符串问题

在使用带有 switch 语句的字符串时,还应考虑另外两个问题:

  • 遇到空值时

  • 字符串的大小写敏感性

当在 switch 语句中使用了一个已分配了空值的字符串变量时,将抛出java.lang.NullPointerException异常。当针对已分配了空值的引用变量执行方法时,这将发生。在 Java 7 中,对于java.util.Objects类中发现的空值,还有额外的支持。

关于字符串和 switch 语句的第二件事是,在switch语句中进行的比较是区分大小写的。在前面的示例中,如果使用了字符串值east,则East情况将不匹配,并且将执行defa ult情况。

控制结构问题

到目前为止,我们已经确定了几种在 Java 中可用的决策结构。例如,简单的决策可以使用if语句轻松处理。要么-要么类型的决策可以使用else if子句或switch语句来处理。

正确使用控制结构在开发良好的代码中至关重要。然而,做决定不仅仅是在不同的控制结构之间进行选择。我们还需要测试我们的假设并处理意外情况。

在本节中,我们将首先解决在使用决策结构时应牢记的一些一般问题。然后,我们将检查各种浮点问题,这可能对不熟悉浮点数限制的人造成麻烦。接下来,我们将简要介绍比较对象的主题,并总结三种基本编码活动的概述,这可能有助于理解编程的本质。

一般决策结构问题

在使用决策结构时有几个重要问题:

  • 决策语句的结构

  • 测试你的假设

  • 为失败做计划

决策过程的整体结构可以是良好结构化的,也可以是一系列难以遵循的临时语句。对这种结构的良好组织方法可以提高决策过程的可读性和可维护性。

一个程序可能结构良好,但可能无法按预期工作。这往往是由于无效的假设。例如,如果假定年龄的值是非负的,那么使用的代码可能形式良好,从逻辑上讲可能是无可挑剔的。然而,如果假设使用的年龄值不正确,那么结果可能不如预期。例如,如果一个人的年龄输入为负数,那么逻辑可能会失败。始终测试你的假设,或者至少确保基础数据已经通过了某种质量控制检查。始终预料意外。帮助这一过程的技术包括:

  • 始终保留else子句

  • 测试你的假设

  • 抛出异常(在第八章中介绍,在应用程序中处理异常

  • 始终使用块语句

当一切都失败时,请使用调试技术。

浮点数考虑

浮点数在内部使用 IEEE 754 浮点算术标准(ieeexplore.ieee.org/xpl/mostRecentIssue.jsp?punumber=4610933)来表示。这些操作通常在软件中执行,因为并非所有平台都提供对该标准的硬件支持。在软件中执行这些操作将比直接在硬件中执行慢。在软件中执行这些操作的优势在于它支持应用程序的可移植性。

Java 支持两种浮点类型,floatdouble,它们的精度如下表所示。此外,IntegerFloat类是这两种数据类型的包装类。包装类用于封装值,比如整数或浮点数:

数据类型 大小(字节) 精度
float 4 二进制数字
double 8 二进制数字

处理浮点数可能比处理其他数据类型更复杂。需要考虑浮点数的几个方面。这些包括:

  • 特殊的浮点值

  • 比较浮点数

  • 舍入误差

特殊的浮点值

总结在下表中有几个特殊的浮点值。它们存在是为了当发生错误条件时,有一个可以用来识别错误的表示。

这些值存在是为了当发生算术溢出、对负数取平方根和除以 0 等错误条件时,可以得到一个可以在浮点值内表示的结果,而不会抛出异常或以其他方式终止应用程序:

含义 可能由以下产生
非数字 NaN:表示生成未定义值的操作的结果 除以零取平方根
负无穷 一个非常小的值 一个负数除以零
正无穷 一个非常大的值 一个正数除以零
负零 负零 一个负数非常接近零,但不能正常表示

如果需要,NaN 可以通过Float.NaNDouble.NaN在代码中表示。对 NaN 值进行算术运算将得到 NaN 的结果。将 NaN 转换为整数将返回0,这可能导致应用程序错误。NaN 的使用在以下代码序列中进行了说明:

float num1 = 0.0f;

System.out.println(num1 / 0.0f);
System.out.println(Math.sqrt(-4));
System.out.println(Double.NaN + Double.NaN);
System.out.println(Float.NaN + 2);
System.out.println((int) Double.NaN);

当执行时,我们得到以下输出:

NaN
NaN
NaN
NaN
0

在 Java 中,无穷大使用以下字段表示。正如它们的名称所暗示的,我们可以表示负无穷或正无穷。负无穷意味着一个非常小的数,正无穷代表一个非常大的数:

  • Float.NEGATIVE_INFINITY

  • Double.NEGATIVE_INFINITY

  • Float.POSITIVE_INFINITY

  • Double.POSITIVE_INFINITY

一般来说,涉及无限值的算术运算将得到一个无限值。涉及 NaN 的运算将得到 NaN 的结果。除以零将得到正无穷。以下代码片段说明了其中一些操作:

System.out.println(Float.NEGATIVE_INFINITY);
System.out.println(Double.NEGATIVE_INFINITY);
System.out.println(Float.POSITIVE_INFINITY);
System.out.println(Double.POSITIVE_INFINITY);
System.out.println(Float.POSITIVE_INFINITY+2);
System.out.println(1.0 / 0.0);
System.out.println((1.0 / 0.0) - (1.0 / 0.0));
System.out.println(23.0f / 0.0f);
System.out.println((int)(1.0 / 0.0)); 
System.out.println(Float.NEGATIVE_INFINITY == Double.NEGATIVE_INFINITY);

这个序列的输出如下:

-Infinity
-Infinity
Infinity
Infinity
Infinity
Infinity
NaN
Infinity
2147483647
True

通过将负数除以正无穷或将正数除以负无穷,可以生成负零,如下面的代码片段所示。这两个语句的输出将是负零:

System.out.println(-1.0f / Float.POSITIVE_INFINITY);
System.out.println(1.0f / Float.NEGATIVE_INFINITY);

0-0是不同的值。然而,当它们相互比较时,它们将被确定为相等:

System.out.println(0 == -0);

这将生成以下输出:

True

比较浮点数

浮点数,在计算机中的表示,实际上并不是真实的数字。也就是说,在数字系统中有无限多个浮点数。然而,只有 32 位或 64 位用于表示一个浮点数。这意味着只能精确表示有限数量的浮点数。例如,分数 1/3 在十进制中无法精确表示。如果我们尝试,会得到类似 0.333333 的结果。同样,在二进制中,有一些浮点数无法精确表示,比如分数 1/10。

这意味着比较浮点数可能会很困难。考虑以下例子,我们将两个数字相除,并将结果与期望的商 6 进行比较:

double num2 = 1.2f;
double num3 = 0.2f;
System.out.println((num2 / num3) == 6);

执行时的结果给出了一个意外的值,如下所示:

false

这是因为这些数字使用double类型并不是精确表示。为了解决这个问题,我们可以检查操作的结果,并查看我们期望的结果与实际结果之间的差异。在以下序列中,定义了一个差异epsilon,它是可接受的最大差异:

float epsilon = 0.000001f;
if (Math.abs((num2 / num3) - 6) < epsilon) {
   System.out.println("They are effectively equal");
} else {
   System.out.println("They are not equal");
}

当执行此代码时,我们得到以下输出:

They are effectively equal

此外,当使用compareTo方法比较FloatDouble对象时,记住这些对象按从低到高的顺序排列:

  • 负无穷

  • 负数

  • -0.0

  • 0.0

  • 正数

  • 正无穷

  • NaN

例如,以下代码将返回-1,表示负数小于-0.0。输出将是true

System.out.println((new Float(-2.0)).compareTo(-0.0f));

舍入误差

在某些情况下,注意舍入误差是很重要的。考虑以下代码序列:

for(int i = 0; i < 10; i++) {
   sum += 0.1f;
}
System.out.println(sum);

当执行此代码时,我们得到以下输出:

1.0000001

这是由于舍入误差导致的,其根源来自于对分数 1/10 的不准确表示。

提示

对于精确值,使用浮点数并不是一个好主意。这适用于美元和美分。相反,使用BigDecimal,因为它提供更好的精度,并且设计用于支持这种类型的操作。

strictfp 关键字

strictfp关键字可以应用于类、接口或方法。在 Java 2 之前,所有浮点计算都是按照 IEEE 754 规范执行的。在 Java 2 之后,中间计算不再受标准限制,允许使用一些处理器上可用的额外位来提高精度。这可能导致应用程序在舍入方面存在差异,从而导致可移植性较差。通过使用strictfp关键字,所有计算都将严格遵守 IEEE 标准。

比较对象

在比较对象时,我们需要考虑:

  • 比较对象引用

  • 使用equals方法比较对象

在比较引用时,我们确定两个引用变量是否指向相同的对象。如果我们想确定指向两个不同对象的两个引用变量是否相同,我们使用equals方法。

这两种比较如下图所示。三个引用变量r1r2r3用于引用两个对象。变量r1r2引用对象 1,而r3引用对象 2:

比较对象

在这个例子中,以下条件为真:

  • r1 == r2

  • r1 != r3

  • r2 != r3

  • r1.equals(r2)

然而,根据对象的equals方法的实现和对象本身,对象 1 可能与对象 2 等价,也可能不等价。字符串的比较在第二章的字符串比较部分中有更详细的介绍,Java 数据类型及其使用。覆盖equals方法在第六章中有讨论,类、构造函数和方法

三种基本编码活动

在编写代码时,很难确定如何最好地组织代码。为了保持透视,记住这三个一般的编码活动:

  • 你想做什么

  • 如何做

  • 何时做

如果应用程序的要求是计算小时工的工资,那么:

  • “什么”是计算工资

  • “如何”决定如何编写代码来使用工作小时和工资率计算工资

  • “when”涉及放置代码的位置,即在确定工作小时和工资率之后

虽然这可能看起来很简单,但许多初学者程序员会在编程的“when”方面遇到问题。这对于今天的基于图形用户界面GUI)的事件驱动程序尤其如此。

goto 语句

goto语句在旧的编程语言中可用,并提供了程序内部转移控制的强大但不受约束的方式。它的使用经常导致程序组织混乱,并且是不被鼓励的。在 Java 中,goto关键字的使用受到限制。它根本不能被使用。它已经被彻底从 Java 编程中驱逐出去。

然而,与goto语句功能类似的语句仍然存在于许多语言中。例如,break语句会导致控制立即转移到 switch 语句的末尾,并且我们稍后会看到,退出循环。标签也可以与 break 语句一起使用,正如我们将在第五章的使用标签部分中看到的,循环结构。这种转移是即时和无条件的。它实际上是一个goto语句。然而,break语句,以类似的方式,return 语句和异常处理,被认为更加结构化和安全。控制不会转移到程序内的任意位置。它只会转移到switch语句末尾附近的特定位置。

总结

决策是编程的重要方面。大多数程序的实用性基于其做出某些决定的能力。决策过程基于控制结构的使用,如逻辑表达式、if语句和 switch 语句。

有不同类型的决策需要做出,并且在 Java 中使用不同的控制结构进行支持。本章讨论的主要内容包括if语句和switch语句。

在使用这些语句时必须小心,以避免可能出现的问题。这些问题包括误用比较运算符,不习惯使用块语句,以及避免悬空 else 问题。我们还研究了在使用浮点数时可能出现的一些问题。

在 Java 中做决策可以是简单的或复杂的。简单和复杂的二选一决策最好使用if then else语句来处理。对于一些更简单的决策,可以使用简单的if语句或条件语句。

多选决策可以使用if语句或switch语句来实现,具体取决于决策的性质。更复杂的决策可以通过嵌套的if语句和switch语句来处理。

现在我们已经了解了决策结构,我们准备研究如何使用数组和集合,这是下一章的主题。

涵盖的认证目标

关于认证目标,我们将研究:

  • 使用运算符和决策结构

  • 使用 Java 关系和逻辑运算符

  • 使用括号来覆盖运算符优先级

  • 创建 if 和 if/else 结构

  • 使用switch语句

测试你的知识

  1. 以下操作的结果是什么?
System.out.println(4 % 3);

a. 0

b. 1

c. 2

d. 3

  1. 以下哪个表达式将计算为 7?

a. 2 + 4 * 3- 7

b. (2 + 4) * (3 - 7)

c. 2 + (4 * 3) - 7

d. ((2 + 4) * 3) - 7)

  1. 以下语句的输出是什么?
System.out.println( 16  >>>  3);

a. 1

b. 2

c. 4

d. 8

  1. 给定以下声明,哪些 if 语句将在没有错误的情况下编译?
int i = 3;
int j = 3;
int k = 3;

a. if(i > j) {}

b. if(i > j > k) {}

c. if(i > j && i > k) {}

d. if(i > j && > k) {}

  1. 当执行以下代码时,将会打印出什么?
switch (5) {
case 0:
   System.out.println("zero");
   break;
case 1:
   System.out.println("one");
default:
   System.out.println("default");
case 2:
   System.out.println("two");
}

a. 一个

b. 默认和两个

c. 一个,两个和默认

d. 什么也不会输出,会生成一个编译时错误

第四章:使用数组和集合

这一章,归根结底,是关于数据结构。具体来说,它是关于数组——java.util.Arraysjava.util.ArrayList类。数组是可以使用单个变量名寻址的内存区域。它提供了一种有效的访问数据的技术,可以按顺序或随机方式访问数据。Arrays类提供了对数组的支持,而ArrayList类提供了类似数组的行为,但大小不固定。

我们关心如何创建和使用这些数据结构。一个常见的操作是遍历数组或集合。我们将看到 Java 支持几种方法,允许我们遍历数组或ArrayList对象的元素。数组和集合都可以使用 for-each 语句。迭代器提供了访问集合的另一种方法,比如ArrayList,我们也会讨论到。

我们将从详细讨论数组开始。这将包括创建和使用单维和多维数组。将演示常见的数组操作,比如复制和排序。

由于数组是一种简单的数据结构,大多数语言对它们的操作提供的支持不多。java.util.Arrays类填补了这一空白,并支持对数组的重要操作。这些操作包括复制、填充和排序数组。

java.util包含许多接口和类,可以使处理数据集合变得更加容易。在本章中,我们将研究迭代器和ArrayList类的使用,它们是这个包的一部分。迭代器提供了一种遍历集合的技术,这可能非常有用。ArrayList类经常用于代替数组,当集合的大小可能会改变时。它提供了许多有价值的方法来修改集合。我们还将研究如何将集合(比如ArrayList)封装在另一个类中。

数组

数组允许使用单个变量名访问多个值。数组的每个元素都是相同类型的。元素类型可以是简单的原始数据类型或对象的引用。

一维数组分配到内存的连续区域。这意味着可以有效地访问数组的元素,因为它们是相邻的。数组使用整数索引来访问数组中的元素。索引范围从 0 到数组长度减一。我们能够直接访问数组的元素,按照应用程序需要的任何顺序,而无需访问每个元素。

尽管 Java 支持多维数组,但一维数组最常用。数组可用于各种目的,包括:

  • 代表年龄的数字集合

  • 员工姓名列表

  • 商店商品价格列表

数组的主要缺点是它们具有固定的大小。这使得向数组表示的任何列表或集合添加、删除或调整大小更加困难和低效。

我们的讨论将从一维和多维数组的覆盖开始。接下来是关于常见数组技术的讨论,比如遍历数组和复制数组。在前两个部分中,我们将使用简单的“for 循环”来遍历数组元素。替代方法在遍历数组部分中介绍。

一维数组

一维数组旨在表示一个简单的线性列表。以下代码片段说明了一维数组的声明和使用。数组ages在第一条语句中声明为int类型的数组。在第二条语句中,使用new运算符为数组分配内存。在这个例子中,数组由5个元素组成:

int[] ages;
ages = new int[5];

ages数组有5个元素分配给它。任何数组的第一个索引都是 0。数组的最大索引是其长度-1。因此,数组的最后一个索引是 4。如果使用的索引超出了数组的合法值范围,将生成运行时异常。数组可以使用单个语句声明和创建,如下所示:

int[] ages = new int[5];

由于数组名称是对数组的引用,因此可以在程序的后期将不同的数组分配给变量。我们将在讨论中稍后演示这一点。

数组是从称为的内存区域分配的对象。堆和程序堆栈在第二章的堆栈和堆部分中介绍,Java 数据类型及其使用。在以下示例中,ages的第一个元素被赋予值35,然后显示:

ages[0] = 35;
System.out.println(ages[0]);

数组具有length属性,返回数组中的元素数。当执行下一个代码序列时,它将返回5。请注意,length不是一个方法:

int length = ages.length;
System.out.println(length);

在 Java 中,数组表示为对象。在上一个示例中,ages是一个对象引用变量,引用了分配给堆的数组。这在下图中有所说明:

一维数组

在此示例中,数组的每个元素都默认初始化为 0,然后将第一个元素赋值为 35。

注意

任何尝试使用超出数组范围的索引都将生成java.lang.ArrayIndexOutOfBoundsException异常。

数组括号的放置

在声明数组时,括号的放置还有第二种选择。我们还可以在数组名称后放置括号,如下所示:

int ages[];

对于编译器来说,这等同于先前的声明。但是,括号与数组名称的其他用法的放置是受限制的。例如,当我们声明或引用数组的元素时,必须在数组名称后放置括号。如果我们在声明数组时尝试以下操作:

ages = new [5]int;

我们将得到以下语法错误:

<identifier> expected
';' expected

同样,如果我们尝试在引用数组的元素时在数组名称之前使用括号,如下所示:

[0]ages = 0;

我们得到以下语法错误消息:

illegal start of expression
incompatible types
 required: int[]
 found:    int

更常见的是在数组的数据类型后使用括号。例如,大多数 IDE 在某个时候都会生成一个main方法。它经常出现在数据类型后面跟着括号的下面:

public static void main(String[] args) {
   ...
}

另外,考虑以下声明:

int[] arr1, arr2;

arr1arr2都声明为数组。这是在单行上声明多个数组的简单方法。然而,可以说以下格式更好,因为它更明确:

int arr1[], arr2[];

也可以说,在同一行声明多个变量是一种不好的形式。声明这两个数组的最佳方法如下:

int[] arr1;
int[] arr2;

初始化数组

数组的元素被初始化为默认值,如下表所示。此表是从第二章的初始化标识符部分中复制的,Java 数据类型及其使用,以方便您查看:

数据类型 默认值(对于字段)
boolean false
byte 0
char '\u0000'
short 0
int 0
long 0L
float 0.0f
double 0.0d
String(或任何对象) null

在上一个示例中,我们将值 35 赋给了数组的第一个元素。这是一种简单但繁琐的初始化数组的方法,以便将数组初始化为除默认值之外的值。

另一种技术是使用块语句初始化数组。在下面的示例中,ages初始化为五个不同的值。在使用块语句初始化数组时,不需要指定数组大小:

int ages[] = {35, 10, 43, -5, 12};

如果尝试指定数组的大小,将生成语法错误,如下所示:

int ages[5] = {35, 10, 43, -5, 12};

消息将显示如下:

']' expected
';' expected

如果要显示数组的内容,有几种方法可用。在这里,我们将使用简单的索引和length属性。在遍历数组部分,我们将演示其他技术。

以下代码序列显示了使用toString方法和 for 循环显示数组之间的区别:

int ages[] = {35, 10, 43, -5, 12};
System.out.println(ages.toString());

for(int i = 0; i < ages.length; i++) {
   System.out.println(ages[i]);
}

执行时,我们得到以下输出:

[I@27341e11
35
10
43
-5
12

请注意,toString方法的使用并不返回数组的内容。相反,它返回数组的奇怪表示。我们无法控制toString方法返回的字符串。然而,for 循环给了我们预期的结果。

注意

请记住,Java 中的数组始终从索引 0 开始。

与在之前的示例中硬编码数组的大小为5不同,更好的方法是使用一个常量。例如,整个序列可以重写如下:

static final int SIZE = 5;

int ages[] = new int[SIZE];
// initialize ages as needed

for(int i = 0; i < ages.length; i++) {
   System.out.println(ages[i]);
}

注意

使用命名常量来表示数组大小。但是,一旦数组声明后,使用length属性更可取,因为如果数组大小发生变化,它更易于维护。

对象数组

保持对象引用变量和对象本身之间的清晰区分是很重要的。对象数组使用引用变量,例如下面声明的names变量,它是包含对数组对象引用的单个内存位置。数组的每个元素是另一个引用,可能引用一个字符串。最初,它们被分配了null值。

public static void main(String args[]) {
   String names[] = new String[5];
   ...
}

这个示例的内存分配如下图所示。但是,我们没有在图中包含数组的索引。我们可以假设顶部元素的索引为 0,最后一个元素的索引为 4:

对象数组

当将字符串分配给数组的一个元素时,该数组元素将被修改为引用该字符串,如下所示:

names[2] = "Steve";

下图说明了将索引 2 处的引用修改为引用字符串:

对象数组

在使用可能包含空值的数组时要小心。考虑以下代码序列,其中我们显示names数组的内容:

for(int i = 0; i < names.length; i++) {
   System.out.println(names[i]);
}

执行时,我们将得到以下输出:

null
null
Steve
null
null

多维数组

许多应用程序需要使用具有两个或更多维度的数组。具有行和列的表格数据或使用 x/y 坐标系的数据是使用二维数组表示的良好候选。三个或更多的高维度不太常见,但使用 x、y 和 z 值的坐标系将使用三个维度。在本节中,我们将使用整数来演示多维数组。然而,这些技术同样适用于对象数组。

二维数组的声明示例如下所示:

static final int ROWS = 2;
static final int COLS = 3;

int grades[][] = new int[ROWS][COLS];

这将创建一个具有 2 行 3 列的数组,逻辑上如下图所示:

多维数组

请注意,索引从零开始。我们可以使用一系列赋值语句初始化每个元素,如下所示:

grades[0][0] = 0;
grades[0][1] = 1;
grades[0][2] = 2;
grades[1][0] = 3;
grades[1][1] = 4;
grades[1][2] = 5;

这有点繁琐,但它说明了将数字放入数组的位置,如下图所示:

多维数组

嵌套循环对于处理二维数组非常有用。例如,要显示这些数组的内容,我们将使用一组嵌套的 for 循环,如下所示:

for (int rows = 0; rows < ROWS; rows++) {
   for (int cols = 0; cols < COLS; cols++) {
      System.out.printf("%d  ", grades[rows][cols]);
   }
   System.out.println();
}

执行时,我们得到以下输出:

0  1  2 
3  4  5 

实际上,Java 不严格支持二维数组。实际上它们是数组的数组。在诸如 C 之类的语言中,二维数组按行列顺序存储。这意味着二维数组被映射到一个一维空间,其中第一行存储在内存中,然后是第二行,然后是第三行,依此类推。这在 Java 中不适用。

实际上,我们拥有的是一个一维数组,其中包含对一系列其他一维数组的引用。例如,我们可以创建相同的grades数组:

grades = new int[ROWS][];
grades[0] = new int[COLS];
grades[1] = new int[COLS];

数组在内存中分配,如下图所示:

多维数组

在二维数组中,行不一定要具有相同的大小。在以下代码序列中,我们创建了一个具有不同行长度的数组。这种类型的数组称为不规则数组

grades[0] = new int[4];
grades[1] = new int[2];

内存分配与前面的示例类似,只是数组长度不同,如下图所示:

多维数组

数组技术

有许多处理数组的技术。在本节中,我们将研究其中许多技术,包括:

  • 遍历数组

  • 比较数组

  • 复制数组

  • 传递数组

  • 使用命令行参数

我们将根据需要演示每种技术的变化。将可变数量的参数传递给方法在第六章类、构造函数和方法中有介绍。

遍历数组

遍历数组是访问数组的每个元素的过程。通常从第一个元素开始,逐个元素移动,直到到达数组的末尾。但是,也可以向后移动或跳过元素。在这里,我们将重点介绍如何使用两种不同的技术从头到尾遍历数组:

  • 使用简单的 for 循环

  • 使用 for-each 语句

我们将使用如下声明的ages数组来说明如何遍历数组:

static final int SIZE = 5;
int[] ages = new int[SIZE];

在每个示例中,我们将使用此代码将数组的每个元素初始化为 5。

使用简单循环

任何简单的循环都可以用来遍历数组。循环结构将在第五章循环结构中更详细地介绍。在这里,我们将使用 for 循环和 while 循环。首先,让我们来看看 for 循环。在以下序列中,一个整数变量从 0 开始,逐步增加到数组长度减一:

for(int i = 0; i < ages.length; i++) {
      ages[i] = 5;
}

等效的 while 循环如下。请注意,i变量在循环外声明:

int i = 0;
while(i < ages.length) {
   ages[i++] = 5;
}      

通常情况下,for 循环是首选,因为我们知道数组的长度,并且对于这些类型的问题来说更简单。在这两个示例中,我们使用数组的length属性来控制循环。这比使用可能用于声明数组的常量变量更可取。考虑以下情况,我们重新定义数组:

int[] ages = new int[SIZE];
...
for(int i = 0; i < SIZE; i++) {
   ages[i] = 5;
}

// Array redefined
int[] ages = new int[DIFFERENT_SIZE];
...
for(int i = 0; i < SIZE; i++) {
   ages[i] = 5;
}

第二个 for 循环将无法正确执行,因为我们忘记更改SIZE常量,如果数组小于SIZE甚至可能抛出异常。如果我们使用length属性,就不会有问题。

请注意,如写的 for 循环在循环内声明了变量i。这限制了对该变量的访问仅限于 for 循环内的语句。在 while 循环示例中,我们在循环外声明了i,使其在 while 循环内外都可以访问。我们可以重写 for 循环以使用外部的i变量。但是,最好的做法是将对变量的访问限制在只有需要访问的语句中。因此,如果只在循环内部需要它,那么 for 循环提供了更好的选择。

注意

使用简单的 for 语句可能会导致偏移一个错误(从错误的开始或结束索引开始)。例如,如果用作最后一个索引的值大于数组大小减一,则会抛出ArrayIndexOutOfBoundsException异常。

使用 for-each 语句

如果我们不需要显式访问每个元素的索引值,for-each 语句提供了一种更方便的遍历数组的方法。for-each 括号的主体由数据类型、变量名、冒号,然后是一个数组(或集合)组成。该语句将从第一个元素开始迭代数组,并以最后一个元素结束。在每次迭代期间,变量引用该数组元素。以下是使用该语句与ages数组的示例。在第一次迭代中,number引用ages[0]。在第二次迭代中,number引用ages[1]。对数组的每个元素都是如此:

for(int number : ages) {
   number = 5;
}

for-each 语句使得遍历数组变得容易。但是,如果我们需要使用数组元素的索引,该语句不提供对其值的访问。需要使用传统的 for 循环来访问索引。

以下表格总结了使用 for 循环和 for-each 循环的差异:

for 循环 for-each 循环
提供对数组元素的访问 使用 for-each 语句 使用 for-each 语句
提供对数组索引的访问 使用 for-each 语句 使用 for-each 语句
使用逻辑表达式来控制循环 使用 for-each 语句 使用 for-each 语句
最简单的 使用 for-each 语句 使用 for-each 语句

比较数组

由于数组变量是引用变量,因此比较数组引用变量以确定相等性的方法并不总是有效。在这里,我们将研究几种比较数组的技术,包括:

  • 逐个元素比较

  • 使用等号运算符

  • 使用equals方法

  • 使用deepEquals方法

我们将通过比较两个整数数组来演示这些技术。考虑以下示例,其中两个数组arr1arr2在我们将它们初始化为包含相同数据后是等价的:

public static void main(String[] args) {
   int arr1[];
   int arr2[];
   arr1 = new int[5];
   arr2 = new int[5];

   for(int i = 0; i < 5; i++) {
      arr1[i] = 0;
      arr2[i] = 0;
    }
  }

以下图表显示了如何为两个数组分配内存:

比较数组

逐个元素比较

这种简单的方法将比较每个数组的对应元素,以确定数组是否相等。它首先假定它们是相等的,并将areEqual变量赋值为true。如果任何比较为 false,则变量将被赋值为false

boolean areEqual = true;
for (i = 0; i < 5; i++) {
   if(arr1[i]!= arr2[i]) {
      areEqual = false;
   }
}
System.out.println(areEqual);

当执行此序列时,它将显示true。这不是最佳方法。使用索引是一种容易出错和繁琐的方法。

使用等号运算符

如果我们尝试使用等号运算符比较两个数组,我们会发现比较的结果将是false

System.out.println(arr1 == arr2);  //Displays false

这是因为我们正在比较数组引用变量arr1arr2而不是数组。变量arr1arr2在内存中引用不同的对象。这两个引用变量的内容是不同的,因此当它们相互比较时它们是不相等的。它们不引用相同的对象。

使用 equals 方法

我们可以像对其他对象一样对数组使用equals方法。在以下示例中,即使它们是等价的,输出也将是 false。这是因为equals方法作用于数组时,测试的是对象的等价性而不是对象值的等价性。

System.out.println(arr1.equals(arr2));  // Displays false

对象等价性是指比较两个对象引用变量。如果这些变量引用相同的对象,则它们被视为等价的。对象值等价性是指当两个不同的对象被认为是等价的,因为它们的内部值相同。

使用deepEquals方法

要正确比较两个数组,我们需要使用Arrays类的equalsdeepEquals方法。equals方法使用对象标识进行比较。deepEquals方法对元素进行更深入的检查以进行值等价性的比较。

以下语句将显示true

System.out.println(Arrays.equals(arr1,arr2));

deepEquals方法需要一个对象数组。在多维数组部分使用的二维grades数组满足要求,因为它是一个数组的数组,即一个引用其他数组(即对象)的数组。

如果我们创建第二个成绩数组grades2,并用与grades相同的值填充它,我们可以使用这些方法来测试它们是否相等。创建和初始化grades2数组如下:

int grades2[][];
grades2 = new int[ROWS][];
grades2[0] = new int[COLS];
grades2[1] = new int[COLS];

grades2[0][0] = 0;
grades2[0][1] = 1;
grades2[0][2] = 2;
grades2[1][0] = 3;
grades2[1][1] = 4;
grades2[1][2] = 5;

如果我们执行以下序列:

System.out.println(grades == grades2);
System.out.println(grades.equals(grades2));
System.out.println(Arrays.equals(grades, grades2));
System.out.println(Arrays.deepEquals(grades, grades2));

我们将得到以下输出:

false
false
false
true

前三个比较返回false,因为它们没有充分比较两个数组。第四种技术对数组进行了深入比较,并准确确定了它们的等价性。

下表总结了这些技术:

技术 注释
逐元素比较 如果正确实现,这将正确比较数组。
使用等号运算符 只有当两个引用变量引用相同的对象时才有效。
使用数组的equals方法 只有当两个引用变量引用相同的对象时才有效。
使用Array类的equals方法 这将适用于一维数组。
使用Array类的deepEquals方法 这使用对象的equals方法进行更深层次的比较。

复制数组

有时我们需要将一个数组复制到另一个数组。在本节中,我们将研究实现这一目标的各种技术。这些包括:

  • 简单的逐元素复制

  • 使用System.arraycopy方法

  • 使用Arrays.copyOf方法

  • 使用Arrays.copyOfRange方法

  • 使用clone方法

我们将使用以下声明的两个一维数组来演示这些技术:

int arr1[] = new int[5];
int arr2[] = new int[5];

我们将使用以下代码将arr1的每个元素初始化为其索引:

for(int i = 0; i < arr1.length; i++) {
   arr1[i] = i;
}

在本节的示例中,目标数组的内容作为注释跟随。

我们还将使用术语浅复制深复制。浅复制是指只复制引用值。复制操作后,原始对象未被复制。在深复制中,对象的引用未被复制。相反,将创建对象的新副本。我们将看到这里演示的一些技术只执行浅复制,这可能并不总是理想的。

简单的逐元素复制

一个简单的技巧是使用如下所示的 for 循环:

for(int i = 0; i < arr1.length; i++) {
   arr2[i] = arr1[i];
}  
// 0, 1, 2, 3, 4

这是一种简单的方法,但您需要小心使用正确的数组索引。这种技术在多维数组中变得更加复杂。

使用System.arraycopy方法

System类的arraycopy方法将尝试将一个数组的所有或部分内容复制到另一个数组中。每个数组中的起始位置都有指定,并指定要复制的元素数量。

要将arr1的所有元素复制到arr2,我们可以使用以下代码:

System.arraycopy(arr1, 0, arr2, 0, 5);
// 0, 1, 2, 3, 4

该方法的参数在下表中详细说明:

参数 描述
1 源数组
2 源数组中的起始索引
3 目标数组
4 目标数组中的起始索引
5 要复制的元素数量

下一个序列将arr1的前三个元素复制到arr2的最后三个元素:

System.arraycopy(arr1, 0, arr2, 2, 3);
// 0  0  0  1  2

我们还可以将一个数组的一部分复制到同一数组的其他位置。在这里,我们将前两个元素复制到arr1数组的最后两个元素:

System.arraycopy(arr1, 0, arr1, 3, 2);
// 0  1  2  0  1

在使用这种技术时,有许多异常发生的机会。如果任一数组引用为 null,则会抛出NullPointerException异常。如果数组索引无效,则会得到IndexOutOfBoundsException异常。

arraycopy方法将源数组的指定元素复制到目标数组的相应元素。根据数组的数据类型,可能会有两种可能的结果。它们如下:

  • 如果数组元素类型是原始数据类型,则两个数组实际上是相同的。

  • 如果数组元素类型是引用类型,则两个数组将是相同的,但它们都将引用相同的对象。这通常不是预期或期望的效果。

在以下代码序列中,尝试创建StringBuilder数组arr3的相同副本:

StringBuilder arr3[] = new StringBuilder[4];
arr3[0] = new StringBuilder("Pine");
arr3[1] = new StringBuilder("Oak");
arr3[2] = new StringBuilder("Maple");
arr3[3] = new StringBuilder("Walnut");
StringBuilder arr4[] = new StringBuilder[4];
System.arraycopy(arr3, 0, arr4, 0, 4);

然而,arr4包含与arr3使用的相同对象引用变量。两个数组的相应元素引用相同的对象。通过以下代码实现了具有对不同字符串的引用的相同数组的创建:

for (int i = 0; i < arr3.length; i++) {
   arr4[i] = new StringBuilder(arr3[i]);
}

我们为目标数组的每个元素创建了一个新的StringBuilder对象。如果需要深复制,则需要使用这种方法。

使用 Arrays.copyOf 方法

Arrays类的copyOf方法将基于现有数组创建一个新数组。该方法的第一个参数指定原始数组。它的第二个参数指定要复制的元素数量。在下面的示例中,我们基于arr1的前三个元素创建一个新数组:

arr2 = Arrays.copyOf(arr1, 3);
// 0  1  2

新数组可以比原始数组大,如下面的代码所示:

arr2 = Arrays.copyOf(arr1, 10);
// 0  1  2  3  4  0  0  0  0  0

arr2的最后五个元素将填充为零。

如果数组是对象数组,则将原始对象的副本分配给新数组。

使用 Arrays.copyOfRange 方法

Arrays类的copyOfRange方法将基于现有数组中的子范围的元素创建一个新数组。该方法的第一个参数指定原始数组。它的第二个参数指定开始索引,最后一个参数指定结束索引(不包括)。在下面的示例中,我们基于arr1的最后两个元素创建一个新数组:

arr2 = Arrays.copyOfRange(arr1, 3, 5);
//  3  4

请注意,最后一个参数对于arr1数组不是有效的索引。这里有效是因为最后一个参数是排他的。它不包括该元素。

实际上,如果在下一个示例中指定一个值,比如8,新数组将填充为零:

arr2 = Arrays.copyOfRange(arr1, 3, 8);
//     3  4  0  0  0

使用 clone 方法

您还可以使用Object类的clone方法来创建数组的副本:

arr2 = arr1.clone();

然而,这只是对原始对象进行浅复制。对于原始数据类型的数组,比如上面的整数数组,这不是问题。对于对象的引用数组,两个数组将引用相同的对象。

以下表格总结了本节介绍的复制技术:

技术 评论
逐个元素简单复制 繁琐,但可以实现浅复制或深复制
使用System.arraycopy方法 执行浅复制
使用Arrays.copyOf方法 执行整个数组的深复制
使用Arrays.copyOfRange方法 对数组的一部分执行深复制
使用clone方法 执行浅复制

传递数组

将数组传递给方法的优点是,它允许我们对一个以上的数组执行相同的操作。要将数组传递给方法,我们在方法调用中使用数组名称,并在方法中声明对传递数组的引用。下面通过调用displayArray方法来说明这一点。该方法只是显示数组。

displayArray(arr2);
   ...
private static void displayArray(int arr[]) {
   for(int number : arr) {
      System.out.print(number + "  ");
   }
   System.out.println();
}

请注意,我们是通过值“传递引用”到arr2数组的。也就是说,如果需要,我们可以在方法中读取和写入arr2数组的元素。但是,如果修改arr参数,则原始的arr2变量不会被修改。

考虑以下代码中的方法,该方法尝试更改arr2引用变量指向的内容:

System.out.println("Length of arr2: " + arr2.length);
changeArray(arr2);
System.out.println("Length of arr2: " + arr2.length);
...    
private static void changeArray(int arr[]) {
   arr = new int[100];
   System.out.println("Length of arr: " + arr.length);
}

当我们执行此代码时,我们会得到以下输出:

Length of arr2: 5
Length of arr: 100
Length of arr2: 5

arr的值被改变,但arr2的值没有改变。以下的图表应该有助于澄清这种行为:

传递数组

使用命令行参数

当 Java 应用程序执行时,执行的第一个方法是main方法。该方法传递一个参数,一个名为argsString对象数组。这些字符串对应于命令行提供的字符串。

Java 数组的length属性将告诉我们使用了多少个命令行参数。数组的第一个参数将包含第一个命令行参数。第二个将包含第二个命令行参数,依此类推。

以下CommandLineDemo应用程序演示了args数组的使用:

public class CommandLineDemo {

   public static void main(String args[]) {
      System.out.println("The command line has " + args.length + " arguments");
      for (int i = 0; i < args.length; i++) {
         System.out.println("\tArgument Number " + i + 
                  ": " + args[i]);
      }
   }
}

考虑应用程序使用以下命令行参数调用:

java CommandLineDemo /D 1024 /f test.dat

程序的输出将如下所示:

The command line has 4 arguments
 Argument Number 0: /D
 Argument Number 1: 1024
 Argument Number 2: /f
 Argument Number 3: test.dat

数组类

java.util.Arrays类拥有几种对数组进行操作的有用方法。该类的每个方法都是静态方法,这意味着我们在使用其方法之前不必创建Arrays类的实例。该类旨在处理数组并对数组执行常见操作。可用的操作类型包括:

  • 基于数组返回一个List

  • 执行二分搜索

  • 复制数组

  • 确定两个数组的相等性

  • 填充数组

  • 对数组进行排序

我们已经在前面的部分看到了几种这些技术的使用。在这里,我们将演示asListfilltoStringdeepToString方法的使用。

考虑以下声明。我们将声明一个整数数组,然后声明一个数组列表。两个字符串将被添加到ArrayList对象中。我们还将创建一个混合对象数组和一个字符串数组。ArrayList类在ArrayList部分中有更详细的讨论:

int arr1[] = new int[5];
ArrayList list = new ArrayList();
list.add("item 1");
list.add("item 2");

Object arr2[] = {"item 3", new Integer(5), list};
String arr3[] = {"Pine", "Oak", "Maple", "Walnut"};

接下来,我们将使用fill方法用数字5填充整数数组:

Arrays.fill(arr1,5);

然后使用asListtoStringdeepToString方法对这些数组进行操作,如下所示:

System.out.println(Arrays.asList(arr3));
System.out.println(Arrays.toString(arr1));
System.out.println(Arrays.deepToString(arr2));

执行时,我们得到以下输出:

[Pine, Oak, Maple, Walnut]
 [5, 5, 5, 5, 5]
[item 3, 5, [item 1, item 2]]

asList方法接受其数组参数并返回表示数组的java.util.List对象。如果修改了数组或列表,它们对应的元素也会被修改。这在以下示例中得到了证明:

List list2 = Arrays.asList(arr3);
list2.set(0, "Birch");
System.out.println(Arrays.toString(arr3));

这个序列的输出如下:

[Birch, Oak, Maple, Walnut]

toString方法返回数组的字符串表示。deepToString方法旨在返回其数组参数的字符串表示,其中数组更复杂。这在arr2中得到了体现,其中包含了不同的对象,包括一个列表。

在使用数组时要记住的关键点

在处理数组时,请记住:

  • 数组索引从 0 开始

  • 索引必须是整数

  • 数组可以保存原始数据类型或对象

  • 数组提供了常数时间的随机访问,这是一种访问数据的高效方式

  • 数组提供良好的引用局部性

  • 与其他数据结构相比,数组更难插入或删除元素

  • 可能存在对无效元素的索引

引用的局部性是指如果访问一个数据项,很可能也会访问附近的另一个数据项。这导致更快的读写操作,是虚拟操作系统中的一个重要概念。在内存中分布的链表的访问元素可能比链表的访问元素更快。

在访问数组元素时要小心。如果数组没有正确初始化,那么索引的元素可能无效,导致运行时或逻辑错误。

集合

集合框架是在 Java 2 中引入的一组接口和类,它们优于早期java.util包中找到的许多接口和类,如VectorStackHashTable。这些接口和类应该在可能的情况下始终使用,而不是旧的接口和类。集合框架的许多接口和类在以下表中进行了总结:

接口
集合 HashSet``TreeSet
列表 ArrayList``LinkedList
映射 HashMap``TreeMap

集合框架在java.sun.com/developer/onlineTraining/collections/Collection.html中有更详细的介绍。在这里,我们将讨论ArrayList类,因为它是一个认证主题。建议在需要List时使用ArrayList类。正如我们将看到的,迭代器用于ArrayList来支持列表的遍历。我们将从这个主题的覆盖开始我们的讨论。

迭代器

迭代器提供了遍历一组数据的方法。它可以与数组和集合框架中的各种类一起使用。Iterator接口支持以下方法:

  • next:该方法返回下一个元素

  • hasNext:如果有附加元素,则该方法返回true

  • remove:该方法从列表中移除元素

remove方法是一个可选的Iterator方法。如果尝试使用此方法,而接口的实现不支持此方法,则会抛出UnsupportedOperationException异常。

ListIterator接口,当可用时,是Iterator接口的替代品。它使用相同的方法并提供额外的功能,包括:

  • 在任一方向上遍历列表

  • 修改其元素

  • 访问元素的位置

ListIterator接口的方法包括以下内容:

  • next:该方法返回下一个元素

  • previous:该方法返回前一个元素

  • hasNext:如果有跟随当前元素的附加元素,则该方法返回true

  • hasPrevious:如果有前面的附加元素,则该方法返回true

  • nextIndex:该方法返回next方法将返回的下一个元素的索引

  • previousIndex:该方法返回previous方法将返回的上一个元素的索引

  • add:该方法在列表中插入一个元素(可选)

  • remove:该方法从列表中移除元素(可选)

  • set:该方法替换列表中的一个元素(可选)

ArrayList

ArrayList类具有几个有用的特性:

  • 它是灵活的

  • 根据需要增长

  • 具有许多有用的方法

  • 访问在常数时间内执行

  • 插入/删除在线性时间内执行

  • 可以使用索引、for-each 循环或迭代器遍历

ArrayList在内部使用数组。当需要增长时,元素从旧数组复制到新数组。

ArrayList类不是同步的。当为ArrayList对象获取迭代器时,如果以并发方式修改,可能会导致数据丢失的可能同时覆盖。当多个线程访问同一对象时,它们可能同时写入对象,即并发。当发生这种同时覆盖时,会抛出ConcurrentModificationException异常。

创建 ArrayList

ArrayList类具有以下三个构造函数:

  • 默认构造函数

  • 接受Collection对象

  • 接受初始容量

ArrayList对象的容量指的是列表可以容纳多少元素。当需要添加更多元素且列表已满时,列表的大小将自动增加。使用其默认构造函数创建的ArrayList的初始容量为10。以下示例创建了两个列表,一个容量为10,另一个容量为20

ArrayList list1 = new ArrayList();
ArrayList list2 = new ArrayList(20);

ArrayList类支持泛型。在这里,创建了一个字符串列表:

ArrayList<String> list3 = new ArrayList<String>();

我们将在接下来的示例中使用list3

添加元素

有几种方法可用于向ArrayList添加元素。它们可以被放入以下两个类别之一:

  • 将一个或多个元素附加到列表的末尾

  • 在列表中的位置插入一个或多个元素

最简单的情况如下所示,其中一个字符串被添加到creatures的末尾:

ArrayList<String> creatures = new ArrayList<String>();
creatures.add("Mutant");
creatures.add("Alien");
creatures.add("Zombie");
System.out.println(creatures);

打印语句的输出如下:

[Mutant, Alien, Zombie]

要在第一个元素之后的索引处插入一个元素,我们使用索引 1:

creatures.add(1,"Godzilla");
System.out.println(creatures);

执行代码将验证操作,如下所示:

 [Mutant, Godzilla, Alien, Zombie]

addAll方法也可以与Collections一起使用,如下所示:

ArrayList<String> cuddles = new ArrayList<String>();
cuddles.add("Tribbles");
cuddles.add("Ewoks");

creatures.addAll(2, cuddles);
System.out.println(creatures);

这将导致cuddles被放置在列表中第二个元素之后,如下所示:

[Mutant, Godzilla, Tribbles, Ewoks, Alien, Zombie]

addAll方法也可以不带索引参数使用。在这种情况下,新元素将添加到列表的末尾。

检索元素

要检索给定位置的元素,请使用get方法。此方法接受一个整数索引值。在下面的示例中,我们检索列表的第三个元素。假设 creatures 列表包含[Mutant, Godzilla, Tribbles, Ewoks, Alien, Zombie],以下语句将检索Tribbles

String element = creatures.get(2);

可以使用indexOf方法获取元素的索引,如下一个代码序列所示。如果元素不存在,该方法将返回-1。

System.out.println(creatures.indexOf("Tribbles"));
System.out.println(creatures.indexOf("King Kong"));

执行此代码将生成以下输出:

2
-1

indexOf方法将返回找到的第一个元素的索引。lastIndexOf方法将返回列表中找到的最后一个元素的索引。

toArray方法将返回列表中对象的数组。在此示例中,返回creatures列表并将其分配给complete数组。如果数组不够大,就像这里一样,将创建并返回一个新数组。

String[] complete = new String[0];
complete = creatures.toArray(complete);
for(String item : complete) {
   System.out.print(item + " ");
}
System.out.println();

执行时,我们得到以下输出:

Mutant Godzilla Tribbles Ewoks Alien Zombie

还有一个subList方法,根据起始和结束索引返回列表的一部分。

遍历ArrayList对象

要遍历ArrayList对象,我们可以使用几种方法之一:

  • 一个简单的 for 语句

  • 一个 for-each 语句

  • 使用Iterator

  • 使用ListIterator

我们可以使用 for 循环,但更容易出错。以下代码将从头到尾显示列表:

for(int i = 0; i < creatures.size(); i++) {
   System.out.print(creatures.get(i) + " ");
}
System.out.println();

注意使用size方法,它返回列表中的元素数。

for-each 语句是最简单的方法,如下面的代码片段所示:

for(String creature : creatures) {
   System.out.print(creature + " ");
}
System.out.println();

iterator方法返回一个Iterator对象,如下所示:

Iterator<String> iterator = creatures.iterator();
while(iterator.hasNext()) {
   System.out.print(iterator.next() + " ");
}
System.out.println();

ListIterator方法返回一个ListIterator对象:

ListIterator<String> listIterator = 
            creatures.listIterator();
while(listIterator.hasNext()) {
   System.out.print(listIterator.next() + " ");
}
System.out.println();

这四种技术都将产生以下相同的输出:

Mutant Godzilla Tribbles Ewoks Alien Zombie

如果我们将以下代码添加到上一个代码序列的末尾,我们可以按照逆序遍历列表,如下面的代码片段所示:

while(listIterator.hasPrevious()) {
   System.out.print(listIterator.previous() + " ");
}
System.out.println();

输出如下:

Zombie Alien Ewoks Tribbles Godzilla Mutant

ArrayList对象进行排序

虽然ArrayList类中没有特定的排序方法,但我们可以使用Arrays类的sort方法,如下面的代码片段所示:

Collections.sort(creatures);
System.out.println(creatures);

输出如下:

[Alien, Ewoks, Godzilla, Mutant, Tribbles, Zombie]

此方法的重载版本使用Comparator对象。此对象确定如何进行比较。

其他ArrayList方法

我们可以使用set方法修改列表的一个元素。此方法接受要替换的元素的索引和新值。例如,要用字符串Ghoul替换 creatures 列表的第一个元素,我们可以使用以下代码:

creatures.set(0,"Ghoul");
System.out.println(creatures);

以下输出验证了替换:

[Ghoul, Godzilla, Tribbles, Ewoks, Alien, Zombie]

我们可以移除列表的所有或部分元素。clear方法将移除所有元素。remove方法将移除单个元素,removeAll方法将从列表中移除给定集合中的所有值。以下代码序列说明了这些方法。cuddles ArrayList添加元素部分中被定义:

System.out.println(creatures);
creatures.remove(0);
System.out.println(creatures);

creatures.remove("Alien");
System.out.println(creatures);

creatures.removeAll(cuddles);
System.out.println(creatures);

creatures.clear();
System.out.println(creatures); 

此序列的输出如下:

[Mutant, Godzilla, Tribbles, Ewoks, Alien, Zombie]
[Godzilla, Tribbles, Ewoks, Alien, Zombie]
[Godzilla, Tribbles, Ewoks, Zombie]
[Godzilla, Zombie]
[]

虽然ArrayList是一个强大的类,但如果:

  • 已知元素的数量

  • 它有一个小的固定上限

  • 需要原始数据类型以提高效率

  • 不需要插入元素

封装集合

在类中使用集合时,隐藏集合以防止无意修改集合。例如,如果一个类封装了一个ArrayListBooks,那么应该提供公共方法来允许访问集合。在下面的例子中,一个名为Library的类隐藏了Book对象的ArrayList

public class Library {

   private ArrayList<Book> books = new ArrayList<Book>();

   public Book getBook(int index) {
      return books.get(index);
   }

   public void addBook(Book book) {
      books.add(book);
   }

   public List getBooks() {
      return books;
   }
}

这是数据封装的一个很好的例子。但是,请确保不要无意中暴露私有数据。在getBook方法中,我们返回了对书的引用。这个引用允许用户修改书。如果不允许进行此修改,则可以返回书的副本,如下所示。这假设Book类有一个构造函数,根据构造函数的参数制作书的新副本:

public Book getBook (int index) {
   return new Book(books.get(index));
}

getBooks方法也存在相同的问题。它返回Library类的私有books引用变量的引用。可以用以下实现替换此方法以确保正确的数据封装:

public List getBooks() {
   ArrayList list = new ArrayList(books.size());
   for(Book book : books) {
      list.add(new Book(book));
   }
   return list;
}

总结

在本章中,我们研究了数组的创建和使用以及ArrayList类的实例。我们还详细介绍了Arrays类在支持各种数组操作方面的使用。

数组包含一个或多个维度,并被视为对象。在使用数组时必须小心,以避免访问其元素时出现问题。通过对内存中的数组分配以及如何执行各种操作(例如复制和比较数组)有很好的理解,可以避免出现问题。当我们需要一个固定大小的列表时,数组是很有用的,因为它允许高效地访问其元素。

Arrays类提供了许多支持数组的静态方法。例如,我们可以使用Arrays类来复制数组,对数组进行排序和填充数组。

ArrayList类提供了处理数据列表的另一种方法。它提供了许多用于操作列表的方法,并且在需要时会根据添加到列表中的新元素而增长。这是它相对于数组的主要优势之一。与大多数数据结构一样,将信息封装在类中以帮助减少软件开发的复杂性是很重要的。

现在我们已经了解了数组,我们准备更仔细地查看 Java 中可用的各种循环结构。我们将在下一章中详细讨论这些结构。

集合框架引入了几个新的接口和类来替换java.util包中的旧版本。我们研究了ArrayList类及其用于操作其元素的方法。ArrayList类比数组更灵活,特别适用于插入和移除元素。

涵盖的认证目标

在本章中,我们涵盖了以下认证目标:

  • 使用一维数组

  • 使用多维数组

  • 声明和使用ArrayList

测试你的知识

  1. 以下哪个语句将编译而不会出错?

a. int arr[];

b. int arr[5];

c. int arr[5] = {1,2,3,4,5};

d. int arr[] = {1,2,3,4,5};

  1. 以下哪个声明了一个支持两行和可变列数的数组?

a. int arr[][] = new int[2][3];

b. int arr[][] = new int[2][];

c. int arr[][] = new int[][];

d. int arr[][] = new int[][3];

  1. 根据以下代码,哪些语句可以用来确定列表中是否可以找到cat
ArrayList<String> list = new ArrayList<>();
list.add("dog");
list.add("cat");
list.add("frog");

a. list.contains("cat")

b. list.hasObject("cat")

c. list.indexOf("cat")

d. list.indexOf(1)

第五章:循环结构

通常希望一遍又一遍地重复一系列操作。例如,我们可能希望显示存储在数组中的组织中员工的信息。数组的每个元素可能包含对Employee对象的引用。对象的方法调用将放置在循环结构内部。

在 Java 中有四种循环结构可用:

  • for 语句

  • For-each 语句

  • while 语句

d. while 语句

此外,break 和 continue 语句用于控制循环的行为。break 语句用于提前退出或短路循环,并在break 语句部分讨论。正如我们在第三章中观察到的,在决策结构中,break 也在 switch 语句中使用。continue 语句用于绕过循环中的语句并继续执行循环。它在continue 语句部分中介绍。我们还将研究在 Java 中使用标签,尽管它们应该谨慎使用。

循环的循环体根据循环结构的特定次数进行迭代。迭代是通常用来描述这种执行的术语。

循环使用控制信息来确定循环体将被执行多少次。对于大多数循环,有一组初始值,一组在循环体结束时执行的操作,以及一个终止条件,它将停止循环的执行。并非所有循环都有这些部分,因为其中一些部分要么缺失,要么隐含。终止条件几乎总是存在的,因为这是终止循环迭代所需的。如果终止条件缺失,则会创建一个无限循环。

无限循环指的是那些可能永远不会终止的循环,而不使用 break 语句等语句。尽管它们的名称是无限循环,但它们并不会无限执行,因为它们总会在某个时刻终止。它们在某些情况下很有用,这些情况中提供循环终止条件是不方便或尴尬的。

我们还将介绍嵌套循环的使用以及与循环相关的各种陷阱。还提供了一个处理编程逻辑开发的部分,以帮助在创建程序逻辑时提供一种方法。

for 语句

当需要知道循环需要执行的次数时,使用 for 语句。for 循环有两种变体。第一种在本节中讨论,是传统形式。for-each 语句是第二种形式,引入于 Java 5。它在for-each 语句部分中讨论。

for 语句由以下三部分组成:

  • 初始操作

  • 终止条件

  • 结束循环操作

for 循环的一般形式如下:

for (<initial-expression>;<terminal-expression>;<end-loopoperation>)
  //statements;

for 循环的循环体通常是一个块语句。初始操作在循环的第一次迭代之前进行,只执行一次。结束循环操作在每次循环执行结束时进行。终止条件确定循环何时终止,并且是一个逻辑表达式。它在每次循环重复开始时执行。因此,如果第一次评估终止条件时,它的值为 false,则 for 循环的循环体可能会执行零次。

通常作为初始操作、终止条件和结束循环操作的一部分使用变量。变量要么作为循环的一部分声明,要么作为循环外部声明。以下代码片段是声明变量i作为循环一部分的示例。使用外部变量的示例在for 语句和作用域部分中介绍:

for (int i = 1; i <= 10; i++) {
   System.out.print(i + "  ");
}
System.out.println();

在这个例子中,我们在循环体中使用了一个语句。变量i被赋予初始值 1,并且每次循环执行时增加 1。循环执行了 10 次,并产生了 1 行输出。语句i++是一种更简洁的方式,表示i = i + 1。输出应该是以下内容:

1  2  3  4  5  6  7  8  9  10 

以下示例使用 for 语句计算从164的整数的平方:

for (int i = 1; i <= 64; i++) {
  System.out.println (i + " squared is = " + i * i);
  }

输出的部分列表如下:

1 的平方是= 1

2 的平方是= 4

3 的平方是= 9

4 的平方是= 16

...

循环变量的初始值可以是任何值。此外,结束循环操作可以根据需要递减或修改变量。在下一个例子中,从101显示数字:

for (int i = 10; i > 0; i--) {
   System.out.print(i + "  ");
}
System.out.println();

以下是此序列的输出:

10  9  8  7  6  5  4  3  2  1 

一个常见的操作是计算累积和,如下面的代码序列所示。这个例子在时间就是一切部分中有更详细的讨论:

int sum = 0;
for(i = 1; i <= 10; i++) {
  sum += i;
}
System.out.println(sum);

sum的值应该是55

逗号运算符

逗号运算符可以作为 for 语句的一部分,用于添加其他变量以在循环内使用和/或控制循环。它用于分隔 for 循环的初始表达式和结束循环操作部分的各个部分。逗号运算符的使用如下所示:

for(int i = 0, j = 10; j > 5; i++, j--) {
   System.out.printf("%3d  %3d%n",i , j);
}

注意在printf语句中使用了%n格式说明符。这指定应生成一个新行字符。此外,这个新行分隔符是特定于平台的,使应用程序更具可移植性。

执行此代码序列时,将产生以下输出:

0   10
1    9
2    8
3    7
4    6

循环声明了两个变量,ij。变量i初始化为0j初始化为10。循环结束时,i增加了1j减少了1。只要j大于5,循环就会执行。

我们可以使用更复杂的终止条件,如下面的代码片段所示:

for(int i = 0, j = 10; j > 5 && i < 3; i++, j--) {
   System.out.printf("%3d  %3d%n",i , j);
}

在这个例子中,循环将在第三次迭代后终止,产生以下输出:

  0   10
  1    9
  2    8

在这里分别声明变量是非法的:

for(int i = 0, int j = 10; j > 5; i++, j--) {

生成了一个语法错误,如下所示。由于消息过长,只提供了消息的第一部分。这也说明了 Java 和大多数其他编程语言生成的错误消息的神秘性质:

<identifier> expected
'.class' expected
...

for 语句和作用域

for 语句使用的索引变量可以根据其声明方式具有不同的作用域。我们可以利用这一点来控制循环的执行,然后根据需要在循环外部使用变量。第一个 for 循环的示例如下重复。在这个代码序列中,i变量的作用域仅限于 for 循环的主体:

for (int i = 1; i <= 10; i++) {
   System.out.println(i);
}
System.out.println();

一种替代方法是将i声明为循环外部,如下所示:

int i;
for (i = 1; i <= 10; i++) {
  System.out.print(i + " ");
}
System.out.println();

这两个 for 循环是等价的,因为它们都在一行上显示 1 到 10 的数字。它们在i变量的作用域上有所不同。在第一个例子中,作用域仅限于循环体。尝试在循环外部使用变量,如下面的代码所示,将导致语法错误:

for (int i = 1; i <= 10; i++) {
   System.out.println(i);
}
System.out.println(i);

错误消息如下:

cannot find symbol
 symbol:   variable i

在第二个例子中,循环终止后,变量将保留其值,并可供后续使用。以下示例说明了这一点:

int i;
for (i = 1; i <= 10; i++) {
  System.out.print(i + "  ");
}
System.out.println();
System.out.println(i);

以下是此序列的输出:

1  2  3  4  5  6  7  8  9  10 
11

作用域在第二章的作用域和生存期部分中有更详细的讨论,Java 数据类型及其用法

for 循环的变体

for 循环的主体可能由多个语句组成。重要的是要记住 for 循环的主体只包含一个语句。下面说明了循环中多个语句的使用。这个循环将读取一系列数字并将它们逐行打印出来。它将继续读取,直到读取到一个负值,然后退出循环。java.util.Scanner类用于从输入源中读取数据。在这种情况下,它使用System.in指定键盘作为输入源:

Scanner scanner = new Scanner(System.in);
int number = 0;

for (int i = 0; number >= 0; i++) {
   System.out.print("Enter a number: ");
   number = scanner.nextInt();
   System.out.printf("%d%n", number);
}

执行这段代码序列的一个可能的输出如下:

Enter a number: 3
3
Enter a number: 56
56
Enter a number: -5
-5

初始操作、终止条件或结束循环操作是不需要的。例如,以下语句将执行i++语句 5 次,退出循环时i的值为5

int i = 0;
for (;i<5;) {
   i++;
}

在下面的例子中,循环的主体将永远执行,创建一个无限循环:

int i = 0;
for (;;i++)
   ;

对于以下的 for 循环也是一样的:

int i = 0;
for(;;) 
   ;

这被称为无限循环,在无限循环部分中有更详细的介绍。

注意

当你知道循环将被执行多少次时,通常使用 for 循环。通常使用一个控制整数变量作为数组的索引或在循环体内进行计算。

for-each 语句

for-each 语句是在 Java 5 发布时引入的。有时它被称为增强型 for 循环。使用 for-each 语句的优点包括:

  • 不需要为计数变量提供结束条件

  • 它更简单,更易读

  • 该语句提供了编译器优化的机会

  • 泛型的使用变得更简单

for-each 语句与集合和数组一起使用。它提供了一种更容易遍历数组或实现了java.util.Iterable接口的类的每个成员的方法。由于Iterable接口是java.util.Collection接口的超级接口,因此 for-each 语句可以与实现Collection接口的类一起使用。

这个语句的语法与常规的 for 语句类似,除了括号内的内容。括号内包括数据类型,后跟一个变量,一个冒号,然后是数组名或集合,如下所示:

for (<dataType variable>:<collection/array>)
   //statements;

它与集合的使用在使用 for-each 语句与列表部分进行了说明。在下面的序列中,声明了一个整数数组,初始化了它,并使用 for-each 语句显示了数组的每个元素:

int numbers[] = new int[10];

for (int i = 0; i < 10; i++) {
   numbers[i] = i;
}

for (int element : numbers) {
   System.out.print(element + "  ");
}    
System.out.println();

numbers 数组的元素被初始化为它们的索引。请注意使用了 for 语句。这是因为我们无法在 for-each 语句中直接访问索引变量。在上述代码片段中,for-each 语句被读作“对于 numbers 中的每个元素”。在循环的每次迭代中,element对应数组的一个元素。它从第一个元素开始,直到最后一个元素结束。这个序列的输出如下:

0 1 2 3 4 5 6 7 8 9

使用 for-each 语句与数组存在一些缺点。无法执行以下操作:

  • 修改数组或列表中的当前位置

  • 直接迭代多个数组或集合

例如,使用前面的例子,如果我们尝试使用以下代码修改包含 5 的数组元素,它不会导致语法错误。但它也不会修改相应的数组元素:

for (int element : numbers) {
   if (element == 5) {
      element = -5;
   }
}

for (int element : numbers) {
   System.out.print(element + "  ");
}
System.out.println();

这个序列的输出如下:

0 1 2 3 4 5 6 7 8 9

如果我们想要使用一个循环来访问两个不同的数组,就不能使用 for-each 循环。例如,如果我们想要将一个数组复制到另一个数组,我们需要使用 for 循环,如下所示:

int source[] = new int[5];
int destination[] = new int[5];

for(int number : source) {
   number = 100;
}

for(int i = 0; i < 5; i++) {
   destination[i] = source[i];
}

虽然我们使用 for-each 来初始化源数组,但我们一次只能处理一个数组。因此,在第二个循环中,我们被迫使用 for 语句。

使用 for-each 语句与列表

我们将首先说明如何使用 for-each 语句与ArrayListArrayList类实现了List接口,该接口扩展了Collection接口。接口的使用和声明在第六章中有更详细的介绍,类,构造函数和方法。由于 for-each 语句可以与实现Collection接口的类一起使用,我们也可以将其与ArrayList类一起使用。在下一节中,我们将创建自己的Iterable类:

ArrayList<String> list = new ArrayList<String>();

list.add("Lions and");
list.add("tigers and");
list.add("bears.");
list.add("Oh My!");

for(String word : list) {
   System.out.print(word + "  ");
}
System.out.println();

输出,正如你可能预测的那样,如下所示:

Lions and tigers and bears. Oh My!

在这个例子中,使用 for-each 与数组的使用并没有太大的不同。我们只是使用了ArrayList的名称而不是数组名称。

使用 for-each 语句与列表具有与我们之前在数组中看到的类似的限制:

  • 可能无法在遍历列表时删除元素

  • 无法修改列表中的当前位置

  • 不可能同时迭代多个集合

remove方法可能会抛出UnsupportedOperationException异常。这是可能的,因为Iteratable接口的Iterator的实现可能没有实现remove方法。这将在下一节详细说明。

ArrayList的情况下,我们可以移除一个元素,如下面的代码片段所示:

for(String word : list) {
   if(word.equals("bears.")) {
      list.remove(word);
      System.out.println(word + " removed");
   }
}

for(String word : list) {
   System.out.print(word + "  ");
}
System.out.println();

for-each 语句用于遍历列表。当找到bears.字符串时,它被移除。前面序列的输出如下:

Lions and tigers and bears. Oh My! 
bears. removed
Lions and tigers and Oh My!

我们不能在 for-each 语句内修改列表。例如,以下代码序列尝试修改word并向list添加一个字符串。列表不会受到影响:

for(String word : list) {
   if(word.equals("bears.")) {
      word = "kitty cats";
      list.add("kitty cats");
   }
}

尝试修改word变量并不会产生任何效果,也不会生成异常。但add方法不是这样。在前面的 for-each 语句中使用它将生成一个java.util.ConcurrentModificationException异常。

注意

与数组一样,使用 for-each 语句一次只能迭代一个集合。由于 for-each 语句只支持一个引用变量,因此一次只能访问一个列表。

如果您需要从列表中删除一个元素,请使用迭代器而不是 for-each 语句。

实现迭代器接口

如前所述,任何实现Iterable接口的类都可以与 for-each 语句一起使用。为了说明这一点,我们将创建两个类:

  • MyIterator:这实现了Iterator接口并支持一个简单的迭代

  • MyIterable:这使用MyIterator来支持它在 for-each 语句中的使用

首先,让我们来看一下接下来的MyIterator类。该类将迭代 1 到 10 的数字。它通过将value变量与 10 的上限进行比较,并在其hasNext方法中返回truefalse来实现这一点。next方法只是返回并增加当前值。remove方法不受支持:

import java.util.Iterator;

public class MyIterator implements Iterator<Integer> {
   private int value;
   private final int size;

   public MyIterator() {
      value = 1;
      size = 10;
   }

   @Override
   public boolean hasNext() {
      return value<=size;
   }

   @Override
   public Integer next() {
      return value++;
   }

   @Override
   public void remove() {
      throw new UnsupportedOperationException(
            "Not supported yet.");
   }
}

MyIterable类实现了Iterable接口。该接口包含一个方法iterator。在这个类中,它使用MyIterator类的一个实例来提供一个Iterator对象:

import java.util.Iterator;

public class MyIterable implements Iterable<Integer> {
   private MyIterator iterator;

   public MyIterable() {
      iterator = new MyIterator();
   }

   @Override
   public Iterator<Integer> iterator() {
      return iterator;
   }
}

我们可以使用以下代码序列测试这些类:

MyIterable iterable = new MyIterable();

for(Integer number : iterable) {
   System.out.print(number + "  ");
}
System.out.println();

输出将显示数字从 1 到 10,如下所示:

1 2 3 4 5 6 7 8 9 10

注意

在许多情况下,并不总是需要使用Iterator方法来迭代集合。在许多情况下,for-each 语句提供了一种更方便和简单的技术。

for-each 语句-使用问题

在使用 for-each 语句时,有几个问题需要注意:

  • 如果数组/集合为空,您将会得到一个空指针异常

  • 它可以很好地与具有可变数量参数的方法一起使用

空值

如果数组/集合为 null,您将获得一个空指针异常。考虑以下示例。我们创建了一个字符串数组,但未初始化第三个元素:

String names[] = new String[5];
names[0] = "Will Turner";
names[1] = "Captain Jack Sparrow";
names[3] = "Barbossa";
names[4] = "Elizabeth Swann";

我们可以使用 for-each 语句显示名称,如下所示:

for(String name : names) {
   System.out.println(name);
}

输出如下所示,将为缺失的条目显示null。这是因为println方法检查其参数是否为 null 值,当为 null 时,它会打印null

Will Turner
Captain Jack Sparrow
null
Barbossa
Elizabeth Swann

但是,如果我们对名称应用toString方法,我们将在第三个元素上得到java.lang.NullPointerException

for(String name : names) {
   System.out.println(name.toString());
}

如下输出所示,这是经过验证的:

Will Turner
Captain Jack Sparrow
java.lang.NullPointerException

可变数量的参数

for-each 语句在使用可变数量的参数的方法中效果很好。关于使用可变数量的参数的方法的更详细解释可以在第六章的可变数量的参数部分找到。

在以下方法中,我们传递了可变数量的整数参数。接下来,我们计算这些整数的累积和并返回总和:

public int total(int ... array) {
   int sum = 0;
   for(int number : array) {
      sum+=number;
   }
   return sum;
}

当使用以下调用执行时,我们得到150作为输出:

result = total(1,2,3,4,5);
result = total();

但是,我们需要小心,不要传递null值,因为这将导致java.lang.NullPointerException,如下面的代码片段所示:

result = total(null);

注意

尽可能使用 for-each 循环,而不是 for 循环。

while 语句

while 语句提供了另一种重复执行一组语句的方法。当要执行块的次数未知时,它经常被使用。它的一般形式包括while关键字后跟一组括号括起来的逻辑表达式,然后是一个语句。只要逻辑表达式评估为 true,循环的主体将执行:

while (<boolean-expression>) <statements>;

一个简单的示例重复了第一个 for 循环示例,其中我们在一行上显示数字 1 到 10:

int i = 1;
while(i <= 10) {
   System.out.print(i++ + "  ");
}
System.out.println();

输出如下:

1 2 3 4 5 6 7 8 9 10

以下示例稍微复杂一些,计算了number变量的因子:

int number;
int divisor = 1;
Scanner scanner = new Scanner(System.in);
System.out.print("Enter a number: ");
number = scanner.nextInt();
while (number >= divisor) {
   if ((number % divisor) == 0) {
      System.out.printf("%d%n", divisor);
   }
   divisor++;
}

当使用输入6执行时,我们得到以下输出:

Enter a number: 6
1
2
3
6

以下表格说明了语句序列的操作:

迭代次数 除数 数字 输出
1 1 6 1
2 2 6 2
3 3 6 3
4 4 6
5 5 6
6 6 6 6

在以下示例中,当用户输入负数时,循环将终止。在此过程中,它计算了输入数字的累积和:

int number;
System.out.print("Enter a number: ");
number = scanner.nextInt();
while (number > 0) {
   sum += number;
   System.out.print("Enter a number: ");
   number = scanner.nextInt();
}
System.out.println("The sum is " + sum);

请注意,此示例重复了提示用户输入数字所需的代码。可以更优雅地使用 do-while 语句来处理这个问题,如下一节所讨论的。以下输出说明了对一系列数字执行此代码的执行情况:

Enter a number: 8
Enter a number: 12
Enter a number: 4
Enter a number: -5
The sum is 24

while 语句对于需要未知循环次数的循环很有用。while 循环的主体将执行,直到循环表达式变为 false。当终止条件相当复杂时,它也很有用。

注意

while 语句的一个重要特点是在循环开始时对表达式进行评估。因此,如果逻辑表达式的第一次评估为 false,则循环的主体可能永远不会执行。

do-while 语句

do-while 语句类似于 while 循环,不同之处在于循环的主体始终至少执行一次。它由do关键字后跟一个语句,while关键字,然后是括号括起来的逻辑表达式组成:

do <statement> while (<boolean-expression>);

通常,do-while 循环的主体,如语句所表示的那样,是一个块语句。以下代码片段说明了 do 语句的用法。它是对前一节中使用的等效 while 循环的改进,因为它避免了在循环开始之前提示输入一个数字:

int sum = 0;
int number;
Scanner scanner = new Scanner(System.in);
do {
   System.out.print("Enter a number: ");
   number = scanner.nextInt();
   if(number > 0 ) {
     sum += number;
   }
} while (number > 0);
System.out.println("The sum is " + sum);

当执行时,您应该获得类似以下的输出:

Enter a number: 8
Enter a number: 12
Enter a number: 4
Enter a number: -5
The sum is 24

注意

do-while语句与while语句不同,因为表达式的评估发生在循环结束时。这意味着该语句至少会执行一次。

这个语句并不像forwhile语句那样经常使用,但在循环底部进行测试时很有用。下一个语句序列将确定整数数字中的数字位数:

int numOfDigits;
System.out.print("Enter a number: ");
Scanner scanner = new Scanner(System.in);
int number = scanner.nextInt();
numOfDigits = 0;
do {
   number /= 10;
   numOfDigits++;
} while (number != 0);
System.out.printf("Number of digits: %d%n", numOfDigits);

这个序列的输出如下:

Enter a number: 452
Number of digits: 3

452的结果如下表所示:

迭代次数 数字 数字位数
0 452 0
1 45 1
2 4 2
3 0 3

break语句

break语句的效果是终止当前循环,无论是whileforfor-each还是do-while语句。它也用于switch语句。break语句将控制传递给循环后面的下一个语句。break语句由关键字break组成。

考虑以下语句序列的影响,该序列会在无限循环中重复提示用户输入命令。当用户输入Quit命令时,循环将终止:

String command;
while (true) {
   System.out.print("Enter a command: ");
   Scanner scanner = new Scanner(System.in);
   command = scanner.next();
   if ("Add".equals(command)) {
      // Process Add command
   } else if ("Subtract".equals(command)) {
      // Process Subtract command
   } else if ("Quit".equals(command)) {
      break;
   } else {
      System.out.println("Invalid Command");
   }
}

注意equals方法的使用方式。equals方法针对字符串字面量执行,并将命令用作其参数。这种方法避免了NullPointerException,如果命令包含空值,将会导致该异常。由于字符串字面量永远不会为空,这种异常永远不会发生。

continue语句

continue语句用于将控制从循环内部转移到循环的末尾,但不像break语句那样退出循环。continue语句由关键字continue组成。

执行时,它会强制执行循环的逻辑表达式。在以下语句序列中:

while (i < j) {
   …
   if (i < 0) {
      continue;
   }
   …
}

如果i小于0,它将绕过循环体的其余部分。如果循环条件i<j不为假,将执行循环的下一次迭代。

continue语句通常用于消除通常是必要的嵌套级别。如果没有使用continue语句,前面的示例将如下所示:

while (i < j) {
   …
   if (i < 0) {
      // Do nothing
   } else {
      …
   }
}

嵌套循环

循环可以嵌套在彼此之内。可以嵌套组合forfor-eachwhiledo-while循环。这对解决许多问题很有用。接下来的示例计算了二维数组中一行元素的总和。它首先将每个元素初始化为其索引的总和。然后显示数组。然后使用嵌套循环计算并显示每行元素的总和:

final int numberOfRows = 2;
final int numberOfColumns = 3;
int matrix[][] = new int[numberOfRows][numberOfColumns];

for (int i = 0; i < matrix.length; i++) {
   for (int j = 0; j < matrix[i].length; j++) {
      matrix[i][j] = i + j;
   }
}

for (int i = 0; i < matrix.length; i++) {
   for(int element : matrix[i]) {
      System.out.print(element + "  ");
   }
   System.out.println();
}  

for (int i = 0; i < matrix.length; i++) {
   int sum = 0;
   for(int element : matrix[i]) {
      sum += element;
   }
   System.out.println("Sum of row " + i + " is " +sum);
}

注意使用length方法来控制循环执行的次数。如果数组的大小发生变化,这将使代码更易于维护。执行时,我们得到以下输出:

0 1 2 
1 2 3 
Sum of row 0 is 3
Sum of row 1 is 6

注意在显示数组和计算行的总和时使用for-each语句。这简化了计算。

breakcontinue语句也可以在嵌套循环中使用。但是,它们只能与当前循环一起使用。也就是说,从内部循环中跳出只会跳出内部循环,而不是外部循环。正如我们将在下一节中看到的,我们可以使用标签从内部循环中跳出外部循环。

在最后一个嵌套循环序列的修改中,当总和超过 2 时,我们跳出内部循环:

for (int i = 0; i < matrix.length; i++) {
   int sum = 0;
   for(int element : matrix[i]) {
      sum += element;
      if(sum > 2) {
         break;
      }
   }
   System.out.println("Sum of row " + i + " is " +sum);
}

这个嵌套循环的执行将改变最后一行的总和,如下所示:

Sum of row 0 is 3
Sum of row 1 is 3

break语句使我们跳出内部循环,但没有跳出外部循环。如果在外部循环的立即体中有相应的break语句,我们可以跳出外部循环。continue语句在内部和外部循环方面的行为类似。

使用标签

标签是程序内的位置名称。它们可以用于改变控制流,并且应该谨慎使用。在前面的例子中,我们无法使用 break 语句跳出最内层的循环。但是,标签可以用于跳出多个循环。

在下面的例子中,我们在外部循环前放置一个标签。在内部循环中,当i大于 0 时执行 break 语句,有效地在第一行的求和计算完成后终止外部循环。标签由名称后跟一个冒号组成:

outerLoop:
for(int i = 0; i < 2; i++) {
   int sum = 0;
   for(int element : matrix[i]) {
      sum += element;
      if(i > 0) {
         break outerLoop;
      }
   }
   System.out.println("Sum of row " + i + " is " +sum);
}

这个序列的输出如下:

Sum of row 0 is 3

我们也可以使用带有标签的 continue 语句来达到类似的效果。

注意

标签应该避免,因为它们会导致代码难以阅读和维护。

无限循环

无限循环是一种除非使用 break 语句等语句强制终止,否则将永远执行的循环。无限循环非常有用,可以避免循环的尴尬逻辑条件。

一个无限 while 循环应该使用true关键字作为其逻辑表达式:

while (true) {
   // body
}

一个 for 循环可以简单到使用 nulls 作为 for 语句的每个部分:

for (;;) {
   // body
}

一个永远不会终止的循环通常对大多数程序没有价值,因为大多数程序最终应该终止。然而,大多数无限循环都设计为使用 break 语句终止,如下所示:

while (true) {
   // first part
   if(someCondition) {
      break;
   }
   // last part
}

这种技术相当常见,用于简化程序的逻辑。考虑需要读取年龄并在年龄为负时终止的需要。需要为年龄分配一个非负值,以确保循环至少执行一次:

int age;
age = 1;
Scanner scanner = new Scanner(System.in);
while (age > 0) {
   System.out.print("Enter an age: ");
   age = scanner.nextInt();
   // use the age
}

另一个选择是在循环开始之前复制用户提示和用于读取年龄的语句:

System.out.print("Enter an age: ");
age = scanner.nextInt();
while (age > 0) {
   System.out.print("Enter an age: ");
   age = scanner.nextInt();
   // use the age
}

在循环开始之前,要么需要为年龄分配一个任意值,要么需要复制代码。这两种方法都不令人满意。

然而,使用无限循环会导致更清晰的代码。不需要分配任意值,也不需要复制代码:

while (true) {
   System.out.print("Enter an age: ");
   age = scanner.nextInt();
   if (age < 0) {
      break;
   }
   // use the age
}

虽然有许多情况下无限循环是可取的,但程序员在不小心的情况下也可能出现无限循环,导致意想不到的结果。一个常见的方式是构建一个没有有效终止条件的 for 循环,如下所示:

for(int i = 1; i > 0; i++) {
   // Body
}

循环从i的值为 1 开始,并且在每次循环迭代时递增i。终止条件表明循环不会终止,因为i只会变得更大,因此总是大于 0。然而,最终变量会溢出,i会变成负数,循环将终止。这需要多长时间取决于机器的执行速度。

故事的寓意是,“小心循环”。无限循环既可以是解决某些问题的有用构造,也可以是在无意中使用时出现问题的构造。

时间就是一切

一个常见的编程需求是执行某种求和。我们在之前的例子中已经计算了几个数字序列的和。虽然求和过程相对简单,但对于新手程序员来说可能会很困难。更重要的是,我们将在这里使用它来提供对编程过程的洞察。

编程本质上是一个抽象的过程。程序员需要查看静态代码清单并推断其动态执行。这对很多人来说可能很困难。帮助开发人员编写代码的一种方法是考虑以下三个问题:

  • 我们想要做什么?

  • 我们想要怎么做?

  • 我们想要做什么时候?

在这里,我们将询问并应用这三个问题的答案来解决求和问题。然而,这些问题同样适用于其他编程问题。

让我们专注于计算一组学生的平均年龄,这将涉及到求和过程。假设年龄存储在age数组中,然后按照以下代码片段中所示进行初始化:

final int size = 5;
int age[] = new int[size];
int total;
float average;

age[0] = 23;
age[1] = 18;
age[2] = 19;
age[3] = 18;
age[4] = 21;

然后求和可以如下计算:

total = 0;
for (int number : age) {
   total = total + number;
}
average = total / (age.length * 1.0f);

请注意,total明确地被赋予了零值。for 循环的每次迭代将把下一个age添加到total中。在循环完成时,total将被数组的长度乘以1.0f来计算平均值。通过使用数组长度,如果数组大小发生变化,代码表达式就不需要更改。乘以1.0f是为了避免整数除法。以下表格说明了循环执行时变量的值:

循环计数 i total
0 - 0
1 1 23
2 2 41
3 3 60
4 4 78
5 5 99

让我们从以下三个基本问题的角度来考虑这个问题:

  • 我们想要做什么:我们想要计算一个部门的总工资。

  • 我们想要如何做:这有一个多部分的答案。我们知道我们需要一个变量来保存总工资,total,并且它需要初始化为 0。

total = 0;

我们还了解到计算累积和的基本操作如下:

total = total + number;

循环需要使用数组的每个元素,因此使用 for-each 语句:

for (int number : age) {
   …
}

我们已经为解决问题奠定了基础。

  • 我们想要什么时候做:在这种情况下,“何时”暗示着三个基本选择:

  • 在循环之前

  • 在循环中

  • 在循环之后

我们的解决方案的三个部分可以以不同的方式组合。基本操作需要在循环内部,因为它需要执行多次。只执行一次基本操作将不会得到我们喜欢的答案。

变量total需要初始化为 0。如何做到这一点?我们通过使用赋值语句来实现。这应该在循环之前、之中还是之后完成?在循环之后这样做是愚蠢的。当循环完成时,total应该包含答案,而不是零。如果我们在循环内部将其初始化为 0,那么在循环的每次迭代中,total都会被重置为 0。这让我们只能选择在循环之前放置该语句作为唯一有意义的选项。我们想要做的第一件事是将total赋值为 0。

大多数问题的解决方案似乎总是有所变化。例如,我们可以使用 while 循环而不是 for-each 循环。+=运算符可以用来简化基本操作。使用这些技术的一个潜在解决方案引入了一个索引变量:

int index = 0;
total = 0;

while(index < age.length) {
   total += age[index++];
}
average = total / (age.length * 1.0f);

显然,并不总是有一个特定问题的最佳解决方案。这使得编程过程既是一种创造性的,也是一种潜在有趣的活动。

陷阱

与大多数编程结构一样,循环也有自己一套潜在的陷阱。在本节中,我们将讨论可能对不慎的开发人员造成问题的领域。

当程序员在每个语句后使用分号时,常见的问题之一是出现问题。例如,以下语句由于额外的分号导致无限循环:

int i = 1;
while(i < 10) ;
  i++;

单独一行上的分号是空语句。这个语句什么也不做。然而,在这个例子中,它构成了 while 循环的主体部分。增量语句不是 while 循环的一部分。它是跟在 while 循环后面的第一个语句。缩进虽然是可取的,但并不会使语句成为循环的一部分。因此,i永远不会增加,逻辑控制表达式将始终返回 true。

在循环的主体部分没有使用块语句可能会导致问题。在下面的例子中,我们尝试计算从 1 到 5 的数字的乘积的总和。然而,这并不起作用,因为循环的主体部分只包括了乘积的计算。当求和语句缩进时,它不是循环的主体部分,只会执行一次:

int sum = 0;
int product = 0;
for(int i = 1; i <= 5; i++)
   product = i * i;;
   sum += product;

循环的正确实现如下所示使用块语句:

int sum = 0;
int product = 0;
for(int i = 1; i <= 5; i++) {
   product = i * i;;
   sum += product;
}

注意

在循环的主体中始终使用块语句是一个很好的策略,即使主体只包含一个语句。

在以下序列中,循环的主体由多个语句组成。然而,i从未递增。这也将导致无限循环,除非限制之一被更改或遇到 break 语句:

int i = 0;
while(i<limit) {
  // Process i
}

即使是看似简单的循环,如果对浮点运算不小心,实际上可能是无限循环。在这个例子中,每次循环迭代时,x都会加上0.1。循环应该在x恰好等于1.1时停止。这永远不会发生,因为某些值的浮点数存储方式存在问题:

float x = 0.1f;
while (x != 1.1) {
   System.out.printf("x = %f%n", x);
   x = x + 0.1f;
}

在二进制基数中,0.1不能被精确存储,就像分数 1/3 的十进制等价物无法被精确表示一样(0.333333…)。将这个数字重复添加到x的结果将导致一个不完全等于1.1的数字。比较x != 1.1将返回 true,循环永远不会结束。printf语句的输出不显示这种差异:

…
x = 0.900000
x = 1.000000
x = 1.100000
x = 1.200000
x = 1.300000
…

在处理涉及自动装箱的操作时要小心。根据实现方式,如果频繁发生装箱和拆箱,可能会导致性能损失。

虽然不一定是陷阱,但要记住逻辑表达式可能会短路。也就是说,逻辑 AND 或 OR 操作的最后部分可能不会被评估,这取决于从第一部分评估返回的值。这在第三章的短路评估部分中有详细讨论,决策结构

注意

请记住,数组、字符串和大多数集合都是从零开始的。忘记从0开始循环将忽略第一个元素。

始终使用块语句作为循环的主体。

总结

在本章中,我们研究了 Java 为循环提供的支持。我们已经说明了 for、for-each、while 和 do-while 语句的使用。这些演示提供了正确使用它们的见解,以及何时应该使用它们,何时不应该使用它们。

展示了使用 break 和 continue 语句,以及标签的使用。我们看到了 break 语句的实用性,特别是在支持无限循环时。标签虽然应该避免使用,但在跳出嵌套循环时可能会有用。

研究了各种陷阱,并研究了总结过程的创建,以深入了解一般编程问题。具体而言,它解决了代码段应放置在何处的问题。

现在我们已经了解了循环,我们准备研究类、方法和数据封装的创建,这是下一章的主题。

认证目标涵盖

在本章中,我们解决了以下认证目标:

  • 创建和使用 while 循环

  • 创建和使用 for 循环,包括增强的 for 循环

  • 创建和使用 do/while 循环

  • 比较循环结构

  • 使用 break 和 continue

此外,我们还提供了对这些目标的额外覆盖:

  • 定义变量的作用域

  • 使用运算符和决策结构

  • 声明和使用 ArrayList

测试你的知识

  1. 给定以下声明,哪个语句将编译?
int i = 5;
int j = 10;

a. while(i < j) {}

b. while(i) {}

c. while(i = 5) {}

d. while((i = 12)!=5) {}

  1. 给定数组的以下声明,哪个语句将显示数组的每个元素?
int arr[] = {1,2,3,4,5};

a. for(int n : arr[]) { System.out.println(n); }

b. for(int n : arr) { System.out.println(n); }

c. for(int n=1; n < 6; n++) { System.out.println(arr[n]); }

d. for(int n=1; n <= 5; n++) { System.out.println(arr[n]); }

  1. 以下哪个 do/while 循环将在没有错误的情况下编译?

a.

int i = 0;
do {
	System.out.println(i++);
} while (i < 5);

b.

int i = 0;
do
	System.out.println(i++);
while (i < 5);

c.

int i = 0;
do 
	System.out.println(i++);
while i < 5;

d.

i = 0;
do
	System.out.println(i);
	i++;
while (i < 5);

  1. 以下哪个循环是等价的?

a.

for(String n : list) { 
	System.out.println(n);
}

b.

for(int n = 0; n < list.size(); n++ ){ 
	System.out.println(list.get(n));
}

c.

Iterator it = list.iterator();
while(it.hasNext()) {
	System.out.println(it.next());
}

  1. 以下代码将输出什么?
int i;
int j;
for (i=1; i < 4; i++) {
   for (j=2; j < 4; j++) {
      if (j == 3) {
         continue;
      }
      System.out.println("i: " + i + " j: " + j);
   }
}

a. i: 1 j: 2i: 2 j: 2i: 3 j: 2

b. i: 1 j: 3i: 2 j: 3i: 3 j: 3

c. i: 1 j: 1i: 2 j: 1i: 3 j: 1

第六章:类,构造函数和方法

面向对象编程的核心是类和从类创建的对象。对象的初始化发生在构造函数中,而对象状态的修改通过方法进行。这些构造函数和方法的封装是数据封装的重点。本章讨论了类,构造函数,方法和数据封装的基础知识。

我们从介绍类开始,包括如何在内存中管理对象。然后介绍了构造函数和方法的共同方面,包括签名的概念,参数的传递以及this关键字的用法。

讨论了构造函数的使用,包括默认构造函数,它们如何重载以及私有构造函数的使用。还介绍了 Java 初始化顺序,包括初始化程序列表的使用。

解释了方法及其用法,包括如何重载它们以及创建访问器和修改器方法。本章最后讨论了静态方法和实例方法。

是数据结构的定义,以及对它们进行操作的动作,通常对应于现实世界的对象或概念。类只定义一次,但不会直接在应用程序中使用。相反,基于类创建(实例化)对象,并为对象分配内存。

在本章中,我们将使用Employee类来说明构造函数和方法的用法。该类的一部分如下所示:

public class Employee {
    private String name;
    private int zip;
    private int age;
   …
}

这个定义将被扩展,以解释与类和对象相关的概念和技术。

对象创建

使用new关键字创建对象。该关键字与类名一起使用,导致为对象从堆中分配内存。堆是内存的一个区域,通常位于堆栈上方,如第二章中的堆栈和堆部分所述,Java 数据类型及其使用

使用new关键字实例化新对象时:

  • 为类的新实例分配内存

  • 然后调用构造函数来初始化对象

  • 返回对象的引用

在下面的例子中,创建了Employee类的两个实例,并将引用分配给引用变量employee1employee2

Employee employee1 = new Employee();
Employee employee2 = new Employee();

类的每个实例都有自己独立的实例变量集。这在下图中显示。请注意,类的两个实例都包含它们自己的实例变量的副本:

对象创建

当创建一个新对象时,会执行该对象的构造函数。构造函数的目的是初始化一个对象。这个过程在构造函数部分有详细介绍。类的方法在类的实例之间是共享的。也就是说,方法只有一个副本。

内存管理

Java 内存管理是动态和自动的。当使用new关键字时,它会自动在堆上分配内存。

在下面的例子中,创建了Employee类的一个实例,并将其分配给employee1变量。接下来,将employee2变量赋值为employee1变量的值。这种赋值的效果是两个引用变量都指向同一个对象:

Employee employee1 = new Employee();
Employee employee2 = employee1;

下面的图表说明了这一点:

内存管理

引用变量可以通过以下方式取消引用对象的实例:

  • 被重新分配给另一个对象

  • 将其设置为 null

当垃圾收集器确定没有引用指向它时,对象就有资格被垃圾收集线程从堆中移除,并且它的内存可以被重新使用。这个垃圾收集过程基本上是应用程序无法控制的。

数据封装

数据封装涉及隐藏程序员不相关的信息,并公开相关信息。隐藏实现细节允许更改而不影响程序的其他部分。例如,如果程序员想要在屏幕上显示一个矩形,可以使用几种方法。可能涉及逐像素绘制矩形或绘制一系列线条。隐藏操作的细节称为数据封装。

数据封装的主要目的是降低软件开发的复杂性。通过隐藏执行操作所需的细节,使用该操作变得更简单。该方法的使用并不复杂,因为用户不必担心其实现的细节。用户可以专注于它的功能,而不是它的实现方式。这反过来又使开发人员能够做更多事情。

例如,考虑Employee类的实现。最初,实例变量都声明为私有:

public class Employee {
    public String name;
    private int age;

   ...

    public int getAge() {
        return age;
    }

    private void setAge(int age) {
        this.age = age;
    }

}

name变量的访问修饰符类型已更改为 public,setAge方法的访问修饰符已更改为 private。这意味着类的任何用户都可以访问name字段,但他们只能读取员工的age。当我们明确决定应该向类的用户公开什么和不公开什么时,数据封装就会受到影响。

类及其实现的细节应该对用户隐藏。这允许修改类内部的实现而不改变类的公共方面。通常情况下,实例变量被设置为私有,方法被设置为公共。根据类的需求,可以对此规则进行例外处理。

还可以控制对构造函数的访问。这个主题在构造函数部分有所涉及。

引用实例变量

引用变量保存对对象的引用或指针。通过在对象引用变量名称后跟一个句点,然后是字段或方法名称,可以访问对象的字段或变量。以下代码片段说明了基于前一节中Employee声明的Employee类的可能引用:

Employee employee = new Employee();
int employeeAge = employee.getAge(24);
String employeeName = employee.name;

请注意,我们没有使用age字段,因为它被声明为Employee类的私有字段。修饰符的使用在第一章的访问修饰符部分中有所涉及,Java 入门

签名

构造函数或方法的签名用于唯一标识构造函数或方法。签名由以下组成:

  • 方法或构造函数名称

  • 参数的数量

  • 参数的类型

  • 参数的顺序

同一类中的所有构造函数或方法必须具有唯一的签名。请注意,方法的返回类型不是签名的一部分。以下表格显示了重载Employee类构造函数的签名。第三个和第四个构造函数在构造函数参数的顺序上有所不同。如果同一类中有多个具有相同名称但具有不同签名的方法或构造函数,则称该方法或构造函数被重载:

方法 参数数量 参数类型
Employee() 0
Employee(String name) 1 String
Employee(String name, int zip) 2 String, int
Employee(int zip, String name) 2 int, String
Employee(String name, int zip, int age) 3 String, int, int

使用 this 关键字

this关键字有四种用途:

  • 执行构造函数链接

  • 访问实例变量

  • 将当前对象传递给方法

  • 从方法返回当前对象

构造函数链接在重载构造函数部分进行了讨论。让我们来看一下使用this关键字访问实例变量的用法。setAge方法可以实现如下:

public class Employee {
    public String name;
    private int age;
   ...

    private void setAge(int age) {
        age = age;
    }

}

这段代码不会产生修改age实例变量的预期结果。实例变量的作用域是整个类。参数的作用域仅限于方法。参数将优先于实例变量。结果是传递给方法的年龄被分配给自己。实例变量没有被修改。

纠正这个问题有两种方法:

  • 更改参数名称

  • 使用this关键字

我们可以更改参数的名称。然而,为同一事物设计不同的名称会导致奇怪或尴尬的名称。例如,我们可以使用以下方法:

public class Employee {
  private int age;
     …
    private void setAge(int initialAge) {
        age = initialAge;
    }

}

initialAge参数将被分配为成员变量age的初始值。然而,也可以使用任意数量的其他可能有意义的名称。对于这种类型的参数,没有标准的命名约定。

另一种方法是使用final关键字将参数声明为常量,如下面的代码片段所示。当采用这种方法时,会生成语法错误,因为我们试图修改参数。由于它是常量,我们无法更改它:

public void setAge(final int age) {
   age = age;
}

生成的语法错误消息如下:

final parameter age may not be assigned

Assignment To Itself

首选方法是使用this关键字明确指定成员变量和参数。下面是一个示例:

public class Employee {
  private int age;
   …
  private void setAge(int age) {
      this.age = age;
  }

}

在这个赋值语句中,我们使用this关键字和一个句点作为成员变量的前缀。考虑以下语句:

       this.age = age;

this关键字引用了赋值语句左侧的age实例变量。在右侧,使用了age参数。因此,参数被分配给实例变量。使用this关键字避免了为参数分配给成员变量而设计一些非标准且可能令人困惑的名称。

this关键字也可以用于传递或返回对当前对象的引用。在下面的序列中,假定validateEmployee方法是Employee类的成员。如果满足条件,则当前员工,由this关键字标识,将被添加到一个维护部门信息的类中,该类由department变量引用。对当前对象的引用被传递给add方法:

private Department department;
   …
private void validateEmployee() {
   if(someCondition) {
      department.add(this);
   }
}

this关键字也可以用于返回对当前对象的引用。在下一个序列中,当前对象由假定为Employee类的getReference方法返回:

private Employee getReference() {
   …
   return this;
}

传递参数

在任何方法中可能存在两种类型的变量——参数和局部变量。参数包含在调用方法时传递的值。局部变量是方法的一部分,并用于帮助方法完成其任务。这里讨论的技术适用于构造函数和方法,尽管在本节的示例中我们只使用方法。

参数作为参数列表的一部分传递。此列表使用逗号来分隔参数的类型和名称的声明。例如,以下代码片段中的方法传递了两个参数——一个整数和一个字符串:

public void passParameters(int number, String label) {
   …
}

原始数据类型或对象被传递给方法。以下术语用于标识被传递的数据:

  • 参数:被传递的变量

  • 参数:这是在方法签名中定义的元素

例如,在以下代码序列中,numberemployee1是参数,而numemployeechangeValues方法的相应参数:

public static void main(String[] args) {
   int number = 10;
   Employee employee1 = new Employee();
   changeValues(number, employee1);
   …
}

private static void changeValues(int num, 
   Employee employee) {
   …
}

在 Java 中,只有原始数据类型和对象引用被传递给方法或构造函数。这是使用一种称为传值的技术执行的。当调用方法时,参数被分配给参数的副本。

当传递原始数据类型时,只传递值的副本。这意味着如果在被调用的方法中更改了副本,则原始数据不会更改。

当传递引用变量时,只传递引用的副本。对象本身不会被传递或复制。此时我们有两个对同一对象的引用——参数引用变量和参数引用变量。我们可以使用任一引用变量修改对象。

我们还可以更改参数的引用。也就是说,我们可以修改参数以引用不同的对象。如果我们修改参数,我们并没有修改参数。参数和参数引用变量是不同的变量。

考虑以下程序,我们将一个整数和一个Employee对象的引用传递给changeValues方法。在方法中,我们更改整数,Employee对象的一个字段,以及employee引用变量。

public static void main(String[] args) {
   …
   int number = 10;
   employee = new Employee();
   employee.setAge(11);
   changeValues(number, employee);

   System.out.println(number);
   System.out.println(employee.getAge());

}
private static void changeValues(int num, Employee employee) {
   num = 20;
   employee.setAge(22);
   employee = new Employee();
   employee.setAge(33);
}

执行时我们得到以下输出:

10
22

注意

请注意,当我们更改num参数的值时,main方法的number变量没有更改。此外,我们使用changeValues方法的employee引用变量更改了对象的age字段。但是,当我们通过创建一个新的 employee 修改了changeValues方法的employee引用变量指向的内容时,我们并没有更改main方法的employee引用变量。它仍然引用原始对象。

以下图示说明了这是如何工作的。堆栈和堆反映了应用程序在启动changeValues方法时和在它返回之前的状态。为简单起见,我们忽略了args变量:

传递参数

通过值传递对象是一种高效的参数传递技术。它是高效的,因为我们不复制整个对象。我们只复制对象的引用。

可变数量的参数

可以将可变数量的参数传递给方法。但是,有一些限制:

  • 可变数量的参数必须都是相同的类型

  • 它们在方法中被视为数组

  • 它们必须是方法的最后一个参数

要理解这些限制,请考虑以下代码片段中使用的方法,用于返回整数列表中的最大整数:

private static int largest(int... numbers) {
   int currentLargest = numbers[0];
   for (int number : numbers) {
      if (number > currentLargest) {
         currentLargest = number;
      }
   }
   return currentLargest;
}

不需要将具有可变数量参数的方法声明为静态。我们这样做是为了可以从静态的main方法中调用它。在以下代码序列中,我们调用该方法两次:

System.out.println(largest(12, -12, 45, 4, 345, 23, 49));
System.out.println(largest(-43, -12, -705, -48, -3));

输出如下:

345
-3

largest方法将第一个参数,numbers数组的第一个元素,分配给currentLargest。它假设最大的数字是第一个参数。如果不是,那么它最终会被替换。这避免了将最小可能值分配给currentLargest变量。

注意

最大和最小的整数分别在Integer类中定义为Integer.MAX_VALUEInteger.MIN_VALUE

我们使用 for-each 语句将 numbers 数组的每个元素与最大变量进行比较。如果数字更大,那么我们用该数字替换largest。for-each 语句在第五章的for-each 语句部分详细说明了循环结构。

如果我们不带参数调用该方法,如下所示:

System.out.println(largest());

程序将执行,但会生成ArrayIndexOutOfBoundsException异常。这是因为我们尝试在方法中访问数组的第一个元素,但该数组为空,因此不存在第一个元素。如果在方法中没有引用第一个元素,这个问题就不会发生。也就是说,在大多数情况下,使用可变数量的参数的方法可以不带参数调用。

我们可以实现一个largest方法的版本,处理没有传递参数的情况。然而,当没有传递任何内容时,返回值应该是什么?我们返回的任何值都会暗示该数字是最大的,而实际上并没有最大的数字。我们能做的最好可能就是返回一个反映这个问题的异常。然而,这实际上就是当前版本所做的。异常ArrayIndexOutOfBoundsException可能不如自定义异常有意义。

我们可以在具有可变数量参数的方法中使用其他参数。在下面的示例中,我们将一个字符串和零个或多个浮点数传递给displayAspects方法。该方法的目的是显示由第一个参数标识的元素的信息:

private static void displayAspects(String item, 
    float... aspects) {
   ...    
}

以下代码是方法可能被调用的示例:

displayAspects("Europa", 2.3f, 56.005f, 0.0034f);

注意

可变参数必须是相同类型,并且必须是参数列表中的最后一个参数。

不可变对象

不可变对象是其状态无法更改的对象。所谓状态,是指其成员变量的值。这些类型的对象可以简化应用程序,并且更不容易出错。JDK 核心中有几个不可变的类,包括String类。

创建不可变对象:

  • 使类变为 final,这意味着它不能被扩展(在第七章的使用 final 关键字与类部分中有介绍,继承和多态

  • 将类的字段保持为私有,最好是 final

d. 不提供任何修改对象状态的方法,即不提供 setter 或类似的方法

d. 不允许可变字段对象被更改

以下是一个表示页面标题的不可变类的声明示例:

package packt;

import java.util.Date;

final public class Header {
    private final String title;
    private final int version;
    private final Date date;

    public Date getDate() {
        return new Date(date.getTime());
    }

    public String getTitle() {
        return title;
    }

    public int getVersion() {
        return version;
    }

    public Header(String title, int version, Date date) {
        this.title = title;
        this.version = version;
        this.date = new Date(date.getTime());
    }

    public String toString() {
        return  "Title: " + this.title + "\n" +
                "Version: " + this.version + "\n" +
                "Date: " + this.date + "\n";
    }
}

注意getDate方法创建了一个基于标题的date字段的新Date对象。任何Date对象都是可变的,因此通过返回日期的副本而不是当前日期的引用,用户无法访问和修改私有字段。三参数构造函数也使用了相同的方法。

构造函数

构造函数用于初始化类的成员变量。当创建对象时,为对象分配内存,并执行类的构造函数。这通常使用new关键字来实现。

初始化对象的实例变量是重要的。开发人员的责任之一是确保对象的状态始终有效。为了协助这一过程,构造函数在创建对象时执行。

另一种方法是使用初始化方法,程序员应该在创建对象后调用该方法。然而,使用这种初始化方法并不是一种万无一失的技术。程序员可能不知道该方法的存在,或者可能忘记调用该方法。为了避免这类问题,当创建对象时会自动调用构造函数。

构造函数的重要特点包括:

  • 构造函数与类名相同

  • 构造函数重载是允许的

  • 构造函数不是方法

  • 构造函数没有返回类型,甚至没有 void

下面的代码片段说明了如何定义构造函数。在这个例子中,定义了三个重载的构造函数。目前,我们省略了它们的主体。这些构造函数的目的是初始化组成类的三个实例变量:

public class Employee {
   private String name;
   private int zip;
   private int age;

   public Employee() {

   }

   public Employee(String name) {

   }

   public Employee(String name, int zip) {

   }

}

默认构造函数

通常类都会有默认构造函数。如果一个类没有显式声明任何构造函数,它会自动拥有一个默认构造函数。默认构造函数是一个没有参数的构造函数。下面的代码片段演示了Employee类中没有定义构造函数的情况:

public class Employee {
   private String name;
   private int zip;
   private int age;

   …

}

默认构造函数本质上会将其实例变量初始化为 0,就像第二章中的初始化标识符部分所解释的那样,Java 数据类型及其使用。分配给成员变量的值在下表中找到,该表从第二章中的Java 数据类型及其使用部分复制过来,以方便您查阅:

数据类型 默认值(对于字段)
布尔值 false
字节 0
字符 '\u0000'
短整型 0
整型 0
长整型 0L
浮点数 0.0f
双精度 0.0d
字符串(或任何对象) null

然而,我们也可以添加一个显式的默认构造函数,如下面的代码片段所示。默认构造函数是一个没有参数的构造函数。正如我们所看到的,我们可以自由地将类的字段初始化为我们选择的任何值。对于我们没有初始化的字段,JVM 将会像上面详细说明的那样将它们初始化为零:

public Employee() {
    this.name = "Default name";
    this.zip = 12345;
    this.age = 21;
}

注意使用this关键字。在这个上下文中,它用于明确指定紧随其后的变量是类成员变量,而不是其他局部变量。在这里,没有其他可能引起混淆的变量。this关键字在使用 this 关键字部分有详细介绍。在成员变量中使用this关键字是一种常见做法。

如果程序员向类添加了构造函数,那么该类将不再自动添加默认构造函数。程序员必须显式为类添加一个默认构造函数。在下面的Employee类的声明中,省略了默认构造函数:

public class Employee {
   private String name;
   private int zip;
   private int age;
   public Employee(String name) {

   }

   …

}

如果我们尝试使用默认构造函数创建对象,如下面的代码片段所示,那么我们将会得到一个语法错误:

Employee employee1 = new Employee();

生成的错误消息如下:

no suitable constructor found for Employee()

注意

一般规则是,总是为类添加一个默认构造函数。当类是一个基类时,这一点尤为重要。

构造函数的重载

构造函数可以被重载。通过重载构造函数,我们为类的用户提供了更多创建对象的灵活性。这可以简化开发过程。

重载的构造函数具有相同的名称但不同的签名。签名的定义在之前讨论的签名部分中提供。在Employee类的以下版本中,我们提供了四个构造函数。请注意,每个构造函数为那些没有通过构造函数传递的成员变量分配了默认值:

public class Employee {
   private String name;
   private int zip;
   private int age;

   public Employee() {
       this.name = "Default name";
       this.zip = 12345;
       this.age = 21;
   }
   public Employee(String name) {
       this.name = name;
       this.zip = 12345;
       this.age = 21;
   }
   public Employee(String name, int zip) {
       this.name = name;
       this.zip = zip;
       this.age = 21;
   }

   public Employee(String name, int zip, int age) {
       this.name = name;
       this.zip = zip;
       this.age = age;
   }

}

这个例子在构造函数之间重复了工作。另一种方法如下所示,使用this关键字来减少这种重复的工作并简化整个过程:

public class Employee {
   private String name;
   private int zip;
   private int age;

   public Employee() {
       this("Default name", 12345, 21);
   }

   public Employee(String name) {
       this(name, 12345, 21);
   }

   public Employee(String name, int zip) {
       this(name, zip, 21);
   }

   public Employee(String name, int zip, int age) {
       this.name = name;
       this.zip = zip;
       this.age = age;
   }
}

在这种情况下,this关键字用于构造函数的参数列表开头。其效果是调用与使用的签名匹配的同一类构造函数。在这个例子中,前三个构造函数中的每一个都调用最后一个构造函数。这被称为构造函数链。所有的工作都是在最后一个构造函数中完成的,减少了重复工作的量和出错的机会,特别是当添加新字段时。

如果在构造函数中对字段变量进行赋值之前进行检查,这样会更加高效。例如,如果我们需要验证名称是否符合特定的命名标准,只需要在一个位置执行,而不是在每个传递名称的构造函数中执行。

私有构造函数

可以将构造函数声明为私有,以便将其隐藏。这样做可能是为了:

  • 限制对类的某些构造函数的访问,而不是全部

  • 隐藏所有构造函数

在某些情况下,我们可能希望将构造函数设置为私有或受保护(参见第七章中的继承和多态,讨论protected关键字)以限制对某些初始化序列的访问。例如,私有构造函数可以用于以较不严格的方式初始化类的字段。由于我们从其他构造函数中调用构造函数,我们可能更加确信被赋值的值,并且不觉得需要对其参数进行广泛的检查。

在某些情况下,我们可能希望将所有构造函数声明为私有。这将限制用户通过类的公共方法创建对象。java.util.Calendar类就是这样一个例子。获取此类的实例的唯一方法是使用其静态的getInstance方法。

私有构造函数的使用用于控制应用程序可以创建的类实例的数量。单例设计模式规定一个类只能创建一个实例。这种设计模式可以通过将所有构造函数设为私有,并提供一个公共的getInstance方法来创建类的单个实例来支持。

以下是Employee类的这种方法的示例。构造函数被设置为私有,getInstance方法确保只创建一个对象:

public class Employee {
   private static Employee instance = null;
   private String name;
   private int zip;
   private int age;

   private Employee instance = null;
   ...

   private Employee() {
      this.name = "Default name";
      this.zip = 12345;
      this.age = 21;
   }

   public Employee getInstance() {
      if(instance == null) {
         instance = new Employee();
      }
      return instance;
   }

   ...
}

第一次调用getInstance方法时,instance变量为 null,这导致创建一个新的Employee对象。在对getInstance方法的后续调用中,instance将不为 null,不会创建新的Employee对象。而是返回对单个对象的当前引用。

构造函数问题

如果一个“构造函数”有返回类型,实际上它是一个方法,恰好与类名相同。即使返回类型是void,也是如此,如下面的代码片段所示:

public void Employee(String name) {

}

我们可以创建Employee类的新实例,然后对该对象应用Employee方法,如下面的代码片段所示:

Employee employee = new Employee();
employee.Employee("Calling a method");

虽然这是合法的,但不是良好的风格,可能会令人困惑。此外,正如我们在第一章中看到的Java 命名约定部分,方法的命名约定建议方法名的初始单词应以小写字母开头。

Java 初始化序列

构造函数涉及对象字段的初始化。然而,还有两种方法可以用来补充构造函数的使用。第一种是使用实例变量初始化器。使用Employee类,我们可以将年龄初始化为 21,如下所示:

public class Employee {
   ...
   private int age = 21;

   ...

}

如果我们以这种方式初始化实例变量,就不必在构造函数中初始化它。

第二种方法是使用初始化块。这种类型的块在构造函数执行之前执行。下面的代码片段说明了这种方法:

public class Employee {
   ...
   private int age;

   // Initialization block
   {
      age = 31;
   }
   ...
}

初始化块在需要更复杂的初始化序列时非常有用,这是无法通过更简单的实例变量初始化器支持的。这种初始化也可以在构造函数中执行。

因此,有几种初始化成员变量的方法。如果我们使用其中一种或多种技术来初始化相同的变量,那么我们可能会想知道它们的执行顺序。实际的初始化顺序比这里描述的要复杂一些。但是,一般的顺序如下:

  1. 在实例化对象时执行字段的清零

  2. final 和静态变量的初始化

  3. 分配实例变量初始化器

  4. 初始化块的执行

  5. 构造函数中的代码

有关初始化顺序的更多详细信息可以在 Java 语言规范(docs.oracle.com/javase/specs/)中找到。

方法

方法是一组语句,用于完成特定的任务。方法具有返回值、名称、一组参数和一个主体。参数被传递给方法,并用于执行操作。如果要从方法返回一个值,则使用返回语句。一个方法可能有零个或多个返回语句。返回void的方法可能使用返回语句,但该语句没有参数。

定义方法

方法是作为类定义的一部分来定义的,通常在实例变量的声明之后。方法声明指定了返回类型。返回类型void表示该方法不返回值。

注意

Java 方法的命名约定指定第一个单词不大写,但后续的单词大写。方法名应该是动词。

在以下示例中,该方法返回boolean,并传递了两个整数参数:

public boolean isAgeInRange(int startAge, int endAge) {
    return (startAge <= age) && (age <= endAge);
}

同一程序中的所有方法必须具有唯一的签名。签名在前面的签名部分中有讨论。请注意,方法的返回类型不是签名的一部分。例如,考虑以下代码片段中的声明:

   public int getAgeInMonths() {
      …
   }

   public float getAgeInMonths() {
      …
   }

这两种方法的签名是相同的。返回类型不被使用。如果我们尝试在Employee类中声明这两种方法,将会得到以下语法错误消息:

getAgeInMonths() is already defined in packt.Employee

调用方法

调用方法的语法看起来类似于使用实例变量。实例方法将始终针对一个对象执行。正常的语法使用对象的名称,后跟一个句点,然后是方法的名称和任何需要的参数。在以下示例中,getAgeInMonths方法针对employee引用变量被调用:

Employee employee = new Employee();
System.out.println(employee.getAgeInMonths());

静态方法可以使用类名或对象来调用。考虑以下静态变量entityCode的声明:

public class Employee {
    // static variables
    private static int entityCode;

    public static void setEntityCode(int entityCode) {
        Employee.entityCode = entityCode;
    }
   ...
}

以下代码片段中的两个方法调用都将调用相同的方法:

Employee employee = new Employee();
employee.setEntityCode(42);
Employee.setEntityCode(42);

但是,使用引用变量调用静态方法并不是一个好的做法。而应该始终使用类名。尝试使用对象将导致以下语法警告:

Accessing static method setEntityCode

注意

静态方法在实例和静态类成员部分有详细说明。

如果不向方法传递参数,则参数列表可以为空。在以下简化的方法中,返回员工的年龄(以月为单位)。没有向方法传递参数,并返回一个整数。该方法被简化了,因为实际的值需要考虑员工的出生日期和当前日期:

public int getAgeInMonths() {
    int months = age*12;
    return months;
}

方法的重载

Java 允许具有相同名称的多个方法。这为实现参数类型不同的方法提供了一种便捷的技术。重载的方法都具有相同的方法名称。这些方法的区别在于每个重载的方法必须具有唯一的签名。签名在前面的签名部分中有讨论。请记住,方法的返回类型不是签名的一部分。

以下代码片段说明了方法的重载:

int max(int, int);
int max(int, int, int);  // Different number of parameters
int max(int …);         // Varying number of arguments
int max(int, float);    // Different type of parameters
int max(float, int)    // Different order of parameters

在调用重载方法时必须小心,因为编译器可能无法确定使用哪个方法。考虑以下 max 方法的声明:

class OverloadingDemo {

    public int max(int n1, int n2, int n3) {
        return 0;
    }

    public float max(long n1, long n2, long n3) {
        return 0.0f;
    }

    public float max(float n1, float n2) {
        return 0.0f;
    }
}

以下代码序列说明了会给编译器带来问题的情况:

int num;
float result;
OverloadingDemo demo = new OverloadingDemo();
num = demo.max(45, 98, 2);
num = demo.max(45L, 98L, 2L);		// assignment issue
result = demo.max(45L, 98L, 2L);
num = demo.max(45, 98, 2L);       // assignment issue
result = demo.max(45, 98, 2L);
result = demo.max(45.0f, 0.056f);
result = demo.max(45.0, 0.056f);  // Overload problem

第二个和第四个赋值语句将与三个长参数方法调用匹配。这对于第二个是预期的。对于第四个赋值,只有一个参数是长整型,但它仍然使用了三个长参数方法。这些赋值的问题在于该方法返回 long 而不是 int。它无法将浮点值分配给 int 变量而不会丢失精度,如以下语法错误消息所示:

possible loss of precision
  required: int
  found:    float

最后一个赋值找不到可接受的重载方法。以下语法错误消息结果如下:

no suitable method found for max(double,float)

注意

与重载密切相关的是重写方法的过程。通过重写,两个方法的签名是相同的,但它们位于不同的类中。这个主题在第七章的继承和多态部分中有所涉及。

访问器/修改器

访问器方法是读取或访问类的变量的方法。修改器方法是修改类的变量的方法。这些方法通常是公共的,变量通常声明为私有的。这是数据封装的重要部分。私有数据对用户隐藏,但通过方法提供访问。

访问器和修改器方法应使用一致的命名约定。该约定使用私有成员变量名称作为基础,并在基础前加上 get 或 set 前缀。get 方法返回变量的值,而 set 方法接受一个参数,该参数被分配给私有变量。在这两种方法中,成员变量名称都是大写的。

这种方法用于 Employee 类的私有 age 字段:

public class Employee {
   ...
   private int age;
   ...
   public int getAge() {
      return age;
   }

   public void setAge(int age) {
      this.age = age;
   }
}

注意 getAge 的返回类型是 int,也是 setAge 方法的参数类型。这是访问器和修改器的标准格式。访问器方法通常被称为 getters,修改器方法被称为 setters。

私有数据通常通过将其设置为私有并提供公共方法来访问而进行封装。具有私有或不存在设置器的字段被称为只读字段。具有私有或不存在获取器的字段被称为只写字段,但不太常见。获取器和设置器的主要原因是限制访问并对字段进行额外处理。

例如,我们可能有一个 getWidth 方法,返回 Rectangle 类的宽度。但是,返回的值可能取决于所使用的测量单位。它可能根据另一个测量单位字段设置为英寸、厘米或像素而返回一个值。在一个安全意识强的环境中,我们可能希望限制可以根据用户或者时间来读取或写入的内容。

实例和静态类成员

有两种类型的变量或方法:

  • 实例

  • 静态

实例变量声明为类的一部分,并与对象关联。静态变量以相同的方式声明,只是在前面加上 static 关键字。当创建对象时,它有自己的一组实例变量。但是,所有对象共享静态变量的单个副本。

有时,有一个可以被所有类实例共享和访问的单个变量是有意义的。当与变量一起使用时,它被称为类变量,并且仅限于类本身。

考虑以下 Employee 类:

public class Employee {
    // static variables
    private static int minimumAge;

    // instance variables
    private String name;
    private int zip;
    private int age;

   ...
}

每个Employee对象都将有自己的namezipage变量的副本。所有Employee对象可能共享相同的minimumAge变量。使用单个变量的副本确保了类的所有部分都可以访问和使用相同的值,并且节省了空间。

考虑以下代码序列:

Employee employee1 = new Employee();
Employee employee2 = new Employee();

以下图表说明了堆中两个对象的分配情况。每个对象都有自己的一组实例变量。单个静态变量显示在堆的上方,分配在自己的特殊内存区域中:

实例和静态类成员

无论方法是实例方法还是静态方法,对于一个类来说,每个方法只有一个副本。静态方法的声明方式与实例方法相同,只是在方法的声明之前加上static关键字。以下代码片段中的静态setMinimumAge方法说明了静态方法的声明:

public static void setMinimumAge(int minimumAge) {
   Employee.minimumAge = minimumAge;
}

所有实例方法必须针对一个对象执行。不可能像静态方法那样针对类名执行。实例方法旨在访问或修改实例变量。因此,它需要针对具有实例变量的对象执行。如果我们尝试针对类名执行实例方法,如下所示:

Employee.getAge();

这将导致以下语法错误消息:

non-static method getAge() cannot be referenced from a static context

静态方法可以针对对象或类名执行。静态方法可能无法访问实例变量或调用实例方法。由于静态方法可以针对类名执行,这意味着即使可能不存在任何对象,它也可以执行。如果没有对象,那么就不可能有实例变量。因此,静态方法无法访问实例变量。

静态方法可能不会调用实例方法。如果它能够访问实例方法,那么它间接地就能够访问实例变量。由于可能不存在任何对象,因此静态方法不允许调用实例方法。

实例方法可以访问静态变量或调用静态方法。静态变量始终存在。因此,实例方法应该能够访问静态变量和方法是毫无理由的。

以下表格总结了静态/实例变量和方法之间的关系:

变量 方法
实例 静态 实例 静态
实例方法 实例和静态类成员 实例和静态类成员 实例和静态类成员 实例和静态类成员
静态方法 实例和静态类成员 实例和静态类成员 实例和静态类成员 实例和静态类成员

总结

在本章中,我们考察了类的许多重要方面。这包括创建类的实例时如何管理内存,初始化过程以及如何调用方法来使用类。

构造函数和方法都涉及到几个问题。在详细讨论构造函数和方法的细节之前,我们讨论了使用this关键字、传递参数和签名。我们还举例说明了构造函数和各种初始化技术,包括这些初始化发生的顺序。还讨论了方法的声明,包括如何重载方法。

我们还考察了实例和静态变量、方法之间的区别。在整个章节中,我们阐明了内存的分配方式。

现在我们已经学习了类的基础知识,我们准备讨论继承和多态的主题,如下一章所讨论的那样。在那一章中,我们将扩展内存分配、初始化顺序,并介绍新的主题,比如重写方法。

涵盖的认证目标

本章涵盖的认证目标包括:

  • 创建带有参数和返回值的方法

  • static关键字应用于方法和字段

  • 创建重载方法

  • 区分默认构造函数和用户定义的构造函数

  • 应用访问修饰符

  • 将封装原则应用于类

  • 确定当对象引用和原始值传递到改变值的方法中时的影响

测试你的知识

  1. 以下哪个声明了一个接受浮点数和整数并返回整数数组的方法?

a. public int[] someMethod(int i, float f) { return new int[5];}

b. public int[] someMethod(int i, float f) { return new int[];}

c. public int[] someMethod(int i, float f) { return new int[i];}

d. public int []someMethod(int i, float f) { return new int[5];}

  1. 如果尝试编译和运行以下代码会发生什么?
public class SomeClass {public static void main(String arguments[]) {
      someMethod(arguments);
   }
   public void someMethod(String[] parameters) {
      System.out.println(parameters);
   }
}

a. 语法错误 - main没有正确声明。

b. 语法错误 - 变量参数不能像在println方法中那样使用。

c. 语法错误 - someMethod需要声明为静态。

d. 程序将无错误地执行。

  1. 以下关于重载方法的陈述哪些是真的?

a. 静态方法不能被重载。

b. 在重载方法时,不考虑返回值。

c. 私有方法不能被重载。

d. 重载的方法不能抛出异常。

  1. 给定以下代码,以下哪些陈述是真的?
public class SomeClass {
   public SomeClass(int i, float f) { }
   public SomeClass(float f, int i) { }
   public SomeClass(float f) { }
   public void SomeClass() { }
}

a. 由于 void 不能与构造函数一起使用,将会发生语法错误。

b. 由于前两个构造函数不是唯一的,将会发生语法错误。

c. 该类没有默认构造函数。

d. 不会生成语法错误。

  1. 在声明类时,以下关键字中哪个不能使用?

a. public

b. private

c. protected

d. package

  1. 假设以下类在同一个包中,哪些陈述是真的?
class SomeClass {void method1() { }public void method2( { }
   private void method3( { }
   protected void method4() { }
}

class demo [
   public void someMethod(String[] parameters) {SomeClass sc = new SomeClass();
      sc.method1();
      sc.method2();
      sc.method3();
      sc.method41();}
}

a. sc.method1()将生成语法错误。

b. sc.method2()将生成语法错误。

c. sc.method3()将生成语法错误。

d. sc.method4()将生成语法错误。

e. 不会生成语法错误。

  1. 以下代码的输出是什么?
public static void main(String args[]) { 
    String s = "string 1";
    int i = 5;
    someMethod1(i);
    System.out.println(i);
    someMethod2(s);
    System.out.println(s);
}

public static void someMethod1(int i) { 
    System.out.println(++i);
}
public static void someMethod2(String s) { 
    s = "string 2"; 
    System.out.println(s);
}

a. 5 5 string 2 string 1

b. 6 6 string 2 string 2

c. 5 5 string 2 string 2

d. 6 5 string 2 string 1

第七章:继承和多态性

在本章中,我们将研究 Java 如何支持包括继承和多态性在内的几个重要的面向对象的概念。当提到“继承”这个词时,我们会想到那位会留下巨额财富的富有的叔叔。或者我们会说她有她母亲的眼睛。在编程术语中,我们谈论类及它们之间的关系。术语“父类”和“子类”用于描述类之间的继承关系,其中类可以访问父类的功能。

注意

有几个术语用于指定父类和子类。您可能会看到父类被称为超类或基类。子类可能被称为子类或派生类。在本章中,我们将使用术语基类派生类

基类通常具有实现该类和从该类派生的类所需的公共功能的方法。例如,我们可能有一个代表个人的 person 类。它可能有允许我们维护个人姓名或年龄的方法。我们可能创建其他代表不同类型的人的类——屠夫、面包师或蜡烛制造商。这些不同类型的人具有超出我们为 person 类定义的功能之外的不同功能。

例如,当我们实现一个面包师时,该类可能有一个名为 cook 的方法,用于烹饪。然而,面包师仍然有姓名和年龄。我们不希望重新实现支持修改姓名或年龄的代码,而是希望重用我们为 person 类开发的代码。这个过程称为继承。

继承允许我们重用基类的功能。这反过来促进了软件的重用,并可以使开发人员更加高效。

我们还将解释 Java 如何支持多态性。这个概念很重要,有助于使应用程序更易于维护。多态性是重写基类方法的结果。重写类似于重载,但它使用与基类方法相同的签名。

多态性经常与抽象类一起使用。抽象类是一种不能被实例化的类。也就是说,不可能创建该类的实例。虽然我们不能创建抽象类的实例,但可以创建从抽象类派生的类的实例。这种能力可以增强应用程序的结构。

继承需要调用基类的构造函数。我们将研究 Java 用于控制初始化顺序的方法。此外,在某些情况下,确定类的类型和在继承层次结构中的类之间进行转换变得重要。

本章最后讨论的主题涉及与继承相关的内存组织。了解内存的组织和处理方式将加深您对语言的理解,并有助于调试应用程序。

继承

继承涉及两个类之间的关系——基类和派生类。在本节中,我们将涵盖以下内容:

  • 实现子类

  • 使用 protected 关键字

  • 重写方法

  • 使用 @Override 注解

  • 使用 final 关键字与类

  • 创建抽象方法和类

构造函数和继承的使用在 The super keyword and constructors 部分中有所涉及。

当继承发生时,派生类继承了基类的所有方法和属性。但它只能访问类的公共和受保护成员。它不能访问类的私有成员。

当向派生类添加一个与基类方法具有相同签名和可访问性的方法时,该方法被称为覆盖基类方法。这允许派生类重新定义该方法的含义。本章的示例将使用一个Employee基类和一个从基类派生的SalaryEmployee类。

实现子类

一个类是通过使用extends关键字实现的,后面跟着基类名。在下面的例子中,定义了Employee基类:

class Employee {
   // Implementation of Employee class
}

SalaryEmployee类可以从基类Employee派生,如下面的代码片段所示:

class SalaryEmployee extends Employee  {
   // Implementation of SalaryEmployee class
}

继承在 Java 库中被广泛使用。例如,小程序是通过扩展Applet类创建的。

注意

成为一名熟练的 Java 程序员的重要部分是学会找到、理解和使用与应用程序领域相关的库中的类。

在下面的例子中,HelloWorldApplet类扩展并继承了这个类的所有方法和属性。在这种情况下,paint方法被HelloWorldApplet覆盖:

import java.awt.Graphics;

public class HelloWorldApplet extends java.applet.Applet {

   public void paint (Graphics g) {
      g.drawString ("Hello World!", 5, 15);
   }

}

一个基类可以有一个或多个派生类,这是可能的,也是完全可取的。对于Employee基类,我们可能不仅创建一个SalaryEmployee类,还可能创建一个HourlyEmployee类。它们将共享基类的通用功能,但又包含自己独特的功能。

让我们仔细研究Employee基类和SalaryEmployee类。首先,让我们从Employee类的更详细实现开始,如下面的代码片段所示:

class Employee {
   private String name;
   private int zip;
   private int age;
   …

   public int getAge() {
      return age;
   }

   public void setAge(int age) {
      this.age = age;
   }

   …
}

在这个实现中,我们只包括了一个私有的age实例变量和一个用于它的 getter 和 setter 方法。在接下来的SalaryEmployee类中,我们没有添加任何字段:

class SalaryEmployee extends Employee  {
   // Implementation of SalaryEmployee class
}

然而,即使我们没有向SalaryEmployee类添加任何新内容,它也具有基类的功能。在下面的序列中,我们创建了两个类的实例并使用它们的方法:

public static void main(String[] args) {
   Employee employee1 = new Employee();
   SalaryEmployee employee2 = new SalaryEmployee();

   employee1.setAge(25);
   employee2.setAge(35);

   System.out.println("Employee1 age: " +
      employee1.getAge());
   System.out.println("Employee2 age: " + 
      employee2.getAge());
}

当代码执行时,我们得到以下输出:

Employee1 age: 25
Employee2 age: 35

由于getAgesetAge方法是公共的,我们可以在SalaryEmployee类中使用它们,即使我们没有定义新版本。然而,如果我们尝试访问私有的age变量,如下面的代码片段所示,我们将得到一个语法错误:

employee2.age = 35;

生成的语法错误如下:

age has private access in Employee

作用域审查部分,我们将更深入地探讨作用域和继承。

注意

Java 不支持类之间的多重继承。也就是说,派生类不能扩展多个类。派生类只能扩展一个类。然而,Java 支持接口之间的多重继承。

使用受保护的关键字

在前面的例子中,我们确定无法从实例变量employee2.age中访问私有成员。我们也无法从派生类的方法或构造函数中访问它。在下面的SalaryEmployee类的实现中,我们尝试在其默认构造函数中初始化age变量:

public class SalaryEmployee extends Employee{

   public SalaryEmployee() {
      age = 35;
   }

}

语法错误如下:

age has private access in Employee

然而,任何声明为公共的基类成员都可以从派生类的成员方法或构造函数中访问,或者通过引用派生类的实例变量。

有些情况下,成员变量应该可以从派生类的构造函数或方法中访问,但不能从其实例变量中访问。我们可能希望在比公共或私有更细的级别上限制对成员的访问。对于age变量,我们可能信任派生类正确使用变量,但可能不信任实例变量的使用者。使用受保护字段限制了应用程序中可以修改字段的位置以及可能引入问题的位置。

这就是protected访问修饰符的作用。通过在基类成员中使用protected关键字,我们限制对该成员的访问。它只能从基类内部或派生类的构造函数或方法中访问。

Employee类的以下实现中,age变量被声明为protected

class Employee {
   protected int age;
   …

   public int getAge() {
      return age;
   }

   public void setAge(int age) {
      this.age = age;
   }

   …
}

age变量现在可以从SalaryEmployee类中访问,如下所示进行初始化:

public SalaryEmployee() {
   age = 35;
}

这种初始化不会产生语法错误。然而,我们仍然无法从实例引用变量中访问age变量。假设该语句所在的类不在与SalaryEmployee类相同的包中,以下代码仍将导致语法错误。这在作用域复习部分中有解释:

employee2.age = 35;

protected关键字也可以用于方法。它与方法一起进一步增强了您对类成员访问的控制能力。例如,下面的Employee类的实现使用protected关键字与setAge方法:

class Employee {
   protected int age;
   …

   public int getAge() {
      return age;
   }

   protected void setAge(int age) {
      this.age = age;
   }

   …
}

这意味着类的任何用户都可以使用getAge方法,但只有基类、相同包中的类或派生类才能访问setAge方法。

覆盖方法

虽然基类的方法在派生类中自动可用,但实际的实现可能对派生类不正确。考虑使用一个方法来计算员工的工资。Employee类中的computePay方法可能只是返回一个基本金额,如下面的代码片段所示:

class Employee {
   private float pay = 500.0f;

   public float computePay() {
      return pay;
   }

  …
}

这个例子是基于浮点数据类型的,这并不一定是表示货币值的最佳数据类型。java.math.BigDecimal类更适合这个目的。我们在这里使用浮点数据类型是为了简化示例。

然而,对于派生类如HourlyEmployeecomputePay方法是不正确的。这种情况可以通过覆盖computePay方法来纠正,如下所示的简化的HourlyEmployee实现:

public class HourlyEmployee extends Employee{
   private float hoursWorked;
   private float payRate;

   public HourlyEmployee() {
      this.hoursWorked = 40.0f;
      this.payRate = 22.25f;
   }

   public float computePay() {
      return hoursWorked * payRate;
   }

}

覆盖的方法具有两个基本特征:

  • 具有与基类方法相同的签名

  • 在派生类中找到

类的签名由其名称、参数数量、参数类型和参数顺序组成。这在第六章的签名部分中有更详细的讨论,类、构造函数和方法

重载和覆盖这两个术语很容易混淆。以下表格总结了这些术语之间的关键区别:

特征 重载 覆盖
方法名称 相同 相同
签名 不同 相同
相同类 在派生类中

让我们来看一下computePay方法的使用。在以下顺序中,computePay方法针对employee1employee3实例变量执行:

Employee employee1 = new Employee();
HourlyEmployee employee3 = new HourlyEmployee();

System.out.println("Employee1 pay: " + employee1.computePay());
System.out.println("Employee3 pay: " + employee3.computePay());

输出将如下所示:

Employee1 pay: 500.0
Employee3 pay: 890.0

Employee基类的computePay方法针对employee1引用变量执行,而HourlyEmployeecomputePay方法针对employee3引用变量执行。Java 虚拟机JVM)在程序执行时确定要使用哪个方法。这实际上是多态行为的一个例子,我们将在多态部分中讨论。

在更复杂的类层次结构中,中间类可能不会覆盖一个方法。例如,如果SupervisorEmployee类是从SalaryEmployee类派生的,那么SalaryEmployee类不需要实现computePay方法。SupervisorEmployee类可以覆盖EmployeecomputePay方法,无论其直接基类是否覆盖了它。

@Override 注解

一个 Java 语言设计问题涉及方法重写。问题在于开发人员可能打算重写一个方法,但由于方法声明中的简单错误,可能没有实际重写。然而,在以下尝试重写computePay方法时,方法名拼写错误:

public float computPay() {
     return hoursWorked * payRate;
}

虽然方法拼写错误可能很明显(或者可能不明显),但开发人员可能没有注意到这个错误。使用前面的例子:

Employee employee1 = new Employee();
HourlyEmployee employee3 = new HourlyEmployee();

System.out.println("Employee1 pay: " + 
   employee1.computePay());
System.out.println("Employee3 pay: " + 
   employee3.computePay());

程序仍将执行,但不会生成预期的输出,如下所示:

Employee1 pay: 500.0
Employee3 pay: 500.0

注意在两种情况下都使用了基类的computePay方法。这是因为调用了computePay方法,而不是拼写错误的computPay方法。由于HourlyEmployee类不再有computePay方法,JVM 使用了基类方法。显然,这不是预期的结果。

很难立即发现这些类型的错误。为了帮助防止这些类型的错误,我们可以在方法中使用@Override注解,如下所示:

@Override
public float computPay() {
   return hoursWorked * payRate;
}

这个注解通知编译器确保接下来的方法实际上重写了基类方法。在这种情况下,它没有,因为方法名拼写错误。当这种情况发生时,将生成一个语法错误,指示存在问题。语法错误消息如下:

method does not override or implement a method from a supertype

当方法的拼写被纠正时,语法错误消息将消失。

正如名称注解所暗示的,注解是一种在稍后可以处理的 Java 应用程序部分中添加附加信息的方式。在编译时,@Override注解会进行检查以验证是否实际发生了重写。注解也可以用于其他目的,比如标记方法为弃用。

提示

始终使用@Override注解与重写的方法是一个好习惯。

使用 final 关键字与类

在声明类时,可以使用publicabstractfinal关键字。public关键字指定了类的范围,将在范围回顾部分中解释。abstract关键字的使用在下一节抽象方法和类中介绍。当在class关键字之前使用final关键字时,表示该类不能被扩展。它将是继承层次结构中的那个分支中的最后一个类。

在下面的例子中,Employee类被指定为 final 类。虽然对于本章的例子来说,将Employee类设为 final 没有意义,但它确实说明了使类成为 final 所需的语法:

final class Employee {
   …
}

通过限制其他类扩展类,可以确保类的预期操作不会被派生类覆盖基类方法而破坏。如果实现得当,这可以导致更可靠的应用程序基础。

java.lang.String类是核心 JDK 中的一个类的例子,它被定义为 final。不可能扩展这个类或修改它的行为。这意味着全世界的开发人员可以使用这个类,而不必担心意外使用派生类而不是String类。

final关键字也可以与方法定义一起使用。在这种情况下使用时,它意味着该方法不能在派生类中被重写。这比使一个类 final 提供了更多的灵活性。开发人员可以指定哪些方法可以被重写,哪些方法不能被重写。

以下示例说明了在Employee类中将getAge方法设为 final:

public class Employee {
   ...
   public final int getAge() {
      return age;
   }
}

如果我们尝试在派生类中重写方法,比如SalaryEmployee类,我们将得到以下错误消息:

getAge() in SalaryEmployee cannot override getAge() in Employee
 overridden method is final

抽象方法和类

抽象类在面向对象继承层次结构的设计中非常有用。它们通常用于强制派生类实现特定的一组方法。基类和/或类的一个或多个方法被声明为抽象。抽象类不能被实例化。相反,非抽象类必须在其层次树中实现所有抽象方法(如果有的话)。

以下示例说明了如何使Employee类成为抽象类。在这个例子中,没有抽象方法,但使用了abstract关键字来指定类为抽象类:

public abstract class Employee {
   ...
}

由于Employee类没有抽象方法,因此派生类都不会被强制实现任何额外的方法。上述定义对本章中先前的示例没有实际影响。

Employee类的下一个定义使computePay方法成为抽象方法。注意该方法没有主体,而是以分号结束:

public abstract class Employee {
   ...
   public abstract float computePay();
   ...
}

所有直接从Employee类派生的类必须实现抽象方法,否则它们本身将变成抽象类。如果它们选择不实现computePay方法,则必须将该类声明为抽象类。

当我们将一个方法声明为抽象时,我们被迫在类中使用abstract关键字。抽象类也可以拥有非抽象方法。

在复杂的层次结构中,你可能会发现非抽象类和抽象类的混合。例如,在java.awt包中,你会发现非抽象的Container类扩展了抽象的Component类,而Component类又扩展了非抽象的Object类。抽象类可以在层次结构的任何级别引入,以满足库的需求。

抽象类可以拥有最终方法,但不能被声明为最终。也就是说,final关键字不能用作抽象类或方法的修饰符。如果这是可能的,那么扩展该类将是不可能的。因为它是抽象的,所以它永远不能被实例化,因此将是无用的。但是,抽象类可以拥有最终方法。这些方法必须在该抽象类中实现。该类仍然可以被扩展,但最终方法不能被覆盖。

多态

多态是一个关键的面向对象编程概念,但最初可能很难理解。使用多态的主要目的是使应用程序更易于维护。当我们谈论多态时,通常说一个方法表现出多态行为。

注意

如果方法的行为取决于它正在执行的对象,则该方法被称为多态方法。

假设我们想要绘制某些东西。每个类可能都有一个名为draw的方法,它可以用来绘制自己。例如,圆形类可能有一个绘制自身为圆形的绘制方法。人类可能有一个显示该人的图像的绘制方法。这些方法的签名是相同的。

因此,如果我们对不同类的不同对象应用draw方法,这些类都有相同的基类,那么根据我们是对圆形还是对人应用draw方法,绘制的结果将不同。这就是多态行为。

通过设计我们的应用程序使用多态,我们可以更容易地添加具有绘制方法的新类,并将它们集成到我们的应用程序中,这比在非面向对象的编程语言中以前可能的要容易得多。

当创建对象的实例时,对象会经历一系列初始化步骤,详细信息请参阅第六章中的Java 初始化顺序部分,类,构造函数和方法。这也适用于从基类派生的对象。Java 内存管理是动态和自动的。当使用new关键字时,它会自动从堆中分配内存。

在 Java 中,可以将对基类及其派生类的引用分配给基类引用变量。这是可能的,因为为基类和派生类分配内存的方式。在派生类中,首先分配基类的实例变量,然后是派生类的实例变量。当将基类引用变量分配给派生类对象时,它会看到它所期望的基类实例变量以及“额外”的派生类实例变量。

让我们使用以下EmployeeSalaryEmployee类的定义:

public class Employee {
   private String name;
   private int age;

   ...

}

public class SalaryEmployee extends Employee {
   private float stock;
   …
}

在以下示例中,从引用变量的角度来看,将EmployeeSalaryEmployee对象分配给基类引用是有意义的,因为它期望看到nameage的实例变量。我们可以将新的Employee对象分配给employee变量,如下面的代码片段所示:

Employee employee;
employee = new Employee();

这也在以下图表中说明:

多态性

我们还可以使用以下代码将新的SalaryEmployee对象分配给employee变量。请注意,在前一个图中和这个图中,employee引用变量指向按顺序排列的nameage字段。employee引用变量期望一个由name字段和age字段组成的Employee对象,这就是它看到的。

employee = new SalaryEmployee();

这种情况在以下图表中描述:

多态性

如果执行以下代码片段,基于EmployeeSalaryEmployee类的先前声明,将执行SalaryEmployeecomputePay方法,而不是Employee类的:

Employee employee = new SalaryEmployee();
System.out.println(employee.computePay());

computePay方法在与其运行的对象相关时被称为多态的。如果computePay方法针对Employee对象运行,将执行EmployeecomputePay方法。

可以将对派生对象的引用分配给该类的对象引用变量或该类的任何基类。通过下一个示例可以更好地理解多态行为的优势。在这里,计算employees数组中所有员工的工资总和:

Employee employees[] = new Employee[10];
float sum = 0;

// initialize array
employees[0] = new Employee();
employees[1] = new SalaryEmployee();
employees[2] = new HourlyEmployee();
...

for(Employee employee : employees) {
   sum += employee.computePay();
}

computePay方法针对数组的每个元素执行。根据它正在执行的对象,会调用适当的computePay方法。如果从Employee类派生出一个新类,比如SalesEmployee类,使求和过程正常工作所需的唯一修改是向数组中添加一个SalesEmployee对象。不需要进行其他更改。结果是一个更易维护和可扩展的应用程序。

为派生类分配内存有助于解释多态的工作原理。我们可以将对SalaryEmployee的引用分配给SalaryEmployee引用变量或Employee引用变量。这在以下代码序列中有所说明:

Employee employee1 = new Employee();
SalaryEmployee employee2 = new SalaryEmployee();
employee1 = new SalaryEmployee();
employee1 = employee2;

以上所有分配都是合法的。可以将派生类对象分配给基类引用变量,因为基类引用变量实际上指向的是其第一部分包含基类实例变量的内存。这在以下图表中有所说明,其中每个堆栈反映了四个分配语句的累积效果:

多态性

请注意,一些对象不再被应用程序引用。这些对象有资格进行垃圾回收。如果需要,它们将在某个时候返回到堆中。

管理类和对象

本节涉及与类和对象的一般管理相关的一些问题。它包括:

  • 创建和初始化对象

  • 访问基类的方法

  • 确定对象的类型

  • 使用Object

  • 对象转换

  • 控制类和成员的范围

super 关键字和构造函数

正如我们在第六章的使用 this 关键字部分中所看到的,this关键字指的是当前对象。它可以用于:

  • 访问实例变量

  • 将当前对象传递给方法

  • 从方法返回当前对象

super关键字在派生类中以一种互补的方式使用。它用于:

  • 调用基类构造函数

  • 访问基类中的重写方法

调用基类构造函数

让我们来看看在调用基类构造函数时的使用。当创建派生类对象时,会通过调用其构造函数来初始化。构造函数的使用在第六章的构造函数部分中有所涵盖。但是,在执行派生类构造函数之前,会调用基类构造函数。这导致基类在派生类之前被初始化。这在派生类在初始化序列中使用任何基类方法时尤为重要。

除非我们使用super关键字调用替代的基类构造函数,否则基类的默认构造函数会自动调用。以下是Employee类的实现,它定义了两个构造函数——一个默认构造函数和一个三个参数的构造函数:

public class Employee {
   private String name;
   private int zip;
   private int age;
   ...

   public Employee() {
      this("Default name", 12345, 21);
   }

   public Employee(String name, int age, int zip) {
      this.name = name;
      this.zip = zip;
      this.age = age;
   }

   ...
}

注意使用this关键字调用三个参数的构造函数。以下是SalaryEmployee类的部分实现。只定义了一个默认构造函数:

public class SalaryEmployee extends Employee {
   private int age;
   …
   public SalaryEmployee() {
      age = 35;
   }
}

在这个例子中,Employee类的默认构造函数会被自动调用。但是,我们可以通过使用super关键字后跟一对括号来显式地进行这个调用,如下所示:

public SalaryEmployee() {
   super();
   age = 35;
}

在这两种情况下,SalaryEmployee对象中Employee基类的成员变量将按照基类构造函数中指定的方式进行初始化。

注意

如果我们明确使用super关键字来调用基类构造函数,它必须是派生类构造函数的第一行。this关键字必须是构造函数中调用同一类的另一个构造函数的第一个语句。这两个关键字不能在同一个构造函数中用于调用另一个构造函数。

然而,有些情况下,我们可能希望调用除默认基类构造函数之外的其他构造函数。为此,我们使用super关键字作为派生类构造函数中的第一个语句,并提供与另一个基类构造函数相匹配的参数列表。在以下示例中,SalaryEmployee的四个参数构造函数调用了基类的三个参数构造函数:

public SalaryEmployee(String name, int age, int zip, 
         float stock) {
   super(name, age, zip);
   this.stock = stock;
}

如果我们无法选择基类构造函数,那么我们需要在基类的默认构造函数执行后显式调用适当的 setter 方法来初始化基类变量。这在以下代码片段中有所说明:

public SalaryEmployee(String name, int age, int zip, 
         float stock) {
   this.setName(name);
   this.setAge(age);
   this.setZip(zip);
   this.stock = stock;
}

这种方法并不是首选方法。最好让基类初始化自己的成员变量。派生类并不总是能够确定它们应该如何初始化,如果它们完全隐藏了,派生类甚至可能不知道它们的存在。

注意

如果构造函数调用了基类方法,这些方法应该声明为 final。否则,覆盖它们的派生类可能会对初始化序列产生不利影响。

访问基类中的重写方法

我们还可以使用super关键字来访问基类方法的重写方法。例如,重写toString方法以返回表示该类实例的字符串总是一个好主意。以下代码片段提供了Employee类的此方法的一种可能实现:

public class Employee {
   …
   @Override
   public String toString() {
      return "Name: " + this.name +
             "  Age: " + this.age;
   }
}

SalaryEmployee类的一个实现如下代码片段所示,它使用基类的 getter 方法返回名称和年龄:

public class SalaryEmployee extends Employee {
   …
   @Override
   public String toString() {
      return "Name: " + this.getName() +
             "  Age: " + this.getAge() +
             "  Stock: " + this.stock;
   }
}

然而,这种实现方式很笨拙,因为它需要调用 getter 方法。这种方法的另一个问题是,每个派生类可能会提供基类变量的不同表示,可能会让使用这种方法的用户感到困惑。

理想情况下,在这个例子中我们应该简单地调用基类的toString方法来获得基类的字符串表示。然而,从派生类的toString方法中调用toString方法会导致递归调用。也就是说,运行时系统认为我们正在调用当前方法。这在下面的代码片段中得到了证明:

public class SalaryEmployee extends Employee {
   …
   @Override
   public String toString() {
      // Results in a recursive call to the current method
      return toString() + "  Stock: " + this.stock;
   }
}

我们可以通过使用super关键字来调用基类方法来避免这些问题。这是通过在基类方法的名称前加上super关键字和一个句点来实现的,如下面的代码片段所示:

public class SalaryEmployee extends Employee {
   …
   @Override
   public String toString() {
      return super.toString() + "  Stock: " + this.stock;
   }
}

使用super关键字的效果在下一个代码序列中得到了证明:

   Employee employee1 = new Employee("Paula", 23, 12345);
   SalaryEmployee employee2 = 
      new SalaryEmployee("Phillip", 31, 54321, 32);

   System.out.println(employee1);
   System.out.println(employee2);

输出将如下所示:

Name: Paula  Age: 23
Name: Phillip  Age: 31  Stock: 32.0

注意,在println方法中并没有显式调用toString方法。当在printprintln方法中使用对象引用时,如果没有使用其他方法,toString方法会自动被调用。

不像必须在构造函数中使用super关键字作为第一条语句来调用基类构造函数,当用于调用派生类方法时,super关键字可以在任何地方使用。它不必在相同的重写方法中使用。

在接下来的例子中,display方法调用了基类的toString方法:

public class SalaryEmployee extends Employee {
   …
   public void display() {
      System.out.println("Employee Base Data");
      System.out.println(super.toString());
      System.out.println("SalaryEmployee Data");
      System.out.println("Stock: " + this.stock);
    }
}

在这里,display方法被调用来对employee2引用变量进行操作:

SalaryEmployee employee2 = new SalaryEmployee();
employee2.display();

结果输出如下:

Employee Base Data
Name: Phillip  Age: 31
SalaryEmployee Data
Stock: 32.0

不可能调用当前基类以上的基类方法。也就是说,假设Employee - SalaryEmployee - Supervisor的继承层次结构,Employee类的基类方法不能直接从Supervisor方法中调用。以下代码将导致语法错误消息:

super.super.toString();  //illegal

确定对象的类型

有时候知道对象的类是很有用的。有几种方法可以确定它的类型。第一种方法是使用Class类获取类名。第二种方法是使用instanceof运算符。

实际上,在 Java 中有一个名为Class的类,它位于java.lang包中。它用于获取有关当前对象的信息。为了我们的目的,我们将使用它的getName方法来返回类的名称。首先,我们使用getClass方法获取Class的一个实例。这个方法是Object类的一个成员。以下是这种方法的示例:

Employee employee1 = new Employee();
SalaryEmployee employee2 = new SalaryEmployee();

Class object = employee1.getClass();
System.out.println("Employee1 type: " + object.getName());
object = employee2.getClass();
System.out.println("Employee2 type: " + object.getName());

当执行这个序列时,我们得到以下输出。在这个例子中,类名都是以它们的包名为前缀的。本书中开发的所有类都放在packt包中:

Employee1 type: packt.Employee
Employee2 type: packt.SalaryEmployee

虽然在某些情况下知道类的名称可能很有用,但instanceof运算符通常更有用。我们可以使用这个运算符来确定一个对象是否是一个类的实例。这在下面的例子中得到了证明,我们确定了employee1employee2变量引用的类的类型:

System.out.println("Employee1 is an Employee: " + (employee1 instanceof Employee));
System.out.println("Employee1 is a SalaryEmployee: " + (employee1 instanceof SalaryEmployee));   
System.out.println("Employee1 is an HourlyEmployee: " + (employee1 instanceofHourlyEmployee));  
System.out.println("Employee2 is an Employee: " + (employee2 instanceof Employee));
System.out.println("Employee2 is a SalaryEmployee: " + (employee2 instanceof SalaryEmployee)); 

这个序列根据运算符的操作数显示一个 true 或 false 值。输出如下:

Employee1 is an Employee: true
Employee1 is a SalaryEmployee: false
Employee1 is an HourlyEmployee: false
Employee2 is an Employee: true
Employee2 is a SalaryEmployee: true

Object 类

Object类位于java.lang包中。这个类是所有 Java 类的最终基类。如果一个类没有明确地扩展一个类,Java 将自动从Object类扩展该类。为了说明这一点,考虑Employee类的以下定义:

public class Employee {
   // Implementation of Employee class
}

虽然我们没有显式扩展Object类,但它是从Object类扩展的。要验证这一点,请考虑以下代码序列:

Employee employee1 = new Employee();
System.out.println("Employee1 is an Object: " + (employee1 instanceof Object)); 

输出如下:

Employee1 is an Object: true

instanceof运算符的应用确认Employee类最终是Object的对象。上述Employee类的定义具有与我们明确从Object派生它的效果相同,如下面的代码片段所示:

public class Employee extends Object  {
   // Implementation of Employee class
}

在 Java 中使用一个共同的基类可以保证所有类都有共同的方法。Object类拥有大多数类可能需要的几种方法,如下表所示:

方法 意义
clone 生成对象的副本。
equals 如果两个对象“相等”,则返回 true。
toString 返回对象的字符串表示。
finalize 在对象返回给堆管理器之前执行。
getClass 返回一个提供有关对象的附加信息的Class对象。
hashCode 返回对象的唯一哈希码。
notify 用于线程管理。
notifyAll 也用于线程管理。
wait 重载方法,用于线程管理。

提示

创建新类时,始终要重写toStringequalshashCode方法是一个好主意。

注意

在对象可以克隆之前,它的类必须实现java.lang.Cloneable接口。clone方法是受保护的。

对象转换

在 Java 中,我们能够将一个对象转换为原始对象以外的不同类。转换可以沿着层次结构向上或向下进行。当我们将一个派生类对象转换为基类引用变量时,称为向上转型。当我们将一个基类对象转换为派生类引用变量时,称为向下转型。让我们从以下声明开始,其中EmployeeSalaryEmployee的基类:

Employee employee1;
SalaryEmployee employee2;

以下示例说明了向上转型。将派生类SalaryEmployee的实例分配给基类引用变量employee1。这是合法的,也是多态行为的重要部分:

employee1 = new SalaryEmployee();

下一条语句尝试执行向下转型。将基类的实例分配给派生类引用变量。这条语句将导致语法错误:

employee2 = new Employee(); // Syntax error

然而,可以通过使用转换运算符来避免语法错误,如下所示:

employee2 = (SalaryEmployee) new Employee(); 

但是,当执行上述语句时,将抛出ClassCastException异常,如下所示:

java.lang.ClassCastException: packt.Employee cannot be cast to packt.SalaryEmployee

向上转型是可能的,因为派生对象包含基类具有的一切,以及更多的东西。向下转型不是一个好主意,因为引用变量期望提供比所提供的更多功能的对象。

请注意,通过向上转型,引用变量可用的方法是基类的方法,而不是派生类的方法。即使引用变量指向派生类对象,它也只能使用基类方法,因为这是我们告诉 Java 编译器对象的方式。这在以下语句中得到了说明,我们尝试使用派生类的setStock方法:

employee1.setStock(35.0f);

对于此语句将生成以下语法错误:

cannot find symbol
symbol:   method setStock(float)
 location: variable employee1 of type Employee

作用域的回顾

作用域是指变量何时可见和可访问。在早期的章节中,我们学习了publicprivate关键字如何用于控制成员变量的作用域。在本章的使用 protected 关键字部分,我们探讨了protected关键字的工作原理。然而,成员变量的声明不需要使用任何这些关键字。当不使用修饰符时,变量声明称为包私有。顾名思义,变量的作用域仅限于同一包中的那些类。

我们还需要考虑在类定义中使用public关键字。如果一个类声明为 public,它对所有类都是可见的。如果没有使用声明,它的可见性被限制在当前包内。该类被称为具有包私有可见性。

注意

privateprotected关键字不能与类定义一起使用,除非该类是内部类。内部类是在另一个类中声明的类。

以下表格总结了应用于类成员变量和方法的访问修饰符的作用域:

修饰符 派生类 其他
public A review of scope A review of scope A review of scope A review of scope
private A review of scope A review of scope A review of scope A review of scope
protected A review of scope A review of scope A review of scope A review of scope
none A review of scope A review of scope A review of scope A review of scope

让我们也考虑以下的包/类安排,这提供了对作用域规则的更详细的了解:

A review of scope

假设类A有以下声明:

public class A {
   public int v1;
   private int v2;
   protected int v3;
   int v4;
}

以下表格总结了这些声明的作用域规则。这些规则适用于类A中声明的变量和方法。它与前一个表格略有不同,因为它说明了派生类在不同包中的放置。因此,受保护行中的访问权限似乎与前一个表格不同:

变量 A B C D E
public int v1; A review of scope A review of scope A review of scope A review of scope A review of scope
private int v2; A review of scope A review of scope A review of scope A review of scope A review of scope
protected int v3; A review of scope A review of scope A review of scope A review of scope A review of scope
int v4; A review of scope A review of scope A review of scope A review of scope A review of scope

在这些类中,可能需要声明类A的一个实例,以便访问A的实例变量。例如,在类D中,需要以下代码来访问类A

A a = new A();
a.v1 = 35;
…

提示

一般来说,使用最严格的访问权限是有意义的。这将通过避免意外访问成员导致意想不到的后果来提高应用程序的可靠性。

总结

在本章中,我们研究了 Java 定义的继承和多态行为。我们研究了对象在内存中的分配,以更全面地了解多态性和构造函数的工作原理。还研究了thissuper关键字在构造函数和派生类方法中的使用。此外,还研究了抽象类以及它们对多态行为的影响。

涵盖了protectedfinal关键字。我们看到final关键字如何影响继承和重写方法。protected关键字允许我们更好地控制派生类中的信息访问。

解决了类和对象的管理,包括如何在包中组织类以及如何使用Class类获取关于对象的信息。介绍了包保护成员的使用。还涵盖了类的转换使用。

在下一章中,我们将涵盖异常处理这一重要主题。了解如何正确使用异常处理将使您能够创建更健壮和可维护的程序。

涵盖的认证目标

本章涉及的认证目标包括:

  • 实现继承

  • 开发演示多态使用的代码

  • 区分引用类型和对象类型

  • 确定何时需要转换

  • 使用superthis访问对象和构造函数

  • 使用抽象类和接口

测试你的知识

  1. 哪组语句导致ClassBClassCClassA派生?

a. ClassB 类扩展自 ClassA 类{}

b. ClassB 类扩展自 ClassC 类{}

c. ClassA 类扩展自 ClassB 类{}

d. ClassC 类扩展自 ClassB 类{}

e. 没有组合会起作用

  1. 以下哪些条件必须为方法支持多态性?

a. 该方法必须重写基类方法

b. 该方法必须重载基类方法

c. 该方法的类必须扩展具有被重写方法的基类

d. 该方法必须针对基类引用变量执行

  1. 用于确定对象类型的方法是什么?

a. isType

b. typeOf

c. instanceof

d. instanceOf

  1. 以下哪些是有效的转换?

a. num1 = num2;

b. num1 = (int)num2;

c. num1 = (float)num2;

d. num1(int) = num2;

  1. 给定以下类定义:
public class ClassA {
   public ClassA() {
      System.out.println("ClassA constructor");
   }

   public void someMethod() {
      System.out.println("ClassA someMethod");
   }
}

class ClassB extends ClassA {
   public ClassB() {
      System.out.println("ClassB constructor");
   }

   public void someMethod() {
      // comment
      System.out.println("ClassB someMethod");
   }    
   public static void main(String args[]) {
      ClassB b = new ClassB();
      b.someMethod();

   }
}

在注释行需要什么语句才能生成以下输出:

ClassA constructor
ClassB constructor
ClassA someMethod
ClassB someMethod

a. super();

b. super().someMethod;

c. super.someMethod();

d. someMethod();

e. 以上都不是

  1. 以下哪些陈述是真实的?

a. 抽象类在声明时必须使用 abstract 关键字

b. 抽象类必须有一个或多个抽象方法

c. 抽象类不能扩展非抽象类

d. 抽象类不能实现接口

第八章:在应用程序中处理异常

异常是由应用程序或Java 虚拟机JVM)在发生某种错误时抛出的对象。Java 提供了各种预定义的异常,并允许开发人员声明和创建自己的异常类。

虽然有许多分类异常的方法,但其中一种方案将它们分类为三种类型:

  • 程序错误

  • 代码的不当使用

  • 与资源相关的故障

程序错误是代码序列中的内部缺陷。程序员可能对这些类型的错误无能为力。例如,常见的异常是NullPointerException。这通常是由于未正确初始化或分配值给引用变量。在编写代码时,这种错误很难避免和预料。然而,一旦检测到,可以修改代码以纠正情况。

代码可能被错误地使用。大多数库都是设计用于特定方式的。它们可能期望数据以某种方式组织,如果库的用户未能遵循格式,就可能引发异常。例如,方法的参数可能未按方法所期望的结构化,或者可能是错误的类型。

一些错误与资源故障有关。当底层系统无法满足程序的需求时,可能会发生资源类型的异常。例如,网络故障可能会阻止程序正常执行。这种类型的错误可能需要在以后的时间重新执行程序。

处理异常的传统方法是从过程中返回错误代码。例如,如果函数执行时没有错误,则通常会返回零。如果发生错误,则会返回非零值。这种方法的问题在于函数的调用可能会出现以下情况:

  • 不知道函数返回错误代码(例如,C 的printf函数)

  • 忘记检查错误

  • 完全忽略错误

当错误没有被捕获时,程序的继续执行可能会导致不可预测的,可能是灾难性的后果。

这种方法的替代方法是“捕获”错误。大多数现代的块结构化语言,如 Java,使用这种方法。这种技术需要更少的编码,更易读和更健壮。当一个例程检测到错误时,它会“抛出”一个异常对象。然后将异常对象返回给调用者,调用者捕获并处理错误。

异常应该被捕获有许多原因。未能处理异常可能导致应用程序失败,或者以不正确的输出结束处于无效状态。保持一致的环境总是一个好主意。此外,如果打开了资源,比如文件,在完成后应该始终关闭资源,除了最琐碎的程序。

Java 中提供的异常处理机制允许您这样做。当打开资源时,即使程序中发生异常,也可以关闭资源。为了完成这个任务,资源在try块中打开,并在catchfinally块中关闭。trycatchfinally块构成了 Java 中使用的异常处理机制的核心。

异常类型

Java 已经提供了一套广泛的类来支持 Java 中的异常处理。异常是直接或间接从Throwable类派生的类的实例。从Throwable派生了两个预定义的 Java 类——ErrorException。从Exception类派生了一个RuntimeException类。正如我们将很快看到的,程序员定义的异常通常是从Exception类派生的:

异常类型

有许多预定义的错误,它们源自ErrorRuntimeException类。程序员对于从Error对象派生的异常几乎不会做任何处理。这些异常代表了 JVM 的问题,通常无法恢复。Exception类是不同的。从Exception类派生的两个类支持两种类型的异常:

  • 经过检查的异常:这些异常在代码中需要处理

  • 未经检查的异常:这些异常在代码中不需要处理

经过检查的异常包括所有从Exception类派生而不是从RuntimeException类派生的异常。这些必须在代码中处理,否则代码将无法编译,导致编译时错误。

未经检查的异常是所有其他异常。它们包括除零和数组下标错误等异常。这些异常不必被捕获,但是像Error异常一样,如果它们没有被捕获,程序将终止。

我们可以创建自己的异常类。当我们这样做时,我们需要决定是创建一个经过检查的异常还是未经检查的异常。一个经验法则是,如果客户端代码无法从异常中恢复,将异常声明为未经检查的异常。否则,如果他们可以处理它,就将其作为经过检查的异常。

注意

一个类的用户不必考虑未经检查的异常,这些异常可能导致程序终止,如果客户端程序从未处理它们。一个经过检查的异常要求客户端要么捕获异常,要么显式地将其传递到调用层次结构中。

Java 中的异常处理技术

在处理 Java 异常时,我们可以使用三种常规技术:

  • 传统的try

  • Java 7 中引入的新的“try-with-resources”块

  • 推卸责任

第三种技术是在当前方法不适合处理异常时使用的。它允许异常传播到方法调用序列中更高的位置。在以下示例中,anotherMethod可能会遇到一些条件,导致它可能抛出IOException。在someMethod中不处理异常,而是在someMethod定义中使用throws关键字,结果是将异常传递给调用此方法的代码:

public void someMethod() throws IOException {
   …
   object.anotherMethod(); // may throw an IOException
   …
}

该方法将跳过方法中剩余的所有代码行,并立即返回给调用者。未捕获的异常会传播到下一个更高的上下文,直到它们被捕获,或者它们从main中抛出,那里将打印错误消息和堆栈跟踪。

堆栈跟踪

printStackTraceThrowable类的一个方法,它将显示程序在该点的堆栈。当异常未被捕获时,它会自动使用,或者可以显式调用。该方法的输出指出了导致程序失败的行和方法。您以前已经看到过这种方法的使用,每当您遇到未处理的运行时异常时。当异常未被处理时,该方法会自动调用。

ExceptionDemo程序说明了该方法的显式使用:

public class ExceptionDemo {

   public void foo3() {
      try {
         …
         throw new Exception();
      }
      catch (Exception e) {
         e.printStackTrace();
      }
   }

   public void foo2() { foo3(); }
   public void foo1() { foo2(); }

   public static void main(String args[]) {
      new ExceptionDemo().foo1();
   }
}

输出如下所示:

java.lang.Exception
 at ExceptionDemo.foo3(ExceptionDemo.java:8)
 at ExceptionDemo.foo2(ExceptionDemo.java:16)
 at ExceptionDemo.foo1(ExceptionDemo.java:20)
 at ExceptionDemo.main(ExceptionDemo.java:25)

使用 Throwable 方法

Throwable类拥有许多其他方法,可以提供更多关于异常性质的见解。为了说明这些方法的使用,我们将使用以下代码序列。在这个序列中,我们尝试打开一个不存在的文件并检查抛出的异常:

private static void losingStackTrace(){
   try {
      File file = new File("c:\\NonExistentFile.txt");
      FileReader fileReader = new FileReader(file);
   } 
   catch (FileNotFoundException e) {
      e.printStackTrace();

      System.out.println();
      System.out.println("---e.getCause(): " + 
                   e.getCause());
      System.out.println("---e.getMessage(): " + 
                   e.getMessage());
      System.out.println("---e.getLocalizedMessage(): " + 
                   e.getLocalizedMessage());
      System.out.println("---e.toString(): " + 
                   e.toString());
   }
}

由于一些 IDE 的性质,应用程序的标准输出和标准错误输出可能会交错。例如,上述序列的执行可能导致以下输出。您可能会在输出中看到交错,也可能不会看到。输出前面的破折号用于帮助查看交错行为:

java.io.FileNotFoundException: c:\NonExistentFile.txt (The system cannot find the file specified)
---e.getCause(): null
---e.getMessage(): c:\NonExistentFile.txt (The system cannot find the file specified)
   at java.io.FileInputStream.open(Native Method)
---e.getLocalizedMessage(): c:\NonExistentFile.txt (The system cannot find the file specified)
---e.toString(): java.io.FileNotFoundException: c:\NonExistentFile.txt (The system cannot find the file specified)
   at java.io.FileInputStream.<init>(FileInputStream.java:138)
   at java.io.FileReader.<init>(FileReader.java:72)
   at packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:64)
   at packt.Chapter8Examples.main(Chapter8Examples.java:57)

在本例中使用的方法总结在以下表中:

方法 意义
getCause 返回异常的原因。如果无法确定原因,则返回 null。
getMessage 返回详细消息。
getLocalizedMessage 返回消息的本地化版本。
toString 返回消息的字符串版本。

请注意,printStackTrace方法的第一行是toString方法的输出。

getStackTrace方法返回一个StackTraceElement对象数组,其中每个元素表示堆栈跟踪的一行。我们可以使用以下代码序列复制printStackTrace方法的效果:

try {
   File file = new File("c:\\NonExistentFile.txt");
   FileReader fileReader = new FileReader(file);
} 
catch (FileNotFoundException e) {
   e.printStackTrace();
   System.out.println();
   StackTraceElement traces[] = e.getStackTrace();
   for (StackTraceElement ste : traces) {
      System.out.println(ste);
   }
}

执行时,我们得到以下输出:

java.io.FileNotFoundException: c:\NonExistentFile.txt (The system cannot find the file specified)
 at java.io.FileInputStream.open(Native Method)
 at java.io.FileInputStream.<init>(FileInputStream.java:138)
 at java.io.FileReader.<init>(FileReader.java:72)
 at packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:64)
 at packt.Chapter8Examples.main(Chapter8Examples.java:57)

java.io.FileInputStream.open(Native Method)
java.io.FileInputStream.<init>(FileInputStream.java:138)
java.io.FileReader.<init>(FileReader.java:72)
packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:64)
packt.Chapter8Examples.main(Chapter8Examples.java:57)

传统的 try-catch 块

处理异常的传统技术使用trycatchfinally块的组合。try块用于包围可能引发异常的代码,然后是零个或多个catch块,最后是一个可选的finally块。

catch块在try块之后添加以“捕获”异常。catch块中的语句提供了“处理”错误的代码块。在catch块之后可以选择使用finally子句。它保证即使trycatch块中的代码引发或不引发异常,也会执行。

注意

但是,如果在 try 或 catch 块中调用System.exit方法,则 finally 块将不会执行。

以下序列说明了这些块的使用。在 try 块内,读取一行并提取一个整数。使用两个 catch 块来处理可能抛出的异常:

try {
   inString = is.readLine();
   value = Integer.parseInt (inString);
   …
} 
catch (IOException e) {
   System.out.println("I/O Exception occurred");
} 
catch (NumberFormatException e) {
   System.out.println("Bad format, try again...");
} 
finally {
   // Perform any necessary clean-up action
}

在此代码序列中可能出现两种错误中的一种:

  • 要么尝试读取输入行时会发生错误,要么

  • 尝试将字符串转换为整数时将发生错误

第一个 catch 块将捕获 IO 错误,第二个 catch 块将捕获转换错误。当抛出异常时,只有一个 catch 块会被执行。

可能会发生错误,也可能不会。无论如何,finally 块将在 try 块完成或 catch 块执行后执行。finally子句保证运行,并通常包含“清理”代码。

使用 try-with-resource 块

当多个资源被打开并发生故障时,使用先前的技术可能会很麻烦。它可能导致多个难以跟踪的 try-catch 块。在 Java 7 中,引入了 try-with-resources 块来解决这种情况。

try-with-resources 块的优势在于,块中打开的所有资源在退出块时会自动关闭。使用 try-with-resources 块的任何资源都必须实现java.lang.AutoCloseable接口。

我们将通过创建一个简单的方法来将一个文件复制到另一个文件来说明这种方法。在下面的示例中,一个文件用于读取,另一个文件用于写入。请注意,它们是在try关键字和块的左花括号之间创建的:

try (BufferedReader reader = Files.newBufferedReader(
    Paths.get(new URI("file:///C:/data.txt")),
      Charset.defaultCharset());
    BufferedWriter writer = Files.newBufferedWriter(
      Paths.get(new URI("file:///C:/data.bak")),
      Charset.defaultCharset())) {

  String input;
  while ((input = reader.readLine()) != null) {
    writer.write(input);
    writer.newLine();
  }
} catch (URISyntaxException | IOException ex) {
  ex.printStackTrace();
}

要管理的资源在一对括号内声明和初始化,并放在try关键字和 try 块的左花括号之间。第一个资源是使用data.txt文件的BufferedReader对象,第二个资源是与data.bak文件一起使用的BufferedWriter对象。Paths类是 Java 7 中的新功能,提供了改进的 IO 支持。

使用 try-with-resources 块声明的资源必须用分号分隔,否则将生成编译时错误。有关 try-with-resources 块的更深入覆盖可以在《Java 7 Cookbook》中找到。

在 catch 块中使用竖线是 Java 7 中的新功能,允许我们在单个 catch 块中捕获多个异常。这在在 catch 块中使用|运算符部分有解释。

catch 语句

catch 语句只有一个参数。如果 catch 语句的参数:

  • 完全匹配异常类型

  • 是异常类型的基类

  • 是异常类型实现的接口

只有与异常匹配的第一个 catch 语句将被执行。如果没有匹配,方法将终止,并且异常将冒泡到调用方法,那里可能会处理它。

之前的try块的一部分如下所示重复。catch语句的格式由catch关键字后面跟着一组括号括起来的异常声明组成。然后是一个块语句中的零个或多个语句:

try {
   …
} 
catch (IOException e) {
   System.out.println("I/O Exception occurred");
} 
catch (NumberFormatException e) {
   System.out.println("Bad format, try again...");
} 

处理错误的过程由程序员决定。它可能只是简单地显示一个错误消息,也可能非常复杂。程序员可以使用错误对象重试操作或以其他方式处理它。在某些情况下,这可能涉及将其传播回调用方法。

catch 块的顺序

在 try 块后列出 catch 块的顺序可能很重要。当抛出异常时,异常对象将按照它们的顺序与 catch 块进行比较。比较检查抛出的异常是否是 catch 块中异常的类型。

例如,如果抛出了FileNotFoundException,它将匹配具有IOExceptionFileNotFoundException异常的 catch 块,因为FileNotFoundExceptionIOException的子类型。由于在找到第一个匹配项时比较会停止,如果IOException的 catch 块在FileNotFoundException的 catch 块之前列出,FileNotFoundException块将永远不会被执行。

考虑以下异常类的层次结构:

catch 块的顺序

给定以下代码序列:

try {
   …
}
catch (AException e) {…}
catch (BException e) {…}
catch (CException e) {…}
catch (DException e) {…}

如果抛出的异常是这些类型的异常之一,AException catch 块将始终被执行。这是因为AExceptionBExceptionCExceptionDException都是AException类型。异常将始终匹配AException异常。其他catch块将永远不会被执行。

通常规则是始终首先列出“最派生”的异常。以下是列出异常的正确方式:

try {
   …
}
catch (DException e) {…}
catch (BException e) {…}
catch (CException e) {…}
catch (AException e) {…}

请注意,对于异常的这种层次结构,无论BException是紧接着还是跟在CException之后,都没有任何区别,因为它们处于同一级别。

在 catch 块中使用|运算符

有时希望以相同的方式处理多个异常。我们可以使用竖线来允许一个 catch 块捕获多个异常,而不是在每个 catch 块中重复代码。

考虑可能抛出两个异常并以相同方式处理的情况:

try {
   …
} 
catch (IOException e) {
   e.printStackTrace();
} 
catch (NumberFormatException e) {
   e.printStackTrace();
} 

竖线可以用于在相同的catch语句中捕获两个或更多的异常,如下面的代码片段所示。这可以减少处理以相同方式处理的两个异常所需的代码量。

try {
   …
} 
catch (IOException | NumberFormatException e) {
   e.printStackTrace();
}

当多个异常可以以相同方式处理时,这种方法是有效的。请记住,catch 块的参数是隐式 final 的。无法将不同的异常赋值给该参数。以下尝试是非法的,不会编译通过:

catch (IOException | NumberFormatException e) {
   e = new Exception();  // Compile time error
}

finally 块

finally块跟在一系列catch块后面,由finally关键字后面跟着一系列语句组成。它包含一个或多个语句,这些语句将始终被执行以清理之前的操作。finally块将始终执行,无论异常是否存在。但是,如果trycatch块调用了System.exit方法,程序将立即终止,finally块将不会执行。

finally块的目的是关闭或以其他方式处理在try块中打开的任何资源。关闭不再需要的资源是一种良好的实践。我们将在下一个例子中看到这一点。

然而,在实践中,这通常是繁琐的,如果需要关闭多个资源,关闭过程可能也会生成异常,这可能会导致错误。此外,如果一个资源在打开时抛出异常,而另一个资源没有打开,我们必须小心不要尝试关闭第二个资源。因此,在 Java 7 中引入了 try-with-resources 块来解决这种问题。这个块在使用 try-with-resources 块部分中进行了讨论。在这里,我们将介绍finally块的简化使用。

一个使用finally块的简单示例如下所示。在这个序列中,我们将打开一个文件进行输入,然后显示其内容:

BufferedReader reader = null;     
try {
   File file1 = new File("c:\\File1.txt");

   reader = new BufferedReader(new FileReader(file1));
   // Copy file
   String line;
   while((line = reader.readLine()) != null) {
      System.out.println(line);
   }
} 
catch (IOException e) {
   e.printStackTrace();
}
finally {
   if(reader != null) {
      reader.close();
   }
}

无论是否抛出异常,文件都将被关闭。如果文件不存在,将抛出FileNotFoundException。这将在catch块中捕获。请注意我们如何检查reader变量以确保它不是 null。

在下面的例子中,我们打开两个文件,然后尝试将一个文件复制到另一个文件。finally块用于关闭资源。这说明了在处理多个资源时finally块的问题:

BufferedReader br = null;
BufferedWriter bw = null;        
try {
   File file1 = new File("c:\\File1.txt");
   File file2 = new File("c:\\File2.txt");

   br = new BufferedReader(new FileReader(file1));
   bw = new BufferedWriter(new FileWriter(file2));
   // Copy file
} 
catch (FileNotFoundException e) {
   e.printStackTrace();
}
catch (IOException e) {
   e.printStackTrace();
}
finally {
   try {
      br.close();
      bw.close();
   } catch (IOException ex) {
      // Handle close exception
   }
}

请注意,close方法也可能会抛出IOException。我们也必须处理这些异常。这可能需要一个更复杂的异常处理序列,这可能会导致错误。在这种情况下,请注意,如果在关闭第一个文件时抛出异常,第二个文件将不会被关闭。在这种情况下,最好使用 try-with-resources 块,如使用 try-with-resources 块部分所述。

提示

try 块需要一个 catch 块或一个 finally 块。如果没有一个或两个,将生成编译时错误。

嵌套的 try-catch 块

异常处理可以嵌套。当在catchfinally块中使用也会抛出异常的方法时,这可能是必要的。以下是在catch块中使用嵌套try块的示例:

try {
   // Code that may throw an exception
}
catch (someException e) {
   try {
      // Code to handle the exception
   }
   catch (anException e) {
      // Code to handle the nested exception
   } 
}
catch (someOtherException e) {
   // Code to handle the exception
} 

在上一节的最后一个例子中,我们在finally块中使用了close方法。然而,close方法可能会抛出IOException。由于它是一个受检异常,我们需要捕获它。这导致了一个try块嵌套在一个finally块中。此外,当我们尝试关闭BufferedReader时,第二个try块将抛出NullPointerException,因为我们尝试执行关闭方法针对从未分配值的reader变量。

为了完成前面的例子,考虑以下实现:

finally {
   try {
      br.close();
      bw.close();
   } catch (IOException | NullPointerException e) {
       // Handle close exceptions
   }
   }

我们使用|符号来简化捕获两个异常,如在 catch 块中使用|操作符部分所述。这也是我们可能丢失原始异常的另一个例子。在这种情况下,FileNotFoundException丢失为NullPointerException。这将在丢失堆栈跟踪部分中讨论。

异常处理指南

本节介绍了处理异常的一般指导方针。它旨在提供如何以更有用和更有效的方式使用异常处理的示例。虽然糟糕的技术可能不会导致编译时错误或不正确的程序,但它们通常反映了糟糕的设计。

重复抛出异常的代码

当抛出异常然后捕获时,我们有时会想尝试重新执行有问题的代码。如果代码结构良好,这并不困难。

在这个代码序列中,假设try块进入时存在错误。如果生成错误,它将被catch块捕获并处理。由于errorsArePresent仍然设置为 true,try块将被重复执行。然而,如果没有发生错误,在try块结束时,errorsArePresent标志将被设置为 false,这将允许程序执行 while 循环并继续执行:

boolean errorsArePresent;

…
errorsArePresent = true;	
while (errorsArePresent) {
   try {
      …
      errorsArePresent = false;
   } 

   catch (someException e) {
      // Process error
   } 

}

在这个例子中,假设用于处理错误的代码将需要重新执行try块。当我们在处理错误代码序列中所做的一切就是显示一个标识错误的错误消息时,比如用户输入了一个错误的文件名时,这可能是情况。

如果所需的资源不可用,使用这种方法时需要小心。这可能导致一个无限循环,我们检查一个不可用的资源,抛出异常,然后再次执行。可以添加一个循环计数器来指定我们尝试处理异常的次数。

不具体指明捕获的异常

在捕获异常时,要具体指明需要捕获的异常。例如,在以下示例中捕获了通用的Exception。没有具体的信息可以显示异常的原因:

try {
   someMethod();
} catch (Exception e) {
   System.out.println("Something failed" + e);
}

接下来是一个更有用的版本,它捕获了实际抛出的异常:

try {
   someMethod();
} catch (SpecificException e) {
   System.out.println("A specific exception message" + e);
}

丢失堆栈跟踪

有时会捕获异常,然后重新抛出不同的异常。考虑以下方法,其中抛出了一个FileNotFoundException异常:

private static void losingStackTrace(){
   try {
      File file = new File("c:\\NonExistentFile.txt");
      FileReader fileReader = new FileReader(file);
   }
   catch(FileNotFoundException e) {
      e.printStackTrace();
   }
}

假设文件不存在,将生成以下堆栈跟踪:

java.io.FileNotFoundException: c:\NonExistentFile.txt (The system cannot find the file specified)
   at java.io.FileInputStream.open(Native Method)
   at java.io.FileInputStream.<init>(FileInputStream.java:138)
   at java.io.FileReader.<init>(FileReader.java:72)
   at packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:49)
   at packt.Chapter8Examples.main(Chapter8Examples.java:42)

我们可以知道确切的异常是什么,以及它发生在哪里。接下来,考虑使用MyException类而不是FileNotFoundException异常:

public class MyException extends Exception {
   private String information;

   public MyException(String information) {
      this.information = information;
   }
}

如果重新抛出异常,就像下面的代码片段所示,我们将丢失有关原始异常的信息:

private static void losingStackTrace() throws MyException {
   try {
      File file = new File("c:\\NonExistentFile.txt");
      FileReader fileReader = new FileReader(file);
   }
   catch(FileNotFoundException e) {
      throw new MyException(e.getMessage());
   }
}

由此实现产生的堆栈跟踪如下:

Exception in thread "main" packt.MyException
 at packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:53)
 at packt.Chapter8Examples.main(Chapter8Examples.java:42)

请注意,实际异常的细节已经丢失。一般来说,最好不要使用这种方法,因为丢失了用于调试的关键信息。这个问题的另一个例子可以在嵌套的 try-catch 块部分找到。

可以重新抛出并保留堆栈跟踪。为此,我们需要做以下操作:

  1. 添加一个带有Throwable对象作为参数的构造函数。

  2. 在需要保留堆栈跟踪时使用这个。

以下显示了将此构造函数添加到MyException类中:

public MyException(Throwable cause) {
   super(cause);
}

catch块中,我们将使用下面显示的这个构造函数。

catch (FileNotFoundException e) {
   (new MyException(e)).printStackTrace();
}

我们本可以抛出异常。相反,我们使用了printStackTrace方法,如下所示:

packt.MyException: java.io.FileNotFoundException: c:\NonExistentFile.txt (The system cannot find the file specified)
 at packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:139)
 at packt.Chapter8Examples.main(Chapter8Examples.java:40)
Caused by: java.io.FileNotFoundException: c:\NonExistentFile.txt (The system cannot find the file specified)
 at java.io.FileInputStream.open(Native Method)
 at java.io.FileInputStream.<init>(FileInputStream.java:138)
 at java.io.FileReader.<init>(FileReader.java:72)
 at packt.Chapter8Examples.losingStackTrace(Chapter8Examples.java:136)

作用域和块长度

trycatchfinally块中声明的任何变量的作用域都限于该块。尽可能地限制变量的作用域是一个好主意。在下面的示例中,由于在finally块中需要,所以需要在trycatch块之外定义reader变量:

BufferedReader reader = null;
try {
   reader = …
   …
}
catch (IOException e) {
   …
} finally {
   try {
      reader.close();
   } 
   catch (Exception e) {
      …
   }
}

块的长度应该是有限的。然而,块太小可能会导致您的代码变得混乱,异常处理代码也会变得混乱。假设有四种方法,每种方法都可能抛出不同的异常。如果我们为每个方法使用单独的 try 块,我们最终会得到类似以下的代码:

try {
   method1();
}
catch (Exception1 e1) {
   …
} 
try {
   method2();
}

catch (Exception1 e2) {
   …
} 
try {
   method3();
}
catch (Exception1 e3) {
   …
} 
try {
   method4();
}
catch (Exception1 e4) {
   …
} 

这有点笨拙,而且如果每个try块都需要一个finally块,也会出现问题。如果这些在逻辑上相关,一个更好的方法是使用一个单独的try块,如下所示:

try {
   method1();
   method2();
   method3();
   method4();
}
catch (Exception1 e1) {
   …
} 
catch (Exception1 e2) {
   …
} 
catch (Exception1 e3) {
   …
} 
catch (Exception1 e4) {
   …
} 

finally {
      …
}

根据异常的性质,我们还可以使用一个通用的基类异常,或者在 Java 7 中引入的|运算符与单个 catch 块。如果异常可以以相同的方式处理,这是特别有用的。

然而,将整个方法体放在一个包含与异常无关的代码的 try/catch 块中是一个不好的做法。如果可能的话,最好将异常处理代码与非执行处理代码分开。

一个经验法则是将异常处理代码的长度保持在一次可以看到的大小。使用多个 try 块是完全可以接受的。但是,请确保每个块包含逻辑相关的操作。这有助于模块化代码并使其更易读。

抛出 UnsupportedOperationException 对象

有时,打算被覆盖的方法会返回一个“无效”的值,以指示需要实现该方法。例如,在以下代码序列中,getAttribute方法返回null

class Base {
   public String getAttribute() {
      return null;
   }
   …
}

但是,如果该方法没有被覆盖并且使用了基类方法,可能会出现问题,例如产生不正确的结果,或者如果针对返回值执行方法,则可能生成NullPointerException

更好的方法是抛出UnsupportedOperationException来指示该方法的功能尚未实现。这在以下代码序列中有所体现:

class Base {
   public String getAttribute() {
      throw new UnsupportedOperationException();
   }
   …
}

在提供有效的实现之前,该方法无法成功使用。这种方法在 Java API 中经常使用。java.util.Collection类的unmodifiableList方法使用了这种技术(docs.oracle.com/javase/1.5.0/docs/api/java/util/Collections.html#unmodifiableList%28java.util.List%29)。通过将方法声明为抽象,也可以实现类似的效果。

忽略异常

通常忽略异常是一个不好的做法。它们被抛出是有原因的,如果有什么可以做来恢复,那么你应该处理它。否则,至少可以优雅地终止应用程序。

例如,通常会忽略InterruptedException,如下面的代码片段所示:

while (true) {
   try {
      Thread.sleep(100000);
   } 
   catch (InterruptedException e) {
      // Ignore it
   }
}

然而,即使在这里也出了问题。例如,如果线程是线程池的一部分,池可能正在终止,你应该处理这个事件。始终了解程序运行的环境,并且预料到意外。

另一个糟糕的错误处理示例在以下代码片段中显示。在这个例子中,我们忽略了可能抛出的FileNotFoundException异常:

private static void losingStackTrace(){
   try {
      File file = new File("c:\\NonExistentFile.txt");
      FileReader fileReader = new FileReader(file);
   }
   catch(FileNotFoundException e) {
      // Do nothing
   }
}

这个用户并不知道曾经遇到异常。这很少是一个可以接受的方法。

尽可能晚处理异常

当方法抛出异常时,方法的使用者可以在那一点处理它,或者将异常传递到调用序列中的另一个方法。诀窍是在适当的级别处理异常。通常,该级别是可以处理异常的级别。

例如,如果需要应用程序用户的输入才能成功处理异常,那么应该使用最适合与用户交互的级别。如果该方法是库的一部分,那么假设用户应该被提示可能不合适。当我们尝试打开一个文件而文件不存在时,我们不希望调用的方法提示用户输入不同的文件名。相反,我们更倾向于自己处理。在某些情况下,甚至可能没有用户可以提示,就像许多服务器应用程序一样。

在单个块中捕获太多

当我们向应用程序添加 catch 块时,我们经常会诱使使用最少数量的 catch 块,通过使用基类异常类来捕获它们。下面的示例中,catch 块使用Exception类来捕获多个异常。在这里,我们假设可能会抛出多个已检查的异常,并且需要处理它们:

try {
   …
}
catch (Exception e) {
   …
} 

如果它们都以完全相同的方式处理,那可能没问题。但是,如果它们在处理方式上有所不同,那么我们需要包含额外的逻辑来确定实际发生了什么。如果我们忽略了这些差异,那么它可能会使任何调试过程更加困难,因为我们可能已经丢失了有关异常的有用信息。此外,这种方法不仅太粗糙,而且我们还捕获了所有的 RuntimeException,而这些可能无法处理。

相反,通常最好在它们自己的捕获块中捕获多个异常,如下面的代码片段所示:

try {
   …
}
catch (Exception1 e1) {
   …
} 
catch (Exception1 e2) {
   …
} 
catch (Exception1 e3) {
   …
} 
catch (Exception1 e4) {
   …
} 

记录异常

通常的做法是即使成功处理了异常,也要记录异常。这对评估应用程序的行为很有用。当然,如果我们无法处理异常并需要优雅地终止应用程序,错误日志可以帮助确定应用程序出了什么问题。

注意

异常只记录一次。多次记录可能会让试图查看发生了什么的人感到困惑,并创建比必要更大的日志文件。

不要使用异常来控制正常的逻辑流程

在应该进行验证的地方使用异常是不好的做法。此外,抛出异常会消耗额外的资源。例如,NullPointerException 是一种常见的异常,当尝试对一个具有空值分配的引用变量执行方法时会出现。我们应该检测这种情况并在正常的逻辑序列中处理它,而不是捕获这个异常。考虑以下情况,我们捕获了一个 NullPointerException

String state = ...  // Somehow assigned a null value
try {
   if(state.equals("Ready") { … }
}
catch(NullPointerException e) {
   // Handle null state
}

相反,在使用状态变量之前应该检查它的值:

String state = ...  // Somehow assigned a null value

if(state != null) {
   if(state.equals("Ready") { … }
} else {
   // Handle null state
}

完全消除了 try 块的需要。另一种方法使用短路评估,如下面的代码片段所示,并在 第三章 的 决策结构 部分进行了介绍。如果 state 变量为空,则避免使用 equals 方法:

String state = ...  // Somehow assigned a null value

if(state != null && state.equals("Ready") { 
   // Handle ready state
} else {
   // Handle null state
}

不要尝试处理未经检查的异常

通常不值得花费精力处理未经检查的异常。这些大多数是程序员无法控制的,并且需要大量的努力才能从中恢复。例如,ArrayIndexOutOfBoundsException,虽然是编程错误的结果,但在运行时很难处理。假设修改数组索引变量是可行的,可能不清楚应该为其分配什么新值,或者如何重新执行有问题的代码序列。

注意

永远不要捕获 ThrowableError 异常。这些异常不应该被处理或抑制。

总结

程序中的正确异常处理将增强其健壮性和可靠性。trycatchfinally 块可用于在应用程序中实现异常处理。在 Java 7 中,添加了 try-with-resources 块,更容易处理资源的打开和关闭。还可以将异常传播回调用序列。

我们学到了捕获块的顺序很重要,以便正确处理异常。此外,| 运算符可以在捕获块中使用,以相同的方式处理多个异常。

异常处理可能嵌套以解决在捕获块或 finally 块中的代码可能也会抛出异常的问题。当这种情况发生时,程序员需要小心确保之前的异常不会丢失,并且新的异常会得到适当处理。

我们还解决了处理异常时可能出现的一些常见问题。它们提供了避免结构不良和容易出错的代码的指导。这包括在异常发生时不要忽略异常,并在适当的级别处理异常。

现在我们已经了解了异常处理过程,我们准备在下一章结束我们对 Java 认证目标的覆盖。

涵盖的认证目标

本章涵盖的认证目标包括:

  • 描述 Java 中异常的用途

  • 区分已检查的异常、运行时异常和错误

  • 创建一个 try-catch 块,并确定异常如何改变正常的程序流程

  • 调用一个抛出异常的方法

  • 识别常见的异常类和类别

测试你的知识

  1. 以下哪些实现了已检查的异常?

a. Class A extends RuntimeException

b. Class A extends Throwable

c. Class A extends Exception

d. Class A extends IOException

  1. 给定以下一组类:

class Exception A extends Exception {}

class Exception B extends A {}

class Exception C extends A {}

class Exception D extends C {}

以下try块的 catch 块的正确顺序是什么?

try {
   // method throws an exception of the above types
}

a. 捕获ABCD

b. 捕获DCBA

c. 捕获DBCA

d. 捕获CDBA

  1. 以下哪些陈述是真的?

a. 已检查的异常是从Error类派生的异常。

b. 应该通常忽略已检查的异常,因为我们无法处理它们。

c. 必须重新抛出已检查的异常。

d. 应该在调用堆栈中的适当方法中处理已检查的异常。

  1. 当一个方法抛出一个已检查的异常时,以下哪些是有效的响应?

a. 将方法放在 try-catch 块中。

b. 不要使用这些类型的方法。

c. 通常无法处理已检查的异常,因此不做任何处理。

d. 在调用这个方法的方法上使用throws子句。

  1. 以下代码可能在运行时生成什么异常?
String s;
int i = 5;
try{
   i = i/0;
   s += "next";
}

a. ArithmeticException

b. DivisionByZeroException

c. FileNotFoundException

d. NullPointerException

第九章:Java 应用程序

在本章中,我们将从包的角度来检查 Java 应用程序的结构。将介绍包和导入语句的使用,以及用于包的基础目录结构。

我们还将看到 Java 如何通过使用区域设置和资源包支持国际化。将介绍 JDBC 的使用,以及如何回收未使用的对象。这通常被称为垃圾回收

代码组织

代码的组织是应用程序的重要部分。可以说,正是这种组织(以及数据组织)决定了应用程序的质量。

Java 应用程序是围绕包组织的。包含类的包。类包含数据和代码。代码可以在初始化列表或方法中找到。这种基本组织如下图所示:

代码组织

代码可以被认为在性质上既是静态的又是动态的。Java 程序的组织在静态上围绕包、类、接口、初始化列表和方法进行结构化。这种组织的唯一变化来自执行程序的不同版本。然而,当程序执行时,不同的可能执行路径导致执行序列通常变得复杂。

Java API 被组织成许多包,其中包含数百个类。新的包和类正在不断添加,使得跟上 Java 的所有功能变得具有挑战性。

然而,正如在第七章继承和多态中的对象类部分中提到的,Java 中的所有类都有一个基类—java.lang.Object—直接或间接地。在您定义的类中,如果不明确扩展另一个类,Java 将自动从Object类扩展此类。

包的目的是将相关的类和其他元素组合在一起。理想情况下,它们形成一组连贯的类和接口。一个包可以包括:

  • 接口

  • 枚举

  • 异常

类似功能的类应该自然地被组合在一起。大多数 Java 的 IO 类都被分组在java.iojava.nio相关的包中。所有 Java 的网络类都在java.net包中找到。这种分组机制为我们提供了一个更容易讨论和处理的单一逻辑分组。

所有类都属于一个包。如果未指定包,则类属于一个未命名的默认包。该包包括未声明为属于某个包的目录中的所有类。

包的目录/文件组织

要将类放入包中,有必要:

  • 在类源文件中使用包语句

  • 将相应的.class文件移动到包目录中

包语句需要是类源文件中的第一个语句。该语句由关键字package和包的名称组成。以下示例声明了类Phone属于acme.telephony包:

package acme.telephony;

class Phone {
   …
}

Java 源代码文件以.java扩展名保存在与类同名的文件中。如果一个文件中保存了多个类,则只能声明一个类为 public,并且文件必须以此公共类命名。java.lang包包含许多常用的类,并且在每个应用程序中自动包含。

第二个要求是将类文件移动到适当的包目录中。系统中的某个地方必须存在一个反映包名的目录结构。例如,对于包名employee.benefits,需要有一个名为employee的目录,其中有一个名为benefits的子目录。employee包的所有类文件都放在employee目录中。employee.benefits包的所有类文件都放在benefits子目录中。下图显示了这一点,其中目录和文件位于C驱动器的某个地方:

包的目录/文件组织

您可能还会发现一个包的目录和类被压缩成一个Java 存档JAR)或.jar文件。如果您在目录系统中寻找特定的包结构,可能会找到一个 JAR 文件。通过将包压缩成 JAR 文件,可以最小化内存。如果找到这样的文件,请不要解压缩,因为 Java 编译器和 JVM 希望它们在 JAR 文件中。

大多数集成开发环境将源文件和类文件分开放置在不同的目录中。这种分离使它们更容易处理和部署。

导入语句

import语句向编译器提供了关于在程序中使用的类的定义在哪里找到的信息。关于导入语句有几点需要考虑,我们将在下文中进行讨论:

  • 它的使用是可选的。

  • 使用通配符字符

  • 访问具有相同名称的多个类

  • 静态导入语句

避免使用 import 语句

import语句是可选的。在下面的例子中,我们没有使用import语句来导入BigDecimal类,而是直接在代码中明确使用包名:

private java.math.BigDecimal balance;
     …
this.balance = new java.math.BigDecimal("0");

这更冗长,但更具表现力。它毫无疑问地表明BigDecimal类是在java.math包中找到的。但是,如果我们在程序中多次使用该类,这将变得很烦人。通常会使用import语句。

使用导入语句

为了避免每个类都要用其包名作为前缀,import语句可以用来指示编译器类的位置。在这个例子中,java.io包的BufferedReader类可以在使用时不必每次都用其包名作为前缀:

import java.io.BufferReader;
   …
   BufferedReader br = new BufferedReader();

使用通配符字符

如果需要使用多个类,并且它们在同一个包中,可以使用星号代替包括多个导入语句,每个类一个。例如,如果我们需要在应用程序中同时使用BufferedReaderBufferedWriter类,可以使用两个导入语句,如下所示:

import java.io.BufferedReader;
import java.io.BufferedWriter;

通过显式列出每个类,代码的读者将立即知道在哪里找到该类。否则,当通配符字符与多个导入语句一起使用时,读者可能会猜测一个类来自哪个包。

虽然每个类的显式导入更好地记录了文档,但导入列表可能会变得很长。大多数集成开发环境支持折叠或隐藏列表的功能。

另一种方法是使用带有星号的单个导入语句,如下所示:

import java.io.*;

现在可以在不使用包名的情况下使用该包的所有元素。但是,这并不意味着子包的类可以以同样的方式使用。例如,有许多以java.awt开头的包。以下图表显示了其中一些及其元素:

使用通配符字符

当针对“基本”包使用通配符字符时,可能会觉得通配符字符应该包括这些附加包中找到的类,如下面的代码所示:

import java.awt.*;

但是,它只导入java.awt包中的那些类,而不导入java.awt.font或类似包中的任何类。为了还引用java.awt.font的所有类,需要第二个导入语句:

import java.awt.*;
import java.awt.font.*;

具有相同名称的多个类

由于可能在不同的包中有多个同名类,因此导入语句用于指定要使用的类。但是,第二个类将需要显式使用包名称。

例如,假设我们在com.company.account包中创建了一个BigDecimal类,并且我们需要使用它和java.math.BigDecimal类。我们不能为两个类使用导入,如下面的代码片段所示,因为这将生成一个语法错误,指出名称冲突。

import java.math.BigDecimal;
import com.company.customer.BigDecimal;

相反,我们需要:

  • 使用导入语句声明一个,并在使用时显式前缀第二个类名,或者

d.根本不使用导入语句,并在使用它们时显式前缀两个类

假设我们使用import语句与java.math类,我们在代码中使用两个类,如下所示:

this.balance = new BigDecimal("0");
com.company.customer.BigDecimal secondary = 
   new com.company.customer.BigDecimal();

请注意,我们必须在第二个语句中为BigDecimal的两个用法添加前缀,否则它会假定未加前缀的那个在java.math包中,从而生成类型不匹配的语法错误。

静态导入语句

静态导入语句可用于简化方法的使用。这通常与println方法一起使用。在下面的示例中,我们多次使用println方法:

System.out.println("Employee Information");
System.out.println("Name: ");
System.out.println("Department: ");
System.out.println("Pay grade: ");

在每种情况下,都需要System类名。但是,如果我们使用以下import语句,其中添加了static关键字,我们将不需要使用System类名:

import static java.lang.System.out;

以下代码语句序列实现了相同的结果:

out.println("Employee Information");
out.println("Name: ");
out.println("Department: ");
out.println("Pay grade: ");   

虽然这种方法节省了输入时间,但对于不了解静态导入语句的人来说可能会感到困惑。

垃圾回收

Java 执行自动垃圾回收。使用new关键字分配内存时,内存是从程序堆中获取的。这是程序堆栈上方的内存区域。分配的对象将由程序保留,直到程序释放它。这是通过删除对对象的所有引用来完成的。一旦释放,垃圾回收例程最终将运行并回收对象分配的内存。

以下代码序列说明了如何创建String对象。然后将其分配给第二个引用变量:

String s1 = new String("A string object");
String s2 = s1;

此时,s1s2都引用字符串对象。以下图表说明了s1s2的内存分配:

垃圾回收

在这种情况下,使用new关键字确保从堆中分配了字符串对象。如果我们使用字符串文字,如下所示,对象将分配给内部池,如第二章中的字符串比较部分所讨论的那样,Java 数据类型及其用法

String s1 = "A string object";

下面的两个语句说明了如何删除对对象的引用:

s1 = null;
s2 = null;

执行这些语句后,应用程序的状态如下图所示:

垃圾回收

存在一个 JVM 后台线程,定期执行以回收未使用的对象。在将来的某个时候,线程将执行。当对象准备回收时,线程将执行以下操作:

  • 执行方法的finalize方法

  • 回收内存以供堆管理器重用

finalize方法通常不由开发人员实现。它最初的目的是对应于诸如 C++之类的语言中找到的析构函数。它们用于执行清理活动。

在 Java 中,不应依赖于方法执行。对于小型程序,垃圾回收例程可能永远不会运行,因为程序可能在有机会执行之前终止。多年来,已经尝试了几次提供程序员强制执行方法的能力。这些尝试都没有成功。

资源包和 Locale 类

Locale类用于表示世界的一部分。与区域关联的是一组与货币或日期显示方式控制相关的约定。使用区域有助于应用的国际化。开发人员指定区域,然后在应用的各个部分中使用该区域。

除了Locale类,我们还可以使用资源包。它们提供了一种根据区域设置自定义外观的方式,适用于除数字和日期之外的数据类型。在处理根据区域设置更改的字符串时特别有用。

例如,GUI 应用程序将具有不同的可视组件,其文本在世界不同地区使用时应有所不同。在西班牙,文本和货币应以西班牙语显示。在中国,应使用中文字符和约定。使用区域设置可以简化将应用程序适应世界不同地区的过程。

在本节中,我们将讨论用于支持应用国际化的三种方法:

  • 使用Locale

  • 使用属性资源文件

  • 使用ListResourceBundle

使用 Locale 类

为了说明区域设置的使用,我们首先创建Locale类的实例。该类具有许多预定义的区域设置常量。在以下示例中,我们将创建一个美国的区域设置,然后显示该区域设置:

Locale locale;

locale = Locale.US;
System.out.println(locale);

输出如下:

en_US

第一部分en_代表英语。第二部分指定为美国。如果我们将区域设置更改为德国如下:

locale = Locale.GERMANY;
System.out.println(locale);

您将得到以下输出:

de_DE

您可以使用区域设置格式化货币值。在以下示例中,我们使用静态的getCurrencyInstance方法使用美国的区域返回NumberFormat类的实例。然后使用format方法对双精度数进行格式化:

NumberFormat currencyFormatter = 
   NumberFormat.getCurrencyInstance(Locale.US);
System.out.println(currencyFormatter.format(23.45));

输出如下:

$23.45

如果我们使用德国区域设置,将得到以下输出:

23,45 €

日期也可以根据区域设置进行格式化。在以下代码片段中,使用DateFormat类的getDateInstance方法,使用美国的区域设置。format方法使用Date对象获取日期的字符串表示,如以下代码片段所示:

DateFormat dateFormatter = 
   DateFormat.getDateInstance(DateFormat.LONG, Locale.US);
System.out.println(dateFormatter.format(new Date()));

输出将类似于以下内容:

May 2, 2012

在以下代码片段中,我们将使用法国的区域设置:

dateFormatter = DateFormat.getDateInstance(
   DateFormat.LONG, Locale.FRANCE);
System.out.println(dateFormatter.format(new Date()));

此示例的输出如下:

2 mai 2012

使用资源包

资源包是按区域组织的对象集合。例如,我们可能有一个资源包,其中包含英语用户的字符串和 GUI 组件,另一个资源包适用于西班牙语用户。这些语言组可以进一步分为语言子组,例如美国英语用户与加拿大英语用户。

资源包可以存储为文件,也可以定义为类。属性资源包存储在.properties文件中,仅限于字符串。ListResourceBundle是一个类,可以保存字符串和其他对象。

使用属性资源包

属性资源包是一个文件,包含一组以键值对形式存在的字符串,文件名以.properties结尾。字符串键用于标识特定的字符串值。例如,WINDOW_CAPTION键可以与字符串值Editor相关联。以下是ResourceExamples.properties文件的内容:

WINDOW_CAPTION=Editor
FILE_NOT_FOUND=The file could not be found
FILE_EXISTS=The file already exists
UNKNOWN=Unknown problem with application

要访问资源文件中的值,我们需要创建一个ResourceBundle类的实例。我们可以使用ResourceBundle类的静态getBundle方法来实现这一点,如下面的代码片段所示。请注意,资源文件名被用作方法的参数,但不包括文件扩展名。如果我们知道键,我们可以使用getString方法来返回其对应的值:

ResourceBundle bundle = ResourceBundle.getBundle(
      "ResourceExamples");
System.out.println("UNKNOWN" + ":" +
      bundle.getString("UNKNOWN"));

输出将如下所示:

UNKNOWN:Unknown problem with application

我们可以使用getKeys方法来获取一个Enumeration对象。如下面的代码片段所示,用于显示文件的所有键值对的枚举:

ResourceBundle bundle = ResourceBundle.getBundle(
      "ResourceExamples");

Enumeration keys = bundle.getKeys();
while (keys.hasMoreElements()) {
   String key = (String) keys.nextElement();
   System.out.println(key + ":" + bundle.getString(key));
}

这个序列的输出如下:

FILE_NOT_FOUND:The US file could not be found
UNKNOWN:Unknown problem with application
FILE_EXISTS:The US file already exists
WINDOW_CAPTION:Editor

请注意,输出与ResourceExamples.properties文件的顺序或内容不匹配。顺序由枚举控制。FILE_NOT_FOUNDFILE_EXISTS键的内容不同。这是因为它实际上使用了不同的文件,ResourceExamples_en_US.properties。属性资源包之间存在层次关系。代码序列是在默认区域设置为美国的情况下执行的。系统查找ResourceExamples_en_US.properties文件,因为它代表特定于该区域设置的字符串。资源文件中的任何缺失元素都会从其“基本”文件中继承。

我们将创建四个不同的资源包文件,以说明资源包的使用以及它们之间的层次关系:

  • ResourceExamples.properties

  • ResourceExamples_en.properties

  • ResourceExamples_en_US.properties

  • ResourceExamples_sp.properties

这些在层次上是相关的,如下图所示:

使用属性资源包

这些文件将包含四个键的字符串,如下表所示:

文件
WINDOW_CAPTION 编辑器
FILE_NOT_FOUND 文件找不到
FILE_EXISTS 文件已经存在
UNKNOWN 应用程序出现未知问题
en WINDOW_CAPTION 编辑器
FILE_NOT_FOUND 英文文件找不到
UNKNOWN 应用程序出现未知问题
en_US WINDOW_CAPTION 编辑器
FILE_NOT_FOUND 美国文件找不到
FILE_EXISTS 美国文件已经存在
UNKNOWN 应用程序出现未知问题
sp FILE_NOT_FOUND El archivo no se pudo encontrar
FILE_EXISTS El archivo ya existe
UNKNOWN Problema desconocido con la aplicación

en条目缺少FILE_EXISTS键的值,sp条目缺少WINDOW_CAPTION键。它们将继承默认资源文件的值,如下所示,对于en区域设置:

bundle = ResourceBundle.getBundle("ResourceExamples",
      new Locale("en"));
System.out.println("en");
keys = bundle.getKeys();
while (keys.hasMoreElements()) {
   String key = (String) keys.nextElement();
   System.out.println(key + ":" + bundle.getString(key));
}

输出列出了FILE_EXISTS的值,即使它在ResourceExamples_en.properties文件中找不到:

en
WINDOW_CAPTION:Editor
FILE_NOT_FOUND:The English file could not be found
UNKNOWN:Unknown problem with application
FILE_EXISTS:The file already exists

这些文件的继承行为允许开发人员基于基本文件名创建资源文件的层次结构,然后通过添加区域设置后缀来扩展它们。这将导致自动使用特定于当前区域设置的字符串。如果需要的区域设置不同于默认区域设置,则可以指定特定的区域设置。

使用ListResourceBundle

ListResourceBundle类也用于保存资源。它不仅可以保存字符串,还可以保存其他类型的对象。但是,键仍然是字符串值。为了演示这个类的使用,我们将创建一个从ListResourceBundle类派生的ListResource类,如下所示。创建一个包含键值对的静态二维对象数组。请注意,最后一对包含一个ArrayList。类的getContents方法将资源作为二维对象数组返回:

public class ListResource extends ListResourceBundle {

   @Override
   protected Object[][] getContents() {
      return resources;
   }

   static Object[][] resources = {
      {"FILE_NOT_FOUND", "The file could not be found"},
      {"FILE_EXISTS", "The file already exists"},
      {"UNKNOWN", "Unknown problem with application"},
      {"PREFIXES",new 
            ArrayList(Arrays.asList("Mr.","Ms.","Dr."))}

   };
}

创建的ArrayList旨在存储各种名称前缀。它使用asList方法创建,该方法传递可变数量的字符串参数,并将一个List返回给ArrayList构造函数。

以下代码演示了如何使用ListResource。创建了一个ListResource的实例,然后使用字符串键执行了getString方法。对于PREFIXES键,使用了getObject方法:

System.out.println("ListResource");
ListResource listResource = new ListResource();

System.out.println(
   listResource.getString("FILE_NOT_FOUND"));
System.out.println(
   listResource.getString("FILE_EXISTS"));
System.out.println(listResource.getString("UNKNOWN"));
ArrayList<String> salutations = 
       (ArrayList)listResource.getObject("PREFIXES");
for(String salutation : salutations) {
   System.out.println(salutation);
}

此序列的输出如下:

ListResource
The file could not be found
The file already exists
Unknown problem with application
Mr.
Ms.
Dr.

使用 JDBC

JDBC 用于连接到数据库并操作数据库中的表。使用 JDBC 的过程包括以下步骤:

  1. 连接到数据库

  2. 创建要提交到数据库的 SQL 语句

  3. 处理生成的结果和任何异常

在 Java 7 中,使用 JDBC 已经通过添加 try-with-resources 块得到增强,该块简化了连接的打开和关闭。有关此块的详细说明,请参见第八章中的“使用 try-with-resource 块”部分。

连接到数据库

连接到数据库涉及两个步骤:

  1. 加载适当的驱动程序

  2. 建立连接

这假设数据库已经设置并且可访问。在以下示例中,我们将使用 MySQL Version 5.5。MySQL 带有包含customer表的Sakila模式。我们将使用此表来演示各种 JDBC 技术。

加载适当的驱动程序

首先我们需要加载一个驱动程序。 JDBC 支持多种驱动程序,如developers.sun.com/product/jdbc/drivers中所讨论的。在这里,我们将使用MySQLConnector/J驱动程序。我们使用Class类的forName方法加载驱动程序,如下面的代码片段所示:

try {
   Class.forName(
            "com.mysql.jdbc.Driver").newInstance();
} catch (InstantiationException | 
         IllegalAccessException |
         ClassNotFoundException e) {
   e.printStackTrace();
}

该方法会抛出几个需要捕获的异常。

请注意,从 JDBC 4.0 开始,不再需要上述序列,假设使用的 JDBC 驱动程序支持 JDBC 4.0。这适用于与 MySQL Version 5.5 一起使用的 MySQL 驱动程序。这里使用此序列是因为您可能会在旧程序中遇到这种方法。

建立连接

接下来,需要建立与数据库的连接。 java.sql.Connection表示与数据库的连接。 DriverManager类的静态getConnection方法将返回与数据库的连接。它的参数包括:

  • 代表数据库的 URL

  • 用户 ID

  • 一个密码

以下代码序列将使用 try-with-resources 块来建立与数据库的连接。第一个参数是特定于 MySQL 的连接字符串。连接字符串是供应商特定的:

try (Connection connection = DriverManager.getConnection(
      "jdbc:mysql://localhost:3306/", "id", "password")) {
         ...
} catch (SQLException e) {
   e.printStackTrace();
}

创建 SQL 语句

接下来,我们需要创建一个Statement对象,用于执行查询。 Connection类的createStatement方法将返回一个Statement对象。我们将把它添加到 try-with-resources 块中以创建对象:

try (Connection connection = DriverManager.getConnection(
      "jdbc:mysql://localhost:3306/", "root", "explore");
     Statement statement = connection.createStatement()) {
      ...
} catch (SQLException e) {
   e.printStackTrace();
}

然后形成一个查询字符串,该字符串将选择customer表中address_id小于 10 的那些客户的名字和姓氏。我们选择此查询以最小化结果集的大小。使用executeQuery方法执行查询并返回一个包含与所选查询匹配的表的行的ResultSet对象:

try (Connection connection = DriverManager.getConnection(
      "jdbc:mysql://localhost:3306/", "root", "explore");
     Statement statement = connection.createStatement()) {
      String query = "select first_name, last_name"
         + " from sakila.customer "
         + "where address_id < 10";
      try (ResultSet resultset = 
                        statement.executeQuery(query)) {
         ...
      }
         ...
} catch (SQLException e) {
   e.printStackTrace();
}

处理结果

最后一步是使用 while 循环遍历结果集并显示返回的行。在下面的示例中,next方法将在resultset中从一行移到另一行。getString方法返回与指定要访问的列对应的值的方法参数:

try (Connection connection = DriverManager.getConnection(
      "jdbc:mysql://localhost:3306/", "root", "explore");
     Statement statement = connection.createStatement()) {
      String query = "select first_name, last_name"
         + " from sakila.customer "
         + "where address_id < 10";
      try (ResultSet resultset = 
                        statement.executeQuery(query)) {
         while (resultset.next()) {
            String firstName = 
                       resultset.getString("first_name");
            String lastName = 
                       resultset.getString("last_name");
            System.out.println(firstName + " " + lastName);
         }
      }
} catch (SQLException e) {
   e.printStackTrace();
}

输出如下:

MARY SMITH
PATRICIA JOHNSON
LINDA WILLIAMS
BARBARA JONES
ELIZABETH BROWN

JDBC 支持其他 SQL 语句的使用,如updatedelete。此外,它支持使用参数化查询和存储过程。

摘要

在本章中,我们重新审视了 Java 应用程序的整体结构。我们讨论了importpackage语句的使用,并讨论了包库与其支持的目录/文件之间的关系。我们学习了如何在import语句中使用星号通配符。此外,我们看到了静态导入语句的使用。

我们讨论了初始化程序列表的使用以及 Java 中垃圾回收的工作原理。这个过程导致对象在不再需要时自动回收。

我们探讨了国际化的支持,从Locale类开始,然后是资源包。我们讨论了属性资源包和ListResourceBundle类。我们学习了当使用一致的命名约定组织时,属性资源包的继承如何工作。

最后,我们讨论了 JDBC 的使用。我们看到需要驱动程序来建立与数据库的连接,以及如何使用Statement类来检索ResultSet对象。这个对象允许我们遍历select查询返回的行。

涵盖的认证目标

本章涵盖的认证目标包括:

  • 定义 Java 类的结构

  • 根据区域设置选择资源包

  • 使用适当的 JDBC API 提交查询并从数据库中读取结果。

测试你的知识

  1. 以下哪个将不会出现编译错误?

a. package somepackage;

import java.nio.*;

class SomeClass {}

b. import java.nio.*;

package somepackage;

class SomeClass {}

c. /*这是一个注释*/

package somepackage;

import java.nio.*;

class SomeClass {}

  1. 对于资源属性文件的层次结构,如果一个键在一个派生文件中丢失,根据丢失的键,以下哪些关于返回的值是真的?

a. 返回值将是一个空字符串

b. 返回值将是一个空值

c. 返回值将是来自基本资源包的字符串

d. 会抛出运行时异常

  1. forName方法不会抛出哪个异常:

a. InstantiationException

b. ClassNotFoundException

c. ClassDoesNotExistException

d. IllegalAccessException

附录 A:检验你的知识 - 答案

第一章:开始学习 Java

问题编号 答案 解释
1 a 第二个命令行参数被显示。
2 a,b 和 d 选项 c 是不正确的,因为你不能将一个双精度值赋给一个整数变量。

第二章:Java 数据类型及其用法

问题编号 答案 解释
1 c 你不能从静态方法中访问实例变量。
2 c 和 e 选项 a 是不正确的,因为单引号用于字符数据。选项 b 需要后缀f,如3.14159f。字节只接受-128 到+127 的值。
3 b 和 d 选项 a 是不正确的,因为实例变量需要与对象一起使用。选项 c 是不正确的,因为实例变量不能与类名一起使用。
4 a,b 和 d 没有StringBuilder toInteger方法。
5 b lastIndexOf方法接受一个 char 参数。charAt方法返回位置上的字母。indexOf的最后一个用法不同时接受字符串和 char 参数。
6 c 选项 a 只比较对象的相等性。选项 b 是不正确的,因为没有matchCase这样的方法。在选项 d 中,equals方法使用了两个字符串中不同的大小写。

第三章:决策结构

问题编号 答案 解释
1 b %运算符是取模运算符,返回余数。
2 a 和 c 选项 b 评估为-24。选项 d 评估为 11。
3 b 位序列 0001000 向右移动 3 个位置,使用零填充。
4 a 和 c 选项 b 导致ij之间的比较,返回一个布尔值。这个值不能与整数k进行比较。选项 d 在表达式>k之前需要一个操作数。
5 b 默认情况可以放置在 switch 语句的任何位置。由于除了第一个之外的所有情况都缺少 break 语句,流会穿过最后三个情况。虽然不常见,但常量可以用于 switch 语句。

第四章:使用数组和集合

问题编号 答案 解释

| 1 | a 和 d | 数组声明中的元素数量在声明中没有使用。然而,我们可以使用以下内容:

int arr[] = new int[5];

|

2 b 多维数组的至少第一个维度必须被指定。
3 a 和 c 如果找到对象,contains方法将返回 true,indexOf接受一个对象引用并返回对象的索引(如果找到),否则返回-1。indexOf方法不接受整数参数,而hasObject方法不存在。

第五章:循环结构

问题编号 答案 解释
1 a 和 d 其他选项不起作用,因为表达式不会求值为布尔值。
2 b,c 和 d 你不能在 for-each 语句中使用[]
3 a 和 b 选项 c 需要在表达式i < 5周围加上括号。如果在dowhile关键字之间使用了多个语句,则选项 d 需要一个块语句。
4 a,b,c 和 d 它们都是等价的。
5 a continue 语句跳过了j的值为3

第六章:类、构造函数和方法

问题编号 答案 解释
1 a,c 和 d 选项 b 未能正确初始化数组。
2 c 你不能从静态方法中访问实例方法。
3 a 重载方法时不考虑返回值。
4 c 和 d 最后一行是一个方法,恰好与构造函数同名。由于定义了构造函数但没有默认构造函数,该类没有默认构造函数。
5 a 和 b 在声明类时只能使用privatepublic关键字,private关键字只能用于内部类。
6 c 由于这些类在同一个包中,除了私有方法之外,所有方法都是可见的。
7 d main中的i变量没有被修改,因为它是按值传递的。虽然字符串是按引用传递的,但是在第三个方法中修改的是局部变量s,而不是main方法中的变量。

第七章:继承和多态

问题编号 答案 解释
1 a 和 d 这导致ClassC成为ClassA的“孙子”。
2 a 和 d 重载只会发生在同一个类内。不需要有一个基类。一个常见的实现接口也可以用于多态行为。
3 d 其他方法不存在。
4 b 其他的会生成语法错误。
5 c 第一个答案只用作构造函数的第一个语句。第二个答案会生成语法错误。第四个选项会导致无限递归。
6 a 抽象类不一定要有抽象方法,可以扩展其他类,无论它们是否是抽象的。通常可以找到实现接口的抽象类。

第八章:在应用程序中处理异常

问题编号 答案 解释
---
1 c 和 d 已检查的异常是那些扩展了Exception类但没有扩展RuntimeException类的类。
2 b 和 c 应该首先捕获派生最多的类。在同一层次结构的类的顺序并不重要。
3 d 应该处理已检查的异常。它们可以使用 try-catch 块处理,也可以重新抛出到调用堆栈中更适合处理异常的另一个方法中。
4 a 和 d 我们通常可以处理已检查的异常,并且应该使用它们。
5 a 和 d DivisionByZeroException不存在。这里不执行任何文件操作。

第九章:Java 应用程序

问题编号 答案 解释
1 a 和 c 包声明必须在任何其他代码之前。但是,注释可以出现在代码的任何位置。
2 c 如果存在的话,将返回来自基本资源包的字符串。
3 c 这个异常不存在。
posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报