Зона кода

А давайте немного попрограммируем...

C-строки в C99

C99Полезное

Поддержка работы с текстовыми строками является обязательной для любого языка программирования, претендующего в той или иной степени на универсальность. Изучению создания и обработки текстовых строк применительно к языку C99 и будет посвящена данная статья. Адресована она, в первую очередь, новичкам.

Однако и сам я, возвращаясь к программированию на C99 после перерывов, во время которых работаю с другими языками программирования, бываю вынужден восстанавливать в памяти особенности работы с C-строками (именно к этому типу относятся текстовые строки, поддерживаемые C99). Так что пишу эту статью, я, в том числе, и для себя, как памятку на будущее.

C-строка — это набор символов (т. е. значений типа char), расположенных последовательно в памяти, последним символом которого является нулевой (т. е. символ '\0'). Других нулевых символов, помимо завершающего, в этом наборе быть не должно. Иногда C-строки называют ещё "строками с завершающим нулём".

Именно нулевой символ, завершающий строку, является особенностью C-строк. Благодаря его наличию, длину строки подсчитать очень просто (хоть для этого и требуется последовательно перебрать все символы, вплоть до завершающего), поэтому отдельно её хранить не требуется. Особенно это актуально в случае обработки строк функциями. Всю нужную информацию о C-строке функция может получить через один-единственный параметр — адрес первого символа строки.

В C99 отсутствует специальный тип для хранения строк, поскольку C-строка может храниться в массиве переменных типа char. Если мы хотим обратиться к строке через идентификатор, то можем взять в качестве оного имя этого массива, или же имя указателя на первый символ строки. Отметим, что "строкой" часто называют как символьный массив, так и указатель на первый символ строки.

C-строки можно разделить на два типа. Первый тип — константные строки (неизменяемые строки, строковые константы). Второй — изменяемые строки. Рассмотрим каждый из типов более подробно.

Константные строки

Константная строка задаётся строковым литералом, т. е. набором символов, заключённым в кавычки (сами кавычки не являются частью строки). Этот набор не должен содержать нулевых символов. При размещении в памяти он автоматически дополняется нулевым символом в конце. Вот пример задания константной строки:

"Это - строка!"

Оговоримся, что сам по себе строковый литерал может содержать один и более нулевых символов, но в этом случае он будет задавать строку, не являющуюся C-строкой. В дальнейшем, для простоты, строковые литералы, задающие (C-)строки, часто будем отождествлять с самими (C-)строками.

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

В программном коде строковый литерал интерпретируется как адрес первого символа строки.

Длиной C-строки называется количество всех содержащихся в ней символов, за исключением нулевого. Размером строки называется количество байт, которое она занимает в памяти. Оператор sizeof корректно работает со строковым литералом, т. е. возвращает его размер в памяти, а не размер адреса его первого элемента.

Ясно, что размер строки всегда будет на единицу превышать её длину. В следующем фрагменте программного кода на консоль выводится сначала размер строки, приведённой выше, а затем — её длина:

printf("Размер строки: %d", sizeof "Это строка!");
printf("\nДлина строки: %d", strlen("Это строка!"));

Для вычисления длины используется функция strlen() из стандартной библиотеки <string.h>. В результате выполнения фрагмента на консоль будет выведено:

Размер строки: 12
Длина строки: 11

Для того, чтобы вывести на печать C-строку с помощью функции printf(), нужно использовать спецификатор формата %s:

printf("%s", "Это строка!");

Если же внутри строки имеются нулевые символы, то такая строка не будет корректно обработана функцией printf(), поскольку уже не будет являться C-строкой. На консоль будет выведена только часть строки, предшествующая первому нулевому символу. Рассмотрим, например, следующий фрагмент:

printf("%s", "Это\0строка!");

На консоль будет выведено:

Это

Перепишем теперь для новой строки фрагмент, выводящий длину строки и её размер:

printf("Размер строки: %d", sizeof "Это\0строка!");
printf("\nДлина строки: %d", strlen("Это\0строка!"));

В результате будет напечатано:

Размер строки: 12
Длина строки: 3

Как мы видим, размер строки, которая, фактически, является "склейкой" двух C-строк, найден верно. А при подсчёте её длины были учтены лишь первые три символа, поскольку четвёртый является нулевым.

Давайте теперь покажем, что в программном коде константная строка действительно  отождествляется с адресом своего первого символа. Для начала выведем адрес строки с помощью функции printf(), используя спецификатор формата %p:

printf("%p", "Это строка!");
printf("\n%p", "Это строка!");

В результате выполнения этого фрагмента кода я получил следующий консольный вывод:

00404000
00404000

Я не случайно вывел на печать адреса двух одинаковых строк. Я хотел показать, что в моём случае компилятор поступает экономно, генерируя код, размещающий в памяти лишь один экземпляр двух одинаковых строк. Действительно, если просмотреть содержимое исполняемого файла, то можно увидеть, что компилятор, которым я пользуюсь, разместил в нём строку "Это строка!" лишь единожды.

Покажем, теперь, что строковый литерал, как и обычный адрес, можно разыменовывать и применять к нему адресную арифметику, а также операцию обращения по индексу:

printf("%c", *"Это строка");
printf("\n%c", *("Это строка" + 1));
printf("\n%c", "Это строка"[2]);
printf("\n%s", "Это строка" + 4);

В первом случае мы разыменовываем адрес первого символа строки и получаем сам этот символ, который и выводим на печать. Во втором — увеличиваем этот адрес на размер типа char, т. е. на единицу, в результате чего получаем адрес второго символа, после чего разыменовываем его и печатаем.

В третьем случае выводим на печать третий символ, используя уже операцию обращения по индексу. В четвёртом — с помощью адресной арифметики получаем адрес пятого символа строки. Поскольку в printf() мы используем спецификатор формата %s, то на печать будет выведена часть исходной строки, начиная с пятого символа.

В результате выполнения фрагмента на консоль будет выведено:

Э
т
о
строка

Адрес первого символа строкового литерала можно присвоить указателю на char. Через этот указатель можно попытаться изменить какой-нибудь символ строки. Рассмотрим, например, следующий код:

char * str = "Это строка!";
*(str + 1)  = 'г';

Данный код будет успешно скомпилирован, но его выполнение приведёт к краху программы, вызванному попыткой записи в область памяти, доступную только для чтения. Чтобы фатальные ошибки такого рода "ловились" ещё на этапе компиляции, адрес строки часто помещают в указатель на константные данные. Так, например, следующий код скомпилирован не будет:

const char *str = "Это строка";
*(str + 1) = 'г';

Заметим, что в приведённом фрагменте сам указатель str не является константным, поэтому его содержимое можно изменять:

const char *str = "Это строка!";
str += 4;
printf("%s", str);

В результате на консоль будет выведено:

строка

Нужно иметь в виду, что, даже используя указатель на константные данные, всё равно можно "организовать" попытку записи в область памяти, предназначенную только для чтения. Для этого достаточно скопировать адрес из этого указателя в указатель на изменяемые данные, после чего попытаться изменить какой-либо символ через второй указатель:

const char *str = "Это строка!";
char *str2 = (char *) str;
*(str2 + 1) = 'г';

Код будет успешно скомпилирован, но программа завершится аварийно. Обратите внимание на приведение типа во 2-й строке фрагмента. Если выполнить присваивание без него, то код будет скомпилирован, но компилятор выведет предупреждение о потере квалификатора const в результате присваивания.

Как мы видим, с константными строками нужно работать очень осторожно. Это касается и передачи адресов таких строк в функции, которая будет рассмотрена позже.

Изменяемые строки

Для создания изменяемой C-строки достаточно объявить массив переменных типа char и заполнить его символами, не забыв о завершающем нулевом символе. Сделать это можно в одну строчку:

char str[] = {'Э', 'т', 'о', ' ', 'с', 'т', 'р', 'о', 'к', 'а', '!', '\0'};

Того же самого можно добиться, используя строковый литерал. Запись заметно сократится:

char str[] = "Это строка!";

В обоих случаях будет создан массив из 12 элементов, который будет заполнен символами, последний из которых — нулевой. Заметим, что такая инициализация обязательно должна быть объединена с объявлением массива. Если массив уже объявлен ранее, то заполнять его символами придётся через обращения к элементам.

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

Массивы, как известно, можно объявлять константными. Попытки непосредственного изменения содержимого элементов таких массивов будут "пресечены" компилятором. Например, следующий код не скомпилируется:

const char str[] = "Это строка!";
str[0] = 'Е';

Однако этот запрет можно легко обойти тем же способом, который был описан в предыдущем разделе, присвоив адрес первого элемента массива указателю на неконстантные данные. Рассмотрим следующий фрагмент:

const char str[] = "Это строка!";
char *str2 = (char *) str;
*str2 = 'Е';
printf("%s\n",str);

Данный код успешно скомпилируется. В результате его выполнения на экран будет выведено:

Ето строка!

Таким образом, объявление массива константным не влечёт за собой сохранения помещаемой в него строки в области памяти, доступной только для чтения, а данную строку можно, всё же, изменить.

Передача адресов строк в функции

Строки невозможно передавать в функции, но в функции можно передавать адреса их первых символов. При этом нужно соблюдать ряд предосторожностей.

Во-первых, передавая адрес константной строки, нужно быть уверенным в том, что внутри функции не будет предпринято попытки изменить строку, поскольку такая попытка приведёт к краху программы. Условным признаком того, что такие попытки не предпринимаются, является объявление в заголовке функции формального параметра, принимающего адрес строки, с квалификатором const.

Вот как, например, выглядит прототип уже упоминавшейся нами ранее функции strlen():

size_t strlen(const char *str);

Наличие квалификатора const гарантирует, что в теле данной функции строка, на которую указывает str, не изменяется непосредственно через данный указатель. Впрочем, строку можно изменить через другой указатель тем же способом, который мы уже неоднократно рассматривали (именно поэтому выше мы говорили об "условности" признака). Возьмём, в качестве примера, функцию, которая меняет порядок символов в строке на обратный:

void reverse(char *str)
{
    for(int i = 0, j = strlen(str) - 1; i < j; i++, j--)
    {
        char t = *(str + i);
        *(str + i) = *(str + j);
        *(str + j) = t;
    }
}

Разумеется, нельзя было объявить формальный параметр str указателем на константные данные, поскольку строка в функции модифицируется. Но это можно сделать, если тело функции немного изменить:

void reverse(const char *str)
{
    char *str2 = (char *) str;
    for(int i = 0, j = strlen(str) - 1; i < j; i++, j--)
    {
        char t = *(str + i);
        *(str2 + i) = *(str + j);
        *(str2 + j) = t;
    }
}

Данный вариант функции будет так же успешно скомпилирован, как и предыдущий. Как мы видим, наличие квалификатора const в списке формальных параметров функции не даёт нам гарантий, что строка, обрабатываемая функцией, не подвергнется попытке модификации. Поэтому, если мы не уверены в том, что используемая нами функция не изменяет обрабатываемые строки, не стоит передавать ей адрес константной строки. Но вместо этого можно передать ей изменяемую копию последней.

Рассмотрим, например, следующий фрагмент:

const char *str = "Это строка!";
char copy[strlen(str) + 1];
reverse(strcpy(copy, str));
printf("%s\n%s", str, copy);

Здесь создаётся массив copy, после чего в него копируется константная строка, адресуемая указателем str. Копирование происходит с помощью стандартной функции strcopy() из библиотеки <string.h>, принимающей в качестве первого параметра адрес области памяти, в которую нужно скопировать строку, адресуемую вторым параметром. Функция возвращает первый параметр. Далее этот параметр передаётся в функцию reverse(). На печать выводится исходная константная строка, а также строка, полученная из копии исходной путём перестановки символов в обратном порядке.

В результате, на консоль будет выведено:

Это строка!
!акортс отЭ

Заметим, что из приведённого фрагмента можно вызывать любую из двух версий функции reverse(). В обоих случаях будет получен один и тот же результат.

Справедливости ради, заметим, что функциям из стандартной библиотеки C99 можно смело доверять в том смысле, что если в списке формальных параметров функции объявлен указатель на константные данные, то внутри этой функции данные, адресуемые этим параметром, изменены не будут. Таким образом, кстати, объявляются единственный параметр функции strlen() и первый параметр функции strcpy().

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

К функциям, производящим запись в массивы, относятся, например, рассмотренная нами функция strcpy(), а также функция strcat(), помещающая сразу же после одной строки, хранящейся в массиве, другую (по сути, речь идёт о конкатенации строк).

Наконец, в-третьих, следует следить за тем, чтобы в функции, обрабатывающие C-строки, передавались адреса строк, заканчивающихся нулевым символом. Отсутствие такового в лучшем случае приведёт к некорректной работе программы, а в худшем — к её краху. Рассмотрим, например, следующий фрагмент:

char str[] = {'Э', 'т', 'о', ' ', 'с', 'т', 'р', 'о', 'к', 'а', '!'};
int len = strlen(str);

Функция strlen() не сможет корректно вычислить длину строки, хранящейся в массиве str, поскольку та не завершается нулевым символов. В поисках нулевого символа функция выйдет за пределы массива str. Если в процессе перебора байтов за границами массива она, всё же, обнаружит нулевой символ, то вернёт некорректное значение, превышающее реальную длину строки, и завершит свою работу. А если не найдёт, то попытается, в конце концов, прочитать содержимое недоступного блока памяти, что приведёт к краху программы.

(Признаемся, что в предыдущем абзаце, всё же, допущена некоторая неточность. Мизерная вероятность того, что длина строки будет найдена верно, всё же, существует. Это произойдёт в том случае, если по какой-то невероятной случайности за последним символом массива в памяти окажется расположенным нулевой символ.)

Заметим, что в C99 можно использовать и строки, не завершающиеся нулевым символом. Но нужно иметь в виду, что все функции из стандартной библиотеки C99, работающие со строками, поддерживают только C-строки. Поэтому реализация любых операций со строками, не являющимися C-строками, целиком и полностью ложится на плечи программиста.

Заключение

Во многих языках программирования, например, в таких, как Java, Python, C#, PHP, реализованы очень удобные механизмы для работы со строками. Информация о длине строки хранится в той же структуре данных, что и сама строка; при необходимости, размеры строк автоматически изменяются; одну из самых распространённых операций со строками — конкатенацию осуществить так же просто, как и сложить значения двух переменных и т. д.

В C99 всё обстоит иначе. Размеры строк фиксированы и изменению не подлежат. Любые операции со строками осуществляются посредством вызовов функций. Недостаток бдительности программиста может привести к нарушению функциями границ строк. Именно поэтому, когда я возвращаюсь к C99 после программирования на других языках, я вынужден некоторое время "привыкать" к C-строкам и восстанавливать навыки работы с ними.

Ну а преодолевая сложности работы со строками в C99, какие выгоды мы получаем? Ответ достаточно очевиден — быстродействие. По сути, низкоуровневые операции работы со строками в языках, о которых я говорил выше, реализованы примерно те же, как и в С99. Но в первом случае они полностью автоматизированы, а во втором — в значительной степени находятся под контролем программиста. Автоматизация всегда содержит некоторые "баластные" операции, без которых в конкретных случаях можно обойтись. Эти операции, безусловно, отнимают компьютерные ресурсы, в первую очередь, процессорное время.

Что важнее — удобство или быстродействие? Вопрос, очевидно, риторический. Конкретных ситуаций — миллионы, и в каждой конкретной ситуации — свой ответ.