Java-学习指南第六版-三-

256 阅读1小时+

Java 学习指南第六版(三)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:集合与泛型

随着我们利用日益增长的对象知识来处理更多有趣的问题,一个经常出现的问题是如何存储我们在解决这些问题过程中操作的数据?我们肯定会使用各种不同类型的变量,但我们还需要更大更复杂的存储选项。我们在“数组”章节中讨论过的数组是一个开始,但是数组有一些限制。在本章中,我们将看到如何使用 Java 集合的概念来高效、灵活地访问大量数据。我们还将看到如何处理我们想要存储在这些大容器中的各种类型的数据,就像我们处理变量中的单个值一样。这就是泛型的用武之地。我们将在“类型限制”中深入讨论它们。

集合

集合是所有类型编程中基础的数据结构。每当我们需要引用一组对象时,就会涉及某种类型的集合。在核心语言级别上,Java 通过数组支持集合。但是数组是静态的,由于长度固定,对于应用程序生命周期内增长和缩小的对象组来说显得笨拙。数组也不擅长表示对象之间的抽象关系。在早期,Java 平台仅有两个基本类来满足这些需求:java.util.Vector类代表动态对象列表,java.util.Hashtable类保存键/值对映射。如今,Java 有了更全面的方法,称为集合框架。该框架标准化了处理各种集合的方式。旧的类仍然存在,但已经被整合到框架中(带有一些古怪之处),通常不再使用。

虽然在概念上简单,集合是任何编程语言中最强大的部分之一。它们实现了管理复杂问题核心的数据结构。基础计算机科学致力于描述如何以最高效的方式实现某些类型的算法来操作集合。 (如何在大型集合中快速找到某物?如何对集合中的项目进行排序?如何高效地添加或删除项目?)掌握这些工具并理解如何使用它们可以使您的代码变得更小更快。它还可以避免重复造轮子。

原始的集合框架有两个主要缺陷。第一个是集合由于需要是无类型的,只能使用未区分的Object而不能使用特定类型如DateString。这意味着每次从集合中取出对象时都必须进行类型转换。这与 Java 的编译时类型安全相悖。但实际上,这不是问题,只是非常繁琐和乏味。第二个问题是,出于实际原因,集合只能处理对象而不能处理原始类型。这意味着每当你想将数字或其他原始类型放入集合时,你必须首先将其存储在包装类中,然后在检索时解包。这些因素的结合使得使用集合的代码更加难以阅读和更加危险。

泛型类型(稍后在“类型限制”中详述)使得真正类型安全的集合可以由程序员控制。除了泛型,原始类型的自动装箱和拆箱意味着在涉及集合时,你通常可以将对象和原始类型视为相等。这些新特性的结合增加了一些安全性,并且可以显著减少你编写的代码量。正如我们将看到的,现在所有的集合类都利用了这些特性。

集合框架围绕java.util包中的少数接口展开。这些接口分为两个层次结构。第一个层次结构从Collection接口派生。这个接口(及其子类)代表一个容器,用来保存其他对象。第二个独立的层次结构基于Map接口,另一个容器,表示一组键值对,其中键可以用来以高效的方式检索值。

集合接口

所有集合的鼻祖是一个名为Collection的接口。它作为容器来保存其他对象,即它的元素。它并不明确指定对象的组织方式;例如,它并不说明是否允许重复对象或对象是否以某种方式有序。这些细节留给子接口或实现类处理。尽管如此,Collection接口定义了一些对所有集合通用的基本操作:

public boolean add( element )

将提供的对象添加到此集合。如果操作成功,则此方法返回true。如果对象已经存在于此集合中并且集合不允许重复,则返回false。此外,某些集合是只读的。如果调用此方法,则这些集合会抛出UnsupportedOperationException

public boolean remove( element )

从此集合中移除指定对象。类似于add()方法,如果从集合中移除对象,则此方法返回true。如果对象在此集合中不存在,则返回false。只读集合在调用此方法时会抛出UnsupportedOperationException

public boolean contains( element )

如果集合包含指定对象,则返回true

public int size()

返回此集合中的元素数。

public boolean isEmpty()

如果此集合没有元素,则返回true

public Iterator iterator()

检查此集合中的所有元素。此方法返回一个Iterator,这是一个可以用来遍历集合元素的对象。我们将在下一节详细讨论迭代器。

另外,方法addAll()removeAll()containsAll()接受另一个Collection,并添加、移除或测试供应集合的所有元素。

集合类型

Collection接口有三个子接口。Set表示不允许重复元素的集合。List是其元素具有特定顺序的集合。Queue接口是具有“头”元素概念的对象缓冲区,该元素是下一个要处理的元素。

集合

Set除了从Collection继承的方法外,没有其他方法。它只是强制执行不允许重复的规则。如果尝试添加已存在于Set中的元素,则add()方法简单地返回falseSortedSet按照规定的顺序维护元素;类似于无法包含重复项的排序列表。您可以使用subSet()headSet()tailSet()方法检索子集(这些子集也是排序的)。这些方法接受一个或两个标记边界的元素。first()last()调用提供对第一个和最后一个元素的访问。comparator()方法返回用于比较元素的对象(关于此方法的更多信息请参见“深入了解:sort() 方法”)。

NavigableSet扩展了SortedSet并添加了一些方法,用于在Set的排序顺序内找到大于或小于目标值的最接近匹配。您可以使用跳跃表等技术有效地实现此接口,使得查找有序元素变得更快。

列表

Collection的下一个子接口是ListList是一个有序集合,类似于数组,但具有用于操作列表中元素位置的方法:

public boolean add(E element )

将指定元素从列表末尾移除。

public void add(int index , E element )

在列表中指定位置插入给定对象。如果位置小于零或大于列表长度,则抛出IndexOutOfBoundsException。原来在指定位置的元素和其后的所有元素都将向上移动一个索引位置。

public void remove(int index )

移除指定位置的元素。所有后续元素向下移动一个索引位置。

public E get(int index )

返回给定位置的元素,但不更改列表。

public Object set(int index , E element )

将给定位置的元素更改为指定对象。必须已经有一个对象在索引处,否则会抛出 IndexOutOfBoundsException。不会影响列表的其他元素。

这些方法中的类型 EList 类的参数化元素类型。CollectionSetList 都是接口类型。这是我们在本章开头提到的泛型特性的一个示例,我们将很快看到这些类型的具体实现。

队列

Queue 是一个行为类似缓冲区的集合。队列维护放入其中的项目的插入顺序,并且有“头”项目的概念。队列可以是先进先出(FIFO 或“按顺序”)或后进先出(LIFO,有时是“最近”或“逆序”),这取决于实现:

public boolean offer(E element), public boolean add(E element)

offer() 方法尝试将元素放入队列,如果成功则返回 true。不同的 Queue 类型可能对元素类型(包括容量)有不同的限制或限制。该方法与从 Collection 继承的 add() 方法不同,它返回一个布尔值而不是抛出异常以指示集合无法接受元素。

public E poll(), public E remove()

poll() 方法移除队列头部的元素并返回它。该方法与 Collectionremove() 方法不同,如果队列为空,则返回 null 而不是抛出异常。

public E peek()

返回头部元素,但不从队列中删除它。如果队列为空,则返回 null

映射接口

集合框架还包括 java.util.Map,它是一组键值对的集合。映射的其他名称包括“字典”或“关联数组”。映射存储和检索具有键值的元素;它们对于像缓存和最小数据库这样的东西非常有用。将值存储在映射中时,您将一个键对象与该值关联起来。当您需要查找值时,映射使用键检索它。

使用泛型(再次出现的 E 类型),Map 类型是使用两种类型进行参数化的:一种用于键,一种用于值。以下片段使用 HashMap,这是一种高效但无序的映射实现类型,我们稍后会讨论它:

    Map<String, Date> dateMap = new HashMap<String, Date>();
    dateMap.put("today", new Date());
    Date today = dateMap.get("today");

在旧代码中,映射简单地将 Object 类型映射到 Object 类型,并需要适当的类型转换来检索值。

Map 的基本操作很简单。在以下方法中,类型 K 是指键参数类型,类型 V 是指值参数类型:

public V put(K key , V value )

将指定的键/值对添加到地图中。如果地图已经包含指定键的值,则旧值将被替换并作为结果返回。

public V get(K key )

从地图中检索与key对应的值。

public V remove(K key )

从地图中删除与key对应的值。返回已删除的值。

public int size()

返回此地图中键/值对的数量。

使用以下方法可以检索地图中的所有键或值:

public Set keySet()

此方法返回一个Set,其中包含此地图中的所有键。

public Collection values()

使用此方法检索此地图中的所有值。返回的Collection可以包含重复的元素。

public Set entrySet()

此方法返回一个Set,该集合包含此地图中的所有键/值对(作为Map.Entry对象)。

Map有一个子接口,SortedMapSortedMap根据键的特定顺序维护其键/值对排序。它提供了subMap()headMap()tailMap()方法来检索排序地图的子集。与SortedSet一样,它还提供了一个comparator()方法,该方法返回一个对象,该对象确定地图键的排序方式。我们将在“更近距离看:sort()方法”中详细讨论这一点。Java 7 添加了一个NavigableMap,其功能与NavigableSet类似;也就是说,它添加了搜索排序元素的方法,以查找大于或小于目标值的元素。

最后,我们应该明确指出,虽然它们相关,但Map不是Collection的一种类型(Map不扩展Collection接口)。你可能会想为什么。Collection接口的所有方法似乎都适用于Map,除了iterator()。再次强调,Map有两组对象:键和值,并且分别有迭代器。这就是为什么Map不实现Collection的原因。如果您确实想要Map的类似Collection的视图,包含键和值,您可以使用entrySet()方法。

关于地图的另一个说明:某些地图实现(包括 Java 的标准HashMap)允许使用null作为键或值,但其他地图则不允许。

类型限制

泛型是关于抽象的。泛型允许您创建在不同类型的对象上以相同方式工作的类和方法。术语generic源于我们希望能够编写通用算法,这些算法可以广泛地重用于许多类型的对象,而不是必须使我们的代码适应每种情况。这个概念并不新鲜;这正是面向对象编程背后的推动力。Java 泛型并不是向语言添加新功能,而是使可重用的 Java 代码更易于编写和阅读。

泛型将重用推向了一个新的水平,通过使我们处理的对象的 类型 成为泛型代码的显式参数。因此,泛型也被称为 参数化类型。对于泛型类来说,开发者在使用泛型类型时指定一个类型作为参数(一个参数),代码则根据提供的类型进行自适应。

在其他语言中,泛型有时被称为 模板,这更多是一种实现术语。模板就像是中间类,等待其类型参数以便使用。Java 走了一条不同的路线,这既有利也有弊,我们将在本章节详细描述。

Java 泛型有很多值得探讨的地方。一些细节起初可能显得有点晦涩,但不要灰心。你将会大量使用泛型,例如使用现有的类如 ListSet,这些都是简单直观的。设计和创建你自己的泛型需要更谨慎的理解,以及一点耐心和试验。

我们从直觉的角度开始讨论泛型的最引人注目的案例:刚才提到的容器类和集合。接下来,我们退一步,看看 Java 泛型的好坏与丑陋。最后,我们将看几个 Java 中真实世界的泛型类。

容器:打造更好的捕鼠器

请记住,在像 Java 这样的面向对象编程语言中,多态性 意味着对象总是在某种程度上可互换的。任何类型对象的子类都可以替代其父类型,最终,每个对象都是 java.lang.Object 的子类:可以说是面向对象的“夏娃”。

Java 中最一般类型的容器通常与类型 Object 一起工作,因此它们可以容纳几乎任何内容。通过 容器,我们指的是以某种方式持有其他类实例的类。我们在前一节中看到的 Java 集合框架就是容器的最佳例子。List,简而言之,持有一个类型为 Object 的有序元素集合。而 Map 则持有键值对的关联,其键和值也是最一般的类型 Object。通过原始类型的包装器的帮助,这种安排已经为我们服务良好。但是(不要太深奥),“任何类型的集合”也是“没有类型的集合”,而且使用 Object 带来了开发者很大的责任。

这有点像对象的化装派对,每个人都戴着同样的面具,消失在集合的人群中。一旦对象穿上Object类型的服装,编译器就再也看不到真正的类型并且无法跟踪它们。用户需要稍后使用类型转换来穿透对象的匿名性。就像试图拔掉派对参与者的假胡须一样,您最好确保类型转换是正确的,否则会得到一个不受欢迎的惊喜:

    Date date = new Date();
    List list = new ArrayList();
    list.add(date);
    // other code that might add or remove elements ...
    Date firstElement = (Date)list.get(0); // Is the cast correct? Maybe.

List接口有一个接受任何类型Objectadd()方法。在这里,我们分配了一个ArrayList的实例,它只是List接口的一个实现,并添加了一个Date对象。这个例子中的转换是否正确?这取决于省略的“其他代码”段内发生了什么。

Java 编译器知道这种类型的活动是危险的,并且在您向简单的ArrayList添加元素时发出警告,就像上面的例子一样。我们可以通过一个小小的jshell迂回看到这一点。在从java.utiljavax.swing包导入后,尝试创建一个ArrayList并添加一些不同的元素:

jshell> import java.util.ArrayList;

jshell> import javax.swing.JLabel;

jshell> ArrayList things = new ArrayList();
things ==> []

jshell> things.add("Hi there");
|  Warning:
|  unchecked call to add(E) as a member of the raw type java.util.ArrayList
|  things.add("Hi there");
|  ^--------------------^
$3 ==> true

jshell> things.add(new JLabel("Hi there"));
|  Warning:
|  unchecked call to add(E) as a member of the raw type java.util.ArrayList
|  things.add(new JLabel("Hi there"));
|  ^--------------------------------^
$5 ==> true

jshell> things
things ==> [Hi there, javax.swing.JLabel[...,text=Hi there,...]]

无论您添加的是什么类型的对象,您都可以看到警告是相同的。在最后一步,当我们显示things的内容时,普通的String对象和JLabel对象都在列表中。编译器并不担心使用不同的类型;它友好地警告您,它不知道像上面的(Date)转换在运行时是否会起作用。

可以修复容器吗?

自然而然地会问,是否有办法改善这种情况。如果我们知道我们只会将Date放入我们的列表中,我们不能只创建一个只接受Date对象的列表,消除转换,再次让编译器帮助我们吗?也许令人惊讶的答案是,不行。至少,不是以一种令人满意的方式。

我们的第一反应可能是尝试在子类中“重写”ArrayList的方法。但当然,重写add()方法在子类中实际上并没有覆盖任何东西;它会添加一个新的重载方法:

    public void add(Object o) { ... } // still here
    public void add(Date d) { ... }   // overloaded method

结果对象仍然接受任何类型的对象——它只是调用不同的方法来实现这一点。

继续前进,我们可能会承担更大的任务。例如,我们可以编写自己的DateList类,该类不是扩展ArrayList,而是将其方法的实质部分委托给ArrayList的实现。通过相当多的单调工作,我们可以得到一个对象,它可以做所有List做的事情,但以一种编译器和运行时环境都能理解和强制执行的方式处理Date。然而,我们现在给自己挖了个大坑,因为我们的容器不再是List的一个实现。这意味着我们不能与所有处理集合的实用程序(如Collections.sort())互操作,也不能使用Collection addAll()方法将其添加到另一个集合中。

总结一下,问题在于我们并不想细化对象的行为,我们真正想做的是改变它们与用户的契约。我们希望调整它们的方法签名以适应更具体的类型,而多态性无法做到这一点。所以我们是否为我们的集合困于Object?这就是泛型的用武之地。

进入泛型

如前一节介绍类型限制时所指出的,泛型增强了允许我们为特定类型或一组类型定制类的语法。泛型类在引用类类型时需要一个或多个类型参数。它们用于自定义自身。

例如,如果你查看List类的源代码或 Javadoc,你会看到它定义了类似这样的内容:

public class List< E > {
  // ...
  public void add(E element) { ... }
  public E get(int i) { ... }
}

角括号(<>)之间的标识符E类型参数。¹ 它指示List类是泛型的,并需要一个 Java 类型作为参数以使其完整。名称E是任意的,但随着我们继续,会看到一些惯例。在这种情况下,类型参数E代表我们希望存储在列表中的元素类型。List类在其体和方法中引用类型参数,就好像它是一个真实的类型,稍后会被替换。类型参数可以用于声明实例变量、方法参数和方法的返回类型。在这种情况下,E用作我们将通过add()方法添加的元素的类型,以及get()方法的返回类型。让我们看看如何使用它。

当我们想使用List类型时,同样的角括号语法提供了类型参数:

    List<String> listOfStrings;

在这个片段中,我们使用了泛型类型List声明了一个名为listOfStrings的变量,其类型参数为StringString指的是String类,但我们也可以有一个以任何 Java 类类型为类型参数的专门化List。例如:

    List<Date> dates;
    List<java.math.BigDecimal> decimals;
    List<HelloJava> greetings;

通过提供其类型参数来完成类型称为实例化该类型。有时也称为调用该类型,类比于调用方法并提供其参数。与普通的 Java 类型不同,我们简单地通过名称引用类型,像List<>这样的泛型类型必须在使用时用参数实例化。² 具体而言,这意味着我们必须在可以出现类型的任何地方实例化类型:作为变量的声明类型(如本代码片段所示),作为方法参数的类型,作为方法的返回类型,或者在使用new关键字的对象分配表达式中。

回到我们的listOfStrings,现在实际上是一个List,其中String类型已经替换了类体中的类型变量E

public class List< String > {
  // ...
  public void add(String element) { ... }
  public String get(int i) { ... }
}

我们已将List类专门化为仅与String类型的元素一起使用。此方法签名不再能接受任意的Object类型。

List 只是一个接口。要使用该变量,我们需要创建一些实际的 List 实现的实例。正如我们在介绍中所做的那样,我们将使用 ArrayList。与以前一样,ArrayList 是实现 List 接口的类,但在这种情况下,ListArrayList 都是泛型类。因此,在使用它们的地方需要类型参数来实例化它们。当然,我们将创建我们的 ArrayList 来保存 String 元素以匹配我们的 ListString

    List<String> listOfStrings = new ArrayList<String>();
    // Or shorthand in Java 7.0 and later
    List<String> listOfStrings = new ArrayList<>();

如往常一样,new 关键字接受一个 Java 类型和可能包含类构造函数参数的括号。在这种情况下,类型是 ArrayList<String>——泛型 ArrayList 类型实例化为 String 类型。

声明变量(如上例中第一行所示)有点麻烦,因为它要求我们在变量类型的左侧和初始化表达式的右侧各提供一次泛型参数类型。在复杂情况下,泛型类型可以变得非常冗长且相互嵌套。

编译器足够智能,可以从您分配给变量的表达式的类型中推断出初始化表达式的类型。这称为泛型类型推断,其本质在于您可以通过在变量声明的右侧省略 <> 符号的内容来使用简写,如示例的第二个版本所示。

现在我们可以使用我们专门的字符串 List。编译器甚至阻止我们尝试将除 String 对象(如果有的话还有子类型)之外的任何东西放入列表中。它还允许我们使用 get() 方法获取 String 对象,而无需进行任何强制转换:

jshell> ArrayList<String> listOfStrings = new ArrayList<>();
listOfStrings ==> []

jshell> listOfStrings.add("Hey!");
$8 ==> true

jshell> listOfStrings.add(new JLabel("Hey there"));
|  Error:
|  incompatible types: javax.swing.JLabel cannot be converted to java.lang.String
|  listOfStrings.add(new JLabel("Hey there"));
|                    ^---------------------^

jshell> String s = strings.get(0);
s ==> "Hey!"

让我们从 Collections API 中再举一个例子。Map 接口提供了类似字典的映射,将键对象与值对象关联起来。键和值不必是相同类型。泛型 Map 接口需要两个类型参数:一个是键的类型,另一个是值的类型。Javadoc 如下所示:

public class Map< K, V > {
  // ...
  public V put(K key, V value) { ... } // returns any old value
  public V get(K key) { ... }
}

我们可以创建一个 Map,用于按 Integer 类型的“员工 ID”号存储 Employee 对象,如下所示:

    Map< Integer, Employee > employees = new HashMap<Integer, Employee>();
    Integer bobsId = 314; // hooray for autoboxing!
    Employee bob = new Employee("Bob", ...);

    employees.put(bobsId, bob);
    Employee employee = employees.get(bobsId);

在这里,我们使用了 HashMap,它是实现 Map 接口的泛型类。我们用类型参数 IntegerEmployee 实例化了两种类型。现在,Map 只能使用类型为 Integer 的键,并保存类型为 Employee 的值。

我们在这里使用 Integer 来保存我们的数字的原因是,泛型类的类型参数必须是类类型。我们不能使用原始类型(例如 intboolean)参数化泛型类。幸运的是,在 Java 中,原始类型的自动装箱(参见“原始类型的包装类”)几乎使其看起来像是我们可以通过允许我们像使用包装类型一样使用原始类型。

超过 Collections 的许多其他 API 都使用泛型来使您能够将它们适应特定类型。我们将在本书的各个部分讨论它们。

谈论类型

在我们转向更重要的事情之前,我们应该对我们如何描述泛型类的特定参数化方式说几句话。因为最常见和最引人注目的泛型案例是用于类似容器的对象,所以通常会以泛型类型“持有”参数类型的方式来思考。在我们的示例中,我们称我们的 List<String> 为“字符串列表”,因为确实是这样的。类似地,我们可能会称我们的员工映射为“员工 ID 到员工对象的映射”。然而,这些描述更专注于类的行为而不是类型本身。

取而代之的是,考虑一个名为 Trap<E> 的单个对象容器,可以实例化为 Mouse 类型或 Bear 类型的对象;也就是说,Trap<Mouse>Trap<Bear>。我们本能地称新类型为“捕鼠器”或“熊夹”。我们也可以将我们的字符串列表看作是一个新类型。我们可以讨论“字符串列表”,或将我们的员工映射描述为新的“整数员工对象映射”类型。您可以使用您喜欢的任何措辞,但后一种描述更专注于将泛型视为类型的概念,并且在讨论泛型类型在类型系统中如何相关时,可能会帮助您保持术语的清晰性。在那里,我们将看到容器术语实际上有点反直觉。

在接下来的部分中,我们将从不同的角度讨论 Java 中的泛型类型。我们已经看到它们能做些什么;现在我们需要讨论它们如何做到这一点。

“没有勺子”

在电影 The Matrix 中,主人公尼奥被提出了一个选择:服用蓝色药丸并留在幻想世界中,或者服用红色药丸并看到事物的真实面目。在处理 Java 中的泛型时,我们面临着类似的本体论困境。在讨论泛型时,我们只能走得那么远,然后不得不面对它们如何实现的现实。我们的幻想世界是编译器为了让我们编写代码更容易接受而创造的一个地方。我们的现实(虽然不像电影中的反乌托邦噩梦那样严峻)是一个更加艰难的地方,充满了看不见的危险和问题。为什么强制类型转换和测试在泛型中不能正常工作?为什么我不能在一个类中实现看似两个不同的泛型接口?为什么我可以声明一个泛型类型的数组,即使在 Java 中无法创建这样的数组?!

我们将在本章的其余部分回答这些问题,您甚至无需等待续集。您将很快能够弯曲勺子(好吧,类型)。让我们开始吧。

擦除

Java 通用类型的设计目标是雄心勃勃的:在语言中添加一个全新的语法,安全地引入参数化类型,并且不影响性能,并且,哦,顺便兼容所有现有的 Java 代码,并且不以任何严重的方式改变编译后的类。令人惊讶的是,他们实际上满足了这些条件,也不奇怪这需要一些时间。但是一如既往,一些必要的妥协导致了一些头痛。

为了实现这一功能,Java 采用了一种称为擦除的技术。擦除与这样一个想法有关:由于我们与通用类型的大多数操作都是在编译时静态应用的,通用信息不需要在编译后的类中保留。编译器强制执行的类的通用特性可以在二进制类中被“擦除”,以保持与非通用代码的兼容性。

虽然 Java 在编译形式中保留了关于类的通用特性的信息,但这些信息主要由编译器使用。Java 运行时根本不知道通用类型(generics),也不会浪费任何资源在其上。

我们可以使用jshell来确认参数化的List<E>在运行时仍然是一个List

jshell> import java.util.*;

jshell> List<Date> dateList = new ArrayList<Date>();
dateList ==> []

jshell> dateList instanceof List
$3 ==> true

但是我们的通用dateList显然没有实现刚刚讨论的List方法:

jshell> dateList.add(new Object())
|  Error:
|  incompatible types: java.lang.Object cannot be converted to java.util.Date
|  dateList.add(new Object())
|               ^----------^

这说明了 Java 通用类型的有些古怪的性质。编译器相信它们,但运行时却说它们是幻觉。如果我们尝试一些更简单的事情并检查我们的dateList是否是一个List<Date>

jshell> dateList instanceof List<Date>;
|  Error:
|  illegal generic type for instanceof
|  dateList instanceof List<Date>;
|                      ^--------^

这次编译器直截了当地说:“不行。” 你不能在instanceof操作中测试一个通用类型。由于在运行时没有可辨别不同参数化的List的类(每个List仍然是一个List),instanceof运算符无法区分一个List的不同实例。所有的通用安全检查都是在编译时完成的,因此在运行时我们只是处理一个单一的实际List类型。

事实上是这样的:编译器抹去了所有的尖括号语法,并在我们的List类中用一个在运行时可以与任何允许的类型一起工作的类型替换了类型参数:在这种情况下,是Object。我们似乎回到了起点,只是编译器仍然具有在编译时强制我们使用通用类型的知识,并且因此可以为我们处理类型转换。如果你反编译一个使用了List<Date>(使用javap命令和*-c*选项显示字节码,如果你敢的话),你会看到编译后的代码实际上包含了到Date的转换,尽管我们自己并没有写。

现在我们可以回答本节开始时提出的一个问题:“为什么我不能在一个类中实现看起来是两个不同的泛型接口?”我们不能有一个类同时实现两个不同的泛型List实例化,因为它们在运行时实际上是相同类型,没有办法区分它们:

public abstract class DualList implements List<String>, List<Date> { }
// Error: java.util.List cannot be inherited with different arguments:
//    <java.lang.String> and <java.util.Date>

幸运的是,总有办法解决。例如,在这种情况下,您可以使用一个共同的超类或创建多个类。虽然这些替代方法可能不那么优雅,但您几乎总能找到一个干净的答案,即使有点冗长。

原始类型

尽管编译器在编译时将泛型类型的不同参数化视为不同的类型(具有不同的 API),但我们已经看到在运行时只存在一个真正的类型。例如,List<Date>List<String>共享旧式的 Java 类ListList被称为泛型类的原始类型。每个泛型都有一个原始类型。它是“普通”的 Java 形式,所有泛型类型信息已被移除,类型变量被一般的 Java 类型如Object替换。

在 Java 中可以使用原始类型。然而,Java 编译器在以“不安全”方式使用它们时生成警告。在jshell之外,编译器仍然会注意到这些问题:

    // nongeneric Java code using the raw type
    List list = new ArrayList(); // assignment ok
    list.add("foo"); // Compiler warning on usage of raw type

此代码片段像 Java 5 之前的老式 Java 代码一样使用了原始的List类型。不同之处在于现在 Java 编译器在我们尝试向列表中插入对象时会发出未经检查的警告

% javac RawType.java
Note: RawType.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

编译器指导我们使用-Xlint:unchecked选项,以获取有关不安全操作位置的更详细信息:

% javac -Xlint:unchecked MyClass.java
RawType.java:6: warning: [unchecked] unchecked call to add(E)
as a member of the raw type List
    list.add("foo");
            ^
  where E is a type-variable:
    E extends Object declared in interface List

请注意,创建和分配原始的ArrayList并不会生成警告。只有当我们尝试使用“不安全”的方法(引用类型变量的方法)时才会收到警告。这意味着仍然可以使用与原始类型相关的旧式非泛型 Java API。只有在我们自己的代码中做一些不安全的操作时才会收到警告。

在我们继续之前,还有关于擦除的一件事。在前面的示例中,类型变量被Object类型替换,它可以表示适用于类型变量E的任何类型。后面我们会看到,并非总是这样。我们可以对参数类型设置限制或边界,当我们这样做时,编译器对类型的擦除可以更加严格,例如:

class Bounded< E extends Date > {
  public void addElement(E element) { ... }
}

此参数类型声明表示元素类型E必须是Date类型的子类型。在这种情况下,addElement()方法的擦除因此比Object更加严格,编译器使用Date

  public void addElement(Date element) { ... }

Date被称为此类型的上界,这意味着它是对象层次结构的顶部。您只能在Date或“较低”(更派生或子类化)类型上实例化参数化类型。

现在我们对泛型类型的真实含义有了一些了解,我们可以更详细地探讨它们的行为。

参数化类型关系

我们现在知道参数化类型共享一个普通的原始类型。这就是为什么我们的参数化 List<Date> 在运行时只是一个 List。事实上,如果需要,我们可以将 List 的任何实例分配给原始类型:

    List list = new ArrayList<Date>();

我们甚至可以反过来,将原始类型分配给泛型类型的特定实例:

    List<Date> dates = new ArrayList(); // unchecked warning

此语句在分配时生成了未检查的警告,但之后,编译器相信该列表在分配之前只包含 Date。您可以尝试将 new ArrayList() 强制转换为 List<Date>,但这不会解决警告。我们将在“类型转换”中讨论向泛型类型的转换。

无论运行时类型如何,编译器都掌控着一切。它不允许我们分配明显不兼容的事物:

    List<Date> dates = new ArrayList<String>(); // Compile-time Error!

当然,ArrayList<String> 没有实现编译器需要的 List<Date> 方法,所以这些类型是不兼容的。

但更有趣的类型关系呢?例如,List 接口是更一般的 Collection 接口的子类型。你能将泛型 List 的特定实例分配给某个泛型 Collection 实例吗?这是否取决于类型参数及其关系?显然,List<Date> 不是 Collection<String>。但 List<Date> 是否是 Collection<Date>List<Date> 可以是 Collection<Object> 吗?

首先,我们先快速说出答案,然后详细讲解。到目前为止,我们讨论的简单泛型实例的规则是继承仅适用于“基本”泛型类型,而不适用于参数类型。此外,仅当两个泛型类型在完全相同的参数类型上实例化时,可赋值性才适用。换句话说,仍然存在一维继承,遵循基本泛型类类型,但有一个附加限制,即参数类型必须完全相同。

例如,由于 ListCollection 的一种类型,当类型参数完全相同时,我们可以将 List 的实例分配给 Collection 的实例:

    Collection<Date> cd;
    List<Date> ld = new ArrayList<Date>();
    cd = ld; // Ok!

这段代码片段表明 List<Date>Collection<Date> —— 非常直观。但在参数类型变化的情况下尝试相同的逻辑则失败:

    List<Object> lo;
    List<Date> ld = new ArrayList<Date>();
    lo = ld; // Compile-time Error!  Incompatible types.

虽然我们的直觉告诉我们,List 中的 Date 可以作为 Object 幸福地存活,但分配是一个错误。我们将在下一节中详细解释原因,但现在只需注意类型参数并非完全相同,并且在泛型中,参数类型之间没有继承关系。

这是一个有助于以类型而不是以实例化对象所做的事情的角度来思考的情况。这些实际上并不是“日期列表”和“对象列表”——更像是一个DateList和一个ObjectList,它们之间的关系并不显而易见。

试着挑出下面示例中哪些是可以的,哪些是不可以的:

    Collection<Number> cn;
    List<Integer> li = new ArrayList<Integer>();
    cn = li;

一个List的实例化可以是一个Collection的实例化,但前提是参数类型完全相同。继承不遵循参数类型,所以这个示例中的最后一个赋值失败了。

之前我们提到过,这个规则适用于本章我们讨论过的实例化的简单类型。还有哪些类型呢?嗯,到目前为止我们所见过的实例化类型,我们将一个实际的 Java 类型作为参数插入的那种类型被称为具体类型实例化。稍后,我们将讨论通配符实例化,它们类似于类型的数学集合操作(比如并集和交集)。还可能有更多的泛型实例化,其中类型关系实际上是二维的,取决于基类型和参数化。但不用担心:这种情况并不经常发生,也没有听起来那么可怕。

为什么List<Date>不是List<Object>

这是一个合理的问题。为什么我们不能将我们的List<Date>分配给List<Object>并将Date元素作为Object类型来使用?

原因在于泛型的理论基础:改变编程契约。在最简单的情况下,假设一个DateList类型扩展了一个ObjectList类型,那么DateList将拥有所有ObjectList的方法,我们可以向其中插入Object。现在,你可能会反对说泛型让我们改变了方法签名,所以这不再适用。这是正确的,但有一个更大的问题。如果我们能够将DateList分配给ObjectList变量,我们就可以使用Object方法将类型不是Date的元素插入其中。

我们可以将DateList(提供替代性、更广泛的类型)别名ObjectList。使用别名对象,我们可以试图欺骗它接受其他类型:

    DateList dateList = new DateList();
    ObjectList objectList = dateList; // Can't really do this
    objectList.add(new Foo()); // should be runtime error!

当实际的DateList实现被呈现错误类型的对象时,我们预期会得到一个运行时错误。

这就是问题所在。Java 泛型没有运行时表示。即使这个功能很有用,Java 也无法在运行时知道该做什么。这个特性非常危险——它允许在运行时发生无法在编译时捕获的错误。通常,我们希望在编译时捕获类型错误。

如果您认为 Java 可以通过禁止这些赋值来在编译时不生成未检查的警告来保证代码的类型安全性。不幸的是它不能,但这种限制与泛型无关;它与数组有关。(如果这些对你听起来很熟悉,那是因为我们在第四章中提到了这个问题,与 Java 数组有关。)数组类型具有一种继承关系,允许这种别名发生:

    Date [] dates = new Date[10];
    Object [] objects = dates;
    objects[0] = "not a date"; // Runtime ArrayStoreException!

数组在运行时具有不同的类表示。它们在运行时进行自检,在这种情况下会抛出ArrayStoreException。如果你以这种方式使用数组,Java 编译器无法保证你的代码的类型安全性。

强制类型转换

现在我们已经谈论了泛型类型之间甚至泛型类型与原始类型之间的关系。但我们还没有真正探讨在泛型世界中的强制类型转换的概念。

当我们用泛型与它们的原始类型交换时,是不需要强制类型转换的。但我们会触发编译器的未检查警告:

    List list = new ArrayList<Date>();
    List<Date> dl = list;  // unchecked warning

通常情况下,我们在 Java 中使用强制类型转换来处理可能可赋值的两种类型。例如,我们可以尝试将一个Object转换为Date,因为Object可能是一个Date值。然后强制类型转换会在运行时进行检查,以查看我们是否正确。

在不相关的类型之间进行强制类型转换是一个编译时错误。例如,我们甚至无法尝试将一个Integer转换为String。这些类型之间没有继承关系。那么在兼容的泛型类型之间进行转换呢?

    Collection<Date> cd = new ArrayList<Date>();
    List<Date> ld = (List<Date>)cd; // Ok!

这段代码片段展示了从更一般的Collection<Date>List<Date>的有效转换。这里的转换是合理的,因为一个Collection<Date>可以赋值并且实际上可能是一个List<Date>

类似地,下面的强制类型转换捕获了我们的错误:我们将TreeSet<Date>别名为Collection<Date>,然后尝试将其转换为List<Date>

    Collection<Date> cd = new TreeSet<Date>();
    List<Date> ld = (List<Date>)cd; // Runtime ClassCastException!
    ld.add(new Date());

但是有一种情况下,泛型的强制类型转换是无效的,那就是在尝试根据它们的参数类型来区分类型时:

    Object o = new ArrayList<String>();
    List<Date> ld = (List<Date>)o; // unchecked warning, ineffective
    Date d = ld.get(0); // unsafe at runtime, implicit cast may fail

在这里,我们将一个ArrayList<String>别名为一个普通的Object。接下来,我们将o强制类型转换为List<Date>。不幸的是,Java 在运行时无法区分List<String>List<Date>之间的区别,所以这种强制类型转换是无效的。编译器通过在强制类型转换位置生成未检查的警告来提醒我们。当我们尝试使用强制类型转换的对象ld时,我们可能会发现它是不正确的。由于擦除和缺乏类型信息,泛型类型上的强制类型转换在运行时是无效的。

在集合和数组之间进行转换

尽管它们没有直接的继承关系或共享接口,但在集合和数组之间进行转换仍然很简单。为了方便起见,您可以使用以下方法将集合的元素作为数组检索出来:

    public Object[] toArray()
    public <E> E[] toArray(E[] a)

第一个方法返回一个普通的 Object 数组。通过第二种形式,我们可以更具体地返回正确元素类型的数组。如果我们提供了足够大小的数组,它将用值填充。但如果数组长度太短(例如,长度为零),Java 将创建一个所需长度的相同类型的新数组,并返回它。因此,您可以像这样传递一个空数组来获取正确类型的数组:

    Collection<String> myCollection = ...;
    String [] myStrings = myCollection.toArray(new String[0]);

这个技巧有点笨拙。如果 Java 允许我们使用 Class 引用显式指定类型,会更好,但出于某种原因,它并没有这样做。

另一种方法是,您可以使用 java.util.Arrays 辅助类的静态 asList() 方法将对象数组转换为 List 集合:

    String [] myStrings = { "a", "b", "c" };
    List list = Arrays.asList(myStrings);

编译器还足够智能,能够识别对 List<String> 变量的有效赋值。

迭代器

迭代器 是一种允许您逐步浏览一系列值的对象。这种操作非常常见,因此有一个标准接口:java.util.IteratorIterator 接口有三个有趣的方法:

public E next()

此方法返回关联集合的下一个元素(泛型类型 E 的元素)。

public boolean hasNext()

如果尚未遍历完 Collection 的所有元素,则此方法返回 true。换句话说,如果可以调用 next() 获取下一个元素,则返回 true

public void remove()

此方法从关联的 Collection 中移除从 next() 返回的最近对象。

以下示例显示了如何使用 Iterator 打印集合的每个元素:

  public void printElements(Collection c, PrintStream out) {
    Iterator iterator = c.iterator();
    while (iterator.hasNext()) {
      out.println(iterator.next());
    }
  }

使用 next() 获取下一个元素后,有时可以使用 remove() 将其移除。例如,通过待办事项清单的方式进行处理时,可能会遵循以下模式:“获取一个项目,处理该项目,移除该项目”。但是,迭代器的移除功能并不总是合适,也不是所有迭代器都实现了 remove()。例如,无法从只读集合中移除元素是没有意义的。

如果不允许删除元素,则从此方法中抛出 UnsupportedOperationException。如果在首次调用 next() 之前调用 remove(),或者连续两次调用 remove(),则会抛出 IllegalStateException

遍历集合

在 “for 循环” 中描述的一种形式的 for 循环可以操作所有 Iterable 类型,这意味着它可以迭代所有 Collection 对象类型,因为该接口扩展了 Iterable。例如,它现在可以遍历类型化的 Date 对象集合的所有元素,如下所示:

    Collection<Date> col = ...
    for (Date date : col) {
      System.out.println(date);
    }

Java 内置 for 循环的这个特性称为“增强” for 循环(与预泛型、仅数字 for 循环相对)。增强 for 循环仅适用于 Collection 类型的集合,而不适用于 Map。但在某些情况下,遍历映射可能很有用。您可以使用 Map 方法 keySet()values()(甚至 entrySet() 如果您希望每个键/值对作为单个实体)从您的映射中获取一个可以使用这个增强 for 循环的集合:

    Map<Integer, Employee> employees = new HashMap<>();
    // ...
    for (Integer id : employees.keySet()) {
      System.out.print("Employee " + id);
      System.out.println(" => " + employees.get(id));
    }

键的集合是一个简单的无序集合。上面的增强 for 循环将显示所有您的员工,但它们的打印顺序可能看起来有些随机。如果您希望按其 ID 或者也许是它们的名称列出它们,您需要首先对键或值进行排序。幸运的是,排序是一个非常常见的任务——集合框架可以帮助。

详细解析:sort() 方法

java.util.Collections 类中查找,我们找到了各种用于处理集合的静态实用方法。其中之一是这个好东西——静态泛型方法 sort()

<T extends Comparable<? super T>> void sort(List<T> list) { ... }

另一个我们要解决的难题。让我们专注于边界的最后部分:

Comparable<? super T>

这是我们在 “参数化类型关系” 中提到的通配符实例化。在这种情况下,它是一个接口,因此我们可以将 sort() 方法返回类型中的 extends 理解为 implements

Comparable 包含一个 compareTo() 方法,用于某个参数类型。Comparable<String> 意味着 compareTo() 方法接受类型 String。因此,Comparable<? super T> 是在 T 及其所有超类上的 Comparable 实例化的集合。Comparable<T> 足够,并且在另一端,Comparable<Object> 也是如此。

这在英语中意味着元素必须与它们自己的类型可比较,或者与它们自己的类型的某个超类型可比较,以便 sort() 方法可以使用它们。这确保了所有元素可以相互比较,但并不像说它们都必须自己实现 compareTo() 方法那样具有限制性。一些元素可以从一个知道如何仅与 T 的某个超类型比较的父类继承 Comparable 接口,这正是允许的。

应用:田野上的树木

本章中有很多理论。不要害怕理论——它可以帮助您预测新场景中的行为,并激发解决新问题的解决方案。但实践同样重要,所以让我们回顾一下我们在 “类” 中开始的游戏。特别是,现在是存储每种类型多个对象的时候了。

在 第十三章 中,我们将介绍网络和创建需要存储多个物理学家的两人游戏设置。现在,我们仍然只有一个物理学家可以一次扔一个苹果。但我们可以在我们的场地上种植几棵树作为靶子练习。

让我们添加六棵树。我们将使用一对循环,这样您可以轻松增加树木数量(如果愿意)。我们的Field当前仅存储一个树实例。我们可以将该存储升级为一个类型化列表(我们称之为trees)。从那里,我们可以以多种方式添加和移除树木:

  • 我们可以为Field创建一些处理列表的方法,也许还可以实施其他一些游戏规则(例如管理最大数量的树木)。

  • 我们可以直接使用列表,因为List类已经有了大多数我们想做的事情的好方法。

  • 我们可以结合这些方法的一些组合:适合我们的游戏的特殊方法,以及其他所有地方直接操作。

由于我们确实有一些特定于Field的游戏规则,因此我们将在此处采取第一种方法。(但是请查看示例,并考虑如何修改以直接使用树木列表。)我们将从一个addTree()方法开始。采用这种方法的一个好处是,我们还可以将树实例的创建重定位到我们的方法中,而不是单独创建和操作树。以下是在场地上添加树木的一种方法:

  List<Tree> trees = new ArrayList<>();
  // other field state

  public void addTree(int x, int y) {
    Tree tree = new Tree();
    tree.setPosition(x,y);
    trees.add(tree);
  }

有了这种方法,我们可以很快地添加一些树木:

    Field field = new Field();
    // other setup code
    field.addTree(100,100);
    field.addTree(200,100);

这两行代码并排添加了一对树木。让我们继续编写我们需要创建六棵树的循环:

    Field field = new Field();
    // other setup code
    for (int row = 1; row <= 2; row++) {
      for (int col = 1; col <=3; col++) {
        field.addTree(col * 100, row * 100);
      }
    }

现在你能看到,如果要添加八、九或一百棵树是多么容易了吗?计算机在重复方面做得很好。

为了创建我们的苹果目标森林万岁!尽管我们遗漏了一些关键细节。最重要的是,我们需要在屏幕上显示我们的新森林。我们还需要更新Field类的绘图方法,以便正确理解和使用我们的树木列表。随着我们为游戏添加更多功能,我们也将对物理学家和苹果执行相同的操作。此外,我们还需要一种方法来移除不再活动的元素。但首先,让我们看看我们的森林!

// File: Field.java
  protected void paintComponent(Graphics g) {
    g.setColor(fieldColor);
    g.fillRect(0,0, getWidth(), getHeight());
    for (Tree t : trees) {
      t.draw(g);
    }
    physicist.draw(g);
    apple.draw(g);
  }

由于我们已经在存储我们的树木的Field类中,没有必要编写一个单独的函数来提取单个树并对其进行绘制。我们可以使用巧妙的增强型for循环结构,快速将所有树木放在场地上,如图 7-1 所示。

ljv6 0701

图 7-1。在我们的List中渲染所有树木

有用的特性

Java 集合和泛型是语言中非常强大和有用的附加功能。尽管本章后半部分深入探讨的一些细节可能看起来令人生畏,但通常使用却非常简单和引人注目:泛型使集合更好。随着您对泛型的使用增加,您会发现您的代码变得更加可读和可维护。集合允许优雅、高效的存储。泛型使您之前根据使用推断出来的内容显式化。

复习问题

  1. 如果你想存储一个包含姓名和电话号码的联系人列表,哪种类型的集合最适合?

  2. 你使用什么方法来获取Set中项的迭代器?

  3. 如何将List转换为数组?

  4. 如何将数组转换为List

  5. 你应该实现哪个接口以使用Collections.sort()方法对列表进行排序?

代码练习

  1. ch07exercises文件夹中的EmployeeList类包含了一些员工,加载到一个员工 ID 和Employee对象的映射中,与前面几个示例中使用的类似。我们提到要按 ID 号对这些员工进行排序,但没有展示任何代码。尝试按照他们的 ID 号排序员工。你可能需要使用keySet()方法,然后从该集合创建一个临时但可排序的列表。

  2. 在高级练习 5.1 中,你创建了一个新的障碍类Hedge。更新游戏,使得你可以拥有多个树篱,类似于多个树木。确保当你运行程序时所有的树木和树篱都能正确绘制。

高级练习

  1. 在上述代码练习 1 中,你可能对映射的键进行了排序,然后使用正确排序的键来获取相应的员工。为了更具挑战性,尝试在你的Employee类中实现Comparable接口。你可以决定如何组织员工:按 ID、姓氏、全名或者可能是这些属性的某种组合。而不是对keySet()集合进行排序,尝试直接通过从你的映射的values()中构建临时列表来对你新建的可比较员工进行排序。

¹ 你可能还会看到术语类型变量。Java 语言规范大多使用参数,所以我们尽量坚持这个术语,但你可能会看到两个名称都在使用。

² 也就是说,除非你想以非泛型方式使用泛型类型。我们稍后会讨论“原始”类型。

³ 如果你们中有些人想要了解本节标题的背景,这里是它的来源。我们的英雄 Neo 正在学习他的超能力。

男孩:不要试图弯曲勺子。那是不可能的。相反,只需试图意识到真相。

Neo:什么真相?

男孩:没有勺子。

Neo:没有勺子?

男孩:那么你会看到不是勺子弯曲,只有你自己在弯曲。

—瓦卓斯基姐弟。黑客帝国。136 分钟。华纳兄弟,1999 年。

第八章:文本和核心实用程序

如果你按顺序阅读本书,你已经学习了所有关于核心 Java 语言构造的内容,包括语言的面向对象方面和线程的使用。现在是时候转变思路,开始讨论组成标准 Java 包并随每个 Java 实现提供的类集合了。Java 的核心包是其最显著的特点之一。许多其他面向对象的语言具有类似的功能,但没有一个像 Java 那样拥有如此广泛的标准化类和工具集。这既是 Java 成功的反映,也是其成功的原因之一。

字符串

我们将首先仔细查看 Java 的String类(更具体地说是java.lang.String)。因为与String一起工作如此基础,了解它们的实现方式及其可执行的操作是非常重要的。String对象封装了一系列 Unicode 字符。在内部,这些字符存储在常规的 Java 数组中,但String对象嫉妒地保护这个数组,并且只能通过其自己的 API 访问它。这是为了支持String是不可变的想法;一旦创建了String对象,就无法更改其值。对String对象的许多操作似乎会改变字符串的字符或长度,但实际上它们只是返回一个新的String对象,该对象复制或内部引用原始所需的字符。Java 实现会努力将在同一类中使用的相同字符串合并为共享字符串池,并在可能的情况下共享String的部分。

所有这一切最初的动机都是性能。不可变的String可以节省内存,并且 Java 虚拟机可以优化它们的使用以提高速度。但它们并非神奇。为了避免在性能受到影响的地方创建过多的String对象,您应该对String类有基本的理解。¹

构造字符串

字面字符串在源代码中用双引号定义,并可以分配给String变量:

    String quote = "To be or not to be";

Java 会自动将字面字符串转换为String对象,并将其分配给变量。

String对象在 Java 中跟踪其自身的长度,因此不需要特殊的终止符。您可以使用length()方法获取String的长度。您还可以通过使用isEmpty()测试零长度字符串:

    int length = quote.length();
    boolean empty = quote.isEmpty();

String可以利用 Java 中唯一的重载运算符+进行字符串连接。以下两行产生的字符串是等效的:

    String name = "John " + "Smith";
    // or, equivalently:
    String name = "John ".concat("Smith");

对于大于一个名称的文本块,Java 13 引入了文本块。我们可以通过使用三个双引号来标记多行块的开始和结束,轻松存储一首诗。这个特性甚至以聪明的方式保留了前导空格:最左边的非空格字符成为左侧的“边缘”。在后续行中,在该边缘左侧的空格将被忽略,但该边缘后面的空格将被保留。考虑在jshell中重新制作我们的诗:

jshell> String poem = """
   ...> Twas brillig, and the slithy toves
   ...>    Did gyre and gimble in the wabe:
   ...> All mimsy were the borogoves,
   ...>    And the mome raths outgrabe.
   ...> """;
poem ==> "Twas brillig, and ... the mome raths outgrabe.\n"

jshell> System.out.print(poem);
Twas brillig, and the slithy toves
   Did gyre and gimble in the wabe:
All mimsy were the borogoves,
   And the mome raths outgrabe.

jshell>

在源代码中嵌入长文本通常不是您想做的事情。对于超过几十行的文本,第十章介绍了从文件加载String的方法。

除了从文字表达中生成字符串外,你还可以直接从字符数组构造String

    char [] data = new char [] { 'L', 'e', 'm', 'm', 'i', 'n', 'g' };
    String lemming = new String(data);

你还可以从字节数组构造一个String

    byte [] data = new byte [] { (byte)97, (byte)98, (byte)99 };
    String abc = new String(data, "ISO8859_1");

在这种情况下,String构造函数的第二个参数是字符编码方案的名称。String构造函数使用它来将指定编码中的原始字节转换为运行时选择的内部编码。如果不指定字符编码,则使用系统上的默认编码方案。²

相反,String类的charAt()方法允许你以类似数组的方式访问String的字符:

    String s = "Newton";
    for (int i = 0; i < s.length(); i++)
      System.out.println(s.charAt(i) );

此代码逐个打印字符串的字符。

String类实现java.lang.CharSequence接口,这个概念将String定义为字符序列,并指定了length()charAt()方法作为获取字符子集的方式。

从事物中获取字符串

Java 中的对象和原始类型可以被转换为一个默认的文本表示作为String。对于诸如数字之类的原始类型,字符串应该是显而易见的;对于对象类型,则由对象本身控制。我们可以通过静态的String.valueOf()方法获取项目的字符串表示。这个方法的各种重载版本接受每个原始类型:

    String one = String.valueOf(1); // integer, "1"
    String two = String.valueOf(2.384f);  // float, "2.384"
    String notTrue = String.valueOf(false); // boolean, "false"

Java 中的所有对象都有一个从Object类继承而来的toString()方法。对于许多对象,这个方法返回一个有用的结果,显示对象的内容。例如,java.util.Date对象的toString()方法返回它表示的日期格式化为字符串。对于不提供表示的对象,字符串结果只是一个可以用于调试的唯一标识符。当针对对象调用String.valueOf()方法时,它会调用对象的toString()方法并返回结果。使用这个方法的唯一真正的区别是,如果传递给它一个空对象引用,它会为你返回String“null”,而不是产生NullPointerException

    Date date = new Date();
    // Equivalent, e.g., "Fri Dec 19 05:45:34 CST 1969"
    String d1 = String.valueOf(date);
    String d2 = date.toString();

    date = null;
    d1 = String.valueOf(date);  // "null"
    d2 = date.toString();  // NullPointerException!

字符串连接在内部使用valueOf()方法,因此如果使用加号运算符(+)“添加”对象或原始类型,则会得到一个String

    String today = "Today's date is :" + date;

有时你会看到人们使用空字符串和加号运算符(+)作为快捷方式来获取对象的字符串值。例如:

    String two = "" + 2.384f;
    String today = "" + new Date();

这有点欺骗,但确实有效,而且视觉上简洁。

比较字符串

标准的 equals() 方法可以比较字符串是否 相等;它们必须按相同顺序包含完全相同的字符。你可以使用 equalsIgnoreCase() 方法以不区分大小写的方式检查字符串的等价性:

    String one = "FOO";
    String two = "foo";

    one.equals(two);             // false
    one.equalsIgnoreCase(two);   // true

在 Java 中,初学者常见的错误是在需要 equals() 方法时使用 == 运算符比较字符串。请记住,Java 中字符串是对象,== 测试对象的 身份:即测试两个被测试参数是否是同一个对象。在 Java 中,很容易创建两个具有相同字符但不是同一个字符串对象的字符串。例如:

    String foo1 = "foo";
    String foo2 = String.valueOf(new char [] { 'f', 'o', 'o' });

    foo1 == foo2         // false!
    foo1.equals(foo2)  // true

这个错误特别危险,因为它通常对比较“字面字符串”(直接在代码中用双引号声明的字符串)有效。这是因为 Java 尝试通过组合它们来有效管理字符串。在编译时,Java 找出给定类中的所有相同字符串,并为它们创建一个对象。这是安全的,因为字符串是不可变的,不能改变,但这确实为此比较问题留下了空间。

compareTo() 方法比较 String 的词法值与另一个 String,使用 Unicode 规范比较两个字符串在“字母表”中的相对位置。(我们用引号是因为 Unicode 不仅包含英语字母的许多更多字符。)它返回一个整数,小于、等于或大于零:

    String abc = "abc";
    String def = "def";
    String num = "123";

    if (abc.compareTo(def) < 0) { ... }  // true
    if (abc.compareTo(abc) == 0) { ... } // true
    if (abc.compareTo(num) > 0) { ... }  // true

compareTo() 方法返回的实际值有三种可能性,你不能真正使用它们。任何负数,比如 -1-5-1,000,仅意味着第一个字符串“小于”第二个字符串。compareTo() 方法严格按照 Unicode 规范中字符的位置比较字符串。这对简单文本有效,但不能很好地处理所有语言变体。如果你需要更复杂的比较及更广泛的国际化支持,请查阅 java.text.Collator 类的文档

搜索

String 类提供了几个简单的方法来查找字符串中的固定子字符串。startsWith()endsWith() 方法分别与 String 的开头和结尾的参数字符串进行比较:

    String url = "http://foo.bar.com/";
    if (url.startsWith("http:"))  // true

indexOf() 方法搜索字符或子字符串的第一个出现位置,并返回起始字符位置,如果未找到子字符串,则返回 -1

    String abcs = "abcdefghijklmnopqrstuvwxyz";
    int i = abcs.indexOf('p');     // 15
    int i = abcs.indexOf("def");   // 3
    int I = abcs.indexOf("Fang");  // -1

类似地,lastIndexOf() 向后搜索字符串中字符或子字符串的最后一个出现位置。

contains() 方法处理一个非常常见的任务,即检查目标字符串中是否包含给定的子字符串:

    String log = "There is an emergency in sector 7!";
    if  (log.contains("emergency")) pageSomeone();

    // equivalent to
    if (log.indexOf("emergency") != -1) ...

对于更复杂的搜索,可以使用正则表达式 API,它允许您查找和解析复杂模式。我们将在本章后面讨论正则表达式。

字符串方法摘要

表 8-1 总结了 String 类提供的方法。我们包含了本章未讨论的几种方法。可以在 jshell 中尝试这些方法,或者查看在线文档

表 8-1. 字符串方法

方法功能
charAt()获取字符串中特定位置的字符
compareTo()比较字符串与另一个字符串
concat()将字符串与另一个字符串连接起来
contains()检查字符串是否包含另一个字符串
copyValueOf()返回与指定字符数组等效的字符串
endsWith()检查字符串是否以指定后缀结尾
equals()比较字符串与另一个字符串是否相等
equalsIgnoreCase()忽略大小写比较字符串与另一个字符串
getBytes()将字符从字符串复制到字节数组
getChars()将字符串中的字符复制到字符数组
hashCode()返回字符串的哈希码
indexOf()在字符串中搜索字符或子字符串的第一次出现位置
intern()从全局共享字符串池中获取字符串的唯一实例
isBlank()如果字符串长度为零或仅包含空白字符,则返回 true
isEmpty()如果字符串长度为零,则返回 true
lastIndexOf()在字符串中搜索字符或子字符串的最后一次出现位置
length()返回字符串的长度
lines()返回由行终止符分隔的流
matches()确定整个字符串是否与正则表达式模式匹配
regionMatches()检查字符串的区域是否与另一个字符串的指定区域匹配
repeat()返回重复给定次数的此字符串的连接
replace()将字符串中所有出现的字符替换为另一个字符
replaceAll()使用模式替换字符串中所有正则表达式模式的匹配项
replaceFirst()使用模式替换字符串中第一次出现的正则表达式模式
split()使用正则表达式模式作为分隔符,将字符串拆分为字符串数组
startsWith()检查字符串是否以指定前缀开头
strip()根据 Character.isWhitespace() 定义,移除字符串的前导和尾随空白
stripLeading()类似于 strip(),移除前导空白
stripTrailing()类似于 strip(),移除尾随空白
substring()返回字符串的子串
toCharArray()返回字符串的字符数组
toLowerCase()将字符串转换为小写
toString()返回对象的字符串值
toUpperCase()将字符串转换为大写
trim()删除前导和尾随空白,这里定义为任何 Unicode 位置(称为其代码点)小于或等于 32 的字符(“空格”字符)
valueOf()返回值的字符串表示形式

字符串的用途

解析和格式化文本是一个庞大而开放的主题。到目前为止,在本章中,我们只研究了字符串的原始操作—创建、搜索和将简单值转换为字符串。现在我们想要转向更结构化的文本形式。Java 有一套丰富的 API 用于解析和打印格式化的字符串,包括数字、日期、时间和货币值。我们将在本章中涵盖大多数这些主题,但我们将等待在“本地日期和时间”中讨论日期和时间格式化。

我们将从解析开始—从字符串中读取原始数字和值,并将长字符串切割成标记。然后我们将看一下正则表达式,Java 提供的最强大的文本解析工具。正则表达式允许您定义任意复杂度的模式,搜索它们并从文本中解析它们。

解析原始数字

在 Java 中,数字、字符和布尔值是原始类型—而不是对象。但是对于每种原始类型,Java 还定义了一个原始包装类。具体来说,java.lang包包括以下类:ByteShortIntegerLongFloatDoubleCharacterBoolean。我们在“原始类型的包装器”中谈到过这些,但我们现在提到它们是因为这些类包含了解析其各自类型的静态实用方法。每个这些原始包装类都有一个静态的“parse”方法,它读取一个String并返回相应的原始类型。例如:

    byte b = Byte.parseByte("16");
    int n = Integer.parseInt("42");
    long l = Long.parseLong("99999999999");
    float f = Float.parseFloat("4.2");
    double d = Double.parseDouble("99.99999999");
    boolean b = Boolean.parseBoolean("true");

你可以找到其他将字符串转换为基本类型并再次转换的方法,但这些包装类方法简单直接易于阅读。在IntegerLong的情况下,您还可以提供一个可选的radix参数(数字系统的基数;例如,十进制数字的基数为 10)来转换带有八进制或十六进制数字的字符串。(处理诸如加密签名或电子邮件附件等内容时,非十进制数据有时会出现。)

分词文本

你很少会遇到只有一个数字要解析或只有你需要的单词的字符串。将长字符串解析为由一些分隔符字符(如空格或逗号)分隔的单个单词或标记是一项更常见的编程任务。

程序员们谈论标记(tokens)作为讨论文本中不同值或类型的通用方式。标记可以是一个简单的单词,一个用户名,一个电子邮件地址或一个数字。让我们看几个例子。

考虑下面的样本文本。第一行包含由单个空格分隔的单词。剩下的一对行包括以逗号分隔的字段:

    Now is the time for all good people

    Check Number, Description,      Amount
    4231,         Java Programming, 1000.00

Java 有几种(不幸地重叠)处理此类情况的方法和类。我们将使用 String 类中强大的 split() 方法。它利用正则表达式允许你根据任意模式分割字符串。我们稍后会讨论正则表达式,但现在为了向你展示它是如何工作的,我们先告诉你必要的魔法。

split() 方法接受描述分隔符的正则表达式。它使用该表达式将字符串分割成一个较小的 String 数组:

    String text1 = "Now is the time for all good people";
    String [] words = text1.split("\\s");
    // words = "Now", "is", "the", "time", ...

    String text2 = "4231,         Java Programming, 1000.00";
    String [] fields = text2.split("\\s*,\\s*");
    // fields = "4231", "Java Programming", "1000.00"

在第一个例子中,我们使用了正则表达式 \\s,它匹配单个空白字符(空格、制表符或换行符)。在我们的 text1 变量上调用 split() 返回一个包含八个字符串的数组。在第二个例子中,我们使用了一个更复杂的正则表达式 \\s*,\\s*,它匹配由任意量的可选空白字符包围的逗号。这将我们的文本减少为三个漂亮整洁的字段。

正则表达式

现在是时候在我们通过 Java 的旅程中稍作停顿,进入 正则表达式 的领域了。正则表达式,简称 regex,描述了一个文本模式。正则表达式与许多工具一起使用——包括 java.util.regex 包、文本编辑器和许多脚本语言——提供了复杂的文本搜索和字符串操作能力。

正则表达式可以帮助你在大文件中找到所有的电话号码。它们可以帮助你找到带有特定区号的所有电话号码。它们可以帮助你找到没有特定区号的所有电话号码。你可以使用正则表达式在网页源码中找到链接。甚至可以使用正则表达式在文本文件中进行一些编辑。例如,你可以查找带有括号区号的电话号码,如 (123) 456-7890,并将其替换为更简单的 123-456-7890 格式。正则表达式的强大之处在于,你可以找到文本块中带有括号的 每一个 电话号码,并对其进行转换,而不仅仅是一个特定的电话号码。

如果你已经熟悉了正则表达式的概念以及它们如何与其他语言一起使用,你可能想要快速浏览一下这一部分,但不要完全跳过。至少,你需要稍后查看本章中的 “The java.util.regex API”,它涵盖了使用它们所需的 Java 类。如果你想知道正则表达式到底是什么,那就准备好一罐或一杯你最喜欢的饮料吧。你将在几页之内了解到文本操作工具中最强大的工具,以及一种语言中的微小语言。

正则表达式符号

正则表达式(regex)描述了文本中的模式。通过 模式,我们指的是几乎可以想象出的任何你可以单纯从文本中的文字了解的特征,而不必真正理解它们的含义。这包括诸如单词、单词组合、行和段落、标点、大写或小写,以及更一般地说,具有特定结构的字符串和数字。 (想想电话号码、电子邮件地址或邮政编码之类的东西。)使用正则表达式,你可以搜索字典中所有包含字母“q”但其旁边没有它的“u”的单词,或者以相同字母开头和结尾的单词。一旦你构建了一个模式,你就可以使用简单的工具在文本中搜索它,或确定给定的字符串是否与之匹配。

一次编写,一次逃避

正则表达式构成了一种简单的编程语言形式。想一想我们之前引用的例子。我们需要类似一种语言来描述甚至是简单模式——比如电子邮件地址——它们具有共同的元素但形式上也有些变化。

计算机科学教科书会将正则表达式分类为计算机语言的底层,无论是从它们能描述的内容还是你可以用它们做什么来看。但是,它们仍然有可能非常复杂。与大多数编程语言一样,正则表达式的元素很简单,但你可以将它们组合起来创建一些相当复杂的东西。而这种潜在的复杂性正是事情开始变得棘手的地方。

由于正则表达式适用于字符串,而 Java 代码中的字符串无处不在,因此具有非常紧凑的表示法是很方便的。但紧凑的表示法可能很神秘,经验表明,编写一个复杂的语句要比稍后再次阅读它要容易得多。这就是正则表达式的诅咒。你可能会发现自己在一个深夜、咖啡因推动的灵感时刻,写下一个辉煌的模式来简化你程序的其余部分到一行。然而,当你第二天回来阅读这一行时,它可能对你来说就像埃及象形文字一样难以理解。更简单通常更好,但如果你能更清晰地将问题分解为几个步骤,也许你应该这样做。

转义字符

现在您已经得到了适当的警告,在我们重建您之前,我们还需要介绍一件事情。正则表达式的表示法不仅可能有些复杂,而且在普通的 Java 字符串中使用时也有些模糊。表示法的一个重要部分是转义字符——带有反斜杠的字符。例如,在正则表达式表示法中,转义字符\d(反斜杠d)是任意单个数字字符(0-9)的缩写。然而,您不能简单地在 Java 字符串中写\d,因为 Java 使用反斜杠来表示自己的特殊字符和指定 Unicode 字符序列(\uxxxx)。幸运的是,Java 给了我们一个替代方案:转义的反斜杠:两个反斜杠(\\)。它代表一个字面上的反斜杠。规则是,当您希望在正则表达式中出现反斜杠时,必须用额外的一个反斜杠对其进行转义:

    "\\d" // Java string that yields \d in a regex

它变得更加奇怪了!因为正则表达式表示法本身使用反斜杠来表示特殊字符,所以它必须为自己提供相同的“逃逸舱口”。如果您希望您的正则表达式匹配一个字面上的反斜杠,您需要双倍反斜杠。它看起来像这样:

    "\\\\"  // Java string yields two backslashes; regex yields one

本节中大多数“魔术”运算符字符都作用于它们之前的字符,所以如果要保留它们的字面意义,就必须对它们进行转义。这些字符包括.*+{}()。一个可以匹配标准美国电话号码(带有括号内的区号)的表达式看起来是这样的:

    "\\(\\d\\d\dd\\) \\d\\d\\d-\\d\\d\\d\\d"

如果您需要创建一个表达式的一部分,其中包含许多字面上的字符,您可以使用特殊的分隔符\Q\E来帮助您。出现在\Q\E之间的任何文本都会自动转义。 (您仍然需要 Java 的String转义——对于反斜杠,双反斜杠,但不是四倍。)还有一个名为Pattern.quote()的静态方法,它执行相同的操作,返回您给定字符串的正确转义版本。

当我们在处理这些示例时,我们还有一个建议可以帮助您保持冷静。在实际的 Java 字符串(在其中必须加倍所有反斜杠)上面写出纯正则表达式,我们也倾向于在其中包含一个带有希望匹配的文本示例的注释。这里再次是带有这种注释方法的美国电话号码示例:

    // US phone number: (123) 456-7890
    // regex: \(\d\d\d\) \d\d\d-\d\d\d\d
    "\\(\\d\\d\dd\\) \\d\\d\\d-\\d\\d\\d\\d"

还有别忘了jshell!它可以是一个非常强大的测试和调整模式的场所。我们将在“java.util.regex API”中看到几个在jshell中测试模式的例子。但首先,让我们看看更多可以用来构造模式的元素。

字符和字符类

现在,让我们深入了解实际的正则表达式语法。正则表达式的最简单形式是纯文本,它没有特殊含义,直接(逐个字符)与输入匹配。这可以是一个或多个字符。例如,在下面的字符串中,模式s可以匹配单词“rose”和“is”中的字符s

    "A rose is $1.99."

模式rose只能匹配字面上的单词 rose。但这并不是非常有趣。我们通过引入一些特殊字符和字符“类”的概念来提高一点难度:

任何字符:点.

特殊字符点 (.) 匹配任何单个字符。模式 .ose 匹配“rose”、“nose”、“_ose”(空格后跟“ose”),或者任何其他字符后跟序列“ose”。两个点匹配任何两个字符(“prose”、“close”),依此类推。点操作符非常广泛;通常仅在行终止符(换行符、回车符或两者的组合)时停止。将 . 视为表示所有字符的类。

空白或非空白字符:\s, \S

特殊字符 \s 匹配空白字符。空白字符包括与文本中的视觉空间相关的任何字符,或标记行尾的字符。常见的空白字符包括文字空格字符(按键盘空格键时得到的内容)、\t(制表符)、\r(回车符)、\n(换行符)和 \f(换页符)。对应的特殊字符 \S 则相反,匹配空白字符。

数字或非数字字符\d, \D

\d 匹配从 0 到 9 的任何数字。\D 则相反,匹配除数字外的所有字符。

字母或非字母字符\w, \W

\w 匹配通常在“单词”中找到的字符,例如大写和小写字母 A–Z,a–z,数字 0–9 和下划线字符 (_)。\W 匹配除这些字符以外的所有内容。

自定义字符类

您可以使用方括号 ([ ]) 定义自己的字符类,包围您想要的字符。以下是一些示例:

    [abcxyz]     // matches any of a, b, c, x, y, or z
    [02468]      // matches any even digit
    [aeiouAEIOU] // matches any vowel, upper- or lowercase
    [AaEeIiOoUu] // also matches any vowel

特殊 x-y 范围表示法 可以用作连续运行的字母数字字符的简写:

    [LMNOPQ]     // Explicit class of L, M, N, O, P, or Q
    [L-Q]        // Equivalent shorthand version
    [12345]      // Explicit class of 1, 2, 3, 4, or 5
    [1-5]        // Equivalent shorthand version

在方括号内将插入符号 (^) 作为第一个字符会反转字符类,匹配除了方括号中包括的字符以外的任何字符:

    [^A-F]       // G, H, I, ..., a, b, c, 1, 2, $, #... etc.
    [^aeiou]     // Any character that isn't a lowercase vowel

嵌套字符类简单地将它们连接成一个单一的类:

    [A-F[G-Z]\s] // A-Z plus whitespace

您可以使用 && 逻辑 AND 表示法(类似于我们在 “运算符” 中看到的布尔运算符)来取交集(共同的字符):

    [a-p&&[l-z]] // l, m, n, o, p
    [A-Z&&[^P]]  // A through Z except P

位置标记

模式 [Aa] rose(包括大写或小写字母 A)在以下短语中匹配三次:

    "A rose is a rose is a rose"

位置字符允许您指定匹配在行内的相对位置。最重要的是 ^$,分别匹配行的开头和结尾:

    ^[Aa] rose  // matches "A rose" at the beginning of line
    [Aa] rose$  // matches "a rose" at end of line

要更加精确一些,^$ 匹配“输入”的开头和结尾,通常这是单行。如果您处理多行文本,并希望匹配单个大字符串内行的开头和结尾,可以通过标志打开“多行”模式,如后面在 “特殊选项” 中描述。

位置标记符\b\B匹配单词边界(空格、标点或行的开头或结尾),或非单词边界(单词的中间),分别如下。例如,第一个模式匹配“rose”和“rosemary”,但不匹配“primrose”。第二个模式匹配“primrose”和“prose”,但不匹配单词开头的“rose”或独立的“rose”:

    \brose      // rose, rosemary, roses; NOT prose
    \Brose      // prose, primrose; NOT rose or rosemary

当需要查找或排除前缀或后缀时,通常使用这些位置标记符。

迭代(多重性)

简单地匹配固定字符模式将无法使我们走得更远。接下来,我们看一下可以计数字符(或更一般地说,模式的出现次数,正如我们将在“模式”中看到的那样)的操作符。

任意(零个或多个迭代):星号*

在字符或字符类之后放置星号(*)表示“允许任意数量的该类型字符”——换句话说,零个或更多。例如,下面的模式匹配具有任意数量前导零的数字(可能没有):

    0*\d   // match a digit with any number of leading zeros

一些(一个或多个迭代):加号 (+)

加号(+)表示“一个或多个”迭代,等同于 XX*(模式后跟模式星号)。例如,下面的模式匹配一个带有一个或多个数字的数字,加上可选的前导零:

    0*\d+   // match a number (one or more digits) with optional
            // leading zeros

在表达式开头匹配零似乎是多余的,因为零是一个数字,因此会被表达式中\d+部分匹配。然而,稍后我们将展示如何使用正则表达式分析字符串并仅获取想要的部分。在这种情况下,您可能希望去掉前导零,只保留数字。

可选(零个或一次迭代):问号?

问号操作符(?)允许零次或一次迭代。例如,下面的模式匹配信用卡过期日期,中间可能有或可能没有斜杠:

    \d\d/?\d\d  // match four digits with optional slash in the middle

范围(介于 x 和 y 次迭代之间,包括 x 和 y){x,y}

{x,y} 花括号范围运算符是最常见的迭代运算符。它指定一个精确的匹配范围。一个范围接受两个参数:一个下界和一个上界,用逗号分隔。这个正则表达式匹配任何具有五到七个字符的单词:

    \b\w{5,7}\b  // match words with 5, 6, or 7 characters

至少 x 次或更多次迭代(y 是无穷大){x,}

如果省略上界,只留下范围中的悬挂逗号,上界将变为无限大。这是指定具有无最大次数的最小出现次数的方法。

替换

竖线(|)操作符表示逻辑 OR 操作,也称为替换选择|操作符不操作单个字符,而是应用于其两侧的所有内容。它将表达式分成两部分,除非受到括号分组的限制。例如,对解析日期的稍微天真的方法可能如下所示:

    \w+, \w+ \d+, \d+|\d\d/\d\d/\d\d  // pattern 1 OR pattern 2

在这个表达式中,左侧匹配诸如“Fri, Oct 12, 2001,” 这样的模式,右侧匹配“10/12/01”。

以下正则表达式可能用于匹配具有netedugov三个域名中的电子邮件地址之一:

    \w+@[\w.]+\.(net|edu|gov)
    // email address ending in .net, .edu, or .gov

这个模式在真实有效的电子邮件地址方面并不完整。但它确实突显了如何使用交替来构建具有一些有用特性的正则表达式。

特殊选项

几个特殊选项会影响正则表达式引擎的匹配方式。这些选项可以通过两种方式应用:

  • 你可以在Pattern.compile()步骤中提供一个或多个特殊参数(标志)(见“java.util.regex API”)。

  • 你可以在你的正则表达式中包含一个特殊的代码块。

我们将在这里展示后一种方法。为此,在一个特殊块(?x)中包含一个或多个标志,其中*x是我们想要打开选项的标志。通常,你在正则表达式的开头这样做。你也可以通过添加减号来关闭标志(?-x*),这允许你对模式的部分区域应用标志。

可用以下标志:

不区分大小写(?i)

(?i)标志告诉正则表达式引擎在匹配时忽略字符大小写。例如:

    (?i)yahoo   // matches Yahoo, yahoo, yahOO, etc.

点全部(?s)

(?s)标志打开了“点任意”模式,允许点字符匹配任何内容,包括行尾字符。如果你要匹配跨多行的模式,这很有用。s代表“单行模式”,这个名字有点令人困惑,来源于 Perl。

多行模式(?m)

默认情况下,^$实际上不匹配行的开头和结尾(由回车或换行符组合定义)。相反,它们匹配整个输入文本的开头或结尾。在许多情况下,“一行”与整个输入是同义的。

如果你有一个大文本块需要处理,通常会出于其他原因将该块分成单独的行。如果你这样做,检查给定行是否符合正则表达式将会很简单,并且^$将会按预期行为。然而,如果你想要在包含多行的整个输入字符串上使用正则表达式(由那些回车或换行符组合分隔),你可以打开多行模式(?m)。此标志导致^$匹配文本块内单个行的开头和结尾,以及整个块的开头和结尾。具体来说,这意味着第一个字符之前的位置,最后一个字符之后的位置,以及字符串内的行终止符之前和之后的位置。

Unix 行(?d)

(?d)标志将^$.特殊字符的行终止符定义限制为仅 Unix 风格的换行符(\n)。默认情况下,也允许回车换行符(\r\n)。

java.util.regex API

现在我们已经讨论了如何构建正则表达式的理论部分,困难的部分已经过去了。剩下的就是调查 Java API,看看如何应用这些表达式。

模式

正如我们所说,我们写成字符串形式的正则表达式模式实际上是描述如何匹配文本的小程序。在运行时,Java 正则表达式包将这些小程序编译成一种可以针对某个目标文本执行的形式。几个简单的便捷方法直接接受字符串以用作模式。

静态方法Pattern.matches()接受两个字符串——一个正则表达式和一个目标字符串——并确定目标是否与正则表达式匹配。如果您想在应用程序中进行快速测试,这非常方便。例如:

    Boolean match = Pattern.matches("\\d+\\.\\d+f?", myText);

这行代码可以测试字符串myText是否包含类似“42.0f.”这样的 Java 风格浮点数。请注意,字符串必须完全匹配才能被认为是匹配的。如果你想查看一个小模式是否包含在较大的字符串中,但不关心字符串的其余部分,你必须使用Matcher,如Matcher中所述。

让我们尝试另一个(简化的)模式,一旦我们开始让多个玩家相互竞争,我们可以在我们的游戏中使用。许多登录系统使用电子邮件地址作为用户标识符。当然,这样的系统并不完美,但电子邮件地址将符合我们的需求。我们希望邀请用户输入他们的电子邮件地址,但在使用之前,我们希望确保它看起来是有效的。正则表达式可以快速进行这样的验证[³]。

就像编写算法来解决编程问题一样,设计正则表达式需要您将模式匹配问题分解为易于处理的部分。如果我们考虑电子邮件地址,几个模式立即显而易见。最明显的是每个地址中间的@符号。依赖于这个事实的一个天真(但比没有好!)的模式可以构建如下:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches(".*@.*", sample);

但这个模式太宽容了。它确实能够识别有效的电子邮件地址,但也会识别许多无效的地址,比如"bad.address@""@also.bad"甚至"@@"。让我们在jshell中测试一下:

jshell> String sample = "my.name@some.domain";
sample ==> "my.name@some.domain"

jshell> Pattern.matches(".*@.*", sample)
Pattern.matches(".*@.*", sample)$2 ==> true

jshell> Pattern.matches(".*@.*", "bad.address@")
Pattern.matches(".*@.*", "bad.address@")$3 ==> true

jshell> Pattern.matches(".*@.*", "@@")
Pattern.matches(".*@.*", "@@")$4 ==> true

试着自己制造一些更糟糕的例子。你很快就会发现,我们简单的电子邮件模式确实太简单了。

我们如何做出更好的匹配?一个快速的调整是使用+修饰符而不是*。升级后的模式现在要求@符号两边至少有一个字符。但我们对电子邮件地址还了解其他一些情况。例如,地址的左半部分(名称部分)不能包含@字符。同样,域部分也不能包含。对于这种下一个升级,我们可以使用自定义字符类:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("[^@]+@[^@]+", sample);

这个模式更好一些,但仍然允许一些无效地址,比如"still@bad",因为域名至少有一个名称,后面跟着一个点(.),然后是顶级域(TLD),比如“oreilly.com.” 所以也许可以像这样设置一个模式:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("[^@]+@[^@]+\\.(com|org)", sample);

那个模式修复了我们在像"still@bad"这样的地址上的问题,但我们可能有点过火了。有许多、许多顶级域名后缀 —— 即使我们忽略了随着新的顶级域名后缀的添加而保持该列表的问题,也无法合理地列出所有顶级域名后缀。⁴ 所以让我们稍微退一步。我们会保留域名部分的“点”,但移除特定的顶级域名后缀,只接受简单的字母序列:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("[^@]+@[^@]+\\.[a-z]+", sample);

好多了。我们可以添加最后一个微调,以确保我们不用担心地址的大小写,因为所有电子邮件地址都是不区分大小写的。只需在我们的模式字符串的开头添加(?i)标志即可:

    String sample = "my.name@some.domain";
    Boolean validEmail = Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", sample);

再次强调,这绝不是一个完美的电子邮件验证器,但它绝对是一个很好的开始,足以满足我们虚拟的登录系统的需求:

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "good@some.domain")
$1 ==> true

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "good@oreilly.com")
$2 ==> true

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "oreilly.com")
$3 ==> false

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "bad@oreilly@com")
$4 ==> false

jshell> Pattern.matches("(?i)[^@]+@[^@]+\\.[a-z]+", "me@oreilly.COM")
$5 ==> true

jshell> Pattern.matches("[^@]+@[^@]+\\.[a-z]+", "me@oreilly.COM")
$6 ==> false

在这些例子中,我们只需一次输入完整的Pattern.matches(…​)行。之后只需简单地按向上箭头、编辑,然后按回车键即可获取接下来的五行。你能找出我们最终模式中导致匹配失败的缺陷吗?

注意

如果你想调整验证模式并进行扩展或改进,请记住你可以使用键盘的上箭头在jshell中“重复使用”行。使用向上箭头检索前一行。确实,你可以使用上箭头和下箭头来导航你最近的所有行。在一行内,使用左箭头和右箭头移动、删除、添加或编辑你的命令。然后只需按回车键运行新修改的命令 —— 你不需要在按回车键之前将光标移动到行尾。

Matcher

Matcher关联模式与字符串,并提供测试、查找和迭代匹配项的工具。Matcher是“有状态的”。例如,find()方法每次调用时都会尝试找到下一个匹配项。但你可以通过调用其reset()方法来清除Matcher并重新开始。

要创建一个Matcher对象,首先需要使用静态的Pattern.compile()方法将模式字符串编译成一个Pattern对象。有了该模式对象后,你可以使用matcher()方法获取你的Matcher,如下所示:

    String myText = "Lots of text with hyperlinks and stuff ...";
    Pattern urlPattern = Pattern.compile("https?://[\\w./]*");
    Matcher matcher = urlPattern.matcher(myText);

如果你只对“一次大匹配”感兴趣 —— 也就是说,你期望你的字符串要么与模式匹配要么不匹配 —— 你可以使用matches()lookingAt()。这些方法大致对应于String类的equals()startsWith()方法。matches()方法询问字符串是否完全匹配模式(没有多余的字符串字符)并返回truefalselookingAt()方法做同样的事情,只是它只询问字符串是否以该模式开头,并不在乎模式是否使用完所有字符串的字符。

更普遍地,您希望能够搜索字符串并找到一个或多个匹配项。为此,可以使用find()方法。每次调用find()返回模式的下一个匹配项的truefalse,并在内部记录匹配文本的位置。您可以使用Matcher start()end()方法获取匹配文本的起始和结束字符位置,或者简单地使用group()方法检索匹配的文本。例如:

import java.util.regex.*;

// ...

    String text="A horse is a horse, of course of course...";
    String pattern="horse|course";

    Matcher matcher = Pattern.compile(pattern).matcher(text);
    while (matcher.find())
      System.out.println(
        "Matched: '"+matcher.group()+"' at position "+matcher.start());

前面的代码片段打印了单词“horse”和“course”的起始位置(总共四个):

    Matched: 'horse' at position 2
    Matched: 'horse' at position 13
    Matched: 'course' at position 23
    Matched: 'course' at position 33

字符串拆分

非常常见的需求是根据某个分隔符(例如逗号)将字符串解析为一堆字段。这是一个如此常见的问题,以至于String类中包含了一个专门用于此目的的方法。split()方法接受一个正则表达式,并返回围绕该模式分割的子字符串数组。考虑以下字符串和split()调用:

    String text = "Foo, bar ,   blah";
    String[] badFields = text.split(",");
    // { "Foo", " bar ", "   blah" }
    String[] goodFields = text.split("\\s*,\\s*");
    // { "Foo", "bar", "blah" }

第一个split()返回一个String数组,但是使用逗号进行简单分隔的结果意味着我们的text变量中的空格字符仍然粘在更有趣的字符上。我们得到了“Foo”作为一个单词,正如预期的那样,但接着我们得到了“bar<space>”,最后是“<space><space><space>blah”。哎呀!第二个split()也产生一个String数组,但这次包含了预期的“Foo”, “bar”(没有尾随空格),和“blah”(没有前导空格)。

如果你在代码中要多次使用这样的操作,你应该编译模式并使用它的split()方法,该方法与String中的版本相同。String split()方法等同于:

    Pattern.compile(pattern).split(string);

正如我们之前提到的,关于正则表达式的知识远远超出了我们在这里介绍的几个正则表达式功能。查看模式文档。在自己的jshell上玩耍。修改ch08/examples/ValidEmail.java文件,看看能否创建更好的电子邮件验证器!这绝对是一个需要实践的主题。

数学工具

当然,字符串操作和模式匹配并不是 Java 唯一能做的操作。Java 直接支持整数和浮点数算术。通过java.lang.Math类支持更高级别的数学运算。正如您所见,基本数据类型的包装类允许您将它们视为对象处理。包装类还包含一些基本转换的方法。

让我们快速浏览一下 Java 中的内置算术功能。Java 通过抛出ArithmeticException来处理整数算术中的错误:

    int zero = 0;

    try {
      int i = 72 / zero;
    } catch (ArithmeticException e) {
      // division by zero
    }

要在这个示例中生成错误,我们创建了中间变量zero。编译器有点狡猾。如果我们直接尝试除以0,它会抓住我们。

另一方面,浮点算术表达式不会抛出异常。相反,它们会采用在表 8-2 中显示的特殊超出范围值。

表 8-2. 特殊浮点值

数学表示
POSITIVE_INFINITY1.0/0.0
NEGATIVE_INFINITY-1.0/0.0
NaN0.0/0.0

下面的例子生成一个无限的结果:

    double zero = 0.0;
    double d = 1.0/zero;

    if (d == Double.POSITIVE_INFINITY)
      System.out.println("Division by zero");

特殊值NaN(不是一个数字)表示将零除以零的结果。这个值在数学上有特殊的区别,即它不等于自身(NaN != NaN计算结果为true)。使用Float.isNaN()Double.isNaN()来测试NaN

java.lang.Math

java.lang.Math 类是 Java 的数学库。它包含一系列静态方法,涵盖了所有常见的数学操作,如 sin()cos()sqrt()Math 类不是很面向对象(不能创建 Math 的实例)。相反,它只是一个方便的静态方法的容器,更像是全局函数。正如我们在第五章看到的,可以使用静态导入功能将静态方法和常量的名称直接导入到我们类的范围内,并通过它们简单而不加修饰地使用它们。

表 8-3 总结了 java.lang.Math 中的方法。

Table 8-3. java.lang.Math 中的方法

方法参数类型功能
Math.abs(a)int, long, float, double绝对值
Math.acos(a)double反余弦
Math.asin(a)double反正弦
Math.atan(a)double反正切
Math.atan2(a,b)double矩形到极坐标转换的角度部分
Math.ceil(a)double大于或等于 a 的最小整数
Math.cbrt(a)doublea 的立方根
Math.cos(a)double余弦
Math.cosh(a)double双曲余弦
Math.exp(a)doubleMath.Ea 次幂
Math.floor(a)double小于或等于 a 的最大整数
Math.hypot(a,b)double精确计算 sqrt()a2 + b2
Math.log(a)doublea 的自然对数
Math.log10(a)doublea 的以 10 为底的对数
Math.max(a, b)int, long, float, doubleab 更接近 Long.MAX_VALUE
Math.min(a, b)int, long, float, doubleab 更接近 Long.MIN_VALUE
Math.pow(a, b)doubleab 次幂
Math.random()None随机数生成器
Math.rint(a)double将双精度值转换为双精度格式中的整数值
Math.round(a)float, double四舍五入到整数
Math.signum(a)float, double获取数字的符号,为 1.0,-1.0 或 0
Math.sin(a)double正弦
Math.sinh(a)double双曲正弦
Math.sqrt(a)double平方根
Math.tan(a)double正切
Math.tanh(a)double双曲正切
Math.toDegrees(a)double将弧度转换为角度
Math.toRadians(a)double将角度转换为弧度

方法 log()pow()sqrt() 可能会抛出运行时的 ArithmeticException。方法 abs()max()min() 都针对所有标量值(intlongfloatdouble)进行了重载,并返回相应的类型。Math.round() 的版本接受 floatdouble,分别返回 intlong,其余的方法均操作并返回 double 值:

    double irrational = Math.sqrt(2.0); // 1.414...
    int bigger = Math.max(3, 4);  // 4
    long one = Math.round(1.125798); // 1

只是为了突显静态导入选项的便利性,请在 jshell 中尝试这些简单的函数:

jshell> import static java.lang.Math.*

jshell> double irrational = sqrt(2.0)
irrational ==> 1.4142135623730951

jshell> int bigger = max(3,4)
bigger ==> 4

jshell> long one = round(1.125798)
one ==> 1

Math 还包含静态的最终双精度常量 EPI。例如,要找到圆的周长:

    double circumference = diameter  * Math.PI;

数学的实际应用

我们已经介绍了如何在 “访问字段和方法” 中使用 Math 类及其静态方法。我们可以再次使用它,通过随机化树木出现的位置使我们的游戏更加有趣。Math.random() 方法返回一个大于或等于 0 且小于 1 的随机 double。通过一些算术运算和舍入或截断,您可以使用该值创建任何所需范围内的随机数。特别地,将该值转换为所需范围的方法如下:

    int randomValue = min + (int)(Math.random() * (max - min));

试一试!尝试在 jshell 中生成一个随机的四位数。您可以将 min 设置为 1,000,将 max 设置为 10,000,如下所示:

jshell> int min = 1000
min ==> 1000

jshell> int max = 10000
max ==> 10000

jshell> int fourDigit = min + (int)(Math.random() * (max - min))
fourDigit ==> 9603

jshell> fourDigit = min + (int)(Math.random() * (max - min))
fourDigit ==> 9178

jshell> fourDigit = min + (int)(Math.random() * (max - min))
fourDigit ==> 3789

要放置我们的树木,我们需要两个随机数来获取 x 和 y 坐标。我们可以设置一个边缘周围的范围,通过在边缘周围留出一些空白来保持树木在屏幕上。对于 x 坐标,可能会像这样:

  private int goodX() {
    // at least half the width of the tree plus a few pixels
    int leftMargin = Field.TREE_WIDTH_IN_PIXELS / 2 + 5;
    // now find a random number between a left and right margin
    int rightMargin = FIELD_WIDTH - leftMargin;

    // And return a random number starting at the left margin
    return leftMargin + (int)(Math.random() * (rightMargin - leftMargin));
  }

设置一个类似的方法来查找 y 值,你应该会看到类似于 图 8-1 中显示的图像。您甚至可以使用我们在 第五章 中讨论过的 isTouching() 方法,以避免将任何树木放置在与我们的物理学家直接接触的位置。这是我们升级后的树木设置循环:

  for (int i = field.trees.size(); i < Field.MAX_TREES; i++) {
    Tree t = new Tree();
    t.setPosition(goodX(), goodY());

    // Trees can be close to each other and overlap,
    // but they shouldn't intersect our physicist
    while(player1.isTouching(t)) {
      // We do intersect this tree, so let's try again
      System.err.println("Repositioning an intersecting tree...");
      t.setPosition(goodX(), goodY());
    }
    field.addTree(t);
  }

ljv6 0801

图 8-1. 随机分布的树木

尝试退出游戏并再次启动它。您应该会看到每次运行应用程序时树木的不同位置。

大/精确数值

如果 longdouble 类型对您来说不够大或精确,那么 java.math 包提供了两个类,BigIntegerBigDecimal,支持任意精度的数字。这些功能齐全的类具有大量方法,用于执行任意精度数学运算并精确控制余数的舍入。在以下示例中,我们使用 BigDecimal 来添加两个非常大的数字,然后创建一个带有 100 位小数的分数:

    long l1 = 9223372036854775807L; // Long.MAX_VALUE
    long l2 = 9223372036854775807L;
    System.out.println(l1 + l2); // -2 ! Not good.

    try {
      BigDecimal bd1 = new BigDecimal("9223372036854775807");
      BigDecimal bd2 = new BigDecimal(9223372036854775807L);
      System.out.println(bd1.add(bd2) ); // 18446744073709551614

      BigDecimal numerator = new BigDecimal(1);
      BigDecimal denominator = new BigDecimal(3);
      BigDecimal fraction =
          numerator.divide(denominator, 100, BigDecimal.ROUND_UP);
      // 100-digit fraction = 0.333333 ... 3334
    }
    catch (NumberFormatException nfe) { }
    catch (ArithmeticException ae) { }

如果您为了乐趣实施加密或科学算法,BigInteger 是至关重要的。反过来,BigDecimal 可在涉及货币和财务数据的应用程序中找到。除此之外,您可能不太需要这些类。

日期和时间

如果没有适当的工具,处理日期和时间可能会很繁琐。Java 包含三个类来处理简单的情况。java.util.Date 类封装了一个时间点。java.util.GregorianCalendar 类继承自抽象类 java.util.Calendar,在时间点和日历字段(如月份、日期和年份)之间进行转换。最后,java.text.DateFormat 类知道如何生成和解析多种语言和区域设置下的日期和时间的字符串表示。

虽然 DateCalendar 类涵盖了许多用例,但它们缺乏精确性和其他功能。出现了几个第三方库,旨在使开发人员更容易处理日期、时间和时间段。Java 8 在这方面提供了非常必要的改进,引入了 java.time 包。本章的其余部分将探讨该包,但你仍然会遇到很多 DateCalendar 的示例,因此了解它们的存在是有用的。正如始终如此,在线文档 是回顾我们未涉及的 Java API 部分的宝贵资源。

本地日期和时间

java.time.LocalDate 类代表您本地区域的无时间信息的日期。想象一年一度的事件,例如每年的冬至,即 12 月 21 日。类似地,java.time.LocalTime 表示没有任何日期信息的时间。也许你的闹钟每天早上 7:15 分响起。java.time.LocalDateTime 存储日期和时间值,例如与眼科医生的约会(这样您就可以继续阅读关于 Java 的书)。所有这些类都提供了静态方法来创建新实例,可以使用适当的数值和 of() 方法,或者使用 parse() 方法解析字符串。让我们进入 jshell 并尝试创建一些示例:

jshell> import java.time.*

jshell> LocalDate.of(2019,5,4)
$2 ==> 2019-05-04

jshell> LocalDate.parse("2019-05-04")
$3 ==> 2019-05-04

jshell> LocalTime.of(7,15)
$4 ==> 07:15

jshell> LocalTime.parse("07:15")
$5 ==> 07:15

jshell> LocalDateTime.of(2019,5,4,7,0)
$6 ==> 2019-05-04T07:00

jshell> LocalDateTime.parse("2019-05-04T07:15")
$7 ==> 2019-05-04T07:15

创建这些对象的另一个很棒的静态方法是 now(),它会提供当前的日期、时间或日期时间,正如你期望的那样:

jshell> LocalTime.now()
$8 ==> 15:57:24.052935

jshell> LocalDate.now()
$9 ==> 2023-03-31

jshell> LocalDateTime.now()
$10 ==> 2023-03-31T15:57:37.909038

很棒!在导入 java.time 包后,您可以为特定时刻或“现在”创建每个 Local…​ 类的实例。您可能已经注意到用 now() 创建的对象包括时间的秒和纳秒。如果需要,您可以向 of()parse() 方法提供这些值。虽然这里没有太多激动人心的内容,但一旦您拥有这些对象,您可以做很多事情。继续阅读!

比较和操作日期和时间

使用 java.time 类的一个重要优势是可用于比较和修改日期和时间的一致方法集。例如,许多聊天应用程序会显示消息发送“多久前”的信息。java.time.temporal 子包正是我们所需的:ChronoUnit 接口。它包含几个日期和时间单位,如 MONTHS, DAYS, HOURS, MINUTES 等。这些单位可用于计算时间差。例如,我们可以使用 between() 方法在 jshell 中计算创建两个示例日期时间所需的时间:

jshell> LocalDateTime first = LocalDateTime.now()
first ==> 2023-03-31T16:03:21.875196

jshell> LocalDateTime second = LocalDateTime.now()
second ==> 2023-03-31T16:03:33.175675

jshell> import java.time.temporal.*

jshell> ChronoUnit.SECONDS.between(first, second)
$12 ==> 11

视觉检查显示,确实花费大约 11 秒的时间输入创建我们的 second 变量的行。查看 ChronoUnit 的文档 获取完整列表,但你将获得从纳秒到千年的全范围。

这些单位还可以帮助你使用 plus()minus() 方法操作日期和时间。例如,设置一周后的提醒:

jshell> LocalDate today = LocalDate.now()
today ==> 2023-03-31

jshell> LocalDate reminder = today.plus(1, ChronoUnit.WEEKS)
reminder ==> 2023-04-07

很棒!但是这个 reminder 示例提出了你可能需要偶尔执行的另一个操作。你可能希望在第 7 天的特定时间提醒。你可以使用 atDate()atTime() 方法轻松在日期、时间和日期时间之间进行转换:

jshell> LocalDateTime betterReminder = reminder.atTime(LocalTime.of(9,0))
betterReminder ==> 2023-04-07T09:00

现在你将在上午 9 点收到提醒。但是,如果你在亚特兰大设置提醒然后飞往旧金山,闹钟会在什么时候响?LocalDateTime 是本地的!所以 T09:00 部分无论你何时运行程序都是上午 9 点。但是如果你要处理像安排会议这样涉及不同时区的事情,就不能忽视不同的时区了。幸运的是 java.time 包也考虑到了这一点。

时区

java.time 包的作者鼓励你尽可能使用时间和日期类的本地变体。支持时区意味着向你的应用程序添加复杂性——他们希望你尽可能避免这种复杂性。但是有许多场景是无法避免支持时区的。你可以使用 ZonedDateTimeOffsetDateTime 类处理带“区域”日期和时间。区域变体理解命名时区和夏令时调整等内容。偏移量变体是与 UTC/Greenwich 的恒定简单数值偏移量。

大多数用户界面上使用日期和时间的地方会使用命名区域方法,因此让我们看一下创建带区域日期时间的方法。为了附加一个区域,我们使用 ZoneId 类,它具有用于创建新实例的常见 of() 静态方法。你可以提供一个区域区作为 String 来获取你的区域值:

jshell> LocalDateTime piLocal = LocalDateTime.parse("2023-03-14T01:59")
piLocal ==> 2023-03-14T01:59

jshell> ZonedDateTime piCentral = piLocal.atZone(ZoneId.of("America/Chicago"))
piCentral ==> 2023-03-14T01:59-05:00[America/Chicago]

现在,你可以确保巴黎的朋友可以在正确的时刻加入你,使用命名为 withZoneSameInstant() 的方法:

jshell> ZonedDateTime piAlaMode =
piCentral.withZoneSameInstant(ZoneId.of("Europe/Paris"))
piAlaMode ==> 2023-03-14T07:59+01:00[Europe/Paris]

如果您有其他朋友并非方便位于主要都会区域,但您也希望他们参与,您可以使用ZoneIdsystemDefault()方法以编程方式选择他们的时区:

jshell> ZonedDateTime piOther =
piCentral.withZoneSameInstant(ZoneId.systemDefault())
piOther ==> 2023-03-14T02:59-04:00[America/New_York]

我们在美国东部时区的笔记本电脑上运行jshellpiOther的输出正如预期的那样。systemDefault()时区 ID 是一种非常方便的方式,可以快速调整来自其他时区的日期时间,以便与用户的时钟和日历匹配。在商业应用中,您可能希望让用户告诉您他们首选的时区,但通常systemDefault()是一个很好的猜测。

解析和格式化日期和时间

对于使用字符串创建和显示我们的本地日期时间和带区域的日期时间,我们一直依赖于遵循 ISO 值的默认格式。这些通常在我们需要接受或显示日期和时间的任何地方起作用。但是正如每个程序员所知,“通常”并非“总是”。幸运的是,您可以使用实用类java.time.format.DateTimeFormatter来帮助解析输入和格式化输出。

DateTimeFormatter的核心在于构建一个格式字符串,该字符串管理解析和格式化。您可以通过在表 8-4 中列出的部分选项来构建格式。这里我们仅列出了部分选项,但这些选项应该能够涵盖您遇到的大部分日期和时间。请注意,在使用上述字符时大小写是敏感的!

表 8-4。流行且有用的DateTimeFormatter元素

字符描述示例
a上午/下午PM
d一个月中的日期10
E一周中的日期周二; Tuesday; T
G纪元BCE, CE
k一天中的时钟小时数(1-24)24
K上午/下午的小时数(0-11)0
L月份7 月; July; J
h上午/下午的时钟小时数(1-12)12
H一天中的小时数(0-23)0
m一个小时中的分钟数30
M月份7; 07
s分钟的秒数55
S秒的小数部分033954
u年份(不包含纪元)2004; 04
y纪元年份2004; 04
z时区名称太平洋标准时间; PST
Z时区偏移+0000; -0800; -08:00

举例来说,如果要创建一个常见的美国短格式,您可以使用Mdy字符。您可以通过静态的ofPattern()方法来构建格式化器。现在,您可以使用(并重复使用)任何日期或时间类的parse()方法来使用该格式化器:

jshell> import java.time.format.DateTimeFormatter

jshell> DateTimeFormatter shortUS =
   ...> DateTimeFormatter.ofPattern("MM/dd/yy")
shortUS ==> Value(MonthOfYe ...) ... (YearOfEra,2,2,2000-01-01)

jshell> LocalDate valentines = LocalDate.parse("02/14/23", shortUS)
valentines ==> 2023-02-14

jshell> LocalDate piDay = LocalDate.parse("03/14/23", shortUS)
piDay ==> 2023-03-14

正如我们之前提到的,格式化器可以双向工作。只需使用您的格式化器的format()方法,即可生成日期或时间的字符串表示:

jshell> LocalDate today = LocalDate.now()
today ==> 2023-12-14

jshell> shortUS.format(today)
$30 ==> "12/14/23"

jshell> shortUS.format(piDay)
$31 ==> "03/14/23"

当然,格式化器同样适用于时间和日期时间!

jshell> DateTimeFormatter military =
   ...> DateTimeFormatter.ofPattern("HHmm")
military ==> Value(HourOfDay,2)Value(MinuteOfHour,2)

jshell> LocalTime sunset = LocalTime.parse("2020", military)
sunset ==> 20:20

jshell> DateTimeFormatter basic =
   ...> DateTimeFormatter.ofPattern("h:mm a")
basic ==> Value(ClockHourOfAmPm)': ... ,SHORT)

jshell> basic.format(sunset)
$42 ==> "8:20 PM"

jshell> DateTimeFormatter appointment =
DateTimeFormatter.ofPattern("h:mm a MM/dd/yy z")
appointment ==>
Value(ClockHourOfAmPm)':' ...
0-01-01)' 'ZoneText(SHORT)

注意,在接下来的ZonedDateTime部分中,我们将时区标识符(z字符)放在了最后——这可能不是您预期的位置!

jshell> ZonedDateTime dentist =
ZonedDateTime.parse("10:30 AM 11/01/23 EST", appointment)
dentist ==> 2023-11-01T10:30-04:00[America/New_York]

jshell> ZonedDateTime nowEST = ZonedDateTime.now()
nowEST ==> 2023-12-14T09:55:58.493006-05:00[America/New_York]

jshell> appointment.format(nowEST)
$47 ==> "9:55 AM 12/14/23 EST"

我们希望说明这些格式的强大之处。您可以设计一个格式,以适应非常广泛的输入或输出样式。传统数据和设计不良的 Web 表单显然是直接需要DateTimeFormatter帮助的例子。

解析错误

尽管您可以随时利用这种解析能力,但有时候事情会出错。遗憾的是,您看到的异常通常过于模糊,无法立即派上用场。考虑以下尝试解析包含小时、分钟和秒的时间:

jshell> DateTimeFormatter withSeconds =
   ...> DateTimeFormatter.ofPattern("hh:mm:ss")
withSeconds ==>
Value(ClockHourOfAmPm,2)':' ...
Value(SecondOfMinute,2)

jshell> LocalTime.parse("03:14:15", withSeconds)
|  Exception java.time.format.DateTimeParseException:
|  Text '03:14:15' could not be parsed: Unable to obtain
|  LocalTime from TemporalAccessor: {MinuteOfHour=14, MilliOfSecond=0,
|  SecondOfMinute=15, NanoOfSecond=0, HourOfAmPm=3,
|  MicroOfSecond=0},ISO of type java.time.format.Parsed
|        at DateTimeFormatter.createError (DateTimeFormatter.java:2020)
|        at DateTimeFormatter.parse (DateTimeFormatter.java:1955)
|        at LocalTime.parse (LocalTime.java:463)
|        at (#33:1)
|  Caused by: java.time.DateTimeException:
  Unable to obtain LocalTime from ...
|        at LocalTime.from (LocalTime.java:431)
|        at Parsed.query (Parsed.java:235)
|        at DateTimeFormatter.parse (DateTimeFormatter.java:1951)
|        ...

糟糕!Java 在无法解析字符串输入时会抛出DateTimeParseException异常。在我们上面的例子中,即使正确从字符串中解析了字段,但未提供足够的信息来创建LocalTime对象时,Java 也会抛出异常。也许不太明显,但我们的时间“3:14:15,”可能是下午或清晨。我们选择的hh模式作为小时的原因是罪魁祸首。我们可以选择一个使用明确的 24 小时制的小时模式,或者添加显式的上午/下午元素:

jshell> DateTimeFormatter valid1 =
   ...> DateTimeFormatter.ofPattern("hh:mm:ss a")
valid1 ==> Value(ClockHourOfAmPm,...y,SHORT)

jshell> DateTimeFormatter valid2 =
   ...> DateTimeFormatter.ofPattern("HH:mm:ss")
valid2 ==> Value(HourOfDay,2)': ... Minute,2)

jshell> LocalTime piDay1 =
   ...> LocalTime.parse("03:14:15 PM", valid1)
piDay1 ==> 15:14:15

jshell> LocalTime piDay2 =
   ...> LocalTime.parse("03:14:15", valid2)
piDay2 ==> 03:14:15

如果您曾经遇到DateTimeParseException,但您的输入看起来是格式正确的匹配,请确保您的格式本身包括创建日期或时间所需的所有内容。关于这些异常的最后一点思考:如果您的日期不自然地包括一个诸如CE的纪元指示符,您可能需要使用非助记符u字符来解析年份。

有关DateTimeFormatter的详细信息还有很多,很多。对于这一点,相较于大多数实用程序类而言,阅读在线文档是值得的。

格式化日期和时间

现在您知道如何创建、解析和存储日期和时间了,接下来需要展示这些便捷的数据。幸运的是,您可以使用同一格式化程序创建用于解析日期和时间的漂亮、易读的字符串。还记得我们的withSecondsmilitary格式化程序吗?您可以获取当前时间并快速将其转换为任何格式,如下所示:

jshell> DateTimeFormatter withSeconds =
   ...> DateTimeFormatter.ofPattern("hh:mm:ss")
withSeconds ==> Value(ClockHou ... OfMinute,2)

jshell> DateTimeFormatter military =
   ...> DateTimeFormatter.ofPattern("HHmm")
military ==> Value(HourOfDay,2)Value(MinuteOfHour,2)

jshell> LocalTime t = LocalTime.now()
t ==> 09:17:34.356758

jshell> withSeconds.format(t)
$7 ==> "09:17:34"

jshell> military.format(t)
$8 ==> "0917"

您可以使用从 Table 8-4 中显示的部分构建任何日期或时间模式,以生成这种格式化的输出。进入jshell并尝试创建几个格式。您可以使用LocalTime.now()LocalDate.now()方法创建一些易于格式化测试的目标。

时间戳

java.time理解的另一个流行日期时间概念是时间戳。在任何需要跟踪信息流的情况下,您都需要记录信息生成或修改的确切时间。您仍然会看到java.util.Date类用于存储这些时间点,但java.time.Instant类提供了生成时间戳所需的一切,同时还具备java.time包中其他类的所有其他优势:

jshell> Instant time1 = Instant.now()
time1 ==> 2019-12-14T15:38:29.033954Z

jshell> Instant time2 = Instant.now()
time2 ==> 2019-12-14T15:38:46.095633Z

jshell> time1.isAfter(time2)
$54 ==> false

jshell> time1.plus(3, ChronoUnit.DAYS)
$55 ==> 2019-12-17T15:38:29.033954Z

如果您的工作中涉及日期或时间,java.time包将是一个非常有用的助手。 您有一套成熟、设计良好的工具,用于处理这些数据——无需第三方库!

其他有用的工具

我们已经查看了 Java 的一些构建块,包括字符串和数字,以及其中一个最受欢迎的组合——日期——在LocalDateLocalTime类中。 我们希望这些实用程序的范围为您展示了 Java 如何处理您可能会遇到的许多元素。

请务必阅读关于java.utiljava.textjava.time包的文档,以了解更多可能有用的工具。 例如,您可以查看使用java.util.Random生成图 8-1 中树木随机坐标的方法。 有时,“实用程序”工作实际上是复杂的,并需要仔细的细节注意。 在线搜索其他开发人员编写的代码示例甚至完整库可能加快您的工作进度。

接下来,我们将开始构建这些基本概念。 Java 之所以如此受欢迎,是因为它除了包含基础支持外,还包括更高级技术的支持。 其中之一是“线程”功能,它们已经内置。 线程提供了更好的访问现代强大系统的方式,即使处理许多复杂任务,您的应用程序也能保持高效。 我们将向您展示如何利用这一标志性特性在第九章中。

复习问题

  1. 哪个类包含常量π? 需要导入该类以使用π吗?

  2. 哪个包含了用于替代原java.util.Date类的更好的替代品?

  3. 用于格式化日期以便用户友好输出的类应该是哪个?

  4. 您会在正则表达式中使用什么符号来帮助匹配“yes”和“yup”这两个单词?

  5. 如何将字符串“42”转换为整数 42?

  6. 如何比较两个字符串(例如“yes”和“YES”),以忽略任何大写?

  7. 哪个运算符用于连接字符串?

代码练习

让我们重新审视我们的图形化 Hello Java 应用程序,并使用本章讨论的一些新实用程序和字符串功能进行升级。 您可以从exercises/ch08文件夹中的HelloChapter8类开始。 我们希望程序支持一些用于消息和初始位置的命令行参数。

您的程序应接受 0、1 或 2 个参数:

  • 零参数应该将文本“Hello, utilities!”居中开始。

  • 一个参数应该被视为要显示的消息;应该居中开始:

    • 请记住,多个单词的消息必须用引号括起来。

    • 如果消息是单词today,您的代码应生成一个格式化日期以用作消息。

  • 两个参数分别表示消息和初始坐标以确定显示位置:

    • 坐标应为带有逗号和可选空格分隔的一对数字的引用字符串。以下都是有效的坐标字符串:

      • 150,150

      • 50, 50

      • 100, 220

    • 坐标参数也可以是单词random,意味着您的代码应生成一个随机的初始位置。

以下是一些示例:

$ java HelloChapter8
// "Hello, utilities!" centered in the window
$ java HelloChapter8 "It works!"
// "It works!" centered in the window
$ java HelloChapter8 "I feel cornered" "20,20"
// "I feel cornered" in the upper left corner

如果用户尝试传递三个或更多参数,您的代码应生成错误消息并退出。

从测试参数数量开始。如果您的程序至少获得一个参数,请使用第一个参数作为消息。如果获得两个参数,您需要拆分坐标并将其转换为数字。如果您获得random参数,请确保生成的随机数将使消息保持可见。(您可以假设消息的默认长度是合理的;如果更长的消息右侧被截断,这是可以接受的。)

使用几次运行测试您的解决方案。尝试不同的坐标。尝试随机化选项。连续几次尝试随机化选项以确保起始位置确实改变。如果在第二个参数中拼写random错误会发生什么?

对于进一步的升级:尝试编写一个正则表达式来接受一些random的变体,同时忽略大小写:

  • random

  • rand

  • rndm

  • r

始终可以在附录 B 中找到关于这个问题的一些提示。我们的解决方案位于源代码的ch08/exercises文件夹中。

¹ 当存在疑问时,请测量它!如果您的String操作代码干净且易于理解,请不要重写它,直到有人向您证明它速度太慢。有可能他们是错误的。不要被相对比较所愚弄。毫秒比微秒慢一千倍,但对于您应用程序的整体性能可能是可以忽略不计的。

² 在大多数平台上,默认的编码是 UTF-8。您可以在官方 Javadoc 文档中获取有关 Java 支持的字符集、默认集和标准集的更多详细信息。

³ 验证电子邮件地址比我们在这里能够解决的要困难得多。正则表达式可以涵盖大多数有效的地址,但如果您正在为商业或其他专业应用程序进行验证,您可能希望调查第三方库,例如来自Apache Commons的库。

⁴ 如果您手头有几十万美元,欢迎申请您自己的定制全球顶级域名

float 类型是“单精度”的,而 double 类型则是“双精度”的。(因此得名!)double 类型可以保留大约两倍于 float 类型的精度。任意精度意味着你可以在小数点前后拥有需要的任意位数的数字。公平地说,NASA 使用的 π 值精确到 15 位小数,这对 double 类型来说处理得很好。