Java 解惑系列(二): 这个看似简单的字符串拼接问题,你还记得么

接上文,我们来接着看这些小问题。

问题一:字符串拼接问题

来看下下面的程序将会打印什么:

public static void main(String[] args) {
    System.out.println("H" + "a");
    System.out.println('H' + 'a');

    int three = 3;
    int four = 4;
    String str = "XX";
    System.out.println(three + four + str);
    System.out.println(three + str + four);
    System.out.println(str + three + four);
}

问题比较简单,这里就直接给出答案了,分别是: Ha
169
7XX
3XX4
XX34
,是不是和预期的结果相同,如果不是的话,那就可以来简单看下下面的内容。

解惑1:

针对前两条打印结果和后三条打印结果,我们分开来学习,先说下前两条:

针对第一个输出,比较简单,执行字符串拼接,打印 Ha
,这里没什么好说的;而第二个打印的结果是 169
,由于这里的两个参数都不是字符串,所以这里执行的是加法操作而不是字符串拼接操作;

  • 编译器
    计算常量表达式
    'H'+'a'
    时,是通过我们所了解的扩展转换的方式进行操作的(【JLS5.1.2,5.6.2】),将两个字符型数值的操作提升为两个int数值进行实现的;

  • 从char到int的扩展,上篇文章已经说过,16位零扩展为32位, H
    的数值是 72
    ,而 a
    的数值是 97
    ,因此表达式的结果是两者相加之后的结果,是 169

再来看下后面三条输出结果:

这三条语句,操作数是相同的,只是类型的顺序是不同的,但结果却是不同的。因为,当执行 +
运算时,程序并不是从最开始就计算出整个表达式的结果类型,而是从左到右依次计算,在计算过程中遇到了特殊的类型时,才进行必要的转换:

  1. 第一个表达式, three + four + str
    ,首先计算 three + four
    ,结果是 7
    ,然后发现 str
    是字符串类型,接着将 7
    转换为字符串(因为字符串与其他类型进行 +
    操作时,会将其他类型转换为字符串 (toString)),执行字符串拼接操作;

  2. 第二个表达式, three + str + four
    ,先将 three
    转换为字符串,然后与 str
    进行拼接,然后再将 four
    转换为字符串,执行拼接操作;第三个表达式也是同样的操作。

我们可以再延伸一下,再来看下下面的内容会打印什么呢?

public static void main(String[] args) {
    String names = "ABC";
    char[] numbers = {'1', '2', '3'};
    System.out.println(numbers);
    System.out.println(names + numbers);
}

这个比较简单,或许你一眼就看出来了,打印的分别是: 123
ABC[C@4554617c
,我们来简单说下:

  • 因为 numbers
    是一个数组类型,这里拼接的时候,自然会调用数组的toString方法,而数组则是继承了Object的toString方法,这一点Java编程规范【JLS10.7】中也有描述到:
    这里会返回一个字符串,包含了该对象所属类的名字, @
    符号,以及表示对象散列码的一个无符号十六进制整数

  • 而在 Class.getName
    中也有描述:在char[]类型的类对象上调用该方法的字符串是 [c

  • 直接打印数组是可以打印出我们所需要的字符串的,因为直接打印调用的是重载的 println(char x[])
    方法;如果我们想在拼接的操作中也打印我们所需要的字符串的话,可以将借助于 String.valueof(char[])
    方法将char数组转为String:

    System.out.println(names + String.valueOf(numbers));
    

到这里,我们就介绍完了,针对上面的 +
操作,我们可以简单总结下:

  1. 当且仅当 +
    操作符的操作数中至少有一个是String类型时,才会执行字符串连接操作;否则,执行的是加法操作;

  2. 当执行 +
    操作时,程序是从左到右依次计算,在计算过程中遇到了对应的类型,才会进行必要的类型转换;并且当 +
    操作符针对对象类型的时候,会调用该对象的toString方法,如果对象为 null
    或者调用toString方法的返回结果也是 null
    ,则将null转换为字符串 null

备注:这里除了包含《Java解惑》,还包含了一点《细说Java》中的内容。

问题二:byte数组转换问题

看下下面的程序,最终会打印什么:

public static void main(String[] args) {
    byte bytes[] = new byte[256];
    for (int i = 0; i < 256; i++) {
        bytes[i] = (byte) i;
    }
    String str = new String(bytes);
    for (int i = 0, n = str.length(); i < n; i++) {
        System.out.print((int) str.charAt(i) + " ");
    }
}

该程序的目的可能稍微有些繁琐,首先将0-255中每个数值初始化到一个byte数组,然后将这些byte数值通过String构造方法转换成char数值,最后将char数值转型为int并打印。正常情况下,我们是希望打印0-255的整数的,但运行该程序,结果可能并不是我们想的那样。我本地运行的结果如下:

0 1 2 3 4 5 6 ... 125 126 127 65533 65533 65533 65533 65533 65533 ... 65533 65533

解惑2:

在不同的机器上运行,我们或许会看到不同的结果,申请极端情况下,这个程序都不能保证能正常终止,这是为什么呢?问题就在于String的构造方法 Strng(byte[])
,我们可以看下Java-API中对它的描述:

Constructs a new String by decoding the specified array of bytes using the platform's default charset. The length of the new String is a function of the charset, and hence may not be equal to the length of the byte array. The behavior of this constructor when the given bytes are not valid in the default charset is unspecified. The CharsetDecoder class should be used when more control over the decoding process is required.

简而言之就是说,如果我们没有指定具体的字符集,将使用系统缺省的字符集。但如果给定的字节在缺省字符串中并非全部有效时,那这个构造方法的最终结果将是不确定的。我们可以通过调用 java.nio.charset.Charset.defaultCharset()
来查询我们机器的缺省字符集。

System.out.println(java.nio.charset.Charset.defaultCharset());

在该程序中, ISO-8859-1
是唯一能够让该程序按顺序打印从0到255的整数的字符集,也就是大家所熟知的 Latin-1

String str = new String(bytes, "ISO-8859-1");

声明这个构造方法会抛出异常UnsupportedEncodingException,需要捕获或抛出一下。
这个问题告诉我们:
每当要将一个byte序列转换成一个String时,你都在使用一个字符集,不管你是否显示的指定了它。如果想让我们的程序的代码可预知,那么我们在每次使用时都应该明确的指定字符集。

问题三:打印类的名称

接下来我们将使用下面的程序来打印我们的程序,来看看这个代码是否能正确打印呢:

public static void main(String[] args) {
    System.out.println(Main.class.getName().replaceAll(".", "/") + ".class");
}

我们的目的很简单,获取到类的全路径 com.test.Main
,然后用 /
替换掉出现的字符串 .
,并在末尾追加字符串 .class
,所以我们预期程序应该会打印 com.test.Main.class
,但实际上运行之后发现它打印的是 /////////////.class

解惑3:

很显然,熟悉 正则表达式
的你一眼就看出来了问题所在,那就是:

replaceAll
的第一个参数是一个正则表达式,而不是一个字符串的常量,而正则表达式 .
可以匹配任何单个的字符。因此,类名中的每一个字符都被替换为了 /
,这也就是我们最终的输出结果了。
那如何操作,才能满足我们所要的结果呢?

根据正则表达式,我们可以在所要替换的 .
前面添加固定的转义字符 \
,但因为反斜杠在字符串中有特殊的含义:它标志转义字符序列的开始。因此反斜杠自身必须要用另一个反斜杠来转义,我们来看下:

System.out.println(Main.class.getName().replaceAll("\\.", "/") + ".class");

不过在jdk5.0之后,提供了新的静态方法 Pattern.quote
,该方法接受一个字符串作为参数,并可以添加必须的转义字符,返回一个正则表达式字符串,该字符串将精确匹配输入的字符串:

System.out.println(Main.class.getName().replaceAll(Pattern.quote("."), "/") + ".class");

不过对该程序而言还有另外一个小问题:如果一个系统的分隔符号不是使用 /
来分隔层次结构的话,那我们该如何进行分隔呢?所以,想获取一个正在运行的系统上的有效文件名,我们应该使用该系统对应的分隔符号来代替斜杠符号。

这时候我们需要借助于 File.separator
,该变量会返回平台对应的分隔符,那么基于此,我们可以编写代码如下:

System.out.println(Main.class.getName().replaceAll("\\.", File.separator) + ".class");

在Unix上,或许它能正确运行,不过在我的Windows上,报错如下:

Exception in thread "main" java.lang.IllegalArgumentException: character to be escaped is missing     at java.util.regex.Matcher.appendReplacement(Matcher.java:809)     at java.util.regex.Matcher.replaceAll(Matcher.java:955)     at java.lang.String.replaceAll(String.java:2223)     at com.test.Main.main(Main.java:11)

这个异常表示缺少要转义的字符。这里又引出了使用 Matcher
的另外一个问题,也就是 Matcher.appendReplacement
方法的使用有一个注意项,在该方法的API文档有详细的说明,我们这里就简单来说一下:

就是replaceAll的第二个参数是作为替换字符串(replacement string),而替换字符串中的反斜杠(\)和美元符号($)是有特殊用途的,美元符号可被视为对捕获的子序列的引用,而反斜杠则是用于将紧随其后的字符进行转义的。而在Windows中,替代字符串是一个单独的反斜杠,后面没有任何要转义的字符,那么它在这里自然就会报错了;可以简单看下原码:

public Matcher appendReplacement(StringBuffer sb, String replacement) {

    // 省略
    int cursor = 0;
    StringBuilder result = new StringBuilder();

    while (cursor < replacement.length()) {
        char nextChar = replacement.charAt(cursor);
        if (nextChar == '\\') {
            cursor++;
            if (cursor == replacement.length())
                throw new IllegalArgumentException(
                    "character to be escaped is missing");
            nextChar = replacement.charAt(cursor);
            result.append(nextChar);
            cursor++;
        } else if (nextChar == '$') {
            // Skip past $
            cursor++;
    // 省略
}

那么,要解决这个问题,可以使用jdk5.0之后引入的两个方法,首先是 Matcher.quoteReplacement
,它会将字符串转换成相应的替代字符串:

System.out.println(Main.class.getName().replaceAll("\\.", Matcher.quoteReplacement(File.separator)) + ".class");

当然,我们也可也使用另一个jdk5.0之后引入的方法,就是 String.replace(CharSequence, CharSequence)
,它做的内容和 replaceAll
相同,只是将参数都作为字符串字面常量进行处理:

System.out.println(Main.class.getName().replace(".", File.separator) + ".class");

至此,这个问题就告一段落了。

问题四:URL的问题

我们来看下下面这个程序,将会打印什么?

public static void main(String[] args) {
    System.out.print("iexplore:");
    http://www.google.com;
    System.out.println(":maximize");
}

你是不是觉得会编译不通过呢?但结局可能未必如你所愿,该程序可以正常编译运行,打印结果是: iexplore::maximize
。这里涉及到了Java中一个鲜为人知的特性: Labeled Statements

解惑4:

与C和C ++不同,Java编程语言没有goto语句,而Java中有类似操作的就是 Labeled Statements
,可以称为标签语句:

  • Java 中的标签是为循环设计的,是为了在多重循环中方便的使用 break 和coutinue;
  • 正常情况下,我们如果使用标签的话,都是用于有循环嵌套时,想从多层嵌套中break或continue;
  • 像本例中的标签,虽然能正常编译及运行,但却没什么用;

标签的用途如下:

continue;          // 跳转到循环开头,接着执行
break;             // 中断并跳出当前循环
continue label:    // 到达标签的位置,并重新进入紧接着标签的循环
break label;       // 中断并跳出标签所紧挨着的循环

来看一个简单的例子即可:

public static void main(String[] args) {
    int i = 0;
    outer:
    while (true){
        i++;
        System.out.println("外层循环");
        while (true) {
            if (i == 1) {
                System.out.println("内层循环:break");
                break outer;
            }
            if (i == 2) {
                System.out.println("内层循环:continue");
                continue outer;
            }
        }
    }
    System.out.println("main ...");
}

这个操作中的 continue outer
只是用于示范,没什么用途,最终会打印为:

外层循环
内层循环:break
main …
对于标签,如果非要用的话,那标签其实可以用在任何语句前面的,只不过没有什么用途,现在也早就不再建议使用了,不过我们需要知道Java中有这么一个特性。

问题五:不劳而获的问题

下面的程序将打印一个单词,其首字母由一个随机数生成器生成,请猜测一下该程序会打印什么?

public class Main {
    private static Random rnd = new Random();

    public static void main(String[] args) {
        StringBuffer word = null;
        switch (rnd.nextInt(2)) {
            case 1: word = new StringBuffer('P');
            case 2: word = new StringBuffer('G');
            default: word = new StringBuffer('M');
        }
        word.append('a').append('i').append('n');
        System.out.println(word);
    }
}

很显然,你一眼就能看出来, case
表达式后面没有跟 break
,那最终会打印 Pain
还是 Gain
还是 Main
呢?很遗憾,最终的结果并不会随着随机数的变化而变化,而是一直是 ain

解惑5:

在该程序中,总共有3个bug一起导致了这个结果的产生。

  • 首先,随机数 Random.nextInt(int)
    的范围是0(包含)到指定的范围(不包含)之间的一个数值,也就是左开右闭的。那就意味着表达式 rnd.nextInt(2)
    可能的取值只能是0和1,switch中则是永远无法到达 case 2
    分支,这表示程序永远不会打印 Gain
    ,所以 nextInt
    的参数应该是3;

  • 其次,case表达式没有跟break语句,那么无论switch为何值,该程序都将执行其对应的case以及所有后续的case;所以在本例中,总是最后的赋值覆盖掉前面的赋值,也就是最终的结果总是default语句;
  • 最有一个bug比较微妙,表达式 new StringBuffer('M')
    ,构造方法中的参数是char类型,你可能对 StringBuffer(char)
    构造方法不太熟悉,因为它根据不存在,也就是说 StringBuffer 中根本没有 StringBuffer(char)
    这个构造方法。StringBuffer中有一个无参的构造器,还有一个String作为参数的构造器,还有一个int作为参数的构造器。

  • 那么在本例中,自然是使用了int类型的构造器,而int类型的构造方法则是用于构造一个包含初始容量的StringBuffer;
  • 使用了int类型的构造器后,字符数值 M
    会转换为int数值 77
    ,换句话说, new StringBuffer('M')
    返回的是一个具有初始容量为77的StringBuffer;

那么该程序最终会创建一个初始容量为77的StringBuffer,然后将剩下的字符 a
i
n
给添加到这个StringBuffer中。
知道了这点之后,代码就比较容易好改了,按照上面的操作,简单改下:

public static void main(String[] args) {
    StringBuffer word = null;
    switch (rnd.nextInt(3)) {
        case 1: {word = new StringBuffer("P");break;}
        case 2: {word = new StringBuffer("G");break;}
        default: word = new StringBuffer("M");
    }
    word.append('a').append('i').append('n');
    System.out.println(word);
}

不过该程序有点长,我们可以简单优化下:

System.out.println("PGM".charAt(rnd.nextInt(3)) + "ain");

当然,我们可以写的更加通用些,让它不依赖于所有可能的输入只是首字母不同:

public static void main(String[] args) {
    String a[] = {"Main", "Pain", "Gain"};
    System.out.println(randomElement(a));
}
private static String randomElement(String[] a) {
    return a[rnd.nextInt(a.length)];
}

至此,这里的内容也告一段落。