Содержание статьи
8 бит и все-все-все…
Начнем с главного. Создатели языка си были минималистами. По сей день в стандарте C/C++ не предусмотрено типа «байт». Вместо этого типа используется тип char. Char означает character, иными словами — символ. Соответственно, говоря в С/С++ о типе char, мы подразумеваем «байт», и наоборот. Вот тут и начинается самое интересное. Дело в том, что максимально возможное число символов, кодируемых 8 битами, равно 256, и это при том, что на сегодняшний день в таблице Unicode насчитываются сотни тысяч символов. Хитрые создатели ASCII-кодов сразу же зарезервировали первые 128 кодов под стандартные символы, которыми смело можно закодировать практически все в англоязычном мире, оставив нам лишь половину байта под свои нужды, а точнее лишь один свободный старший бит.
В результате в первые годы становления информатики все пытались ужаться в эти оставшиеся «отрицательные» числа от –128 до –1. Каждый набор кодов стандартизировался под определенным именем и с этого момента именовался кодировкой. В какой‑то момент кодировок стало больше, чем символов в байте, и все они были несовместимы между собой в той части, что выходила за пределы первых 128 ASCII-символов. В результате, если не угадать с кодировкой, все, что не являет собой набор символов первой необходимости для американского сообщества, будет отображено в виде так называемых кракозябр, символов, как правило, вообще нечитаемых.
Мало того, для одних и тех же алфавитов разные системы вводили кодировки, совершенно рассогласованные между собой, даже если это две системы за авторством одной компании. Так, для кириллицы в MS DOS использовались кодировки 855 и 866, а для Windows уже 1251, все для той же кириллицы в Mac OS используется уже своя кодировка, особняком от них стоят KOI8 и KOI7, есть даже ISO 8859-5, и все будут трактовать одни и те же наборы char совершенно разными символами. Мало того, что было невозможно при обработке различных байт‑символов пользоваться сразу несколькими кодировками, например при переводе с русского на немецкий с умлаутами, вдобавок сами символы в некоторых алфавитах ну никак не хотели помещаться в оставленные для них 128 позиций. В результате в интернациональных программах символы могли интерпретироваться в разных кодировках даже в соседних строках, приходилось запоминать, какая строка в какой кодировке, что неизбежно вело к ошибкам отображения текста, от забавных до совсем не смешных.
Поставь себе на виртуальную машину любую другую операционную систему с другой кодировкой по умолчанию, нежели на твоей хостовой системе, например Windows c кодировкой 1251, если у тебя Linux с UTF-8 по умолчанию, и наоборот. Попробуй написать код с выводом строки кириллицей в std::cout, который без изменения кода будет собираться и работать под обеими системами одинаково. Согласись, интернационализация кросс‑платформенного кода не такая простая задача.
Пришествие Юникода
Задумка Юникода была проста. Каждому символу раз и навсегда присваивается один код на веки вечные, это стандартизуется в очередной версии спецификации таблицы символов Юникода, и код символа уже не ограничен одним байтом. Великолепная задумка во всем, кроме одного: в языки программирования C/C++ и не только в них символ char раз и навсегда ассоциировался с байтом. Повсюду в коде подразумевался sizeof(char), равный единице. Строки текста же были обычными последовательностями этих самых char, заканчивающимися символом с нулевым кодом. В защиту создателей языка си, Ритчи и Кернигана, следует сказать, что в те далекие 70-е годы никто и подумать не мог, что для кодирования символа понадобится так много кодов, ведь для кодирования символов печатной машинки вполне хватало и байта. Как бы то ни было, основное зло было сотворено, любое изменение типа char привело бы к потере совместимости с уже написанным кодом.
Разумным решением стало введение нового типа «широкого символа» wchar_t и дублирование всех стандартных функций языка си для работы с новыми, «широкими» строками. Контейнер стандартной библиотеки C++ string также обрел «широкого» собрата wstring. Все рады и счастливы, если бы не одно «но»: все уже привыкли писать код на основе байтовых строк, а префикс L перед строковым литералом не добавлял энтузиазма разработчикам на C/C++. Люди предпочитали не использовать символы за пределами ASCII и смириться с ограниченностью латиницы, чем писать непривычные конструкции, несовместимые с уже написанным кодом, работавшим с типом char.
Осложняло ситуацию то, что wchar_t не имеет стандартизированного размера: например, в современных GCC-компиляторах g++ он 4 байта, в Visual C++ — 2 байта, а разработчики Android NDK урезали его до одного байта и сделали неотличимым от char. Получилось так себе решение, которое работает далеко не везде. С одной стороны, 4-байтный wchar_t наиболее близок к правде, так как по стандарту один wchar_t должен соответствовать одному символу Юникода, с другой стороны, никто не гарантирует, что будет именно 4 байта в коде, использующем wchar_t.
Альтернативным решением стала однобайтовая кодировка UTF-8, которая мало того, что совместима с ASCII (старший бит, равный нулю, отвечает за однобайтовые символы), так еще и позволяет кодировать вплоть до 4-байтового целого, то есть свыше 2 миллиардов символов. Плата, правда, довольно существенная, символы получаются различного размера, и чтобы, например, заменить латинский символ R на русский символ Я, потребуется полностью перестроить всю строку, что значительно дороже обычной замены кода в случае 4-байтового wchar_t.
Таким образом, любая активная работа с символами строки в UTF-8 может поставить крест на идее использовать данную кодировку. Тем не менее кодировка довольно компактно ужимает текст, содержит защиту от ошибок чтения и, главное, интернациональна: любой человек в любой точке мира увидит одни и те же символы из таблицы Юникода, если будет читать строку, закодированную в UTF-8. Конечно, за исключением случая, когда пытается интерпретировать эту строку в другой кодировке, все помнят «кракозябры» при попытке открыть кириллицу в UTF-8 как текст в кодировке по умолчанию в Windows 1251.
Устройство однобайтного Юникода
Устроена кодировка UTF-8 весьма занятно. Вот основные принципы:
-
Символ кодируется последовательностью байтов, в каждом байте лидирующие биты кодируют позицию байта в последовательности, а для первого байта еще и длину последовательности. Например, так выглядит в UTF-8 символ Я:
[1101 0000] [1010 1111] Байты последовательности, начиная со второго, всегда начинаются с битов 10, соответственно, первый байт последовательности кода каждого символа начинаться с 10 не может. На этом строится основная проверка корректности декодирования кода символа из UTF-8.
Первый байт может быть единственным, тогда лидирующий бит равен 0 и символ соответствует коду ASCII, поскольку для кодирования остается 7 младших бит.
Если символ не ASCII, то первые биты содержат столько единиц, сколько байт в последовательности, включая лидирующий байт, после чего идет 0 как окончание последовательности единиц и потом уже значащие биты первого байта. Как видно из приведенного примера, кодирование символа Я занимает 2 байта, это можно распознать по старшим двум битам первого байта последовательности.
-
Все значащие биты склеиваются в единую последовательность бит и уже интерпретируются как число. Например, для любого символа, кодируемого двумя байтами, значащие биты я условно помечу символом x:
[110x xxxx] [10xx xxxx]
При склейке, как видно, можно получить число, кодируемое 11 битами, то есть вплоть до 0x7FF символа таблицы Юникода. Этого вполне хватает для символов кириллицы, расположенной в пределах от 0x400 до 0x530. При склейке символа Я из примера получится код:
1 0000 10 1111
Как раз 0x42F
— код символа Я в таблице символов Юникода.
Другими словами, если не работать с символами в строке, заменяя их другими символами из таблицы Юникода, то можно использовать кодировку UTF-8, она надежна, компактна и совместима с типом char в том плане, что элементы строк совпадают по размеру с байтом, но не обязательно являются при этом символами.
Собственно, именно эффективностью и популярностью кодировки UTF-8 и обусловлено насильственное введение однобайтового wchar_t в Android NDK, разработчики призывают использовать UTF-8, а «широкие» строки не признают как жизнеспособный вид. С другой стороны, Google не так давно отрицал даже исключения в C++, однако весь мир не переспоришь, будь ты хоть трижды Google, и обработку исключений пришлось поддержать. Что касается wchar_t символов с размером в один байт, то множество библиотек уже привыкло к мытарствам с типом wchar_t и дублируют «широкий» функционал обработкой обычных байтовых строк.
info
UTF (Unicode Transformation Format) — по сути байтовое представление текста, использующее коды символов из таблицы Юникода, запакованные в байтовый массив согласно стандартизированным правилам. Наиболее популярны UTF-8 и UTF-16, которые представляют символы элементами по 8 бит и по 16 бит соответственно.
В обоих случаях символ совершенно необязательно занимает ровно 8 или 16 бит, например, в UTF-16 используются суррогатные пары, по сути пары 16-битных значений, используемых вместе. В результате значащих битов становится меньше (20 в случае суррогатной пары), чем битов в группе представляющих символ, но возможности кодировать символы начинают превышать ограничения в 256 или 65 536 значений, и можно закодировать любой символ из таблицы Юникода.
Выгодно отличающийся от собратьев UTF-32 менее популярен, ввиду избыточности представления данных, что критично при большом объеме текста.
Пишем по-русски в коде
Беды и дискриминация по языковому признаку начинаются, когда мы пытаемся использовать в коде строку на языке, отличном от ASCII. Так, Visual Studio под Windows создает все файлы в кодировке файловой системы по умолчанию (1251), и при попытке открыть код со строками по‑русски в том же Linux с кодировкой по умолчанию UTF-8 получим кучу непонятных символов вместо исходного текста.
Ситуацию частично спасает пересохранение исходников в кодировке UTF-8 с обязательным символом BOM, без него Visual Studio начинает интерпретировать «широкие» строки с кириллицей весьма своеобразно. Однако, указав BOM (Byte Order Mark — метка порядка байтов) кодировки UTF-8 — символ, кодируемый тремя байтами 0xEF, 0xBB и 0xBF, мы получаем узнавание кодировки UTF-8 в любой системе.
BOM — стандартный заголовочный набор байтов, нужный для распознавания кодировке текста в Юникоде, для каждой из кодировок UTF он выглядит по‑разному.
Не стесняйся использовать родной язык в программе. Даже если тебе придется локализовывать ее в другие страны, механизмы интернационализации помогут превратить любую строку на одном языке в любую строку на другом. Разумеется, это в случае, если продукт разрабатывается в русскоязычном сегменте.
Старайся использовать «широкие» строки как для строковых констант, так и для хранения и обработки промежуточных текстовых значений. Эффективная замена символов, а также совпадение количества элементов в строке с количеством символов дорогого стоит. Да, до сих пор не все библиотеки научились работать с «широкими» символами, даже в Boost попадается целый ряд библиотек, где поддержка широких строк сделана небрежно, но ситуация исправляется, во многом благодаря разработчикам, пишущим ошибки в трекер библиотеки, не стесняйся и ты фиксировать ошибки на сайте разработчика библиотеки.
Писать константы и переменные, а также названия функций кириллицей все же не нужно. Одно дело — выводить константы на родном языке, совсем другое — писать код, постоянно переключая раскладку. Не самый лучший вариант.
Различаем тип «байты» и тип «текст»
Главное, что нужно уметь и иметь в виду, — что тип «текст» в корне отличается от типа «набор байтов». Если мы говорим о строке сообщения, то это текст, а если о текстовом файле в некоторой кодировке, то это набор байтов, который можно вычитать как текст. Если по сети нам приходят текстовые данные, то они приходят к нам именно байтами, вместе с указанием кодировки, как из этих байтов получить текст.
Если посмотреть на Python 3 в сравнении с Python 2, то третья версия совершила по‑настоящему серьезный скачок в развитии, разделив эти два понятия. Крайне рекомендую даже опытному C/C++ разработчику поработать немного в Python 3, чтобы ощутить всю глубину, с которой произошло разделение текста и байтов на уровне языка в Python. Фактически текст в Python 3 отделен от понятия кодировки, что для разработчика C/C++ звучит крайне непривычно, строки в Python 3 отображаются одинаково в любой точке мира, и если мы хотим работать с представлением этой строки в какой‑либо кодировке, то придется преобразовать текст в набор байтов, с указанием кодировки. При этом внутреннее представление объекта типа str, по сути, не так важно, как понимание, что внутреннее представление сохранено в Юникоде и готово к преобразованию в любую кодировку, но уже в виде набора байтов типа bytes.
В C/C++ подобный механизм нам мешает ввести отсутствие такой роскоши, как потеря обратной совместимости, которую позволил себе Python 3 относительно второй версии. Одно лишь разделение типа char на аналог wchar_t и byte в одной из следующих редакций стандарта приведет к коллапсу языка и потере совместимости с непомерным количеством уже написанного кода на С/С++. Точнее, всего, на чем ты сейчас работаешь.
Веселые перекодировки
Итак, исходная проблема осталась нерешенной. У нас по‑прежнему есть однобайтовые кодировки, как UTF-8, так и старые и недобрые однобайтовые кодировки вроде кодировки Windows 1251. С другой стороны, мы задаем строковые константы широкими строками и обрабатываем текст через wchar_t — «широкие» символы.
Здесь нам на помощь придет механизм перекодировок. Ведь, зная кодировку набора байтов, мы всегда сможем преобразовать его в набор символов wchar_t и обратно. Не спеши только самостоятельно создавать свою библиотеку перекодировки, я понимаю, что коды символов любой кодировки сейчас можно найти за минуту, как и всю таблицу кодов Юникода последней редакции. Однако библиотек перекодировки достаточно и без этого. Есть кросс‑платформенная библиотека libiconv, под лицензией LGPL, самая популярная на сегодняшний день для кросс‑платформенной разработки. Перекодировка сводится к нескольким инструкциям:
iconv_t conv = iconv_open("UTF-8","CP1251");iconv(conv, &src_ptr, &src_len, &dst_ptr, &dst_len);iconv_close(conv);
Соответственно, сначала создание обработчика перекодировки из одной кодировки в другую, затем сама операция перекодировки одного набора байтов в другой (даже если один из наборов байтов на самом деле байты массива wchar_t), после чего обязательное закрытие созданного обработчика перекодировки.
Есть также и более амбициозная библиотека ICU, которая предоставляет как C++ интерфейс для работы с перекодировкой, так и специальный тип icu::UnicodeString для хранения непосредственно текста в представлении Юникода. Библиотека ICU также является кросс‑платформенной, и вариантов ее использования предоставляется на порядок больше. Приятно, что библиотека сама заботится о создании, кешировании и применении обработчиков для перекодировки, если использовать C++ API библиотеки.
Например, чтобы создать строку в Юникоде, предлагается использовать обычный конструктор класса icu::UnicodeString:
icu::UnicodeString text(source_bytes, source_encoding);
Таким образом, предлагается полностью отказаться от типа wchar_t. Проблема, однако, в том, что внутреннее представление Юникода для такой строки установлено в два байта, что влечет за собой проблему в случае, когда код за эти два байта выходит. Кроме того, интерфейс icu::UnicodeString полностью несовместим со стандартным wstring, однако использование ICU — хороший вариант для С++ разработчика.
Кроме того, есть пара стандартных функций mbstowcs и wcstombs. В общем и целом при правильно заданной локали они, соответственно, преобразуют (мульти-) байтовую строку в «широкую» и наоборот. Расшифровываются сокращения mbs и wcs как Multi-Byte String и Wide Character String соответственно. Кстати, большинство привычных разработчику на языке си функций работы со строками дублируются именно функциями, в которых в названии str заменено на wcs, например wcslen вместо strlen или wcscpy вместо strcpy.
Нельзя не вспомнить и о Windows-разработке. Счастливых обладателей WinAPI ждет очередная пара функций с кучей параметров: WideCharToMultiByte и MultiByteToWideChar. Делают эти функции ровно то, что говорят их названия. Указываем кодировку, параметры входного и выходного массива и флаги и получаем результат. Несмотря на то что функции эти внешне страшненькие, работу свою делают быстро и эффективно. Правда, не всегда точно: могут попытаться преобразовать символ в похожий, поэтому осторожнее с флагами, которые передаются вторым параметром в функцию, лучше указать WC_NO_BEST_FIT_CHARS.
Пример использования:
WideCharToMultiByte( CP_UTF8, WC_NO_BEST_FIT_CHARS, pszWideSource, nWideLength, pszByteSource, nByteLength, NULL, NULL );
Разумеется, этот код не переносим на любую платформу, кроме Windows, поэтому крайне рекомендую пользоваться кросс‑платформенными библиотеками ICU4C или libiconv.
info
Наиболее популярная библиотека — именно libiconv, однако в ней используются исключительно параметры char*. Это не должно пугать, в любом случае массив чисел любой битности — это всего лишь набор байтов. Следует, однако, помнить про направление двубайтовых и более чисел. То есть в каком порядке в байтовом массиве представлены байты — компоненты числа. Различают Big-endian и Little-endian соответственно.
Общепринятый порядок представления чисел в подавляющем большинстве машин — Little-endian: сначала идет младший байт, а в конце старший байт числа. Big-endian знаком тем, кто работает с протоколами передачи данных по сети, где числа принято передавать начиная со старшего байта (часто содержащего служебную информацию) и кончая младшим. Следует быть аккуратным и помнить, что UTF-16, UTF-16BE и UTF-16LE — не одно и то же.
Класс текста
Давай теперь аккумулируем полученные знания и решим исходную задачу: нам нужно создать сущность, по сути класс, инициализируемый строкой, либо «широкой», либо байтовой, с указанием кодировки, и предоставляющий интерфейс привычного контейнера строки std::string, с возможностью обращения к элементам‑символам, изменяя их, удаляя, преобразуя экземпляр текста в строке как «широкой», так и байтовой с указанием кодировки. В общем, нам нужно значительно упростить работу с Юникодом, с одной стороны, и получить совместимость с прежде написанным кодом, с другой стороны.
Класс текста, таким образом, получит следующие конструкторы:
text(char const* byte_string, char const* encoding);text(wchar_t const* wide_string);
Стоит перегрузить также от std::string и std::wstring вариантов, а также от итераторов начала и конца контейнера‑источника.
Доступ к элементу, очевидно, должен быть открыт, но в качестве результата нельзя использовать байтовый char или платформозависимый wchar_t, мы должны использовать абстракцию над целочисленным кодом в таблице Юникода: symbol.
symbol& operator [] (int index);symbol const& operator [] (int index) const;
Таким образом, становится очевидно, что мы не можем сохранять строку Юникода в виде char или wchar_t строки. Нам нужно как минимум std::basic_string
С другой стороны, за пределами класса text никому не нужен наш std::
, назовем его unicode_string
. Все библиотеки любят работать с std::
и std::
или char
и
wchar_t const*`. Таким образом, лучше всего кешировать как входящий std::string или std::wstring, так и результат перекодировки текста в кодировку байт‑строки. Мало того, часто наш класс text понадобится лишь как временное хранилище для путешествующей строки, например байтовой в UTF-8 из базы данных в JSON-строку, то есть перекодирование в unicode_string нам понадобится лишь по требованию обратиться к элементам — символам текста. Текст и его внутреннее представление — это тот класс, который должен быть оптимизирован по максимуму, так как предполагает интенсивное использование, а также не допускать перекодировок без причины и до первого требования. Пользователь API класса text должен явно указать, что хочет преобразовать текст в байтовую строку в определенной кодировке либо получить специфичную для системы «широкую» строку:
std::string const& byte_string(std::string const& encoding) const;std::wstring const& wide_string() const;
Как видно выше, мы возвращаем ссылку на строку, которую мы высчитали и сохранили в поле класса. Разумеется, нам нужно будет почистить кеш с std::
и std::
при первом же изменении значения хотя бы одного символа, здесь нам поможет operator → от неконстантного this класса данных text::
. Как это делать, смотри предыдущие два урока академии C++.
Нужно не забыть также и о получении char const* и wchar_t const*, что несложно делается, учитывая то, что std::string и std::wstring кешируются полями класса text.
char const* byte_c_str(char const* encoding) const;wchar_t const* wide_c_str() const;
Реализация сводится к вызову c_str(
у результатов byte_string и wide_string соответственно.
Можно считать кодировкой по умолчанию для байтовых строк UTF-8, это гораздо лучше, чем пытаться работать с системной кодировкой по умолчанию, так код в зависимости от системы будет работать по‑разному. Введя ряд дополнительных перегрузок без указания кодировки при работе с байтовыми строками, мы также получаем возможность переопределить оператор присвоения:
// в кодировке ”UTF-8”text& operator = (std::string const& byte_string);text& operator = (std::wstring const& wide_string);
Нужно также не забыть о перегрузке операторов + и +=, но в целом остальные операции можно уже сводить к аргументу и результату типа text, универсальному значению, предоставляющему текст вне зависимости от кодировки.
Разумеется, Академия C++ не была бы академией, если бы я не предложил тебе теперь реализовать класс текста самостоятельно. Попробуй создать класс text на основе материала этой статьи. Реализация должна удовлетворять двум простым свойствам:
- Классом должно быть удобнее пользоваться, чем стандартными строками, вдобавок класс предоставляет совместимость либо взаимное преобразование с типами
std::
,string std::
,wstring char
иconst* wchar_t
.const* - Класс подразумевает максимальную оптимизацию, работа со строками не должна быть дороже, чем при работе со стандартными std::string и std::wstring. То есть никаких неявных перекодировок, пока API явно не подразумевает перекодировку содержимого, иначе классом никто не будет пользоваться.
Здесь как раз имеет смысл обработать дополнительно неконстантный operator
` для сброса кеша со строками, однако оставляю это на усмотрение разработчика. То есть тебя. Удачи!
Разумеется, в реализации не обойтись без класса copy_on_write
из предыдущих статей. Как обычно, на всякий случай напоминаю его упрощенный вид:
template <class data_type> class copy_on_write { public: copy_on_write(data_type* data) : m_data(data) { } data_type const* operator -> () const { return m_data.get(); } data_type* operator -> () { if (!m_data.unique()) m_data.reset(new data_type(*m_data)); return m_data.get(); } private: std::shared_ptr<data_type> m_data; };
Что мы получаем
Реализовав класс text, мы получим абстракцию от множества кодировок, все, что нам потребуется, — одна перегрузка от класса text. Например, так:
text to_json() const;void from_json(text const& source);
Нам больше не нужно множество перегрузок от std::
и std::
, не нужно будет переходить на поддержку «широких» строк, достаточно заменить в API ссылки на строки на text, и получаем Юникод автоматом. Вдобавок мы получаем отличное кросс‑платформенное поведение, вне зависимости от того, какую библиотеку мы выбрали в качестве движка перекодировки, — ICU4C или libiconv, ввиду того, что внутреннее представление у нас всегда UTF-32 при распаковке символов и мы нигде не завязаны на платформозависимый wchar_t.
Итого: у нас есть совместимость либо взаимоконвертация со стандартными типами, а значит, и упрощение поддержки Юникода на стороне кода, написанного на С++. Ведь если мы пишем высокоуровневую логику на C++, меньше всего нам хочется получить проблемы при использовании wchar_t символов и кучи однообразного кода при обработке и перекодировке текста.
При том что сама перекодировка уже реализована в тех же ICU4C и libiconv, алгоритм для внутренней работы класса text довольно прост. Дерзай, и, может, уже завтра именно твоя библиотека работы с текстом будет использоваться повсюду в качестве высокоуровневой абстракции при обработке любых текстовых данных, от простого JSON с клиента до сложных текстовых структур со стороны различных баз данных.