
Как это работает — проверяем управляющие биты
Пишем по-человечески — более универсальный подход
Всегда найдется место для оптимизации
Наш последний эксперимент показал, что количество символов в строке мы правильно посчитать не можем. Однако некоторые мысли на этот счет уже появились. Для начала кое-что нужно проверить.
«Закроем» нашу строку с помощью комментирования
// char some_string[] = "Иногда самое сложное - придумать простую задачу";
и наберем такой вариант:
char some_string[] = "My name is Boris";
Таким образом, наша программа будет выглядеть так:
#include <stdio.h>
#include <string.h> // Модуль для работы со строками
int main() {
// char some_string[] = "Иногда самое сложное - придумать простую задачу";
char some_string[] = "My name is Boris";
printf("%s\n", some_string);
int char_count = 0; // Объявляем "счетчик" и инициализируем его нулем
// Далее - цикл "с предусловием": "пока" ("условие") {"код"}
while (some_string[char_count] != '\0') {
printf("%c", some_string[char_count]); // Выводим очередной символ на экран
char_count++; // "Наращиваем" счетчик
}
// Выводим значение счетчика
printf("\nДлина строки, что сами посчитали - %d символов\n", char_count);
// А тут длину строки узнаем с помощью функции strlen()
printf("Длина строки, что функция strlen() вернула - %zu символов\n", strlen(some_string));
return 0;
}
Сохраняем текст программы в файле с именем proj03.c, компилируем и запускаем:
username ~/Папка_с_программой $ gcc -Wall -o proj03 proj03.c username ~/Папка_с_программой $ ./proj03 My name is Boris My name is Boris Длина строки, что сами посчитали - 16 символов Длина строки, что функция strlen() вернула - 16 символов username ~/Папка_с_программой $
Ага, всё считается, и притом считается корректно. Как вручную, так и с помощью функции strlen(). Но только для строки с буквами, набранными латиницей. А вместе с тем вывод в консоль, даже с кириллицей, вполне работал. В чем же дело?
Учитываем кодировку — utf8
Собрав все факты воедино и немного пораскинув мозгами, приходим к выводу, что скорее всего дело в применяемой операционной системой кодировке символов. А точнее — в несоответствии размерности типа char в нашей программе реальному количеству памяти, необходимому для представления символов кириллицы. Достаточно немного почитать немного о кодировках и станет понятно даже то, почему длина строки с буквами на латинице подсчитывается нами корректно.
Ситуация примерно такая: элементы типа char в нашей программе, видимо, имеют размер в один байт. И массив из таких элементов, видимо, индексируется по одному байту. Но в системе у нас установлена кодировка utf8 (unicode). Замечательная кодировка. И тут самое интересное. Для представления стандартных символов вроде пробелов и знака «минус», а также всех символов латиницы нашей кодировке достаточно одного байта — в подобном случае она совместима с 7-битной ASCII (где все символы отлично умещаются в диапазон значений, определяемый семью младшими битами, при этом старший — восьмой, он же служебный — бит равен нулю). А вот если попадается что-то другое, в том числе и символы кириллицы, одного байта становится мало. В общем случае для представления символа utf8 может задействовать от одного до четырех байт.
Разумнее всего было бы ознакомиться с описанием стандартов на кодировки или, по крайней мере, почитать Википедию. И мы её непременно прочитаем. А сейчас посмотрим на ситуацию примитивно. Вот как можно с этим работать в нашей программе: берем первый элемент строки, считая его байтом, и «смотрим» на старший бит этого байта. Если старший бит равен 0, значит этот байт — и есть весь символ. Посчитали его — нарастили счетчик символов. Если же старший бит равен 1 — значит на символ приходится более одного байта (и текущий байт лишь часть данных символа). В нашем случае мы «догадываемся», что на символы кириллицы в текущей кодировке приходится два байта. Поэтому следующий байт надо «не считать», пропустить. Другими словами, просмотрев два байта счетчик символов нарастим только на единицу. Переходим к следующему байту. И так далее, пока не доберемся до нашего '\0'.
Дело за малым — написать соответствующий цикл для прохода по элементам строки, добавив в него проверку старшего бита в байте текущего элемента и какой-нибудь «переключатель», позволяющий игнорировать в подсчете «лишние» байты. Напишем же подобное, поняв попутно, что это будет своеобразный «хак», работающий только для нашего случая в нашей текущей кодировке:
#include <stdio.h>
#include <string.h>
int main() {
char some_string[] = "Иногда самое сложное - придумать простую задачу";
printf("%s\n", some_string);
int char_count = 0; // Объявляем "счетчик" и инициализируем его нулем
int ext_flag = 0; // Флаг utf-8 расширения до 2 байт
// Пройдем в цикле по всем элементам массива
for (int i = 0; some_string[i] != '\0'; i++) {
if ( (unsigned char)some_string[i] & (unsigned char)128 ) {
switch (ext_flag) {
case 0: ext_flag = 1;
break;
case 1: ext_flag = 0;
}
}
printf("%c", some_string[i]); // Выводим очередной символ на экран
if (!ext_flag) {
char_count++;
}
}
// Выводим значение счетчика
printf("\nДлина строки, что сами посчитали - %d символов\n", char_count);
// А тут длину строки узнаем с помощью функции strlen()
printf("Длина строки, что функция strlen() вернула - %zu символов\n", strlen(some_string));
return 0;
}
Отличный пример, как делать не надо. Если код работает — необязательно он правильный (возможно, это просто случайность). Но иногда сначала делаешь, а потом думаешь 🙂 Что нам было нужно — проверить старший бит текущего элемента-символа (предположительно, байта). Если он равен 0, то этот символ состоит из одного байта, если 1 — из нескольких (в нашем случае из двух).
Сохраняем, компилируем и запускаем:
username ~/Папка_с_программой $ gcc -Wall -o proj03 proj03.c username ~/Папка_с_программой $ ./proj03 Иногда самое сложное - придумать простую задачу Иногда самое сложное - придумать простую задачу Длина строки, что сами посчитали - 47 символов Длина строки, что функция strlen() вернула - 87 символов username ~/Папка_с_программой $
Как это работает — проверяем управляющие биты
Ну что же, работает. А теперь рассмотрим код. Мы снова вернули объявление строки с символами на кириллице (проще говоря, русский текст). Объявили переменную-флаг ext_flag типа int и инициализировали её значением 0. Будем использовать эту переменную в цикле для «обозначения» случаев, когда нам нужно «посчитать» лишь половину от «двухбайтового» символа. Далее — используем цикл for для прохода по всем элементам (символам) нашей строки.
Чтобы узнать, чему равен старший бит текущего байта, его нужно как-то «выделить», рассмотреть отдельно от остальных. Самый простой способ — «замаскировать» остальные биты (например обнулить). Для этого в языке C есть удобная побитовая операция «&». Она выполняет для каждого бита в байте «логическое И». Если коротко: результат «логического И» между двумя битами равен 1, если каждый из них равен 1, во всех остальных случаях результат равен 0. Как это использовать? Допустим, у нас есть некий байт, равный в двоичном представлении такой последовательности бит — «10011010». Итого восемь бит, из которых нас интересует старший (крайний слева). Чтобы выделить его, нам достаточно выполнить побитовую операцию «&» между нашим байтом и «маской», которая выглядит так — «10000000» (это, кстати, равно десятичному значению 128). И в тех позициях, где у байта-маски стоят нули, результат также будет иметь нули. Получим 10011010 & 10000000 = 10000000 (обнулили все биты, кроме старшего). Так и поступим в в цикле — там, где первая проверка. На всякий случай используем явное приведение типа к беззнаковому виду — (unsigned char)some_string[i], чтобы биты в байте стандартно располагались (с битами вообще надо быть повнимательнее). И еще используем соглашение языка C относительно логических («булевых») выражений: нулевое значение выражения — это «ложь» («false»), ненулевое — «истина» («true»). Таким образом, если побитовая операция «&» текущего байта и байта-маски, равного 128 («10000000» в двоичном представлении), выявит ненулевой старший бит, то и само выражение даст ненулевое значение — то есть «истина» для условия if (в данном случае конкретно 128):
if ( (unsigned char)some_string[i] & (unsigned char)128 ) {
}
И если условие истинно, то делаем нечто совсем невразумительное: с помощью конструкции swith меняем значение флага ext_flag на логически противоположенное (ноль на единицу и наоборот). Это, мягко говоря, очень оригинальный метод «деления пополам». И это дает нам возможность считать двухбайтовые последовательности как однобайтовые. Работает лишь потому, что больше двух байт на символ нам не попадется, и потому, что дополнительные байты тоже содержат ненулевой старший бит. Поэтому подобное считаем «хаком». Оставим пока так (раз уж сделали), а позже реализуем «по-человечески».
Далее «отправляем на вывод» очередной элемент строки. Кстати, поток вывода все символы принимает и корректно выводит. И еще одна условная конструкция if — если «не ext_flag», то «нарастить на единицу char_count» (собственно, это и есть подсчет символов). Когда ext_flag == 0, наращиваем char_count, когда ext_flag != 0, не наращиваем.
Пишем по-человечески — более универсальный подход
И всё-таки, лучше реализовать нашу программу по-человечески. Поэтому ознакомимся с описанием utf8. Теперь мы знаем, что для символа доступны всего четыре случая — символ может быть представлен одним, двумя, тремя или четырьмя байтами. И чтобы понять, сколько именно байт приходится на символ, достаточно посмотреть на старшие биты первого байта символа: «0xxxxxxx» — символ представлен единственным байтом (здесь utf8 совместим с ASCII), «110xxxxx» — символ представлен двумя байтами, «1110xxxx» — символ представлен тремя байтами, «11110xxx» — символ представлен четырьмя байтами (это «управляющие» биты, а «x» — значащие биты самого символа в unicode). А у всех дополнительных байтов будет «10xxxxxx» (это чтобы в произвольном байтовом потоке всегда можно было отличить первый байт символа от дополнительных).
Теперь у нас достаточно информации. Будем анализировать байты и выставлять «счетчик» дополнительных байтов в соответствующее значение, чтобы счетчик символов наращивался только для «целого» символа. Переписываем нашу программу (в файл с именем proj04.c):
#include <stdio.h>
#include <string.h>
#define B_10000000 128 // ...и маска для 0xxxxxxx
#define B_11000000 192 // ...и маска для 10xxxxxx
#define B_11100000 224 // ...и маска для 110xxxxx
#define B_11110000 240 // ...и маска для 1110xxxx
#define B_11111000 248 // ...и маска для 11110xxx
int main() {
char some_string[] = "Иногда самое сложное - придумать простую задачу";
printf("%s\n", some_string);
int char_count = 0; // Объявляем "счетчик" и инициализируем его нулем
int extr_count = 0; // Счетчик-количество дополнительных байт на один символ
for (int i = 0; some_string[i] != '\0'; i++) {
// Проверяем старший бит текущего байта на "ненулевое" значение
if ( (unsigned char)some_string[i] & (unsigned char)B_10000000 ) {
if ( ((unsigned char)some_string[i] & (unsigned char)B_11100000) == (unsigned char)B_11000000 ) {
extr_count = 1; // Требуется 1+1 = 2 байта на символ
} else if ( ((unsigned char)some_string[i] & (unsigned char)B_11110000) == (unsigned char)B_11100000 ) {
extr_count = 2; // Требуется 1+2 = 3 байта на символ
} else if ( ((unsigned char)some_string[i] & (unsigned char)B_11111000) == (unsigned char)B_11110000 ) {
extr_count = 3; // Требуется 1+3 = 4 байта на символ
}
}
printf("%c", some_string[i]); // Выводим очередной символ на экран
if (!extr_count) {
char_count++; // Наращиваем счетчик символов
} else {
extr_count--; // Уменьшаем счетчик дополнительных байт
}
}
// Выводим значение счетчика
printf("\nДлина строки, что сами посчитали - %d символов\n", char_count);
// А тут длину строки узнаем с помощью функции strlen()
printf("Длина строки, что функция strlen() вернула - %zu символов\n", strlen(some_string));
return 0;
}
Что мы тут видим? Поскольку двоичного представления в C «не завезли», для собственного удобства с помощью #define определим несколько псевдонимов для констант в десятичном представлении, которые нам понадобятся при анализе байтов (тут никаких «фокусов» нет, компилятор просто подставит в тексте программы вместо каждого псевдонима соответствующее численное значение). Объявим переменную-счетчик extr_count для обозначения количества дополнительных байт на символ. А дальше в цикле выполняем все необходимые проверки. Сперва проверяем очередной элемент (байт) на значение старшего бита: если старший бит нулевой, то символ представлен единственным байтом и мы сразу переходим к наращиванию счетчика символом. Если старший бит ненулевой, то выполняем последовательно проверку на значения управляющих битов, чтобы определить количество байтов на символ. Тут нам и пригодятся константы-псевдонимы (просто так нагляднее). А потом уже переходим к наращиванию счетчика символов: если счетчик дополнительных байтов равен нулю, то наращиваем счетчик символов, если не равен нулю — не наращиваем, а уменьшаем на единицу сам счетчик дополнительных байтов (компенсируя тем самым «расширенное» в байтах представление символа).
Собственно, это всё. Сохраняем, компилируем, запускаем:
username ~/Папка_с_программой $ gcc -Wall -o proj04 proj04.c username ~/Папка_с_программой $ ./proj04 Иногда самое сложное - придумать простую задачу Иногда самое сложное - придумать простую задачу Длина строки, что сами посчитали - 47 символов Длина строки, что функция strlen() вернула - 87 символов username ~/Папка_с_программой $
Как видим, работает. Можете попробовать сами 🙂
Всегда найдется место для оптимизации
Ну и разумеется, учитывая, что мы обрабатываем не произвольный байтовый поток, а вполне конкретную строку с четко обозначенным началом и концом, в данном случае код можно немного оптимизировать (и упростить при этом текст программы). Нам не нужно контролировать, является ли текущий байт первым или дополнительным (алгоритм всегда стартует строго с первого байта), поэтому можем сразу проверять его на конкретные управляющие значения из ряда: «110xxxxx», «1110xxxx» и «11110xxx». Таким образом, «выкидываем» проверку на «0xxxxxxx» получаем более простой код:
#include <stdio.h>
#include <string.h>
#define B_10000000 128 // ...и маска для 0xxxxxxx
#define B_11000000 192 // ...и маска для 10xxxxxx
#define B_11100000 224 // ...и маска для 110xxxxx
#define B_11110000 240 // ...и маска для 1110xxxx
#define B_11111000 248 // ...и маска для 11110xxx
int main() {
char some_string[] = "Иногда самое сложное - придумать простую задачу";
printf("%s\n", some_string);
int char_count = 0; // Объявляем "счетчик" и инициализируем его нулем
int extr_count = 0; // Счетчик-количество дополнительных байт на один символ
for (int i = 0; some_string[i] != '\0'; i++) {
// Сразу проверяем "коды расширения"
if ( ((unsigned char)some_string[i] & (unsigned char)B_11100000) == (unsigned char)B_11000000 ) {
extr_count = 1;
} else if ( ((unsigned char)some_string[i] & (unsigned char)B_11110000) == (unsigned char)B_11100000 ) {
extr_count = 2;
} else if ( ((unsigned char)some_string[i] & (unsigned char)B_11111000) == (unsigned char)B_11110000 ) {
extr_count = 3;
}
printf("%c", some_string[i]); // Выводим очередной символ на экран
if (!extr_count) {
char_count++;
} else {
extr_count--;
}
}
// Выводим значение счетчика
printf("\nДлина строки, что сами посчитали - %d символов\n", char_count);
// А тут длину строки узнаем с помощью функции strlen()
printf("Длина строки, что функция strlen() вернула - %zu символов\n", strlen(some_string));
return 0;
}
Сильно подозреваю, что в настоящий момент есть «штатные» средства для корректной работы с unicode-строками (какие-нибудь соответствующие типы данных и функции, в соответствующих же библиотеках). Стандарты ведь обновляются, язык C развивается и остается актуальным. Если немного поискать, такие средства непременно найдутся. А здесь мы просто немного «пощупали» utf8, раз уж наткнулись на проблему, да и мозгами иногда пошевелить бывает полезно 🙂