本文介绍了 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); System.out.println(t); } }
|
当 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); } }
|
我们可以使用 JDK 自带的 javap
工具反编译上述代码:
也可以在 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
|
public class string/StringConcat {
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
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 }
|
像 DUP
和 INVOKEVIRTUAL
这样的语句相当于 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()); System.out.println(getString2()); } }
|
在 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); } }
|
如果你希望 toString()
打印对象的内存地址,不能使用 this
:
1 2 3 4
| @Override public String toString() { return "Cat " + this; }
|
这样会得到一个很长的异常栈,因为编译器看到 String
后面加上了一个非 String
的内容,会尝试调用 toString()
方法自动转换,而这样就会递归调用自身的 toString()
。因此我们需要直接调用 Object
的 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
| 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); } }
|
4. 格式化输出
有 C 语言基础的肯定对 printf()
很熟悉,printf()
使用特殊的占位符来表示数据的位置。Java 5 引入的 format()
方法可用于 PrintStream
或 PrintWriter
对象,因此也可以直接用于 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); System.out.format("[%d, %f]%n", x, y);
} }
|
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); } }
|
4.3 格式说明符
如果想要在插入数据时控制间距和对齐方式,你需要更详细的格式说明符。一般我们会控制字段的最小长度,或者控制浮点数的小数位数,设置长度后默认是右对齐的,可以使用 -
来设置成左对齐,例如:
我们来看一下简单的示例,它使用了生成器模式,你可以创建一个起始对象,然后向其中添加内容,最后使用 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());
} }
|
将 StringBuilder
传递给 Formatter
构造器后,它就有了一个构建 String
的地方,还可以使用构造器参数将其发送到标准输出甚至文件里。
注意:如果格式化输出参数 %b
用于输出非 boolean
基本类型或 Boolean
对象,那么只要参数类型不是 null
,格式化结果总是 true
,即使是数值0输出也为 true
,这和 C 语言是不一样的。
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); } } }
|
5. 扫描输入
到目前为止,从人类可读的文件或标准输入中读取数据还是比较痛苦的,一般的解决方案是读入一行文本,对其进行分词解析,然后使用 Integer
、Double
等类里的各种方法来解析数据:
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);
System.out.println(Arrays.asList(inputs)); } catch (IOException e) { System.out.println(e); }
} }
|
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);
} }
|
Scanner
的构造器可以接受任何类型的输入对象,包括 File
对象、InputStream
、String
,或者 Readable
接口。
在 Scanner
中,输入、分词和解析这些操作都被包含在各种不同类型的 next()
方法中。一个普通的 next()
返回下一个 String
,所有的基本类型(Char 除外)以及 BigDecimal
和 BigInteger
都有对应的 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); } }
|
此示例使用逗号(由任意数量的空白符包围)作为分隔符,来处理读取的给定字符串,同样的技术也可以用来读取逗号分隔的文件:
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); } }
|
其中 numbers.txt
文件内容如下:
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); }
} }
|
next()
与特定模式一起使用时,该模式会和下一个输入分词进行匹配,结果由 match()
方法提供。