发现了 Java 的 Bug?为什么忘记注释的 URL 不报错,注释掉的 println 还能执行
闲言少叙,我们先来看这样一段 Java 代码:
乍看之下,这段代码应该无法通过编译,因为第 4 行的 URL 要么应该写在注释中,要么应该用引号括起来,作为一个字符串,如 String url = "https://...";
。
然而,实际编译一下就会发现,竟然没有报错!不但没有报错,执行 java HelloWorld
甚至还能输出 Hello, world!
!
就是这么神奇,可这是为什么呢?难道 Java 出 Bug 了?
IDE 给出了一些提示
如果把这段代码放入一些高级的 IDE(如 IntelliJ IDEA)应该能得到一些提示:
当把光标停留在背景为土黄色的 \u000a
(000a 是换行符在 Unicode 字符集中的编码)上时,IDE 会弹出一个小窗口,提示我们这个 \u000a
可以被替换成换行符(line feed):
若点击左下角蓝色的“Replace with line feed character”,则会发现代码被调整成了:
似乎接近真相了哦,我们已经发现,
- URL 中
//
及其之后的部分被识别成了注释 - Java 的编译器在分析单行注释时,一旦遇到
\u000a
这个字符序列,就认为单行注释结束了,\u000a
之后的内容是要继续解析的代码。这等同于将\u000a
前后的代码分别写在两行上 https:
这样的单词+引号的组合可以出现在一行的开头,也就是说这样写没有语法错误(但这表示什么呢?)
这些结论是靠 IDE 给出的线索推理出来的,为了验证正确性,我们还需要知道 Java 的编译器是如何分析这段代码的。
利用 Java 的编译器探索真相
Java 的代码要想转换成 .class
文件,就必须依次经过词法分析器和语法分析器的处理。
词法分析器(lexer,也叫作 scanner)的主要任务是将 Java 代码转换成一系列的词法单元(token,也叫作符号或记号)。关键词、标识符、常量和操作符等都是词法单元。而语法分析器(parser)的任务是检查词法单元的序列是否符合语法规则,并将它们转换为一种称为语法树的内部结构。词法分析器和语法分析器都是 Java 编译器的重要组成部分。
Java 内置了一个名为 com.sun.tools.javac.parser
的包,里面包含了用 Java 语言本身实现的 Java 语言的词法分析器和语法分析器。
由于注释是在词法分析阶段处理的,且处理方法通常是丢弃,所以我们先来看看 Java 内置的词法分析器(com.sun.tools.javac.parser.Scanner
类)的分析结果。
这段代码的分析结果如下所示(构造词法分析器的代码见文末,这里用了个小技巧把注释保留了下来):
这里的每一行都是一个词法单元,由此可以看出,
class
、public
等关键词都是词法单元- 类名(
HelloWorld
、System
等)、方法名或属性名(main
、out
等)以及方法的参数(args
)都是类型为identifier
的词法单元 - 各种符号也是词法单元
- 字符串“Hello, world!”整体是一个类型为
string
的词法单元 https
也是一个类型为identifier
的词法单元
请注意 System(token.identifier)
这个词法单元,它关联了 2 个注释,一个没有内容(其实包含 1 个空格, \u000a
相当于按下了回车键,表示单行注释结束,所以这里只剩下 1 个空格了);另一个注释是 URL 中 “https:
” 之后的部分。这与刚刚根据 IDE 给出的线索得到的结论一致!
现在还剩一个遗留问题,行首孤零零的 https:
为什么不算语法错误呢?
我们很可能被 “https” 这个单词迷惑了,认为这就是 URL 的协议,应该用引号括起来。但其实只要把它换作其他单词,很可能一下就明白了,比如:
// 外部循环的标签
outerLoop:
for (int i = 1; i <= 3; i++) {
System.out.println("Outer loop i: " + i);
for (int j = 1; j <= 3; j++) {
System.out.println(" Inner loop j: " + j);
if (j == 2) {
// 使用 break 标签跳出外部循环
break outerLoop;
}
}
}
Java 支持通过 break
和标签(label)的结合来跳出多重嵌套循环,https:
和 outerLoop:
一样,也只不过是个标签!Java 内置的语法分析器也写明了如何处理由单词和引号构成的标签,相关代码在这里 https://github.com/openjdk/jdk/blob/master/src/jdk.compiler/share/classes/com/sun/tools/javac/parser/JavacParser.java#L2814。
现在真相大白了,忘记注释的 URL 确实不算语法错误,而且多亏(就赖) \u000a
,“注释掉“的语句也确实还能执行,这些都不是 Java 的 Bug。
\u000a
和 \n
是不是同一个字符呢?如果试着用 System.out.println('\u000a' == '\n');
判断一下,你觉得会是什么结果呢,得到这样的结果算是 Bug 吗?
使用 Java 内置的词法分析器分析 HelloWorld.java
的代码
import com.sun.tools.javac.parser.*;
import com.sun.tools.javac.util.*;
import java.io.*;
import java.nio.file.Files;
public class DebugScanner {
public static void main(String[] args) throws IOException {
// 构造 ScannerFactory
Context ctx = new Context();
ScannerFactory scannerFactory = ScannerFactory.instance(ctx);
// 要分析的源文件
File sourceFile = new File("HelloWorld.java");
CharSequence input = new String(Files.readAllBytes(sourceFile.toPath()));
boolean keepDocComments = false;
// 构造 Scanner,它实现了 Lexer 接口
Lexer lexer = scannerFactory.newScanner(input, keepDocComments);
MyJavaTokenizer myTokenizer = new MyJavaTokenizer(scannerFactory, input.toString().toCharArray(), input.length());
// 逐个读取 token
lexer.nextToken();
Tokens.Token token = lexer.token();
while (token.kind != Tokens.TokenKind.EOF) {
if (token.kind == Tokens.TokenKind.IDENTIFIER) {
System.out.println(token.name() + "(" + token.kind + ")");
if (token.comments != null) {
System.out.println("\tAssociated Comments: " + token.comments.size());
for (Tokens.Comment comment : token.comments) {
System.out.print("\t");
myTokenizer.showBasicComment(comment);
}
}
} else if (token.kind == Tokens.TokenKind.STRINGLITERAL) {
System.out.println(token.stringVal() + "(" + token.kind + ")");
} else {
System.out.println(token.kind);
}
lexer.nextToken();
token = lexer.token();
}
}
}
// 小技巧,为了访问能够访问 BasicComment 上的方法,我们需要继承 JavaTokenizer
// 因为 BasicComment 是 JavaTokenizer 的 protected 的内部类
class MyJavaTokenizer extends JavaTokenizer {
public MyJavaTokenizer(ScannerFactory scannerFactory, char[] array, int length) {
super(scannerFactory, array, length);
}
public void showBasicComment(Tokens.Comment comment) {
BasicComment basicComment = (BasicComment) comment;
System.out.println(basicComment.getRawCharacters());
}
}
// $ javac --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED DebugScanner.java
// $ java --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED DebugScanner
转载自:https://juejin.cn/post/7393192749405192229