图源:PHP中文网
字符串连接是程序中最常使用的对字符串的操作,看一个最简单的例子:
package ch11.conn;
public class Main {
public static void main(String[] args) {
String a = "hellow";
String b = "world";
String c = "!";
String result = a + b + c;
}
}
表面上看,程序中只出现了4个字符串,但实际上有5个,因为a+b+c
实际上是先执行a+b
,并生成一个临时字符串,然后再执行+c
,并生成最终的result
字符串。
这里只是一个理想状态的探讨,实际上Java已经采取了一些优化措施,稍后会解释。
如果只是连接三个字符串,这样似乎也并没有什么问题,但如果连接多个字符串,就会造成性能浪费。
如果我们将字符串连接的时间复杂度看做O(1)
,则连接n
个字符串就需要产生n-1
个“中间字符串”,也就是说整个操作的时间复杂度是O(n-1)
,而其中n-2
个中间字符串实际上是可以避免的。
这也是为什么很多编程语言会推荐用字符串数组来连接长字符串的原因:
package ch11.conn2;
public class Main {
public static void main(String[] args) {
String[] strs = new String[3];
strs[0] = "hello";
strs[1] = " world";
strs[2] = "!";
String result = String.join("", strs);
System.out.println(result);
// hello world!
}
}
这样可以避免创建不必要的“中间字符串”,整个操作的时间复杂度接近于O(1)
,自然要比使用字符串连接操作符的性能高效的多。
当然,在实际使用中我们并不需要像上面那样麻烦地使用字符串数组和String.join
,Java提供一个更方便的创建字符串的类StringBuilder
:
package ch11.conn3;
public class Main {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append("hello");
sb.append(" world");
sb.append("!");
String result = sb.toString();
System.out.println(result);
// hello world!
}
}
StrinngBuilder
的实际工作原理和用字符串数组的方式类似,在调用sb.append
时,只会记录要连接的字符串,并不会真的进行字符串连接,只有最终调用sb.toString
时才会执行最终的字符串连接操作。
当然,经过Java这么多年的发展,只有最初的JDK是采用上面所说的原始方式来处理字符串连接,效率较差。而之后对此已经经过了多次优化,从最早的使用StringBuffer
到使用StringBuilder
再到动态调用makeConcatWithConstant
。
JDK对字符串连接方式的优化过程可以见Java字符串连接,StringBuilder和invokedynamic - 知乎 (zhihu.com)。
我们可以使用JDK工具对代码反编译后查看字节码,以观察字符串优化细节:
❯ javac .\Main.java
❯ javap -c .\Main.class
这里对上面第一个示例反编译:
Compiled from "Main.java"
public class ch11.conn.Main {
public ch11.conn.Main();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #7 // String hellow
2: astore_1
3: ldc #9 // String world
5: astore_2
6: ldc #11 // String !
8: astore_3
9: aload_1
10: aload_2
11: aload_3
12: invokedynamic #13, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
17: astore 4
19: getstatic #17 // Field java/lang/System.out:Ljava/io/PrintStream;
22: aload 4
24: invokevirtual #23 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
27: return
}
这里的注释内容是反编译工具自行添加的,可以看到,在需要进行字符串连接时,编译器执行了InvokeDynamic #0:makeConcatWithConstants
操作,这其实是通过动态调用的方式执行java.lang.invoke.StringConcatFactory
类的makeConcatWithConstants
方法进行字符串连接。
似乎这种改变和Java底层字符串存储方式的改变有关。
这里不细究makeConcatWithConstants
的实现方式,将其简单的看做是某种高效的多字符串连接实现即可。
但编译器的这种自动优化依然是有限的,比如下面这个代码:
package ch11.conn4;
public class Main {
public static void main(String[] args) {
String[] strs = new String[] { "hello", "world", "!" };
String result = "";
for (String s : strs) {
String begin = "[";
String end = "]";
result += begin + s + end;
}
System.out.println(result);
}
}
这里涉及在循环中连接字符串,反编译后的字节码:
Compiled from "Main.java"
public class ch11.conn4.Main {
public ch11.conn4.Main();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_3
1: anewarray #7 // class java/lang/String
4: dup
5: iconst_0
6: ldc #9 // String hello
8: aastore
9: dup
10: iconst_1
11: ldc #11 // String world
13: aastore
14: dup
15: iconst_2
16: ldc #13 // String !
18: aastore
19: astore_1
20: ldc #15 // String
22: astore_2
23: aload_1
24: astore_3
25: aload_3
26: arraylength
27: istore 4
29: iconst_0
30: istore 5
32: iload 5
34: iload 4
36: if_icmpge 72
39: aload_3
40: iload 5
42: aaload
43: astore 6
45: ldc #17 // String [
47: astore 7
49: ldc #19 // String ]
51: astore 8
53: aload_2
54: aload 7
56: aload 6
58: aload 8
60: invokedynamic #21, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
65: astore_2
66: iinc 5, 1
69: goto 32
72: getstatic #25 // Field java/lang/System.out:Ljava/io/PrintStream;
75: aload_2
76: invokevirtual #31 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
79: return
}
其中32到69行时循环体,60行是动态调用连接字符串。也就是说即使经过编译器优化,循环体中的字符串连接也是局部的,如果循环n
次,依旧会创建n
个“中间字符串”,也就是说时间复杂度是O(n)
。
如果用StringBuilder
改写上边的代码:
package ch11.conn5;
public class Main {
public static void main(String[] args) {
String[] strs = new String[] { "hello", "world", "!" };
StringBuilder sb = new StringBuilder();
for (String s : strs) {
String begin = "[";
String end = "]";
sb.append(begin);
sb.append(s);
sb.append(end);
}
String result = sb.toString();
System.out.println(result);
}
}
反编译后的字节码:
Compiled from "Main.java"
public class ch11.conn5.Main {
public ch11.conn5.Main();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: iconst_3
1: anewarray #7 // class java/lang/String
4: dup
5: iconst_0
6: ldc #9 // String hello
8: aastore
9: dup
10: iconst_1
11: ldc #11 // String world
13: aastore
14: dup
15: iconst_2
16: ldc #13 // String !
18: aastore
19: astore_1
20: new #15 // class java/lang/StringBuilder
23: dup
24: invokespecial #17 // Method java/lang/StringBuilder."":()V
27: astore_2
28: aload_1
29: astore_3
30: aload_3
31: arraylength
32: istore 4
34: iconst_0
35: istore 5
37: iload 5
39: iload 4
41: if_icmpge 85
44: aload_3
45: iload 5
47: aaload
48: astore 6
50: ldc #18 // String [
52: astore 7
54: ldc #20 // String ]
56: astore 8
58: aload_2
59: aload 7
61: invokevirtual #22 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
64: pop
65: aload_2
66: aload 6
68: invokevirtual #22 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
71: pop
72: aload_2
73: aload 8
75: invokevirtual #22 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
78: pop
79: iinc 5, 1
82: goto 37
85: aload_2
86: invokevirtual #26 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
89: astore_3
90: getstatic #30 // Field java/lang/System.out:Ljava/io/PrintStream;
93: aload_3
94: invokevirtual #36 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
97: return
}
其中37到82行是循环体,20到24行是创建StringBuilder
,也就是说StringBuilder
是在循环体外创建的,循环体中只调用append
向其中附加字符串。
所以这里的性能是优于上边的编译器对字符串连接符优化后的效果的。
所以虽然编译器可以在一定程度上优化字符串连接符,但如果在代码中需要大量连接字符串,最优的方式依然是使用StringBuilder
,尤其是在涉及循环的时候。
StringBuilder
是线程不安全的,如果是多线程程序,涉及并发,需要使用StringBuffer
而不是StringBuilder
,当然前者需要同步,性能更差一些。
在Java中,如果需要将对象转换为字符串,解释器就会尝试调用对象的toString
方法,事实上这个方法属于Object
,如果不对其进行覆盖,就会遵循Object
的默认实现,即返回一个包含对象地址的字符串:
package ch11.tostring;
class MyCls {
}
public class Main {
public static void main(String[] args) {
MyCls mc = new MyCls();
System.out.println(mc);
// ch11.tostring.MyCls@24d46ca6
}
}
就像这里展示的,默认toString
返回的字符串中@
之前的是带包名的完整类名,@
后的是表示对象所在内存地址的字符串。
如果你打算对默认的toString
行为进行修饰,可能会编写诸如下面的代码:
package ch11.tostring2;
class MyCls {
@Override
public String toString() {
return "MyCls's string:" + this;
}
}
public class Main {
...
}
// at java.base/java.lang.String.valueOf(String.java:3365)
// at java.base/java.lang.StringBuilder.append(StringBuilder.java:169)
// at ch11.tostring2.MyCls.toString(Main.java:6)
// at java.base/java.lang.String.valueOf(String.java:3365)
// at java.base/java.lang.StringBuilder.append(StringBuilder.java:169)
// at ch11.tostring2.MyCls.toString(Main.java:6)
但实际上这段代码不能正常执行,而是会陷入”无限递归“。这是因为"MyCls's string:" + this
这条语句,因为要将this
和一个字符串连接,所以编译器会尝试调用this.toString()
将其转换为字符串,这就导致递归的发生,而且还是一个没有中止条件的递归,即无限递归。其唯一的结果就是撑爆调用栈,导致程序异常退出。
解决的方法也很简单,显式调用父类的toString
方法以避免递归即可:
package ch11.tostring3;
class MyCls {
@Override
public String toString() {
return "MyCls's string:" + super.toString();
}
}
public class Main {
public static void main(String[] args) {
MyCls mc = new MyCls();
System.out.println(mc);
// MyCls's string:ch11.tostring3.MyCls@24d46ca6
}
}
String
类支持的操作相当多,具体可以阅读官方APIString (Java Platform SE 8 ) (oracle.com)。
需要注意的是,因为String
属于“内容不可修改的对象”,所以凡是会改变字符串内容的调用,其返回的结果字符串都是一个新的字符串,而非原始字符串。
C
和C++
都提供一种非常方便的“字符串格式化函数”printf
及相应的“格式化符号”,用于对字符串输出进行格式化。虽然语法上需要进行额外学习,但的确相当方便,且被很多开发者所熟知,因此后来发展的编程语言都会以某种方式支持类似的字符串格式化功能,并完全沿用C/C++的格式化符号。
完整的格式化符号列表可以阅读Formatter (Java Platform SE 8 ) (oracle.com)。
使用格式化符号对字符串进行格式化有多种方式:
PrintStream
类有一个format
方法可以格式化字符串,并输出到相应的流:
public PrintStream format(String format, Object... args)
之前也提到过,作为标准输出流,system.out
是PrintStream
类的实例,所以自然也可以通过该方法直接将格式化后的字符串输出到屏幕:
package ch11.format;
public class Main {
public static void main(String[] args) {
String name = "apple";
double price = 12.5;
int num = 15;
System.out.format("name %s, num %d, price %.2f", name, num, price);
}
}
// name apple, num 15, price 12.50
PrintStream.format
更多的是为了方便地对字符串进行格式化并输出,在Java中,真正负责格式化工作的是java.util.Formatter
类。
Formatter
类初始化时需要指定一个输出目标,其构造器在重载后支持多种类型的目标,包括:
Appendable
File
OultputStream
PrintStream
完整的构造器列表见Formatter (Java Platform SE 8 ) (oracle.com)。
创建Formatter
实例后只需要调用format
方法即可,使用方式和PrintStream.format
类似:
package ch11.format;
public class Main {
public static void main(String[] args) {
String name = "apple";
double price = 12.5;
int num = 15;
System.out.format("name %s, num %d, price %.2f", name, num, price);
}
}
// name apple, num 15, price 12.50
有意思的是,Formatter
支持Appendable
参数的构造器,而StringBuilder
实现了Appendable
接口,所以我们可以让Formatter
格式化后的字符串输出到StringBuilder
中,然后通过StringBuilder
获取格式化后的字符串:
package ch11.format3;
import java.util.Formatter;
public class Main {
public static void main(String[] args) {
String name = "apple";
double price = 12.5;
int num = 15;
StringBuilder sb = new StringBuilder();
Formatter formatter = new Formatter(sb);
formatter.format("name %s, num %d, price %.2f", name, num, price);
System.out.println(sb.toString());
}
}
// name apple, num 15, price 12.50
当然,如果要获取一个格式化后的字符串而不是输出,可以用更简单的方式:
String
类提供一个format
函数,可以直接格式化字符串后将结果字符串返回:
package ch11.format4;
public class Main {
public static void main(String[] args) {
String name = "apple";
double price = 12.5;
int num = 15;
String result = String.format("name %s, num %d, price %.2f", name, num, price);
System.out.println(result);
}
}
// name apple, num 15, price 12.50
我认为Java对格式化字符串的设计很不好用,就像在之前的笔记中展示的那样,我们可以创建类似Go的fmt
包的工具类,让格式化字符串的工作更简单、风格更统一一些:
package util;
import java.util.Formatter;
public class Fmt {
public static void printf(String format, Object... args) {
System.out.printf(format, args);
}
public static String sprintf(String format, Object... args){
return String.format(format, args);
}
public static void fprintf(Appendable appendable, String format, Object... args){
Formatter formatter = new Formatter(appendable);
formatter.format(format, args);
formatter.close();
}
}
关于格式化符号的详细说明,可以阅读Formatter (Java Platform SE 8 ) (oracle.com),这里仅进行一些简单说明。
格式化符号的完整语法是:
%[argument_index$][flags][width][.precision]conversion
其中符号的含义:
argument_index
,所对应的格式化参数位置,第一个参数为$1
,第二个为$2
,依此类推。flags
,用于控制输出格式,比如左对齐还是右对齐等。width
,最小宽度,如果格式化后的结果位数不够,会用空白符填充到足够长度。precision
,最大宽度,不同的格式化符号对应的效果不同。对于%s
,对应可以显示的字符串最大长度,对于%f
,对应小数位数,不够会用0
填充,不能应用于%d
。conversion
,转换后的数据类型。格式化符号支持的数据类型有:
%d
,(decimal integer),整数%f
,(float),浮点数%s
,(string),字符串%c
,(character),Unicode字符%b
,(boolean),布尔值%h
,(hash code),十六进制散列码,相当于调用Integer.toHexString(arg.hashCode())
%o
,(octal integer),八进制整数%x
,(hexadecimal integer),十六进制整数%e
,科学计数法形式的浮点数%a
,十六进制浮点数%t
,日期和时间%%
,百分比%n
,换行符(和平台相关)其中%d
、%s
、%f
比较常用,其余的类型可以用于一些特殊用途,比如进行类型转换:
package ch11.format5;
public class Main {
public static void main(String[] args) {
char a = 'a';
System.out.format("%%c:%c\n", a);
System.out.format("%%d:%d\n", (int) a);
System.out.format("%%o:%o\n", (int) a);
System.out.format("%%x:%x\n", (int) a);
}
}
// %c:a
// %d:97
// %o:141
// %x:61
输出的结果分别是字符a
的十进制、八进制、十六进制。
需要注意的是,与其它语言不同,Java的格式化符号只能处理特定类型,所以这里要想将char
格式化为各种进制的整数,需要先将其用类型转换转换为int
。
这里再举一个例子,我们知道,文件分为两种:文本文件和二进制文件,后者往往是不能直接查看的,但有时候可以借助一些工具将其中的二进制按照字节转化为十六进制进行查看,利用之前所说的字符串格式化,我们可以用Java实现这个小工具:
package ch11.hex_reader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
public class Main {
private static byte[] readFile(String fname) throws IOException {
InputStream is = new FileInputStream(fname);
try {
byte[] bytesCache = new byte[20];
int size = is.available();
byte[] allBytes = new byte[size];
if (size == 0) {
return allBytes;
}
int cursor = 0;
int readNum = 0;
do {
readNum = is.read(bytesCache);
if (readNum > 0) {
for (int i = 0; i < readNum; i++) {
allBytes[cursor] = bytesCache[i];
cursor++;
}
}
} while (readNum != -1);
return allBytes;
} finally {
is.close();
}
}
public static void main(String[] args) {
String fname = "D:\\workspace\\java\\java-notebook\\xyz\\icexmoon\\java_notes\\ch11\\conn\\Main.class";
try {
byte[] bytes = readFile(fname);
int index = 0;
System.out.format("%05X: ", index);
for (byte b : bytes) {
index++;
System.out.format("%02X ", b);
if (index % 16 == 0) {
System.out.println();
System.out.format("%05X: ", index);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
// 00000: CA FE BA BE 00 00 00 3C 00 36 0A 00 02 00 03 07
// 00010: 00 04 0C 00 05 00 06 01 00 10 6A 61 76 61 2F 6C
// 00020: 61 6E 67 2F 4F 62 6A 65 63 74 01 00 06 3C 69 6E
// 00030: 69 74 3E 01 00 03 28 29 56 08 00 08 01 00 06 68
// 00040: 65 6C 6C 6F 77 08 00 0A 01 00 01 20 08 00 0C 01
// 00050: 00 05 77 6F 72 6C 64 08 00 0E 01 00 01 21 12 00
// 00060: 00 00 10 0C 00 11 00 12 01 00 17 6D 61 6B 65 43
// ...
这里
readFile
函数主要是借助FileInputStream
将二进制文件以字节流的方式读入到字节数组,IO流相关内容后边会介绍。
最后再用一个经常会用作格式化打印示例的购物小票说明格式化打印的作用:
package ch11.shopping;
import java.util.ArrayList;
import java.util.List;
class ShoppingDetail {
String name = "";
double num;
double price;
public ShoppingDetail(String name, double num, double price) {
this.name = name;
this.num = num;
this.price = price;
}
}
public class Main {
private static void addDetail(List<ShoppingDetail> details, String name, double num, double price) {
details.add(new ShoppingDetail(name, num, price));
}
public static void main(String[] args) {
List<ShoppingDetail> details = new ArrayList<>();
addDetail(details, "apple", 2.5, 6);
addDetail(details, "banana", 3, 2.5);
addDetail(details, "toy", 2, 5);
System.out.format("%-10s %6s %9s\n", "name", "num", "price");
System.out.format("%-10s %6s %9s\n", "----------", "------", "---------");
double total = 0;
for (ShoppingDetail sd : details) {
System.out.format("%-10s% 7.2f% 10.2f\n", sd.name, sd.num, sd.price);
total += sd.num * sd.price;
}
System.out.format("%-17s%10s\n","-----------------","---------");
System.out.format("%-10s%17.2f\n", "total", total);
}
}
// name num price
// ---------- ------ ---------
// apple 2.50 6.00
// banana 3.00 2.50
// toy 2.00 5.00
// ----------------- ---------
// total 32.50
这里的格式化符号%-10s
中-
是之前所说的flags
,其用途是让格式化后的字符串靠左对齐(默认是靠右对齐)。
正则表达式的语法这里不进行说明,因为作为一门完备的语言,正则表达式本身相当复杂,一来我的能力有限,很难说清楚,二来这也会耗费很大的精力。
如果你还不了解正则表达式的语法,可以阅读正则表达式参考文档 - Regular Expression Syntax Reference (regexlab.com),这是一个非常不错的教程。
另外推荐一个正则表达式在线工具:Debuggex: Online visual regex tester. JavaScript, Python, and PCRE.该工具可以用图形化的方式分析具体正则表达式的结构,可以通过它检查正则表达式中可能的错误。
这里直接进入到介绍如何在Java中使用正则表达式。
String
类本身的一些方法就支持正则表达式,比如matchs
:
package ch11.string;
public class Main {
private static void checkAndPrint(String str, String regex) {
System.out.println(str.matches(regex));
}
public static void main(String[] args) {
checkAndPrint("12345", "[0-9]+");
checkAndPrint("12345", "\\d+");
checkAndPrint("-12345", "\\d+");
checkAndPrint("-12345", "-\\d+");
}
}
// true
// true
// false
// true
String.matches
方法可以检查当前字符串是否能匹配给定的正则表达式,并返回一个boolean
。
需要注意的是,Java不像其它的编程语言可以使用单引号字符串来避免大量使用转义符,因此在用Java编写正则字符串时,必须使用\\
作为正则中的特殊符号\
,如果要在正则字符串中表示一个普通的斜杠,需要使用\\\\
来表示:
checkAndPrint("123\\456", "\\d+(\\\\)\\d+");
这里的分组符号
(...)
其实是可以省略的,但如果省略,整个正则表达式的可读性就很差了。
除了matchs
,切割字符串常用的split
方法同样可以使用正则表达式:
package ch11.string3;
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
String str="-agentlib:jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:8662";
String[] result = str.split("(,|=|:)");
System.out.println(Arrays.toString(result));
result = str.split("[,=:]");
System.out.println(Arrays.toString(result));
}
}
// [-agentlib, jdwp, transport, dt_socket, server, n, suspend, y, address, localhost, 8662]
// [-agentlib, jdwp, transport, dt_socket, server, n, suspend, y, address, localhost, 8662]
这里的正则表达式(,|=|:)
表示,
或=
或:
字符都可以匹配,实际上等同于[,=:]
。
如果需要使用正则匹配字符串中的某些内容并进行替换,可以使用String.replaceAll
和String.replaceFist
:
package ch11.string4;
public class Main {
public static void main(String[] args) {
String str = "-agentlib:jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:8662";
String str2 = str.replaceAll("\\w+=\\w+", "key=value");
System.out.println(str2);
String str3 = str.replaceFirst("\\w+=\\w+", "key=value");
System.out.println(str3);
}
}
// -agentlib:key=value=dt_socket,key=value,key=value,key=value:8662
// -agentlib:key=value=dt_socket,server=n,suspend=y,address=localhost:8662
String
类中可以使用正则表达式的相关方法只能说是为某些一次性的正则使用场景提供了便利,如果需要重复且高效地使用某个正则表达式执行复杂任务,就需要使用Pattern
类:
package ch11.string5;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import util.Fmt;
public class Main {
public static void main(String[] args) {
String str = "-agentlib:jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:8662";
Pattern pattern = Pattern.compile("(\\w+)=(\\w+(:\\d+)?)");
Matcher machter = pattern.matcher(str);
while (machter.find()) {
String key = machter.group(1);
String value = machter.group(2);
Fmt.printf("key:%s, value:%s\n", key, value);
}
}
}
// key:jdwp, value:transport
// key:server, value:n
// key:suspend, value:y
// key:address, value:localhost:8662
通过静态方法Pattern.compile
可以指定一个正则表达式并创建Pattern
实例,然后使用Pattern
实例的matcher
方法可以将正则表达式作用于给定的字符串,并返回一个Matcher
实例。
之后就可以使用得到的Matcher
实例的find
方法尝试匹配正则表达式,每次匹配成功后,都可以使用Macher.group()
方法获取相应的分组内容,其中group(0)
表示匹配到的整个字符串。
可以通过Macher.start()
和Macher.end()
方法获取匹配到的字符串在原始字符串上的位置:
...
public class Main {
public static void main(String[] args) {
...
while (machter.find()) {
...
System.out.println(str.substring(machter.start(), machter.end()));
}
}
}
// key:jdwp, value:transport
// jdwp=transport
// key:server, value:n
// server=n
// key:suspend, value:y
// suspend=y
// key:address, value:localhost:8662
// address=localhost:8662
当然,Formatter
同样可以用来检查正则表达式是否与整个字符串匹配:
package ch11.string7;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
Matcher matcher = Pattern.compile("\\d+").matcher("12345");
System.out.println(matcher.matches());
}
}
// true
但这样做似乎并没有什么必要性,用String.maches
要方便的多。
Matcher
还有一个与maches
类似的lookingAt
方法,只不过只要字符串开始的部分能匹配正则就会返回true
:
package ch11.string8;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
String[] strs = new String[] { "12345abcde", "12345", "+12345", "abc123" };
for (String str : strs) {
Matcher matcher = Pattern.compile("\\d+").matcher(str);
System.out.println(matcher.lookingAt());
}
}
}
// true
// true
// false
// false
正则表达式有一些特殊标识可以控制整个表达式的运行模式,而Java通过类常量可以起到相同的作用:
Pattern.UNIX_LINES
,(?d),UNIX行模式,使用UNIX换行符\n
作为每行的结束标识。Pattern.CASE_INSENSITIVE
,(?i),大小写不敏感匹配模式,默认情况下只有ASCII字符集才能正常进行,如果要对Unicode字符集使用,需要同时使用Patter.UNICODE_CASE
模式。Pattern.COMMENTS
,(?x),忽略空格以及#
开头的注释部分。Pattern.MULTILINE
,(?m),多行模式。在这种模式下,^
和$
将匹配一行的开始和结束。Pattern.DOTALL
,(?s),特殊字符.
将匹配所有字符,包括行终结符(默认情况下.
不会匹配行终结符)。Pattern.UNICODE_CASE
,(?u),可以和Pattern.CASE_INSENSITIVE
模式结合使用,以大小写不敏感的方式匹配Unicode字符集组成的字符串。Pattern.UNICODE_CASE
,在匹配时会考虑字符集中字符的等价性,比如a\u030A
和?
会匹配。这里用一个读取当前代码,并匹配出其中导入的包的示例程序作为说明:
package ch11.string9;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) throws IOException {
String fname = "D:\\workspace\\java\\java-notebook\\xyz\\icexmoon\\java_notes\\ch11\\string9\\Main.java";
BufferedReader bf = new BufferedReader(new FileReader(fname));
StringBuffer sb = new StringBuffer();
try {
String line = null;
while ((line = bf.readLine()) != null) {
sb.append(line);
sb.append("\n");
}
} finally {
bf.close();
}
String regex = "^import\\s+(((\\w+(\\.)?))+);$";
Pattern pattern = Pattern.compile(regex,
Pattern.MULTILINE | Pattern.COMMENTS);
Matcher matcher = pattern.matcher(sb);
while (matcher.find()) {
System.out.println(matcher.group(1));
}
}
}
// java.io.BufferedReader
// java.io.FileReader
// java.io.IOException
// java.util.regex.Matcher
// java.util.regex.Pattern
- 似乎被
readLine
方法读取出的单行数据是缺少换行符的,所以这里手动追加换行符\n
。Pattren.matcher
方法接受的参数类型是CharSequence
,而StringBuffer
、StringBuilder
等类型都实现了该接口,所以可以直接作为参数使用,无需先转换为字符串。
这里使用了Pattern.MULTILINE
模式和Pattern.COMMENTS
来构建Pattern
实例,所以在正则表达式中可以用^...$
的方式匹配单行内容。
其实更为通用的方式是直接在正则表达式中添加模式符号:
...
String regex = "(?m)^import\\s+(((\\w+(\\.)?))+);$";
Pattern pattern = Pattern.compile(regex);
...
这种方式是在所有支持标准正则表达式的编程语言中通用的。
Pattern.split
方法的用途与String.split
相似,同样是切分字符串:
package ch11.split;
import java.util.Arrays;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
String str = "jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:9515";
Pattern p = Pattern.compile("[=:,]");
String[] result = p.split(str);
System.out.println(Arrays.toString(result));
}
}
// [jdwp, transport, dt_socket, server, n, suspend, y, address, localhost, 9515]
此外,split
还可以指定最大切分次数:
...
public class Main {
public static void main(String[] args) {
...
String[] result = p.split(str, 3);
System.out.println(Arrays.toString(result));
}
}
// [jdwp, transport, dt_socket,server=n,suspend=y,address=localhost:9515]
使用Pattern
和Matcher
同样可以对匹配到的内容进行替换操作:
package ch11.replace;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
String str = "jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:9515";
Pattern p = Pattern.compile("\\w+=\\w+");
Matcher m = p.matcher(str);
String result = "";
result = m.replaceFirst("key=value");
System.out.println(result);
result = m.replaceAll("key=value");
System.out.println(result);
}
}
// key=value=dt_socket,server=n,suspend=y,address=localhost:9515
// key=value=dt_socket,key=value,key=value,key=value:9515
此外Matcher
还具备一些其它的替换方法,可以结合find
方法实现更复杂的替换操作:
package ch11.replace2;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
String str = "jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:9515";
Pattern p = Pattern.compile("(\\w+)=(\\w+(:\\d+)?)");
Matcher m = p.matcher(str);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String key = m.group(1);
String value = m.group(2);
String replacement = m.group();
if (key.equals("address")) {
replacement = "address=127.0.0.1:8888";
} else if (key.equals("server")) {
replacement = "server=y";
} else {
;
}
m.appendReplacement(sb, replacement);
}
m.appendTail(sb);
System.out.println(str);
System.out.println(sb.toString());
}
}
// jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:9515
// jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:8888
通过使用appendReplacement
方法,可以替换find
方法匹配到的内容,没有匹配到的内容会自动填充,不需要开发者操心。需要注意的是,最后必须调用appendTail
方法,将剩余的没有匹配到的内容填充到StringBuffer
中,这样才完整。
使用Matcher.reset
可以给Matcher
指定一个新的匹配用字符串:
package ch11.reset;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
public static void main(String[] args) {
String str = "jdwp=transport=dt_socket,server=n,suspend=y,address=localhost:9515";
Pattern p = Pattern.compile("\\w+=\\w+");
Matcher m = p.matcher(str);
String result = "";
result = m.replaceAll("key=value");
System.out.println(result);
m.reset("hello=world");
result = m.replaceAll("key=value");
System.out.println(result);
}
}
// key=value=dt_socket,key=value,key=value,key=value:9515
// key=value
如果使用不带参数的reset
,会将Matcher
的匹配查找状态重置为起始状态。
虽然大多数情况下,需要从文件中加载数据都会是结构化的数据,比如Excel、JSON或XML。对于这种结构化数据我们可以调用相应的解析器进行处理,但如果是非结构化的数据,就比较麻烦了。
比如有一个如下文本:
Han mei,20,female
Li Lei,15,male
我们就需要用下面这样的代码读取并处理:
package ch11.scanner;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
enum Sex {
MALE, FEMALE
}
class Person {
String name = "";
int age;
Sex sex = Sex.FEMALE;
public Person(String name, int age, Sex sex) {
this.name = name;
this.age = age;
this.sex = sex;
}
@Override
public String toString() {
return "Person [age=" + age + ", name=" + name + ", sex=" + sex + "]";
}
}
public class Main {
public static void main(String[] args) throws IOException {
String fname = "D:\\workspace\\java\\java-notebook\\xyz\\icexmoon\\java_notes\\ch11\\scanner\\persons.txt";
BufferedReader br = new BufferedReader(new FileReader(fname));
List<Person> list = new ArrayList<>();
try {
String line;
while ((line = br.readLine()) != null) {
String[] info = line.split(",");
int age = Integer.parseInt(info[1]);
Sex sex = Sex.MALE;
if (info[2].equals("female")) {
sex = Sex.FEMALE;
}
list.add(new Person(info[0], age, sex));
}
} finally {
br.close();
}
System.out.println(list);
}
}
// [Person [age=20, name=Han mei, sex=FEMALE], Person [age=15, name=Li Lei, sex=MALE]]
通常会像上面展示的那用,采取逐行读入,并利用可能存在的分隔符来进行字符串截取,并将截取后的数据进行类型转换后存进结构化数据中。
Java提供一个Scanner
类,可以用于字符流的扫描和提取工作,这里用Scanner
类改写上边的示例:
...
public class Main {
public static void main(String[] args) throws IOException {
String fname = "D:\\workspace\\java\\java-notebook\\xyz\\icexmoon\\java_notes\\ch11\\scanner\\persons.txt";
Scanner scanner = new Scanner(new FileReader(fname));
scanner.useDelimiter(",|\n");
List<Person> persons = new ArrayList<>();
while (true) {
try {
String name = scanner.next();
int age = scanner.nextInt();
String sexStr = scanner.next();
Sex sex = Sex.MALE;
if (sexStr.equals("female")) {
sex = Sex.FEMALE;
}
persons.add(new Person(name, age, sex));
} catch (NoSuchElementException e) {
break;
}
}
System.out.println(persons);
}
}
// [Person [age=20, name=Han mei, sex=FEMALE], Person [age=15, name=Li Lei,
// sex=MALE]]
可以看到,使用Scanner
类的代码简单了很多,这体现在两方面:
Scanner
类可以接受多种输入,如File
、InputStream
、String
等。Scanner
类的next
和nextXXX
等方法可以“自动”地查找下一个符合类型要求的数据,不需要手动分词。事实上Scanner
是依赖“界定符”进行分词和查找的,默认情况下会使用空白符作为界定符(相当于正则表达式\\s
),如果像示例中那样用,
和换行符作为界定符,就需要使用Scanner.useDelimiter
方法重新指定界定符(该方法支持正则)。
在检索结束后,Scanner
会抛出一个NoSuchElementException
异常,可以进行捕获并作为结束依据。
Scanner
也支持使用正则表达式进行扫描:
...
public class Main {
public static void main(String[] args) throws IOException {
String fname = "D:\\workspace\\java\\java-notebook\\xyz\\icexmoon\\java_notes\\ch11\\scanner\\persons.txt";
Scanner scanner = new Scanner(new FileReader(fname));
scanner.useDelimiter("\r\n");
List<Person> persons = new ArrayList<>();
String pattern = "(\\w+(\\s+\\w+)?),(\\d+),(\\w+)";
while (scanner.hasNext(pattern)) {
scanner.next(pattern);
MatchResult mr = scanner.match();
String name = mr.group(1);
String ageStr = mr.group(3);
String sexStr = mr.group(4);
int age = Integer.parseInt(ageStr);
Sex sex = Sex.FEMALE;
if (sexStr.equals("male")) {
sex = Sex.MALE;
}
persons.add(new Person(name, age, sex));
}
System.out.println(persons);
}
}
// [Person [age=20, name=Han mei, sex=FEMALE], Person [age=15, name=Li Lei,
// sex=MALE]]
可以看到,使用正则的好处是可以编写更精确的匹配语句。
需要注意的是,即使是使用正则匹配,Scanner
同样是先用界定符分词,再将分词后的结果用正则匹配,所以如果是对整行进行正则匹配,就需要使用换行符作为Scanner
的界定符。
比较奇怪的是这里必须使用Windows下的换行符
\r\n
作为界定符才能正常用正则扫描,否则会失败。但之前不使用正则的扫描是可以使用\n
的。
没想到又是7000+字的一篇笔记。
谢谢阅读。