О кириллице в программах на C++

newbie_

Опубликован:  2019-07-23T08:16:02.905315Z
Отредактирован:  2019-07-23T08:35:20.107741Z

В порядке подготовки к одному более или менее серьёзному проекту на C++ озадачился я вопросами представления, ввода и вывода данных в программах на C++, в частности, меня интересует кириллический текст и обработка строк, его представляющих. Как выяснилось, не напрасно озадачился, об этом и пойдёт речь в этом выпуске блога.

Русский язык - мой родной и горячо любимый, хоть и не единственный в арсенале, и практическое программирование так или иначе всегда будет связано для меня с обработкой данных в форме русскоязычных текстов. Мой предыдущий опыт программирования, а это были высокоуровневые языки программирования Python3 и JavaScript, не давал ни малейшего повода задумываться о способе представления кириллических текстов в своих программах, строки Python3 - великолепный и удобный инструмент, с помощью которого решаются все проблемы, не хуже обстоят дела и в JavaScript. Но мой первый опыт с C++ показывает, что с этим диалектом придётся искать подход к обработке кириллических текстов. Я провёл небольшое начальное исследование проблемы, выводы попытаюсь изложить в этом тексте.

Языковые вопросы в программировании выделены в особую статью, которая в обычной практике получила название - локализация. Локализация подразумевает несколько аспектов, один из аспектов локализации как раз и определяет способ кодирования и представления данных на национальном языке. Любая операционная система имеет собственные параметры и настройки локализации, от которых будет зависеть и локализация написанных для этой операционной системы программ, обычно эту совокупность параметров и настроек называют локалью, и прежде чем писать программу с вводом и выводом на национальном языке, стоит хотя бы поверхностно заглянуть в локаль своей операционной системы. В Debian для этого есть соответствующая консольная утилита locale, и выхлоп этой утилиты на моём компьютере выглядит следующим образом.

r3tpDkdT3a.png

Как видно на снимке экрана, в моей операционной системе настроена локаль ru_RU.UTF-8, следует обратить внимание на последнюю строчку выхлопа - параметр LC_ALL, который в качестве значения имеет пустую строку. В обозначении локали присутствует обозначение языка локали и кодировки локали, в данном случае имеет место кодировка UTF-8. Ниже мы увидим, что программы, написанные на C++, для правильной работы в данной локали должны иметь соответствующие настройки, но об этом ниже...

С++ в отличие от Python3 и JavaScript является низкоуровневым языком программирования со статической типизацией, написанные на нём программы демонстрируют лучшую производительность, кроме этого диалект предоставляет великолепные возможности оптимизации - это безусловно плюс. Но есть и минусы... Для работы с текстовыми данными у C++ есть собственный класс объектов - string, и надо признать, что этот класс тоже достаточно низкоуровневый. Базируется он на типе char, а переменные этого типа могут занимать в памяти компьютера ячейку размером всего-навсего 8 бит - этого достаточно, чтобы закодировать все символы английского алфавита, и используется для этого кодировка ASCII. В случае с русскоязычными текстами класс string прекрасно хранит данные и неплохо обеспечивает ввод и вывод, но совершенно не подходит для качественной обработки этих данных. Рассмотрим элементарную демонстрацию... Открываю Geany, создаю файл с именем sstring.cpp и пишу в него следующий код.

#include <iostream>
#include <string>

int main()
{
  std::string word = u8"Эталон";
  std::cout << word << std::endl;
  std::cout << u8"Количество знаков: " << word.size() << std::endl;
  for (int i = 0; i < word.size(); i++)
    std::cout << word[i] << ' ';
  std::cout << std::endl;
  return 0;
}

В этой элементарной программе я создал переменную типа string и присвоил ей значение - кириллическую строку в кодировке UTF-8, о чём свидетельствует соответствующая литера в начале строки - u8. Все текстовые файлы созданные в моей операционной системе, если имеют символы национального алфавита, автоматически получают кодировку локали - UTF-8. Литера u8 как раз и извещает об этом компилятор. Далее я вывел созданную переменную на терминал, попробовал определить количество знаков в заданной строке и попросил программу вывести на экран терминала все составляющие строку символы через пробел - это элементарные манипуляции со строкой. Посмотрим, как класс string справится с предложенной последовательностью действий. Компилирую программу и запускаю.

g++ -o exe sstring.cpp
./exe

В итоге получаю на экран терминала следующий выхлоп.

7NEUlfvJQj.png

Как видно, программа великолепно сохранила в отобразила на экране терминала заданную строку, при этом определила в строке 12 знаков вместо 6, и не справилась с выводом на экран всех символов строки через пробел. Меня постигла неудача уже на элементарных операциях со строкой, результат предсказуемый, всё дело в базовом типе char и его размере, проблема заключается в том, что закодированные юникодом UTF-8 кириллические символы требуют 16 бит для хранения в памяти. Таким образом я делаю первый вывод: сохранённая в переменной string кириллическая строка может быть использована в тексте программы только как байт-строка, для качественной обработки которой потребуются дополнительные мероприятия.

В арсенале C++ для работы со строками имеется ещё один базовый класс - wstring, который в свою очередь базируется на базовом типе wchar_t, и в свою очередь этот базовый тип предполагает хранение переменных в ячейке памяти размером 32 бита. Стоит взглянуть на тип wchar_t, создаю ещё один файл с именем wchar.cpp и пишу в него следующий код.

#include <cstdint>
#include <iostream>

int main()
{
  std::cout << "Size of wchar_t: " << sizeof(wchar_t)
            << " bytes." << std::endl;
  std::cout << "Min value of wchar_t: " << WCHAR_MIN << std::endl;
  std::cout << "Max value of wchar_t: " << WCHAR_MAX << std::endl;
  return 0;
}

Опять компилирую и запускаю программу в терминале.

pQAtLtqQiv.png

Выхлоп этой программы показывает, что в моей системе тип wchar_t хранит переменные в ячейках памяти размером 32 бит и может иметь как положительные, так и отрицательные значения (является signed-типом). Вполне логичным будет ожидать, что для хранения и обработки UTF-8 юникод-строк класс wstring окажется вполне приемлемым выбором. Посмотрим, с какими проблемами я столкнусь в этом случае. Создаю ещё один файл - wsstring.cpp и даю ему следующее содержимое.

#include <iostream>
#include <string>

int main()
{
  std::wstring wword = L"Эталон";
  std::wcout << wword << std::endl;
  std::wcout << L"Количество знаков: " << wword.size() << std::endl;
  for (int i = 0; i < wword.size(); i++)
    std::wcout << wword[i] << ' ';
  std::wcout << std::endl;
}

Здесь я создал переменную типа wstring - wword, присвоил ей соответствующее задаче значение, следует обратить внимание на литеру L - она сообщает компилятору, что он имеет дело с широкой строкой. Далее я вывел на экран саму строку, попытался найти количество символов в этой строке и вывести на экран каждый символ строки через пробел - всё те же элементарные операции, только вместо string - wstring, а вместо cout - wcout. Посмотрим, будет ли мне удача на этом пути, компилирую и исполняю программу.

gtdZ9ccnF1.png

Упс..! Меня постигла неудача. Как видно на снимке экрана программа правильно определила длину строки, но абсолютно не справилась с выводом строки на терминал, потому что вместо заданных символов я вижу вопросительные знаки. И это опять ожидаемый результат. Я не сообщил программе в какой локали интерпретировать код, и она интерпретировала мой код в локали по-умолчанию, для моего компилятора это локаль C.UTF-8. Выход из положения находится достаточно просто, следует добавить в исходный код одну единственную строчку.

  std::setlocale(LC_ALL, "");

Эта строчка устанавливает локаль для данной конкретной программы, и в данном случае я задал для параметра LC_ALL в качестве значения пустую строку. Где-то это уже было, ах да, в выхлопе команды locale. Посмотрим как изменится характер вывода моей программы. Опять компилирую и исполняю программу.

eNCL81vGnJ.png

Всё встало на свои места, данные отобразились в полном соответствии с заданной локалью, количество символов определено верно, и вывод символов на экран через пробел произошел успешно. Таким образом класс wstring можно рассматривать в качестве главного кандидата для хранения и обработки кириллических строк в программах на C++.

Вроде бы все точки над i стоят, но меня всё равно что-то тревожит... Дело в том, что вполне вероятна ситуация, когда в одной программе будут сочетаться переменные типов string и wstring, а значит, возможно, потребуется качественный трансфер данных из одного типа в другой. И такой трансфер вполне возможен, хоть и предполагает дополнительные трудности.

Рассмотрим перевод данных из строки типа string в строку типа wstring. Стандартная библиотека С++ имеет в своём арсенале модуль codecvt, который и позволит справиться с этой задачей. Создаю ещё один файл - stow.cpp, пишу в него такой код.

#include <codecvt>
#include <iostream>
#include <string>
#include <locale>


int main()
{
  std::setlocale(LC_ALL, "");
  std::string word = u8"Эталон";
  std::wstring_convert<std::codecvt_utf8_utf16<wchar_t> > converter;
  std::wstring wword = converter.from_bytes(word);
  std::wcout << L"Количество знаков: " << wword.size() << std::endl;
  for (wchar_t c: wword)
    std::wcout << c << ' ';
  std::wcout << std::endl;
  return 0;
}

Здесь я задал переменную типа string - word, задал ей значение, затем с помощью соответствующих инструментов перекодировал хранящиеся в этой переменной данные и поместил их в переменную типа wstring - wword, а затем попытался осуществить с ней элементарные операции. Посмотрим, всё ли хорошо. Компилирую и исполняю программу.

aRjgRSTPqD.png

Успех..! Перевод данных из переменной одного типа в другой осуществлён без потерь, можно ожидать, что с кириллическим текстом всё будет в порядке при такой возможной обработке. Здесь следует заметить, что использованные для такого трансфера инструменты получили метку deprecated в грядущем стандарте C++20, остаётся надеяться, что грядущее приготовило нам не только плохие новости...

Теперь рассмотрим обратное преобразование данных, попробуем передать данные из строки типа wstring в строку типа string. Создаю ещё один файл - wstos.cpp, и даю ему следующий код.

#include <codecvt>
#include <iostream>
#include <string>
#include <locale>

int main()
{
  std::setlocale(LC_ALL, "");
  std::wstring wword = L"Эталон";
  std::wstring_convert<std::codecvt_utf8<wchar_t> > converter;
  std::string word = converter.to_bytes(wword);
  std::cout << word << std::endl;
  return 0;
}

Компилирую и исполняю этот код.

NPwck3Lo4h.png

Опять успех, заданная строка без потерь переведена из одного типа в другой и выведена на печать.

Последнее, на что мне хочется обратить своё внимание в рамках этого обзора, - это способ преобразования юникод строки из типа string в тип wstring, для такого преобразования в файле stow.cpp я создавал соответствующий конвертер:

  std::wstring_convert<std::codecvt_utf8_utf16<wchar_t> > converter;

Соответствующий метод этого объекта и выполняет заданное преобразование. Здесь следует обратить внимание, что преобразование осуществляется следующим образом: юникод UTF-8 преобразуется в юникод UTF-16. В связи с этим, мне очень хочется сравнить природу юникода в Python3 с таким преобразованием. Для этого я напишу ещё одну программу на C++, возьму эталонную строку, преобразую её тем же конвертером и выведу на терминал hex-коды каждого символа этой строки. Код этой программы будет выглядеть следующим образом.

#include <codecvt>
#include <iostream>
#include <string>
#include <locale>


int main()
{
  std::setlocale(LC_ALL, "");
  std::string word = u8"Эталон";
  std::wstring_convert<std::codecvt_utf8_utf16<wchar_t> > converter;
  for (wchar_t c: converter.from_bytes(word))
    std::cout << std::hex << std::showbase << c << std::endl;
  return 0;
}

Компилирую и пробую исполнить эту программу.

hKG1X6V6YS.png

Теперь произвожу аналогичные манипуляции с аналогичной строкой в интерпретаторе Python3.

i63e0I2G2g.png

И вижу одну природу кириллических символов в обоих случаях, это значит, что в случае с питоном имеет место такое же перекодирование UTF-8 -> UTF-16. Остаётся только догадываться, что произойдёт, если обрабатываемая программой UTF-8 строка будет иметь символы, реализации которых нет в кодовой таблице UTF-16.

В заключении хочу сообщить, что для обработки текстовых данных в юникоде на C++ разработана специализированная библиотека с открытым исходным кодом, которая в Debian buster представлена пакетом libicu-dev, и, видимо, есть смысл озаботиться изучением этой библиотеки и предоставленных ею инструментов. Но это тема отдельной большой статьи...

Комментарии: