View Categories

String-строки

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

Рекомендуется изучить следующие уроки:

Arduino String #

В стандартной библиотеке C++ есть динамические строки типа std::string - они есть не во всех компиляторах для МК, а также довольно неудобны в использовании, поэтому в рамках уроков Arduino мы их не рассматриваем. В Arduino фреймворке есть свой инструмент для работы с динамическими строками - класс String. По сути это "обёртка" на стандартную библиотеку для работы со строками, внутри которой создан динамический массив - размер такой строки может меняться после создания. Это аналог std::string, который работает на всех платформах.

Создание и инициализация #

Строка создаётся как переменная типа String и может быть инициализирована строковым литералом, другой String-строкой или F-строкой (в документации это не explicit конструкторы):

String s;               // пустая
String s1 = "str";      // const char*
String s2 = s;          // String
String s3 = F("fstr");  // F-строка

Также инициализацию можно сделать через конструктор. В этом случае можно использовать числа - они будут преобразованы в текст:

String s1("str");       // из строковой константы
String s2(s1);          // String
String s3(F("fstr"));   // F-строка

String s4(1234);        // из целого числа, в десятичном виде == "1234"
String s5(1234, HEX);   // с указанием основания (BIN, DEC, HEX) == "4D2"
String s6(3.141);       // из float, по умолч. точность 2 знака == "3.14"
String s7(3.141, 1);    // из float, с указанием кол-ва знаков после запятой == "3.1"
String s8('a');         // из символа

Инициализация через конструктор выполняется чуточку быстрее

Присваивание #

Строке можно присвоить новое значение, поддерживаются все стандартные типы данных:

String s;
s = 12345;      // целое число. s == "12345"
s = 3.1415;     // float, по умолч. 2 знака после запятой. s == "3.14"
s = "text";     // строковая константа
s = F("text");  // F-строковая константа
s = 's';        // символ

s = "";         // "очистить" строку

Печать #

Тип String поддерживается стандартным интерфейсом печати Print, поэтому может выводиться в Serial, а также почти на любые дисплеи, которые поддерживают Print:

String s = "abcd";
Serial.println(s);

Прибавление (конкатенация) #

К строке можно прибавить данные любого типа - они будут преобразованы в текст. Операция изменяет исходную строку:

String s;       // s == ""
s += 12345;     // целое число. s == "12345"
s += 3.14;      // float. s == "123453.14"
s += "text";    // строковая константа. s == "123453.14text"
s += F("text"); // F-строковая константа
s += 's';       // символ
int val = 1234;
String s2(1234);
s += s2;        // другая строка

Сложение #

String можно складывать между собой и с данными стандартных типов при помощи оператора +:

  • Одним из первых двух слагаемых должна быть String-строка: строка + данные или данные + строка, т.е. чтобы оператор + применялся к строке слева или справа
  • Если строки нет - можно создать её через конструктор String(): String(данные1) + данные2
  • Операция сложения не изменяет строку
  • Операция сложения возвращает новую строку как результат сложения, что позволяет сделать "каскад" из таких сложений и собрать строку "одной строчкой кода", сборка происходит слева направо: строка + данные1 + данные2 или данные1 + строка + данные2 + данные3
  • Результат всей "суммы" можно:
    • Приравнять к String: строка = строка + данные1 + данные2
    • Отправить в функцию, которая принимает String: f(строка + данные1 + данные2)
    • И так далее
String s;
s = String("Hello") + ',' + " World " + 1234;
Serial.println(s);                      // Hello, World 1234
Serial.println(s + "text " + 5678);     // Hello, World 1234text 5678

Не нужно преобразовывать к String остальные слагаемые - достаточно чтобы первое или второе было String:

s = String("Hello") + String(1234) + String(", ") + String(3.14);   // не надо так!
s = String("Hello") + 1234 + ", " + 3.14;

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

Доступ к символам #

К строке можно обратиться как к массиву через [] и прочитать или изменить символ по индексу:

String s("hello!");
s[0] = 'H';
// Здесь s == "Hello!"

char c = s[1];
// здесь c == 'e'

Сравнение #

String можно сравнивать между собой и с Си-строками:

String s1 = "text1";
String s2 = "text2";
s1 == s2;           // false
s1 == "text1";      // true
s2 != F("text2");   // false

Доступ к буферу #

Не все функции и методы других библиотек поддерживают String, некоторые принимают только обычные const char*-строки. Чтобы передать свою строку в такую функцию, нужно вызвать у неё метод c_str() - это буфер строки, указатель на текущий адрес первого символа. В процессе работы этот адрес может меняться, поэтому сохранять его в переменную на долгий срок не рекомендуется.

String s = "text";
Serial.println(s);          // print работает со String
Serial.println(s.c_str());  // и с const char* тоже

Остальные возможности #

Остальные методы класса String смотри в документации.

Проблемы и рекомендации #

String - очень мощный и удобный инструмент для работы со строками, но пользоваться им нужно с умом и пониманием того, как он работает - программа может стать сильно тяжелее и медленнее.

Дублирование памяти #

Не используйте String для хранения текста как константы, особенно глобально. Т.е. это строка, которая не меняется дальше в программе и используется для сборки других строк. В записи вида String s = "abc"; происходит следующее:

  • Строка "abc" хранится в постоянной памяти программы
  • В AVR строковая константа при запуске программы копируется в оперативную память
  • Затем она ещё раз дублируется в динамической памяти внутри String

Таким образом, одна и та же строка занимает в программе тройной объём памяти! Чтобы этого избежать, нужно использовать строковые константы напрямую, либо хранить их как const char*:

const char* cstr = "World";

String s;
s += "Hello, ";
s += cstr;
// s == "Hello, World"

По этой же причине не рекомендуется использовать массив String-строк - используйте массив указателей, как в уроке о Си-строках

Ещё лучше использовать F-строки, подробнее о них будет рассказано в уроке о PROGMEM. F("строка") хранится только в памяти программы, не занимая места в оперативной. Такие строки можно прибавлять к String:

String s(F("Hello, "));
s += F("World");
// s == "Hello, World"

Использование памяти #

Несмотря на то, что строка - это динамический инструмент, в реализации Arduino она может только увеличиваться. Это означает, что если была длинная строка, а затем её "очистили" - места в памяти она не стала занимать меньше! То есть:

String s = "0123456789";
// занято 11 байт
s = "";
// всё ещё занято 11 байт

String s2;

Следующая созданная строка (s2) будет размещена в памяти сразу за предыдущей, если старая пустая строка нам уже не нужна - в памяти останется "дырка". Как удалить строку и освободить память?

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

Избегайте глобальных строк

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

void foo() {
    {
        String s;
        s += 1234;
        Serial.println(s);
    }
    {
        String s;
        s += "abcd";
        Serial.println(s);
    }
}

Реаллокация и reserve #

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

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

String позволяет зарезервировать память под строку при помощи метода reserve(длина), чтобы эффективно увеличивать строку в рамках выделенного буфера

Наглядный пример:

String s1, s2;

s1 += 'a';
s2 += 'b';

s1 += 'a';
s2 += 'b';

s1 += 'a';
s2 += 'b';

Здесь строки s1 и s2 "прыгают" по памяти друг через друга и в начало, чтобы получить место под увеличение строки на один символ. Если изначально зарезервировать место под строки - код будет выполняться значительно быстрее.

String s1, s2;

s1.reserve(3);
s2.reserve(3);

// ...

Даже если зарезервировать место под одну строку - уже будет хорошо, вторая строка будет увеличиваться без перемещения по памяти

В reserve() указывается абсолютный размер строки, то есть если попытаться зарезервировать меньше, чем в строке уже есть - ничего не произойдёт. Чтобы зарезервировать относительное количество символов, например +10 к текущей длине строки, можно сделать так:

s.reserve(s.length() + 10);

Хорошие практики #

  • Не создавать глобальные строки, которые будут изменяться в процессе работы программы, чтобы избежать реаллокации
  • Если без этого не обойтись - резервировать место в строке по максимальному размеру
  • Не создавать новых строк (в т.ч. не вызывать конструктор String()), пока собирается старая строка, либо заранее резервировать длину для старой строки
  • Не собирать строку через оператор +, использовать вместо этого +=. Это быстрее, эффективнее и не создаёт дырок и реаллокаций:
String s;
// s = String("abc") + 123 + 3.14;  // это работает медленнее и может создавать дыры
s += "abc";
s += 123
s += 3.14;

В то же время, не страшно собирать строку через + в вызове функции:

Serial.println(String("abc") + 123);
  • Не вызывать конструктор String() там, где это не нужно, т.к. это реаллокация и смещение строки с образованием дырки размером со строку из конструктора:
String s = String(123);     // лишний, можно просто s = 123
s = s + String(123);        // лишний, можно просто s + 123
s += String(123);           // лишний, можно просто s += 123

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

String s;
s += String(3.1415, 3);     // += "3.141"
s += String(24, BIN);       // += "11000"

В этом случае будут реаллокации, т.к. новая строка создаётся в памяти сразу за старой. Для оптимизации скорости и памяти нужно зарезервировать строку на нужное количество символов:

//s.reserve(10);
s.reserve(s.length() + 10);
s += String(3.1415, 3);     // += "3.141"
s += String(24, BIN);       // += "11000"

Передача в функцию #

Функции и методы многих библиотек для Arduino принимают String-строки. Это может быть вывод на дисплей, отправка в веб и многое другое. Если посмотреть реализацию этих функций - они принимают аргументы типа String& или const String&. Это - ссылка на строку, подробнее читайте в уроке про указатели и ссылки. При разработке собственных функций и методов стоит делать именно так.

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

void prStr(String s) {
    Serial.println(s);
}

Проблема в том, что когда мы передадим в эту функцию строку:

String s = "123";
prStr(s);

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

void prStr(const String& s) {
    Serial.println(s);
}

Так как строка может быть создана из Си-строки, другой String-строки и F-строки, то в const String& можно передавать аргументы этих типов - будет автоматически вызван конструктор String:

String s = "123";
prStr(s);
prStr(String(123));
prStr("abc");
prStr(F("abc"));

Если в условной библиотеке функция принимает аргумент const String&, то туда можно передавать строки в любом виде

Сборка строки в функции #

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

String getString() {
    String s;
    s += 12345;
    return s;
}

// ...
send(getString());
// ...

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

void makeString(String &s) {
    s += 12345;
}

// ...
String s;
makeString(s);
send(s);
// ...

Такой вариант работает чуточку быстрее (на пару десятков мкс на AVR) и использует чуть меньше оперативной памяти в рамках всей конструкции.

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

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