Последнее изменение: 1 сентября 2007г.

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

Вступление окончено. Перейдем к делу.

Вавилонское столпотворение. Часть 1. Кодировки

Язык складывается из слов. Слова – из символов. Думаю, этого никто отрицать не будет. Ибо это очевидно. Что же такое символ? По большому счету – это картинка с характерными особенностями. Именно по этим особенностям мы опознаем символ, даже если он нарисован по-разному.

Компьютер же – такой предмет, что рисунки распознает с трудом. Можно его, конечно, научить, но... больно сложно. Он понимает только числа. Потому – решение очевидно: сопоставить каждому символу (картинке) число. И задать алгоритм рисования. Ура! Компьютер умеет писать.

Вопрос на засыпку. А какое число сопоставляется символу? Как бы нам не получить анекдот про секретаршу со скоростью набора в 2000 знаков в минуту. У нее получалась полная ерунда. И чтобы не пойти по ее стопам, необходимы четкие правила сопоставления. Об этих правилах и пойдет речь.

Статью эту можно разделить на следующие части:

Начнем с начала. Пункт первый –

Основы

Еще один вопрос на засыпку. А сколькими способами можно сказать компьютеру, что мы хотим нарисовать русскую букву "А"? Теоретически таких способов бесконечное множество. А практически?

Лично я сразу могу назвать 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 – U7F0xxxxxxx
U80 – U7FF110xxxxx 10xxxxxx
U800 – UFFFF1110xxxx 10xxxxxx 10xxxxxx
U10000 – U10FFFF11110xxx 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 можно рассказать много интересного. Учитывая, что я и сам до недавнего времени знал не все, что тут написано, я посчитал необходимым добавить этот материал в данную статью.

* * *

Пожалуй, о кодировках всё. Возможно, я не затронул каких-то интересных моментов. Возможно, где-то ошибся. В любом случае – мой адрес внизу страницы есть. Пишите! А на сей момент – позвольте откланяться!