Java字符串与输入输出详解

  1. 1. 不可变字符串String
  2. 2. 重载加法运算符与StringBuilder
  3. 3. 无意识的递归
  4. 4. 格式化输出
    1. 4.1 printf()与format()
    2. 4.2 Formatter类
    3. 4.3 格式说明符
    4. 4.4 String.format()
  5. 5. 扫描输入
    1. 5.1 Scanner分隔符
    2. 5.2 使用正则表达式扫描

本文介绍了 Java 中的字符串类以及输入输出的方式。

1. 不可变字符串String

String 类的对象是不可变的,该类中每个看起来似乎会修改 String 值的方法,实际上都创建并返回了一个全新的 String 对象,该对象包含了修改的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package string;

public class StringDemo {
static String upcase(String s) {
return s.toUpperCase();
}

public static void main(String[] args) {
String s = "Hello";
String t = upcase(s);
System.out.println(s); // Hello
System.out.println(t); // HELLO
}
}

s 被传递给 upcase() 时,实际上传递的是 s 对象引用的一个副本,此引用所指向的对象只存在于单一的物理位置中,在传递时被复制的只是引用。

upcase() 里,参数 s 只存活于这个方法的方法体里,当 upcase() 运行完成后,局部引用 s 就会消失。upcase() 返回执行的结果:一个指向新字符串的引用。

2. 重载加法运算符与StringBuilder

不变性可能会带来效率问题,一个典型的例子是操作符 +,它针对 String 对象做了重载(String++= 是 Java 中仅有的被重载的操作符,Java 不允许程序员重载其他操作符,这与 C++ 不同)。操作符重载意味着在与特定类一起使用时,相应的操作具有额外的意义。

假设我们有以下代码:

1
2
3
4
5
6
7
8
9
package string;

public class StringConcat {
public static void main(String[] args) {
String s = "Hello";
String t = s + " world";
System.out.println(t); // Hello world
}
}

我们可以使用 JDK 自带的 javap 工具反编译上述代码:

1
javap -c StringConcat

也可以在 IDEA 中编译代码后通过 View -> Show Bytecode 选项查看,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// class version 52.0 (52)
// access flags 0x21
public class string/StringConcat {

// compiled from: StringConcat.java

// access flags 0x1
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lstring/StringConcat; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

// access flags 0x9
public static main([Ljava/lang/String;)V
L0
LINENUMBER 5 L0
LDC "Hello"
ASTORE 1
L1
LINENUMBER 6 L1
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
ALOAD 1
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
LDC " world"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 2
L2
LINENUMBER 7 L2
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L3
LINENUMBER 8 L3
RETURN
L4
LOCALVARIABLE args [Ljava/lang/String; L0 L4 0
LOCALVARIABLE s Ljava/lang/String; L1 L4 1
LOCALVARIABLE t Ljava/lang/String; L2 L4 2
MAXSTACK = 2
MAXLOCALS = 3
}

DUPINVOKEVIRTUAL 这样的语句相当于 JVM 的汇编语言。这边不用看懂是什么意思,需要重点注意的是编译器对 java.lang.StringBuilder 类的引入,我们的代码中没有用到 StringBuilder,但编译器还是决定使用它,因为它的效率更高。

在这里,编译器创建了一个 StringBuilder 对象来构建字符串,并为每个字符串调用了一次 append(),总共两次。最后,它调用了 toString() 来生成结果,并将其存为 t(使用 ASTORE 2 语句实现)。

你或许会认为可以随意使用 String,反正编译器会对字符串的使用进行优化。当创建 toString() 方法时,如果操作很简单,通常可以依赖编译器,让它以合理的方式自行构建结果。但是如果涉及循环,并且对性能也有一定要求,那就需要显式使用 StringBuilder 了。来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package string;

import java.util.Random;
import java.util.stream.Collectors;

public class UsingStringBuilder {
static String getString1() {
Random rand = new Random(47);
StringBuilder res = new StringBuilder("[");
for (int i = 0; i < 5; i++) {
res.append(rand.nextInt(10));
res.append(", ");
}
res.delete(res.length() - 2, res.length()); // 删除最后一个逗号和空格
res.append("]");
return res.toString();
}

static String getString2() {
String res = new Random(47)
.ints(5, 0, 10)
.mapToObj(Integer::toString)
.collect(Collectors.joining(", "));
return "[" + res + "]";
}

public static void main(String[] args) {
System.out.println(getString1()); // [8, 5, 3, 1, 1]
System.out.println(getString2()); // [8, 5, 3, 1, 1]
}
}

getString1() 中,最终的结果是用 append() 语句对每一部分分别进行拼接而成的,如果你想走捷径,执行诸如 append(rand.nextInt(10) + ", ") 之类的操作,编译器就会介入,并开始创建更多的 StringBuilder 对象,影响性能。

getString2() 使用了 Stream,生成的代码更加赏心悦目。实际上,Collectors.joining() 内部使用 StringBuilder 实现,所以使用这种方式不会有任何性能损失。

StringBuilder 是在 Java 5 中引入的,在此之前,Java 使用 StringBuffer,它是线程安全的,因此成本也更高,使用 StringBuilder 进行字符串操作会更快。

3. 无意识的递归

和其他类一样,Java 的标准集合最终也是从 Object 继承而来的,所以它们也包含了一个 toString() 方法,这个方法在集合中被重写,这样它生成的结果字符串就能表示容器自身,以及该容器持有的所有对象。以 ArrayList.toString() 为例,它会遍历 ArrayList 的元素并为每个元素调用 toString() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package string;

import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.Stream;

class Cat {
private final int id;
private final static Random rand = new Random(47);

Cat() {
id = rand.nextInt(10);
}

@Override
public String toString() {
return "Cat " + id;
}
}

public class ArrayListDisplay {
public static void main(String[] args) {
List<Cat> cats = Stream.generate(Cat::new)
.limit(5)
.collect(Collectors.toList());

System.out.println(cats); // [Cat 8, Cat 5, Cat 3, Cat 1, Cat 1]
}
}

如果你希望 toString() 打印对象的内存地址,不能使用 this

1
2
3
4
@Override
public String toString() {
return "Cat " + this;
}

这样会得到一个很长的异常栈,因为编译器看到 String 后面加上了一个非 String 的内容,会尝试调用 toString() 方法自动转换,而这样就会递归调用自身的 toString()。因此我们需要直接调用 ObjecttoString() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package string;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

class Cat {
Cat() {}

@Override
public String toString() {
return "Cat " + super.toString();
}
}

public class ArrayListDisplay {
public static void main(String[] args) {
List<Cat> cats = Stream.generate(Cat::new)
.limit(3)
.collect(Collectors.toList());

System.out.println(cats); // [Cat string.Cat@5fd0d5ae, Cat string.Cat@2d98a335, Cat string.Cat@16b98e56]
}
}

4. 格式化输出

4.1 printf()与format()

有 C 语言基础的肯定对 printf() 很熟悉,printf() 使用特殊的占位符来表示数据的位置。Java 5 引入的 format() 方法可用于 PrintStreamPrintWriter 对象,因此也可以直接用于 System.out

format()printf() 是等价的,它们都只需要一个格式化字符串,后面跟着参数,其中每个参数都对应一个格式说明符。String 类也有一个静态的 format() 方法,它会产生一个格式化字符串,之后我们会看到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package string;

public class PrintfAndFormat {
public static void main(String[] args) {
int x = 2;
double y = 3.14;

System.out.printf("[%d, %f]%n", x, y); // %n同C语言中的\n
System.out.format("[%d, %f]%n", x, y);

/*
* [2, 3.140000]
* [2, 3.140000]
*/
}
}

4.2 Formatter类

Java 中所有的格式化功能都由 java.util 包里的 Formatter 类处理,你可以将 Formatter 视为一个转换器,将格式化字符串和数据转换为想要的结果。当创建一个 Formatter 对象时,你可以将信息传递给构造器,来表明希望将结果输出到哪里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package string;

import java.io.PrintStream;
import java.util.Formatter;

public class FormatterDemo {
public static void main(String[] args) {
PrintStream out = System.out;
Formatter f = new Formatter(out);

int x = 2;
double y = 3.14;
f.format("[%d, %f]%n", x, y); // [2, 3.140000]
}
}

4.3 格式说明符

如果想要在插入数据时控制间距和对齐方式,你需要更详细的格式说明符。一般我们会控制字段的最小长度,或者控制浮点数的小数位数,设置长度后默认是右对齐的,可以使用 - 来设置成左对齐,例如:

1
%.2f %6d %-15s

我们来看一下简单的示例,它使用了生成器模式,你可以创建一个起始对象,然后向其中添加内容,最后使用 build() 方法来生成最终结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package string;

import java.util.Formatter;

public class ReceiptBuilder {
private double total = 0;
private Formatter f = new Formatter(new StringBuilder());

public ReceiptBuilder() {
f.format("%-10s %8s %8s%n", "Fruit", "Quantity", "Price");
f.format("%-10s %8s %8s%n", "-----", "--------", "-----");
}

public void add(String name, int quantity, double price) {
f.format("%-10s %8d %8.2f%n", name, quantity, price);
total += price;
}

public String build() {
f.format("%-10s %8s %8s%n", "", "", "-----");
f.format("%-10s %8s %8.2f%n", "Total", "", total);
return f.toString();
}

public static void main(String[] args) {
ReceiptBuilder receiptBuilder = new ReceiptBuilder();
receiptBuilder.add("Apple", 3, 2.4);
receiptBuilder.add("Banana", 1, 6);
receiptBuilder.add("Orange", 2, 3.45);
System.out.println(receiptBuilder.build());

/*
* Fruit Quantity Price
* ----- -------- -----
* Apple 3 2.40
* Banana 1 6.00
* Orange 2 3.45
* -----
* Total 11.85
*/
}
}

StringBuilder 传递给 Formatter 构造器后,它就有了一个构建 String 的地方,还可以使用构造器参数将其发送到标准输出甚至文件里。

注意:如果格式化输出参数 %b 用于输出非 boolean 基本类型或 Boolean 对象,那么只要参数类型不是 null,格式化结果总是 true,即使是数值0输出也为 true,这和 C 语言是不一样的。

4.4 String.format()

Java 5 还借鉴了 C 语言中用来创建字符串的 sprintf(),提供了 String.format() 方法。它是一个静态方法,参数与 Formatter 类的 format() 方法完全相同,但返回一个 String

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package string;

public class DatabaseException extends Exception {
public DatabaseException(int transactionID, int queryID, String msg) {
super(String.format("(t%d, q%d) %s", transactionID, queryID, msg));
}

public static void main(String[] args) {
try {
throw new DatabaseException(3, 7, "Write failed");
} catch (Exception e) {
System.out.println(e); // string.DatabaseException: (t3, q7) Write failed
}
}
}

5. 扫描输入

到目前为止,从人类可读的文件或标准输入中读取数据还是比较痛苦的,一般的解决方案是读入一行文本,对其进行分词解析,然后使用 IntegerDouble 等类里的各种方法来解析数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package string;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.stream.Stream;

public class SimpleRead {
static BufferedReader in = new BufferedReader(new InputStreamReader(System.in));

public static void main(String[] args) {
try {
Integer[] inputs = Stream.of(in.readLine().split(" ")).mapToInt(Integer::parseInt).boxed().toArray(Integer[]::new); // 转换成包装类型数组
// int [] inputs = Stream.of(in.readLine().split(" ")).mapToInt(Integer::parseInt).toArray(); // 转换成基本类型数组

System.out.println(Arrays.asList(inputs));
} catch (IOException e) {
System.out.println(e);
}

/*
* input: 3 1 4 7 6
* output: [3, 1, 4, 7, 6]
*/
}
}

BufferedReader 有一个 readLine() 方法,每次可以从输入对象(此处是标准输入的 InputStreamReader)里读取一行,readLine() 方法将读入的每一行转为 String 对象,我们使用 split() 方法用空格将这个字符串分割成一个 String[],然后使用 Stream 将数组中的每个字符串转换成整型后重新构建成一个整型数组。

Java 5 中添加的 Scanner 类大大减轻了扫描输入的负担:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package string;

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class BetterRead {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
List<Integer> inputs = new ArrayList<>();

while (sc.hasNextInt())
inputs.add(sc.nextInt());

System.out.println(inputs);

/*
* input: 1 2 33 5 end 1 2
* output: [1, 2, 33, 5]
*/
}
}

Scanner 的构造器可以接受任何类型的输入对象,包括 File 对象、InputStreamString,或者 Readable 接口。

Scanner 中,输入、分词和解析这些操作都被包含在各种不同类型的 next() 方法中。一个普通的 next() 返回下一个 String,所有的基本类型(Char 除外)以及 BigDecimalBigInteger 都有对应的 next() 方法。所有的 next() 方法都是阻塞的,这意味着它们只有在输入流能提供一个完整可用的数据分词时才会返回。你也可以根据相应的 hasNext() 方法是否返回 true 来判断下一个输入分词的类型是否正确。

Scanner 会假设 IOException 表示输入结束,因此 Scanner 会把 IOException 隐藏起来。

5.1 Scanner分隔符

默认情况下,Scanner 通过空格分割输入数据,但也可以用正则表达式的形式来指定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package string;

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class ScannerDelimite {
public static void main(String[] args) {
Scanner sc = new Scanner("3, 12, 7 , 6, 9");
sc.useDelimiter("\\s*,\\s*");

List<Integer> inputs = new ArrayList<>();
while (sc.hasNextInt())
inputs.add(sc.nextInt());
System.out.println(inputs); // [3, 12, 7, 6, 9]
}
}

此示例使用逗号(由任意数量的空白符包围)作为分隔符,来处理读取的给定字符串,同样的技术也可以用来读取逗号分隔的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package string;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.stream.Collectors;

public class ScannerDelimite {
public static void main(String[] args) throws IOException {
Scanner sc = new Scanner(Files.lines(Paths.get("src/file/numbers.txt")).collect(Collectors.joining(",")));
sc.useDelimiter("\\s*,\\s*");

List<Integer> inputs = new ArrayList<>();
while (sc.hasNextInt())
inputs.add(sc.nextInt());
System.out.println(inputs); // [3, 12, 7, 6, 9, 5, 1]
}
}

其中 numbers.txt 文件内容如下:

1
2
3, 12,  7 , 6, 9
5, 1

5.2 使用正则表达式扫描

除了扫描预定义的基本类型,你还可以用自己定义的正则表达式模式来扫描,这在扫描更复杂的数据时非常有用。下面这个示例扫描防火墙日志中的威胁数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package string;

import java.util.Scanner;
import java.util.regex.MatchResult;

public class ThreatAnalyzer {
static String threatData =
"12.124.52.163@03/11/2023\n" +
"23.141.61.192@01/11/2023\n" +
"Over";

public static void main(String[] args) {
Scanner sc = new Scanner(threatData);
String pattern = "(\\d+[.]\\d+[.]\\d+[.]\\d+)@" + "(\\d{2}/\\d{2}/\\d{4})";

while (sc.hasNext(pattern)) {
sc.next(pattern);
MatchResult match = sc.match();
String ip = match.group(1);
String date = match.group(2);
System.out.printf("Threat on %s from %s%n", date, ip);
}

/*
* Threat on 03/11/2023 from 12.124.52.163
* Threat on 01/11/2023 from 23.141.61.192
*/
}
}

next() 与特定模式一起使用时,该模式会和下一个输入分词进行匹配,结果由 match() 方法提供。