Последнее изменение: 1 сентября 2007г.
Это первая статья из цикла "Вавилонское столпотворение", посвященного локализации приложений. В ней пойдет речь о кодировках, их использовании, разрешении связаных с ними проблем и т.п. В других статьях я коснусь таких вопросов, как использование локализованных ресурсов и работа с кодировками в веб-приложениях.
Вступление окончено. Перейдем к делу.
Вавилонское столпотворение. Часть 1. Кодировки
Язык складывается из слов. Слова – из символов. Думаю, этого никто отрицать не будет. Ибо это очевидно. Что же такое символ? По большому счету – это картинка с характерными особенностями. Именно по этим особенностям мы опознаем символ, даже если он нарисован по-разному.
Компьютер же – такой предмет, что рисунки распознает с трудом. Можно его, конечно, научить, но... больно сложно. Он понимает только числа. Потому – решение очевидно: сопоставить каждому символу (картинке) число. И задать алгоритм рисования. Ура! Компьютер умеет писать.
Вопрос на засыпку. А какое число сопоставляется символу? Как бы нам не получить анекдот про секретаршу со скоростью набора в 2000 знаков в минуту. У нее получалась полная ерунда. И чтобы не пойти по ее стопам, необходимы четкие правила сопоставления. Об этих правилах и пойдет речь.
Статью эту можно разделить на следующие части:
- Основы
- Символы в Java
- Чтение и запись текста
- Частые проблемы и их устранение
- Unicode и кодировки семейства UTF
Начнем с начала. Пункт первый –
Основы
Еще один вопрос на засыпку. А сколькими способами можно сказать компьютеру, что мы хотим нарисовать русскую букву "А"? Теоретически таких способов бесконечное множество. А практически?
Лично я сразу могу назвать 5 различных вариантов. Это может быть один байт со значением 0x80
,
0xC0
или 0xЕ1
, либо два байта со значениями 0xD0,0x90
или 0x04,0x10
. Чем
эти варианты различаются? Думаю, вы уже догадались. Это различные кодировки: DOS
(она же пресловутая кодовая
страница 866), Windows
(известная также как windows-1251
), KOI-8
(помните еще такую?),
UTF-8
и UTF-16
соответственно.
Но это все лирика. Поговорим более строго. В представлении символьной информации различают два аспекта. Первый из них – какое именно число сопоставлено тому или иному абстрактному символу. Таблица сопоставления носит название кодированный набор символов (coded character set). Второй аспект – как это число переводится в набор байтов. Эта таблица сопоставления называется схемой кодирования символов.
Надо отметить, что эти два аспекта далеко не взаимно однозначны. Один и тот же набор символов может быть закодирован
(и кодируется на практике!) по разным схемам. И наоборот – одна схема может использоваться для кодирования разных наборов.
Классический пример первого – кодирование набора unicode
с помощью схем UTF-8
, UTF-16
,
UCS-2
. Пример второго – кодирование с помощью схемы EUC-JP
наборов JIS-X-0201
,
JIS-X-0208
, JIS-X-0212
и т.д.
Так вот, согласно RFC 2278 кодировка (charset)
определяется как комбинация набора символов и схемы кодирования. Что касается наименования кодировок, то тут принят такой
подход. Если набор символов кодируется одной единственной схемой, то кодировка, как правило, называется по имени набора
символов. Примеры – US-ASCII
, cp866
, koi8-r
. Все они однобайтовые (а первая – так
вообще семибитная!), потому тут и схемы-то никакой нет. Если же набор кодируется разными схемами, то кодировка называется по
имени схемы. Примеры – UTF-8
, UTF-16LE
.
Чаще всего у кодировки есть несколько названий, под которыми она фигурирует в разных стандартах. Например, стандартная
кодировка DOS
также называется IBM866
, cp866
, csIBM866
. А у кодировки
iso-8859-7
– 16 синонимов!
Возникает интересный вопрос. А сколько всего кодировок поддерживает Java? Ответ не так прост. Дело в том, что, существуют две разновидности JRE – с поддержкой "многоязыковости" и без нее. Почему так? Потому что эта поддержка требует приблизительно 30Мб дополнительного пространства на диске и, что более важно, дает около 10Мб дополнительного размера инсталляционного файла, увеличивая его больше чем в 2 раза (речь именно о JRE, не о JDK). И в случаях, когда поддержка большого количества языков не нужна, может быть достаточно урезаной версии.
В любом случае – количество кодировок можно легко проверить. Простейшее тестовое приложение:
/* * Copyright (c) 2005 Eugene Matyushkin */ package test; import java.util.SortedMap; import java.util.Iterator; import java.nio.charset.Charset; /** * CharsetTest * * @author Eugene Matyushkin */ public class CharsetTest { public static void main(String[] args){ SortedMap<String, Charset> charsetsMap = Charset.availableCharsets(); System.out.println("Charsets available: "+charsetsMap.size()); for(String name : charsetsMap.keySet()){ Charset charset = charsetsMap.get(name); StringBuffer sb = new StringBuffer(); sb.append(name); sb.append(" ("); for(Iterator<String> aliases = charset.aliases().iterator();aliases.hasNext();){ sb.append(aliases.next()); if (aliases.hasNext()) sb.append(","); } sb.append(")"); System.out.println(sb.toString()); } } }
Это приложение получает список всех кодировок, известных системе, и выводит их в консоль вместе с синонимами в скобках.
В моей рабочей системе (естественно, поддержка "многоязыковости" у меня стоит) оно находит 148 кодировок. Естественно,
публиковать их тут смысла нет, желающие могут запустить приведенный код самостоятельно. Я его запускал под версией
1.5.0_03
, в других версиях количество может отличаться.
А какие кодировки присутствуют в системе, когда поддержки "многоязыковости" нет? Вернее, если говорить строго – какие
кодировки должны присутствовать в любой Java-платформе? Должны – потому что всегда существует вероятность ошибок
в реализации. Таких кодировок шесть: US-ASCII
, ISO-8859-1
,
UTF-8
, UTF-16
, UTF-16BE
и UTF-16LE
. Их описания можно найти в описании
класса java.nio.charset.Charset
, кроме того, я еще буду части из них касаться в дальнейшем.
С теоретической частью, наверное, закончим, тем более, что разъяснения еще будут. Пойдем дальше. Тема –
Символы в Java
В качестве базовой кодировки в Java выбрана 16-битная кодировка Unicode
. Таким образом, в этой кодировке
возможно представить 65536 символов. Символу соответствует примитивный тип char
и его объектная оболочка
java.lang.Character
. Строки содержат внутри массив char[]
.
При хранении символов в памяти используется схема кодирования UTF-16
. В ней каждому символу соответствует
двухбайтовая последовательность.
Возможно, эта информация известна и не столь нужна для работы. Но она необходима для понимания происходящего. А именно, вот чего.
Задайтесь вопросом: а сколько приложений и операционных систем умеют работать с кодировкой Unicode
? Ну ладно,
операционные системы наверняка все. В смысле, все умеют работать с этой кодировкой, что вовсе не означает, что они
используют ее повсеместно. Скорее наоборот. Используются кодировки, соответствующие региональным настройкам. Что в общем-то
правильно, ибо они чаще всего умещаются в 8 бит, что позволяет отводить на хранение текста в два раза меньше памяти, чем
при использовании Unicode
. Но это в свою очередь означает, что один и тот же символ (и, следовательно, текст),
представлен в памяти виртуальной машины и на диске совершенно разными наборами байтов. Следовательно, для корректного
отображения при чтении с диска текста один набор байтов должен заменяться другим. Иначе говоря, должна производиться
перекодировка. О том, как это происходит, мы поговорим в следующей части.
Итак, следующая часть –
Чтение и запись текста
Посмотрим на пакет java.io
. Это вообще хорошая практика – смотреть в документацию. Сейчас же я хочу обратить
внимание на две системы имеющихся там классов, предназначенных для чтения и записи чего угодно и куда угодно. Эти системы –
InputStream/OutputStream
с наследниками и Reader/Writer
с наследниками. Очень надеюсь, что
принципы потокового ввода-вывода вы знаете, потому останавливаться на них не буду.
Итак, прежде всего – какова разница между этими системами классов? Посмотрите описание классов Reader
и
Writer
, вернее, описание методов. Во всех описаниях методов readXXX
и writeXXX
присутствует слово character. Т.е. – эти классы оперируют с символами, в то время как
InputStream
и OutputStream
– с байтами. И это – основное отличие. К сожалению, очень многие этого не
знают.
Что получается. Если мы хотим работать с текстом, то нам надо читать и писать именно символы. То есть – использовать
Reader
-ы и Writer
-ы. Однако чаще всего приходится иметь дело именно с байтами и с
InputStream/OutputStream
. Значит, для получения символов из байтов надо знать кодировку исходного текста. И не
только знать, но и применить ее для перекодировки в тот самый Unicode
, который использует виртуальная машина.
Вопрос. Как это сделать? Где именно надо указать кодировку?
Ответ довольно прост. Существуют классы, являющиеся своего рода шлюзами между системой, работающей с символами, и
системой, работающей с байтами. Это InputStreamReader
и OutputStreamWriter
. У этих классов есть
конструкторы, позволяющие указать кодировку, в которой записан входящий поток байтов или должен быть записан исходящий поток.
Таким образом, общая схема чтения и записи текста приблизительно такова:
BufferedReader reader = new BufferedReader( new InputStreamReader(<stream to read>, "<encoding name>")); BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(<stream to write>, "<encoding name>"));
reader
и writer
позволяют читать и писать текст в указанной кодировке. Использование
буферизованных версий, разумеется, необязательно. Это лишь одна из наиболее часто встречающихся конструкций. Ключевое в этом
фрагменте – использование InputStreamReader
и OutputStreamWriter
.
Сразу хочу сделать замечание. Конструкторы этих классов инициируют исключение UnsupportedEncodingException
,
если кодировка, указанная в них, не поддерживается. Потому, перед использование кодировки стоит сначала проверить ее
поддержку с помощью метода java.nio.charset.Charset.isSupported("<имя кодировки>")
.
Ну ладно, со всякими региональными кодировками более-менее понятно, их поддержки действительно может не быть. А что делать, если требуемая кодировка одна из шести основных? Не обрабатывать исключение компилятор не даст. Оставить блок пустым?
Я лично придерживаюсь в этом вопросе следующего мнения. Я уже писал об этом в статье Качественный код – слагаемые, но повторюсь еще раз. В любом случае надо действовать по ситуации, и этот – не исключение. Возможно, это исключение не особо повлияет на работу приложения. Я допускаю, что такие случаи бывают. Однако код из J2SE, который я видел, написан с позиции, что это достаточно серьезная ситуация чтобы остановить работу приложения без возможности восстановления. И чаще всего обработка таких ситуаций выглядит следующим образом:
catch (UnsupportedEncodingException e) { // The default encoding should always be available throw new Error(e); }
Как вы, я думаю, знаете, Error
и его наследники перехватываются только через catch
Throwable
, что является очень плохой практикой и практически нигде не используется. Так что приложение с большой
долей вероятности будет остановлено.
Еще один момент, которого я бы хотел коснуться – классы FileReader
и FileWriter
. С одной
стороны – они принадлежат к системе работы с символами, с другой – на входе они принимают файл или его имя, а возможности
указать кодировку у них нет. Возникает резонный вопрос – а какую кодировку они используют? В описании у них написано
следующее (это для FileReader
, у FileWriter
указаны, соответственно, другие классы):
The constructors of this class assume that the default character encoding and the default byte-buffer size
are appropriate. To specify these values yourself, construct an InputStreamReader
on a
FileInputStream
.
Так вот, я не рекомендую использовать эти классы. Меня пугает словосочетание default character encoding.
Дело в двух моментах. Первое – у каждой системы может быть разная кодировка по умолчанию. Следовательно, нельзя быть
уверенным, что приложение поведет себя так, как задумано. Второй момент – то, как именно определяется кодировка по умолчанию:
проверяется системное свойство file.encoding
. В идеале оно соответствует региональным настройкам системы
(скажем, в Windows с русскими настройками это свойство имеет значение Cp1251). Однако это свойство можно легко
преопределить. Например, так (параметр интерпретатора): -Dfile.encoding=UTF-8. И всё. Кодировка по умолчанию уже
не соответствует реальной кодировке системы. А установку системного свойства изнутри приложения я считаю очень опасным
делом. Ибо она глобальна и затрагивает всё приложение. А самое страшное – эта установка не видна (в отличии от использования
параметра командной строки), о ней надо просто знать. И если в другом месте это свойство используется, то... Масштабы
катастрофы и время, потраченное на поиски ТАКОЙ ошибки, оцените сами.
Кроме того, есть еще один момент. Если вы установите кодировку с помощью свойства file.encoding
, а она не
поддерживается – код J2SE может быть (и будет!) очень недоволен. Приведенный пример с перехватом
UnsupportedEncodingException
и выбрасыванием Error
взят как раз из обработки такой ситуации.
Так что я считаю, что в любом случае лучше следовать рекомендациям, приведенным в описании этих классов: явно
использовать InputStreamReader
/OutputStreamWriter
и указывать кодировку. Пусть она даже будет
системной – вы в любом случае будете знать, что делаете.
Вот мы и подобрались к последней части –
Частые проблемы и их устранение
Наиболее часто встречающиеся проблемы, связанные с кодировками, можно поделить на следующие группы: прямоугольники вместо символов, вопросительные знаки вместо символов и символы есть, но не те. Пройдемся по возможным причинам.
Прямоугольники вместо символов. Причем в количестве, равном количеству символов. Причина, скорее всего, банальна – шрифт просто не содержит символов, которые мы хотим им отобразить. Ибо очень многие компоненты используют системные шрифты, которые чаще всего локализованы и не содержат, скажем, азиатских иероглифов. Декодирование же входного потока байтов скорее всего было успешным.
Самое интересное, что совсем недавно я наступил на эти грабли сам. Я считал, что японские иероглифы не отображаются стандартными компонентами в принципе, если не установить Unicode-шрифт. В конференции мне возразили. Я попробовал и был очень удивлен, ибо точно помнил, что при разработке некой компоненты я тестировал ее в том числе и с японским текстом, и проблемы у меня тогда были! А тут – нет. Ну ладно, нет – так нет. Скорее всего, что-то исправили в JDK. Пришел домой с работы, запустил эту же компоненту (на точно той же версии JDK) и... вуаля, заветные прямоугольнички! Я стал копать... В конечном итоге выяснилось, что на работе у меня стоит в WinXP поддержка азиатских шрифтов (компания японская и администратор, естественно, ставит поддержку). А дома – не стоит. Я даже не знал, что ее надо ставить отдельно. Соответственно, после установки этой поддержки (включавшей в себя в том числе и большое количество шрифтов) проблема исчезла.
Так что в этом случае рецепт довольно прост. Установить компоненте Unicode
-шрифт, если проблема ушла – искать
легальные пути решения (ибо Unicode
-шрифтов очень мало и они, как правило, имеют разные имена в разных
системах). Возможно, существуют и другие ситуации, когда символы замещаются прямоугольниками, но я с ними не сталкивался.
Второй тип проблем – вопросительные знаки вместо символов. Скорее всего причина в ошибке при декодировании входного потока. Такое бывает, к примеру, при попытке прочитать текст в кодировке windows-1251 с использованием кодировки UTF-8. Простейший тест. Возьмите этот файл – alphabet.cp1251, – содержащий русский алфавит в кодировке windows-1251. После чего выполните следующий код (не забудьте указать правильный путь к файлу):
BufferedReader br = new BufferedReader( new InputStreamReader( new FileInputStream("alphabet.cp1251"),"UTF-8")); String str = br.readLine(); System.out.println(str);
Вместо русских символов вы увидите знаки вопроса. Это происходит потому, что декодер не в состоянии распознать поток байтов, они не соответствуют схеме кодирования, которой он ожидает. Соответственно, нераспознанные байты заменяются на знаки вопроса.
Почему я особо подчеркивал – русский текст, русские символы и т.п.? Потому как для данного примера этот факт критичен. Нижние 128 символов (с кодами от 0 до 127), к которым относятся и латинские, у windows-1251 и UTF-8 идентичны и будут декодированы без ошибок. А вот если вместо UTF-8 использовать UTF-16, ситуация будет гораздо более интересной. Латинские символы имеют коды [0x41..0x5A] (большие) и [0x61..0x7A] (маленькие). Составленные из них двухбайтовые последовательности, которых ожидает UTF-16, частично соответствуют последовательностям японских иероглифов схемы UTF-16 и, следовательно, именно так и будут декодированы. Декодер будет считать эти последовательности допустимыми. Думаю, нет нужды говорить, что текст, полученный таким образом, не будет иметь никакого смысла.
Думаю, суть происходящего ясна. Решение очевидно – выбирать соответствующую тексту кодировку.
Третий тип проблем – символы есть, но не те. Тоже весьма распространенная ситуация. Символов может быть существенно больше (иногда – раза в три), определенные символы могут встречаться достаточно часто. Полученная строка может выглядеть, например, так:
абвгдеёжзийклмнопрстуфхцчшщьыъэюя
Знакомая картинка? Думаю, что для многих – да. Что здесь произошло? Нелогичных вопросительных знаков нет – значит, декодер не нашел ошибок. Символы отображаются – значит, они есть в шрифте. Символов существенно больше чем нужно, многие регулярно повторяются. На что это похоже? А похоже это на попытку расценить несколько-(в данном случае – двух, судя по регулярности появления одинаковых символов)-байтовую кодировку как однобайтовую. Скажем, русский текст, записаный в кодировке UTF-8, прочитали с использованием схемы кодировки windows-1251. Кодировка эта однобайтовая, схема тривиальна – декодер каждый байт воспринимает как символ.
Честно сказать, именно так и было. Строка, содержащая русский алфавит, записаный маленькими буквами, была записана в файл в формате UTF-8, а потом прочитана в windows-1251.
Итак, ошибка заключается в выборе неверной кодировки при декодировании. Но ошибок декодера не было. А значит, возможно произвести и обратную операцию. А именно – получить исходный массив байтов и декодировать их заново, с использованием правильной схемы кодирования.
Рассмотрим следующий пример. Для него необходим тестовый файл, вот он: alphabet.utf8. Сохраните этот файл где-нибудь на диске. Он содержит русский алфавит, кодировка, как несложно догадаться – UTF-8.
В этом примере файл сначала читается в неверной кодировке – windows-1251 (точка 1. в листинге). После чего из неверно декодированной строки получается корректная (точка 2.). Напоследок строка читается еще раз, но уже в верной кодировке (точка 3.) – для сравнения.
/* * Copyright (c) 2005 Eugene Matyushkin */ package test; import java.io.*; /** * StringReconstructionTest * * @author Eugene Matyushkin */ public class StringReconstructionTest { private static final String FILENAME = "./alphabet.utf8"; private static final String ENCODING_WIN1251 = "windows-1251"; private static final String ENCODING_UTF8 = "UTF-8"; public static void main(String[] args) throws IOException{ BufferedReader br = new BufferedReader( new InputStreamReader( new FileInputStream(FILENAME),ENCODING_WIN1251)); // 1. String incorrect = br.readLine(); br.close(); System.out.println("Incorrect string: "+incorrect); String restored = new String(incorrect.getBytes(ENCODING_WIN1251),ENCODING_UTF8); // 2. System.out.println("Restored string: "+restored); br = new BufferedReader( new InputStreamReader( new FileInputStream(FILENAME),ENCODING_UTF8)); // 3. String correct = br.readLine(); br.close(); System.out.println("Correct string: "+correct); } }
Думаю, в коде должно быть понятно все, кроме разве что точки 2. Потому эту операцию я хочу разобрать досконально.
Итак, что происходит. Мы имеем на входе массив байтов – исходный файл. Далее мы переводим массив байтов в массив символов.
Как я уже говорил, это делается с помощью схемы кодировки. Выбранная кодировка – windows-1251. Как выясняется, эта
кодировка неверна. Т.е. нам надо проделать операцию заново. А для этого надо иметь исходный массив байтов. Для чего
имеющийся массив символов должен быть переведен в массив байтов с использованием той же самой кодировки, которая была
использована для получения этого массива символов из массива байтов. Так вот, исходный массив байтов получается с помощью
вызова метода некорректной строки – incorrect.getBytes(ENCODING_WIN1251)
. Этот метод делает ровно
то, что нам надо – переводит строку (массив char
) в массив байтов, с использованием указанной кодировки.
Далее в той же точке 2. мы из полученного массива байтов создаем новую строку, но уже используя верную кодировку – UTF-8. Результат вы сможете увидеть сами.
Запустите этот тест. Не забудьте прописать верный путь к файлу alphabet.utf8
в константе
FILENAME
. Посмотрите на результат. Нравится? Если да – посмотрите внимательнее. Мне результат не очень нравится.
Прежде всего – потерявшимся символом "И". Причем видно, что потерялся он уже при чтении файла. На его месте стоит
характерный знак вопроса.
Честно сказать, я наткнулся на этот эффект только что, при написании этого теста. Что я могу сказать по этому поводу?
Символ "И" кодируется в UTF-8 последовательностью из двух байтов: 0xD0,0x98
. Соответственно, при
чтении в кодировке windows-1251 каждый из этих байтов декодируется по схеме данной кодировке. Так вот, я обнаружил
следующее – код 0x98
не определен в наборе символов windows-1251 и, соответственно, в схеме
этой кодировки. Вот схема кодировки cp1251, взятая с сайта
Microsoft, а вот ее описание (взято отсюда). Как вы можете видеть, код 0x98
описан как UNDEFINED
.
И в этом случае легко понять действия декодера. Он натыкается на байт, которого нет в его схеме и с чистой совестью
говорит, что не знает символа с таким кодом. Соответственно, символ помечается как неизвестный и отображается в виде
вопросительного знака. И, естественно, при операции получения байтов из строки вместо байта 0x98
получается
что-то совершенно другое (если быть точным – 0x3F
, т.е. знак вопроса). А полученная последовательность байтов
0xD0,0x3F
декодером UTF-8 не интерпретируется. Вот откуда берется неизвестный символ в восстановленной
строке.
Как вы могли заметить, такого эффекта нет, если читать сразу в нужной кодировке. Этот пример служит хорошей иллюстрацией факта, что восстановить строку после прочтения ее в неправильной кодировке можно далеко не всегда.
Возникает резонный вопрос. А зачем вообще получать байты из строки, если их можно прочитать заново, и использовать уже нужную кодировку? Да, какой-то смысл в этом есть. Но только по отношению к чтению файлов. Да и то не всегда, особенно при использовании потокового ввода-вывода. А в целом ряде ситуаций лучшее, что мы можем получить – строка. Байты и их декодирование от нас скрыты. Причем за примерами далеко ходить не надо. Не так давно кто-то в форуме жаловался, что разработчики некоторого фреймворка для построения web-приложений вообще не подумали, что данные могут приходить в какой-либо кодировке кроме как в стандарной iso-8859-1. Соответственно, все данные с формы декодировались с использованием этой кодировки. Пользователь фреймворка уже получал строки. В каком виде – думаю, вы догадываетесь.
Другой пример. За ним вообще никуда ходить не надо. Класс java.util.Properties
. Он
умеет сохранять свое содержимое в файл и, соответственно, читать его из файла. Так вот, использует он при этом
iso-8859-1. Для символов, которые нельзя в этой кодировке представить, предлагается использовать
escape-последовательности вида \uXXXX
. Чтобы сконвертировать стандартный текст в эту форму надо использовать
утилиту native2ascii
из SDK. Это достаточно неудобно удобно, особенно при частых изменениях. Кодировку, в
которой записан исходный файл, можно задать в качестве параметра командной строки. Подробнее об этом можно почитать в
описании самой утилиты:
http://java.sun.com/j2se/1.5.0/docs/tooldocs/index.html#intl. См. также последнюю часть статьи о локализации –
загрузка ресурсов из файлов свойств.
Теоретически в этом случае можно дать объекту прочитать файл, а потом пройтись по значениям и перекодировать имеющиеся строки из iso-8859-1 в реально использованную кодировку. Желательно, конечно, проверить, не попортит ли текст чтение в iso-8859-1, как это было с windows-1251 и UTF-8 в только что приведенном мною примере. Вроде не должно, ибо iso-8859-1 не содержит неопределенных кодов.
Случаев, когда приходится иметь дело с неверно выбранной кодировкой – очень и очень много. Потому я считаю, что способ перекодирования строки знать необходимо.
Итак, на очереди последняя тема –
Unicode и кодировки семейства UTF
Строго говоря, к Java эта часть статьи не относится. Да и написана она на 5 месяцев позже основной. Я решил добавить описание самого Unicode и кодировок семейства UTF (Unicode Tranformation Formats), ибо, как выяснилось, тут не все так ясно, как может показаться.
Итак. Что такое Unicode? Теоретически – это набор символов, содержащий все символы, существующие на сегодняшний день. Их там немало. Соответственно, на код символа приходится больше одного байта. Сколько? Стандартный ответ – 2. И это неправильно. Ибо стандарт Unicode 4.0 содержит в себе 96382 символа. Максимальный размер беззнакового двухбайтового целого – 65535. Следовательно, байтов может больше двух. Я лично как-то об этом не задумывался до недавнего времени, пока не занялся Unicode вплотную и не наткнулся на статью авторства Joel Spolsky The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!). Это и побудило меня начать исследования.
Начнем с основ. Сколько символов может содержать набор Unicode? На сегодняшний день коды символов могут быть в пределах
U000000 – U10FFFF
. Иначе говоря, возможных кодов (в unicode они называются codepoint)
220+216 = 1114112 при уже определенных, как я говорил выше, 96382. Так что пространство для роста есть,
и немалое.
Коды разделены на некоторое количество блоков (chart). Их описания можно найти тут: http://www.unicode.org/charts/. Символы из области U0000 – UFFFF
называются Basic Multilingual Plane (BMP), из области U10000 – U10FFFF
– прикладными
(supplementary). Блоки UD800 – UDBFF
и UDC00 – UDFFF
зарезервированы, см. ниже.
Схемы кодирования в Unicode применяются разные. Я хочу поговорить о схемах UTF, как наиболее часто употребляемых (по
правде говоря, других схем для Unicode я вообще не знаю). В общем, речь пойдет о UTF-32
, UTF-16
и
UTF-8
.
Начнем с UTF-32
. По правде говоря, этой кодировки в реальной жизни я не встречал. В ней на один символ
отведено 4 байта, в которые гарантированно уложится любой код символа из Unicode. Надостаток очевиден – четырехкратное
увеличение в объеме для латиницы, которой пользуется намалое количество пользователей. Преимущество тоже очевидно – никаких
ухищрений по кодированию, код символа существует с своем нормальном виде.
Далее, UTF-16
. Как нетрудно догадаться, на символ тут отведено 16 бит. Эта кодировка используется гораздо
более активно, нежели UTF-32
, ввиду того, что коды символов языков как раз-то и не выходят за пределы 16 бит.
Это весьма удобно для многих языков, прежде всего, азиатских, которые невозможно втиснуть в однобайтовую кодировку.
16 бит на символ – это хорошо. Однако, Unicode потенциально содержит символы с кодами до U10FFFF
, что, как
нетрудно подсчитать, требует для представления 21 бит. Вопрос на засыпку. Что делать с символами U10000 –
U10FFFF
?
Это символы кодируются следующим образом. Зарезервированные блоки UD800 – UDBFF
и UDC00 – UDFFF
,
которые я упоминал выше, служат для кодирования частей кода символа. Т.е. старшие 6 бит идентифицируют блок
(high surrogate area и low surrogate area, соответственно), а младшие 10 отведены под код – старшие
10 бит кода и младшие 10 бит кода, соответственно.
Возникает резонный вопрос. Если 10 + 10 = 20, о чем говорит математика еще школьных времен, а нужно закодировать 21 бит –
один бит теряется, как ни крути. Где нас кидают? Ответ. Нигде. Дело в том, что все коды, требующие кодирования по частям,
больше U10000
. А, следовательно, U10000
из кодов можно вычесть. Тогда интервал U10000 –
U10FFFF
перейдет в ... U0 – UFFFFF
, где старшее значение 20-битное! Что и требовалось. Ну и
поскольку блоки зарезервированы – есть гарантия, что полученые таким образом две двухбайтовые последовательности не совпадут
с какими-либо кодами символов.
Таким образом, процедура декодирования UTF-16
выглядит следующим образом:
- Берем 2 байта.
- Если они не соответствуют блоку
UD800 – UDBFF
– эти два байта представляют собой код символа. Декодировать в этом случае нечего. - Если же они соответствуют блоку
UD800 – UDBFF
– эти два байта содержат старшие 10 бит кода, превышающегоU10000
. В этом случае за ними должна следовать двухбайтовая последовательность из блокаUDC00 – UDFFF
, иначе данные просто неправильные. - Если следующие два байта – из блока
UDC00 – UDFFF
, то они содержат младшие 10 бит кода. - Извлекаем старшие 10 бит, сдвигаем на 10, прибавляем младшие 10 бит, прибавляем
U10000
. В результате получаем код символа из областиU10000 – U10FFFF
.
Кодирование оставляю в качестве самостоятельного упражнения. :)
Со схемами кодирования UTF-16
и UTF-32
связана еще одна проблема, а именно – последовательности
байтов. Одна архитектура процессора подразумевает порядок "сначала старший, потом младший", другая – "сначала младший, потом
старший". Если не принимать это во внимание, хорошего ничего не получится. А потому были введены различные варианты
кодировок – UTF-16BE
, UTF-16LE
, UTF-32BE
и UTF-32LE
. BE
=
Big Endian
, сначала старший байт, потом младший, LE
= Little Endian
, сначала младший,
потом старший. По умолчанию порядок принимается равным BE
. Существует также возможность задать порядок в явном
виде – с помощью, т.н. byte order mark, BOM
. Для UTF-16
значение первых двух байтов
FEFF
означает порядок BE
, значение FFFE
– порядок LE
. Для
UTF-32
– соответственно 0000FEFF
=BE
и FFFE0000
=LE
. Всех
желающих более глубоко разобраться в этом вопросе отсылаю непосредственно к стандарту Unicode: http://www.unicode.org/versions/Unicode4.0.0/ch03.pdf.
Перейдем теперь к UTF-8
. Это наиболее любопытная кодировка из всего семейства UTF. И любопытна она прежде
всего переменным количеством байтов. Для всех символов с кодами из области 0x00-0x7F
используется один байт.
Таким образом существует совместимость с очень большим количеством текста, использующего только латиницу (ASCII). Для кодов
больше 0x7F
применяется следующая схема:
Интервал | Битовая последовательность |
U0 – U7F | 0xxxxxxx |
U80 – U7FF | 110xxxxx 10xxxxxx |
U800 – UFFFF | 1110xxxx 10xxxxxx 10xxxxxx |
U10000 – U10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Таким образом, для кодов меньше 128 используется один байт, от 128 до 2047 – 2 байта, от 2048 до 65535 – три байта,
от 65535 до 1114111 – 4 байта. Как видно, все символы BMP укладываются в 3 байта. Однако,
UTF-16
для кодов от 2048 до 65535 требует два байта, потому для азиатских языков, например, UTF-16
предпочтительнее.
Теперь об использовании кодировок UTF
и Java. Любая реализация Java-платформы обязана поддерживать
UTF-8
, UTF-16
, UTF-16BE
и UTF-16LE
(см. тут:
http://java.sun.com/j2se/1.4.2/docs/api/java/nio/charset/Charset.html). В памяти же строки хранятся в формате
UTF-16BE
. Если вы посмотрите на API класса java.lang.String
(http://java.sun.com/j2se/1.5.0/docs/api/java/lang/String.html) из JDK 1.5, то обнаружите там множество методов, в
названии которых фигурирует codepoint, и которые появились только в версии 1.5. На мой взгляд это означает, что
только в версии 1.5 Java получил поддержку Unicode 3.1 и выше. Но я могу и ошибаться. Хотя одно я знаю наверняка – до версии
1.5 о codepoint-ах речи не шло. А, следовательно, supplementary-символы не поддерживались.
Как видите, о кодировках семейства UTF
можно рассказать много интересного. Учитывая, что я и сам до недавнего
времени знал не все, что тут написано, я посчитал необходимым добавить этот материал в данную статью.
* * *
Пожалуй, о кодировках всё. Возможно, я не затронул каких-то интересных моментов. Возможно, где-то ошибся. В любом случае – мой адрес внизу страницы есть. Пишите! А на сей момент – позвольте откланяться!