Перейти к содержимому

Строки

Строки — это массивы символов.

Как представить символ в памяти (то есть в виде числа)? Для этого люди придумали кодировки. Простейшая кодировка — 7-битная, называется ASCII. Таблицу символов и их значений можно посмотреть, например, здесь.

Заметьте, что любой символ ASCII представим с помощью 7 бит, значит, в одном байте можно хранить максимум один символ. Конечно, старший бит при этом всегда равен 0, чтобы значение байта совпадало со значением из таблицы ASCII.

Тип, представляющий ASCII-символ, — char. char — это 8-битный тип; знаковый он или беззнаковый, зависит от компилятора.

🤓 «8-битный» здесь — деталь реализации. Стандарт технически гарантирует лишь, что байт содержит не менее 8 бит (точное число задается макросом CHAR_BIT), а знаковость char определяется реализацией. На практике же на любом современном железе байт 8-битный.

#include <iostream>
int main()
{
char character = 65; // 65 — код символа A в таблице ASCII
std::cout << character; // выводит A
}

Можно также пользоваться символьными литералами: тогда компилятор сам найдет за вас число в таблице ASCII.

char character = 'A';
// Эквивалентно:
char character = 65;

Над символом можно выполнять арифметику: увеличивать его или прибавлять к нему смещение. Теперь присмотритесь к заглавным латинским буквам в таблице ASCII. Они идут в таблице подряд. Значит, весь алфавит можно вывести с помощью цикла.

char startCharacter = 'A';
char endCharacter = 'Z';
for (char ch = startCharacter; ch <= endCharacter; ch++)
std::cout << ch;

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

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

char offset = 'a' - 'A';
for (char ch = 'A'; ch <= 'Z'; ch++)
{
char result = ch + offset;
std::cout << result;
}
// или наоборот
char offset = 'A' - 'a';
for (char ch = 'a'; ch <= 'z'; ch++)
{
char result = ch + offset;
std::cout << result;
}

Unicode — это стандартизированный набор кодировок, позволяющий закодировать любой символ. Самая популярная из них — UTF-8.

Все обычные символы ASCII представляются в UTF-8 без изменений. Проблема в том, что места в байте для остальных символов уже не остается — если не считать одного свободного бита, который ASCII не использует. Идея в том, чтобы использовать этот бит как знак того, что символ закодирован в нескольких байтах.

Например, символ ș можно записать в двух байтах как C8 99 в шестнадцатеричном виде. UTF-8 задействует старшие биты, чтобы указать число байтов, которыми закодирован символ.

  • Старший бит, равный 0, означает, что это самостоятельный однобайтовый ASCII-символ.
  • Если старший бит равен 1, байт принадлежит многобайтной последовательности: ведущие (первые) байты имеют шаблон 11xxxxxx, а байты продолжения — шаблон 10xxxxxx.
  • Число ведущих единиц в первом байте, до первого 0, указывает на число байтов символа: 110xxxxx — два байта, 1110xxxx — три, 11110xxx — четыре.

Идея в том, что каждый байт сам по себе несет контекст: это самостоятельный байт ASCII (первый бит 0), первый байт последовательности (шаблон 11) или один из последующих байтов последовательности (шаблон 10).

Таблица ниже иллюстрирует шаблон:

Число байтовБайт 1Байт 2Байт 3Байт 4
1 (чистый ASCII)0…N/AN/AN/A
2110…10…N/AN/A
31110…10…10…N/A
411110…10…10…10…

Максимум — 4 байта на одну кодовую точку Unicode. Но один видимый символ может состоять из нескольких кодовых точек: составные сущности (как большинство эмодзи, собранные из нескольких кодовых точек через разделитель ZWJ) называются графемными кластерами. В UTF-8 такой кластер представляется несколькими последовательностями по 1–4 байта.

Разнообразие терминологии может запутать, поэтому термин «символ» по сути бессмыслен, когда речь идет об UTF-8. Во избежание путаницы лучше, например, называть символ (единое то, что встречается в тексте) текстовым элементом. Разные источники и реализации используют для этого разные термины.

Символ, который умещается в 4 байта, иногда называют руной (rune).

Есть также UTF-16 и UTF-32, где минимальное число байтов на символ увеличено до 2 и 4 соответственно, а кодирование устроено похожим образом.

C-строка — это последовательность символов, которая заканчивается нулевым терминатором, то есть числом 0. Строки обычно хранятся в массиве или как указатель, без хранения длины строки (числа байтов). Такой способ представления пришел из C, но широко используется и в C++. Поэтому такие строки и называют C-строками. Предполагается, что программа доверяет: там, где строка кончается, будет нулевой байт, по нему она и находит конец строки.

#include <iostream>
#include <array>
int main()
{
std::array<char, 6> arr{
'H',
'e',
'l',
'l',
'o',
'\0', // то же самое, что просто 0
};
// `std::cout` обрабатывает `char*` особым образом.
// Он считает это C-строкой и печатает как строку.
std::cout << &arr[0]; // Hello
return 0;
}

А если записать нулевой байт в середину строки, вывод остановится, как только он встретится.

std::array<char, 6> arr{
'H',
'\0',
'l',
'l',
'o',
'\0',
};
std::cout << &arr[0]; // H

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

std::array<char, 6> arr = "Hello";

Строковые литералы можно присваивать и переменной-указателю. Тогда они попадут в статическую память; на некоторых платформах эта память неизменяема (запись в нее уронит вашу программу). Писать в память, на которую указывают такие указатели, не следует.

const char* str = "Hello";

Нам очень часто нужно знать длину строки или сослаться лишь на часть всей строки, поэтому полезно хранить длину вместе с указателем.

В этом плане std::string_view работает как std::span<const char>. Он хранит указатель на строку вместе с длиной. Единственная разница в том, что у него есть несколько методов, удобных для строк, а потоки вывода (например, std::cout) печатают его как строку.

Для справки: оператор << для потоков не перегружен для std::span, значит, std::cout << span; сделать нельзя — просто не скомпилируется.

Есть некоторая разница и в конструкторах: std::string_view не может быть неявно создан из std::array. Зато его можно создать из const char* — тогда он просканирует строку, пока не встретит нулевой байт, и так определит длину.

#include <iostream>
#include <array>
#include <string>
#include <string_view>
int main()
{
std::array<char, 6> arr = "Hello";
std::string_view str{&arr[0]};
std::cout << str.size(); // 5
std::cout << str; // Hello
return 0;
}

Как и std::span, ему можно передать длину в конструкторе:

std::array<char, 6> arr = "Hello";
std::string_view str{&arr[0], 2};
std::cout << str; // He

std::string — это RAII-тип, позволяющий разместить строку в куче.

#include <string>
#include <iostream>
int main()
{
// Конструирование из литерала.
// Заметьте: строка копируется в буфер, выделенный в куче,
// из статической памяти, где хранится литерал.
// Локальная переменная хранит указатель на буфер и длину.
std::string string1{"Hello world!!"};
// конструктор из указателя и длины
// заметьте, он копирует строку в новый буфер, выделенный в куче.
std::string string2{&string1[0], 10};
// Это НЕ создает новый буфер.
// Он указывает на буфер string2.
std::string_view view{string2};
std::cout << string1; // Hello world!!
std::cout << string2; // Hello worl
std::cout << view; // Hello worl
return 0;
// Неявно добавлено:
// string2.~string()
// string1.~string()
}
std::string string1{"Hello world!!"};
string1[0] = 'L';
std::cout << string1; // Lello world!!

Он также поддерживает удобные операторы конкатенации:

std::string string1{"Hello"};
string1 += " world"; // Может перераспределить буфер.
std::cout << string1; // Hello world

Кстати, для коротких строк он на самом деле не выделяет память в куче. Если строка достаточно короткая, он хранит её внутри самого объекта (как правило, до 23 байтов размещение не происходит). Это называется оптимизацией коротких строк (small string optimization).

std::string — по сути std::vector<char>: вместимость и длина его буфера хранятся отдельно, что позволяет потенциально добавлять символы в конец без перераспределения памяти при каждом добавлении.

В C#, например, строки неизменяемы, поэтому при каждой попытке добавить к строке символ создается новая строка, в нее копируется старая, и лишь затем в конец добавляется новый символ. Это очень расточительно, зато строки C# выигрывают тем, что неизменяемы, а у неизменяемости есть свои плюсы.

Не используйте const std::string& в качестве типа параметра.

Если const означает, что изменить его не получится, то зачем требовать, чтобы строка была именно std::string? Способов иметь под рукой строку полно — она может лежать в любом линейном блоке памяти.

Лучший вариант — заменить это на std::string_view, который как раз для этого и создан. Он куда гибче: его можно создать без выделения динамической памяти из любой пары указатель + длина.

Символьный буфер любого std::string гарантированно оканчивается нулевым байтом по стандарту C++. Получить указатель на буфер можно методом c_str().