Статьи

Java, Unicode и таинственная ошибка компиляции

Unicode — это стандарт кодирования текста, который поддерживает широкий диапазон символов и символов. Хотя последняя версия стандарта — 9.0, JDK 8 поддерживает Unicode 6.2, и ожидается, что JDK 9 будет выпущен с поддержкой Unicode 8.0 . Java позволяет вставлять любые поддерживаемые символы Unicode с экранированием Unicode. По сути, это последовательность шестнадцатеричных цифр, представляющих кодовую точку . В этой статье я расскажу о том, как использовать экранирование Unicode в Java и как избежать необъяснимых ошибок компилятора, вызванных неправильным использованием экранирования Unicode.

Что такое Unicode Escape ?

Начнем с самого начала. Экранирование Unicode используется для представления символов Unicode только с символами ASCII. Это пригодится, когда вам нужно вставить символ, который не может быть представлен в наборе символов исходного файла. В соответствии с разделом 3.3 Спецификации языка Java (JLS) экранирование Юникода состоит из символа обратной косой черты (\), за которым следуют один или несколько символов «u» и четыре шестнадцатеричных цифры.

UnicodeEscape:
    \ UnicodeMarker HexDigit HexDigit HexDigit HexDigit

UnicodeMarker:
    u
    UnicodeMarker u

Так, например, \u000A

Пример использования

Ниже приведен фрагмент кода Java, содержащий escape-код Unicode.

 public class HelloUnicode {
    public static void main(String[] args) {
        // \u0055 is a Unicode escape for the capital U character (U)
        System.out.println("Hello \u0055nicode".length());
    }
}

Найдите минутку, чтобы подумать о том, что будет распечатано. Если вы хотите, скопируйте и вставьте код в новый файл, скомпилируйте и запустите его.

На первый взгляд, похоже, что программа печатает 18 Между двойными кавычками 18 символов, поэтому длина строки должна быть 18. Но если вы запустите программу, вы получите 13 Как следует из комментария, экранирование Unicode будет заменено одним символом.

Осознавая, что экранированные символы Юникода заменяются соответствующими символами Юникода, давайте рассмотрим следующий пример.

 public class NewLine {
    public static void main(String[] args) {
        // \u000A is a unicode escape for the line feed (LF)
        // \u0055 is a Unicode escape for the capital U character (U)
        System.out.println("Hello \u0055nicode".length());
    }
}

Можете ли вы угадать, что будет распечатано сейчас? Ответ должен быть таким же, как и раньше, верно? Я уверен, что некоторые из вас могут подозревать, что это вопрос с подвохом, и на самом деле это так. Этот пример не будет компилироваться вообще.

 $ javac NewLine.java
NewLine.java:3: error: ';' expected
        // \u000A is a unicode escape for the line feed (LF)
                      ^
NewLine.java:3: error: ';' expected
        // \u000A is a unicode escape for the line feed (LF)
                                     ^
NewLine.java:3: error: '(' expected
        // \u000A is a unicode escape for the line feed (LF)
                                         ^
NewLine.java:3: error: ';' expected
        // \u000A is a unicode escape for the line feed (LF)
                                                  ^
NewLine.java:3: error: ';' expected
        // \u000A is a unicode escape for the line feed (LF)
                                                            ^
NewLine.java:5: error: ')' expected
        System.out.println("Hello \u0055nicode".length());
                                                         ^
6 errors

Какая!? Так много ошибок! Моя IDE не показывает волнистые красные линии, и я не могу найти синтаксические ошибки самостоятельно. Ошибка в строке 3? Но это комментарий. Что здесь происходит?

Что вызвало ошибку?

Чтобы лучше понять, что происходит, нам нужно взглянуть на раздел 3.2 Спецификации языка Java — Лексические переводы . Я не могу говорить о всех компиляторах, которые когда-либо существовали, но обычно первая задача компилятора — взять исходный код программы, обработать его как последовательность символов и создать последовательность токенов.

Токен — это то, что имеет значение в контексте языка. Например, в Java это может быть зарезервированное слово ( publicclassinterfaceоператор ( +>>литерал (нотация для представления фиксированного значения). Процесс генерации токенов из последовательности символов называется лексическим анализом (или лексическим переводом, как его называют в документации Oracle), а выполняемая программа называется лексером или токенизатором .

Спецификация языка Java говорит, что лексический перевод выполняется в следующих 3 шагах, где каждый шаг применяется к результату предыдущего шага:

  1. Перевод Unicode ускользает.
  2. Разделите поток входных символов на строки, распознавая ограничители строки (LF, CR или CR LF).
  3. Откажитесь от пробелов и комментариев и токенизируйте результат предыдущего шага.

Как видите, самый первый шаг процесса Unicode обрабатывает побеги. Это делается до того, как у компилятора появится возможность разделить исходный код на токены. Вообще говоря, это похоже на применение функции поиска и замены в исходном коде, замену всех правильно сформированных экранированных символов Юникода соответствующими символами Юникода, а затем разрешение компилятору работать с остальной частью кода.

Имейте в виду, что при обработке экранирования Unicode компилятор не отличает комментарии от реального кода. Он может видеть только последовательность символов. И это объясняет ошибочный код, который вы видели во введении этого поста. Давайте посмотрим на это снова.

 //This is the original source code
public class NewLine {
    public static void main(String[] args) {
        // \u000A is a unicode escape for the line feed (LF)
        // \u0055 is a Unicode escape for the capital U character (U)
        System.out.println("Hello \u0055nicode".length());
    }
}

//This is what it looks like after Unicode escapes have been processed
public class NewLine {
    public static void main(String[] args) {
        //
 is a unicode escape for the line feed (LF)
        // U is a Unicode escape for the capital U character (U)
        System.out.println("Hello Unicode".length());
    }
}

Экран Unicode, представляющий символ перевода строки, заменяется переводом строки, и теперь часть комментария находится на новой строке. К сожалению, новая строка не начинается с двойной косой черты ( // Отсюда запутанная ошибка компилятора, показанная ранее.

Быстрый обход : native2ascii

Вы можете поиграть с преобразованием Unicode самостоятельно. До Java 8 JRE поставляется с инструментом под названием native2ascii , который преобразует файл с символами в любой поддерживаемой кодировке символов в файл с экранированием ASCII и / или Unicode, или наоборот.

 $ native2ascii -reverse NewLine.java

public class NewLine {
    public static void main(String[] args) {
        //
 is a unicode escape for the line feed (LF)
        // U is a Unicode escape for the capital U character (U)
        System.out.println("Hello Unicode".length());
    }
}

А как насчет Java 9 (и позже)? До этого в файлах свойств Java по умолчанию использовался набор символов ISO-8859-1 . Символы, которые не могут быть представлены в ISO-8859-1, преобразуются в экранированные символы Юникода с помощью инструмента native2ascii . Но JEP 226 меняет это, и теперь файлы свойств могут быть закодированы в UTF-8, что означает, что инструмент native2ascii больше не нужен.

В этом посте native2ascii используется для демонстрации того, как будет выглядеть исходный файл Java, если экранированные символы Юникода будут заменены действительными символами Юникода. Для пользователей Java 9 я рекомендую использовать uni2ascii , который может достичь того же результата.

 
# uni2ascii package consists of two programs: uni2ascii and ascii2uni.
# Commandline argument -a U specifies the format of Unicode escapes
# which matches the one used in Java
ascii2uni -a U NewLine.java

Сокрытие кода в комментариях

Если экранирование Unicode обрабатывается раньше, чем все остальное, то могу ли я хитро спрятать код внутри комментариев, которые позже будут выполнены? Несколько пугающий ответ на этот вопрос — да. Оглядываясь на предыдущий пример, мы увидели, что был вставлен перевод строки, а оставшаяся часть комментария была на следующей строке, что привело к некорректному Java-коду. Но мы могли бы написать следующее

 public class HidingCode {
    public static void main(String[] args) {
        //\u000A System.out.println("This is a comment");
        System.out.println("Hello world");
    }
}

Если экранирование Unicode заменяется переводом строки, то должно быть ясно, что на самом деле выполняются два оператора печати.

 $ native2ascii -reverse HidingCode.java
public class HidingCode {
    public static void main(String[] args) {
        //
 System.out.println("This is a comment");
        System.out.println("Hello world");
    }
}
$ javac HidingCode.java
$ java HidingCode
This is a comment
Hello world

Почему Java это позволяет?

Это все кажется странным, верно? Почему Java так разработана? Это ошибка, которая была случайно введена и никогда не исправлялась, потому что это могло сломать что-то еще? Чтобы найти ответ на этот вопрос, нам нужно взглянуть на раздел 3.1 и раздел 3.3 Спецификации языка Java (JLS) .

Из раздела 3.1:

Язык программирования Java представляет текст в последовательностях 16-битных кодовых единиц, используя кодировку UTF-16.

Из раздела 3.3:

Язык программирования Java определяет стандартный способ преобразования программы, написанной на Unicode, в ASCII, которая превращает программу в форму, которая может обрабатываться инструментами на основе ASCII. Преобразование включает в себя преобразование любых escape-кодов Unicode в исходном тексте программы в ASCII путем добавления дополнительного u — например, \ uxxxx становится \ uuxxxx — при одновременном преобразовании не-ASCII-символов в исходном тексте в экранированные символы Unicode, содержащие по одному u каждый ,

Экранирование Unicode было разработано для обеспечения совместимости с широким набором символов. Подумайте о следующем сценарии. Вы получаете фрагмент кода с кодировкой, которую ваш текстовый редактор не понимает (т. Е. Код включает символы, недоступные в используемой вами кодировке). Эту проблему можно решить, заменив все неизвестные символы на экранированные символы Юникода. Поскольку ASCII является наименьшим общим знаменателем наборов символов, всегда можно представить код Java в любой кодировке, заменив символы, которые не поддерживаются целевой кодировкой, на экранирование Unicode. Сегодня Unicode довольно распространен, и это не должно быть проблемой, но я думаю, что в первые дни это было полезно.

Преобразованная версия равна исходной версии, и компилятор рассматривает их как одинаковые. Поскольку этот процесс обратим, компилятор может вернуться к исходной версии, заменив экранированные символы Юникода соответствующими символами Юникода.

Из раздела 3.3

Эта преобразованная версия одинаково приемлема для компилятора Java и представляет собой точно такую ​​же программу. Точный источник Unicode может быть позже восстановлен из этой ASCII-формы путем преобразования каждой escape-последовательности [Unicode], в которой присутствует несколько u, в последовательность символов Unicode с одним меньшим u, при одновременном преобразовании каждой escape-последовательности [Unicode] с одним u в соответствующий одиночный символ Unicode.

Предпочитайте Escape-последовательности

Поскольку экранирование Unicode обрабатывается раньше, чем все остальное в процессе компиляции, они могут создать значительную путаницу. Поэтому лучше избегать их, если это возможно. Вместо этого предпочтите escape-последовательности , например, \n\” Нет необходимости использовать экранирование Unicode для символов ASCII.

Unicode Escape должны быть хорошо сформированы

Ранее я упоминал, что только правильно сформированные экранированные символы Юникода заменяются символами Юникода в процессе компиляции. Вы получите сообщение об ошибке, если в вашем коде неправильно сформирован код Unicode. Посмотрите на следующий пример.

 public class IllFormedUnicodeEscape {
    public static void main(String[] args) {
        // user data is read from C:\data\users\profile
        System.out.println("User data");
    }
}

Это похоже на невинно выглядящий кусок кода. Комментарий пытается быть полезным и сообщить что-то важное для читателя. К сожалению, в этом коде скрывается экранирование Unicode, которое не очень хорошо сформировано. Как вы уже знаете, экранирование Unicode начинается с \u Если это правило не соблюдается, компилятор выдаст ошибку.

Имена путей Windows используют обратную косую черту для разделения имен каталогов. Но если за одной из этих обратных косых черт следует символ u Проблема в этом примере — последовательность символов \users

Java Unicode Letters

Принимая это до крайности

Мы рассмотрели несколько примеров, когда экранирование Unicode может причинить вред. Ваш глаз должен быть достаточно натренирован, чтобы заметить большинство из них. В следующем примере я покажу вам фрагмент кода, который я впервые увидел, когда читал книгу « Java Puzzlers » Джошуа Блоха и Нила Гафтера.

 \u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020\u0020
\u0063\u006c\u0061\u0073\u0073\u0020\u0055\u0067\u006c\u0079
\u007b\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020
\u0020\u0020\u0020\u0020\u0073\u0074\u0061\u0074\u0069\u0063
\u0076\u006f\u0069\u0064\u0020\u006d\u0061\u0069\u006e\u0028
\u0053\u0074\u0072\u0069\u006e\u0067\u005b\u005d\u0020\u0020
\u0020\u0020\u0020\u0020\u0061\u0072\u0067\u0073\u0029\u007b
\u0053\u0079\u0073\u0074\u0065\u006d\u002e\u006f\u0075\u0074
\u002e\u0070\u0072\u0069\u006e\u0074\u006c\u006e\u0028\u0020
\u0022\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u0022\u002b
\u0022\u006f\u0072\u006c\u0064\u0022\u0029\u003b\u007d\u007d

Какая? В самом деле? Это похоже на вход в конкурс обфускации кода . Но если подумать, похоже, что он должен скомпилироваться, при условии, что все экранированные символы Юникода на самом деле представляют символы, которые составляют действительную программу Java. Мы узнали, что самое первое, что делает компилятор, — это поиск выходов Unicode и их замена. На данный момент он ничего не знает о структуре программы.

Вы можете попробовать это сами. Скопируйте текст в файл с именем Ugly.java Затем скомпилируйте и запустите программу. Кстати, нет смысла пытаться запустить его из IDE (по крайней мере IntelliJ IDEA сбит с толку и может показывать только волнистые красные линии). Вместо этого используйте инструменты командной строки.

 $ javac Ugly.java
$ java Ugly
Hello world

Кроме того, вы можете использовать инструмент native2ascii

 $ native2ascii -reverse Ugly.java
public
class Ugly
{public
    static
void main(
String[]  
    args){
System.out
.println(
"Hello w"+
"orld");}}

У меня есть только одна вещь, чтобы сказать. То, что ты можешь , не означает, что ты должен .

Резюме

Профессионально у меня никогда не было необходимости вставлять escape-символы Юникода. В настоящее время Unicode довольно распространен, и большинство текстовых редакторов могут отображать символы не ASCII. Если я попадаю в ситуацию, когда мне нужно вставить символ, которого нет на моей клавиатуре, я могу использовать методы, предоставленные большинством операционных систем, для их ввода . Если возможно, избегайте выходов Unicode, потому что они создают путаницу. Вместо этого предпочитайте escape-последовательности.