Pull to refresh

Грустная история забытых символов. Как не сойти с ума при работе с кодировками в C++

Reading time 15 min
Views 79K


Говоря о тексте, большинство программистов C++ думают о массивах кодов символов и кодировке, которой эти коды соответствуют. Наиболее опытные разработчики вообще не мыслят понятие текста без указания кодировки, наименее опытные просто считают массив байтов с кодами символов данностью и интерпретируют в понятиях кодировки операционной системы. Фундаментальная разница между этими двумя подходами не только в опыте разработчика, но и в том, что не думать о кодировке намного проще. Пора рассмотреть способ, как не заботиться о хранении кодировки, перекодировке текста, получать свободный доступ к символам и при этом видеть безошибочное представление текста вне зависимости от того, кто и где смотрит на строку текста: в Китае ли, в США или на острове Мадагаскар.

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 весьма занятно. Вот основные принципы:

  1. Символ кодируется последовательностью байтов, в каждом байте лидирующие биты кодируют позицию байта в последовательности, а для первого байта еще и длину последовательности. Например, так выглядит в UTF-8 символ Я: [1101 0000] [1010 1111]
  2. Байты последовательности, начиная со второго, всегда начинаются с битов 10, соответственно, первый байт последовательности кода каждого символа начинаться с 10 не может. На этом строится основная проверка корректности декодирования кода символа из UTF-8.
  3. Первый байт может быть единственным, тогда лидирующий бит равен 0 и символ соответствует коду ASCII, поскольку для кодирования остается 7 младших бит.
  4. Если символ не ASCII, то первые биты содержат столько единиц, сколько байт в последовательности, включая лидирующий байт, после чего идет 0 как окончание последовательности единиц и потом уже значащие биты первого байта. Как видно из приведенного примера, кодирование символа Я занимает 2 байта, это можно распознать по старшим двум битам первого байта последовательности.
  5. Все значащие биты склеиваются в единую последовательность бит и уже интерпретируются как число. Например, для любого символа, кодируемого двумя байтами, значащие биты я условно помечу символом 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 и дублируют «широкий» функционал обработкой обычных байтовых строк.

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.

Наиболее популярная библиотека — именно 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<int32_t>, поскольку на данный момент кодировки UTF-8 и UTF-16 кодируют символы в пределах int32_t, не говоря про UTF-32.

С другой стороны, за пределами класса text никому не нужен наш `std::basic_string<int32_t>`, назовем его `unicode_string`. Все библиотеки любят работать с `std::string` и `std::wstring` или `char const*`` и `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::string` и `std::wstring` при первом же изменении значения хотя бы одного символа, здесь нам поможет operator -> от неконстантного this класса данных `text::data`. Как это делать, смотри предыдущие два урока академии 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, это гораздо лучше, чем пытаться работать с системной кодировкой по умолчанию, так код в зависимости от системы будет работать по-разному. Введя ряд дополнительных перегрузок без указания кодировки при работе с байтовыми строками, мы также получаем возможность переопределить оператор присвоения:

text& operator = (std::string const& byte_string); // в кодировке ”UTF-8”
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::string` и `std::wstring`, не нужно будет переходить на поддержку «широких» строк, достаточно заменить в API ссылки на строки на text, и получаем Юникод автоматом. Вдобавок мы получаем отличное кросс-платформенное поведение, вне зависимости от того, какую библиотеку мы выбрали в качестве движка перекодировки, — ICU4C или libiconv, ввиду того, что внутреннее представление у нас всегда UTF-32 при распаковке символов и мы нигде не завязаны на платформозависимый wchar_t.

Итого: у нас есть совместимость либо взаимоконвертация со стандартными типами, а значит, и упрощение поддержки Юникода на стороне кода, написанного на С++. Ведь если мы пишем высокоуровневую логику на C++, меньше всего нам хочется получить проблемы при использовании wchar_t символов и кучи однообразного кода при обработке и перекодировке текста.

При том что сама перекодировка уже реализована в тех же ICU4C и libiconv, алгоритм для внутренней работы класса text довольно прост. Дерзай, и, может, уже завтра именно твоя библиотека работы с текстом будет использоваться повсюду в качестве высокоуровневой абстракции при обработке любых текстовых данных, от простого JSON с клиента до сложных текстовых структур со стороны различных баз данных.

image

Впервые опубликовано в журнале Хакер #191.
Автор: Владимир Qualab Керимов, ведущий С++ разработчик компании Parallels


Подпишись на «Хакер»
Tags:
Hubs:
+24
Comments 71
Comments Comments 71

Articles

Information

Website
xakep.ru
Registered
Founded
Employees
51–100 employees
Location
Россия