Символьный тип в Java: приглядимся повнимательнее
Чак Макмейнис
В Java 1.1 введен ряд новых классов для работы с символами. Эти классы формируют абстракцию для преобразования символов в понимании той или иной конкретной платформы в символы Unicode. В статье рассказывается о том, что именно и с какой целью добавлено в стандарт языка.
Язык Java внешне похож на Си, но по сути отличается от него. Одно из наиболее фундаментальных отличий касается способа представления символов. Как-то я спросил себя, зачем было в версии 1.1 заменять потоковый класс PrintStream, который используется в Java для вывода символов в журнальный файл. Поиск ответа вылился в странствие через глухие дебри кодировок. И вот что мне удалось оттуда вынести.
Символьный тип
Символьный тип (char) языка Си, наверное, чаще всех используется не по назначению. Это связано с тем, что для переменной типа char определена длина 8 бит - столько же, сколько последние 25 лет составляет размер минимальной неделимой частицы памяти компьютера. А поскольку в наборе символов ASCII на представление символа отводилось 7 бит, char оказался очень удобным "универсальным" типом. Далее, указатель на переменную типа char превратился в Си в универсальный указатель, поскольку к переменной, доступной по указателю типа char, можно обратиться как к переменной любого другого типа, пользуясь приведением типов.
Законное и незаконное использование типа char в языке Си привело к множеству случаев несовместимости между реализациями компилятора, и поэтому в стандарт ANSI для Си было внесено два специфических изменения: универсальный указатель получил тип void, так что теперь программист обязан был объявлять его в явной форме, а числовое значение символьных переменных, определяющее их поведение при арифметических операциях, стало считаться имеющим знак.
Позже, в середине 80-х годов, специалисты и пользователи сообразили, что 8 бит для представления всех символов в мире явно недостаточно. Увы, Си к тому времени укоренился настолько прочно, что никто не хотел (возможно, даже был не в состоянии) изменить определение символьного типа. В 90-е годы, когда начиналась разработка Java, в основу языка среди прочего был заложен принцип, в соответствии с которым каждому символу отводилось 16 бит. Из него логически вытекало использование Unicode - стандартного способа представления множества различных типов символов из всевозможных языков. Но увы, этот подход стал почвой для возникновения ряда проблем, которые только теперь начали разрешаться.
Что такое символ
В какой-то момент я не без удивления обнаружил, что спрашиваю себя: "Так что же такое символ?" Вроде бы символ - это буква или цифра, верно? Буквы складываются в слова, слова - в предложения, и т. д. Однако в действительности отношение между графемой (glyph), т. е. графическим представлением символа, например, на экране компьютера, и ее кодом, т. е. определяющим ее числовым значением, оказывается далеко не простым.
Я считаю, что мне повезло с родным языком. Это английский, который, во-первых, был общим языком для значительного числа людей, создававших и развивавших современную вычислительную технику, а во-вторых, обходится сравнительно небольшим набором графем. Набор ASCII определяет 96 печатаемых символов, которые могут использоваться для записи английских текстов. Сравните это с каким-нибудь китайским, для которого стандарт - причем неполный - определяет более 20 000 графем! Благодаря своей простоте (небольшое число графем и высокая частота их встречаемости) английский начиная уже с кодов Морзе и Бодо стал языком межнационального общения цифровой эпохи. Но по мере того как в цифровой мир вступали новые и новые люди, там становилось все больше тех, для кого английский не был родным, и они были вовсе не склонны принимать положение, при котором компьютеры ограничивались набором символов ASCII и изъяснялись только на английском. Пойти им навстречу означало существенно увеличить число символов, понятных для компьютера. В качестве промежуточной меры число графем, кодируемых в компьютере, было удвоено.
Удвоилось оно тогда, когда всеми чтимый 7-битовый код ASCII был включен в 8-битовую кодировку символов под названием ISO Latin-1 (или ISO 8859-1; ISO означает International Standards Organization - Международная организация по стандартам). Как можно догадаться по названию стандарта, он обеспечивал представление символов для многих европейских языков с письменностью на латинской основе1. К моменту его появления дополнительные 128 символов уже применялись в ряде компьютеров, что позволяло извлечь определенную пользу из 8-битовой кодировки. Два примера использования 8-битовых символов, дошедшие до наших дней, мы находим в IBM PC и самом популярном из компьютерных терминалов - VT-100 корпорации DEC (он продолжает существовать в форме программных эмуляторов терминала).
Споры о том, когда в действительности умер 8-битовый символ, без сомнения, будут продолжаться еще не один десяток лет, но, по-моему, он был обречен с момента выхода в 1984 г. первого компьютера Macintosh. Эта машина привнесла в работу с символами две революционные идеи - во-первых, загрузку шрифтов в оперативную память, а во-вторых, способ представления символов WorldScript, пригодный для всех языков мира. Конечно, в Macintosh просто копировался подход, применявшийся в системе обработки текстов Star, созданной компанией Xerox для машин класса Dandelion, но в результате новые наборы символов и шрифты были представлены людям, которые до той поры пользовались исключительно "безъязыкими" терминалами.
Раз начавшись, использование различных шрифтов уже не могло прекратиться просто потому, что было слишком привлекательным для слишком большого числа людей. К концу 80-х годов зашла речь о стандартизации всех подобных символов и был сформирован Консорциум по Unicode, который к 1990 г. выпустил первую спецификацию своего набора символов. Но, увы, в течение 80-х и даже 90-х годов продолжали создаваться также новые наборы символов. В большинстве своем те, кто изобретал новые коды символов, считали нарождающийся стандарт Unicode нежизнеспособным и изобретали коды для графем по собственному разумению. И все же, несмотря на довольно холодный прием Unicode, представление о том, что общее число символов не может превышать 128 или в крайнем случае 256, отошло в прошлое. С появлением компьютеров Macintosh поддержка различных шрифтов стала обязательным требованием к системам обработки текстов, и 8-битовые символы начали постепенно сходить на нет.
Java и Unicode
Впервые я вплотную соприкоснулся с Unicode в 1992 г., когда вошел в группу программистов Sun, работавшую над проектом Oak (так первоначально назывался язык Java). Базовый тип char определялся как 16 бит без знака, и это был единственный в языке тип без знака. Основанием для принятия 16-битового представления символов было то, что оно обеспечивает поддержку Unicode, благодаря которой язык Java становится пригодным для представления строк на любом из языков, включенных в этот стандарт. Однако представление строки и ее печать - это две совершенно разные проблемы. При том что члены группы Oak имели опыт главным образом в области разработки Unix-систем и систем, производных от Unix, самым удобным для них оказался все тот же набор символов ISO Latin-1.
Влияние Unix сказалось и на организации ввода-вывода в Java, которая во многом следует модели потоков Unix, а в Unix, как известно, каждое устройство ввода-вывода может быть представлено потоком 8-битовых байтов. В результате 16-битовые символы должны были вводиться и выводиться через 8-битовые устройства. Возникающее несоответствие устранялось с помощью совершенно хакерского приема - путем вставки нескольких команд, "магически" преобразующих 8-битовые символы в символы Unicode и наоборот (соответственно при чтении и записи 8-битового потока).
В версии 1.0 пакета Java Developer Kit (JDK) команды преобразования для ввода находились в классе DataInputStream, а команды для вывода составляли целиком класс PrintStream. (В действительности во второй альфа-версии Java имелся класс ввода под названием TextInputStream, но впоследствии он был вытеснен классом DataInputStream и в окончательный вариант пакета не вошел). Такое положение вызывало и вызывает проблемы у программистов, которые, начиная осваивать Java, безуспешно пытаются найти там аналог функции getc() языка Си.
Рассмотрим программу на Java, показанную в листинге 1. На первый взгляд кажется, что она должна открывать файл, читать его символ за символом, выводить каждый считанный символ и заканчивать работу после считывания символа конца строки. На самом деле то, что она выведет, не будет соответствовать содержимому файла, поскольку функция readChar считывает 16-битовые символы Unicode, а функция System.out.print пытается распечатать 8-битовые символы кодировки ISO Latin-1.
Если же вместо readChar использовать функцию readLine из класса DataInputStream, программа заработает. Дело в том, что readLine осуществляет считывание в модифицированном формате UTF-8, который определен с учетом спецификации Unicode (UTF-8 - это формат для представления символов Unicode в 8-битовом потоке ввода). Тем самым ситуация в Java 1.0 выглядит следующим образом: строки состоят из 16-битовых символов Unicode, и их можно единственным способом преобразовать в 8-битовые символы набора ISO Latin-1. На счастье разработчиков, набору Latin-1 в точности соответствует в Unicode нулевая кодовая страница, т. е. 256 символов, в кодах которых старшие 8 бит нулевые, так что преобразование совершенно тривиально. Поэтому у программистов, работающих исключительно с символами ISO Latin-1, не возникнет никаких проблем с извлечением символьных данных из файла, манипулированием этими данными внутри классов Java и возвращением их обратно в файл.
"Захоронение" процедуры преобразования формата в названных выше классах приводит к возникновению двух проблем. Во-первых, существует не так уж мало платформ, на которых многоязычные файлы хранятся в формате, отличном от модифицированного UTF-8. Во-вторых, как легко догадаться, далеко не все программы для таких платформ ожидают нелатинские символы именно в этом формате. Следовательно, реализация поддержки Unicode в Java 1.0 была неполной, и простого способа дополнить ее не существовало.
Java 1.1 и Unicode
В версии Java 1.1 были введены совершенно новые интерфейсы для работы с символами. В листинге 2 приводится модифицированный класс bogus из листинга 1, который в новом варианте называется cool. Для ввода символов из файла в нем использован не DataInputStream, а InputStreamReader - подкласс нового класса Reader, место же System.out занял объект класса PrintWriter, который является подклассом класса Writer.
Главное отличие примера из листинга 1 от примера из листинга 2 состоит в том, что класс DataInputStream заменен на InputStreamReader. Кроме того, в листинге 2 появилась дополнительная строка для печати названия кодировки, используемой классом InputStreamReader.
Важно здесь удаление существовавшего ранее недокументированного и не очень понятного кода, "зашитого" в реализацию метода getChar класса DataInputStream (в действительности в версии 1.1 лишь возбраняется его использование, а удален он будет в одной из следующих версий). В Java 1.1 механизм преобразования инкапсулирован в класс Reader, и эта инкапсуляция позволяет организовать в библиотеках классов Java поддержку различных внешних представлений нелатинских символов при том, что для внутреннего представления всегда будет применяться Unicode.
Разумеется, как и в прежней системе ввода-вывода, классы, ответственные за запись, симметричны ответственным за чтение. Класс OutputStreamWriter служит для записи строк в выводной поток, класс BufferedWriter обеспечивает запись с буферизацией и т. д.
Косметический ремонт или реальный прогресс?
Целью - пусть и несколько размытой - создания классов Reader и Writer было упорядочение мешанины стандартов, предназначенных для представления одной и той же информации, за счет разработки стандартного метода конвертирования в Unicode любых старых форматов, будь то греческая кодировка Macintosh или кириллица Windows. Такой подход устраняет необходимость в изменении классов Java, работающих со строками, при переносе их с одной платформы на другую. И на этом можно было бы закончить, если бы с появлением инкапсулированного кода не возникал вопрос о том, какие именно функции на него возложены.
Во время работы над этой статьей мне напомнили известное высказывание одного из руководителей компании Xerox (еще тех времен, когда компания называлась не Xerox, а Haloid) насчет того, что светокопировальный аппарат - совершенно излишняя вещь, поскольку секретарше ничего не стоит при печати оригинала заложить в свою машинку лист копирки. Очевидно, он не учитывал, что копировальные аппараты приносят намного больше пользы получателям документов, чем их авторам. Похожую непредусмотрительность проявили и разработчики JavaSoft: при проектировании классов, ответственных за перекодирование символьной информации, они исходили из неправильных предположений об их будущем применении.
Руководство Haloid думало в первую очередь о создании документов, забывая о тех, кто их будет получать. Перекодирование в Java ориентировано на преобразование символов Unicode в "родной" формат платформы, т. е. операционной системы, под управлением которой работает JVM. Типичное (и простейшее) использование класса InputStreamReader состоит просто в создании его экземпляра, работающего с потоком байтов (что и сделано в нашем примере). Для дисковых файлов создан специальный класс под названием FileReader. При типовом применении, т. е. когда создается экземпляр класса, в класс "втыкается" модуль перекодирования, заданный для данной платформы по умолчанию. В случае Windows и Unix это модуль перекодирования из ISO Latin-1 в Unicode. Однако класс InputStreamReader должен обеспечивать также преобразование в Unicode и обратно других форматов представления текста. Ниже мы разберем, где именно в нынешней версии Java эта потребность совершенно не учтена.
Перекодировщики, работающие с форматами, отличными от ISO Latin-1, существуют: можно преобразовывать в Unicode и обратно символы, представленные в японском стандарте JIS, китайском Han и т. д. Но сколько ни терзай документацию JDK (она также имеется на Web-узле JavaSoft), совершенно невозможно выяснить, что же они собой представляют2. Не видно и API, с помощью которого можно было бы получить список доступных перекодировщиков. Реально это можно сделать только одним способом - прочитав исходный текст InputStreamReader. А то, что мы там находим, открывает совершенно новую проблему.
Оказывается, в исходном тексте InputStreamReader функция преобразования инкапсулирована в классе, специфичном для Sun, который называется sun.io.ByteToCharConverter. Как и все классы Sun в составе JDK, он поставляется без исходного текста и без документации, но, к счастью, мы можем получить от Sun и полные исходные тексты JDK, по которым все-таки можно определить, что этот класс в действительности делает. Конструктор InputStreamReader принимает в качестве одного из параметров строку с именем перекодировщика, который должен использоваться. В реализации Sun эта строка вставляется в шаблон sun.io.ByteToCharXXX (последовательность XXX заменяется на строку, переданную конструктору). Зная это, нетрудно опознать перекодировщики среди классов, запакованных в поставляемом с JDK архиве classes.zip: их имена имеют вид ByteToChar8859_1, ByteToCharCp1255 и т. д. Все это классы Sun, а значит, они не документированы. Правда, кое-какая документация присутствует на Web-узле JavaSoft (полную ссылку см. во врезке "Ресурсы"). Она озаглавлена Internationalization Specification (спецификация интернационализации) и содержит описания большинства (а возможно, и всех) поддерживаемых перекодировщиков3. Главная же проблема, о которой и шла речь выше, состоит в невозможности добавлять собственные перекодировщики.
Авторы соответствующих программ, видимо, исходили из того, что файлы данных, существующие на некоторой платформе, по определению на ней же и создаются. Между тем в нашем мире Internet-соединений и неоднородных сетей файл, который вам нужно прочесть, нередко бывает создан на машине, совсем непохожей на вашу, и, таким образом, требует специального перекодировщика.
Предположим на минуту, что файл, который вы пытаетесь прочитать и, возможно, проанализировать в своей программе на Java, создан на другой платформе - скажем, Commodore-64. На этих машинах используется особый вариант ASCII, следовательно, чтобы преобразовать файл Commodore-64 в Unicode, нужен специализированный подкласс ByteTo Char, работающий с соответствующим форматом. Построение класса Reader это позволяет, но в реализации JDK классы, осуществляющие собственно преобразование, представляют собой часть закрытого пакета sun.io. Что же, использовать недокументированный и неопубликованный интерфейс? Более того, чтобы конвертер правильно работал, он должен быть включен в состав пакета sun.io на той машине, где установлен JDK!
Очевидно, подобные задачи должны часто вставать перед немалым отрядом программистов, обеспечивающих работу со старыми машинами. Похоже, об их интересах позабыли точно так же, как об интересах получателей документов в эпизоде с Xerox. Однако принятые меры все же продвигают нас в нужном направлении.
Об авторе: Чак Макмейнис - директор по системному ПО в корпорации FreeGate. Ранее он был членом группы разработчиков Java, в которую вошел практически с момента ее создания, а до того работал в отделе операционных систем SunSoft.
E-mail: chuck.mcmanis@javaworld.com; http://www.professionals.com/~cmcmanis
Ресурсы
1. Наборы символов ISO
http://czyborra.com/charsets/iso8859.html
2. Страница Unicode
http://www.unicode.org/
3. Спецификации многоязыковой поддержки в Java
http://java.sun.com/products/jdk/1.1/docs/guide/intl/index.html
4. Замечательная статья о том, как и для чего был создан стандарт Unicode
http://www.nyu.edu/acf/pubs/connect/fall96/HumHargGlobVilF96.html
Листинг 1. Пример некорректной работы с символьной информацией
import java.io.*;
public class bogus {
public static void main(String args[]) {
FileInputStream fis;
DataInputStream dis;
char c;
try {
fis = new FileInputStream("data.txt");
dis = new DataInputStream(fis);
while (true) {
c = dis.readChar();
System.out.print(c);
System.out.flush();
if (c == 'n') break;
}
fis.close();
} catch (Exception e) { }
System.exit(0);
}
}
Листинг 2 Пример использования класса InputStreamReader
import java.io.*;
public class cool {
public static void main(String args[]) {
FileInputStream fis;
InputStreamReader irs;
char c;
try {
fis = new FileInputStream("data.txt");
irs = new InputStreamReader(fis);
System.out.println("Using encoding : "+irs.getEncoding());
while (true) {
c = (char) irs.read();
System.out.print(c);
System.out.flush();
if (c == 'n') break;
}
fis.close();
} catch (Exception e) { }
System.exit(0);
}
}
|