Занятие 9. Строки: от C-стиля до modern C++
Строки — самый используемый и самый недопонятый тип данных: под словом «строка» в C++ живут три разных зверя (const char*, std::string, std::string_view), а под словом «символ» — целых четыре понятия. Разбираем всё по слоям, с реальными замерами (Apple M-серия, clang++ -O2).
Наследие C
Массив байтов с секретом в конце
Тип литерала "Hello" — const char[6]: пять букв плюс невидимый '\0'. Длина нигде не хранится — конец обозначает терминатор, поэтому каждый strlen — проход по строке за $O(n)$ (занятие 2 передаёт привет). Переход массив → указатель — это decay из занятия 3.
Три классические ловушки:
Литералы живут в .rodata
Строковые литералы лежат в секции .rodata исполняемого файла (занятие 1) — память только для чтения, время жизни — вся программа. Возвращать const char* на литерал безопасно (в отличие от локального массива — привет, висячие указатели из занятия 5); менять литерал — честный мгновенный крэш. Компилятор может склеивать одинаковые литералы — поэтому сравнение их адресов иногда «работает», а потом внезапно нет.
Зачем это знать, если есть std::string
main(int argc, char* argv[]), системные вызовы и все C-библиотеки говорят на const char*. C-строки — язык границы; внутри программы — современные типы.
std::string
Это DynArray для символов
std::string — владеющая строка: внутри указатель, size и capacity — вчерашний DynArray (занятие 8) для char, плюс полторы сотни методов. Следствия: size() за $O(1)$ (длина хранится), глубокое копирование, RAII, рост с запасом ×2, автоматический терминатор в конце — для совместимости с C через c_str().
SSO: короткие строки не трогают кучу
Small String Optimization: в 24 байтах объекта прячется буфер — строки до 22 символов (libc++) живут прямо в объекте, копия — это memcpy 24 байт. Байты объекта переиспользуются: либо (ptr, size, capacity), либо сам текст. Вот чем строка отличается от «вектора символов» — инженерия победила чистоту абстракции.
+= против +: квадрат в невинной строчке
t + 'x' создаёт новую строку с копированием накопленного: $1 + 2 + \dots + n = \Theta(n^2)$ — «расти на +1» из занятия 8, вид сбоку. Тот же капкан есть в Python и Java (там ради него придумали StringBuilder). Правило: наращиваете — +=/append; знаете итоговый размер — reserve.
std::string_view
Окно в чужие символы
string_view — невладеющее «окно»: указатель + длина, всего 16 байт. Смотрит на что угодно — string, литерал, кусок буфера; substr у view — $O(1)$ без копии (у string — копия!).
Идеальный тип параметра для чтения:
И его тёмная сторона — висячие view:
Правило времени жизни
View живёт, пока жив владелец символов. Это ссылка из занятия 3 со всеми болезнями висячих указателей: параметры и локальный разбор — да; поля классов и возвраты — почти всегда нет.
Таблица выбора
| задача | тип |
|---|---|
| хранить строку (поле, контейнер) | std::string |
| читать в функции | std::string_view |
| менять в функции | std::string& |
| граница с C API | const char* через c_str() |
У string_view нет c_str() — терминатор не гарантирован. Дефолт курса: хранение — string, чтение — string_view.
Повседневные операции
find/rfind (не найдено — npos; сравнивайте с ним, не с −1 — привет, беззнаковый size_t), substr, C++20-шные starts_with/ends_with. Числа ↔ строки: to_string, stoi (бросает исключения), быстрый from_chars — без локалей и исключений.
Unicode и UTF-8
Краткая история хаоса
ASCII (1963) — 7 бит, 128 символов; коды '0'..'9' подряд — отсюда c - '0'. Потом восьмибитные кодовые страницы (КОИ-8, CP1251…) и «кракозябры». С 1991 — Unicode: единый реестр на ~155 тысяч символов. Термины: код-поинт — номер символа (U+041F = «П»); кодировка — как номера становятся байтами; графема — то, что человек видит как один символ.
UTF-8
| код-поинты | байтов | раскладка | примеры |
|---|---|---|---|
| U+0000…U+007F | 1 | 0xxxxxxx |
весь ASCII |
| U+0080…U+07FF | 2 | 110xxxxx 10xxxxxx |
кириллица |
| U+0800…U+FFFF | 3 | 1110xxxx 10xxxxxx ×2 |
иероглифы, ₽ |
| U+10000…U+10FFFF | 4 | 11110xxx 10xxxxxx ×3 |
эмодзи |
Два гениальных свойства: ASCII-текст — уже валидный UTF-8, а байты-продолжения всегда 10xxxxxx — с любого места можно найти границу символа (самосинхронизация).
«Привет» под микроскопом
Каждая кириллическая буква — 2 байта; size() честно считает байты. p[0] — это байт 0xD0, не буква «П». Следствие: обрезка по произвольному индексу разрезает символ пополам и даёт невалидный UTF-8 (тот самый «�») — резать можно только по границам символов.
Практический рецепт курса
Храните текст в std::string с UTF-8. Работайте с целыми подстроками (поиск/сравнение безопасны). Не индексируйте «буквы». Длина в код-поинтах — счёт байтов, не равных продолжениям: (c & 0xC0) != 0x80. Для серьёзной обработки текста (регистры, нормализация) — библиотека ICU: стандартная библиотека C++ этого не умеет.
Самопроверка
- Почему
strlen— $O(n)$, аs.size()— $O(1)$? if (cmd == "quit")дляconst char* cmd— что сравнивается на самом деле?- Когда
string_view-параметр лучшеconst std::string&— и когда view опасен? - Сколько байтов займёт строка
"π = Пи"в UTF-8?
Домашнее задание (сдача через Git)
split(std::string_view s, char sep)→ вектор view — без единой копии символов; тесты на пустые части.utf8_length(std::string_view)— длина в код-поинтах; проверить на «Привет», «héllo», эмодзи.- Повторить бенчмарк
+=против+; объяснить числа через занятие 8. - *
utf8_valid()— проверка корректности байтов по таблице шаблонов.