Последнее изменение: 17 октября 2012г.

Честно сказать, эта статья тоже из серии «ликбеза» – я считал, что об этом должны знать все. Практика показывает, что я ошибался. Долго думал, куда бы этот материал добавить – то ли действительно в статью «Ликбез», то ли в первую часть «Вавилонского столпотворения». В итоге решил вынести в отдельную статью.

Вавилонское столпотворение. Часть 4. Проза жизни: компиляция и вывод в консоль

Итак, речь у нас пойдет о таких, казалось бы, примитивных процессах, как компиляция и вывод в консоль. Крайне рекомендую ознакомиться со статьями «Ликбез» и «Вавилонское столпотворение. Часть 1. Кодировки» – это необходимо для понимания данного материала. По сложности же кода дальше уровня «Hello, World» мы не пойдем.

Компиляция «в лоб»

Для начала простое упражнение. Возьмите вот этот файл – HelloWorldUTF8.java – если у вас Windows, и вот этот – HelloWorldCp866.java – если у вас Linux или Mac. Скомпилируйте и запустите (все команды я привожу для Windows):

javac -cp . -d . HelloWorldUTF8.java
java -cp . test.HelloWorldUTF8

Надеюсь, вы понимаете, что делают эти команды. Если нет – читайте вот это: «Ликбез».

В свой хрустальный шар я вижу, что в консоли у вас ничего хорошего. Во-первых, под Linux-ом я ожидаю кучу предупреждений еще на стадии компиляции – 8 штук. Во-вторых, при выполнении у меня (под Windows) – вот так:

╨Ч╨┤╤А╨░╨▓╤Б╤В╨▓╤Г╨╣, ╨Ь╨╕╤А!

Под Linux должны быть прямоугольнички вместо букв.

Теперь поменяйтесь – возьмите файл, который я предназначил для другой операционки. И то же самое – скомпилируйте и запустите:

javac -cp . -d . HelloWorldCp866.java
java -cp . test.HelloWorldCp866

Вот так уже намного лучше. К сожалению, не могу ручаться, что в Linux тоже все хорошо, ибо настраивается эта ОС по-разному. Но при стандартной настройке должен быть виден нормальный русский текст:

Здравствуй, Мир!

Упражнение окончено.

Что можно из него вынести? Основное, что мы увидели – результат компиляции зависит от операционной системы. Что нас, понятное дело, устраивать не может.

Но и это еще не все. Вот то, что получилось при компиляции (необходимая версия Java – 6.0+): test_windows.zip и test_linux.zip. Запустим это безобразие на других системах, отличных от той, где они компилировались. Содержимое test_windows.zip, запущенное под Linux, дает такой результат:

$ java -cp . test.HelloWorldUTF8
Здравствуй, Мир!
$ java -cp . test.HelloWorldCp866
‡¤а ўбвўг©, ЊЁа!

Ну и код из test_linux.zip под windows дает вот что:

java -cp . test.HelloWorldUTF8
╟фЁртёЄтєщ, ╠шЁ!
java -cp . test.HelloWorldCp866
??????, ???!

То есть – во всех случаях бредятина и ничего похожего на то, что было под другой OС. Никакой переносимости. Что нас тоже никак не может устраивать.

В общем, ситуация ясна. Имеется проблема. Надо понять, как ее решать. А для этого необходимо понимание происходящего.

Что происходит?

Для начала я открою страшную тайну. В разных системах используются разные кодировки по умолчанию. В Windows это Cp1251, в Linux – как правило, UTF-8. Приведенные мной примеры рассчитаны именно на это.

Второй момент, о котором знает существенно меньшее количество разработчиков. В Windows кодировка консоли не совпадает с кодировкой системы! Консоль по историческим причинам имеет кодировку Cp866, также известную под неофициальными именами DOS и OEM.

Третий момент. Компилятор при разборе исходного кода по умолчанию использует кодировку системы. В Windows – Cp1251, в Linux – UTF-8.

Четвертый момент. Посмотрите на класс java.lang.System, а точнее – на его переменную java.lang.System.out. Она имеет тип PrintStream. Т.е. – оперирует байтами. А следовательно, тоже использует какую-то кодировку.

И вот тут-то начинается веселье. Кодировка, используемая при выводе в консоль – системная! А вовсе не кодировка консоли, как можно было бы предположить. И если они не совпадают – жди неприятностей.

Понятно? Тогда вопрос. Почему же при компиляции исходника в Cp866 под Windows и его последующем исполнении там же мы видим нормальный текст??? Кодировки-то не совпадают!

Отчасти этот факт и побудил меня вообще написать эту статью. Недавно меня на полном серьезе убеждали в одном из форумов, что для компиляции и нормального вывода в консоль необходимо и достаточно, чтобы исходник был в кодировке консоли. Более того, как вы только что видели, это вроде бы действительно так. Так?

Нет. И я это сейчас продемонстрирую. Возьмем еще один исходник в Cp866, почувствуем себя немножко логопедом, отрабатывающим шипящие, и заменим уже набившую оскомину фразу «Здравствуй, мир!» на более веселую – «МыШка суШек насуШила!». Вот этот исходник: HelloWorldCp866_2.java. Как и прежде – компилируем и исполняем (это уже только под Windows):

javac -cp . -d . HelloWorldCp866_2.java
HelloWorldCp866_2.java:6: warning: unmappable character for encoding Cp1251
        System.out.println("Мы?ка су?ек насу?ила!");
                              ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding Cp1251
        System.out.println("Мы?ка су?ек насу?ила!");
                                    ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding Cp1251
        System.out.println("Мы?ка су?ек насу?ила!");
                                            ^
3 warnings

java -cp . test.HelloWorldCp866_2
Мы?ка су?ек насу?ила!

Как вам результат?

Не звучат шипящие у пациента. Причем уже на стадии компиляции. Почему? Ма-а-аленькая подсказка. Код буквы «Ш» – 0x98. Вам это ничего не напоминает? Мне это напоминает потерявшуюся букву «И» в статье о кодировках. У нее в составе байтов тоже было значение 0x98.

Догадались, что произошло? Двойная ошибка. Сначала Cp866 читается как системная Cp1251. В нижней части они совпадают, так что программный код не повреждается. А вот верхние части таблицы (выше 127 символов) различаются, и серьезно. В результате символы, которые после компиляции сохраняются в байт-коде, не имеют ничего общего с тем, что было в исходнике.

Однако при выводе на консоль происходит ровно обратное. Символы с помощью кодировки Cp1251 превращаются в байты. А эти байты соответствуют Cp866. Только поэтому на консоль выводится правильный текст.

И все было бы замечательно, если бы не отсутствие в Cp1251 символа с кодом 0x98, на который попадает буква «Ш». Декодер встречает байт 0x98, не находит такого символа и выдает предупреждение: unmappable character for encoding Cp1251. Буквально – неотображаемый символ для кодировки Cp1251. А сам символ замещает на «?». Это мы уже видели. Вот «Ш» и пропала.

И еще более показательна компиляция того же исходника под Linux:

$ javac -cp . -d . HelloWorldCp866_2.java
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                            ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                              ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                  ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                    ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                     ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                      ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                        ^
HelloWorldCp866_2.java:6: warning: unmappable character for encoding UTF8
        System.out.println("�똪� �㘥� ���㘨��!");
                                         ^

Даже сказать нечего. Если под Windows, когда однобайтовая Cp866 расценивается как однобайтовая же Cp1251, не отображается только «Ш», то под Linux, при попытке расценить Cp866 как многобайтовую UTF-8, текст вообще не распознался. Вылез какой-то странный иероглиф, результат случайного совпадения нескольких байтов со схемой кодирования UTF-8.

Думаю, вы уже понимаете и то, почему при переносе скомпилированного кода из-под Windows в Linux все перестало работать. Байты, выводимые на консоль, соответствую Cp866. А в Linux ожидается UTF-8. При переносе с Linux на Windows код тоже ломается, правда, по другой причине. При компиляции там все в порядке, ибо кодировки файла и системы совпадают (если вы помните, там нормально работал именно UTF-8 вариант кода). А вот при выводе на консоль используется системная кодировка, отличная от консоли.

Итак, к какому выводу можно придти. Кодировку надо учитывать как во время компиляции, так и при выводе на консоль. Невыполнение этого правила в любом из двух мест способно вызвать проблемы.

Как именно надо учитывать кодировку? Очень просто.

Как это должно быть сделано

Начнем с компиляции.

У компилятора есть опция -encoding. Она позволяет указать кодировку исходника. Вот ее и надо использовать. Попробуем на примере с шипящими. Под Windows:

javac -cp . -d . -encoding Cp866 HelloWorldCp866_2.java

java -cp . test.HelloWorldCp866_2
╠√╪ър ёє╪хъ эрёє╪шыр!

И под Linux:

$ javac -cp . -d . -encoding Cp866 HelloWorldCp866_2.java
$ java -cp . test.HelloWorldCp866_2
МыШка суШек насуШила!

Если сравнивать с предыдущим вариантом, радует уже хотя бы отсутствие предупреждений типа unmappable character for encoding XXX – исходник прочитан корректно. Под Linux и выполнение замечательно – кодировка консоли совпадает с системной. А вот в Windows при выполнении по-прежнему грустная картина. Не беда, сейчас и это поправим.

Итак, для того, чтобы обеспечить правильный вывод на консоль, нужно в приложении установить кодировку этой консоли. Делается это просто. Тип поля System.outjava.io.PrintStream. У этого класса есть один конструктор, в который можно передать кодировку. Этот конструктор и надо использовать для создания нового потока с правильной кодировкой над существующим System.out:

System.setOut(new java.io.PrintStream(System.out, true, "Cp866"));

И всё бы хорошо. Но в разных системах кодировки консоли разные. Потому – лучше выставить значение кодировки через свойство виртуальной машины. При старте проверить его наличие, и если установлено – использовать. Это средство будет универсальным. Т.е. код установки кодировки консоли получится такой:

String consoleEncoding = System.getProperty("consoleEncoding");
if (consoleEncoding != null) {
    try {
        System.setOut(new PrintStream(System.out, true, consoleEncoding));
    } catch (java.io.UnsupportedEncodingException ex) {
        System.err.println("Unsupported encoding set for console: "+consoleEncoding);
    }
}

Вот версия в кодировке Cp866, в которой всё это учтено: HelloWorldCp866_2_ok.java. Компилируем с ключом -encoding Cp866, запускаем в различных вариантах:

javac -cp . -d . -encoding Cp866 HelloWorldCp866_2_ok.java

java -cp . test.HelloWorldCp866_2_ok
╠√╪ър ёє╪хъ эрёє╪шыр!

java -cp . -DconsoleEncoding=C866 test.HelloWorldCp866_2_ok
Unsupported encoding set for console: C866
╠√╪ър ёє╪хъ эрёє╪шыр!

java -cp . -DconsoleEncoding=Cp866 test.HelloWorldCp866_2_ok
МыШка суШек насуШила!

Ура! Шипящие появились! А ну-ка попробуем изначальную версию в UTF-8, с добавлением кодировки консоли (HelloWorldUTF8_ok.java):

javac -cp . -d . -encoding UTF-8 HelloWorldUTF8_ok.java

java -cp . test.HelloWorldUTF8_ok
╟фЁртёЄтєщ, ╠шЁ!

java -cp . -DconsoleEncoding=Cp866 test.HelloWorldUTF8_ok
Здравствуй, Мир!

И тут при указании кодировки консоли все в порядке. В Linux же все еще проще, там даже кодировку указывать необязательно:

$ javac -cp . -d . -encoding Cp866 HelloWorldCp866_2_ok.java
$ java -cp . test.HelloWorldCp866_2_ok
МыШка суШек насуШила!

Ну и последняя проверка – переносимость. Вот написаные и скомпилированные уже по всем правилам test.HelloWorldCp866_2_ok и test.HelloWorldUTF8_ok: test_ok.zip. Компиляция сделана под Windows, впрочем, тут уже никакой разницы. Запускаем под Linux:

$ java -cp . test.HelloWorldCp866_2_ok
МыШка суШек насуШила!
$ java -cp . test.HelloWorldUTF8_ok
Здравствуй, Мир!

При обратном переносе ситуация аналогичная, так что консольный вывод приводит не буду.

* * *

Вот мы и добрались до финиша. Что мы вынесли из прочитанного:

Для того, чтобы исключить проблемы с кодировками при компиляции и консольном выводе, необходимо:

  1. В явном виде указать компилятору кодировку исходника – с помощью опции -encoding или же каким-либо другим способом (в ant и maven есть свои настройки, в средах разработки также можно указать кодировку исходников)
  2. В явном виде установить кодировку консоли с помощью вызова:
    System.setOut(new java.io.PrintStream(System.out, true, "<имя кодировки>"));

Всё то же самое применимо и к потоку System.err. С System.in, использующимся для чтения из консоли, ситуация немного другая, это чистый java.io.InputStream, потому для учета кодировки из System.in надо читать, оборачивая его в java.io.Reader (а точнее, в его наследника) с указанием кодировки.

Вот теперь – совсем всё! Всем спасибо!

P.S. А для чего это?

Как-то за кадром остался один вопрос. А для чего всё это вообще надо? Я разрабатываю все под Windows, исполняю там же. Зачем мне все эти пляски с бубном про кодировки? Один раз настроил – и хорошо. Вопрос, кстати, не праздный, мне его задавали.

Так вот. Дело в современных тенденциях в разработке ПО – она становится все более и более распределенной. И, что более важно, над одним проектом начинают работать люди, говорящие на разных языках, работающие под разными ОС и с большой вероятностью использующие разные кодовые страницы. Так вот, чтобы не иметь проблем с исходниками, написанными товарищем по команде – жизненно необходимо работать в одной кодировке. Иначе коллега пишет комментарии на немецком – Cp1252 – а я не могу их прочитать, часть символов у меня заменяется на русские буквы. Ну ладно, с немецким еще как-то, а когда комментарии на иврите? На японском? Единая кодировка – например, UTF-8, – эту проблему решает.

А по хорошему, конечно, код вообще не должен содержать ничего, кроме нижних 127 символов iso-8859-1, т.е. ASCII. Тогда будет гарантия чтения исходника в любых условиях.

Вот теперь действительно всё!


P.S. Обсуждение статьи: http://skipy-ru.livejournal.com/4755.html.