Этот пост написан по моему вопросу «
Why in C++ are some characters in a multibyte UTF-8 string represented by negative numbers?» в англоязычной части сайта вопросов и ответов «Stack Overflow». Там пользователи сайта своими комментариями помогли мне разобраться в этом вопросе и я сам же написал на свой вопрос ответ, развернув то, что понял из комментариев.
Появление этого вопроса
Я экспериментировал с кодировкой UTF-8 и написал следующий несложный исходный код, который сохранил в файле «chars.cpp» в кодировке UTF-8 без метки BOM (с окончаниями строк CRLF):
#include
#include
int main()
{
char str[] = "Hello, привет, 😎!";
std::cout << str << "\n\n";
for (int i = 0; i < std::strlen(str); i++) {
std::cout << (int) str[i] << ' ';
} std::cout << "\n\n";
for (int i = 0; i < std::strlen(str); i++) {
std::cout << std::hex << (int) str[i] << ' ';
} std::cout << "\n\n";
for (int i = 0; i < std::strlen(str); i++) {
std::cout << std::hex << (str[i] & 0xff) << ' ';
} std::cout << '\n';
return 0;
}
Этот код я скомпилировал и запустил в операционных системах «Windows 10» и «Ubuntu» (через подсистему «WSL 2»). В «Windows 10» я использую компилятор MSVC (cl.exe) из набора инструментов командной строки «Microsoft C++ Build Tools». В «Ubuntu» я использую компилятор «g++» из набора компиляторов «GCC». Вот как у меня выглядят команды запуска этих компиляторов:
cl /EHsc /utf-8 "chars.cpp"
g++ /mnt/c/Users/Илья/source/repos/test/chars.cpp -o chars
В консоли я получил следующий результат работы этой программы (в операционной системе «Windows 10» требуется предварительная настройка активной кодовой страницы, должна быть кодовая страница 65001 для кодировки UTF-8; в операционной системе «Ubuntu» никаких дополнительных настроек консоли не требуется):
Hello, привет, 😎!
72 101 108 108 111 44 32 -48 -65 -47 -128 -48 -72 -48 -78 -48 -75 -47 -126 44 32 -16 -97 -104 -114 33
48 65 6c 6c 6f 2c 20 ffffffd0 ffffffbf ffffffd1 ffffff80 ffffffd0 ffffffb8 ffffffd0 ffffffb2 ffffffd0 ffffffb5 ffffffd1 ffffff82 2c 20 fffffff0 ffffff9f ffffff98 ffffff8e 21
48 65 6c 6c 6f 2c 20 d0 bf d1 80 d0 b8 d0 b2 d0 b5 d1 82 2c 20 f0 9f 98 8e 21
Сначала я обратил внимание на то, что некоторые числа из представляющих символы в строке выведены в шестнадцатеричном исчислении в виде последовательностей вида ffffffd0. Это значит, что они занимают 4 байта вместо одного. Но потом я вспомнил, что перед выводом преобразую все символы в тип int, который и занимает 4 байта. Так что с длиной чисел в 4 байта тут всё в порядке, так и должно быть. Например, ffffffd0 выводится как ffffffd0, а 00000048 выводится как 48 (ведущие нули по умолчанию не выводятся).
Затем я стал думать, почему некоторые символы в строке представлены положительными числами, а другие - отрицательными. (Ведущие шестнадцатеричные цифры f в шестнадцатеричных числах вида ffffffd0 в данном случае означают, что это число отрицательное.
Тут подробнее.) Так родился этот вопрос, по которому я в итоге создал вышеупомянутый пост на сайте «Stack Overflow».
Ответ
Как видно из блока исходного кода в начале поста, я использую в коде тип char. На самом деле, физически (в виде единиц и нулей) типа char не существует, это лишь абстракция для обозначения символа в рамках языка C++.
Действующий стандарт языка C++ (на данный момент это ISO/IEC 14882:2020) разрешает компиляторам интерпретировать тип char либо как тип signed char, либо как тип unsigned char. При этом, если в одной и той же программе будут использоваться тип char и другие два перечисленных типа (по отдельности или все вместе), то эти три типа всё равно будут считаться разными (по-английски «distinct») типами. (
Тут подробнее.)
Так что из себя представляет тип char физически именно в моем случае? Это зависит от используемого компилятора и от используемой операционной системы. В моем случае оба используемых компилятора (MSVC и g++) по умолчанию интерпретируют тип char в физическом смысле так же, как тип signed char, при этом не смешивая их.
То есть в моем случае каждый символ строки представляется числом, лежащим в диапазоне -128..127 (этот диапазон определяется типом signed char; слово «signed» переводится на русский как «со знаком», то есть имеется в виду, что символ может быть представлен как положительным, так и отрицательным числом).
Рассмотрим, как это работает на практике. Возьмем для примера русскую строчную букву «п». В таблице Юникода она обозначается как U+043F. В кодировке UTF-8 (одно из физических представлений Юникода) эта буква представляется двумя байтами d0 bf (шестнадцатеричное исчисление) или 208 191 (десятичное исчисление).
Поскольку числа 208 191 «не влезают» в имеющийся возможный диапазон -128..127, определенный типом char, трактуемым компилятором в данном случае как тип signed char (но без смешивания этих типов), то эти числа преобразуются в отрицательные -48 -65 (208 - 256, 191 - 256). Таким же образом обрабатываются все символы в строке. Тут следует обратить внимание, что, если число попадает в диапазон 0..127 (таблица ASCII), то его никак не преобразуют, в этом нет необходимости.
Такое поведение компиляторов можно поменять с помощью специальных ключей. Чтобы компилятор MSVC интерпретировал тип char как тип unsigned char (не смешивая их), можно использовать ключ /J (
тут подробнее):
cl /EHsc /utf-8 /J "chars.cpp"
При работе с компилятором «g++» для того же можно использовать ключ -funsigned-char (
тут подробнее):
g++ /mnt/c/Users/Илья/source/repos/test/chars.cpp -o chars -funsigned-char
После компиляции с этими дополнительными ключами тот же исходный код, который был показан в блоке кода в начале поста, при запуске выдаст другой результат:
Hello, привет, 😎!
72 101 108 108 111 44 32 208 191 209 128 208 184 208 178 208 181 209 130 44 32 240 159 152 142 33
48 65 6c 6c 6f 2c 20 d0 bf d1 80 d0 b8 d0 b2 d0 b5 d1 82 2c 20 f0 9f 98 8e 21
48 65 6c 6c 6f 2c 20 d0 bf d1 80 d0 b8 d0 b2 d0 b5 d1 82 2c 20 f0 9f 98 8e 21
Как видно из блока кода выше, среди чисел, представляющих символы в строке, теперь уже нет отрицательных. Почему? Потому что тип char теперь трактуется компилятором как тип unsigned char (без их смешения; слово «unsigned» переводится на русский как «без знака» или «беззнаковый», то есть имеется в виду, что для представления символа используются только положительные числа). При этом для представления каждого байта используется диапазон чисел 0..255, то есть тут просто не может быть отрицательных чисел.