Java编程笔记11:字符串

Java编程笔记11:字符串

Java编程笔记11:字符串_第1张图片

图源: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上的操作

String类支持的操作相当多,具体可以阅读官方APIString (Java Platform SE 8 ) (oracle.com)。

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

格式化输出

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

完整的格式化符号列表可以阅读Formatter (Java Platform SE 8 ) (oracle.com)。

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

PrintStream.format

PrintStream类有一个format方法可以格式化字符串,并输出到相应的流:

public PrintStream format(String format, Object... args)

之前也提到过,作为标准输出流,system.outPrintStream类的实例,所以自然也可以通过该方法直接将格式化后的字符串输出到屏幕:

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.replaceAllString.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,而StringBufferStringBuilder等类型都实现了该接口,所以可以直接作为参数使用,无需先转换为字符串。

这里使用了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]
替换操作

使用PatternMatcher同样可以对匹配到的内容进行替换操作:

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类可以接受多种输入,如FileInputStreamString等。
  2. Scanner类的nextnextXXX等方法可以“自动”地查找下一个符合类型要求的数据,不需要手动分词。

事实上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+字的一篇笔记。

谢谢阅读。

参考资料

  • Java菜谱(五)——怎么把字符串列表合并为一个字符串? - 知乎 (zhihu.com)
  • Java字符串连接,StringBuilder和invokedynamic - 知乎 (zhihu.com)
  • String字符串拼接性能优化 - 简书 (jianshu.com)
  • PrintStream (Java Platform SE 8 ) (oracle.com)
  • Formatter (Java Platform SE 8 ) (oracle.com)
  • ByteBuffer详解 - 简书 (jianshu.com)
  • Java 流(Stream)、文件(File)和IO | 菜鸟教程 (runoob.com)

你可能感兴趣的:(JAVA,java,开发语言,后端,字符串,正则)