Строки
Строки — это массивы символов.
Как представить символ в памяти (то есть в виде числа)? Для этого люди придумали кодировки. Простейшая кодировка — 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
Заголовок раздела «Unicode»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/A | N/A | N/A |
| 2 | 110… | 10… | N/A | N/A |
| 3 | 1110… | 10… | 10… | N/A |
| 4 | 11110… | 10… | 10… | 10… |
Максимум — 4 байта на одну кодовую точку Unicode. Но один видимый символ может состоять из нескольких кодовых точек: составные сущности (как большинство эмодзи, собранные из нескольких кодовых точек через разделитель ZWJ) называются графемными кластерами. В UTF-8 такой кластер представляется несколькими последовательностями по 1–4 байта.
Разнообразие терминологии может запутать, поэтому термин «символ» по сути бессмыслен, когда речь идет об UTF-8. Во избежание путаницы лучше, например, называть символ (единое то, что встречается в тексте) текстовым элементом. Разные источники и реализации используют для этого разные термины.
Символ, который умещается в 4 байта, иногда называют руной (rune).
Есть также UTF-16 и UTF-32, где минимальное число байтов на символ увеличено до 2 и 4 соответственно, а кодирование устроено похожим образом.
C-строки
Заголовок раздела «C-строки»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::string_view»Нам очень часто нужно знать длину строки или сослаться лишь на часть всей строки, поэтому полезно хранить длину вместе с указателем.
В этом плане 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; // Hestd::string
Заголовок раздела «std::string»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 изменяем
Заголовок раздела «std::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 в C++ — это динамический массив
Заголовок раздела «std::string в C++ — это динамический массив»std::string — по сути std::vector<char>: вместимость и длина его буфера хранятся отдельно,
что позволяет потенциально добавлять символы в конец
без перераспределения памяти при каждом добавлении.
В C#, например, строки неизменяемы, поэтому при каждой попытке добавить к строке символ создается новая строка, в нее копируется старая, и лишь затем в конец добавляется новый символ. Это очень расточительно, зато строки C# выигрывают тем, что неизменяемы, а у неизменяемости есть свои плюсы.
const std::string& как параметр — плохая идея
Заголовок раздела «const std::string& как параметр — плохая идея»Не используйте const std::string& в качестве типа параметра.
Если const означает, что изменить его не получится, то зачем требовать,
чтобы строка была именно std::string?
Способов иметь под рукой строку полно — она может лежать в любом
линейном блоке памяти.
Лучший вариант — заменить это на std::string_view, который как раз
для этого и создан.
Он куда гибче: его можно создать без выделения динамической памяти
из любой пары указатель + длина.
std::string оканчивается нулевым байтом
Заголовок раздела «std::string оканчивается нулевым байтом»Символьный буфер любого std::string
гарантированно оканчивается нулевым байтом по стандарту C++.
Получить указатель на буфер можно методом c_str().