Java 秘籍第四版(二)

原文:zh.annas-archive.org/md5/0f97e455a02e6f168767c004952156f0

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:使用正则表达式进行模式匹配

4.0 简介

假设你已经在互联网上几年了,并且一直在保留所有通讯记录,以防万一您(或您的律师或检察官)需要一份副本。结果是您有一个专门用于保存邮件的 5 GB 磁盘分区。让我们进一步假设您记得在其中的某个位置有一封来自某个名叫 Angie 或 Anjie 的人的电子邮件。还是说是 Angy?但您不记得您如何称呼它或者您将其存储在何处。显然,您得去找它。

但是当你们中的一些人试图在文字处理器中打开所有 15,000,000 个文档时,我只需用一个简单的命令找到它。任何提供正则表达式支持的系统都允许我以几种方式搜索模式。最简单易懂的是:

Angie|Anjie|Angy

您可能猜到这意味着只需搜索任何变体。更简洁的形式(更多思考,更少打字)是:

An[^ dn]

语法将在我们进行本章讨论时变得清晰。简而言之,“A”和“n”会自动匹配它们自己,实际上找到以“An”开头的单词,而神秘的[^ dn]要求“An”后面跟着一个不是(^在这里的意思是not)空格的字符(以消除在句子开头非常常见的英语单词“an”)或“d”(以消除常见词“and”)或“n”(以消除“Anne”、“Announcing”等)。您的文字处理器是否已经跳过了它的闪屏页面?好吧,这没关系,因为我已经找到了丢失的文件。要找到答案,我只需输入这个命令:

grep 'An[^ dn]' *

正则表达式,或称为regexes,提供了一种简洁而精确的模式规范,用于在文本中匹配。一种好的理解正则表达式的方式是它是用于在字符串中匹配字符模式的一种小语言。正则表达式 API 是用于匹配正则表达式的解释器

另一个正则表达式强大之处的例子是考虑大规模更新数百个文件的问题。当我开始学习 Java 时,声明数组引用的语法是baseType arrayVariableName[]。例如,一个带有数组参数的方法,例如每个程序的主方法,通常是这样写的:

public static void main(String args[]) {

但随着时间的推移,Java 语言的管理者们认识到将其写为baseType[] arrayVariableName会更好,就像这样:

public static void main(String[] args) {

这是更好的 Java 风格,因为它将类型的“数组性”与类型本身关联起来,而不是与局部参数名关联,并且编译器仍然接受这两种模式。我希望将所有以旧方式编写的main更改为新方式。我使用了模式*main(String [a-z],通过之前描述的grep实用程序查找所有包含旧式主体声明(即main(String后跟一个空格和一个名称字符而不是一个开方括号)的文件名。然后,我在一个小的 shell 脚本中使用另一个基于正则表达式的 Unix 工具流编辑器sed来更改这些文件中所有出现的内容,从main(String *([a-z][a-z]*)[]main(String[] $1*(此处使用的正则表达式语法在本章后面讨论)。同样,基于正则表达式的方法比交互式地进行操作快得多,即使使用像viemacs这样的强大编辑器,更不用说尝试使用图形化的文字处理器了。

历史上,随着正则表达式被越来越多的工具和编程语言所采纳,其语法也发生了变化,因此先前示例中的确切语法并不完全适用于 Java,但它确实传达了正则表达式机制的简洁性和强大性。¹

第三个例子是解析 Apache Web 服务器日志文件,其中一些字段用引号分隔,另一些用方括号分隔,还有些用空格分隔。在任何语言中编写解析代码都很混乱,但是一个精心设计的正则表达式可以在一次操作中将行分解为所有组成部分(此示例在配方 4.10 中开发)。

Java 开发人员也可以获得同样的时间节省。正则表达式支持已经在标准 Java 运行时中存在多年,并且得到了很好的集成(例如,标准类java.lang.String和新 I/O 包中都有正则表达式方法)。Java 还有一些其他正则表达式包,偶尔可能会遇到使用它们的代码,但几乎所有本世纪的代码都可以预期使用内置包。Java 正则表达式的语法本身在配方 4.1 中讨论,使用正则表达式的 Java API 的语法在配方 4.2 中描述。其余的配方展示了 Java 中正则表达式技术的一些应用。

参见

精通正则表达式 由 Jeffrey Friedl(O’Reilly)是正则表达式所有细节的权威指南。Unix 和 Perl 的大多数入门书籍都包含对正则表达式的讨论;Unix 权威指南 由 Mike Loukides、Tim O’Reilly、Jerry Peek 和 Shelley Powers(O’Reilly)专门有一章介绍正则表达式。

4.1 正则表达式语法

问题

您需要学习 Java 正则表达式的语法。

解决方案

参考 Table 4-1 获取正则表达式字符列表。

讨论

这些模式字符允许您指定具有相当强大的正则表达式。在构建模式时,您可以使用任何普通文本和元字符或特殊字符的组合,在 Table 4-1 中。这些可以以任何合理的组合方式使用。例如,a+ 表示字母 a 的任意次数,从一次到百万次或无限多次。模式 Mrs?. 匹配 Mr.Mrs..* 表示任意字符,任意次数,类似于大多数命令行解释器对单独的 \* 的含义。模式 \d+ 表示任意数量的数字。\d{2,3} 表示二位或三位数字。

Table 4-1. 正则表达式元字符语法

子表达式匹配注释
通用
\^行/字符串的起始位置
$行/字符串的结束位置
\b单词边界
\B非单词边界
\A整个字符串的开始
\z整个字符串的结束
\Z整个字符串的结束(除了允许的最终行终止符)参见 Recipe 4.9
.任一字符(不包括行终止符)
[…]“字符类”;包含列出的任一字符
[\^…]不包含列出的任一字符参见 Recipe 4.2
替代和分组
(…)分组(捕获组)参见 Recipe 4.3
|替代
(?:re )非捕获括号
\G前一次匹配的结束
++n回溯引用到捕获组编号 n
普通(贪婪)量词
{ m,n }mn 次重复的量词参见 Recipe 4.4
{ m ,}至少 m 次重复的量词
{ m }正好 m 次重复的量词参见 Recipe 4.10
{,n }0 到 n 次重复的量词
\*0 或多次重复的量词等同于 {0,}
+1 次或更多次重复的量词等同于 {1,};参见 Recipe 4.2
?0 或 1 次重复的量词(即,确切出现一次,或者根本不出现)等同于 {0,1}
懒惰(非贪婪)量词
{ m,n }?懒惰量词,从 mn 次重复
{ m,}?`懒惰量词,至少 m 次重复
{,n }?懒惰量词,从 0 到 n 次重复
\*?懒惰量词:0 或多次
+?懒惰量词:1 次或更多次参见 Recipe 4.10
??懒惰量词:0 或 1 次
占有型(非常贪婪)量词
{ m,n }+mn次重复的占有量词
{ m ,}+至少m次重复的占有量词
{,n }+0 到n次重复的占有量词
\*+占有量词:0 或更多
++占有量词:1 次或更多
?+占有量词:0 或 1 次
转义和速记
\转义(引号)字符:关闭大多数元字符;将后续字母转换为元字符
\Q转义(引用)直到\E的所有字符
\E结束以\Q开始的引用
\t制表符
\r回车(换行回车)字符
\n换行符参见 Recipe 4.9
\f换页符
\w单词中的字符使用\w+表示一个单词;参见 Recipe 4.10
\W非单词字符
\d数字字符使用\d+表示整数;参见 Recipe 4.2
\D非数字字符
\s空白字符空格、制表符等,由java.lang.Character.isWhitespace()确定
\S非空白字符参见 Recipe 4.10
Unicode 区块(代表性样本)
\p{InGreek}希腊区块中的字符(简单区块)
\P{InGreek}不在希腊区块中的任何字符
\p{Lu}大写字母(简单类别)
\p{Sc}货币符号
POSIX 风格的字符类(仅适用于 US-ASCII)
\p{Alnum}字母数字字符[A-Za-z0-9]
\p{Alpha}字母字符[A-Za-z]
\p{ASCII}任何 ASCII 字符[\x00-\x7F]
\p{Blank}空格和制表符字符
\p{Space}空格字符[ \t\n\x0B\f\r]
\p{Cntrl}控制字符[\x00-\x1F\x7F]
\p{Digit}数字字符[0-9]
\p{Graph}可打印且可见字符(非空格或控制字符)
\p{Print}可打印字符\p{Graph}相同
\p{Punct}标点字符!"#$%&'()\*+,-./:;<=>?@[]\^_{|}~`
\p{Lower}小写字符[a-z]
\p{Upper}大写字符[A-Z]
\p{XDigit}十六进制数字字符[0-9a-fA-F]

正则表达式尽可能在字符串中匹配任何位置。紧随贪婪量词(在传统 Unix 正则表达式中唯一存在的类型)的模式尽可能多地消耗(匹配),而不会影响接下来的子表达式。紧随占有量词的模式尽可能多地匹配,而不考虑接下来的子表达式。紧随懒惰量词的模式尽可能少地消耗字符,以便仍然能够匹配。

此外,与其他一些语言中的正则表达式包不同,Java 正则表达式包从一开始就被设计用来处理 Unicode 字符。标准的 Java 转义序列\u+nnnn用于在模式中指定 Unicode 字符。我们使用java.lang.Character的方法来确定 Unicode 字符的属性,例如给定字符是否为空格。再次注意,如果这是在编译中的 Java 字符串中,则必须加倍反斜杠,因为编译器否则会将其解析为“反斜杠-u”后跟一些数字。

为了帮助您了解正则表达式的工作原理,我提供了一个名为 REDemo 的小程序。² REDemo 的代码太长了,无法包含在本书中;您可以在 darwinsys-api 仓库的 regex 目录中找到 REDemo.java,您可以运行它来探索正则表达式的工作原理。

在最上面的文本框中(参见 Figure 4-1),键入您要测试的正则表达式模式。请注意,当您键入每个字符时,都会检查正则表达式的语法;如果语法正确,您会看到其旁边有一个复选标记。然后,您可以选择匹配、查找或查找所有。匹配意味着整个字符串必须与正则表达式匹配,而查找意味着正则表达式必须在字符串中的某个位置找到(查找所有会计算找到的出现次数)。在下方,您键入要与正则表达式匹配的字符串。尽情实验。当您将正则表达式调整到想要的方式时,您可以将其粘贴到您的 Java 程序中。您需要转义(反斜杠)任何由 Java 编译器和 Java 正则表达式包同时特殊处理的字符,例如反斜杠本身、双引号等。一旦您获得想要的正则表达式,就有一个复制按钮(在这些截图中未显示)可将正则表达式导出到剪贴板上,根据您希望如何使用它进行反斜杠加倍或不加倍。

提示

请记住,因为正则表达式是作为将由 Java 编译器编译的字符串输入的,通常需要两个级别的转义以处理任何特殊字符,包括反斜杠和双引号。例如,正则表达式(其中包含双引号):

"You said it\."

必须像这样键入才能成为有效的编译时 Java 语言 String

String pattern = "\"You said it\\.\""

在 Java 14+ 中,您也可以使用文本块来避免转义引号:

String pattern = """
	"You said it\\.""""

我无法告诉你我有多少次犯了忘记在\d+\w+及其类似形式中添加额外反斜杠的错误!

在 Figure 4-1 中,我在 REDemo 程序的 Pattern 框中键入了 qu,这是一个语法上有效的正则表达式模式:任何普通字符都是其自身的正则表达式,因此这将查找字母 q 后跟 u。在顶部版本中,我只键入了一个 q 到字符串中,这是不匹配的。在第二个版本中,我键入了 quack 和第二个 quackq。因为我已经选择了查找所有,所以计数显示一个匹配项。当我键入第二个 u 时,计数将更新为两个,如第三个版本所示。

正则表达式不仅可以进行字符匹配。例如,两个字符的正则表达式 ^T 将匹配行的开头 (^) 立即跟随一个大写字母 T —— 也就是说,任何以大写字母 T 开头的行都会匹配。无论行是否以“Tiny trumpets,” “Titanic tubas,” 或 “Triumphant twisted trombones” 开头,只要第一个位置有大写字母 T 即可。

但是,我们目前并没有取得很大进展。我们真的要投入所有这些精力来开发正则表达式技术,只是为了能够做到使用 java.lang.StringstartsWith() 方法已经可以做到的事情吗?嗯,我能听到一些人开始有点不耐烦了。请坐好!如果你想要匹配不仅在第一个位置有字母 T,而且紧随其后立即有元音字母,并且后面有任意数量的单词中的字母,最后是一个感叹号呢?在 Java 中,你肯定可以通过检查 startsWith("T")charAt(1) == 'a' || charAt(1) == 'e' 等来实现。是的,但是当你这样做的时候,你会写很多非常专门化的代码,这些代码在其他应用程序中无法使用。使用正则表达式,你只需给出模式 ^T[aeiou]\w*!。也就是说,与之前一样的 ^T,后跟列出元音字母的字符类,然后是任意数量的单词字符 (\w*),最后是感叹号。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_0401.png

图 4-1. REDemo with simple examples

“但等等,还有更多!” 我已故的伟大老板 Yuri Rubinsky 曾经说过。如果你想要能够在 运行时 更改你正在寻找的模式呢?还记得你刚刚编写的所有 Java 代码来匹配第一列的 T,加上一个元音字母,一些单词字符,以及一个感叹号吗?好吧,现在是时候将它们扔掉了。因为今天早上我们需要匹配 Q,后跟一个不是 u 的字母,然后是一些数字,最后是一个句点。当一些人开始编写一个新的函数来实现这个目标时,我们其他人只需漫步到 RegEx 酒吧和餐厅,向酒保点一杯 ^Q[^u]\d+\. 就可以继续我们的工作了。

好的,如果您需要解释:[^u] 表示匹配任何一个不是字符 u 的字符。\d+ 表示一个或多个数字。+ 是一个量词,表示它后面的内容出现一次或多次,而 \d 表示任意一个数字。所以 \d+ 表示一个、两个或更多位数的数字。最后,\.?嗯,. 本身是一个元字符。大多数单个元字符在前面加上转义字符就会被禁用。当然不是键盘上的 Esc 键。正则表达式的转义字符是反斜杠。在元字符(如.)前面加上这个转义字符会禁用它的特殊含义,因此我们寻找的是一个字面上的句点而不是任何字符。在前面加上几个选定的字母字符(例如 nrtsw)的转义字符会将它们转换为元字符。图 4-2 展示了 ^Q[^u]\d+\.. 正则表达式的应用。在第一帧中,我已经输入了正则表达式的一部分 ^Q[^u。因为有一个未关闭的方括号,语法 OK 标志被关闭了;当我完成正则表达式时,它将被重新打开。在第二帧中,我已经完成了正则表达式的输入,并且我已经输入了数据字符串 QA577(您应该期望它匹配 $$^Q[^u]\d+$$,但不包括句点,因为我还没有输入它)。在第三帧中,我输入了句点,因此匹配标志设置为是。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_0402.png

图 4-2. REDemo 示例:“不跟随 u 的 Q”

因为在将正则表达式粘贴到 Java 代码中时需要转义反斜杠,所以当前版本的 REDemo 有一个 复制模式 按钮,它将正则表达式原样复制以供文档和 Unix 命令使用,并且有一个 复制模式(反斜杠) 按钮,它将正则表达式复制到剪贴板上并将反斜杠加倍,以便粘贴到 Java 字符串中。

到目前为止,您至少应该基本掌握了正则表达式在实践中的工作原理。本章的其余部分将提供更多示例,并解释一些更强大的主题,例如捕获组。至于正则表达式在理论上的工作原理——不同的正则表达式风格之间有很多理论细节和差异——对于感兴趣的读者,建议参考 Mastering Regular Expressions。同时,让我们开始学习如何编写使用正则表达式的 Java 程序的方法。

4.2 在 Java 中使用正则表达式:测试一个模式

问题

现在,您可以开始使用正则表达式处理来增强您的 Java 代码,测试给定模式是否可以在给定字符串中匹配。

解决方案

使用 Java 正则表达式包,java.util.regex

讨论

好消息是,Java 的正则表达式 API 实际上非常易于使用。如果您只需要查找给定的正则表达式是否与字符串匹配,可以使用 String 类的便捷方法 boolean matches(),它接受一个以字符串形式表示的正则表达式模式作为其参数:

if (inputString.matches(stringRegexPattern)) {
    // it matched... do something with it...
}

然而,这只是一个便利程序,而便利总是有代价的。如果正则表达式在程序中要使用一次或两次以上,构建并使用Pattern及其Matcher更有效率。一个完整的程序示例如下,构建Pattern并使用它进行match

public class RESimple {
    public static void main(String[] argv) {
        String pattern = "^Q[^u]\\d+\\.";
        String[] input = {
            "QA777\. is the next flight. It is on time.",
            "Quack, Quack, Quack!"
        };

        Pattern p = Pattern.compile(pattern);

        for (String in : input) {
            boolean found = p.matcher(in).lookingAt();

            System.out.println("'" + pattern + "'" +
            (found ? " matches '" : " doesn't match '") + in + "'");
        }
    }
}

java.util.regex包含两个类,PatternMatcher,提供了示例Example 4-1中显示的公共 API。

示例 4-1. 正则表达式公共 API
/**
 * The main public API of the java.util.regex package.
 */

package java.util.regex;

public final class Pattern {
    // Flags values ('or' together)
    public static final int
        UNIX_LINES, CASE_INSENSITIVE, COMMENTS, MULTILINE,
        DOTALL, UNICODE_CASE, CANON_EQ;
    // No public constructors; use these Factory methods
    public static Pattern compile(String patt);
    public static Pattern compile(String patt, int flags);
    // Method to get a Matcher for this Pattern
    public Matcher matcher(CharSequence input);
    // Information methods
    public String pattern();
    public int flags();
    // Convenience methods
    public static boolean matches(String pattern, CharSequence input);
    public String[] split(CharSequence input);
    public String[] split(CharSequence input, int max);
}

public final class Matcher {
    // Action: find or match methods
    public boolean matches();
    public boolean find();
    public boolean find(int start);
    public boolean lookingAt();
    // "Information about the previous match" methods
    public int start();
    public int start(int whichGroup);
    public int end();
    public int end(int whichGroup);
    public int groupCount();
    public String group();
    public String group(int whichGroup);
    // Reset methods
    public Matcher reset();
    public Matcher reset(CharSequence newInput);
    // Replacement methods
    public Matcher appendReplacement(StringBuffer where, String newText);
    public StringBuffer appendTail(StringBuffer where);
    public String replaceAll(String newText);
    public String replaceFirst(String newText);
    // information methods
    public Pattern pattern();
}

/* String, showing only the RE-related methods */
public final class String {
    public boolean matches(String regex);
    public String replaceFirst(String regex, String newStr);
    public String replaceAll(String regex, String newStr);
    public String[] split(String regex);
    public String[] split(String regex, int max);
}

此 API 足够大,需要一些解释。这些是生产程序中正则表达式匹配的常规步骤:

  1. 通过调用静态方法Pattern.compile()来创建Pattern

  2. 对每个希望查找的String(或其他CharSequence),通过调用pattern.matcher(CharSequence)从模式中请求一个Matcher

  3. 调用(一次或多次)结果Matcher中的一个查找方法(稍后在本节中讨论)。

java.lang.CharSequence接口提供对包含字符集合的对象的简单只读访问。标准实现包括StringStringBuffer/StringBuilder(在Chapter 3中描述)以及新的 I/O 类java.nio.CharBuffer

当然,您可以以其他方式执行正则表达式匹配,例如使用Pattern中的便利方法或甚至在java.lang.String中,如下所示:

public class StringConvenience {
    public static void main(String[] argv) {

        String pattern = ".*Q[^u]\\d+\\..*";
        String line = "Order QT300\. Now!";
        if (line.matches(pattern)) {
            System.out.println(line + " matches \"" + pattern + "\"");
        } else {
            System.out.println("NO MATCH");
        }
    }
}

但是,三步列表是匹配的标准模式。如果正则表达式仅在程序中使用一次,可能会使用String便利程序;如果正则表达式使用超过一次,则值得花时间编译它,因为编译版本运行更快。

此外,Matcher具有多个查找方法,提供比String便利程序match()更灵活的功能。这些是Matcher的方法:

match()

用于将整个字符串与模式进行比较;这与java.lang.String中的常规程序相同。因为它匹配整个String,所以我必须在模式之前和之后放置.*

lookingAt()

仅用于匹配字符串的开头。

find()

用于在字符串中匹配模式(不一定在字符串的第一个字符处),从字符串的开头开始,或者如果先前调用该方法并成功,则从未由上一个匹配项匹配的第一个字符开始。

每种方法都返回boolean,其中true表示匹配,false表示不匹配。要检查给定字符串是否与给定模式匹配,只需输入如下内容:

Matcher m = Pattern.compile(patt).matcher(line);
if (m.find( )) {
    System.out.println(line + " matches " + patt)
}

但您可能还想提取匹配的文本,这是下一个配方的主题。

以下示例涵盖了 Matcher API 的用法。最初,示例仅使用String类型的参数作为输入源。其他CharSequence类型的使用在Recipe 4.5中介绍。

4.3 寻找匹配的文本

问题

您需要找到正则表达式匹配的文本。

解决方案

有时候你不仅需要知道正则表达式是否匹配了字符串。在编辑器和许多其他工具中,您需要知道确切匹配了哪些字符。请记住,对于像*的量词,匹配的文本长度可能与匹配它的模式长度没有关系。不要低估强大的.*,如果允许的话,它可以轻松匹配成千上万个字符。正如您在前面的示例中看到的,您可以仅通过使用find()matches()来确定给定匹配是否成功。但在其他应用程序中,您可能需要获取模式匹配的字符。

在前述方法成功调用后,您可以使用Matcher上的这些信息方法获取有关匹配的信息:

start(), end()

返回字符串中匹配的起始和结束字符的字符位置。

groupCount()

返回括号分组(如果有的话)的数量;如果未使用任何组,则返回 0。

group(int i)

返回当前匹配的第i组匹配的字符,如果i大于等于零且小于等于groupCount()的返回值。组 0 是整个匹配,因此group(0)(或只是group())返回匹配输入的整个部分。

括号或捕获组的概念是正则表达式处理的核心。正则表达式可以嵌套到任意复杂的级别。group(int)方法允许您检索匹配给定括号组的字符。如果您没有使用任何显式的括号,则可以将匹配的任何内容视为零级。Example 4-2 展示了REMatch.java的部分内容。

示例 4-2. main/src/main/java/regex/REMatch.java 的一部分
public class REmatch {
    public static void main(String[] argv) {

        String patt = "Q[^u]\\d+\\.";
        Pattern r = Pattern.compile(patt);
        String line = "Order QT300\. Now!";
        Matcher m = r.matcher(line);
        if (m.find()) {
            System.out.println(patt + " matches \"" +
                m.group(0) +
                "\" in \"" + line + "\"");
        } else {
            System.out.println("NO MATCH");
        }
    }
}

运行时,这将打印:

Q[\^u]\d+\. matches "QT300." in "Order QT300\. Now!"

通过选中Match按钮,REDemo 提供了给定正则表达式中所有捕获组的显示;其中一个示例显示在 Figure 4-3 中。

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_0403.png

图 4-3. REDemo 的操作示例

还可以获取模式匹配的起始和结束索引以及文本的长度(请记住,例如此示例中的\d+等具有量词的术语可以匹配字符串中任意数量的字符)。可以与String.substring()方法结合使用,如下所示:

        String patt = "Q[^u]\\d+\\.";
        Pattern r = Pattern.compile(patt);
        String line = "Order QT300\. Now!";
        Matcher m = r.matcher(line);
        if (m.find()) {
            System.out.println(patt + " matches \"" +
                line.substring(m.start(0), m.end(0)) +
                "\" in \"" + line + "\"");
        } else {
            System.out.println("NO MATCH");
        }

假设您需要从字符串中提取多个项目。如果输入为

Smith, John
Adams, John Quincy

并且您希望退出

John Smith
John Quincy Adams

只需使用以下内容:

public class REmatchTwoFields {
    public static void main(String[] args) {
        String inputLine = "Adams, John Quincy";
        // Construct an RE with parens to "grab" both field1 and field2
        Pattern r = Pattern.compile("(.*), (.*)");
        Matcher m = r.matcher(inputLine);
        if (!m.matches())
            throw new IllegalArgumentException("Bad input");
        System.out.println(m.group(2) + ' ' + m.group(1));
    }
}

4.4 替换匹配的文本

问题

找到某个文本使用模式后,您希望用不同的文本替换该文本,而不干扰字符串的其余部分。

解决方案

正如我们在前面的示例中看到的,涉及量词的正则表达式模式可以匹配大量字符,而只需很少的元字符。我们需要一种方法来替换正则表达式匹配的文本,而不更改其前后的其他文本。我们可以手动使用 String 方法 substring() 来实现这一点。然而,由于这是一个如此常见的需求,Java 正则表达式 API 提供了一些替换方法。

讨论

Matcher 类提供了几种方法来仅替换匹配模式的文本。在所有这些方法中,你需要传入替换文本或“右侧”替换的内容(这个术语来自历史上的命令行文本编辑器的替换命令,左侧是模式,右侧是替换文本)。这些是替换方法:

replaceAll(newString)

替换了所有匹配的字符串

replaceFirst(newString)

与上例相同,但只替换第一个匹配项

appendReplacement(StringBuffer, newString)

复制直到第一个匹配项之前,再加上给定的 newString

appendTail(StringBuffer)

在最后一个匹配项后添加文本(通常用于 appendReplacement 后)

尽管它们的名称如此,replace* 方法的行为符合 Strings 的不可变性(参见 “Timeless, Immutable, and Unchangeable”):它们创建一个执行替换的新 String 对象;它们不会(事实上,也不能)修改 Matcher 对象中引用的字符串。

Example 4-3 展示了这三种方法的使用。

示例 4-3. main/src/main/java/regex/ReplaceDemo.java
/**
 * Quick demo of RE substitution: correct U.S. 'favor'
 * to Canadian/British 'favour', but not in "favorite"
 * @author Ian F. Darwin, http://www.darwinsys.com/
 */
public class ReplaceDemo {
    public static void main(String[] argv) {

        // Make an RE pattern to match as a word only (\b=word boundary)
        String patt = "\\bfavor\\b";

        // A test input
        String input = "Do me a favor? Fetch my favorite.";
        System.out.println("Input: " + input);

        // Run it from a RE instance and see that it works
        Pattern r = Pattern.compile(patt);
        Matcher m = r.matcher(input);
        System.out.println("ReplaceAll: " + m.replaceAll("favour"));

        // Show the appendReplacement method
        m.reset();
        StringBuffer sb = new StringBuffer();
        System.out.print("Append methods: ");
        while (m.find()) {
            // Copy to before first match,
            // plus the word "favor"
            m.appendReplacement(sb, "favour");
        }
        m.appendTail(sb);        // copy remainder
        System.out.println(sb.toString());
    }
}

当你运行它时,它确实按我们的预期执行:

Input: Do me a favor? Fetch my favorite.
ReplaceAll: Do me a favour? Fetch my favorite.
Append methods: Do me a favour? Fetch my favorite.

replaceAll() 方法处理了在整个字符串中进行相同更改的情况。如果你想要将每个匹配的出现更改为不同的值,可以在循环中使用 replaceFirst(),如在 Example 4-4 中所示。在这里,我们遍历整个字符串,将每个 catdog 的出现转换为 felinecanine。这是一个简化的实例,它查找了 bit.ly URL,并将其替换为实际的 URL;其中 computeReplacement 方法使用了来自 Recipe 12.1 的网络客户端代码。

示例 4-4. main/src/main/java/regex/ReplaceMulti.java
/**
 * To perform multiple distinct substitutions in the same String,
 * you need a loop, and must call reset() on the matcher.
 */
public class ReplaceMulti {
    public static void main(String[] args) {

        Pattern patt = Pattern.compile("cat|dog");
        String line = "The cat and the dog never got along well.";
        System.out.println("Input: " + line);
        Matcher matcher = patt.matcher(line);
        while (matcher.find()) {
            String found = matcher.group(0);
            String replacement = computeReplacement(found);
            line = matcher.replaceFirst(replacement);
            matcher.reset(line);
        }
        System.out.println("Final: " + line);
    }

    static String computeReplacement(String in) {
        switch(in) {
        case "cat": return "feline";
        case "dog": return "canine";
        default: return "animal";
        }
    }
}

如果你需要引用与正则表达式匹配的部分,可以在模式中用额外的括号标记它们,并在替换字符串中使用 $1$2 等来引用匹配的部分。Example 4-5 就是使用这种方法来交换两个字段,即将形如 Firstname Lastname 的姓名转换为 Lastname, FirstName

示例 4-5. main/src/main/java/regex/ReplaceDemo2.java
public class ReplaceDemo2 {
    public static void main(String[] argv) {

        // Make an RE pattern
        String patt = "(\\w+)\\s+(\\w+)";

        // A test input
        String input = "Ian Darwin";
        System.out.println("Input: " + input);

        // Run it from a RE instance and see that it works
        Pattern r = Pattern.compile(patt);
        Matcher m = r.matcher(input);
        m.find();
        System.out.println("Replaced: " + m.replaceFirst("$2, $1"));

        // The short inline version:
        // System.out.println(input.replaceFirst("(\\w+)\\s+(\\w+)", "$2, $1"));
    }
}

4.5 打印模式的所有出现

问题

你需要在一个或多个文件或其他来源中查找所有匹配给定正则表达式的字符串。

解决方案

此示例逐行读取文件。每当找到匹配项时,我从line中提取并打印它。

这段代码从 Recipe 4.3 中获取了group()方法,从CharacterIterator接口中获取了substring方法,并从正则表达式中获取了match()方法,然后将它们组合在一起。我编写它来从给定文件中提取所有名称;在运行程序时,它会打印出importjavauntilregex等单词,每个单词独占一行:

C:\> java ReaderIter.java ReaderIter.java
import
java
util
regex
import
java
io
Print
all
the
strings
that
match
given
pattern
from
file
public
...
C:\\>

我在这里中断了以节省纸张。这可以有两种写法:一种是逐行模式,如 Example 4-6 中所示,另一种是使用新 I/O 的更紧凑形式,如 Example 4-7 中所示(两个示例中使用的新 I/O 包在 Chapter 10 中描述)。

示例 4-6. main/src/main/java/regex/ReaderIter.java
public class ReaderIter {
    public static void main(String[] args) throws IOException {
        // The RE pattern
        Pattern patt = Pattern.compile("[A-Za-z][a-z]+");
        // See the I/O chapter
        // For each line of input, try matching in it.
        Files.lines(Path.of(args[0])).forEach(line -> {
            // For each match in the line, extract and print it.
            Matcher m = patt.matcher(line);
            while (m.find()) {
                // Simplest method:
                // System.out.println(m.group(0));

                // Get the starting position of the text
                int start = m.start(0);
                // Get ending position
                int end = m.end(0);
                // Print whatever matched.
                // Use CharacterIterator.substring(offset, end);
                System.out.println(line.substring(start, end));
            }
        });
    }
}
示例 4-7. main/src/main/java/regex/GrepNIO.java
public class GrepNIO {
    public static void main(String[] args) throws IOException {

        if (args.length < 2) {
            System.err.println("Usage: GrepNIO patt file [...]");
            System.exit(1);
        }

        Pattern p=Pattern.compile(args[0]);
        for (int i=1; i<args.length; i++)
            process(p, args[i]);
    }

    static void process(Pattern pattern, String fileName) throws IOException {

        // Get a FileChannel from the given file
        FileInputStream fis = new FileInputStream(fileName);
        FileChannel fc = fis.getChannel();

        // Map the file's content
        ByteBuffer buf = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());

        // Decode ByteBuffer into CharBuffer
        CharBuffer cbuf =
            Charset.forName("ISO-8859-1").newDecoder().decode(buf);

        Matcher m = pattern.matcher(cbuf);
        while (m.find()) {
            System.out.println(m.group(0));
        }
        fis.close();
    }
}

非阻塞 I/O(NIO)版本显示在 Example 4-7 中,它依赖于 NIO Buffer可以用作CharSequence的事实。这个程序更通用,因为它的模式参数来自命令行参数。如果以前一个程序的模式参数在命令行上调用,它将打印与前一个示例相同的输出:

java regex.GrepNIO "[A-Za-z][a-z]+"  ReaderIter.java

你可能会考虑使用\w+作为模式;唯一的区别是,我的模式寻找格式良好的大写单词,而\w+会包括 Java 中心的奇怪用法,如theVariableName,其中大写字母位于非标准位置。

同样注意,NIO 版本可能更有效,因为它不会像ReaderIter那样在每行输入时将Matcher重置为新的输入源。

4.6 打印包含模式的行

问题

你需要查找一个或多个文件中与给定正则表达式匹配的行。

解决方案

编写一个简单的类似于grep的程序。

讨论

正如我之前提到的,一旦你有了一个正则表达式包,你就可以编写类似于grep的程序。我之前给出了 Unix 的grep程序的示例。grep被调用时带有一些可选参数,后面跟着一个必需的正则表达式模式,然后是任意数量的文件名。它打印包含该模式的任何行,与 Recipe 4.5 不同,后者只打印匹配的文本本身。这里是一个例子:

grep "[dD]arwin" *.txt 

此代码搜索包含darwinDarwin的行,它位于文件名以.txt 结尾的每个文件的每一行中。³ 第一个版本的执行此操作的程序的源是 Example 4-8,名为Grep0。它从标准输入读取行,并且不接受任何可选参数,但它处理Pattern类实现的完整一套正则表达式(因此与同名的 Unix 程序不完全相同)。我们尚未涵盖用于输入和输出的java.io包(请参见 Chapter 10),但我们在此处的使用足够简单,您可能可以直观理解它。在线源包括Grep1,它执行相同操作但结构更好(因此更长)。本章后面的 Recipe 4.11 介绍了一个名为JGrep的程序,该程序解析一组命令行选项。

示例 4-8. main/src/main/java/regex/Grep0.java
public class Grep0 {
    public static void main(String[] args) throws IOException {
        BufferedReader is =
            new BufferedReader(new InputStreamReader(System.in));
        if (args.length != 1) {
            System.err.println("Usage: MatchLines pattern");
            System.exit(1);
        }
        Pattern patt = Pattern.compile(args[0]);
        Matcher matcher = patt.matcher("");
        String line = null;
        while ((line = is.readLine()) != null) {
            matcher.reset(line);
            if (matcher.find()) {
                System.out.println("MATCH: " + line);
            }
        }
    }
}

4.7 在正则表达式中控制大小写

问题

您希望无视大小写地查找文本。

解决方案

编译Pattern时,传入Pattern.CASE_INSENSITIVE作为flags参数,指示匹配应该是不区分大小写的(即应该忽略大小写的差异)。如果您的代码可能在不同的地区运行(参见 Recipe 3.12),那么应添加Pattern.UNICODE_CASE。如果没有这些标志,默认行为是普通的大小写敏感匹配行为。像这样将这些标志(和其他标志)传递给Pattern.compile()方法:

// regex/CaseMatch.java
Pattern  reCaseInsens = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE |
    Pattern.UNICODE_CASE);
reCaseInsens.matches(input);        // will match case-insensitively

在创建Pattern时必须传递此标志;因为Pattern对象是不可变的,一旦构建就无法更改。

此示例的完整源代码在线查看,文件名为CaseMatch.java

4.8 匹配重音或复合字符

问题

您希望字符匹配无论以何种形式输入。

解决方案

使用Pattern.CANON_EQ作为规范相等性的flags参数来编译Pattern

讨论

复合字符可以以多种形式输入。例如,考虑带有重音符号的字母e。这个字符可能以多种形式出现在 Unicode 文本中,例如单个字符é(Unicode 字符\u00e9)或两字符序列(e 后跟 Unicode 组合的重音符号\u0301)。为了允许您匹配这些字符,无论使用哪种可能的完全分解形式,正则表达式包中有一个规范匹配选项,它将任何形式视为等效。通过在Pattern.compile()的第二个参数中传递CANON_EQ来启用此选项。此程序展示了使用CANON_EQ来匹配多种形式的示例:

public class CanonEqDemo {
    public static void main(String[] args) {
        String pattStr = "\u00e9gal"; // egal
        String[] input = {
                "\u00e9gal", // egal - this one had better match :-)
                "e\u0301gal", // e + "Combining acute accent"
                "e\u02cagal", // e + "modifier letter acute accent"
                "e'gal", // e + single quote
                "e\u00b4gal", // e + Latin-1 "acute"
        };
        Pattern pattern = Pattern.compile(pattStr, Pattern.CANON_EQ);
        for (int i = 0; i < input.length; i++) {
            if (pattern.matcher(input[i]).matches()) {
                System.out.println(
                    pattStr + " matches input " + input[i]);
            } else {
                System.out.println(
                    pattStr + " does not match input " + input[i]);
            }
        }
    }
}

此程序正确匹配组合重音符号并拒绝其他字符,其中一些不幸地看起来像打印机上的重音符号,但不被视为组合重音符号:

égal matches input égal
égal matches input e?gal
égal does not match input e?gal
égal does not match input e'gal
égal does not match input e´gal

更多详情,请查看字符图表

4.9 匹配文本中的换行符

问题

您需要在文本中匹配换行符。

解决方案

在您的正则表达式模式中使用\n\r。还可以参考标志常量Pattern.MULTILINE,它使换行符作为行首和行尾(\^$)匹配。

讨论

虽然 Unix 中的面向行的工具(如sedgrep)一次只匹配一行的正则表达式,但并非所有工具都是如此。贝尔实验室的sam文本编辑器是我知道的第一个允许多行正则表达式的交互式工具;Perl 脚本语言随后也跟进。在 Java API 中,默认情况下,换行符在正则表达式中没有特殊意义。BufferedReader方法readLine()通常会剥离掉它找到的任何换行符。如果您使用除readLine()之外的某些方法读取大量字符,可能会在文本字符串中有一些\n\r\r\n序列。通常情况下,这些都等同于\n。如果您只想匹配\n,请使用Pattern.compile()方法的UNIX_LINES标志。

在 Unix 中,^$通常用于分别匹配行的开头和结尾。在此 API 中,正则表达式元字符\^$会忽略换行符,并且只在整个字符串的开头和结尾匹配。然而,如果您向Pattern.compile()方法传递MULTILINE标志,这些表达式将在换行符的后面或前面匹配;$也会匹配字符串的最后。因为换行符只是普通字符,您可以用.或类似的表达式来匹配它;如果您想确切地知道它在哪里,模式中的\n\r也可以匹配它。换句话说,对于此 API,换行符只是没有特殊意义的另一个字符。请参见侧边栏“Pattern.compile() Flags”。换行符匹配的示例显示在示例 4-9 中。

示例 4-9. main/src/main/java/regex/NLMatch.java
public class NLMatch {
    public static void main(String[] argv) {

        String input = "I dream of engines\nmore engines, all day long";
        System.out.println("INPUT: " + input);
        System.out.println();

        String[] patt = {
            "engines.more engines",
            "ines\nmore",
            "engines$"
        };

        for (int i = 0; i < patt.length; i++) {
            System.out.println("PATTERN " + patt[i]);

            boolean found;
            Pattern p1l = Pattern.compile(patt[i]);
            found = p1l.matcher(input).find();
            System.out.println("DEFAULT match " + found);

            Pattern pml = Pattern.compile(patt[i],
                Pattern.DOTALL|Pattern.MULTILINE);
            found = pml.matcher(input).find();
            System.out.println("MultiLine match " + found);
            System.out.println();
        }
    }
}

如果您运行此代码,第一个模式(带有通配符字符.)总是匹配,而第二个模式(带有$)仅在设置了MATCH_MULTILINE时匹配:

> java regex.NLMatch
INPUT: I dream of engines
more engines, all day long

PATTERN engines
more engines
DEFAULT match true
MULTILINE match: true

PATTERN engines$
DEFAULT match false
MULTILINE match: true

4.10 程序:解析 Apache 日志文件

Apache Web 服务器是世界上领先的 Web 服务器,几乎在 Web 的整个历史上都是如此。它是世界上最著名的开源项目之一,是 Apache 基金会孵化的第一个项目。Apache 这个名字通常被认为是对服务器起源的一个双关语;其开发人员从自由的 NCSA 服务器开始,并不断地对其进行改进或修补,直到它符合他们的要求。当它与原始版本有了足够的不同之后,就需要一个新的名字。因为它现在是一个“补丁”(patchy)的服务器,所以选择了 Apache 这个名字。官方对这个故事持否定态度,但这个故事非常有趣。实际上显示补丁性的一个地方是日志文件格式。请参考 Example 4-10。

Example 4-10. Apache 日志文件摘录
123.45.67.89 - - [27/Oct/2000:09:27:09 -0400] "GET /java/javaResources.html
HTTP/1.0" 200 10450 "-" "Mozilla/4.6 [en] (X11; U; OpenBSD 2.8 i386; Nav)"

显然,该文件格式设计用于人类检查,但不易于解析。问题在于使用了不同的分隔符:日期用方括号,请求行用引号,整个过程中还散布有空格。考虑尝试使用StringTokenizer;你可能能让它工作,但会花费大量时间摆弄它。实际上,不,你不会让它工作。然而,这个有些扭曲的正则表达式⁵使得解析变得很容易(这是一个超大规模的单个正则表达式;我们不得不将其分成两行以适应书籍的边距):

\^([\d.]+) (\S+) (\S+) \[([\w:/]+\s[+\-]\d{4})\] "(.+?)" (\d{3}) (\d+)
  "([\^"]+)" "([\^"]+)"

如果你回顾一下 Table 4-1,并仔细查看这里使用的完整语法,你可能会觉得很有趣。特别注意在\"(.+?)\"中使用了非贪婪量词+?来匹配引号括起的字符串;你不能仅使用.+,因为那样会匹配太多(直到行尾的引号)。代码用于提取诸如 IP 地址、请求、引用 URL 和浏览器版本等各种字段,在 Example 4-11 中显示。

Example 4-11. main/src/main/java/regex/LogRegExp.java
public class LogRegExp {

    final static String logEntryPattern =
            "^([\\d.]+) (\\S+) (\\S+) \\[([\\w:/]+\\s[+-]\\d{4})\\] " +
            "\"(.+?)\" (\\d{3}) (\\d+) \"([^\"]+)\" \"([^\"]+)\"";

    public static void main(String argv[]) {

        System.out.println("RE Pattern:");
        System.out.println(logEntryPattern);

        System.out.println("Input line is:");
        String logEntryLine = LogParseInfo.LOG_ENTRY_LINE;
        System.out.println(logEntryLine);

        Pattern p = Pattern.compile(logEntryPattern);
        Matcher matcher = p.matcher(logEntryLine);
        if (!matcher.matches() ||
            LogParseInfo.MIN_FIELDS > matcher.groupCount()) {
            System.err.println("Bad log entry (or problem with regex):");
            System.err.println(logEntryLine);
            return;
        }
        System.out.println("IP Address: " + matcher.group(1));
        System.out.println("UserName: " + matcher.group(3));
        System.out.println("Date/Time: " + matcher.group(4));
        System.out.println("Request: " + matcher.group(5));
        System.out.println("Response: " + matcher.group(6));
        System.out.println("Bytes Sent: " + matcher.group(7));
        if (!matcher.group(8).equals("-"))
            System.out.println("Referer: " + matcher.group(8));
        System.out.println("User-Agent: " + matcher.group(9));
    }
}

implements 子句用于仅定义输入字符串的接口;它在演示中被用来比较正则表达式模式与使用StringTokenizer。两个版本的源代码都在本章节的在线资源中。对 Example 4-10 中的示例输入运行程序将得到以下输出:

Using regex Pattern:
\^([\d.]+) (\S+) (\S+) \[([\w:/]+\s[+\-]\d{4})\] "(.+?)" (\d{3}) (\d+) "([\^"]+)"
"([\^"]+)"
Input line is:
123.45.67.89 - - [27/Oct/2000:09:27:09 -0400] "GET /java/javaResources.html
HTTP/1.0" 200 10450 "-" "Mozilla/4.6 [en] (X11; U; OpenBSD 2.8 i386; Nav)"
IP Address: 123.45.67.89
Date&Time: 27/Oct/2000:09:27:09 -0400
Request: GET /java/javaResources.html HTTP/1.0
Response: 200
Bytes Sent: 10450
Browser: Mozilla/4.6 [en] (X11; U; OpenBSD 2.8 i386; Nav)

程序成功地通过一次调用matcher.matches()成功解析了整个日志文件格式条目。

4.11 程序:完整的 Grep

现在我们已经看到正则表达式包如何工作,是时候编写 JGrep 了,这是一个完整的行匹配程序,并包含选项解析。Table 4-2 列出了 Unix 实现中 grep 可能包含的一些典型命令行选项。对于不熟悉 grep 的人来说,它是一个在文本文件中搜索正则表达式的命令行工具。标准 grep 家族中有三到四个程序,还有一个更新的替代 ripgreprg。这个程序是我对这类程序家族的补充。

Table 4-2. Grep 命令行选项

Option意义
-c仅计数;不打印行,只计数
-C上下文;打印与匹配行上下几行(在此版本中未实现;留给读者作为练习)
-f pattern从名为-f的文件中获取模式,而不是从命令行获取
-h抑制在行前打印文件名
-i忽略大小写
-l仅列出文件名:不打印行,只打印它们所在的文件名
-n在匹配行之前打印行号
-s抑制打印某些错误消息
-v反向:仅打印不匹配模式的行

Unix 世界提供了几个用于解析命令行参数的getopt库例程,因此我在 Java 中重新实现了这一过程。通常情况下,因为main()在静态上下文中运行而我们的应用程序主行没有运行,所以我们可能会传递大量信息到构造函数中。为了节省空间,此版本仅使用全局变量来跟踪从命令行获取的设置。与 Unix 的grep工具不同,这个工具尚不能处理组合选项,因此-l -r -i是可以的,但-lri将失败,这是由于使用的GetOpt解析器的限制。

该程序基本上只是读取行,匹配其中的模式,如果找到匹配(或者使用-v找不到),则打印该行(以及可选的其他一些东西)。话虽如此,代码显示在 Example 4-12 中。

Example 4-12. darwinsys-api/src/main/java/regex/JGrep.java
/** A command-line grep-like program. Accepts some command-line options,
 * and takes a pattern and a list of text files.
 * N.B. The current implementation of GetOpt does not allow combining short
 * arguments, so put spaces e.g., "JGrep -l -r -i pattern file..." is OK, but
 * "JGrep -lri pattern file..." will fail. Getopt will hopefully be fixed soon.
 */
public class JGrep {
    private static final String USAGE =
        "Usage: JGrep pattern [-chilrsnv][-f pattfile][filename...]";
    /** The pattern we're looking for */
    protected Pattern pattern;
    /** The matcher for this pattern */
    protected Matcher matcher;
    private boolean debug;
    /** Are we to only count lines, instead of printing? */
    protected static boolean countOnly = false;
    /** Are we to ignore case? */
    protected static boolean ignoreCase = false;
    /** Are we to suppress printing of filenames? */
    protected static boolean dontPrintFileName = false;
    /** Are we to only list names of files that match? */
    protected static boolean listOnly = false;
    /** Are we to print line numbers? */
    protected static boolean numbered = false;
    /** Are we to be silent about errors? */
    protected static boolean silent = false;
    /** Are we to print only lines that DONT match? */
    protected static boolean inVert = false;
    /** Are we to process arguments recursively if directories? */
    protected static boolean recursive = false;

    /** Construct a Grep object for the pattern, and run it
 * on all input files listed in args.
 * Be aware that a few of the command-line options are not
 * acted upon in this version - left as an exercise for the reader!
 * @param args args
 */
    public static void main(String[] args) {

        if (args.length < 1) {
            System.err.println(USAGE);
            System.exit(1);
        }
        String patt = null;

        GetOpt go = new GetOpt("cf:hilnrRsv");

        char c;
        while ((c = go.getopt(args)) != 0) {
            switch(c) {
                case 'c':
                    countOnly = true;
                    break;
                case 'f':    /* External file contains the pattern */
                    try (BufferedReader b =
                        new BufferedReader(new FileReader(go.optarg()))) {
                        patt = b.readLine();
                    } catch (IOException e) {
                        System.err.println(
                            "Can't read pattern file " + go.optarg());
                        System.exit(1);
                    }
                    break;
                case 'h':
                    dontPrintFileName = true;
                    break;
                case 'i':
                    ignoreCase = true;
                    break;
                case 'l':
                    listOnly = true;
                    break;
                case 'n':
                    numbered = true;
                    break;
                case 'r':
                case 'R':
                    recursive = true;
                    break;
                case 's':
                    silent = true;
                    break;
                case 'v':
                    inVert = true;
                    break;
                case '?':
                    System.err.println("Getopts was not happy!");
                    System.err.println(USAGE);
                    break;
            }
        }

        int ix = go.getOptInd();

        if (patt == null)
            patt = args[ix++];

        JGrep prog = null;
        try {
            prog = new JGrep(patt);
        } catch (PatternSyntaxException ex) {
            System.err.println("RE Syntax error in " + patt);
            return;
        }

        if (args.length == ix) {
            dontPrintFileName = true; // Don't print filenames if stdin
            if (recursive) {
                System.err.println("Warning: recursive search of stdin!");
            }
            prog.process(new InputStreamReader(System.in), null);
        } else {
            if (!dontPrintFileName)
                dontPrintFileName = ix == args.length - 1; // Nor if only one file
            if (recursive)
                dontPrintFileName = false;                // unless a directory!

            for (int i=ix; i<args.length; i++) { // note starting index
                try {
                    prog.process(new File(args[i]));
                } catch(Exception e) {
                    System.err.println(e);
                }
            }
        }
    }

    /**
 * Construct a JGrep object.
 * @param patt The regex to look for
 * @throws PatternSyntaxException if pattern is not a valid regex
 */
    public JGrep(String patt) throws PatternSyntaxException {
        if (debug) {
            System.err.printf("JGrep.JGrep(%s)%n", patt);
        }
        // compile the regular expression
        int caseMode = ignoreCase ?
            Pattern.UNICODE_CASE | Pattern.CASE_INSENSITIVE :
            0;
        pattern = Pattern.compile(patt, caseMode);
        matcher = pattern.matcher("");
    }

    /** Process one command line argument (file or directory)
 * @param file The input File
 * @throws FileNotFoundException If the file doesn't exist
 */
    public void process(File file) throws FileNotFoundException {
        if (!file.exists() || !file.canRead()) {
            throw new FileNotFoundException(
                "Can't read file " + file.getAbsolutePath());
        }
        if (file.isFile()) {
            process(new BufferedReader(new FileReader(file)),
                file.getAbsolutePath());
            return;
        }
        if (file.isDirectory()) {
            if (!recursive) {
                System.err.println(
                    "ERROR: -r not specified but directory given " +
                    file.getAbsolutePath());
                return;
            }
            for (File nf : file.listFiles()) {
                process(nf);    // "Recursion, n.: See Recursion."
            }
            return;
        }
        System.err.println(
            "WEIRDNESS: neither file nor directory: " + file.getAbsolutePath());
    }

    /** Do the work of scanning one file
 * @param    ifile    Reader    Reader object already open
 * @param    fileName String    Name of the input file
 */
    public void process(Reader ifile, String fileName) {

        String inputLine;
        int matches = 0;

        try (BufferedReader reader = new BufferedReader(ifile)) {

            while ((inputLine = reader.readLine()) != null) {
                matcher.reset(inputLine);
                if (matcher.find()) {
                    if (listOnly) {
                        // -l, print filename on first match, and we're done
                        System.out.println(fileName);
                        return;
                    }
                    if (countOnly) {
                        matches++;
                    } else {
                        if (!dontPrintFileName) {
                            System.out.print(fileName + ": ");
                        }
                        System.out.println(inputLine);
                    }
                } else if (inVert) {
                    System.out.println(inputLine);
                }
            }
            if (countOnly)
                System.out.println(matches + " matches in " + fileName);
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

¹ 非 Unix 粉丝不必担心,在 Windows 系统上可以使用诸如grep之类的工具,使用几种不同的软件包。其中一个是开源软件包,交替称为 CygWin(源自 Cygnus Software)或GnuWin32。另一个是微软的findstr命令。或者,如果你的系统上没有grep,可以使用我在 Recipe 4.6 中展示的Grep程序。顺便说一句,grep的名字来自于古老的 Unix 行编辑器命令g/RE/p,即在编辑缓冲区中全局查找正则表达式并打印匹配行的命令——这正是grep程序对文件中的行所做的事情。

² REDemo 受到(但未使用任何代码)现已退役的 Apache Jakarta 正则表达式包中提供的类似程序的启发。

³ 在 Unix 上,Shell 或命令行解释器在运行程序之前会将**.txt*扩展到所有匹配的文件名,但在没有足够活跃或聪明的 Shell 系统上,正常的 Java 解释器会为你完成这些工作。

⁴ 或者一些相关的 Unicode 字符,包括下一行(\u0085)、行分隔符(\u2028)和段落分隔符(\u2029)字符。

⁵ 你可能会认为这在正则表达式比赛中是某种复杂性世界纪录,但我相信它已经被超越了许多次。

第五章:数字

5.0 简介

数字是几乎所有计算的基础。它们用于数组索引、温度、薪水、评分以及各种各样的事情。然而,它们并不像它们看起来那么简单。对于浮点数,精度有多精确?对于随机数,随机有多随机?对于应该包含数字的字符串,什么才算是数字?

Java 有几种内置的或 原始 类型可用于表示数字,总结在 Table 5-1 中,以及它们的 包装(对象)类型,以及一些不表示原始类型的数值类型。请注意,与诸如 C 或 Perl 等不指定数值类型的大小或精度的语言不同,Java —— 其目标是可移植性 —— 精确地指定了这些,并声明它们在所有平台上都是相同的。

表 5-1。数值类型

内置类型对象包装器内置大小(位)内容
byteByte8有符号整数
shortShort16有符号整数
intInteger32有符号整数
longLong64有符号整数
floatFloat32IEEE-754 浮点数
doubleDouble64IEEE-754 浮点数
charCharacter16无符号 Unicode 字符
n/aBigInteger无限制任意大小的不可变整数值
n/aBigDecimal无限制任意大小和精度的不可变浮点值

如你所见,Java 提供了几乎任何目的的数字类型。有四种大小的有符号整数,用于表示各种大小的整数。有两种大小的浮点数来近似实数。还有一种类型专门设计用于表示和允许对 Unicode 字符进行操作。这里讨论了原始数值类型。大数值类型在 Recipe 5.12 中描述。

当你从用户输入或文本文件中读取表示数字的字符串时,你需要将其转换为适当的类型。第二列中的对象包装类有几个函数,其中之一是提供此基本转换功能的—替换 C 程序员的 atoi/atof 函数系列和 scanf 的数值参数。

另一种方法是,你可以通过使用字符串连接来将任何数字(事实上,Java 中的任何东西)转换为字符串。如果你想对数字格式进行一点控制,Recipe 5.5 展示了如何使用一些对象包装器的转换例程。如果你想要完全控制,该配方还展示了使用 NumberFormat 及其相关类来提供完全控制格式的方法。

正如 对象包装 这个名字所暗示的,这些类也用于在 Java 对象中包装一个数字,因为标准 API 的许多部分都是以对象的形式定义的。稍后的 “解决方案” 展示了如何使用 Integer 对象将 int 的值保存到文件中,并稍后检索该值。

但我还没有提到浮点数的问题。实数,你可能记得,是带有小数部分的数字。实数有无限多个。计算机用来近似实数的浮点数并非与实数相同。浮点数的数量是有限的,float 有 2³² 个不同的位模式,double 有 2⁶⁴ 个。因此,大多数实数值与浮点数只有近似对应关系。打印实数 0.3 的结果是正确的,如下所示:

// numbers/RealValues.java
System.out.println("The real value 0.3 is " + 0.3);

该代码的输出是:

The real value 0.3 is 0.3

但是,如果将值用于计算,实际值与其浮点数近似值之间的差异可能会累积;这通常称为 舍入误差。继续前面的例子,实数 0.3 乘以 3 的结果是:

The real 0.3 times 3 is 0.89999999999999991

惊讶吗?它不仅比你预期的偏了一点,而且在任何符合 Java 实现上都会得到相同的输出。我在不同的机器上运行过它,如 AMD/Intel PC 上的 OpenBSD、带有标准 JDK 的 Windows PC 和 macOS 上。始终得到相同的答案。

随机数又如何呢?它们有多随机?你可能听说过 伪随机数生成器 或 PRNG 这个术语。所有传统的随机数生成器,无论是用 Fortran、C 还是 Java 编写的,都生成伪随机数。也就是说,它们并非真正随机!真正的随机性只能来自专门构建的硬件:例如连接到模拟至数字转换器的布朗噪声的模拟源。¹ 如今的普通 PC 可能具有一些良好的熵源,甚至是硬件基础的随机源(尚未广泛使用或测试)。然而,对于大多数目的,伪随机数生成器已经足够好,因此我们使用它们。Java 在基础库 java.lang.Math 中提供了一个随机生成器和其他几个;我们将在 食谱 5.9 中详细讨论这些。

java.lang.Math 包含一个完整的数学库,包括三角函数、转换(包括度数到弧度和反向转换)、四舍五入、截断、平方根、最小值和最大值。所有这些功能都在这个类中。查看 java.lang.Math 的 javadoc 获取更多信息。

java.math 包含对 大数 的支持 —— 即大于普通内置长整数的数字。参见 食谱 5.12。

Java 通过确保程序的可靠性而闻名。您通常会在 Java API 中注意到这一点,常见的体现是需要捕获潜在异常,并在尝试存储可能不适合的值时进行 强制转换 或转换。我将展示这些的示例。

总体而言,Java 对数值数据的处理非常符合可移植性、可靠性和编程便利性的理念。

参见

Java 语言规范,以及 java.lang.Math 的 javadoc 页面。

5.1 检查字符串是否为有效数字

问题

您需要检查给定的字符串是否包含有效的数字,如果是,则将其转换为二进制(内部)形式。

解决方案

要实现此目标,使用适当的包装类转换程序,并捕获 NumberFormatException。以下代码将字符串转换为 double

    public static void main(String[] argv) {
        String aNumber = argv[0];    // not argv[1]
        double result;
        try {
            result = Double.parseDouble(aNumber);
            System.out.println("Number is " + result);
        } catch(NumberFormatException exc) {
            System.out.println("Invalid number " + aNumber);
            return;
        }
    }

讨论

此代码允许您仅验证符合包装类设计者期望格式的数字。如果需要接受不同定义的数字,可以使用正则表达式(参见 第四章)进行判断。

有时您可能想知道给定的数字是整数还是浮点数。一种方法是检查输入中是否包含 .、def 字符;如果存在其中一个字符,则将数字转换为 double。否则,将其作为 int 转换:

    /*
 * Process one String, returning it as a Number subclass
 */
    public static Number process(String s) {
        if (s.matches("[+-]*\\d*\\.\\d+[dDeEfF]*")) {
            try {
                double dValue = Double.parseDouble(s);
                System.out.println("It's a double: " + dValue);
                return Double.valueOf(dValue);
            } catch (NumberFormatException e) {
                System.out.println("Invalid double: " + s);
                return Double.NaN;
            }
        } else // did not contain . d e or f, so try as int.
            try {
                int iValue = Integer.parseInt(s);
                System.out.println("It's an int: " + iValue);
                return Integer.valueOf(iValue);
            } catch (NumberFormatException e2) {
                System.out.println("Not a number: " + s);
                return Double.NaN;
            }
    }

参见

DecimalFormat 类提供了更复杂的解析形式,详见 Recipe 5.5。

Scanner 类也存在;参见 Recipe 10.6。

5.2 将数字转换为对象和反之亦然

问题

您需要将数字转换为对象,以及对象转换为数字。

解决方案

使用本章开头列出的对象包装类,请参见 表格 5-1。

讨论

您经常有一个原始数字,需要将其传递给需要 Object 的方法,或者反之亦然。很久以前,您必须调用包装类的转换程序,但现在通常可以使用自动转换(称为 自动装箱/自动拆箱)。参见 示例 5-1 中的示例。

示例 5-1. main/src/main/java/structure/AutoboxDemo.java
public class AutoboxDemo {

    /** Shows auto-boxing (in the call to foo(i), i is wrapped automatically)
     * and auto-unboxing (the return value is automatically unwrapped).
     */
    public static void main(String[] args) {
        int i = 42;
        int result = foo(i);            <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png>
        System.out.println(result);
    }

    public static Integer foo(Integer i) {
        System.out.println("Object = " + i);
        return Integer.valueOf(123);    <https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png>
    }
}

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_numbers_CO1-1

自动装箱:int 42 被转换为 Integer(42)。还有自动拆箱:从 foo() 返回的 Integer 被自动拆箱以赋值给 int result

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/#co_numbers_CO1-2

没有自动装箱:valueOf() 返回 Integer。如果行中写成 return Integer.intValueOf(123),那么这将是自动装箱的第二个示例,因为方法返回值是 Integer

要显式地在 intInteger 对象之间进行转换,或者反之亦然,可以使用包装类的方法:

public class IntObject {
    public static void main(String[] args) {
        // int to Integer
        Integer i1 = Integer.valueOf(42);
        System.out.println(i1.toString());        // or just i1

        // Integer to int
        int i2 = i1.intValue();
        System.out.println(i2);
    }
}

5.3 在不使用浮点数的情况下取整数的分数

问题

您希望将整数乘以分数而不将分数转换为浮点数。

解决方案

将整数乘以分子然后除以分母。

仅当效率比清晰更重要时,才应使用此技术,因为它倾向于减弱代码的可读性—因此也降低了代码的可维护性。

讨论

因为整数和浮点数的存储方式不同,有时为了效率的目的,可能希望以整数乘以分数值而不将值转换为浮点数并返回,也不需要强制转换:

public class FractMult {
    public static void main(String[] u) {

        double d1 = 0.666 * 5;  // fast but obscure and inaccurate: convert
        System.out.println(d1); // 2/3 to 0.666 in programmer's head

        double d2 = 2/3 * 5;    // wrong answer - 2/3 == 0, 0*5 = 0
        System.out.println(d2);

        double d3 = 2d/3d * 5;  // "normal"
        System.out.println(d3);

        double d4 = (2*5)/3d;   // one step done as integers, almost same answer
        System.out.println(d4);

        int i5 = 2*5/3;         // fast, approximate integer answer
        System.out.println(i5);
    }
}

运行代码如下:

$ java numbers.FractMult
3.33
0.0
3.333333333333333
3.3333333333333335
3
$

如果不能保证通过分子的乘法不会溢出,应该注意可能发生的数值溢出,并避免此优化。

5.4 使用浮点数

问题

您希望能够比较和四舍五入浮点数。

解决方案

INFINITY 常量进行比较,并使用 isNaN() 检查是否为 NaN(非数值)。

用一个 epsilon 值比较浮点数值。

Math.round() 或自定义代码四舍五入浮点数值。

讨论

比较可能有些棘手:固定点操作可以做诸如除以零的事情,导致 Java 通过抛出异常来突然通知您。这是因为整数除以零被视为逻辑错误

然而,浮点操作不会抛出异常,因为它们在(几乎)无限的值范围内定义。相反,如果您将正浮点数除以零,则会生成常量 POSITIVE_INFINITY;如果将负浮点值除以零,则会生成常量 NEGATIVE_INFINITY;如果以其他方式生成无效结果,则会生成 NaN。这三个公共常量的值在 FloatDouble 包装类中都有定义。值 NaN 具有不同寻常的属性,即它不等于自身(即 NaN != NaN)。因此,将一个(可能可疑的)数字与 NaN 进行比较几乎没有意义,因为以下表达式永远不会为真:

x == NaN

而应使用方法 Float.isNaN(float)Double.isNaN(double)

    public static void main(String[] argv) {
        double d = 123;
        double e = 0;
        if (d/e == Double.POSITIVE_INFINITY)
            System.out.println("Check for POSITIVE_INFINITY works");
        double s = Math.sqrt(-1);
        if (s == Double.NaN)
            System.out.println("Comparison with NaN incorrectly returns true");
        if (Double.isNaN(s))
            System.out.println("Double.isNaN() correctly returns true");
    }

请注意,仅此本身并不足以确保浮点数计算具有足够的精度。例如,以下程序演示了一种构造的计算——海伦公式用于三角形的面积——分别使用 floatdouble。双精度值是正确的,但由于舍入误差,浮点值为零。这是因为在 Java 中,仅涉及 float 值的操作是以 32 位计算的。相关的语言如 C 在计算过程中会自动将这些值提升为 double,从而可以消除一些精度损失。让我们来看一下:

public class Heron {
    public static void main(String[] args) {
        // Sides for triangle in float
        float af, bf, cf;
        float sf, areaf;

        // Ditto in double
        double ad, bd, cd;
        double sd, aread;

        // Area of triangle in float
        af = 12345679.0f;
        bf = 12345678.0f;
        cf = 1.01233995f;

        sf = (af+bf+cf)/2.0f;
        areaf = (float)Math.sqrt(sf * (sf - af) * (sf - bf) * (sf - cf));
        System.out.println("Single precision: " + areaf);

        // Area of triangle in double
        ad = 12345679.0;
        bd = 12345678.0;
        cd = 1.01233995;

        sd = (ad+bd+cd)/2.0d;
        aread = Math.sqrt(sd * (sd - ad) * (sd - bd) * (sd - cd));
        System.out.println("Double precision: " + aread);
    }
}

现在让我们运行它:

$ java numbers.Heron
Single precision: 0.0
Double precision: 972730.0557076167

如果有疑问,使用 double

为了确保在不同的 Java 实现上对非常大幅度的双精度计算保持一致,Java 提供了关键字 strictfp,它可以应用于类、接口或类中的方法。² 如果计算是 Strict-FP,那么如果计算会使 Double.MAX_VALUE 的值溢出(或者使值 Double.MIN_VALUE 的值下溢),它必须始终返回值 INFINITY。非 Strict-FP 计算(默认情况下)允许在更大范围内执行计算,并且可以返回在范围内的有效最终结果,即使中间产品超出范围。这非常神秘,仅影响接近双精度范围的计算。

比较浮点值

根据我们刚刚讨论的内容,你可能不会只是简单地比较两个浮点数或双精度数的相等性。你可能期望浮点包装类 FloatDouble 重写 equals() 方法,它们确实如此。equals() 方法在这两个值在位上完全相同时返回 true(即这两个数相同或都是 NaN)。否则返回 false,包括传入的参数为 null,或者一个对象是 +0.0 而另一个是 -0.0。

我之前说过 NaN != NaN,但是如果使用 equals() 进行比较,结果是 true:

jshell> Float f1 = Float.valueOf(Float.NaN)
f1 ==> NaN

jshell> Float f2 = Float.valueOf(Float.NaN)
f2 ==> NaN

jshell> f1 == f2 # Comparing object identities
$4 ==> false

jshell> f1.equals(f1) # bitwise comparison of values
$5 ==> true

如果这听起来有些奇怪,请记住,这种复杂性部分来自于在较不精确的浮点硬件中进行实数计算的性质。它也部分来自于 IEEE 标准 754 的细节,该标准指定了 Java 尝试遵循的浮点功能,以便即使在解释 Java 程序时也可以使用底层浮点处理器硬件。

要实际比较浮点数是否相等,通常希望在某个允许误差范围内进行比较;这个范围通常被称为容差或epsilon。示例 5-2 展示了一个你可以用来进行这种比较的 equals() 方法,以及对 NaN 值的比较。运行时,它打印出前两个数字在 epsilon 范围内相等:

$ java numbers.FloatCmp
True within epsilon 1.0E-7
$
示例 5-2. main/src/main/java/numbers/FloatCmp.java
public class FloatCmp {

    final static double EPSILON = 0.0000001;

    public static void main(String[] argv) {
        double da = 3 * .3333333333;
        double db = 0.99999992857;

        // Compare two numbers that are expected to be close.
        if (da == db) {
            System.out.println("Java considers " + da + "==" + db);
        // else compare with our own equals overload
        } else if (equals(da, db, 0.0000001)) {
            System.out.println("Equal within epsilon " + EPSILON);
        } else {
            System.out.println(da + " != " + db);
        }

        System.out.println("NaN prints as " + Double.NaN);

        // Show that comparing two NaNs is not a good idea:
        double nan1 = Double.NaN;
        double nan2 = Double.NaN;
        if (nan1 == nan2)
            System.out.println("Comparing two NaNs incorrectly returns true.");
        else
            System.out.println("Comparing two NaNs correctly reports false.");

        if (Double.valueOf(nan1).equals(Double.valueOf(nan2)))
            System.out.println("Double(NaN).equals(NaN) correctly returns true.");
        else
            System.out.println(
                "Double(NaN).equals(NaN) incorrectly returns false.");
    }

    /** Compare two doubles within a given epsilon */
    public static boolean equals(double a, double b, double eps) {
        if (a==b) return true;
        // If the difference is less than epsilon, treat as equal.
        return Math.abs(a - b) < eps;
    }

    /** Compare two doubles, using default epsilon */
    public static boolean equals(double a, double b) {
        return equals(a, b, EPSILON);
    }
}

注意,关于不正确返回的 System.err 消息,没有任何打印。这个带有 NaN 的例子的重点在于,在将值委托给 Double.equals() 之前,你应该始终确保这些值不是 NaN

舍入

如果你简单地将浮点值强制转换为整数值,Java 会截断该值。像 3.999999 这样的值,被转换为 intlong 就变成了 3,而不是 4。要正确地四舍五入浮点数,请使用 Math.round()。它有两个重载:如果给它一个 double,你会得到一个 long 结果;如果给它一个 float,你会得到一个 int

如果您不喜欢round使用的舍入规则怎么办?如果由于某种奇怪的原因,您想要将大于 0.54 的数字四舍五入而不是正常的 0.5,您可以编写自己版本的round()

public class Round {
    /** We round a number up if its fraction exceeds this threshold. */
    public static final double THRESHOLD = 0.54;

    /*
 * Round floating values to integers.
 * @return the closest int to the argument.
 * @param d A non-negative values to be rounded.
 */
    public static int round(double d) {
        return (int)Math.floor(d + 1.0 - THRESHOLD);
    }

    public static void main(String[] argv) {
        for (double d = 0.1; d<=1.0; d+=0.05) {
            System.out.println("My way:  " + d + "-> " + round(d));
            System.out.println("Math way:" + d + "-> " + Math.round(d));
        }
    }
}

另一方面,如果您只想显示一个比它通常更少精确度的数字,您可能希望使用一个DecimalFormat对象或一个Formatter对象,我们在 Recipe 5.5 中看看它。

5.5 格式化数字

问题

您需要格式化数字。

解决方案

使用NumberFormat子类。

最初 Java 并没有提供类似 C 语言的printf/scanf函数,因为它们往往以一种非常不灵活的方式混合了格式化和输入/输出。例如,使用printf/scanf的程序很难国际化。当然,由于广泛需求,Java 最终引入了printf(),现在和String.format()一起成为 Java 的标准;参见 Recipe 10.4。

Java 有一个完整的包java.text,提供了像您可以想象的任何一样通用和灵活的格式化例程。与printf类似,它有一个复杂的格式化语言,在 javadoc 页面中有描述。考虑长数字的呈现方式。在北美,一千零二十四点二五写作 1,024.25;在大多数欧洲地区,写作 1 024,25;而在世界其他地方,可能写作 1.024,25。更不用说货币和百分比的格式化了!试图自己跟踪这些将会迅速让普通的小软件店崩溃。

幸运的是,java.text包包括一个Locale类;此外,Java 运行时根据用户的环境自动设置默认的Locale对象(在 Macintosh 和 Windows 上是用户的偏好设置,在 Unix 上是用户的环境变量)。要在代码中提供非默认的语言环境,请参见 Recipe 3.12。为了提供针对数字、货币和百分比定制的格式化程序,NumberFormat类具有静态的工厂方法,通常返回一个已经实例化了正确模式的DecimalFormat。可以从工厂方法NumberFormat.getInstance()获取适合用户区域设置的DecimalFormat对象,并使用set方法进行操作。令人惊讶的是,方法setMinimumIntegerDigits()竟然是生成带有前导零的数字格式的简便方法。以下是一个例子:

public class NumFormat2 {
    /** A number to format */
    public static final double data[] = {
        0, 1, 22d/7, 100.2345678
    };

    /** The main (and only) method in this class. */
    public static void main(String[] av) {
        // Get a format instance
        NumberFormat form = NumberFormat.getInstance();

        // Set it to look like 999.99[99]
        form.setMinimumIntegerDigits(3);
        form.setMinimumFractionDigits(2);
        form.setMaximumFractionDigits(4);

        // Now print using it
        for (int i=0; i<data.length; i++)
            System.out.println(data[i] + "\tformats as " +
                form.format(data[i]));
    }
}

这段代码使用NumberFormat实例form打印数组内容:

$ java numbers.NumFormat2
0.0     formats as 000.00
1.0     formats as 001.00
3.142857142857143       formats as 003.1429
100.2345678     formats as 100.2346
$

您还可以使用特定模式构造或使用applyPattern()动态更改DecimalFormat。一些更常见的模式字符在 Table 5-2 中显示。

表 5-2. DecimalFormat 模式字符

字符含义
#数字(不包含前导零)
0数字(包含前导零)
.区域特定的十进制分隔符(小数点)
,区域特定的分组分隔符(英文逗号)
-区域特定的负数指示符(减号)
%将值显示为百分比
;分隔两种格式:第一种是正数,第二种是负数
转义上述字符中的一个以使其显示
其他任何字符仍然显示为它本身

NumFormatDemo程序使用一个DecimalFormat来仅打印带有两位小数的数字,并使用第二个根据默认区域设置格式化数字:

    /** A number to format */
    public static final double intlNumber = 1024.25;
    /** Another number to format */
    public static final double ourNumber = 100.2345678;
        NumberFormat defForm = NumberFormat.getInstance();
        NumberFormat ourForm = new DecimalFormat("##0.##");
        // toPattern() will reveal the combination of #0., etc
        // that this particular Locale uses to format with!
        System.out.println("defForm's pattern is " +
            ((DecimalFormat)defForm).toPattern());
        System.out.println(intlNumber + " formats as " +
            defForm.format(intlNumber));
        System.out.println(ourNumber + " formats as " +
            ourForm.format(ourNumber));
        System.out.println(ourNumber + " formats as " +
            defForm.format(ourNumber) + " using the default format");

此程序打印给定的模式,然后使用几种格式化方法格式化同一个数字:

$ java numbers.NumFormatDemo
defForm's pattern is #,##0.###
1024.25 formats as 1,024.25
100.2345678 formats as 100.23
100.2345678 formats as 100.235 using the default format
$

人类可读的数字格式化

要以 Linux/Unix 中称为“人类可读格式”打印数字(许多显示命令接受a -h参数以此格式),使用 Java 12 的CompactNumberFormat,如示例 5-3 中所示。

示例 5-3. nmain/src/main/java/numbers/CompactFormatDemo.java
public class CompactFormatDemo {

    static final Number[] nums = {
        0, 1, 1.25, 1234, 12345, 123456.78, 123456789012L
    };
    static final String[] strs = {
        "1", "1.25", "1234", "12.345K", "1234556.78", "123456789012L"
    };

    public static void main(String[] args) throws ParseException {
        NumberFormat cnf = NumberFormat.getCompactNumberInstance();
        System.out.println("Formatting:");
        for (Number n : nums) {
            cnf.setParseIntegerOnly(false);
            cnf.setMinimumFractionDigits(2);
            System.out.println(n + ": " + cnf.format(n));
        }
        System.out.println("Parsing:");
        for (String s : strs) {
            System.out.println(s + ": " + cnf.parse(s));
        }
    }

}

罗马数字格式化

要处理罗马数字,使用我的RomanNumberFormat类,如此演示:

        RomanNumberFormat nf = new RomanNumberFormat();
        int year = LocalDate.now().getYear();
        System.out.println(year + " -> " + nf.format(year));

在 2020 年运行RomanNumberSimple会产生以下输出:

2020->MMXX

RomanNumberFormat类的源代码位于src/main/java/numbers/RomanNumberFormat.java。多个公共方法是必需的,因为我希望它是Format的子类,而Format是抽象的。这就解释了一些复杂性,比如有三种不同的格式化方法。

注意,RomanNumberFormat.parseObject( )方法也是必需的,但此版本的代码不实现解析。

参见

Java I/O 由 Elliotte Harold(O’Reilly)包含了一个关于NumberFormat的整章内容,并开发了ExponentialNumberFormat的子类。

5.6 将二进制、八进制、十进制和十六进制相互转换

问题

当你想要以一系列位的形式显示整数时——例如与某些硬件设备交互时——或以其他数制(二进制是基数 2,八进制是基数 8,十进制是 10,十六进制是 16)显示整数时,你想要将二进制数或十六进制值转换为整数。

解决方案

java.lang.Integer类提供了解决方案。大多数情况下,您可以使用Integer.parseInt(String input, int radix)将任何类型的数字转换为Integer,并使用Integer.toString(int input, int radix)完成反向操作。示例 5-4 展示了一些使用Integer类的示例。

示例 5-4. main/src/main/java/numbers/IntegerBinOctHexEtc.java
        String input = "101010";
        for (int radix : new int[] { 2, 8, 10, 16, 36 }) {
            System.out.print(input + " in base " + radix + " is "
                    + Integer.valueOf(input, radix) + "; ");
            int i = 42;
            System.out.println(i + " formatted in base " + radix + " is "
                    + Integer.toString(i, radix));
        }

此程序将二进制字符串打印为各种数制中的整数,并将整数 42 在相同的数制中显示:

$ java numbers.IntegerBinOctHexEtc
101010 in base 2 is 42; 42 formatted in base 2 is 101010
101010 in base 8 is 33288; 42 formatted in base 8 is 52
101010 in base 10 is 101010; 42 formatted in base 10 is 42
101010 in base 16 is 1052688; 42 formatted in base 16 is 2a
101010 in base 36 is 60512868; 42 formatted in base 36 is 16
$ 

讨论

也有专门的toString(int)版本,不需要指定基数,例如,toBinaryString()将整数转换为二进制,toHexString()转换为十六进制,toOctalString()等等。Integer类的 Javadoc 页面是你的好帮手。

String类本身包含一系列静态方法——valueOf(int)valueOf(double)等等,它们还提供默认格式化。也就是说,它们将给定的数值格式化为字符串并返回。

5.7 操作整数序列

问题

你需要处理一系列整数。

解决方案

对于连续的集合,请使用IntStream::rangerangeClosed,或者旧的for循环。

对于不连续的数字范围,使用java.util.BitSet

讨论

为了处理连续的整数集合,Java 提供了IntStreamLongStream类中的range() / rangeClosed()方法。它们接受起始和结束数字;range()排除结束数字,而rangeClosed()包含结束数字。你还可以使用传统的for循环迭代一系列数字。for循环的循环控制有三个部分:初始化、测试和更改。如果测试部分最初为 false,则循环永远不会执行,即使一次也不会执行。你可以使用 for-each 循环来迭代数组或集合的元素(参见第七章)。

示例 5-5 中的程序演示了这些技术。

示例 5-5. main/src/main/java/numbers/NumSeries.java
public class NumSeries {
    public static void main(String[] args) {

        // For ordinal list of numbers n to m, use rangeClosed(start, endInclusive)
        IntStream.rangeClosed(1, 12).forEach(
            i -> System.out.println("Month # " + i));

        // Or, use a for loop starting at 1.
        for (int i = 1; i <= months.length; i++)
            System.out.println("Month # " + i);

        // Or a foreach loop
        for (String month : months) {
            System.out.println(month);
        }

        // When you want a set of array indices, use range(start, endExclusive)
        IntStream.range(0, months.length).forEach(
            i -> System.out.println("Month " + months[i]));

        // Or, use a for loop starting at 0.
        for (int i = 0; i < months.length; i++)
            System.out.println("Month " + months[i]);

        // For e.g., counting by 3 from 11 to 27, use a for loop
        for (int i = 11; i <= 27; i += 3) {
            System.out.println("i = " + i);
        }

        // A discontiguous set of integers, using a BitSet

        // Create a BitSet and turn on a couple of bits.
        BitSet b = new BitSet();
        b.set(0);    // January
        b.set(3);    // April
        b.set(8);    // September

        // Presumably this would be somewhere else in the code.
        for (int i = 0; i<months.length; i++) {
            if (b.get(i))
                System.out.println("Month " + months[i]);
        }

        // Same example but shorter:
        // a discontiguous set of integers, using an array
        int[] numbers = {0, 3, 8};

        // Presumably somewhere else in the code... Also a foreach loop
        for (int n : numbers) {
            System.out.println("Month: " + months[n]);
        }
    }
    /** Names of months. See Dates/Times chapter for a better way to get these */
    protected static String months[] = {
        "January", "February", "March", "April",
        "May", "June", "July", "August",
        "September", "October", "November", "December"
    };
}

5.8 使用正确的复数格式化

问题

你正在打印类似于"We used " + n + " items"的内容,但在英语中,“We used 1 items”是不符合语法的。你想要的是“We used 1 item。”

解决方案

使用ChoiceFormat或条件语句。

在字符串连接中使用 Java 的三元运算符(cond ? trueval : falseval)。在英语中,零和复数的名词都会附加“s”(“no books, one book, two books”),因此我们测试n==1

public class FormatPlurals {
    public static void main(String[] argv) {
        report(0);
        report(1);
        report(2);
    }

    /** report -- using conditional operator */
    public static void report(int n) {
        System.out.println("We used " + n + " item" + (n==1?"":"s"));
    }
}

它有效吗?

$ java numbers.FormatPlurals
We used 0 items
We used 1 item
We used 2 items
$

最终的println语句与以下内容实际上等效:

if (n==1)
    System.out.println("We used " + n + " item");
else
    System.out.println("We used " + n + " items");

这样写会更长,所以学会使用三元条件运算符是值得的。

对于这个问题,ChoiceFormat非常理想。实际上,它能做的远不止这些,但我只展示最简单的用法。我指定了值 0、1 和 2(或更多)以及对应于每个数字的要打印的字符串值。然后根据它们所属的范围来格式化数字:

public class FormatPluralsChoice extends FormatPlurals {

    // ChoiceFormat to just give pluralized word
    static double[] limits = { 0, 1, 2 };
    static String[] formats = { "reviews", "review", "reviews"};
    static ChoiceFormat pluralizedFormat = new ChoiceFormat(limits, formats);

    // ChoiceFormat to give English text version, quantified
    static ChoiceFormat quantizedFormat = new ChoiceFormat(
        "0#no reviews|1#one review|1<many reviews");

    // Test data
    static int[] data = { -1, 0, 1, 2, 3 };

    public static void main(String[] argv) {
        System.out.println("Pluralized Format");
        for (int i : data) {
            System.out.println("Found " + i + " " + pluralizedFormat.format(i));
        }

        System.out.println("Quantized Format");
        for (int i : data) {
            System.out.println("Found " + quantizedFormat.format(i));
        }
    }
}

这与基本版本生成的输出相同。它略长一些,但更通用,更适合国际化。

另请参阅

除了使用ChoiceFormat,还可以通过MessageFormat达到相同的效果。文件main/src/main/java/i18n/MessageFormatDemo.java中有一个示例。

5.9 生成随机数

问题

你需要快速生成伪随机数。

解决方案

使用java.lang.Math.random()来生成随机数。不能保证它返回的随机值非常,然而。像大多数仅软件实现一样,这些都是伪随机数生成器(PRNGs),意味着这些数字不是完全随机的,而是根据算法设计的。尽管如此,它们对于日常使用是足够的。这段代码演示了random()方法:

// numbers/Random1.java
// java.lang.Math.random( ) is static, don't need any constructor calls
System.out.println("A random from java.lang.Math is " + Math.random( ));

注意这种方法只生成双精度浮点数。如果需要整数,请构造一个java.util.Random对象并调用其nextInt()方法;如果传递整数值,这将成为上限。这里我生成了从 1 到 10 的整数:

public class RandomInt {
    public static void main(String[] a) {
        Random r = new Random();
        for (int i=0; i<1000; i++)
            // nextInt(10) goes from 0-9; add 1 for 1-10;
            System.out.println(1+r.nextInt(10));
    }
}

要查看我的RandomInt演示是否真的运行良好,我使用了 Unix 工具sortuniq,它们一起给出每个值被选择多少次的计数。对于 1,000 个整数,每个值应该被选择大约 100 次。我运行了两次以更好地了解分布情况:

$ java numbers.RandomInt | sort | uniq -c | sort -k 2 -n
  96 1
 107 2
 102 3
 122 4
  99 5
 105 6
  97 7
  96 8
  79 9
  97 10
$ java -cp build numbers.RandomInt | sort | uniq -c | sort -k 2 -n
  86 1
  88 2
 110 3
  97 4
  99 5
 109 6
  82 7
 116 8
  99 9
 114 10
$

下一步是通过统计程序运行这些数据,看看它们真的有多随机;我们将在一分钟内返回这个问题。

通常,要生成随机数,您需要构造一个java.util.Random对象(不只是任意的随机对象)并调用其next*()方法。这些方法包括nextBoolean()nextBytes()(它用随机值填充给定的字节数组)、nextDouble()nextFloat()nextInt()nextLong()。不要被FloatDouble等的大写所迷惑。它们返回基本类型booleanfloatdouble等,而不是大写的包装对象。清楚了吗?也许一个例子会有所帮助:

    // java.util.Random methods are non-static, so need to construct
    Random r = new Random();
    for (int i=0; i<10; i++)
    System.out.println("A double from java.util.Random is " + r.nextDouble());
    for (int i=0; i<10; i++)
    System.out.println("An integer from java.util.Random is " + r.nextInt());

可以提供一个固定值(起始种子)以生成可重复的值,例如用于测试。您还可以使用java.util.Random nextGaussian()方法,如下所示。nextDouble()方法试图在 0 到 1.0 之间提供一个平坦的分布,其中每个值被选择的机会相等。高斯或正态分布是一个从负无穷到正无穷的钟形曲线,大多数值围绕着零(0.0)。

// numbers/Random3.java
Random r = new Random();
for (int i = 0; i < 10; i++)
    System.out.println("A gaussian random double is " + r.nextGaussian());

为了说明不同的分布,我首先使用nextRandom()生成了 10,000 个数字,然后使用nextGaussian()。这个代码在Random4.java中(这里未显示),是前几个程序的组合,并包含将结果打印到文件的代码。然后使用 R 统计包绘制了直方图(参见第十一章和http://www.r-project.org)。用于生成图表的 R 脚本randomnesshistograms.r位于javasrc下的main/src/main/resources中。结果显示在图 5-1 中。

看起来两个 PRNG 都在做它们的工作!

https://github.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/jcb4_0501.png

图 5-1。平坦(左)和高斯(右)分布

参见

java.util.Random的 javadoc 文档,以及 Recipe 5.0 中关于伪随机性与真随机性的警告。

对于加密用途,请参阅java.security.SecureRandom类,它提供了具有密码强度的伪随机数生成器。

5.10 矩阵相乘

问题

您需要计算一对二维数组的乘积,这在数学和工程应用中很常见。

解决方案

使用以下代码作为模型。

讨论

在数值类型的数组中进行乘法运算是很直接的。在实际应用中,您可能会使用完整的包,比如Efficient Java Matrix Library (EJML)或 DeepLearning4Java 的ND4J package。然而,一个简单的实现可以展示所涉及的概念;示例 5-6 中的代码实现了矩阵乘法。

示例 5-6. Matrix.java
public class Matrix {

    /* Matrix-multiply two arrays together.
 * The arrays MUST be rectangular.
 * @author Adapted from Tom Christiansen & Nathan Torkington's
 * implementation in their Perl Cookbook.
 */
    public static int[][] multiply(int[][] m1, int[][] m2) {
        int m1rows = m1.length;
        int m1cols = m1[0].length;
        int m2rows = m2.length;
        int m2cols = m2[0].length;
        if (m1cols != m2rows)
            throw new IllegalArgumentException(
                "matrices don't match: " + m1cols + " != " + m2rows);
        int[][] result = new int[m1rows][m2cols];

        // multiply
        for (int i=0; i<m1rows; i++) {
            for (int j=0; j<m2cols; j++) {
                for (int k=0; k<m1cols; k++) {
                    result[i][j] += m1[i][k] * m2[k][j];
                }
            }
        }

        return result;
    }

    /** Matrix print.
 */
    public static void mprint(int[][] a) {
        int rows = a.length;
        int cols = a[0].length;
        System.out.println("array["+rows+"]["+cols+"] = {");
        for (int i=0; i<rows; i++) {
            System.out.print("{");
            for (int j=0; j<cols; j++)
                System.out.print(" " + a[i][j] + ",");
            System.out.println("},");
        }
        System.out.println("};");
    }
}

这里有一个使用Matrix类来计算两个int数组乘积的程序:

        int x[][] = {
            { 3, 2, 3 },
            { 5, 9, 8 },
        };
        int y[][] = {
            { 4, 7 },
            { 9, 3 },
            { 8, 1 },
        };
        int z[][] = Matrix.multiply(x, y);
        Matrix.mprint(x);
        Matrix.mprint(y);
        Matrix.mprint(z);

参见

查阅数值方法书籍以获取更多有关矩阵的操作;我们的一位评论员推荐系列书籍*《数值秘籍》*,可在http://nrbook.com获取。 (请注意,该站点有链接到他们的新网站,https://numerical.recipes,但该站点需要 Adobe Flash,大多数浏览器由于安全原因不再支持。)书中的代码有多种语言的翻译版本,包括Java。价格因套餐而异。

商业软件包可以为您执行一些计算;例如,您可以查看Rogue Wave Software提供的数值库。

5.11 使用复数

问题

您需要处理复数,这在数学、科学或工程应用中很常见。

解决方案

Java 没有提供专门支持处理复数的功能。你可以跟踪实部和虚部并自行计算,但这不是一个很好的解决方案。

当然,更好的解决方案是使用实现复数的类。我曾经写过这样的一个类,但现在我建议使用 Apache Commons Math 库。这个库的构建坐标是org.apache.commons:commons-math3:3.6.1(或更新版本)。首先,让我们看一个使用 Apache 库的例子:

public class ComplexDemoACM {

    public static void main(String[] args) {
        Complex c = new Complex(3,  5);
        Complex d = new Complex(2, -2);
        System.out.println(c);
        System.out.println(c + ".getReal() = " + c.getReal());
        System.out.println(c + " + " + d + " = " + c.add(d));
        System.out.println(c + " + " + d + " = " + c.add(d));
        System.out.println(c + " * " + d + " = " + c.multiply(d));
        System.out.println(c.divide(d));
    }
}

运行这个演示程序会产生以下输出:

(3.0, 5.0)
(3.0, 5.0).getReal() = 3.0
(3.0, 5.0) + (2.0, -2.0) = (5.0, 3.0)
(3.0, 5.0) + (2.0, -2.0) = (5.0, 3.0)
(3.0, 5.0) * (2.0, -2.0) = (16.0, 4.0)
(-0.5, 2.0)

示例 5-7 是我版本的Complex类的源代码,不需要过多解释。尽管 Apache 版本更加复杂,但我留下我的版本只是为了解释复数的基本操作。

为了保持 API 的通用性,我为每个 add、subtract 和 multiply 操作都提供了一个静态方法,用于两个复杂对象,以及一个非静态方法,将操作应用于给定对象和另一个对象。

示例 5-7. main/src/main/java/numbers/Complex.java
public class Complex {
    /** The real part */
    private double r;
    /** The imaginary part */
    private double i;

    /** Construct a Complex */
    Complex(double rr, double ii) {
        r = rr;
        i = ii;
    }

    /** Display the current Complex as a String, for use in
 * println() and elsewhere.
 */
    public String toString() {
        StringBuilder sb = new StringBuilder().append(r);
        if (i>0)
            sb.append('+');    // else append(i) appends - sign
        return sb.append(i).append('i').toString();
    }

    /** Return just the Real part */
    public double getReal() {
        return r;
    }
    /** Return just the Real part */
    public double getImaginary() {
        return i;
    }
    /** Return the magnitude of a complex number */
    public double magnitude() {
        return Math.sqrt(r*r + i*i);
    }

    /** Add another Complex to this one
 */
    public Complex add(Complex other) {
        return add(this, other);
    }

    /** Add two Complexes
 */
    public static Complex add(Complex c1, Complex c2) {
        return new Complex(c1.r+c2.r, c1.i+c2.i);
    }

    /** Subtract another Complex from this one
 */
    public Complex subtract(Complex other) {
        return subtract(this, other);
    }

    /** Subtract two Complexes
 */
    public static Complex subtract(Complex c1, Complex c2) {
        return new Complex(c1.r-c2.r, c1.i-c2.i);
    }

    /** Multiply this Complex times another one
 */
    public Complex multiply(Complex other) {
        return multiply(this, other);
    }

    /** Multiply two Complexes
 */
    public static Complex multiply(Complex c1, Complex c2) {
        return new Complex(c1.r*c2.r - c1.i*c2.i, c1.r*c2.i + c1.i*c2.r);
    }

    /** Divide c1 by c2.
 * @author Gisbert Selke.
 */
    public static Complex divide(Complex c1, Complex c2) {
        return new Complex(
            (c1.r*c2.r+c1.i*c2.i)/(c2.r*c2.r+c2.i*c2.i),
            (c1.i*c2.r-c1.r*c2.i)/(c2.r*c2.r+c2.i*c2.i));
    }

    /* Compare this Complex number with another
 */
    public boolean equals(Object o) {
        if (o.getClass() != Complex.class) {
            throw new IllegalArgumentException(
                    "Complex.equals argument must be a Complex");
        }
        Complex other = (Complex)o;
        return r == other.r && i == other.i;
    }

    /* Generate a hashCode; not sure how well distributed these are.
 */
    public int hashCode() {
        return (int)(r) |  (int)i;
    }
}

5.12 处理非常大的数字

问题

您需要处理大于 Long.MAX_VALUE 的整数或大于 Double.MAX_VALUE 的浮点数值。

解决方案

java.math 包中使用 BigIntegerBigDecimal 值,如 示例 5-8 所示。

示例 5-8. main/src/main/java/numbers/BigNums.java
        System.out.println("Here's Long.MAX_VALUE: " + Long.MAX_VALUE);
        BigInteger bInt = new BigInteger("3419229223372036854775807");
        System.out.println("Here's a bigger number: " + bInt);
        System.out.println("Here it is as a double: " + bInt.doubleValue());

注意构造函数将数字作为字符串。显然,您不能只键入数值数字,因为按定义,这些类设计用于表示超过 Java long 能容纳的数字。

讨论

BigIntegerBigDecimal 对象都是不可变的;也就是说,一旦构造完成,它们始终表示一个给定的数字。尽管如此,许多方法会返回原始对象的新对象,例如 negate() 方法,它返回给定 BigIntegerBigDecimal 的负数。还有许多方法对应于 Java 语言中基本类型 int/longfloat/double 上定义的大多数内置运算符。除法方法需要指定舍入方法;有关详细信息,请参阅数值分析书籍。示例 5-9 是一个简单的基于堆栈的计算器,使用 BigDecimal 作为其数值数据类型。

示例 5-9. main/src/main/java/numbers/BigNumCalc.java
public class BigNumCalc {

    /** an array of Objects, simulating user input */
    public static Object[] testInput = {
        new BigDecimal("3419229223372036854775807.23343"),
        new BigDecimal("2.0"),
        "*",
    };

    public static void main(String[] args) {
        BigNumCalc calc = new BigNumCalc();
        System.out.println(calc.calculate(testInput));
    }

    /**
 * Stack of numbers being used in the calculator.
 */
    Stack<BigDecimal> stack = new Stack<>();

    /**
 * Calculate a set of operands; the input is an Object array containing
 * either BigDecimal objects (which may be pushed onto the Stack) and
 * operators (which are operated on immediately).
 * @param input
 * @return
 */
    public BigDecimal calculate(Object[] input) {
        BigDecimal tmp;
        for (int i = 0; i < input.length; i++) {
            Object o = input[i];
            if (o instanceof BigDecimal) {
                stack.push((BigDecimal) o);
            } else if (o instanceof String) {
                switch (((String)o).charAt(0)) {
                // + and * are commutative, order doesn't matter
                case '+':
                    stack.push((stack.pop()).add(stack.pop()));
                    break;
                case '*':
                    stack.push((stack.pop()).multiply(stack.pop()));
                    break;
                // - and /, order *does* matter
                case '-':
                    tmp = (BigDecimal)stack.pop();
                    stack.push((stack.pop()).subtract(tmp));
                    break;
                case '/':
                    tmp = stack.pop();
                    stack.push((stack.pop()).divide(tmp,
                        BigDecimal.ROUND_HALF_UP));
                    break;
                default:
                    throw new IllegalStateException("Unknown OPERATOR popped");
                }
            } else {
                throw new IllegalArgumentException("Syntax error in input");
            }
        }
        return stack.pop();
    }
}

运行此程序将生成预期的(非常大的)值:

> javac -d . numbers/BigNumCalc.java
> java numbers.BigNumCalc
6838458446744073709551614.466860
>

当前版本的输入是硬编码的,JUnit 测试程序也是如此,但在实际应用中,您可以使用正则表达式从输入流中提取单词或操作符(如 Recipe 4.5 中所述),或者可以使用简单计算器的 StreamTokenizer 方法(请参阅 Recipe 10.5)。数字堆栈是使用 java.util.Stack 维护的(请参阅 Recipe 7.16)。

BigInteger 主要用于加密和安全应用。其方法 isProbablyPrime() 可以为公钥密码生成素数对。BigDecimal 在计算宇宙大小时也可能很有用。

5.13 程序:TempConverter

示例 5-10 中显示的程序打印了华氏温度表(仍然在美国及其领土、利比里亚和一些加勒比国家的日常天气报告中使用),以及相应的摄氏温度(在全球科学界和其他地方的日常生活中使用)。

示例 5-10. main/src/main/java/numbers/TempConverter.java
public class TempConverter {

    public static void main(String[] args) {
        TempConverter t = new TempConverter();
        t.start();
        t.data();
        t.end();
    }

    protected void start() {
    }

    protected void data() {
        for (int i=-40; i<=120; i+=10) {
            double c = fToC(i);
            print(i, c);
        }
    }

    public static double cToF(double deg) {
        return ( deg * 9 / 5) + 32;
    }

    public static double fToC(double deg) {
        return ( deg - 32 ) * ( 5d / 9 );
    }

    protected void print(double f, double c) {
        System.out.println(f + " " + c);
    }

    protected void end() {
    }
}

这有效,但这些数字打印时带有约 15 位(无用的)小数部分!此程序的第二个版本是第一个版本的子类,并使用printf(参见配方 10.4)控制转换后温度的格式(参见示例 5-11)。现在它看起来正常,假设您正在等宽字体中打印。

示例 5-11. main/src/main/java/numbers/TempConverter2.java
public class TempConverter2 extends TempConverter {

    public static void main(String[] args) {
        TempConverter t = new TempConverter2();
        t.start();
        t.data();
        t.end();
    }

    @Override
    protected void print(double f, double c) {
        System.out.printf("%6.2f %6.2f%n", f, c);
    }

    @Override
    protected void start() {
        System.out.println("Fahr    Centigrade");
    }

    @Override
    protected void end() {
        System.out.println("-------------------");
    }
}
C:\javasrc\numbers>java numbers.TempConverter2
Fahr    Centigrade
-40.00 -40.00
-30.00 -34.44
-20.00 -28.89
-10.00 -23.33
  0.00 -17.78
 10.00 -12.22
 20.00  -6.67
 30.00  -1.11
 40.00   4.44
 50.00  10.00
 60.00  15.56
 70.00  21.11
 80.00  26.67
 90.00  32.22
100.00  37.78
110.00  43.33
120.00  48.89

5.14 程序:数字回文

我的妻子贝蒂最近提醒我一个定理,我高中时肯定学过,但其名称我早已忘记:任何正整数都可以通过将其与其数字逆序构成的数相加来生成一个回文数。回文数是指在任何方向上都读取相同的序列,例如姓名“安娜”或短语“Madam, I’m Adam”(忽略空格和标点)。我们通常认为回文是由文本组成的,但这个概念也可以应用于数字:13,531 是一个回文数。例如,从数字 72 开始,加上其反向数字 27。这个加法的结果是 99,是一个(短)回文数。从 142 开始,加上 241,得到 383。有些数字需要多次尝试才能生成回文数。例如,1,951 + 1,591 得到 3,542,不是回文的。然而,第二轮,3,542 + 2,453 得到 5,995,是回文的。我儿子本杰明随意挑选了 17,892,需要 12 轮才能生成一个回文数,但最终还是成功了:

C:\javasrc\numbers>java  numbers.Palindrome 72 142 1951 17892
Trying 72
72->99
Trying 142
142->383
Trying 1951
Trying 3542
1951->5995
Trying 17892
Trying 47763
Trying 84537
Trying 158085
Trying 738936
Trying 1378773
Trying 5157504
Trying 9215019
Trying 18320148
Trying 102422529
Trying 1027646730
Trying 1404113931
17892->2797227972

C:\javasrc\numbers>

如果对您来说这听起来像是递归的一个自然候选项,那么您是正确的。递归涉及将问题分解为简单且相同的步骤,可以由调用自身的函数实现,并提供终止的方式。如我们所示的findPalindrome方法的基本方法如下:

long findPalindrome(long num) {
    if (isPalindrome(num))
        return num;
    return findPalindrome(num + reverseNumber(num));
}

也就是说,如果起始数字已经是回文数,返回它;否则,将它加到它的反向数字上,并再次尝试。此处显示的代码版本直接处理简单情况(例如单个数字始终是回文的)。我们不考虑负数,因为这些负数有一个位于末尾时会失去意义的字符,并且因此不严格是回文的。此外,某些数字的回文形式太长,无法适应 Java 的 64 位long整数。这会导致下溢,被捕获。因此,会报告“太大”的错误消息。³说了这么多,示例 5-12 展示了这段代码。

示例 5-12. main/src/main/java/numbers/Palindrome.java
public class Palindrome {

    public static boolean verbose = true;

    public static void main(String[] argv) {
        for (String num : argv) {
            try {
                long l = Long.parseLong(num);
                if (l < 0) {
                    System.err.println(num + " -> TOO SMALL");
                    continue;
                }
                System.out.println(num + "->" + findPalindrome(l));
            } catch (NumberFormatException e) {
                System.err.println(num + "-> INVALID");
            } catch (IllegalStateException e) {
                System.err.println(num + "-> " + e);
            }
        }
    }

    /** find a palindromic number given a starting point, by
 * recursing until we get a number that is palindromic.
 */
    static long findPalindrome(long num) {
        if (num < 0)
            throw new IllegalStateException("negative");
        if (isPalindrome(num))
            return num;
        if (verbose)
             System.out.println("Trying " + num);
        return findPalindrome(num + reverseNumber(num));
    }

    /** The number of digits in Long.MAX_VALUE */
    protected static final int MAX_DIGITS = 19;

    // digits array is shared by isPalindrome and reverseNumber,
    // which cannot both be running at the same time.

    /* Statically allocated array to avoid new-ing each time. */
    static long[] digits = new long[MAX_DIGITS];

    /** Check if a number is palindromic. */
    static boolean isPalindrome(long num) {
        // Consider any single digit to be as palindromic as can be
        if (num >= 0 && num <= 9)
            return true;

        int nDigits = 0;
        while (num > 0) {
            digits[nDigits++] = num % 10;
            num /= 10;
        }
        for (int i=0; i<nDigits/2; i++)
            if (digits[i] != digits[nDigits - i - 1])
                return false;
        return true;
    }

    static long reverseNumber(long num) {
        int nDigits = 0;
        while (num > 0) {
            digits[nDigits++] = num % 10;
            num /= 10;
        }
        long ret = 0;
        for (int i=0; i<nDigits; i++) {
            ret *= 10;
            ret += digits[i];
        }
        return ret;
    }
}

虽然它不严格是一个数值解决方案,但丹尼尔·伊诺霍萨指出,您可以使用StringBuilder来执行反转部分,从而得到更短、更优雅的代码,只有稍微慢一点:

    static boolean isPalindrome(long num) {
        long result = reverseNumber(num);
        return num == result;
    }

    private static long reverseNumber(long num) {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(num);
        return Long.parseLong(stringBuilder.reverse().toString());
    }

他的完整代码版本在文件PalindromeViaStringBuilder.java中。

参见

使用 Java 进行科学或大规模数值计算的人士可能希望关注即将推出的值类型,来自 Java 的“Valhalla 项目”。另请参阅 2019 年的演示标题为 “JVM 上的向量和数值计算”

¹ 低成本的随机源,请查看现已停止运行的 Lavarand。该过程利用了 1970 年代的熔岩灯视频进行“硬件基础”的随机性提供。有趣!

² 请注意,完全由编译时常量组成的表达式,如 Math.PI \* 2.1e17,也被视为 Strict-FP。

³ 某些数值不适用;例如,Ashish Batia 报告说这个版本在数值 8,989 上会抛出异常(确实如此)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值