Си-строки (массивы символов)

Си-строки


В прошлом уроке мы разобрали динамические String-строки в реализации Arduino, а сейчас настало время стандартных статических строк языка C/C++. Такая строка представляет собой массив символов типа char (char array) и для неё работает такой же синтаксис, как и для остальных массивов (урок про массивы). Конец строки определяется нулевым символом \0 (или целым число 0), за это такой тип строк называют null-terminated string: ноль на конце позволяет программе определять конец строки и её длину. Также это стандартные строки языка Си и поэтому называются cstring.

Текст в кавычках


Любой написанный в двойных кавычках текст "some text":

  • Является строковой константой - string constant
  • Имеет тип данных const char* - то есть указывает на свой первый символ в памяти
  • Хранится и в программной, и в оперативной памяти микроконтроллера
  • Компилятор автоматически добавляет нулевой символ в конец строки '\0' - то есть реальный размер строки всегда на 1 символ больше
  • Оптимизируется компилятором - об этом ниже

Оптимизация компилятором


Компилятор оптимизирует строковые константы, но не во всех случаях. Если создать несколько строк как массивы (которые можно изменять) и присвоить им одинаковые строки, то они займут место в памяти как разные строки, т.е. столько, сколько в них суммарно символов:

char s1[] = "hello";
char s2[] = "hello";

Если создать несколько одинаковых строк как указатели - то компилятор их оптимизирует и они займут место в памяти как одна строка!

const char* s1 = "hello";
const char* s2 = "hello";

Если при выводе в Serial или передаче в другие функции мы используем одинаковые строки, то они также будут оптимизироваться и занимать место как одна строка:

Serial.println("hello");
lcd.print("hello");
String s("hello");

В то же время F() - строки (подробнее в уроке про PROGMEM) не оптимизируются компилятором и занимают в программной памяти каждая своё место:

Serial.println(F("hello"));
lcd.print(F("hello"));

Сложение


Строковые константы можно складывать через пробелы:

char str[] = "Hello"   ", "   "World!";

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

Перенос строк (в программе)


Длинную строку можно переносить для удобства чтения и редактирования программы. Есть два способа:

Первый способ - работает как сложение строк в предыдущей главе. Каждая строка в своих кавычках пишется с новой строки:

char str[] = "Hello"
", "
"World!";

Второй способ - использование символа обратный слэш \ для переноса строки. Кавычки в этом случае нужны только в начале и конце:

char str[] = "Hello\
, \
World!";

Примечание: результирующий текст в переменной str в обоих случаях не имеет переносов, то есть в обоих примерах получится строка "Hello, World!".

Перенос строк (текст)


Для человека текст с новой строки - это текст с новой строки. Чтобы перенести текст на новую строку, мы нажимаем на клавишу Enter на клавиатуре. В то же время текст в текстовых файлах не хранится в разных "строках", он лежит в памяти одной длинной строкой. Когда мы открываем файл, компьютер читает текст и ищет в нём специальные невидимые символы, которые называются управляющими символами. Одним из таких символов является перенос строки - \n, именно его добавляет клавиша Enter. Чтобы компьютер при выводе строки перенёс её - нужно добавить этот символ в текст. В программе мы будем видеть этот символ, а вот в результирующем тексте он автоматически превратится в перенос строки. Примеры (без переноса в программе и с переносом двумя способами):

char str1[] = "Строка1\nСтрока2\nСтрока3";

char str2[] = "Строка1\
\nСтрока2\
\nСтрока3";

char str3[] = "Строка1\n"
"Строка2\n"
"Строка3";

Во всех трёх случаях получится текст

Строка1
Строка2
Строка3

Примечание: ставить символ переноса строки можно как в начале новой строки (см. str2), так и в конце предыдущей (см. str3).

Кавычки внутри строки, экранирование


На практике довольно часто бывает нужно иметь строку, которая содержит символы двойных кавычек, например для вёрстки html:

<div class="button"></div>

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

// приведёт к ошибке компиляции
char str1[] = "Символ " кавычка";

// приведёт к ошибке компиляции
char str2[] = "Этот "текст" в кавычках";

// скомпилируется, получится Этот текст в кавычках (сложение трёх строк)
char str3[] = "Этот ""текст"" в кавычках";

Эту проблему можно решить двумя способами: экранированием и использованием инструмента компилятора raw string literal (С++ 11). Экранирование кавычек во многих языках программирования осуществляется при помощи обратного слэша \. Таким образом просто кавычки " - это оператор, часть синтаксиса языка, а вот так - \" - это печатный символ кавычек, который может входить в состав строки:

// скомпилируется, получится Этот "текст" в кавычках
char str4[] = "Этот \"текст\" в кавычках";

“Сырые” строки


"Сырые" строки - очень удобный инструмент компилятора, позволяющий задать любой текст просто в виде текста, включая кавычки и переносы строк без дополнительного экранирования. Синтаксис следующий R"(ваш текст)" или R"метка(ваш текст)метка", где метка - любой текст длиной до 16 символов без пробелов, должен быть одинаковым в начале и конце. Нужна для того, чтобы компилятор мог корректно определить конец сырой строки, если внутри самой строки есть )". Например строка R"(<tag onclick="func()" class="b1">)" приведёт к ошибке, т.к. компилятор решит что она закончилась после слова func! Добавим метку R"raw(<tag onclick="func()" class="b1">)raw" и компилятор без ошибки найдёт конец сырой вставки. Примеры:

  // вывод: текст "с кавычками" - удобно
  Serial.println(R"(текст "с кавычками" - удобно)");

  char str1[] = R"(текст
с переносами
строки)";
  Serial.println(str1);

  // метку rawliteral часто можно встретить в примерах для esp8266/32.
  char str2[] = R"rawliteral(<!DOCTYPE HTML><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head><body>
</body>
</html>)rawliteral";
  Serial.println(str2);

Примечание: перенос строки внутри экранированной строки в программе станет переносом строки в итоговой строке в переменной!

Массив символов

Объявление как массив


Основное отличие таких строк от String-строк: это обычный массив, размер которого известен заранее и не меняется в процессе работы. Можно объявить строку как массив и посимвольно задать текст:

char str[] = {'h', 'e', 'l', 'l', 'o', 0};  // с нулевым символом на конце

Такой вариант записи не очень удобный, поэтому строки в C/C++ можно задавать просто текстом в двойных кавычках - компилятор сам посчитает размер массива:

char str[] = "hello";

Полученный выше массив содержит 6 символов: 5 на слово hello и 1 на завершающий символ. Текст в данном массиве можно изменять в процессе работы программы, потому что с точки зрения программы мы создали обычный массив и заполнили его буквами. Изменим первую букву на прописную: str[0] = 'H';. Выведем в монитор порта:

Serial.println(str);

Serial умеет работать с такими данными и с радостью их выведет.

Объявление как указатель


Также строку можно объявить как указатель на const char* - то есть сам текст в кавычках хранится где то в программе, а мы получаем на него "ссылку":

const char* str = "hello";

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

Serial.println(str);

Примечание: можно объявить и как char* str = "hello"; и пользоваться дальше точно так же как массивом, но компилятор выдаст предупреждение что строковая константа (текст в кавычках) приравнивается к неконстантному типу.

Массив строк


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

// объявляем массив строк
const char* names[]  = {
  "Period",   // 0
  "Work",     // 1
  "Stop",     // 2
};

// выводим третий элемент
Serial.println(names[2]); // выведет Stop

Таким образом удобно паковать строки для создания текстовых меню и прочего. Единственный большой минус - весь этот текст висит в оперативной памяти мёртвым грузом. Можно сохранить его во Flash - программной памяти (PROGMEM), об этом читайте в отдельном уроке.

Точно также можно создать массив массив пустых строк для дальнейшей работы:

char arr[к-во строк][макс. длина];

По сути это будет двухмерный массив. Копирование другой строки в массив может выглядеть так: strcpy(arr[0], str);. Об этом читайте ниже.

Длина строки


Для определения длины текста можно использовать оператор strlen(), который возвращает количество символов в строке. Сравним его работу с оператором sizeof():

char str[100] = "World";
sizeof(str);  // вернёт 100
strlen(str);  // вернёт 5

Здесь оператор sizeof() вернул количество байт, занимаемое массивом. Массив я специально объявил с размером бОльшим, чем содержащийся в нём текст. А вот оператор strlen() посчитал и вернул количество символов, которые идут с начала массива и до нулевого символа в конце текста без его учёта. А вот такой будет результат при инициализации без указания размера массива:

char text[] = "Hello";
strlen(text);   // вернёт 5 ("читаемых" символов)
sizeof(text);   // вернёт 6 (байт)

Отличия от String


В отличие от String-строк, Си-строки:

char str[] = "hello";
char str2[] = "world";

str += str2;      // НЕЛЬЗЯ складывать 
str = "text";     // НЕЛЬЗЯ присваивать после инициализации 
if (str == str2); // НЕЛЬЗЯ сравнивать

Для этого существуют специальные функции, о которых мы поговорим ниже.

Оптимизация памяти


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

Инструменты для Си-строк


Массивы символов не так просты, как кажутся: их возможности сильно расширяет стандартная библиотека cstring. Использование всех доступных фишек по работе с массивами символов позволяет полностью избавить свой код от тяжёлых String-строк и сделать его легче, быстрее и оптимальнее. Подробно обо всех инструментах можно почитать в официальной документации. Очень интересный пример с манипуляцией этими инструментами можно посмотреть здесь. А мы вкратце рассмотрим самые полезные.

Конвертирование


Есть готовые функции, позволяющие конвертировать различные типы данных в строки:

  • itoa(int_data, str, base) - записывает переменную типа int int_data в строку str с базисом* base.
  • utoa(uint_data, str, base) - записывает переменную типа unsigned int uint_data в строку str с базисом* base.
  • ltoa (long_data, str, base) - записывает переменную типа long long_data в строку str с базисом* base.
  • ultoa (unsigned_long_data, str, base) - записывает переменную типа unsigned long unsigned_long_data в строку str с базисом* base.
  • dtostrf(float_data, width, dec, str) - записывает переменную типа float float_data в строку str с количеством символов width и знаков после запятой dec.

* Примечание: base - основание системы счисления, тут всё как при выводе в Serial:

  • DEC - десятичная
  • BIN - двоичная
  • OCT - восьмеричная
  • HEX - шестнадцатеричная
float x = 12.123;
char str[10] = "";
dtostrf(x, 4, 2, str);
// тут str == "12.12"

int y = 123;
itoa(y, str, DEC);
// тут str == "123"

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

  • atoi(str) - преобразование str в int
  • atol(str) - преобразование str в long
  • atof(str) - преобразование str в float
float x;
char str[10] = "12.345";
x = atof(str);
// тут x == "12.345"
Внимание! Функции конвертирования, работающие с типом float, являются очень тяжёлыми: их "подключение" занимает ~2 кБ Flash памяти!! Максимально избегайте их применения в крупном проекте. Для преобразования можно сделать свою функцию, практически готовые варианты для всех типов данных можно найти в стандартной ардуиновской Print.cpp (ссылка на файл на гитхабе Arduino).

Работа с байтовым буфером


Очень часто в реальных задачах встречается ситуация, когда текстовые данные приходят в виде массива byte: по какому-нибудь каналу связи (MQTT, UDP, Bluetooth...), при чтении из файлов и так далее. Например приём по MQTT во многих библиотеках выглядит так:

void callback(byte* payload, uint16_t len) {
}

Пришёл поток байтов известной длины. Что с ними делать, если это текст и нам в программе он нужен как строка? Во многих примерах в Интернете предлагают преобразовать данные в String, просто как String s = (char*)payload. Делать так категорически нельзя, если переданный текст не оканчивается нулевым символом, а в большинстве случаев это как раз так. Дело в том, что свободная оперативная память во время работы микроконтроллера содержит не нули, а фактически случайные значения, оставшиеся от выгруженных переменных в разных местах программы. И если у нас приходит массив, который не оканчивается нулём, то в памяти после него тоже не обязан быть ноль, и при преобразовании в строку пойдёт вся память по порядку, пока не встретится ноль. Простой пример:

char str0[] = {'a', 'b', 'c'};
char str1[] = {'d', 'e', 'f'};
char str2[] = {'g', 'h', 'i'};

Serial.println(str0); // abc
Serial.println(str1); // defabc
Serial.println(str2); // ghidefabc

Отсюда видно, что в строку пойдут любые данные из памяти, пока не встретится ноль. Что делать? Варианта два.

Через String


Если нужна String-строка, то нужно её создать, зарезервировать место под текст (чтобы избежать лишних аллокаций) и переписать в неё данные. К сожалению в реализации Arduino функция для переписывания массива в строку сделана приватной, поэтому придётся просто прибавить данные в цикле. Этот способ делает в два раза больше действий, чем могло бы быть, но для String-строки это единственный способ:

void callback(byte* payload, uint16_t len) {
  String s;
  s.reserve(len + 1);
  for (uint16_t i = 0; i < len; i++) s += (char)payload[i];
}

Через cstring


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

void callback(byte* payload, uint16_t len) {
  char str[len + 1];
  strncpy(str, (char*)payload, len);
  str[len] = 0;
}

Данный способ сильно быстрее и эффективнее чем String. Дальше можно работать с созданной строкой как обычно.

Прочее


Инструменты для копирования, поиска и сравнения

strcpy(str1, str2)

Копирует str2 в str1, включая NULL. Так как мы передаём указатель, цель и место назначения можно "подвинуть":

char str1[] = "hello world";
char str2[] = "goodbye";
// вставим bye после hello
strcpy(str1 + 6, str2 + 4); 
// тут str1 == hello bye
strncpy(str1, str2, num)

Копирует num символов из начала str2 в начало str1

char str1[] = "hello world";
char str2[] = "goodbye";
// вставим good после hello
strncpy(str1 + 6, str2, 4); 
// тут str1 == hello goodd
// вторая d осталась после "world"
strcat(str1, str2)
Прибавляет str2 к str1, при этом str1 должна иметь достаточный для этого размер. NULL первой строки заменяется на первый символ из str2
char str1[15] = "hello ";
char str2[] = "world";
strcat(str1, str2);
// здесь str1 - "hello world"
strncat(str1, str2, num)
Добавляет num символов из начала str2 к концу str1
strcmp(str1, str2)
Сравнивает str1 и str2. Возвращает 0, если строки одинаковы. Больше нуля, если str1 > str2. Меньше нуля, если str1 < str2.
strncmp(str1, str2, num)
Сравнивает первые num символов из строк str1 и str2. Возвращает 0, если эти участки одинаковы.
strchr(str, symb)
Ищет символ symb в строке str и возвращает указатель на первое совпадение.
strrchr(str, symb)
Ищет символ symb в строке str и возвращает указатель на последнее совпадение.
strcspn(str1, str2)
Выполняет поиск первого вхождения в строку str1 любого из символов строки str2 и возвращает количество символов до найденного первого вхождения.
strpbrk(str1, str2)
Выполняет поиск первого вхождения в строку str1 любого из символов строки str2 и возвращает указатель на найденный символ.
strspn(str1, str2)
Поиск символов строки str2 в строке str1. Возвращает длину начального участка строки str1, который состоит только из символов строки str2.
strstr(str1, str2)
Функция ищет первое вхождение подстроки str2 в строке str1.
strtok(str, delim)
Ищет символы-разделители delim в строке str, возвращает указатель на последний найденный. Как использовать - смотри тут.
strlen(str)
Возвращает длину строки str без учёта нулевого символа.
strdup(str)
Дублирует указанную str строку, динамически выделяя память под новую строку, возвращает указатель на новую строку. Внимание! Новая строка будет в динамической памяти, чтобы удалить такую строку - нужно использовать оператор delete или free.

Библиотека


У меня есть библиотека для удобной работы с Си-строками, по возможностям схожая со String, но гораздо легче и эффективнее. Библиотека называется mString, документацию и примеры смотрите на GitHub.

Видео


Полезные страницы


5/5 - (3 голоса)
5 2 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

8 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх