Java-技术手册第八版-四-

44 阅读1小时+

Java 技术手册第八版(四)

原文:zh.annas-archive.org/md5/450d5a6a158c65e96e7be41e1a8ae3c7

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:使用 Java 集合

本章介绍了 Java 对基本数据结构的解释,即 Java 集合。这些抽象是许多编程类型的核心,并构成任何程序员基本工具包的重要组成部分。因此,这是整本书中最重要的章节之一,提供了几乎所有 Java 程序员都必不可少的工具包。

在本章中,我们将介绍基本接口和类型层次结构,展示如何使用它们,并讨论它们整体设计的各个方面。我们将涵盖处理集合的“经典”方法以及较新方法(使用 Java 8 中引入的 Streams API 和 lambda 表达式功能)。

引入集合 API

Java 集合是一组描述最常见数据结构形式的通用接口。Java 附带了每个经典数据结构的几种实现,因为这些类型被表示为接口,开发团队非常可能为自己的项目开发出专门的接口实现。

Java 集合定义了两种基本类型的数据结构。Collection 是对象的集合,而 Map 是对象之间的映射或关联集合。Java 集合的基本布局如图 Figure 8-1 所示。

在这个基本描述中,Set 是一种没有重复元素的 Collection 类型,而 List 是元素有序(但可以包含重复元素)的 Collection

JN7 0801

图 8-1。集合类和继承

SortedSetSortedMap 是特殊的集合和映射,它们维护其元素以排序顺序排列。

CollectionSetListMapSortedSetSortedMap 都是接口,但 java.util 包还定义了各种具体实现,如基于数组和链表的列表,以及基于哈希表或二叉树的映射和集合。其他重要的接口包括 IteratorIterable,它们允许您遍历集合中的对象,正如我们将在稍后看到的那样。

Collection 接口

Collection<E> 是一个参数化的接口,表示类型为 E 的对象的广义分组。我们可以创建任何类型的引用类型的集合。

注意

要正确地与集合的期望一起工作,您必须在类上定义 hashCode()equals() 方法时小心,正如 Chapter 5 中所讨论的那样。

定义了向组中添加和删除对象的方法,测试对象是否属于组的方法以及迭代组中所有元素的方法。额外的方法将组的元素作为数组返回,并返回集合的大小。

注意

Collection中的分组可以允许或不允许重复元素,并且可以或不可以对元素进行排序。

Java 集合框架提供Collection,因为它定义了所有常见数据结构形式共享的特性。JDK 提供SetListQueue作为Collection的子接口。

以下代码展示了您可以对Collection对象执行的操作:

// Create some collections to work with.
Collection<String> c = new HashSet<>();  // An empty set

// We'll see these utility methods later. Be aware that there are
// some subtleties to watch out for when using them
Collection<String> d = Arrays.asList("one", "two");
Collection<String> e = Collections.singleton("three");

// Add elements to a collection. These methods return true
// if the collection changes, which is useful with Sets that
// don't allow duplicates.
c.add("zero");           // Add a single element
c.addAll(d);             // Add all of the elements in d

// Copy a collection: most implementations have a copy constructor
Collection<String> copy = new ArrayList<String>(c);

// Remove elements from a collection.
// All but clear return true if the collection changes.
c.remove("zero");        // Remove a single element
c.removeAll(e);          // Remove a collection of elements
c.retainAll(d);          // Remove all elements that are not in d
c.clear();               // Remove all elements from the collection

// Querying collection size
boolean b = c.isEmpty(); // c is now empty, so true
int s = c.size();        // Size of c is now 0.

// Restore collection from the copy we made
c.addAll(copy);

// Test membership in the collection. Membership is based on
// the equals method, not the == operator.
b = c.contains("zero");  // true
b = c.containsAll(d);    // true

// Most Collection implementations have a useful toString()  method
System.out.println(c);

// Obtain an array of collection elements.  If the iterator guarantees
// an order, this array has the same order. The Object array is a new
// instance, containing references to the same objects as the original
// collection `c` (aka a shallow copy).
Object[] elements = c.toArray();

// If we want the elements in a String[], we must pass one in
String[] strings = c.toArray(new String[c.size()]);

// Or we can pass an empty String[] just to specify the type and
// the toArray method will allocate an array for us
strings = c.toArray(new String[0]);

请记住,您可以在任何SetListQueue上使用此处显示的任何方法。这些子接口可能对集合的元素施加成员限制或排序约束,但仍提供相同的基本方法。

注意

诸如addAll()retainAll()clear()remove()等修改集合的方法被设计为 API 的可选部分。不幸的是,它们是很久以前指定的,在那时的普遍观点是通过抛出UnsupportedOperationException来指示可选方法的缺失。因此,一些实现(尤其是只读形式)可能会抛出这个未检查异常。

CollectionMap及其子接口扩展CloneableSerializable接口。然而,Java 集合框架提供的所有集合和映射实现类都实现了这些接口。

一些集合实现对它们可以包含的元素施加限制。例如,一个实现可能禁止null作为元素,而EnumSet限制成员只能是指定枚举类型的值。

试图向集合中添加禁止的元素总是会抛出未检查异常,例如NullPointerExceptionClassCastException。检查集合是否包含禁止元素也可能会抛出这样的异常,或者可能简单地返回false

集合接口

集合是一组对象,不允许重复:它可能不包含对同一对象的两个引用、两个对null的引用,或者对满足a.equals(b)条件的两个对象ab的引用。大多数通用的Set实现对集合的元素不施加任何排序,但不排除有序集合的存在(参见SortedSetLinkedHashSet)。集合还通过通常期望具有在常数或对数时间内运行的高效contains方法来与列表等有序集合区分开来。

SetCollection定义的方法之外没有自己的方法,但对某些方法施加了额外的限制。Setadd()addAll()方法必须强制执行无重复规则:如果集合已经包含该元素,则不能将元素添加到Set中。请记住,由Collection接口定义的add()addAll()方法返回true,如果调用导致对集合的更改,则返回false。对于Set对象,这个返回值很重要,因为无重复的限制意味着添加元素并不总是导致对集合的更改。

表 8-1 列出了Set接口的实现方式,并总结了它们的内部表示、排序特性、成员限制以及基本的add()remove()contains操作的性能,以及迭代性能。请注意,CopyOnWriteArraySet位于java.util.concurrent包中;所有其他实现都属于java.util。还请注意,java.util.BitSet不是Set的实现。这个传统类用作boolean值的紧凑和高效列表,但不属于 Java 集合框架。

表 8-1. Set 实现方式

内部表示元素顺序成员限制基本操作迭代性能备注
HashSet哈希表1.2O(1)O(capacity)最佳通用实现
LinkedHashSet链接哈希集合1.2插入顺序O(1)O(n)保留插入顺序
EnumSet枚举集合5.0枚举声明枚举值O(1)O(n)仅包含非null枚举值
TreeSet红黑树1.2按升序排序ComparableO(log(n))O(n)Comparable元素或Comparator
CopyOnWriteArraySet数组5.0插入顺序O(n)O(n)线程安全,无需同步方法

TreeSet实现使用红黑树数据结构来维护一个根据Comparable对象的自然顺序或由Comparator对象指定的顺序按升序迭代的集合。TreeSet实际上实现了SortedSet接口,这是Set的子接口。

SortedSet接口提供了几种利用其排序特性的有趣方法。以下代码示例:

public static void testSortedSet(String[] args) {
    // Create a SortedSet
    SortedSet<String> s = new TreeSet<>(Arrays.asList(args));

    // Iterate set: elements are automatically sorted
    for (String word : s) {
        System.out.println(word);
    }

    // Special elements
    String first = s.first();  // First element
    String last = s.last();    // Last element

    // all elements but first
    SortedSet<String> tail = s.tailSet(first + '\0');
    System.out.println(tail);

    // all elements but last
    SortedSet<String> head = s.headSet(last);
    System.out.println(head);

    SortedSet<String> middle = s.subSet(first+'\0', last);
    System.out.println(middle);
}
警告

添加\0字符是必要的,因为tailSet()和相关方法使用元素的后继,对于字符串来说,后继是附加有NULL字符(ASCII 码 0)的字符串值。

从 Java 9 开始,API 还升级了Set接口的辅助静态方法,如下所示:

Set<String> set = Set.of("Hello", "World");

此 API 有几个重载版本,每个版本都接受固定数量的参数,还有一个可变参数的重载。后者用于需要任意多个元素的情况,并回退到标准的可变参数机制(在调用之前将元素编组成数组)。值得注意的是,Set.of返回的集合是不可变的,如果在实例化后尝试添加或删除元素,将抛出UnsupportedOperationException异常。

列表接口

List是一组有序的对象。列表中的每个元素在列表中都有一个位置,List接口定义了查询或设置特定位置或索引处元素的方法。在这方面,List类似于一个大小会根据需要变化的数组,以容纳其包含的元素数量。与集合不同,列表允许重复元素。

除了基于索引的get()set()方法之外,List接口还定义了方法,在特定索引处添加或删除元素,并且还定义了返回列表中特定值第一次出现或最后一次出现的索引的方法。从Collection继承的add()remove()方法被定义为将元素追加到列表的末尾,并从列表中删除指定值的第一个出现。从指定集合添加所有元素到列表末尾的addAll()方法的另一个版本将元素插入到指定索引处。retainAll()removeAll()方法像对任何Collection一样行为,如果需要,保留或删除相同值的多个出现。

List接口并不定义操作列表索引范围的方法。相反,它定义了一个subList()方法,该方法返回一个List对象,该对象仅表示原始列表的指定范围。子列表由父列表支持,对子列表的任何更改都会立即反映在父列表中。以下是subList()和其他基本的List操作方法的示例:

// Create lists to work with
List<String> l = new ArrayList<String>(Arrays.asList(args));
List<String> words = Arrays.asList("hello", "world");
List<String> words2 = List.of("hello", "world");

// Querying and setting elements by index
String first = l.get(0);             // First element of list
String last = l.get(l.size() - 1);   // Last element of list
l.set(0, last);                      // The last shall be first

// Adding and inserting elements.  add  can append or insert
l.add(first);       // Append the first word at end of list
l.add(0, first);    // Insert first at the start of the list again
l.addAll(words);    // Append a collection at the end of the list
l.addAll(1, words); // Insert collection after first word

// Sublists: backed by the original list
List<String> sub = l.subList(1,3);  // second and third elements
sub.set(0, "hi");                   // modifies 2nd element of l

// Sublists can restrict operations to a subrange of backing list
String s = Collections.min(l.subList(0,4));
Collections.sort(l.subList(0,4));

// Independent copies of a sublist don't affect the parent list.
List<String> subcopy = new ArrayList<String>(l.subList(1,3));
subcopy.clear();

// Searching lists
int p = l.indexOf(last);  // Where does the last word appear?
p = l.lastIndexOf(last);  // Search backward

// Print the index of all occurrences of last in l.  Note subList
int n = l.size();
p = 0;
while (p < n) {
    // Get a view of the list that includes only the elements we
    // haven't searched yet.
    List<String> list = l.subList(p, n);
    int q = list.indexOf(last);
    if (q == -1) break;
    System.out.printf("Found '%s' at index %d%n", last, p+q);
    p += q+1;
}

// Removing elements from a list
l.remove(last);         // Remove first occurrence of the element
l.remove(0);            // Remove element at specified index
l.subList(0,2).clear(); // Remove a range of elements using subList
l.retainAll(words);     // Remove all but elements in words
l.removeAll(words);     // Remove all occurrences of elements in words
l.clear();              // Remove everything

Foreach 循环和迭代

处理集合的一种非常重要的方式是依次处理每个元素,这种方法称为迭代。这是一种查看数据结构的较旧方式,但仍然非常有用(特别是对于小数据集),而且易于理解。这种方法与for循环自然契合,如下面的代码片段所示,而且最容易用List来说明:

List<String> c = new ArrayList<String>();
// ... add some Strings to c

for(String word : c) {
    System.out.println(word);
}

代码的意图应该清晰明了——它逐个取出 c 的元素,并将它们用作循环体中的变量。更正式地说,它遍历数组或集合(或实现 java.lang.Iterable 接口的任何对象)。在每次迭代中,它将数组或 Iterable 对象的一个元素赋给你声明的循环变量,然后执行循环体,通常使用循环变量来操作元素。不涉及循环计数器或 Iterator 对象;循环自动执行迭代,你无需关注循环的正确初始化或终止。

这种类型的 for 循环通常被称为 foreach 循环。让我们看看它是如何工作的。下面的代码片段显示了一个重写(及等效)的 for 循环,明确显示了方法调用:

// Iteration with a for loop
for(Iterator<String> i = c.iterator(); i.hasNext();) {
    System.out.println(i.next());
}

Iterator 对象 i 是从集合生成并用于逐个遍历集合中的项目。它也可以与 while 循环一起使用:

// Iterate through collection elements with a while loop.
// Some implementations (such as lists) guarantee an order of iteration
// Others make no guarantees.
Iterator<String> iterator = c.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

以下是关于 foreach 循环语法的更多信息:

  • 正如前面提到的,expression 必须是数组或实现了 java.lang.Iterable 接口的对象。这种类型必须在编译时已知,以便编译器能够生成适当的循环代码。

  • 数组或 Iterable 元素的类型必须与 declaration 中声明的变量类型兼容。如果使用未参数化元素类型的 Iterable 对象,则必须将变量声明为 Object

  • declaration 通常只包括类型和变量名,但可能包括 final 修饰符和任何适当的注解(参见 第四章)。使用 final 可以防止循环变量获得除了循环分配的数组或集合元素之外的任何值,并强调数组或集合不能通过循环变量进行更改。

  • foreach 循环的循环变量必须作为循环的一部分声明,具有类型和变量名。你不能像 for 循环那样使用在循环外声明的变量。

要详细了解 foreach 循环如何与集合一起工作,我们需要考虑两个接口,java.util.Iteratorjava.lang.Iterable

public interface Iterator<E> {
     boolean hasNext();
     E next();
     void remove();
}

Iterator 定义了通过集合或其他数据结构遍历元素的方法。其工作方式如下:当集合中还有更多元素时(hasNext() 返回 true),调用 next 获取集合的下一个元素。有序集合(如列表)通常具有保证按顺序返回元素的迭代器。无序集合(如 Set)仅保证多次调用 next() 返回集合的所有元素,但不指定顺序。

警告

Iteratornext()方法执行两个功能——它在集合中前进,并返回我们刚刚移动过的集合元素。这些操作的组合可能会在以函数式或不可变风格编程时引发问题,因为它会改变底层集合。

Iterable接口的引入是为了使 foreach 循环工作。类实现此接口来告知任何有兴趣的人它能够提供一个Iterator

public interface Iterable<E> {
     java.util.Iterator<E> iterator();
}

如果对象是Iterable<E>,这意味着它有一个返回Iterator<E>iterator()方法,该迭代器有一个返回E类型对象的next()方法。

如果使用Iterable<E>的 foreach 循环,循环变量必须是类型E或超类或接口。

例如,要遍历List<String>的元素,变量必须声明为String或其超类Object,或者它实现的接口之一:CharSequenceComparableSerializable

迭代器常见的一个陷阱涉及修改。如果在迭代过程中修改了集合,可能会抛出ConcurrentModificationException类型的错误。

List<String> l = new ArrayList<>(List.of("one", "two", "three"));
for (String x : l) {
    if (x.equals("one")) {
        l.remove("one");  // throws ConcurrentModificationException
    }
}

避免此异常需要重新思考算法,以便不修改集合。通常可以通过针对副本而不是原始集合进行操作来实现这一点。新的Stream API 也为这些情况提供了许多有用的辅助功能。

对列表的随机访问

List实现的一个普遍期望是它们能够高效地进行迭代,通常在与列表大小成比例的时间内。然而,并非所有列表都能高效地在任何索引处提供元素的随机访问。顺序访问列表,如LinkedList类,在提供高效的插入和删除操作的同时,牺牲了随机访问的性能。提供高效随机访问的实现会实现RandomAccess标记接口,如果需要确保高效的列表操作,可以使用instanceof测试此接口:

// Arbitrary list we're passed to manipulate
List<?> l = ...;

// Ensure we can do efficient random access.  If not, use a copy
// constructor to make a random-access copy of the list before
// manipulating it.
if (!(l instanceof RandomAccess)) l = new ArrayList<?>(l);

Listiterator()方法返回的Iterator按列表中元素出现的顺序迭代列表元素。List实现了Iterable,因此可以像任何其他集合一样使用 foreach 循环进行迭代。

要仅迭代列表的一部分,可以使用subList()方法创建一个子列表视图:

List<String> words = ...;  // Get a list to iterate

// Iterate just all elements of the list but the first
for(String word : words.subList(1, words.size()))
    System.out.println(word);

表 8-2 总结了 Java 平台中五个通用的List实现。VectorStack是遗留实现,不应使用。CopyOnWriteArrayList属于java.util.concurrent包,仅适用于多线程用例。

表 8-2. 列表实现

类别表示自 Java 版本随机访问备注
ArrayList数组1.2最全面的实现
LinkedList双向链表1.2更有效的在列表中间插入和删除。
CopyOnWriteArrayList数组5.0线程安全;快速遍历,修改慢。
Vector数组1.0旧类;同步方法。不要使用。
Stack数组1.0扩展自Vector;添加push()pop()peek()。旧类;建议使用Deque代替。

映射接口

Map 是一组 key 对象和对该集合中每个成员的 value 对象的映射。Map 接口定义了用于定义和查询映射的 API。Map 是 Java 集合框架的一部分,但它不扩展Collection接口,因此Map是一个小写的集合,而不是大写的CollectionMap 是一个带有两个类型变量的参数化类型,Map<K, V>。类型变量K表示映射中键的类型,类型变量V表示这些键映射到的值的类型。例如,从String键到Integer值的映射可以用Map<String,Integer>表示。

最重要的Map方法是put(),用于在映射中定义键/值对;get(),用于查询与指定键关联的值;以及remove(),用于从映射中移除指定的键及其关联的值。对于Map实现的一般性能期望是这三个基本方法非常高效:它们应该在常数时间内运行,绝对不会更糟糕。

Map的一个重要特性是其支持“集合视图”。这可以总结为:

  • Map 不是 Collection

  • Map的键可以看作是一个Set

  • 值可以看作是一个Collection

  • 映射可以看作是Map.Entry对象的一个Set

注意

Map.Entry 是在Map内部定义的一个嵌套接口:它简单地表示单个键/值对。

下面的示例代码展示了Mapget()put()remove()和其他方法,并演示了Map的集合视图的一些常见用法:

// New, empty map
Map<String,Integer> m = new HashMap<>();

// Immutable Map containing a single key/value pair
Map<String,Integer> singleton = Collections.singletonMap("test", -1);

// Note this rarely used syntax to explicitly specify the parameter
// types of the generic emptyMap method. The returned map is immutable
Map<String,Integer> empty = Collections.<String,Integer>emptyMap();

// Populate the map using the put method to define mappings
// from array elements to the index at which each element appears
String[] words = { "this", "is", "a", "test" };
for(int i = 0; i < words.length; i++) {
    m.put(words[i], i);  // Note autoboxing of int to Integer
}

// Each key must map to a single value. But keys may map to the
// same value
for(int i = 0; i < words.length; i++) {
    m.put(words[i].toUpperCase(), i);
}

// The putAll() method copies mappings from another Map
m.putAll(singleton);

// Query the mappings with the get()  method
for(int i = 0; i < words.length; i++) {
    if (m.get(words[i]) != i) throw new AssertionError();
}

// Key and value membership testing
m.containsKey(words[0]);        // true
m.containsValue(words.length);  // false

// Map keys, values, and entries can be viewed as collections
Set<String> keys = m.keySet();
Collection<Integer> values = m.values();
Set<Map.Entry<String,Integer>> entries = m.entrySet();

// The Map and its collection views typically have useful
// toString  methods
System.out.printf("Map: %s%nKeys: %s%nValues: %s%nEntries: %s%n",
                  m, keys, values, entries);

// These collections can be iterated.
// Most maps have an undefined iteration order (but see SortedMap)
for(String key : m.keySet()) System.out.println(key);
for(Integer value: m.values()) System.out.println(value);

// The Map.Entry<K,V> type represents a single key/value pair in a map
for(Map.Entry<String,Integer> pair : m.entrySet()) {
    // Print out mappings
    System.out.printf("'%s' ==> %d%n", pair.getKey(), pair.getValue());
    // And increment the value of each Entry
    pair.setValue(pair.getValue() + 1);
}

// Removing mappings
m.put("testing", null);   // Mapping to null can "erase" a mapping:
m.get("testing");         // Returns null
m.containsKey("testing"); // Returns true: mapping still exists
m.remove("testing");      // Deletes the mapping altogether
m.get("testing");         // Still returns null
m.containsKey("testing"); // Now returns false.

// Deletions may also be made via the collection views of a map.
// Additions to the map may not be made this way, however.
m.keySet().remove(words[0]);  // Same as m.remove(words[0]);

// Removes one mapping to the value 2 - usually inefficient and of
// limited use
m.values().remove(2);
// Remove all mappings to 4
m.values().removeAll(Collections.singleton(4));
// Keep only mappings to 2 & 3
m.values().retainAll(Arrays.asList(2, 3));

// Deletions can also be done via iterators
Iterator<Map.Entry<String,Integer>> iter = m.entrySet().iterator();
while(iter.hasNext()) {
    Map.Entry<String,Integer> e = iter.next();
    if (e.getValue() == 2) iter.remove();
}

// Find values that appear in both of two maps.  In general, addAll()
// and retainAll() with keySet() and values() allow union and
// intersection
Set<Integer> v = new HashSet<>(m.values());
v.retainAll(singleton.values());

// Miscellaneous methods
m.clear();                // Deletes all mappings
m.size();                 // Returns number of mappings: currently 0
m.isEmpty();              // Returns true
m.equals(empty);          // true: Maps implementations override equals

随着 Java 9 的到来,Map接口也已经通过工厂方法增强了集合的创建:

Map<String, Double> cities =
        Map.of(
          "Barcelona", 22.5,
          "New York", 28.3);

情况与SetList相比稍微复杂一些,因为Map类型既有键又有值,并且 Java 不允许在方法声明中有多个变长参数。解决方法是提供固定大小的重载,最多支持 10 个条目,并提供一个新的静态方法entry(),用于构造表示键/值对的对象。

然后可以编写代码来使用变长参数形式,如下所示:

Map<String, Double> cities =
        Map.ofEntries(
          entry("Barcelona", 22.5),
          entry("New York", 28.3));

方法名称必须与of()不同,因为参数类型不同——现在这是一个变长参数方法在Map.Entry中。

Map 接口包含各种通用和特殊用途的实现,总结如表 8-3。如常,详细信息请参阅 JDK 文档和 javadoc。表 8-3 中的所有类均位于 java.util 包中,除了 ConcurrentHashMapConcurrentSkipListMap,它们属于 java.util.concurrent 包。

表 8-3. 地图实现

表示自从空键空值备注
HashMapHashtable1.2通用实现
ConcurrentHashMapHashtable5.0通用线程安全实现;参见 ConcurrentMap 接口
ConcurrentSkipListMapHashtable6.0专用线程安全实现;参见 ConcurrentNavigableMap 接口
EnumMap数组5.0键是枚举实例
LinkedHashMapHashtable 加列表1.4保持插入或访问顺序
TreeMap红黑树1.2按键值排序。操作复杂度为 O(log(n))。参见SortedMap接口。
IdentityHashMapHashtable1.4使用 == 而不是 equals() 比较
WeakHashMapHashtable1.2不会阻止键的垃圾回收
HashtableHashtable1.0传统类;同步方法。不建议使用。
PropertiesHashtable1.0扩展了 Hashtable 并添加了 String 方法

ConcurrentHashMapConcurrentSkipListMap 类属于 java.util.concurrent 包,实现了该包中的 ConcurrentMap 接口。ConcurrentMap 扩展自 Map 并定义了一些在多线程编程中重要的原子操作。例如,putIfAbsent() 方法类似于 put(),但仅在键尚未映射时才向映射中添加键值对。

TreeMap 实现了 SortedMap 接口,该接口扩展自 Map 并添加了利用有序映射性质的方法。SortedMapSortedSet 接口非常相似。firstKey()lastKey() 方法返回映射中键集的第一个和最后一个键。headMap()tailMap()subMap() 返回原始映射的受限范围。

队列和阻塞队列接口

队列 是一种有序集合,具有从队列头部按顺序提取元素的方法。队列实现通常基于插入顺序,如先进先出 (FIFO) 队列或后进先出 (LIFO) 队列。

注意

LIFO 队列也称为栈,Java 提供了 Stack 类,但强烈不建议使用——而是使用 Deque 接口的实现。

还有其他可能的排序方式:优先队列根据外部Comparator对象或根据Comparable元素的自然顺序对其元素进行排序。与Set不同,Queue实现通常允许重复元素。与List不同,Queue接口不定义用于在任意位置操作队列元素的方法。只有队列头部的元素可供检查。许多Queue实现通常具有固定的容量:当队列已满时,不可能再添加更多元素。类似地,当队列为空时,不可能再移除任何元素。由于满和空的条件是许多基于队列的算法的正常部分,Queue接口定义了用返回值而不是抛出异常来表示这些条件的方法。具体来说,peek()poll()方法返回null来指示队列为空。因此,大多数Queue实现不允许null元素。

阻塞队列是一种定义了阻塞put()take()方法的队列类型。put()方法会添加一个元素到队列中,在必要时等待,直到队列有空间可用。而take()方法会从队列头部移除一个元素,在必要时等待,直到有元素可移除。阻塞队列是许多多线程算法的重要部分,BlockingQueue接口(它扩展了Queue)作为java.util.concurrent包的一部分进行了定义。

与集合、列表和映射相比,队列并不是那么常用,除了在某些多线程编程风格中可能会使用。在这里,我们将尝试澄清不同可能的队列插入和移除操作,而不提供示例代码。

向队列中添加元素

add()

这个Collection方法会以常规方式添加元素。在有界队列中,如果队列已满,该方法可能会抛出异常。

offer()

这个Queue方法类似于add(),但是如果由于有界队列已满而无法添加元素,它会返回false而不是抛出异常。

BlockingQueue定义了offer()的超时版本,它会等待指定时间,直到一个满队列中有空间可用。与该方法的基本版本一样,如果元素被插入则返回true,否则返回false

put()

这个BlockingQueue方法会阻塞:如果由于队列已满而无法插入元素,则put()会等待直到另一个线程从队列中移除一个元素,为新元素腾出空间。

从队列中移除元素

remove()

除了Collection.remove()方法可以从队列中移除指定元素之外,Queue接口还定义了remove()的无参数版本,它会移除并返回队列头部的元素。如果队列为空,该方法会抛出NoSuchElementException

poll()

Queue 方法移除并返回队列头部的元素,类似于 remove(),但如果队列为空则返回 null,而不是抛出异常。

BlockingQueue 定义了 poll() 的超时版本,等待指定的时间量以在空队列中添加元素。

take()

BlockingQueue 方法移除并返回队列头部的元素。如果队列为空,它会阻塞,直到其他线程向队列添加元素。

drainTo()

BlockingQueue 方法从队列中移除所有可用元素并将它们添加到指定的 Collection 中。它不会阻塞等待元素添加到队列中。该方法的一个变体接受最大数量的要排放的元素。

查询

在这个上下文中,查询是指检查队列头部的元素,而不从队列中移除它。

element()

Queue 方法返回队列头部的元素,但不从队列中移除该元素。如果队列为空,则抛出 NoSuchElementException 异常。

peek()

Queue 方法类似于 element,但如果队列为空则返回 null

注意

在使用队列时,通常建议选择一种处理失败的特定方式。例如,如果希望操作阻塞直到成功,则选择 put()take()。如果想要通过方法的返回代码来检查队列操作是否成功,则适合使用 offer()poll()

LinkedList 类也实现了 Queue。它提供了无界的 FIFO 排序,插入和删除操作需要常数时间。尽管 LinkedList 允许使用 null 元素,但在列表用作队列时不建议使用它们。

java.util 包中还有另外两个 Queue 实现。PriorityQueue 根据 Comparator 或者根据元素的 compareTo() 方法定义的顺序对其元素进行排序。PriorityQueue 的头部始终是根据定义顺序的最小元素。最后,ArrayDeque 是双端队列实现,在需要栈实现时经常使用。

java.util.concurrent 包中还包含多个 BlockingQueue 实现,专为多线程编程设计;提供了高级版本,可以避免使用同步方法。

遗憾的是,本书不涵盖对 java.util.concurrent 的全面讨论。有兴趣的读者应参考 Brian Goetz 等人的《Java 并发实战》(Addison-Wesley, 2006)。

实用方法

java.util.Collections 类拥有许多专为集合设计的静态实用方法。其中一个重要的方法组是集合 包装 方法:它们返回围绕您指定的集合包装的特殊目的集合。包装集合的目的是在不提供自身的集合周围提供额外的功能。包装器用于提供线程安全性、写保护和运行时类型检查。包装集合始终是 原始集合支持的,这意味着包装器的方法只是将操作分派到包装的集合的等效方法。这意味着通过包装器对集合进行的更改会通过包装的集合反映出来,反之亦然。

第一组包装方法提供了围绕集合的线程安全包装器。除了遗留类 VectorHashtable 外,java.util 中的集合实现没有 synchronized 方法,并且不能受到多线程并发访问的保护。如果您需要线程安全的集合并且不介意额外的同步开销,可以使用类似以下代码创建它们:

List<String> list =
    Collections.synchronizedList(new ArrayList<>());
Set<Integer> set =
    Collections.synchronizedSet(new HashSet<>());
Map<String,Integer> map =
    Collections.synchronizedMap(new HashMap<>());

第二组包装方法提供了通过这些包装对象无法修改底层集合的集合对象。它们返回集合的只读视图:如果更改集合的内容将导致 UnsupportedOperationException。当您必须传递一个不允许以任何方式修改或变异集合内容的方法时,这些包装器非常有用:

List<Integer> primes = new ArrayList<>();
List<Integer> readonly = Collections.unmodifiableList(primes);
// We can modify the list through primes
primes.addAll(Arrays.asList(2, 3, 5, 7, 11, 13, 17, 19));
// But we can't modify through the read-only wrapper
readonly.add(23);  // UnsupportedOperationException

java.util.Collections 类还定义了操作集合的方法。其中一些最显著的是对集合元素进行排序和搜索的方法:

Collections.sort(list);
// list must be sorted first
int pos = Collections.binarySearch(list, "key");

这里是一些其他有趣的 Collections 方法:

// Copy list2 into list1, overwriting list1
Collections.copy(list1, list2);
// Fill list with Object o
Collections.fill(list, o);
// Find the largest element in Collection c
Collections.max(c);
// Find the smallest element in Collection c
Collections.min(c);

Collections.reverse(list);      // Reverse list
Collections.shuffle(list);      // Mix up list

熟悉 CollectionsArrays 中的实用方法是一个好主意,因为它们可以避免您编写自己的常见任务实现。

特殊情况集合

除了其包装方法外,java.util.Collections 类还定义了用于创建包含单个元素的不可变集合实例以及用于创建空集合的实用方法。singleton(), singletonList()singletonMap() 返回不可变的 Set, ListMap 对象,其中包含单个指定对象或单个键值对。当您需要向期望收集的方法传递单个对象时,这些方法非常有用。

Collections 类还包括返回空集合的方法。如果您正在编写一个返回集合的方法,通常最好通过返回空集合而不是像 null 这样的特殊情况值来处理没有值可返回的情况:

Set<Integer> si = Collections.emptySet();
List<String> ss = Collections.emptyList();
Map<String, Integer> m = Collections.emptyMap();

自 Java 9 以来,这些方法经常被 Set, ListMap 接口上的 of() 方法所取代。

Set<Integer> si = Set.of();
List<String> ss = List.of();
Map<String, Integer> m = Map.of();

这些方法返回它们类型的不可变版本,也可能通过相同的方法获取元素。

Set<Integer> si = Set.of(1);
List<String> ss = List.of("string");
Map<String, Integer> m = Map.of("one", 1);

最后,nCopies() 返回一个包含指定数量副本的不可变 List

List<Integer> tenzeros = Collections.nCopies(10, 0);

数组和辅助方法

对象数组和集合提供类似的功能。可以从一个转换到另一个:

String[] a = { "this", "is", "a", "test" };  // An array
// View array as an ungrowable list
List<String> l = Arrays.asList(a);
// Make a growable copy of the view
List<String> m = new ArrayList<>(l);

// asList() is a varargs method so we can do this, too:
Set<Character> abc =
    new HashSet<Character>(Arrays.asList('a', 'b', 'c'));

// Collection defines a toArray method. The no-args version creates
// an Object[] array, copies collection elements to it and returns it
// Get set elements as an array
Object[] members = set.toArray();
// Get list elements as an array
Object[] items = list.toArray();
// Get map key objects as an array
Object[] keys = map.keySet().toArray();
// Get map value objects as an array
Object[] values = map.values().toArray();

// If you want the return value to be something other than Object[],
// pass in an array of the appropriate type. If the array is not
// big enough, another one of the same type will be allocated.
// If the array is too big, the collection elements copied to it
// will be null-filled
String[] c = l.toArray(new String[0]);

此外,还有一些有用的辅助方法来处理 Java 的数组,这些方法在这里完整列出。

java.lang.System 类定义了一个 arraycopy() 方法,用于将一个数组中的指定元素复制到第二个数组的指定位置。第二个数组必须与第一个数组类型相同,甚至可以是同一个数组:

char[] text = "Now is the time".toCharArray();
char[] copy = new char[100];
// Copy 10 characters from element 4 of text into copy,
// starting at copy[0]
System.arraycopy(text, 4, copy, 0, 10);

// Move some of the text to later elements, making room for
// insertions If target and source are the same, this will involve
// copying to a temporary array
System.arraycopy(copy, 3, copy, 6, 7);

Arrays 类还定义了许多有用的静态方法:

int[] intarray = new int[] { 10, 5, 7, -3 }; // An array of integers
Arrays.sort(intarray);                       // Sort it in place
// Value 7 is found at index 2
int pos = Arrays.binarySearch(intarray, 7);
// Not found: negative return value
pos = Arrays.binarySearch(intarray, 12);

// Arrays of objects can be sorted and searched too
String[] strarray = new String[] { "now", "is", "the", "time" };
Arrays.sort(strarray);   // sorted to: { "is", "now", "the", "time" }

// Arrays.equals compares all elements of two arrays
String[] clone = (String[]) strarray.clone();
boolean b1 = Arrays.equals(strarray, clone);  // Yes, they're equal

// Arrays.fill  initializes array elements
// An empty array; elements set to 0
byte[] data = new byte[100];
// Set them all to -1
Arrays.fill(data, (byte) -1);
// Set elements 5, 6, 7, 8, 9 to -2
Arrays.fill(data, 5, 10, (byte) -2);

// Creates a new array with elements copied into it
int[] copied = Arrays.copyOf(new int[] { 1, 2, 3 }, 2);

在 Java 中,可以将数组视为对象并进行操作。对于任意对象 o,可以使用以下代码来查找该对象是否为数组,以及如果是,则是什么类型的数组:

Class type = o.getClass();
if (type.isArray()) {
  Class elementType = type.getComponentType();
}

Java 流与 Lambda 表达式

引入 Java 8 中 lambda 表达式的一个主要原因是促进对集合 API 的重大改革,以允许 Java 开发者使用更现代的编程风格。直到 Java 8 发布之前,Java 中处理数据结构的方式看起来有些过时。许多现代语言现在支持一种编程风格,允许将集合作为整体来处理,而不是分解和迭代它们。

实际上,许多 Java 开发者已经开始使用替代的数据结构库来实现他们认为在集合 API 中缺乏的表达性和生产力。更新 API 的关键在于引入新的类和方法,这些方法可以接受 lambda 表达式作为参数,以定义需要执行的内容,而不是具体的方式。这是功能风格编程的概念。

引入功能性集合(称为Java 流,以明确其与旧集合方法的区别)是迈出的重要一步。可以通过在现有集合上调用 stream() 方法来创建流。

注意

想要向现有接口添加新方法,这直接导致了一种称为默认方法的新语言特性的引入(详见“默认方法”以获取更多详情)。如果没有这种新机制,Java 8 之前的集合接口的旧实现将无法在 Java 8 下编译,并且如果加载到 Java 8 运行时中,则无法连接。

然而,流 API 的到来并没有抹去历史。集合 API 深深嵌入在 Java 世界中,它不是功能性的。Java 对向后兼容性和严格的语言语法的承诺意味着集合永远不会消失。即使以功能风格编写的 Java 代码也永远不会完全摆脱样板代码,并且永远不会具有我们在 Haskell 或 Scala 等语言中看到的简洁语法。

这是语言设计中不可避免的权衡之一——Java 在命令式设计和基础上添加了功能性能力。这与从头开始为函数式编程设计不同。更重要的问题是:从 Java 8 开始提供的功能性能力是否符合工作程序员构建应用程序的需要?

Java 8 相对于先前版本的快速采用以及社区的反应似乎表明新特性取得了成功,并提供了生态系统所期待的功能。

在本节中,我们将介绍 Java 集合中使用 Java 流(Java streams)和 lambda 表达式的方法。有关更详尽的内容,请参阅Java 8 Lambdas(Richard Warburton 著,O'Reilly 出版社)。

功能性方法

Java 8 Streams 希望启用的方法来源于功能性编程语言和风格。我们在“函数式编程”中遇到了一些关键模式——让我们重新介绍它们,并查看每个的一些示例。

过滤器

过滤器模式应用了一个返回truefalse(称为谓词)的代码片段到集合中的每个元素上。构建一个新的集合,其中包含“通过测试”的元素(即应用于元素时代码返回true的部分)。

例如,让我们看一些用于处理猫集合并挑选出老虎的代码:

List<String> cats = List.of("tiger", "cat", "TIGER", "leopard");
String search = "tiger";
String tigers = cats.stream()
                    .filter(s -> s.equalsIgnoreCase(search))
                    .collect(Collectors.joining(", "));
System.out.println(tigers);

关键部分是调用filter(),它接受一个 lambda 表达式。Lambda 接受一个字符串并返回一个布尔值。这被应用于整个cats集合,并创建一个只包含老虎(无论它们是否大写)的新集合。

filter()方法接受一个来自java.util.function包的Predicate接口的实例。这是一个功能性接口,只有一个非默认方法,因此非常适合 lambda 表达式。

注意最终调用的collect();这是 API 的一个重要部分,用于在 lambda 操作结束时“收集”结果。我们将在下一节中更详细地讨论它。

Predicate还具有一些其他非常有用的默认方法,例如通过逻辑操作构建组合谓词。例如,如果老虎们想要允许豹子加入他们的团体,可以使用or()方法表示:

Predicate<String> p = s -> s.equalsIgnoreCase(search);
Predicate<String> combined = p.or(s -> s.equals("leopard"));
String pride = cats.stream()
                   .filter(combined)
                   .collect(Collectors.joining(", "));
System.out.println(pride);

注意,如果明确创建Predicate<String>对象p,那么默认的or()方法就可以在其上调用,并且第二个 lambda 表达式(也将自动转换为Predicate<String>)将被传递给它。

Map

这种映射范式利用了java.util.function包中的接口Function<T, R>。与Predicate<T>类似,这是一个功能接口,因此只有一个非默认方法apply()。映射范式是关于将一个流转换为一个新流,新流可能具有与原始流不同的类型和值。这在 API 中显示为Function<T, R>有两个单独的类型参数。类型参数R的名称表示这表示函数的返回类型。

让我们看一个使用map()的代码示例:

List<Integer> namesLength = cats.stream()
                .map(String::length)
                .toList();
System.out.println(namesLength);

这是对先前的cats变量(这是一个Stream<String>)调用的,并将函数String::length(方法引用)应用于每个字符串。结果是一个新的流,但这次是Integer。我们使用toList()方法将该流转换为List。请注意,与集合 API 不同,map()方法不会就地变异流,而是返回一个新值。这对于此处使用的功能样式至关重要。

forEach

映射和过滤范式用于从另一个集合创建一个集合。在强烈的函数式语言中,这将与要求原始集合不受 lambda 主体影响而被合并。从计算机科学的角度来看,这意味着 lambda 主体应该是“无副作用”的。

当然,在 Java 中,我们经常需要处理可变数据,因此 Streams API 提供了一种在遍历集合时修改元素的方法——forEach()方法。它接受一个类型为Consumer<T>的参数,这是一个预期通过副作用操作的功能接口(尽管它实际上是否改变数据不太重要)。这意味着可以转换为Consumer<T>的 lambda 的签名是(T t) → void。让我们看一个forEach()的快速示例:

List<String> pets =
  List.of("dog", "cat", "fish", "iguana", "ferret");
pets.stream().forEach(System.out::println);

在此示例中,我们仅仅打印出集合的每个成员。但是,我们通过使用一种特殊类型的方法引用作为 lambda 表达式来实现。这种类型的方法引用称为绑定方法引用,因为它涉及特定对象(在本例中为System.out对象,这是System的静态公共字段)。这相当于 lambda 表达式:

s -> System.out.println(s);

当然,这可以转换为实现Consumer<? super String>的类型的实例,如方法签名所需。

警告

没有什么能阻止map()filter()调用改变元素。只是约定它们不能改变元素,但这是每个 Java 程序员都应该遵守的约定。

在我们继续之前,我们应该看看一个最后的函数式技术。这是将集合聚合到单个值的做法,也是我们下一节的主题。

归约

让我们看看reduce()方法。这实现了归约模式,这实际上是一组相似和相关的操作,有些被称为折叠或聚合操作。

在 Java 中,reduce()接受两个参数。这些是初始值,通常称为标识(或零),以及一个逐步应用的函数。这个函数的类型是BinaryOperator<T>,这是另一个接受两个相同类型参数并返回该类型值的函数式接口。reduce()的第二个参数是一个二参数的 lambda。reduce()javadoc中像这样定义:

T reduce(T identity, BinaryOperator<T> aggregator);

简单来说,reduce()的第二个参数可以想象成在流运行时创建一个“运行总和”。它从将标识元素与流的第一个元素组合起来产生第一个结果开始,然后将该结果与流的第二个元素组合,依此类推。

可以想象,reduce()的实现工作起来有点像这样:

public T reduce(T identity, BinaryOperator<T> aggregator) {
    T runningTotal = identity;
    for (T element : myStream) {
        runningTotal = aggregator.apply(runningTotal, element);
    }

    return runningTotal;
}
注意

实际上,reduce()的实现可以比这更复杂,并且如果数据结构和操作适合,甚至可以并行执行。

让我们快速看一个reduce()的例子,并计算一些质数的总和:

double sumPrimes = List.of(2, 3, 5, 7, 11, 13, 17, 19, 23)
        .stream()
        .reduce(0, (x, y) -> x + y);
System.out.println("Sum of some primes: " + sumPrimes);

在本节中我们遇到的所有示例中,您可能已经注意到在List实例上调用了stream()方法。这是 Java 集合演变的一部分——最初部分地出于必要性选择,但已被证明是一个极好的抽象。让我们继续详细讨论流 API。

流 API

引起 Java 库设计者引入流 API 的根本问题是现有的核心集合接口实现的数量庞大。由于这些实现是在 Java 8 和 lambda 之前存在的,它们不会具有任何对应于新的函数式操作的方法。更糟糕的是,像map()filter()这样的方法名称从未作为集合接口的一部分,可能已经存在于实现中。

为了解决这个问题,引入了一个称为Stream的新抽象。其思想是通过stream()方法可以从集合对象生成一个Stream对象。这种Stream类型是新的,并且受到库设计者的控制,因此可以确保没有冲突。这进一步减少了冲突的风险,因为只有包含stream()方法的集合实现才会受到影响。

Stream 对象在新的集合代码方法中扮演与 Iterator 类似的角色。总体思路是让开发人员建立一个操作序列(或“管道”),对整个集合应用操作(如 mapfilterreduce)。操作的实际内容通常作为每个操作的 Lambda 表达式来表示。

在管道的末端,通常需要收集或“具现化”结果,要么作为新的集合,要么作为另一个值。这可以通过使用 Collector 或通过使用像 reduce() 这样的“终端方法”来完成,后者返回实际值而不是另一个流。总体而言,新的集合方法看起来是这样的:

        stream()   filter()   map()   collect()
Collection -> Stream -> Stream -> Stream -> Collection

Stream 类表现为一系列元素的序列,可以逐个访问(尽管有些类型的流支持并行访问,并且可以用于以自然多线程方式处理更大的集合)。类似于 IteratorStream 用于逐个获取每个项目。

与 Java 中的通用类一样,Stream 是由引用类型参数化的。然而,在许多情况下,我们实际上希望使用基本类型的流,尤其是 intdouble。我们不能有 Stream<int>,因此在 java.util.stream 中有特殊的(非泛型)类,如 IntStreamDoubleStream。这些被称为 Stream 类的原始特化,它们的 API 与一般 Stream 方法非常相似,只是在适当的地方使用原始类型。

惰性求值

实际上,流比迭代器(甚至集合)更为一般化,因为流不管理数据的存储。在 Java 的早期版本中,通常假定集合的所有元素都存在(通常在内存中)。可以通过坚持到处都使用迭代器以及让迭代器在需要时动态构造元素的方式来部分地解决这个问题。然而,这既不是非常方便,也不是很常见。

相比之下,流是一种管理数据的抽象,而不是关注存储细节。这使得可以处理比简单有限集合更为复杂的数据结构。例如,无限流可以轻松地用 Stream 接口表示,并且它们可以作为处理所有平方数集合的一种方式。让我们看看如何使用 Stream 完成这个任务:

public class SquareGenerator implements IntSupplier {
    private int current = 1;

    @Override
    public synchronized int getAsInt() {
        int thisResult = current * current;
        current++;
        return thisResult;
    }
}

IntStream squares = IntStream.generate(new SquareGenerator());
PrimitiveIterator.OfInt stepThrough = squares.iterator();
for (int i = 0; i < 10; i++) {
    System.out.println(stepThrough.nextInt());
}
System.out.println("First iterator done...");

// We can go on as long as we like...
for (int i = 0; i < 10; i++) {
    System.out.println(stepThrough.nextInt());
}

因为我们的可能值列表是无限的,所以我们必须采用一种模型,其中元素不会提前全部存在。基本上,一段代码必须在我们需要时返回下一个元素。用于实现这一点的关键技术是惰性求值

注意

对于 Java 来说,惰性评估是一个重大的变化,因为直到 JDK 8 为止,表达式的值总是在将其分配给变量(或传递给方法)时立即计算的。这种熟悉的模型,即值立即计算,称为“急切评估”,是大多数主流编程语言中表达式评估的默认行为。

我们可以在上面的示例中看到惰性评估的实际操作,如果我们稍微修改getAsInt()来主动提供输出时:

    @Override
    public synchronized int getAsInt() {
        int thisResult = current * current;
        System.out.print(String.format("%d... ", thisResult));
        current++;
        return thisResult;
    }

当运行此修改后的程序时,我们将看到输出,显示每个getAsInt()调用紧接着在for循环中使用该值:

1... 1
4... 4
9... 9
16... 16
25... 25
36... 36
49... 49
64... 64
81... 81
100... 100
First iterator done...
121... 121
...

将无限流建模的一个重要后果是collect()等方法无法工作。这是因为我们无法将整个流实例化为一个集合(在创建无限数量的对象之前,我们会耗尽内存)。

即使流不是无限的,也很重要意识到评估的哪些部分是惰性的。例如,尝试在map操作期间显示诊断信息的以下代码实际上并不产生任何输出:

List.of(1, 2, 3, 4, 5)
    .stream()
    .map((i) - > {
        System.out.println(i);
        return i;
    });

只有当我们提供像collect()toList()这样的终端操作时,我们的map() lambda 才会真正执行。

意识到哪些中间结果在其评估时是惰性的,是 Java 开发人员在使用 Stream API 时应该注意的一个话题。然而,更复杂的实现细节通常由库编写者而不是流的用户来处理。

虽然filtermapreduce的结合几乎可以完成我们所需的任何与流相关的任务,但这并不总是最方便的 API。有许多额外的方法建立在这些原语之上,为我们提供了更丰富的词汇来处理流。

进一步的过滤

处理流时更复杂的方法经常受益于更精细的过滤。Stream接口上的许多方法允许更具表现力地描述我们希望如何裁剪我们的流以供消费:

// Distinct elements only
Stream.of(1, 2, 1, 2, 3, 4)
      .distinct();
// Results in  [1, 2, 3, 4]

// Ignores items until predicate matches, then returns remainder
// Note that later elements aren't required to match the predicate.
Stream.of(1, 2, 3, 4, 5, 3)
      .dropWhile((i) -> i < 4);
// Results in [4, 5, 3]

// Returns items from the stream until the predicate stops matching.
// Note that later elements matching the predicate aren't returned.
Stream.of(1, 2, 3, 4, 3)
      .takeWhile((i) -> i < 4);
// Results in [1, 2, 3]

// Skips the first N items in the stream
Stream.of(1, 2, 3, 4, 5)
      .skip(2);
// Results in [3, 4, 5]

// Limits items taken from stream to an exact value
// Useful with infinite streams to set boundaries
Stream.of(1, 2, 3, 4, 5)
      .limit(3);
// Results in [1, 2, 3]

流中的匹配

另一个典型的操作是对整个元素流提出问题,例如是否所有元素都(或者没有一个)与给定的谓词匹配,或者是否有任何一个单独的元素匹配:

// Are all the items odd?
Stream.of(1, 1, 3, 5)
      .allMatch((i) -> i % 2 == 1);
// Returns true

// Are none of the items even?
Stream.of(1, 1, 3, 5)
      .noneMatch((i) -> i % 2 == 0);
// Returns true

// Is at least one item even?
Stream.of(1, 1, 3, 5, 6)
      .anyMatch((i) -> i % 2 == 0);
// Returns true

展开

一旦我们开始将数据建模为流,发现另一个层次的流并不罕见。例如,如果我们处理多行文本并希望从整个块中收集单词集合,我们可能首先使用以下代码:

var lines = Stream.of(
    "For Brutus is an honourable man",
    "Give me your hands if we be friends and Robin shall restore amends",
    "Misery acquaints a man with strange bedfellows");

lines.map((s) -> s.split(" +"));
// Returns Stream.of(new String[] { "For", "Brutus",...},
//                   new String[] { "Give", "me", "your", ... },
//                   new String[] { "Misery", "acquaints", "a", ... },

然而,这并不是我们所期望的纯粹的单词列表。我们有一个额外的嵌套层次,一个Stream<String[]>而不是Stream<String>

flatMap()方法专为这些情况设计。对于原始流中的每个元素,提供给flatMap()的 lambda 返回的不是单个值,而是另一个Stream。然后flatMap()收集这些多个流并将它们连接起来,平铺成包含类型的单个流。

在我们的例子中,split()给了我们数组,我们可以轻松地将其转换为流。从那里开始,flatMap()将会把那些多个流转换成我们需要的单个单词流:

lines.flatMap((s) -> Arrays.stream(s.split(" +")));
// Returns Stream.of("For", "Brutus", "is", "an", ...)

从流到集合

定义一个单独的Stream接口是一种实用的方式,可以在 Java 中启用更新的开发风格,同时不会破坏现有的代码。然而,有时您仍然需要标准的 Java 集合,无论是传递给另一个 API 还是用于流中不存在的功能。对于返回简单的List或元素数组的最常见情况,这些方法直接在Stream接口上提供:

// Immutable list returned
List<Integer> list =
    Stream.of(1, 2, 3, 4, 5).toList();

// Note the return type is `Object[]`
Object[] array =
    Stream.of(1, 2, 3, 4, 5).toArray();

将流转换为非流集合或其他对象的最主要方法是通过collect()方法执行的。该方法接收Collector接口的一个实例,允许以各种可能的方式收集我们的流结果,而不会向Stream接口本身添加内容。

Collectors类作为静态方法提供了各种收集器的标准实现。例如,我们可以将我们的流转换为我们任何普通的集合类型:

// In earlier versions of Java, Stream#toList() didn't exist
// This was the commonly used approach so you'll still see it often
List<Integer> list =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toList());

// Create a standard Set (no duplicates)
Set<Integer> set =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toSet());

// For Collection types that don't have a specific method, we can
// use toCollection with a function that creates our empty instance
// Each item will be added to that collection
TreeSet<Integer> collection =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toCollection(TreeSet::new));

// When creating maps we must provide two functions
// The first constructs the key for each element, the second the value
// Here, each int is its own key and the value is its toString()
Map<Integer, String> map =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toMap(
                      (i) -> i,
                      Object::toString));

Stream#toList()不同,所有这些选项都返回其集合类型的可修改版本。如果你想返回一个不可修改或不可变版本,Collectors还提供了特定的方法。它们遵循了一个命名约定toUnmodifiableX(),其中X是上面看到的集合类型。

收集集合的最后一种变化是当您想要根据某些属性对元素进行分组时。在这个例子中,我们想要根据它们的第一个数字将数字分组:

Map<Character, List<Integer>> grouped =
        Stream.of(10, 11, 12, 20, 30)
                .collect(Collectors.groupingBy((i) -> {
                    return i.toString().charAt(0);
                }));
// Returns map with {"1"=[10, 11, 12], "2"=[20], "3"=[30]}

从流到值

我们并不总是想从我们的流中检索集合,有时我们需要单个值,就像reduce()方法给我们的那样。

Stream有一些内置方法,用于我们可能想要从我们的流中获得的最常见值:

var count = Stream.of(1,2,3).count();
var max = Stream.of(1,2,3).max(Integer::compareTo);
var min = Stream.of(1,2,3).min(Integer::compareTo);

collect()方法不仅限于返回集合类型。Collectors提供了各种各样的结果收集方法,以帮助进行常见的计算,特别是在数字流上。这些方法都需要一个函数,用于将流中的传入项转换为数字,这样它就可以轻松地与对象以及原始值一起使用:

var average =
    Stream.of(1,2,3)
          .collect(Collectors.averagingInt(Integer::intValue));

var sum =
    Stream.of(1,2,3)
          .collect(Collectors.summingInt(Integer::intValue));

var summary =
    Stream.of(1,2,3)
          .collect(Collectors.summarizingInt(Integer::intValue));
// IntSummaryStatistics{count=3, sum=6, min=1, average=2.0, max=3}

类似的方法也适用于长整型和双精度浮点型,除了整型之外。

从流中获取结果的最后一种方法有助于我们处理字符串。一个经典问题是将一系列较小的字符串转换为一个较大的分隔字符串。流使这变得非常简单。

var words = Stream.of("This", "is", "some", "text");
var csv = words.collect(Collectors.joining(", "));
// Returns string "This, is, some, text"

流实用程序默认方法

Java Streams 利用机会向 Java 集合库引入了许多新方法。通过默认方法,可以向集合添加新方法而不会破坏向后兼容性。

这些方法中,有些是从我们现有的集合中创建流的脚手架方法。这些方法包括Collection::streamCollection::parallelStream以及Collection::spliterator(其具有专门的形式List::spliteratorSet::spliterator)。

其他方法提供了先前版本中存在的功能的快捷方式。例如,List::sort方法基本上委托给Collections类上已经可用的更繁琐的版本:

// Essentially just forwards to the helper method in Collections
public default void sort(Comparator<? super E> c) {
    Collections.<E>sort(this, c);
}

其余的方法利用java.util.function接口提供了额外的功能技术:

Collection::removeIf

此方法接受一个Predicate并在集合内部进行迭代,移除满足谓词对象的任何元素。

Map::forEach

此方法的单个参数是一个接受两个参数(键的类型和值的类型之一)并返回void的 lambda 表达式。这将转换为BiConsumer的实例,并应用于映射中的每个键值对。

Map::computeIfAbsent

这需要一个键和一个将键类型映射到值类型的 lambda 表达式。如果映射中不存在指定的键(第一个参数),则使用 lambda 表达式计算默认值并将其放入映射中。

(还参见Map::computeIfPresentMap::computeMap::merge。)

摘要

在本章中,我们已经了解了 Java 集合库,并看到了如何开始使用 Java 的基本和经典数据结构的实现。我们遇到了通用的Collection接口,以及ListSetMap。我们看到了处理集合的原始迭代方式,并引入了基于基础编程思想的新 Java Streams 风格。在 Streams API 中,我们看到新方法比经典方法更加通用,可以表达比较微妙的编程概念。

我们只是触及了表面——Streams API 是 Java 代码编写和架构中的根本性转变。在 Java 中,函数式编程理念的实现存在设计上的固有限制。尽管如此,Streams 代表“恰好足够的函数式编程”的可能性非常有吸引力。

让我们继续吧。在下一章中,我们将继续探讨数据,以及如文本处理、处理数值数据和 Java 8 的新日期和时间库等常见任务。

第九章:处理常见数据格式

大多数编程是处理各种格式的数据。在本章中,我们将介绍 Java 处理两类重要数据——文本和数字的支持。本章的后半部分将专注于处理日期和时间信息。这尤其重要,因为 Java 8 发布了完全新的 API 来处理日期和时间。在讨论 Java 的原始日期和时间 API 之前,我们会对这个接口进行深入讨论。

许多应用程序仍在使用旧的 API,因此开发人员需要了解旧方法,但新的 API 要好得多,我们建议尽快转换。在我们开始处理更复杂的格式之前,让我们先讨论文本数据和字符串。

文本

我们已经在许多场合见过 Java 的字符串。它们由 Unicode 字符序列组成,并表示为String类的实例。字符串是 Java 程序处理的最常见数据类型之一(您可以通过使用我们将在第十三章中介绍的jmap工具自行验证这一点)。

在本节中,我们将更深入地了解String类,并理解它在 Java 语言中处于相当独特的位置。在本节的后面,我们将介绍正则表达式,这是一种非常常见的用于搜索文本模式的抽象(无论编程语言如何,都是程序员工具箱中的经典工具)。

字符串的特殊语法

String类在 Java 语言中以一种略微特殊的方式处理。这是因为尽管它不是原始类型,但字符串是如此常见,以至于 Java 具有许多特殊的语法功能,旨在使字符串处理变得容易。让我们看看 Java 为字符串提供的一些特殊语法功能的示例。

字符串字面量

正如我们在第二章中看到的,Java 允许将一系列字符放置在双引号中以创建文字字符串对象。就像这样:

String pet = "Cat";

如果没有这种特殊语法,我们将不得不编写像这样可怕的大量代码:

char[] pullingTeeth = {'C', 'a', 't'};
String pet = new String(pullingTeeth);

这种方法很快就会变得枯燥乏味,所以毫无意外,像所有现代编程语言一样,Java 提供了简单的字符串字面量语法。字符串字面量是完全合法的对象,因此像下面这样的代码完全合法:

System.out.println("Dog".length());

使用基本双引号的字符串不能跨越多行,但是最近版本的 Java 已经包括了使用"""语法的多行文本块。生成的字符串对象在编译时创建,并且与"引号括起来的字符串没有区别,只是更易于表达:

String lyrics = """
 This is the song that never ends
 This song goes on and one my friend
 ...""";

请参阅“字符串字面量”了解 Java 中字符串字面量的完整覆盖。

toString()

此方法在Object上定义,旨在允许将任何对象轻松转换为字符串。这使得可以通过使用System.out.println()方法轻松打印出任何对象。实际上,该方法是PrintStream::println,因为System.out是一个类型为PrintStream的静态字段。让我们看看该方法的定义:

    public void println(Object x) {
        String s = String.valueOf(x);
        synchronized (this) {
            print(s);
            newLine();
        }
    }

这通过使用静态方法String::valueOf()创建一个新的字符串:

    public static String valueOf(Object obj) {
        return (obj == null) ? "null" : obj.toString();
    }
注意

静态的valueOf()方法用于代替直接调用toString(),以避免在objnull时抛出NullPointerException

这种构造方式意味着toString()对于任何对象始终可用,这对 Java 提供的另一个主要语法特性非常有用:字符串连接。

字符串连接

Java 允许我们通过将一个字符串的字符“添加”到另一个字符串的末尾来创建新的字符串。这称为字符串连接,并使用+运算符。在 Java 8 及以下版本中,它的工作原理是首先创建一个StringBuilder对象作为“工作区”,其中包含与原始字符串相同的字符序列。

注意

Java 9 引入了一种新的机制,使用invokedynamic指令而不是直接使用StringBuilder。这是一种高级功能,超出了本讨论的范围,但不会改变对 Java 开发人员可见的行为。

然后更新构建器对象,并将附加字符串的字符添加到末尾。最后,在StringBuilder对象上调用toString()(现在包含来自两个字符串的字符)。这给了我们一个包含所有字符的新字符串。无论何时使用+运算符连接字符串,javac都会自动创建所有这些代码。

连接过程返回一个全新的String对象,正如我们在这个例子中所见:

String s1 = "AB";
String s2 = "CD";

String s3 = s1;
System.out.println(s1 == s3); // Same object? Yes.

s3 = s1 + s2;
System.out.println(s1 == s3); // Still same? Nope!
System.out.println(s1);
System.out.println(s3);

连接示例直接显示了+运算符不会直接修改(或突变s1。这是一个更一般的原则示例:Java 的字符串是不可变的。这意味着一旦选择了组成字符串的字符并创建了String对象,那么这个String就不能被改变。这是 Java 中一个重要的语言原则,让我们稍微深入了解一下。

字符串的不可变性

要“改变”一个字符串,就像我们讨论字符串连接时看到的那样,实际上需要创建一个中间的StringBuilder对象作为临时的工作区,并在其上调用toString(),以将其转换为一个新的String实例。让我们看看代码是如何工作的:

String pet = "Cat";
StringBuilder sb = new StringBuilder(pet);
sb.append("amaran");
String boat = sb.toString();
System.out.println(boat);

像这样的代码在行为上等效于以下内容,尽管在 Java 9 及以上版本中实际的字节码序列将有所不同:

String pet = "Cat";
String boat = pet + "amaran";
System.out.println(boat);

当然,除了在javac下使用之外,StringBuilder类也可以直接在应用程序代码中使用,我们已经看到了这一点。

警告

除了 StringBuilder,Java 还有一个 StringBuffer 类。这来自 Java 的最古老版本,不应该用于新开发——应该使用 StringBuilder,除非你确实需要在多个线程之间共享新字符串的构造。

字符串的不可变性是一种非常有用的语言特性。例如,假设 + 修改了字符串而不是创建新的字符串;那么,每当任何线程连接两个字符串时,所有其他线程也会看到这种变化。对于大多数程序来说,这不太可能是有用的行为,因此不可变性是合理的选择。

哈希码和有效不可变性

我们已经在 第五章 中遇到了 hashCode() 方法,我们描述了该方法必须满足的合约。让我们看一下 JDK 源代码,看看 String::hashCode() 方法是如何定义的:

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

字段 hash 存储了字符串的哈希码,而字段 value 是一个 char[],存储了构成字符串的实际字符。从代码中可以看出,Java 通过循环遍历字符串的所有字符来计算哈希值。因此,计算机指令的数量与字符串中的字符数成正比。对于非常大的字符串,这可能需要一些时间。Java 不会预先计算哈希值,而是在需要时才计算。

当方法运行时,通过遍历字符数组来计算哈希值。在数组的末尾,我们退出 for 循环并将计算得到的哈希值写回到 hash 字段中。现在,当再次调用此方法时,值已经被计算过了,所以我们可以直接使用缓存的值,后续调用 hashCode() 将立即返回。

注意

字符串的哈希码计算是一个良性数据竞争的例子。在具有多个线程的程序中,它们可能会竞争计算哈希码。但是,它们最终会得出完全相同的答案,因此称为良性

String 类的所有字段都是 final 的,除了 hash。因此,Java 的字符串严格来说并不是不可变的。但是,因为 hash 字段只是从其他所有不可变字段中确定性计算的值的缓存,只要 String 类被正确编码,它就会表现得像是不可变的。具有这种属性的类称为有效不可变——它们在实践中非常罕见,工作程序员通常可以忽略真正不可变和有效不可变数据之间的区别。

字符串格式化

在 “字符串连接” 中,我们看到 Java 如何支持通过连接较小的字符串来构建更大的字符串。虽然这很有效,但在构造更复杂的输出字符串时,这往往会变得单调和容易出错。Java 提供了许多其他方法和类来进行更丰富的字符串格式化。

String类上的静态方法format允许我们指定一个模板,然后动态地插入各种值:

// Result is the string "The 1 pet is a cat: true?"
var s = String.format("The %d pet is a %s: %b?%n", 1, "cat", true);

// Same result, but called on string instance instead of statically
s = "The %d pet is a %s: %b?%n".formatted(1, "cat", true);

格式字符串中的占位符,其中将引入值,以%字符开始。在这个例子中,我们用%d替换整数,用%s替换字符串,用%b替换布尔值,并最终用%n换行。

那些在 C 或类似语言中有背景的人会从这个受人尊敬的printf函数中认识到这种格式。Java 支持许多相同的格式,尽管不是全部,具有各种各样的选项。Java 的printf还提供了更复杂的日期和时间格式化,就像在 C 的strftime函数中看到的一样。请参阅 Java 文档中的java.util.Formatter,了解可用的所有选项的完整列表。

Java 还通过在出现无效条件时抛出异常来改进使用这些格式字符串的体验,比如占位符与值的数量不匹配,或者无法识别的%值。

String.format()提供了构建复杂字符串的强大工具,但是,特别是在使输出在各个国家正确时,需要更多的帮助。 NumberFormat是 Java 提供的支持更复杂的、与区域设置相关的值格式化的类的一个示例。其他格式化程序也可以在java.text下找到:

// Some common locales are available as constants
// A much longer list can be accessed at runtime
var locale = Locale.US;

NumberFormat.getNumberInstance(locale).format(1_000_000_000L)
// 1,000,000,000

NumberFormat.getCurrencyInstance(locale).format(1_000_000_000L)
// $1,000,000,000.00

NumberFormat.getPercentInstance(locale).format(0.1)
// 10%

NumberFormat.getCompactNumberInstance(locale , NumberFormat.Style.LONG)
            .format(1_000_000_000L)
// 1 billion

NumberFormat.getCompactNumberInstance(locale, NumberFormat.Style.SHORT)
            .format(1_000_000_000L)
// 1B

正则表达式

Java 支持正则表达式(通常缩写为regexregexp)。这些是用于扫描和匹配文本的搜索模式的表示。正则表达式是我们要搜索的字符序列。它们可以非常简单——例如,abc表示我们正在搜索的文本中的任何位置的a,后面紧跟着b,后面紧跟着c。请注意,搜索模式可能在输入文本中匹配零个、一个或多个位置。

最简单的正则表达式只是文本的字面字符序列,比如abc。然而,正则表达式的语言可以表达比字面序列更复杂、更微妙的想法。例如,正则表达式可以表示如下所示的匹配模式:

  • 数字字符

  • 任意字母

  • 任意数量的字母,它们必须都在aj的范围内,但可以是大写或小写

  • a后面跟着任意四个字符,然后是b

我们用来编写正则表达式的语法很简单,但是由于我们可以构建复杂的模式,通常可以编写一个没有精确实现我们想要的内容的表达式。在使用正则表达式时,全面测试它们非常重要。这应该包括应该通过的测试用例和应该失败的情况。

为了表达这些更复杂的模式,正则表达式使用元字符。这些是指示需要特殊处理的特殊字符。这可以类比于操作系统 shell 中使用 * 字符的用法。在这些情况下,理解 * 不是字面上解释,而是表示“任何东西”。如果我们想在 Unix 的当前目录中列出所有的 Java 源文件,我们会发出以下命令:

ls *.java

正则表达式的元字符是相似的,但它们的数量要多得多,并且比 shell 中可用的集合要灵活得多。它们的含义也不同于它们在 shell 脚本中的含义,因此不要感到困惑。

警告

世界上存在许多不同的正则表达式模式。Java 的正则表达式与 PCRE 兼容,支持一组常见的 Perl 编程语言中广泛使用的元字符。但请注意,网上找到的随机正则表达式可能实际上可能有效,也可能无效,取决于你使用的正则表达式库。

让我们来看几个例子。假设我们想要一个拼写检查程序,它对英式英语和美式英语之间的拼写差异宽松。这意味着 honorhonour 都应该被接受为有效的拼写选择。这在正则表达式中很容易做到。

Java 使用一个称为 Pattern(来自包 java.util.regex)的类来表示正则表达式。但是,这个类不能直接实例化。相反,通过使用静态工厂方法 compile() 来创建新实例。然后,从模式派生一个 Matcher 用于特定输入字符串,我们可以用它来探索输入字符串。例如,让我们来看一下莎士比亚戏剧 凯撒大帝 中的一部分:

Pattern p = Pattern.compile("honou?r");

String caesarUK = "For Brutus is an honourable man";
Matcher mUK = p.matcher(caesarUK);

String caesarUS = "For Brutus is an honorable man";
Matcher mUS = p.matcher(caesarUS);

System.out.println("Matches UK spelling? " + mUK.find());
System.out.println("Matches US spelling? " + mUS.find());
注意

当使用 Matcher 时要小心,因为它有一个名为 matches() 的方法。但是,这个方法指示模式是否可以覆盖整个输入字符串。如果模式只在字符串中间开始匹配,它将返回 false

最后一个示例介绍了我们的第一个正则表达式元字符 ?,在模式 honou?r 中。这意味着“前面的字符是可选的”——所以 honourhonor 都将匹配。让我们看另一个例子。假设我们想匹配 minimizeminimise(后一种拼写在英国英语中更常见)。我们可以使用方括号来指示可以从集合中选择任何一个字符(但只能有一个备选项)[]——就像这样:

Pattern p = Pattern.compile("minimi[sz]e");

表 9-1 提供了 Java 正则表达式中可用的扩展元字符列表。

表 9-1. 正则表达式元字符

元字符含义注释
?可选字符——零个或一个实例
*前面字符的零个或多个
+前面字符的一个或多个
{M,N}在前面字符的 MN 个实例之间
\d一个数字
\D一个非数字字符
\w一个单词字符数字,字母和 _
\W一个非单词字符
\s空白字符
\S非空白字符
\n换行符
\t制表符
.任意单个字符在 Java 中不包括换行符
[ ]方括号中的任何字符称为字符类
[^ ]不在方括号中的任何字符称为否定字符类
( )构建模式元素组称为组(或捕获组)
&#124;定义替代可能性实现逻辑OR
^字符串开头
$字符串结尾
\\字面转义(\\)字符

还有一些其他内容,但这是基本列表。java.util.regex.Pattern的 Java 文档是获取所有详细信息的良好来源。通过这些信息,我们可以构建更复杂的匹配表达式,比如本节前面提到的示例:

String text = "Apollo 13";

// A numeric digit. Note we must use \\ because we need a literal \
// and Java uses a single \ as an escape character, as per the table
Pattern p = Pattern.compile("\\d");
Matcher m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

// A single letter
p = Pattern.compile("[a-zA-Z]");
m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

// Any number of letters, which must all be in the range 'a' to 'j'
// but can be upper- or lowercase
p = Pattern.compile("([a-jA-J]*)");
m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

// 'a' followed by any four characters, followed by 'b'
text = "abacab";
p = Pattern.compile("a....b");
m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

正则表达式极其有用,可以确定字符串是否与给定模式匹配,还可以从字符串中提取片段。这是通过机制完成的,模式中通过()表示:

String text = "Apollo 13";

Pattern p = Pattern.compile("Apollo (\\d*)");
Matcher m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println("; mission: " + m.group(1));

调用Matcher.group(1)返回我们模式中(\\d*)匹配的文本。允许多个组,还有通过名称而非位置使用组的语法。详细信息请参阅 Java 文档。

处理正则表达式时的一个常见困难是需要同时为 Java 字符串和正则表达式使用转义字符。文本块中少了一些转义字符,比如引号字符,它们可以提供更清晰的表达:

// Detect if there are any double-quoted passages in string
// Note standard string literal requires escaping quotations
Pattern oldQuoted = Pattern.compile(".*\".*\".*");

Pattern newQuoted = Pattern.compile("""
 .*".*".*""");

让我们通过介绍 Java 8 中作为Pattern的一部分新增的方法asPredicate()来结束我们对正则表达式的快速导览。该方法的存在使我们能够轻松地从正则表达式过渡到 Java 集合及其对 lambda 表达式的新支持。

例如,假设我们有一个正则表达式和一组字符串。很自然地会问一个问题:“哪些字符串与该正则表达式匹配?”我们可以使用过滤惯用法,并使用辅助方法将正则表达式转换为Predicate,像这样:

// Contains a numeric digit
Pattern p = Pattern.compile("\\d");

List<String> ls = List.of("Cat", "Dog", "Ice-9", "99 Luftballoons");
List<String> containDigits = ls.stream()
        .filter(p.asPredicate())
        .toList();

System.out.println(containDigits);

Java 内置的文本处理支持对于大多数商业应用通常需要的文本处理任务已经足够。更高级的任务,例如搜索和处理非常大的数据集,或复杂的解析(包括形式语法),超出了本书的范围,但 Java 拥有大量有用的库和专门技术的绑定,用于文本处理和分析。

数字和数学

在这一节中,我们将更详细地讨论 Java 对数值类型的支持。特别是,我们将讨论 Java 使用的整数类型的二进制补码表示。我们将介绍浮点数的表示方式,并涉及它们可能引起的一些问题。我们还将通过一些使用 Java 标准数学操作库函数的示例来说明。

Java 如何表示整数类型

Java 的整数类型都是有符号的,正如我们在“原始数据类型”中首次提到的那样。这意味着所有整数类型都可以表示正数和负数。由于计算机使用二进制,这意味着表示这些数字的唯一合理方式是分割可能的位模式并使用其中一半来表示负数。

让我们用 Java 的byte类型来研究 Java 如何表示整数。它有 8 位,因此可以表示 256 个不同的数字(即 128 个负数和 128 个非负数)。用0b0000_0000模式表示零是合乎逻辑的(回忆一下 Java 使用0b<二进制数字>的语法来表示二进制数),然后可以轻松地找出正数的位模式:

byte b = 0b0000_0001;
System.out.println(b); // 1

b = 0b0000_0010;
System.out.println(b); // 2

b = 0b0000_0011;
System.out.println(b); // 3

// ...

b = 0b0111_1111;
System.out.println(b); // 127

当我们设置字节的第一个位时,符号会改变(因为我们已经用完了为非负数保留的所有位模式)。所以模式0b1000_0000应该表示某个负数——但是具体是哪个呢?

注意

由于我们定义的方式,我们可以很简单地识别出一个位模式是否表示负数:如果位模式的最高位是1,则表示的数字是负数。

考虑一个由所有位设置为 1 的位模式:0b1111_1111。如果我们给这个数字加上1,结果会溢出一个byte类型的 8 位存储空间,导致0b1_0000_0000。如果我们希望将其限制在byte数据类型内,则应忽略溢出,因此这变为0b0000_0000,也就是零。因此,自然地采用“所有位设置为 1 表示-1”的表示方式。这样可以实现自然的算术行为,如下所示:

b = (byte) 0b1111_1111; // -1
System.out.println(b);
b++;
System.out.println(b);

b = (byte) 0b1111_1110; // -2
System.out.println(b);
b++;
System.out.println(b);

最后,让我们看一下0b1000_0000表示的数字。它是该类型可以表示的最负的数字,所以对于byte类型:

b = (byte) 0b1000_0000;
System.out.println(b); // -128

这种表示方式被称为二进制补码,是有符号整数最常见的表示方式。要有效使用它,你只需记住两点:

  • 一个所有位为 1 的模式表示为-1。

  • 如果最高位设置为 1,则数字为负数。

Java 的其他整数类型(shortintlong)的行为方式非常相似,但其表示中包含更多的位数。char数据类型不同,因为它表示 Unicode 字符,但在某些方面它的行为类似于无符号的 16 位数值类型。在 Java 程序员眼中,它通常不被视为整数类型。

Java 和浮点数

计算机使用二进制表示数字。我们已经看到 Java 如何使用补码表示整数。但是对于分数或小数呢?Java 和几乎所有现代编程语言一样,使用浮点算术来表示它们。让我们首先看看这是如何工作的,首先是十进制,然后是二进制。Java 将两个最重要的数学常数,eπ(pi),定义为java.lang.Math中的常量,如下:

public static final double E = 2.7182818284590452354;
public static final double PI = 3.14159265358979323846;

当然,这些常数实际上是无理数,不能精确地表示为分数或任何有限小数。¹ 这意味着每当我们尝试在计算机中表示它们时,总会存在舍入误差。假设我们只想处理π的八位数,我们希望将这些数字表示为一个整数。我们可以使用以下表示方法:

314159265 • 10^(–8)

This starts to suggest the basis of how floating-point numbers work. We
use some of the bits to represent the significant digits (`314159265`,
in our example) of the number and some bits to represent the *exponent*
of the base (`-8`, in our example). The collection of significant digits
is called the *significand* and the exponent describes whether we need
to shift the significand up or down to get to the desired number.
Of course, in the examples we’ve met until now, we’ve been working in
base-10\. Computers use binary, so we need to use this as the base in our
floating-point examples. This introduces some additional complications.

###### Note

The number `0.1` cannot be expressed as a finite sequence of binary
digits. This means that virtually all calculations that humans care
about will lose precision when performed in floating point, and rounding
error is essentially inevitable.

Let’s look at an example that shows the rounding problem:

double d = 0.3;

System.out.println(d); // 为了避免丑陋的表示而特别处理

double d2 = 0.2;

// 应该是 -0.1,但打印出 -0.09999999999999998

System.out.println(d2 - d);


The official standard that describes floating-point arithmetic is IEEE-754, and
Java’s support for floating point is based on that standard. The standard uses
24 binary digits for standard precision and 53 binary digits for double
precision.
As we mentioned briefly in Chapter 2, Java previously allowed deviation
from this standard, resulting in greater precision when some hardware features
were used to accelerate calculations. As of Java 17, this is no longer allowed,
and all floating-point operations comply with the IEEE-754 standard.

### BigDecimal

Rounding error is a constant source of headaches for programmers who
work with floating-point numbers. In response, Java has a class
`java.math.BigDecimal` that provides arbitrary precision arithmetic, in
a decimal representation. This works around the problem of `0.1` not
having a finite representation in binary, but there are still some edge
conditions when converting to or from Java’s primitive types, as you can
see:

double d = 0.3;

System.out.println(d);

BigDecimal bd = new BigDecimal(d);

System.out.println(bd);

bd = new BigDecimal("0.3");

System.out.println(bd);


However, even with all arithmetic performed in base-10, there are still
numbers, such as `1/3`, that do not have a terminating decimal
representation. Let’s see what happens when we try to represent such
numbers using `BigDecimal`:

bd = new BigDecimal(BigInteger.ONE);

bd.divide(new BigDecimal(3.0));

System.out.println(bd); // 应该是 1/3


As `BigDecimal` can’t represent `1/3` precisely, the call to `divide()`
blows up with `ArithmeticException`. When you are working with `BigDecimal`, it
is therefore necessary to be acutely aware of exactly which operations
could result in a nonterminating decimal result. To make matters worse,
`ArithmeticException` is an unchecked, runtime exception and so the Java
compiler does not even warn about possible exceptions of this type.

As a final note on floating-point numbers, the paper “What Every
Computer Scientist Should Know About Floating-Point Arithmetic” by David
Goldberg should be considered essential further reading for all
professional programmers. It is easily and freely obtainable on the
internet.

Java 的标准数学函数库

结束对 Java 对数值数据和数学支持的探索,让我们快速浏览一下 Java 附带的标准库函数。这些主要是静态辅助方法,位于java.lang.Math类上,包括函数如下:

abs()

返回一个数的绝对值。具有多种基本类型的重载形式。

三角函数

计算正弦、余弦、正切等的基本函数。Java 还包括双曲函数版本和反函数(如反正弦)。

max()min()

重载函数用于返回两个参数中较大和较小的一个(相同的数值类型)。

ceil()floor()

用于舍入到整数。floor()返回小于参数的最大整数(参数为 double)。ceil()返回大于参数的最小整数。

pow()exp()log()

用于计算一个数的幂以及计算指数和自然对数的函数。log10()提供以 10 为底的对数,而不是自然底数。

让我们看一些如何使用这些函数的简单示例:

System.out.println(Math.abs(2));
System.out.println(Math.abs(-2));

double cosp3 = Math.cos(0.3);
double sinp3 = Math.sin(0.3);
System.out.println((cosp3 * cosp3 + sinp3 * sinp3)); // Always 1.0

System.out.println(Math.max(0.3, 0.7));
System.out.println(Math.max(0.3, -0.3));
System.out.println(Math.max(-0.3, -0.7));

System.out.println(Math.min(0.3, 0.7));
System.out.println(Math.min(0.3, -0.3));
System.out.println(Math.min(-0.3, -0.7));

System.out.println(Math.floor(1.3));
System.out.println(Math.ceil(1.3));
System.out.println(Math.floor(7.5));
System.out.println(Math.ceil(7.5));

System.out.println(Math.round(1.3)); // Returns long
System.out.println(Math.round(7.5)); // Returns long

System.out.println(Math.pow(2.0, 10.0));
System.out.println(Math.exp(1));
System.out.println(Math.exp(2));
System.out.println(Math.log(2.718281828459045));
System.out.println(Math.log10(100_000));
System.out.println(Math.log10(Integer.MAX_VALUE));

System.out.println(Math.random());
System.out.println("Let's toss a coin: ");
if (Math.random() > 0.5) {
    System.out.println("It's heads");
} else {
    System.out.println("It's tails");
}

结束本节时,让我们简要讨论一下 Java 的 random() 函数。第一次调用时,它设置一个新的 java.util.Random 实例。这是一个 伪随机数生成器(PRNG)—一个通过数学公式产生 看起来 随机但实际上是由公式产生的确定性代码片段。² 在 Java 的情况下,用于 PRNG 的公式非常简单,例如:

    // From java.util.Random
    public double nextDouble() {
        return (((long)(next(26)) << 27) + next(27)) * DOUBLE_UNIT;
    }

如果伪随机数的序列总是从同一位置开始,那么将生成完全相同的数字流。为了解决这个问题,伪随机数生成器的种子由一个应包含尽可能多真实随机性的值来设置。对于这种随机性来源于种子值的来源,Java 使用通常用于高精度计时的 CPU 计数器值。

警告

虽然 Java 内置的伪随机数对大多数一般应用来说足够了,但某些专业应用(尤其是密码学和某些类型的模拟)对随机数有更严格的要求。如果你正在开发这类应用,请寻求已在该领域工作的程序员的专家建议。

现在我们已经看过了文本和数值数据,让我们继续看一下另一种最常见的数据类型:日期和时间信息。

日期和时间

几乎所有的业务软件应用都涉及到日期和时间的概念。在建模真实世界的事件或交互时,收集事件发生时间点对未来的报告或领域对象比较至关重要。Java 8 对开发者处理日期和时间的方式进行了彻底的改革。本节介绍了这些概念。在早期版本中,唯一的支持是通过类似 java.util.Date 的类,这些类不能很好地建模这些概念。使用旧的 API 的代码应尽快迁移。

引入 Java 8 日期和时间 API

Java 8 引入了新的包 java.time,其中包含大多数开发者使用的核心类。它还包含四个子包:

java.time.chrono

开发者使用非 ISO 标准的日历系统时将与之交互的备选年表。例如,日本的日历系统。

java.time.format

包含用于将日期和时间对象转换为 String,以及将字符串解析为日期和时间对象的 DateTimeFormatter

java.time.temporal

包含核心日期和时间类所需的接口,还包括用于日期高级操作的抽象(如查询和调整器)。

java.time.zone

用于底层时区规则的类;大多数开发者不需要这个包。

在表示时间时,最重要的概念之一是某个实体时间线上瞬时点的概念。虽然这个概念在例如特殊相对论中有明确定义,但在计算机中表示它需要我们做出一些假设。在 Java 中,我们将时间的单个点表示为Instant,它有以下关键假设:

  • 我们无法表示超过long类型可以容纳的秒数。

  • 我们无法以比纳秒精度更精确地表示时间。

这意味着我们限制自己以一种与当前计算机系统能力相一致的方式来建模时间。然而,还应引入另一个基本概念。

一个Instant是关于时空中单个事件的概念。然而,程序员经常需要处理两个事件之间的间隔,因此 Java 还包含了java.time.Duration类。该类忽略了可能出现的日历效应(例如夏令时)。通过这种对瞬时事件和事件之间持续时间的基本理解,让我们继续探讨关于瞬时事件的可能思考方式。

时间戳的部分

在图 9-1 中,我们展示了时间戳的不同部分在多种可能的方式下的分解。

JN7 0901

图 9-1. 时间戳的分解

这里的关键概念是在不同的时间可能适用于多种不同的抽象。例如,有些应用程序中,LocalDate对业务处理至关重要,所需的粒度是工作日。另外,有些应用程序要求亚秒甚至毫秒的精度。开发人员应了解他们的领域,并在应用程序中使用合适的表示。

示例

日期和时间 API 一开始可能会让人感到困惑,所以让我们从看一个示例开始,并讨论一个日记类,用于跟踪生日。如果你对生日很健忘,那么这样的类(特别是像getBirthdaysInNextMonth()这样的方法)可能会非常有帮助:

public class BirthdayDiary {
    private Map<String, LocalDate> birthdays;

    public BirthdayDiary() {
        birthdays = new HashMap<>();
    }

    public LocalDate addBirthday(String name, int day, int month,
                                 int year) {
        LocalDate birthday = LocalDate.of(year, month, day);
        birthdays.put(name, birthday);
        return birthday;
    }

    public LocalDate getBirthdayFor(String name) {
        return birthdays.get(name);
    }

    public int getAgeInYear(String name, int year) {
        Period period = Period.between(
              birthdays.get(name),
              birthdays.get(name).withYear(year));

        return period.getYears();
    }

    public Set<String> getFriendsOfAgeIn(int age, int year) {
        return birthdays.keySet().stream()
                .filter(p -> getAgeInYear(p, year) == age)
                .collect(Collectors.toSet());
    }

    public int getDaysUntilBirthday(String name) {
        Period period = Period.between(
              LocalDate.now(),
              birthdays.get(name));

        return period.getDays();
    }

    public Set<String> getBirthdaysIn(Month month) {
        return birthdays.entrySet().stream()
                .filter(p -> p.getValue().getMonth() == month)
                .map(p -> p.getKey())
                .collect(Collectors.toSet());
    }

    public Set<String> getBirthdaysInCurrentMonth() {
        return getBirthdaysIn(LocalDate.now().getMonth());
    }

    public int getTotalAgeInYears() {
        return birthdays.keySet().stream()
                .mapToInt(p -> getAgeInYear(p,
                      LocalDate.now().getYear()))
                .sum();
    }
}

这个课程展示了如何使用低级 API 来构建有用的功能。它还使用了像 Java Streams API 这样的创新技术,并演示了如何将LocalDate作为不可变类使用,以及如何将日期视为值进行处理。

查询

在广泛的情况下,我们可能会发现自己想要回答有关特定时间对象的问题。一些可能需要回答的示例问题包括:

  • 日期是否在三月一日之前?

  • 日期是否在闰年中?

  • 从今天到我的下一个生日还有多少天?

这是通过使用TemporalQuery接口来实现的,其定义如下:

public interface TemporalQuery<R> {
    R queryFrom(TemporalAccessor temporal);
}

queryFrom()的参数不应为null,但如果结果表明未找到值,则可以使用null作为返回值。

注意

Predicate 接口可以被视为只能代表是或否问题的查询。时间查询更为通用,可以返回“多少?”或“哪个?”的值,而不仅仅是“是”或“否”。

让我们通过考虑一个回答以下问题的查询来看一个查询的示例在操作中的应用:“这个日期属于一年的哪个季度?”Java 不直接支持季度的概念。而是使用这样的代码:

LocalDate today = LocalDate.now();
Month currentMonth = today.getMonth();
Month firstMonthofQuarter = currentMonth.firstMonthOfQuarter();

这仍然没有提供季度作为一个单独的抽象,而是仍然需要特殊的情况代码。所以让我们通过定义这个枚举类型稍微扩展 JDK 的支持:

public enum Quarter {
    FIRST, SECOND, THIRD, FOURTH;
}

现在,查询可以写为:

public class QuarterOfYearQuery implements TemporalQuery<Quarter> {
    @Override
    public Quarter queryFrom(TemporalAccessor temporal) {
        LocalDate now = LocalDate.from(temporal);

        if(now.isBefore(now.with(Month.APRIL).withDayOfMonth(1))) {
            return Quarter.FIRST;
        } else if(now.isBefore(now.with(Month.JULY)
                               .withDayOfMonth(1))) {
            return Quarter.SECOND;
        } else if(now.isBefore(now.with(Month.NOVEMBER)
                               .withDayOfMonth(1))) {
            return Quarter.THIRD;
        } else {
           return Quarter.FOURTH;
        }
    }
}

TemporalQuery 对象可以直接或间接使用。让我们分别看一些示例:

QuarterOfYearQuery q = new QuarterOfYearQuery();

// Direct
Quarter quarter = q.queryFrom(LocalDate.now());
System.out.println(quarter);

// Indirect
quarter = LocalDate.now().query(q);
System.out.println(quarter);

在大多数情况下,最好使用间接方法,其中查询对象作为参数传递给 query()。因为这样在代码中通常更容易阅读。

调整器

调整器修改日期和时间对象。例如,假设我们想返回包含特定时间戳的季度的第一天:

public class FirstDayOfQuarter implements TemporalAdjuster {
    @Override
    public Temporal adjustInto(Temporal temporal) {
        final int currentQuarter = YearMonth.from(temporal)
                .get(IsoFields.QUARTER_OF_YEAR);

        final Month firstMonthOfQuarter = switch (currentQuarter) {
            case 1 -> Month.JANUARY;
            case 2 -> Month.APRIL;
            case 3 -> Month.JULY;
            case 4 -> Month.OCTOBER;
            default -> throw new IllegalArgumentException("Impossible");
        };

        return LocalDate.from(temporal)
                .withMonth(firstMonthOfQuarter.getValue())
                .with(TemporalAdjusters.firstDayOfMonth());
    }
}

让我们看一个使用调整器的例子:

LocalDate now = LocalDate.now();
Temporal fdoq = now.with(new FirstDayOfQuarter());
System.out.println(fdoq);

这里的关键是 with() 方法,代码应该被解读为接受一个 Temporal 对象并返回另一个已修改的对象。对于处理不可变对象的 API 来说,这是完全正常的。

时区

如果你处理关于日期的代码,几乎肯定会遇到来自时区的复杂性。除了向用户清晰地展示信息的简单问题之外,时区还会引起问题,因为它们会变化。无论是来自夏令时的调整还是政府重新分配给定领土的区域,今天的时区定义不能保证下个月相同。

JVM 自带标准 IANA 时区数据的副本,因此通常需要 JDK 升级来获取时区更新。对于那些需要更频繁变更的人,Oracle 发布了一个 tzupdater 工具,可用于在原地修改 JDK 安装以使用更新的数据。

遗留日期和时间

不幸的是,许多应用程序尚未转换为使用随 Java 8 一起提供的优秀日期和时间库。因此,为了完整起见,我们简要提到了基于 java.util.Date 的遗留日期和时间支持。

警告

遗留的日期和时间类,特别是 java.util.Date,不应在现代 Java 环境中使用。考虑重构或重新编写任何仍使用旧类的代码。

在旧版 Java 中,java.time 不可用。相反,程序员依赖于由 java.util.Date 提供的传统和基础支持。从历史上看,这是表示时间戳的唯一方法,尽管被称为 Date,但实际上这个类包含了日期和时间组件 —— 这导致许多程序员感到困惑。

Date提供的遗留支持存在许多问题,例如:

  • Date类的设计有误。它实际上并不指代一个日期,而更像是一个时间戳。事实证明,我们需要不同的表示形式来表示日期、日期时间和瞬时时间戳。

  • Date是可变的。我们可以获得一个日期的引用,然后改变它所指向的日期。

  • Date类实际上不接受 ISO-8601,即通用的 ISO 日期标准,作为有效的日期。

  • Date类有大量被弃用的方法。

当前的 JDK 为Date使用了两个构造函数——一个是旨在成为“现在构造函数”的void构造函数,另一个是接受自纪元以来的毫秒数的构造函数。

如果无法避免使用java.util.Date,你仍然可以通过像以下示例中的代码进行转换,以利用更新的 API:

// Defaults to timestamp when called
var oldDate = new java.util.Date();

// Note both forms require specifying timezone -
// part of the failing in the old API
var newDate = LocalDate.ofInstant(
                  oldDate.toInstant(),
                  ZoneId.systemDefault());

var newTime = LocalDateTime.ofInstant(
                  oldDate.toInstant(),
                  ZoneId.systemDefault());

摘要

在本章中,我们遇到了几种不同类别的数据。文本和数字数据是最明显的例子,但作为工作程序员,我们将遇到许多不同类型的数据。让我们继续看看如何处理整个数据文件以及使用新的 I/O 和网络工作方式。幸运的是,Java 提供了处理许多这些抽象的良好支持。

¹ 实际上,它们实际上是超越数的两个已知例子之一。

² 让计算机产生真正的随机数是非常困难的,在确实需要这样做的罕见情况下,通常需要专门的硬件。