9.1 文本

我们已经在很多场合见过 Java 的字符串。字符串由一系列 Unicode 字符组成,是 String 类的实例。字符串是 Java 程序最常处理的数据类型之一(可以使用第 13 章介绍的 jmap 工具证实这一点)。

本节会深入介绍 String 类,并弄清为什么字符串在 Java 语言中占据如此重要的地位。本节末尾还会介绍正则表达式,这是十分常用的抽象方式,用于搜索文本中的模式匹配(也是程序员的传统工具)。

9.1.1 字符串的特殊句法

Java 语言使用某种特殊的方式处理 String 类。虽然字符串不是基本类型,但十分常用,所以 Java 的设计者觉得有必要提供一些特殊的句法特性,便于处理字符串。下面通过一些示例介绍 Java 为字符串提供的特殊句法特性。

1. 字符串字面量

第 2 章介绍过,Java 允许把一系列字符放在双引号中创建字面量字符串对象。例如:

  1. String pet = "Cat";

如果没有这种特殊的句法,就要编写大量不友好的代码,例如:

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

这样很快就会把代码变得冗长乏味,因此,Java 像所有现代编程语言一样,提供了简单的字符串字面量句法。字符串字面量是完全有效的对象,所以类似下面这种代码是完全合法的:

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

2. toString()方法

这个方法在 Object 类中定义,作用是方便把任何对象转换成字符串。有了这个方法,就可以使用 System.out.println() 方法轻易打印任何对象。System.out.println() 方法其实是 PrintStream::println,因为 System.outPrintStream 类型的静态字段。我们来看一下这个方法是如何定义的:

  1. public void println(Object x) {
  2. String s = String.valueOf(x);
  3. synchronized (this) {
  4. print(s);
  5. newLine();
  6. }
  7. }

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

  1. public static String valueOf(Object obj) {
  2. return (obj == null) ? "null" : obj.toString();
  3. }

9.1 文本 - 图1 println() 方法没有直接使用 toString() 方法,而使用了静态方法 valueOf(),这么做是为了避免 objnull 时抛出 NullPointerException 异常。

这种定义方式让任何对象都能调用 toString() 方法,也十分有利于 Java 提供的另一种重要的句法特性——字符串连接。

3. 字符串连接

在 Java 中,可以把一个字符串“添加”到另一个字符串的末尾,创建新字符串——这是一个语言特性,叫作字符串连接,使用运算符 + 实现。连接字符串时,先创建一个使用 StringBuilder 对象表示的“工作区”,其内容和原始字符串中的字符序列一样。

然后更新 StringBuilder 对象,把另一个字符串中的字符添加到末尾。最后,在 StringBuilder 对象(现在这个对象包含两个字符串中的字符)上调用 toString() 方法,得到一个包含所有字符的新字符串。使用 + 运算符连接字符串时,javac 会自动创建上述所有代码。

连接后得到的是全新的 String 对象,这一点从下面的示例可以看出:

  1. String s1 = "AB";
  2. String s2 = "CD";
  3. String s3 = s1;
  4. System.out.println(s1 == s3); // 是不是同一个对象?
  5. s3 = s1 + s2;
  6. System.out.println(s1 == s3); // 还是不是同一个对象?
  7. System.out.println(s1);
  8. System.out.println(s3);

这个连接字符串的示例直接表明,+ 运算符没有就地修改(或改变)s1。这个示例也体现了一个通用规则:Java 的字符串是不可变的。也就是说,选定组成字符串的字符并创建 String 对象后,字符串的内容就不能改变了。这是 Java 语言的一个重要规则,下面稍微深入地讨论一下。

9.1.2 字符串的不可变性

为了“修改”字符串,就像前面连接字符串那样,其实需要创建一个过渡的 StringBuilder 对象作为暂存区,然后在这个对象上调用 toString() 方法,创建一个新 String 实例。下面通过代码演示这个过程:

  1. String pet = "Cat";
  2. StringBuilder sb = new StringBuilder(pet);
  3. sb.append("amaran");
  4. String boat = sb.toString();
  5. System.out.println(boat);

如果编写的是下述代码,javac 就会生成类似上面的代码:

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

当然,除了能由 javac 隐式使用之外,如前所示,也可以直接使用 StringBuilder 类。

9.1 文本 - 图2 除了 StringBuilder 类,Java 还有 StringBuffer 类。StringBuffer 类在最早的 Java 版本中出现,新编写的程序不要使用这个类——应该使用 StringBuilder 类,除非确实需要在多个线程之间共享构建的新字符串。

字符串的不可变性是极其有用的语言特性。假如 + 运算符直接修改字符串,而不是创建新字符串,那么,只要某个线程连接了两个字符串,其他所有线程都能看到这个变化。对大多数程序来说,这种行为没什么用,所以不可变性更合理。

哈希码和事实不可变性

第 5 章说明方法必须满足的契约(contract)1 时,见过 hashCode() 方法。我们来看一下 String::hashCode() 方法在 JDK 源码中是怎么定义的:

1契约指方法的行为所符合的特定标准。——译者注

  1. public int hashCode() {
  2. int h = hash;
  3. if (h == 0 && value.length > 0) {
  4. char val[] = value;
  5. for (int i = 0; i < value.length; i++) {
  6. h = 31 * h + val[i];
  7. }
  8. hash = h;
  9. }
  10. return h;
  11. }

hash 字段保存的是字符串的哈希码,value 字段是 char[] 类型,保存的是组成字符串的字符。从上述代码可以看出,计算哈希码时会遍历字符串中的所有字符。因此,所执行的机器指令数量和字符串中的字符数量成正比。对超大型字符串来说,要花点时间才能算出哈希码。不过,Java 不会预先计算好哈希码,只在需要使用时才计算。

运行这个方法时,会迭代数组中的字符,算出哈希码。迭代结束后,退出 for 循环,把算出的哈希码存入 hash 字段。如果再次调用这个方法,因为已经算出了哈希码,所以会直接使用缓存的值。因此,后续再调用 hashCode() 方法,会立即返回

9.1 文本 - 图3 计算字符串哈希码的过程是良性数据竞争的一例。运行在多线程环境中的程序,多个线程可能会竞相计算哈希码。不过,这些线程最终会得到完全相同的结果,因此才说这种竞争是“良性的”。

String 类的字段,除了 hash 之外都声明为 final。所以,严格来说,Java 的字符串并不是不可变的。不过,hash 字段缓存的值是根据其他字段计算而来的,而这些字段的值都是不可变的,因此,只要选定了字符串的内容,那么表现出来的行为就像是不可变的一样。具有这种特性的类称为事实不可变的类——现实中很少见到这种类,程序员往往可以忽略真正不可变的数据和事实不可变的数据之间的区别。

9.1.3 正则表达式

Java 支持正则表达式(regular expression,经常简称 regex 或 regexp)。正则表达式表示的是用于扫描和匹配文本的搜索模式。一个正则表达式就是我们想搜索的字符序列。有些正则表达式很简单,例如 abc,这个正则表达式的意思是,在要搜索的文本中查找连在一起的“abc”。注意,搜索模式匹配的文本可以出现零次、一次或多次。

最简单的正则表达式只包含字符字面量序列,例如 abc。不过,正则表达式使用的语言能表达比字面量序列复杂和精细的模式。例如,正则表达式能表示匹配下述内容的模式:

  • 一个数字

  • 任何字母

  • 任意个字母,但字母只能在 a 到 j 之间,大小写不限

  • a 和 b 之间有任意四个字符

编写正则表达式的句法虽然简单,但是因为可能要编写复杂的模式,所以往往写出的正则表达式不能实现真正想要的模式。因此,使用正则表达式时,一定要充分测试,既要有能通过的测试用例,也要有失败的测试用例。

为了表示复杂的模式,在正则表达式中要使用元字符。这种特殊的字符要特别对待。元字符的作用类似于 Unix 或 Windows shell 中使用的 * 字符。在 shell 中,我们知道 * 字符不能按照字面量理解,而是表示“任意字符”。在 Unix 中,如果想列出当前目录中的全部 Java 源码文件,可以执行下述命令:

  1. ls *.java

正则表达式中的元字符和 * 字符的作用类似,和 shell 中可以使用的特殊字符相比,元字符数量更多,用起来也更灵活,而且有不同的意义,所以不要混淆了。

我们看个例子。假如我们要编写一个拼写检查程序,但不严格限制只能使用英式英语或美式英语的拼写方式。也就是说,honor 和 honour 都是有效的拼写。这个要求通过正则表达式很容易实现。

Java 使用 Pattern 类(在 java.util.regex 包中)表示正则表达式。不过,这个类不能直接实例化,只能使用静态工厂方法 compile() 创建实例。然后,再从模式上创建某个输入字符串的 Matcher 对象,用于匹配输入字符串。例如,我们来研究一下莎士比亚写的戏剧《裘里斯 · 凯撒》:

  1. Pattern p = Pattern.compile("honou?r");
  2. String caesarUK = "For Brutus is an honourable man";
  3. Matcher mUK = p.matcher(caesarUK);
  4. String caesarUS = "For Brutus is an honorable man";
  5. Matcher mUS = p.matcher(caesarUS);
  6. System.out.println("Matches UK spelling? " + mUK.find());
  7. System.out.println("Matches US spelling? " + mUS.find());

9.1 文本 - 图4 使用 Matcher 类时要小心,因为它有个名为 matches() 的方法。这个方法判断模式是否匹配整个输入字符串。如果模式只从字符串的中间开始匹配,这个方法会返回 false

上述示例第一次用到了正则表达式元字符——honou?r 模式中的 ?。这个元字符的意思是,“前一个字符是可选的”,所以这个模式既能匹配 honour 也能匹配 honor。下面再看个例子。假设我们既想匹配 minimize,又想匹配 minimise(这种拼写在英式英语中较常见),那么,可以使用方括号表示能匹配一个集中的任意一个字符(只能是一个),如下所示:

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

表 9-1 列出了 Java 正则表达式可以使用的一些元字符。

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

元字符 意义 备注
? 可选字符——出现零次或一次
* 前一个字符出现零次或多次
+ 前一个字符出现一次或多次
{M,N} 前一个字符出现 MN
\d 一个数字
\D 一个不是数字的字符
\w 一个组成单词的字符 数字、字母和 _
\W 一个不能组成单词的字符
\s 一个空白字符
\S 一个不是空白的字符
\n 换行符
\t 制表符
. 任意一个字符 在 Java 中不包含换行符
[ ] 方括号中的任意一个字符 叫作字符组
[^ ] 不在方括号中的任意一个字符 叫作排除字符组
( ) 构成一组模式元素 叫作组(或捕获组)
| 定义可选值 实现逻辑或
^ 字符串的开头
$ 字符串的末尾

除此之外还有一些,不过这些是基本的元字符。使用这些元字符可以编写更复杂的正则表达式,匹配本节前面给出的示例:

  1. // 注意,必须使用\\,因为我们需要的是字面量\,而Java使用单个\转义字符
  2. String pStr = "\\d"; // 一个数字
  3. String text = "Apollo 13";
  4. Pattern p = Pattern.compile(pStr);
  5. Matcher m = p.matcher(text);
  6. System.out.print(pStr + " matches " + text + "? " + m.find());
  7. System.out.println(" ; match: " + m.group());
  8. pStr = "[a..zA..Z]"; // 任意一个字母
  9. p = Pattern.compile(pStr);
  10. m = p.matcher(text);
  11. System.out.print(pStr + " matches " + text + "? " + m.find());
  12. System.out.println(" ; match: " + m.group());
  13. // 任意个字母,但字母只能在a到j之间,大小写不限
  14. pStr = "([a..jA..J]*)";
  15. p = Pattern.compile(pStr);
  16. m = p.matcher(text);
  17. System.out.print(pStr + " matches " + text + "? " + m.find());
  18. System.out.println(" ; match: " + m.group());
  19. text = "abacab";
  20. pStr = "a....b"; // a和b之间有四个字符
  21. p = Pattern.compile(pStr);
  22. m = p.matcher(text);
  23. System.out.print(pStr + " matches " + text + "? " + m.find());
  24. System.out.println(" ; match: " + m.group());

本节结束之前,我们还要介绍 Java 8 添加到 Pattern 类中的一个新方法:asPredicate()。引入这个方法的目的是,让开发者通过简单的方式把正则表达式与 Java 集合和对 lambda 表达式的支持联系起来。

假如有一个正则表达式和一个由字符串组成的集合,很自然地会出现这个问题:“哪些字符串匹配这个正则表达式?”为了回答这个问题,我们可以使用过滤器模式,并使用辅助方法 asPredicate() 把正则表达式转换成 Predicate 对象,如下所示:

  1. String pStr = "\\d"; // 一个数字
  2. Pattern p = Pattern.compile(pStr);
  3. String[] inputs = {"Cat", "Dog", "Ice-9", "99 Luftballoons"};
  4. List<String> ls = Arrays.asList(inputs);
  5. List<String> containDigits = ls.stream()
  6. .filter(p.asPredicate())
  7. .collect(Collectors.toList());
  8. System.out.println(containDigits);

Java 对文本处理的原生支持完全能胜任大多数商业应用对文本处理任务的一般要求。更高级的任务,例如搜索并处理超大型数据集,或复杂的解析操作(包括形式语法),超出了本书范畴,不过要知道,Java 的生态系统很庞大,有很多有用的库,而且有很多用于文本处理和分析的专用技术。