Java编程笔记11:字符串

  • Post author:
  • Post category:java




Java编程笔记11:字符串

5c9c3b3b392ac581.jpg

图源:

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."<init>":()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."<init>":()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."<init>":()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."<init>":()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上的操作


String

类支持的操作相当多,具体可以阅读官方API

String (Java Platform SE 8 ) (oracle.com)

需要注意的是,因为

String

属于“内容不可修改的对象”,所以凡是会改变字符串内容的调用,其返回的结果字符串都是一个新的字符串,而非原始字符串。



格式化输出


C



C++

都提供一种非常方便的“字符串格式化函数”

printf

及相应的“格式化符号”,用于对字符串输出进行格式化。虽然语法上需要进行额外学习,但的确相当方便,且被很多开发者所熟知,因此后来发展的编程语言都会以某种方式支持类似的字符串格式化功能,并完全沿用C/C++的格式化符号。

完整的格式化符号列表可以阅读

Formatter (Java Platform SE 8 ) (oracle.com)

使用格式化符号对字符串进行格式化有多种方式:



PrintStream.format


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



Formatter


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


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



Fmt

我认为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


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



Pattern


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


Pattern标记

正则表达式有一些特殊标识可以控制整个表达式的运行模式,而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);
        ...

这种方式是在所有支持标准正则表达式的编程语言中通用的。



split


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

中,这样才完整。



reset

使用

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

的匹配查找状态重置为起始状态。



Scanner

虽然大多数情况下,需要从文件中加载数据都会是结构化的数据,比如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

类的代码简单了很多,这体现在两方面:


  1. Scanner

    类可以接受多种输入,如

    File



    InputStream



    String

    等。

  2. 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+字的一篇笔记。

谢谢阅读。



参考资料



版权声明:本文为hy6533原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。