likes
comments
collection
share

发现了 Java 的 Bug?为什么忘记注释的 URL 不报错,注释掉的 println 还能执行

作者站长头像
站长
· 阅读数 20

闲言少叙,我们先来看这样一段 Java 代码:

乍看之下,这段代码应该无法通过编译,因为第 4 行的 URL 要么应该写在注释中,要么应该用引号括起来,作为一个字符串,如 String url = "https://...";

然而,实际编译一下就会发现,竟然没有报错!不但没有报错,执行 java HelloWorld 甚至还能输出 Hello, world!

就是这么神奇,可这是为什么呢?难道 Java 出 Bug 了?

IDE 给出了一些提示

如果把这段代码放入一些高级的 IDE(如 IntelliJ IDEA)应该能得到一些提示:

发现了 Java 的 Bug?为什么忘记注释的 URL 不报错,注释掉的 println 还能执行

当把光标停留在背景为土黄色的 \u000a000a 是换行符在 Unicode 字符集中的编码)上时,IDE 会弹出一个小窗口,提示我们这个 \u000a 可以被替换成换行符(line feed):

发现了 Java 的 Bug?为什么忘记注释的 URL 不报错,注释掉的 println 还能执行

若点击左下角蓝色的“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 类)的分析结果。

这段代码的分析结果如下所示(构造词法分析器的代码见文末,这里用了个小技巧把注释保留了下来):

这里的每一行都是一个词法单元,由此可以看出,

  • classpublic 等关键词都是词法单元
  • 类名(HelloWorldSystem 等)、方法名或属性名(mainout 等)以及方法的参数(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
评论
请登录