Сообщений 57    Оценка 241 [+1/-9]         Оценить  
Система Orphus

Строки в С и С++

Часть первая. Строки в C

Автор: Сергей Аристов
Опубликовано: 28.12.2003
Исправлено: 10.12.2016
Версия текста: 1.0.1
Введение
Строковые литералы
Строковые переменные
Символы
Операции со строками
Создание строк
Присваивание строк
Сравнение строк
Длина строки
Преобразования строк
Конкатенация (объединение) строк
Заключение

Введение

Исторически работа со строками в языках С и С++ вызывает затруднения у новичков. Как мне кажется, во многом это связано с тем, что в этих языках строки значительно отличается от строк в языках типа Basic и Pascal. Непонимание базовых принципов приводит к тому, что код либо не работает вовсе, либо что еще хуже, работает, но не стабильно или непредсказуемо даже для его авторов. Основой для написания данной статьи по большей части послужили вопросы, часто задаваемые новичками. Надеюсь, хотя бы часть таких вопросов она снимет.

Это первая часть, в которой обсуждаются «традиционные» строки в С. В С++ существуют более удобные механизмы для работы со строковыми данными, эти механизмы рассматриваются во второй части статьи. А зачем вообще обсуждать неудобные С-строки, если есть С++? К сожалению, совсем забыть о строках в стиле С нельзя по двум причинам:

Строковые литералы

Самая простая строковая сущность (под строковой сущностью я понимаю нечто, с чем можно работать как с привычной строкой) в С — это так называемый «строковый литерал». Он представляет собой последовательность символов, заключенную в двойные кавычки. Пример:

      "Здравствуй, мир!"
    

Пока я намеренно игнорирую вопрос о том, что есть строковый литерал с точки зрения компилятора или языка, ограничиваясь только его свойствами с точки зрения программиста.

Основное свойство строкового литерала — простота его использования. Не имея ни малейшего представления о том, чем он является на самом деле, мы можем использовать его практически везде, где от нас ждут строку. Например, в WinAPI-функцию SetWindowText (она задает текст, связанный с окном) нужно передать описатель окна и строку текста. И мы можем вызвать ее очень просто:

SetWindowText(hwnd, "Новый заголовок окна");

Но конечно, строковых литералов, естественно, фиксируемых при создании программы, будет маловато. Хотелось бы иметь возможность использовать переменные, не правда ли?

Строковые переменные

В С отсутствуют встроенные строковые типы в том смысле, в котором они есть в языках типа Basic и Pascal. И присущая этим языкам легкость оперирования строковыми переменными (присвоение, сравнение) в С недоступна. Что же такое строка в С?

Для начала разберемся, что такое строка в жизни. Очевидно, что строка – это последовательность символов. В С – как в жизни. С-строка — это последовательность символов. Как известно, последовательности в С представляются массивами или указателями. Между первыми и вторыми существует связь, однако природа этой связи выходит за рамки данной статьи. Предполагается, что читателю знакомы следующие особенности связи между массивами и указателями:

Если вышеприведенные высказывания вызывают у вас затруднения, рекомендую сначала разобраться с этим вопросом, а уже потом читать эту статью.

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

Итак, тип строк в С – массив. Однако каков тип элементов этого массива? Вообще говоря, возможны варианты. Исторически символ занимает 1 байт, в этом случае он имеет тип char. При этом существуют и другие кодировки, в которых символ представляется, например, двумя байтами. Для работы с такими строками требуются специальные функции.

Слегка отвлечемся от строк и разберем понятие кодировки. По определению, кодировка - это способ представления чего либо, в нашем случае символов. Кодировки символов делятся на однобайтные - каждый символ представлен одним байтом и многобайтные, в которых одному символу соответствует несколько байтов. В свою очередь многобайтные кодировки можно разделить на кодировки с фиксированным количеством байтов - каждому символу соответствует одинаковое количество байтов, и «плавающие», в которых один символ может представляться разным количеством байтов в зависимости от его содержимого. К первым относятся кодировки типа Unicode, в которой каждый символ представлен двумя байтами, ко вторым - UTF-8 и др. Плавающие кодировки - отдельная тема, языки С/С++ не предлагают для них никакой поддержки.

Необходимость в многобайтных кодировках возникла из-за того, что одним стандартным байтом можно представить не так много символов, например восьмибитный байт способен принимать значения от 0 до 255, а значит в такой кодировке не может существовать более 256 различных символов. Учитывая, что, например, в японском языке около двух тысяч иероглифов, 256 символов японцам явно не хватит. Но уже двух восьмибитных байтов хватит для представления 65536 символов - вполне неплохо, хотя и недостаточно для представления всех символов на свете.

Между однобайтными и фиксированными многобайтными строками принципиальной разницы нет. В С/С++ существует специальный тип для многобайтных символов - wchar_t и специальные функции для работы со строками, состоящими из таких символов. Размер wchar_t не фиксирован в стандарте и определяется реализацией компилятора. На многих платформах и компиляторах это два байта, соответствующих кодировке Unicode. Каждой функции, работающей со строками из однобайтных символов, соответствует функция - «побратим», принимающая строки из многобайтных символов. Кроме того, существует специальная форма для записи строковых литералов, в которых символы представлены несколькими байтами: перед кавычками ставится буква L. Т.е., возвращаясь к нашему первому примеру, вызов функции SetWindowText в Unicode-программе будет выглядеть так:

SetWindowText(hwnd, L"Новый заголовок окна");

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

ПРЕДУПРЕЖДЕНИЕ

Не забывайте пользоваться правильными функциями!

Очевидно, что последовательность должна быть как-то ограничена, то есть мы должны знать, где она заканчивается. Размер строки можно было бы счесть равным размеру массива… но тогда возникают две проблемы:

А значит, нужно поступить как-то по другому. Собственно, у нас есть два варианта – связать со строкой ее размер и где-то хранить его (например, в нулевом элементе массива или специальной переменной), либо ограничить строку (последовательность) специальным символом (элементом последовательности), дойдя до которого мы будем знать, что достигли конца. Именно этот способ и принят в С, причем в качестве терминатора (ограничивающего символа) выбран символ с кодом 0, как наиболее редко встречающийся в строках. То есть в С строка – это массив элементов типа char, ограниченный символом с кодом 0, называемом нуль-терминатором. Напомню, что c массивом элементов типа char связан указатель на char, поэтому С-строка– это еще и указатель типа char* на область памяти, заканчивающуюся символом с кодом 0.

ПРИМЕЧАНИЕ

У такого подхода есть как положительные, так и отрицательные стороны. Но положительных больше. К первым относятся.
1. Натуральное представление строки в том смысле, что начало строки совпадает с началом последовательности символов
2. Неограниченная длина строки - в случае хранения длины строки отдельно длина строки не может превышать значения, максимального для переменной этого типа. Например, при использовании однобайтовых строк и хранении длины строки в первом элементе последовательности (как в Pascal) длина строки не может превышать максимального значения переменной типа char - на многих платформах это 255.
3. «Сдвинув» начало строки вправо, мы все равно получаем абсолютно нормальную строку, заканчивающуюся нулем. Строка, размер которой хранится отдельно, этой особенности лишена.
Недостатки:
1. Для определения длины строки всякий раз необходимо искать ее конец, сравнивая ее символы с нулевым. Чем длиннее строка - тем больше операций. Об этом следует помнить при использовании длины строки, например, в циклах. Если между итерациями строка не изменяется, ее длину следует вычислить заранее, а потом использовать полученное значение.
2. Строки, с которыми используются стандартные функции, не могут содержать в себе нулевые символы. В редких случаях это может доставить неудобство.

Теперь с высоты наших знаний вернемся к строковым литералам. Я думаю, никого не удивит, что их тип – const char*, т.е. указатель на константные символы, а в конце каждого строкового литерала компилятор автоматически подставляет нуль-терминатор.

Символы

Символы сами по себе тоже довольно интересны. Как вы уже знаете, символьная переменная — это переменная типа char, занимающая в памяти 1 байт. В отличие от строк, символ — это встроенный интегральный тип в С/C++, для него допустимы все операции, допустимые для интегральных типов. Существуют символьные литералы, они записываются в одинарных кавычках (прямых апострофах). Пример символьного литерала:

        char sym;
sym='A'; //Символьный литерал. Его значение – код символа А(латинское) в используемой кодировке

В вышеприведенном примере значением sym является 65 в кодовой таблице ASCII. В этом случае строка sym=’A’ абсолютно эквивалентна строке sym=65. Однако, в целях улучшения портируемости лучше всегда использовать запись в апострофах — рано или поздно программу может потребоваться скомпилировать на платформе, где у символа А другой код.

Для записи символьных литералов типа wchar_t используется запись, аналогичная записи для строковых литералов этого типа:

        wchar_t sym;
sym=L'ab'; //Символьный многобайтовый литерал. Количество символов между апострофами зависит от размера типа wchar_t

Существует специальный формат для записи символьных литералов – слеш, за которым идет код символа. Такая форма записи необходима, если мы хотим использовать элемент, не отображающийся в печатный символ, например нуль-терминатор, который представляется так: ’\0’.

ПРИМЕЧАНИЕ

В фрагменте

char sym1;

char sym2;

char sym3;

sym1=0; //(1)

sym2='\0'; //(2)

sym3='\0x00'; //(3)

строки (1), (2) и (3) имеют один и тот же эффект. Однако вторая и третья запись считаются нагляднее – мы сразу видим, что работаем именно с символом.

ПРЕДУПРЕЖДЕНИЕ

Не путайте ‘\0’, ‘0’ и “0”. Первое – символьный литерал, соответствующий символу с кодом 0. Второе – такой же литерал, но обозначающий цифру 0, ее код в ASCII-кодировке 48. Третий — это строковый литерал, содержащий два символа, цифру 0 и нуль-терминатор.

Как я уже упоминал тип char – интегральный, а значит для него определены все операции, определенные для интегральных типов, в том числе +, -, *, / и операции сравнения. Хотя не все они имеют смысл для символов (например, я плохо себе представляю смысл перемножения двух символов), однако их использование совершенно «законно» и их результат обычно именно такой, какой ожидался.

Применение же этих операций к строкам либо вообще запрещено, либо, что еще хуже, их результат в большинстве случаев далек от ожидаемого.

Операции со строками

Создание строк

Проиллюстрирую создание строк на фрагментах кода с комментариями.

        char str1[10]; // Строка - массив из 10 символов. Начальное значение символов не определено.char str2[10]="Hello";
/* Используется инициализация (не присваивание!). В первые 5 символов записывается “Hello”, в 6 – нуль-терминатор, значение трех последних не определено.*/char str3[10]={'H', 'e', 'l', 'l', 'o', '\0'}; //Эквивалентно предыдущему.char str4[10]="Very long line";
//Ошибка. Массив из 10 элементов нельзя инициировать более длинной последовательностью.char str5[]="Very long line";
/*Компилятор автоматически определяет длину массива (в нашем случае 15) и инициализирует его последовательностью символов. */char* str6; 
/*Строка - указатель на символ. В большинстве случаев для ее использования 
потребуется выделить память.*/
str6=(char*) malloc(10);
free(str6);

Присваивание строк

Первый и самый очевидный способ присваивания строк – присваивание отдельных символов. Например,

str1[0]=’H’;
str1[1]=’e’;
str1[2]=’l’;
str1[3]=’l’;
str1[4]=’o’;
str1[5]=’\0’;

Однако, это совершенно неудобно. Не зная о правильных способах, начинающие программисты часто «выдумывают» свои способы присваивания строк, конечно, неправильные. Приведу несколько примеров:

        char str1[10], str2[10];
str1="Hello";
str2=str1;
//Одна и та же ошибка в обоих операторах =. //Имя массива нельзя использовать в левой части оператора присваивания.

Эта ошибка относительно безопасна, так как приводит к сбою на этапе компиляции. Есть и гораздо более опасная ошибка.

        char str1[10]= "Hello";
char* str2;
str2=str1;
str2[1]='u';

Этот код откомпилируется, но, возможно, содержит «идеологическую» ошибку. Неправильно полагать, что в str2 теперь содержится копия str1. На самом деле этот указатель указывает не на копию, а на ту же самую строку. При любом изменении содержимого str2 изменяется str1. Однако, если именно это и требуется, то все в порядке.

Еще один вариант присваивания указателей – присваивание их строковым литералам. Как вы помните, тип строкового литерала – const char*, а значит такой код работает:

        const
        char* str;
str="Hello";

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

        char* str; // Нет const
str="Hello";

Здесь мы имеем дело с наследством C, в котором отсутствовал const. Поэтому стандарт С++ разрешает такое присваивание. Что может иметь неприятные последствия:

        char* str; // Нет const
str="Hello";
str[1]=’u’;

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

        const
        char* str; // const есть
str="Hello";
str[1]=’u’; //error: l-value specifies const object

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

Для копирования строк существуют несколько библиотечных функций, наиболее общеупотребительной из которых является функция

        char* strcpy(char* dest, constchar* src)

Функция посимвольно копирует содержимое строки, на которую указывает src в строку, на которую указывает dest и возвращает dest. Так как массив может быть преобразован в указатель, такой вызов функции абсолютно легален:

        char str1[10], str2[10];
strcpy(str1, "Hello");
strcpy(str2, str1);

При использовании этой функции следует соблюдать осторожность. Опасность заключается в том, что даже если исходная строка окажется больше, чем память, выделенная для второй строки (программистом через malloc или компилятором при использовании массивов), функция strcpy никак про это узнать не сможет и продолжит копирование в невыделенную память. Разумеется, последствия будут катастрофическими.

Снизить риск такого развития событий способна функция

        char* strncpy(char* dest, constchar* src, size_t count)

Последний параметр – максимальное количество копируемых символов. Таким образом, передавая туда размер приемника, вы гарантируете, что функция никогда не выйдет за пределы выделенной памяти. Однако помните, что если исходная строка будет скопирована не полностью, нуль-терминатор не появится в результирующей строке. Его придется записать самостоятельно.

ПРЕДУПРЕЖДЕНИЕ

Никогда не забывайте контролировать используемую память!

Сравнение строк

Для лексикографического сравнения строк используются функции strcmp и stricmp. Первая сравнивает строки с учетом регистра, вторая – без. Однако, все это относится только к латинице. Если вы хотите сравнивать без учета регистра кириллические строки, придется разобраться с локалями.

Прототипы этих функций таковы:

        int stricmp(constchar *string1, constchar *string2);
int strcmp(constchar *string1, constchar *string2);

Обе функции возвращают число меньшее 0, если первая строка меньше второй, большее нуля если первая строка больше второй и 0, если строки лексикографически равны.

Полагаю, вам не придет в голову сравнивать строки, используя операции ‘<’ и ‘>’.

Длина строки

Для вычисления длины строки используется функция

size_t strlen(constchar *string);

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

        for (i=0;i<strlen(str);i++) {
// работа со строкой, не изменяющая ее длину
}  

больше подойдет примерно такой код :

        char len;
len=strlen(str);
for (i=0;i<len;i++) {
// работа со строкой, не изменяющая ее длину
}  

Преобразования строк

Зачастую требуется преобразовать число в строку и наоборот. Есть несколько способов сделать это.

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

Во-вторых, можно использовать функции sprintf и sscanf. Например, так:

        char str[50];
int i=15;
int j;
sprintf(str, "%d", i); // Записать в str строковое представление i
sscanf(str, "%d", &j); // Записать в j число, содержащееся в строке str
sprintf(str, "i=%d and j=%d", i, j);
// содержимое str: "i=15 and j=15"

Эти функции очень похожи на printf и scanf, за исключением того, что они работают не с консолью, а со строковым буфером. Для дополнительной информации об этих функциях см. документацию.

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

В-третьих, доступно целое семейство функций atof, atoi, atol и itoa, ltoa. Все они очень похоже между собой. Функции из первой группы преобразуют строку в число (float, int или long) в зависимости от окончания. Функции из второй группы выполняют обратное преобразование. Должен заметить, что функции из второй группы (равно как и ранее упомянутая stricmp) не входят в стандарт С, однако они весьма удобны и доступны на некоторых платформах.

Прототипы функций из первой группы:

        double atof(constchar* string);
int atoi(constchar* string);
long atol(constchar* string);

Вторая группа:

        char* itoa(int value, char* string, int radix);
char* ltoa(long value, char* string, int radix);

Функции из второй группы могут создавать строковое представление чисел в любой системе (по любому основанию) от 2 до 36. Основание передается в третьем параметре. Чтобы получить строковое представление числа в десятичной системе, передайте 10. Функции возвращают указатель на строку.

ПРИМЕЧАНИЕ

При использовании этих функций не забывайте выделять память, достаточную для предоставления строкового предоставления числа. Например, максимальная длина десятичного строкового представления четырехбайтного беззнакового целого числа - 11 байт, включая нуль-терминатор ("4294967295").

Пример:

        char str1[5];
char str2[5];
char str3[5];
itoa(12, str1, 10); //str1=”12”
itoa(12, str1, 16); //str1=”C”
itoa(12, str1, 2); //str1=”1100”

Конкатенация (объединение) строк

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

        char str1[10]="Hello";
char str2[10]="World!";
char* str3;
str3=str1+str2;

Если ответ – ошибка на этапе компиляции, материал изложенный в статье вы усвоили (или знали это раньше). Если же вы полагаете, что в str3 будет хранится строка "Hello world!", то вероятно, мои предыдущих объяснений оказалось недостаточно. Нельзя складывать указатели (и имена массивов).

Для конкатенации следует использовать функции.

Есть две специальные функции:

        char* strcat(char* dest, constchar* source)
char* strncat(char* dest, constchar* source, size_t size)

Эти функции добавляют к строке, на которую указывает dest, символы из строки source. Первая версия добавляет все символы до нуль-терминатора, вторая – максимум size символов. Результирующая строка завершается нуль-терминатором.

Кроме того, можно воспользоваться общей функцией sprintf так:

        char str1[]="Hello ";
char str2[]="world";
char str3[]="!";
char str4[13];
sprintf(str3, "%s%s%s", str1, str2, str3);

Этот вариант удобнее, если нужно объединить более двух строк. Однако к его недостаткам относится типонебезопасность.

Заключение

На этом первая часть, посвященная С-строкам заканчивается. В качестве домашнего задания рекомендую реализовать упоминавшиеся здесь стандартные функции, за исключением sprintf и sscanf самостоятельно. Это нетрудно, и если вы справитесь, значит, вы отлично овладели материалом. Тем не менее, всегда используйте стандартные функции, а не ваши собственные. Это общее правило – реализовать стандартные функции имеет смысл только в качестве учебного задания.

Для использования строковых функций вам потребуется подключить к программе соответствующие стандартные заголовки. Это string.h для всех функций, кроме sprintf и sscanf, определенных в stdio.h и функций преобразования, определенных в stdlib.h.

В С++ вместо вышеупомянутых заголовочных файлов следует подключать cstring, cstdio и cstdlib соответственно.

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


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 57    Оценка 241 [+1/-9]         Оценить