Blog

Java 的字符串、文本块和数组

Java 字符串创建后就不会再变,每次“修改”都会生成新字符串。本文讲 String 常用方法及其陷阱、循环里的 += 为什么要复制全部内容、StringBuilder、文本块、Unicode,以及数组和 Arrays 工具类。

Java 的 String 一旦创建就不能再改。每个看起来在修改字符串的方法,其实都返回一个新字符串。这一点能解释一个经典 bug、一个经典的慢循环,也解释了 StringBuilder 为什么存在。

本文会讲 String 的常用方法及其陷阱、字符串比较、StringBuilder、Unicode、文本块,以及数组和 Arrays 工具类。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存为 Main.java,然后执行 java Main.java

字符串是不可变的

String 对象里的字符从创建起就固定不变,没有哪个方法能改动它们。toUpperCase 不会把字符串变成大写。它返回一个新的大写字符串,如果你不保存这个返回值,它就丢了:

void main() {
    String name = "ada";
    name.toUpperCase();
    IO.println(name);

    String shout = name.toUpperCase();
    IO.println(shout);
    IO.println(name);

    name = name.toUpperCase();
    IO.println(name);
}

输出:

ada
ADA
ada
ADA

main 的第二行就是那个 bug。它能编译、能运行,却什么也没做,而且 javac 不会警告,就算加上 -Xlint:all 也不会。像最后几行那样把结果存下来。即便如此,name = name.toUpperCase() 也没有改变 "ada"。它只是让 name 引用了另一个字符串。

String 的常用方法

处理字符串,大多是找位置、截取片段,或者检查里面有什么。位置叫作索引,从 0 开始:

void main() {
    String s = "hello, world";
    IO.println(s.length());
    IO.println(s.charAt(0));
    IO.println(s.charAt(s.length() - 1));
    IO.println(s.substring(7));
    IO.println(s.substring(0, 5));
    IO.println(s.indexOf("o"));
    IO.println(s.lastIndexOf("o"));
    IO.println(s.indexOf("z"));
    IO.println(s.contains("world"));
    IO.println(s.replace("l", "L"));
    IO.println(s.startsWith("hell") + " " + s.endsWith("!"));
}

输出:

12
h
d
world
hello
4
8
-1
true
heLLo, worLd
true false

从这段输出里可以记住几条规则:

  • 最后一个索引是 length() - 1 12 个字符的字符串,索引是 0 到 11。
  • substring(begin, end) 包含 begin,在 end 之前停下。 结果的长度总是 end - beginsubstring(7) 一直截到末尾。
  • 没有匹配时,indexOf 返回 -1。 把结果当索引用之前,先检查这个值。
  • replace 会替换所有匹配的普通文本。replaceAll 看起来差不多,但参数是正则表达式。

索引越界会抛出异常

substringcharAt 会检查索引,传入错误的索引,程序就会停下:

void main() {
    String word = "hello";
    IO.println(word.substring(1, 5));
    IO.println(word.substring(2, 9));
}

输出后停止:

ello
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Range [2, 9) out of bounds for length 5

substring(1, 5) 没问题,因为结尾等于长度,意思就是“一直到末尾”。错误信息用数学记号写出了出错的区间:[2, 9) 包含 2、不包含 9,正是“不含结尾”这条规则的写法。

split 的参数是正则表达式

split 在分隔符每次出现的地方切开字符串,返回由各段组成的数组。分隔符是正则表达式,不是普通文本,几乎每个人都会在这里栽一次:

void main() {
    IO.println(Arrays.toString("a,b,c".split(",")));
    IO.println(Arrays.toString("1.2.3".split(".")));
    IO.println(Arrays.toString("1.2.3".split("\\.")));
    IO.println(Arrays.toString("red|green".split("|")));
    IO.println(Arrays.toString("red|green".split(Pattern.quote("|"))));
    IO.println(Arrays.toString("a,b,,".split(",")));
    IO.println(Arrays.toString("a,b,,".split(",", -1)));
    IO.println(Arrays.toString(",a".split(",")));
}

输出:

[a, b, c]
[]
[1, 2, 3]
[r, e, d, |, g, r, e, e, n]
[red, green]
[a, b]
[a, b, , ]
[, a]

按顺序看,有三个意外:

  • split(".") 返回空数组。 . 表示“任意字符”,所以每一段都是空的,而末尾的空段会被丢掉。把点转义成 "\\."
  • split("|") 在每两个字符之间都切一刀。 | 表示“或”,“空或空”在哪里都能匹配。Pattern.quote 能让任何文本按字面匹配。
  • 末尾的空字符串会消失。 "a,b,," 有四个字段,split(",") 却只返回两个。开头的空字符串会保留,",a" 就是例子。传入 -1 作为 limit 参数,就能保留所有字段,比如最后几列为空的 CSV 行。

去空白、判空、重复、拼接和格式化

这些方法用来清理和构建字符串。stripisBlankrepeat 是 Java 11 加入的,formatted 是 Java 15 加入的:

void main() {
    String spaces = "  total  ";
    IO.println("[" + spaces.trim() + "] [" + spaces.strip() + "]");

    String emSpaces = "\u2003total\u2003";
    IO.println(emSpaces.trim().length() + " " + emSpaces.strip().length());

    IO.println("".isEmpty() + " " + "   ".isEmpty() + " " + "   ".isBlank());
    IO.println("ab".repeat(3));
    IO.println(String.join(", ", "Ana", "Ben", "Chen"));
    IO.println(String.join("/", List.of("usr", "local", "bin")));
    IO.println(String.format("%-6s|%5d|%.2f", "tea", 42, 3.14159));
    IO.println("%s scored %d%% on %,d questions".formatted("Ana", 95, 1200));
}

输出:

[total] [total]
7 5
true false true
ababab
Ana, Ben, Chen
usr/local/bin
tea   |   42|3.14
Ana scored 95% on 1,200 questions

对普通空格,trimstrip 的结果一样。遇到 \u2003 就不一样了。它是 em 空格,从网页上复制来的文本里常常带着它。trim 只删除字符表里排在普通空格之前(含普通空格)的字符,所以两个 em 空格都留了下来,返回 7 个字符。strip 认识所有 Unicode 空白字符。新代码里请用 strip

字符串只由空白字符组成时,isBlank 为 true,检查用户输入时通常要的就是它。isEmpty 只在长度为 0 时才为 true。

formattedString.format 做的是同一件事,只不过直接在格式串上调用。%s 是字符串,%d 是整数,%.2f 是保留两位小数,%-6s 是在六列宽度内左对齐,%,d 是带千位分隔符的数字,%% 是百分号。

比较字符串

equals 逐个字符比较两个字符串。讲值和引用的那一篇说明了为什么不能用 == 比较字符串:它检查的是不是同一个对象,而不是文本是否相同。另外还有两个方法,分别处理大小写和先后顺序:

void main() {
    String a = "Java";
    IO.println(a.equals("java"));
    IO.println(a.equalsIgnoreCase("java"));
    IO.println("apple".compareTo("banana"));
    IO.println("banana".compareTo("apple"));
    IO.println("pear".compareTo("pear"));
    IO.println("app".compareTo("apple"));
    IO.println("Zebra".compareTo("apple"));
    IO.println("Zebra".compareToIgnoreCase("apple"));
    String missing = null;
    IO.println("Java".equals(missing));
}

输出:

false
true
-1
1
0
-2
-7
25
false

compareTo 给两个字符串排先后。只有正负号有意义:负数表示第一个字符串排在前面,零表示相等,正数表示排在后面。这个数字是第一对不同字符之间的差值;如果没有不同的字符,就是长度之差,比如 "app""apple"

"Zebra" 排在 "apple" 前面,因为每个大写字母的字符编码都比每个小写字母小。compareToIgnoreCase 给出的才是人期望的顺序。

最后一行是个值得学的习惯。"Java".equals(missing) 遇到 null 返回 false,而 missing.equals("Java") 会抛出 NullPointerException

Java 在字符串池里为每个字符串字面量只保留一份共享的副本,所以两个相同的字面量是同一个对象,对它们用 == 碰巧会得到 true。运行时构建的字符串,比如来自输入或 StringBuilder 的,不会进入字符串池。这就是为什么对字符串用 ==,简单测试能通过,遇到真实数据就出错。

为什么循环里的 += 要复制全部内容

字符串不能变长,所以 s += "!" 会新建一个长一个字符的字符串,把旧字符串的每个字符都复制进去,加上 "!",再让 s 指向结果。旧字符串就成了垃圾。在循环里,每一步都比上一步复制得更多。

下面的程序不计时,而是统计复制了多少。对 +=,每一步都复制当前的整个字符串。对 StringBuilder,统计 builder 换到更大缓冲区的次数,以及换的时候复制了多少:

void main() {
    int n = 10_000;

    String s = "";
    long copiedByPlus = 0;
    for (int i = 0; i < n; i++) {
        copiedByPlus += s.length();
        s += "!";
    }

    var sb = new StringBuilder();
    long copiedByBuilder = 0;
    int regrowths = 0;
    for (int i = 0; i < n; i++) {
        int before = sb.capacity();
        sb.append('!');
        if (sb.capacity() != before) {
            regrowths++;
            copiedByBuilder += sb.length() - 1;
        }
    }

    IO.println("+= made " + n + " strings and copied " + copiedByPlus + " characters");
    IO.println("StringBuilder grew " + regrowths + " times and copied "
            + copiedByBuilder + " characters");
    IO.println("same text: " + s.contentEquals(sb));
}

输出:

+= made 10000 strings and copied 49995000 characters
StringBuilder grew 10 times and copied 18394 characters
same text: true

两个循环构建的是同样的 10,000 个字符。+= 循环先复制 0 个,再复制 1 个、2 个,一直到 9,999 个,总共约 n² / 2。n 翻一倍,复制量就变成四倍。这就是 O(n²)。

StringBuilder 把字符放在一个留有富余空间的缓冲区里,append 把字符写进下一个空闲槽位。缓冲区满了,builder 就分配一个大约两倍大的新缓冲区,把内容整体复制过去一次。这种情况只发生了 10 次,所以复制量始终接近 n

"Hi " + name + ", you have " + n + " messages" 这样的单个表达式,不需要 StringBuilder。从 Java 9 起,javac 会把整个表达式编译成一次调用,只构建一次字符串。只有在循环里反复 += 时才有代价,因为每一轮都要从完整的字符串重新开始。

使用 StringBuilder

StringBuilder 是允许修改的字符串。你先一点点构建它,最后调用一次 toString,得到一个普通的 String

void main() {
    var sb = new StringBuilder("world");
    sb.insert(0, "hello ");
    sb.append('!').append(" x").append(3);
    IO.println(sb);
    sb.setCharAt(0, 'H');
    sb.deleteCharAt(sb.length() - 1);
    String done = sb.toString();
    IO.println(done + " has " + done.length() + " characters");
    IO.println(new StringBuilder("stressed").reverse());

    var one = new StringBuilder("abc");
    var two = new StringBuilder("abc");
    IO.println(one.equals(two));
    IO.println(one.toString().equals(two.toString()));
    IO.println("abc".contentEquals(one));
}

输出:

hello world! x3
Hello world! x has 14 characters
desserts
false
true
true

append 接受字符串、字符和数字,并返回同一个 builder,所以调用可以串起来。insert 在某个索引处插入文本,reverse 原地反转 builder 的内容。

最后三行藏着一个陷阱。StringBuilder 没有重写 equals,所以两个文本相同的 builder 并不相等。要比较它们的字符串,或者用 String.contentEquals。你还可能遇到 StringBuffer,它是更老的、带同步的版本。请用 StringBuilder

用十岁孩子能懂的话说

String 是一块印好的招牌。谁也改不了上面的字。想把“Sale”改成“Sale!”,你得重新印一块完整的新招牌,把旧的扔掉。

现在每天加一个字母。每天你都要印一块新招牌,印上所有旧字母再加一个,回收箱越堆越满。到月底,第一个字母你已经印了 30 遍。

StringBuilder 是一块白板。你在最后一个字母后面写下一个,什么都不用重印。写完以后,再照着白板印一块招牌。

准确的说法

String 内部持有一个私有的字符数组,构造完成后就没有任何代码再写它。replacetoUpperCase+ 都会分配一个新的 String 和一个新数组。

StringBuilder 持有一个数组,数组的容量至少等于它的长度。append 往没用到的部分里写。某次写入放不下时,builder 会分配一个更大的数组,在这个 JDK 上是旧容量的两倍再加 2(16、34、70、142,依此类推),再把字符复制过去。因为大小是翻倍增长的,平均下来每个字符只被复制几次,所以 n 次 append 的代价是 O(n)。toString 把用到的部分复制进一个新的 String,所以之后再改 builder,也影响不到它。

这个比喻的局限:真正的白板大小是固定的。StringBuilder 写满时,会把自己换成一块更大的,这次替换确实要复制全部内容,只是很少发生。如果你知道最终大小,new StringBuilder(10_000) 就能省掉这些替换。

+= 怎样复制、append 怎样填满缓冲区

下面的动画先执行两次 s += "!",再用 StringBuilder 做同样的事:

用 s += "!" s ref g o g o ! g o ! ! 垃圾 垃圾 已复制:0 个字符 已复制:2 个字符 已复制:2 + 3 = 5 个字符 用 sb.append('!') sb g o ! ! toString() g o ! ! 缓冲区 8 格,已用 2 格 同一缓冲区:已用 4 格,没有复制 toString():只复制一次,共 4 个字符 s = "go":一个 String 对象,s 引用它 s += "!":新建 String,把 "go" 复制进去 再次 s += "!":3 个字符全部复制进第三个 String StringBuilder 把 "go" 放进留有空位的缓冲区 sb.append('!') 两次:每个 ! 写进下一个空位 sb.toString() 把缓冲区一次复制成一个 String

每次 s += “!” 都会新建一个字符串,把旧字符串的每个字符复制过去,旧的那个成了垃圾。StringBuilder 把每个 ! 写进同一个缓冲区的空位,toString 只在最后复制一次。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. s 引用一个存着 "go"String
  2. s += "!" 分配一个三个字符的字符串,把 go 复制进去,再加上 !s 现在引用它,旧的 "go" 成了垃圾。复制了两个字符。
  3. 再次 s += "!",分配四个字符,先把 "go!" 的三个字符全部复制进去,再加上 !"go!" 也成了垃圾。为了构建四个字符,一共复制了 5 个字符。
  4. StringBuilder"go" 放在一个 8 个槽位的缓冲区里。
  5. 两次 sb.append('!'),先把 ! 写进第 3 个槽位,再写进第 4 个。始终是同一个缓冲区,什么都没有移动。
  6. sb.toString() 把这 4 个字符复制进一个新的 String,这是 builder 唯一的一次复制。

char 不一定是一个字符

Java 的 char 是一个 16 位的 UTF-16 代码单元,不是人眼看到的一个字母。大多数字母,包括带重音的字母,一个 char 就放得下。emoji 要占两个,叫作代理对,而 length() 统计的是 char 的个数:

void main() {
    String word = "café";
    String thumb = "👍";
    IO.println(word.length() + " " + thumb.length());
    IO.println(thumb.codePointCount(0, thumb.length()));
    IO.println(Character.isHighSurrogate(thumb.charAt(0)));
    IO.println(Integer.toHexString(thumb.codePointAt(0)));
    IO.println(Character.toString(0x1F44D));

    String text = "ok👍";
    IO.println(text.length() + " " + text.codePointCount(0, text.length()));
    IO.println(new StringBuilder(text).reverse());
    IO.println(text.substring(0, 3));
}

输出:

4 2
1
true
1f44d
👍
4 3
👍ko
ok?

码点是 Unicode 给每个字符编的号,这里是 U+1F44D。点赞手势是一个码点,存成两个 char,而 charAt(0) 只返回前一半,也就是“高位代理”。

最后两行让我们也吃了一惊。StringBuilder.reverse 会让代理对保持在一起,所以 emoji 完好无损。substring(0, 3) 把代理对从中间切开了,而落单的代理没有 UTF-8 编码,所以 Java 打印出 ?。在固定索引处截断用户文本,就可能这样把文本弄坏。

就连码点也不是全部。像 🇮🇳 这样的旗帜是两个码点,读者看到的却是一个符号。截断名字,或者按上限统计字符数时,要记住这一点。

文本块:不用转义的多行字符串

文本块是跨多行的字符串字面量,写在两组三个双引号之间。它在 Java 15 成为正式特性,javac --release 14 会拒绝它,报 text blocks are not supported in -source 14。有了它,JSON、SQL 和 HTML 都好读多了:

void main() {
    String json = """
        {
            "name": "Ana",
            "score": 95
        }
        """;
    IO.print(json);

    String shifted = """
            {
                "name": "Ana"
            }
        """;
    IO.print(shifted);

    String noNewline = """
        one line""";
    IO.println("[" + noNewline + "]");
}

输出:

{
    "name": "Ana",
    "score": 95
}
    {
        "name": "Ana"
    }
[one line]

里面的引号不需要反斜杠。缩进值得细看,因为源代码缩进了 8 个空格,第一个结果却没有缩进:

  • 附带缩进会被去掉。 Java 找出内容行和结尾 """ 那一行里最小的缩进,从每一行删去这么多空格。"name" 相对于 { 的缩进保留了下来。
  • 结尾的 """ 决定左边界。shifted 里,它比内容靠左 4 个空格,所以每一行都保留了 4 个空格。
  • 开头的 """ 后面必须换行。"""hello""" 写在一行里,会报 illegal text block open delimiter sequence, missing line terminator
  • 结尾的 """ 单独占一行,会多出一个末尾换行。noNewline 那样把它紧跟在文本后面,就不会有这个换行。

\ 续行,用 \s 保留空格

有两个转义只能在文本块里用。行尾的 \ 把这一行和下一行连起来,\s 是一个 Java 不会删掉的空格:

void main() {
    String sql = """
        SELECT name, score \
        FROM results \
        WHERE score > %d \
        ORDER BY score DESC""".formatted(90);
    IO.println(sql);

    String padded = """
        red  \s
        green\s
        """;
    IO.print(padded.replace(' ', '.'));
    IO.println("""
        She said "hi", then wrote \""" on the board.
        """.strip());
}

输出:

SELECT name, score FROM results WHERE score > 90 ORDER BY score DESC
red...
green.
She said "hi", then wrote """ on the board.

这段 SQL 在源代码里占四行,输出却是一行,因为每个 \ 都去掉了一个换行。formatted 在文本块上和在普通字符串上一样好用。

Java 会删除每行末尾的空格,因为编辑器会悄悄地加上或删掉它们。\s 是标记行尾的空格,所以它前面的空格得以保留,red 后面的点就说明了这一点。要在文本块里放三个引号,就把第一个转义成 \"""

数组:固定数量的槽位

数组存放固定数量、同一类型的值,数量在创建时就定下来。新槽位的初始值是该类型的零值,和字段的默认值一样:

void main() {
    int[] counts = new int[4];
    double[] prices = new double[2];
    boolean[] seen = new boolean[3];
    String[] names = new String[3];
    IO.println(Arrays.toString(counts));
    IO.println(Arrays.toString(prices));
    IO.println(Arrays.toString(seen));
    IO.println(Arrays.toString(names));

    int[] scores = {72, 95, 88};
    String[] days = new String[] {"Mon", "Tue"};
    scores[0] = 75;
    IO.println(scores.length + " " + days.length);
    IO.println(Arrays.toString(scores) + " " + Arrays.toString(days));
}

输出:

[0, 0, 0, 0]
[0.0, 0.0]
[false, false, false]
[null, null, null]
3 2
[75, 95, 88] [Mon, Tue]

{72, 95, 88} 创建并填充一个数组,长度由编译器来数。如果不是直接赋给一个声明好的变量,比如把数组传给方法,就需要完整写法 new String[] {...}

scores.length 后面没有括号:对数组来说,length 是字段,对字符串来说它是方法。数组不能变长。想要第五个计数,你得新建一个数组,再把内容复制过去。

直接打印数组,看到的是哈希值

数组没有重写 toString,所以 IO.println(scores) 打印的不是内容,而是 [I@ 加上一串十六进制数字。[I 是 Java 内部对“int 数组”的叫法,那串数字来自标识哈希码,每次运行都可能不同。String[] 打印出来是 [Ljava.lang.String;@ 加一串数字。

有个细节让我们也吃了一惊。System.out.println 有一个专门针对 char[] 的版本,会打印出字母。IO.println 接受任意 Object,所以 char[] 打印出来是 [C@ 加哈希值。请用 new String(letters)

索引越界会抛出异常

Java 会检查每一次数组访问,索引小于 0,或者大于等于 length,都会抛出 ArrayIndexOutOfBoundsException

void main() {
    int[] scores = {72, 95, 88};
    IO.println("first: " + scores[0]);
    IO.println("last: " + scores[scores.length - 1]);
    IO.println("before first: " + scores[-1]);
}

输出后停止:

first: 72
last: 88
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 3

负数索引不像在 Python 里那样从末尾往前数。讲控制流的那一篇介绍了另一个常见原因:循环条件用了 <=,而不是 <

Arrays 工具类

数组本身没有排序、填充、复制和比较这些方法,java.util.Arrays 把它们补上了。紧凑源文件里用它不需要 import:

void main() {
    int[] scores = {88, 72, 95, 61};
    int[] sorted = Arrays.copyOf(scores, scores.length);
    Arrays.sort(sorted);
    IO.println(Arrays.toString(scores) + " -> " + Arrays.toString(sorted));

    String[] words = {"pear", "apple", "Banana"};
    Arrays.sort(words);
    IO.println(Arrays.toString(words));

    int[] bigger = Arrays.copyOf(scores, 6);
    int[] smaller = Arrays.copyOf(scores, 2);
    IO.println(Arrays.toString(bigger) + " " + Arrays.toString(smaller));
    IO.println(Arrays.toString(Arrays.copyOfRange(scores, 1, 3)));

    char[] line = new char[10];
    Arrays.fill(line, '-');
    IO.println(new String(line));

    int[] a = {1, 2, 3};
    int[] b = {1, 2, 3};
    int[] c = a;
    IO.println((a == b) + " " + a.equals(b) + " " + Arrays.equals(a, b) + " " + (a == c));
}

输出:

[88, 72, 95, 61] -> [61, 72, 88, 95]
[Banana, apple, pear]
[88, 72, 95, 61, 0, 0] [88, 72]
[72, 95]
----------
false false true true

逐行来看:

  • Arrays.sort 原地排序,不返回任何东西,所以程序先复制了一份 scores
  • 字符串按 compareTo 排序,所以 "Banana" 因为大写的 B 排在最前面。
  • copyOf 会用零值补齐,或者截掉末尾。 copyOfRangesubstring 一样,遵循不含结尾的规则。
  • fill 把每个槽位都设成同一个值。
  • ==a.equals(b) 比较的都是引用,因为数组的 equals 继承自 ObjectArrays.equals 比较的是元素。a == ctrue,因为 c = a 复制的是引用。

二维数组

Java 没有真正的网格类型。二维数组就是元素为数组的数组,所以它的每一行可以长度不同:

void main() {
    int[][] grid = new int[2][3];
    grid[1][2] = 7;
    IO.println(grid.length + " rows, " + grid[0].length + " columns");
    IO.println(Arrays.toString(grid[1]));
    IO.println(Arrays.deepToString(grid));

    int[][] triangle = {{1}, {1, 1}, {1, 2, 1}};
    for (int[] row : triangle) {
        IO.println(row.length + ": " + Arrays.toString(row));
    }

    int[][] first = {{1, 2}, {3, 4}};
    int[][] second = {{1, 2}, {3, 4}};
    IO.println(Arrays.equals(first, second) + " " + Arrays.deepEquals(first, second));
}

输出:

2 rows, 3 columns
[0, 0, 7]
[[0, 0, 0], [0, 0, 7]]
1: [1]
2: [1, 1]
3: [1, 2, 1]
false true

grid[1][2] 表示第 1 行,再取这一行的第 2 列。grid.length 统计行数,grid[0].length 是第 0 行的长度。

最后一行常让人栽跟头。Arrays.equals== 比较外层元素,也就是各行数组,所以结果是 false。嵌套数组要用 Arrays.deepEquals,就像打印它们要用 Arrays.deepToString 一样。

用数组还是 List

数组适合大小固定且已知的场景:64 个棋盘格、像素数据,或者 split 返回的 String[]。要存会增减的集合,就用 List。它能自动调整大小,能打印出内容,equals 也能正常工作:

void main() {
    String[] fixed = {"Ana", "Ben"};
    var names = new ArrayList<String>(List.of(fixed));
    names.add("Chen");
    IO.println(names + " " + names.size());

    List<String> view = Arrays.asList(fixed);
    view.set(0, "Zoe");
    IO.println(fixed[0]);
    view.add("Dev");
}

输出后停止:

[Ana, Ben, Chen] 3
Zoe
Exception in thread "main" java.lang.UnsupportedOperationException

new ArrayList<>(List.of(fixed)) 把数组复制进一个可增长的列表。Arrays.asList 什么也不复制。它把数组包装成一个 List 视图,所以 setfixed[0] 也改了,而 add 会抛出异常,因为数组不能变长。

List<int> 是不允许的,所以数字列表里存的是装箱后的 Integer 对象,而 int[] 直接存数字本身。讲 equalshashCode 和集合的那一篇会系统地介绍 ListSetMap

要点

  • String 永远不会改变。单独写一句 s.toUpperCase() 什么也不做,要把结果存下来。
  • substring(begin, end)end 之前停下。split 的参数是正则表达式,而且会丢掉末尾的空字符串。
  • equals 比较字符串。compareTo 把大写字母排在小写字母前面。
  • 在循环里用 +=,每一轮都要复制整个字符串,代价是 O(n²)。改用 StringBuilder,最后调用一次 toString
  • char 是一个 UTF-16 代码单元。一个 emoji 的 length() 是 2,substring 可能把它从中间切开。
  • 文本块会去掉缩进,以结尾的 """ 为准。\ 连接两行,\s 保留行尾空格。
  • 数组长度固定。用 Arrays.toString 打印,用 Arrays.equals 比较,大小会变时就用 List

对 String 的每次修改都会产生新的 String,所以不断增长的文本要放在 StringBuilder 里构建。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。