В ретроспективе 1991 года по истории C++ его создатель Бьярне Страуструп назвал отсутствие стандартного строкового типа
(и некоторых других стандартных типов) в C++ 1.0 худшей ошибкой, которую он допустил при его разработке:
"the absence of those led to everybody re-inventing the wheel and to an unnecessary diversity in the most fundamental classes"
(«Их отсутствие привело к тому, что все заново изобретали велосипед, и к ненужному разнообразию в самых фундаментальных классах»).
</cite>
## Что было и есть
Во вступительной части я хочу немного описать, каково ныне состояние со строками в С++, как мы к нему докатились и почему оно таково.
А также описать недостатки текущих реализаций, чтобы были понятны решения, которые я использую в своей библиотеке строк.
Собственно, изначально как такового стандартного типа для строк в С++ не было.
Для работы со строками использовался подход из C – строка есть указатель на массив байтов, оканчивающихся нулём.
Недостатки таких строк — невозможно в строке использовать байт `0`, т.е. не подходит для бинарных данных,
непонятна стратегия управления/владения ресурсами, ну и основной недостаток — длину строки приходится вычислять каждый раз,
перебирая все её символы.
Откуда ноги растут у такого решения вполне понятно — со времён динозавров: как динозавры были большие, с маленьким мозгом и
короткими ручками, так и компьютеры были большие, память у них была маленькая, а строки короткими. Сэкономить память на хранении длины строки было важнее, чем потерять время на повторный подсчет длины.
Первые попытки стандартизировать строки как класс начались только в С++98 - std::string появился, как часть STL, и как
многое из STL, крайне неоднозначно воспринимался программистами.
И первое, что приходит в голову при улучшении C-строк — надо хранить длину строки:
```cpp
struct simple_string {
const char* data;
size_t length;
};
```
При наличии такой строки, уже множество алгоритмов значительно оптимизируются.
Например, при сравнении двух строк на равенство мы можем даже не начинать сравнивать их символы, если длины строк не равны.
Более того, этих данных абсолютно достаточно для всех методов, которые не модифицируют строку.
Также заметим, что такой объект на современных 64-битных архитектурах прекрасно передается в функции по значению —
обаего поля укладываются в регистры (ну, кроме windows), что облегчает работу оптимизатору компилятора.
Между тем, такое решение попало в стандарт только аж в С++17, в виде `std::string_view`.
Видимо, только тогда до комитета смогли донести мысль, что строки строкам рознь, и использовать только один универсальный объект
для строк — по меньшей мере может приводить к уменьшению производительности, а также нарушает принцип «не плати за то,
чем не пользуешься». Почему же «строки строкам рознь» и почему нам мало одного типа для строки, рассмотрим как раз далее.
### Ресурсы
И следующий вопрос, возникающий со строками — это владение ресурсами.
Практически каждый крупный фреймворк решал эту задачу самостоятельно, изобретая свои велосипеды.
У нас есть `std::string`, в QT у нас `QString`, в MFC - `CString`, в ATL - `CAtlString`, свои строки есть в Folly,
в общем, “тысячи их”, любой игровой движок начинают с того, чтобы написать свои строки.
Многие из этих реализаций в аспекте управления ресурсами для улучшения производительности использовали подход
**COW** – “Copy On Write”. При этом объект строки ссылался на некий разделяемый между несколькими объектами буфер с символами
строки и счётчиком ссылок на этот буфер, что позволяло быстро создавать копию строки, а реально копировать символы только
при её модификации.
Но все они совпадали в одном — строка всегда предполагалась мутабельной, то есть что мы можем модифицировать символы в буфере строки.
### Мутабельность / иммутабельность
Из-за этого подход **COW** умер к С++11: при каждой операции, могущей модифицировать символы строки приходилось проверять,
не ссылаемся ли мы на разделяемый буфер и если да, то копировать символы в другой буфер.
В многопоточной же среде потом ещё и проверять, не надо ли теперь освобождать старый буфер, и естественно всё это обмазавшись
локами или атомиками, что тоже не бесплатно.
Поэтому, начиная сС++11 `std::string` не использует **COW**, и каждое копирование объекта строки приводит и к копированию
всех символов строки в другой буфер.
Естественно, что каждый новый буфер требует аллокации памяти, что пытаются немного оптимизировать за счёт **SSO**–
“Small String Optimization”, когда объект строки содержит внутри себя небольшой буфер и символы коротких строк
располагаются прямо в нём.
Но это уже зависит от реализации: в одних библиотеках помещают в объект строки до 15 байт, в некоторых до 23.
Однако эта оптимизация тоже палка о двух концах, и может в различных реализациях усложнить перемещение строки - если она хранит
указатель на свой внутренний буфер, его придётся корректировать.
А без COW мутабельность строк приводит к тому, что любая инициализация объекта строки приводит к копированию байтов.
Посмотрим такой код:
```cpp
const char* text1 = "Hello, World"; // ничего не стоит
std::string_view text2 = "Hello, World"; // Ничего не стоит, вычисляет длину строки при компиляции
std::string text3 = "Hello, World"; // В рантайме каждый раз копирует символы строки
```
(Удостоверится в правдивости комментариев можно на https://godbolt.org/z/51oKGWT5T )
Но если нам дальше по коду не нужно никак модифицировать строку, мы зря платим за аллокацию, копирование символов,
а также за деструктор строки. То есть хотелось бы иметь как минимум два варианта строк — мутабельные и иммутабельные,
чтобы явно дать понять компилятору, что мы не собираемся модифицировать строку.
Или банальный пример — мы парсим какой-то входящий буфер данных, нам нужно проверить, равен ли некий кусок буфера строке
”hello” на «чистом С++», т.е. без всяких memcmp и strcmp. До появления string_view приходилось делать примерно так:
```cpp
bool is_part_buffer_equal_hello(const char* data, int start, int end) {
return std::string(data + start, end - start) == "hello";
}
```
Тут получается, сначала копируются символы из буфера data в буфер временной строки, возможно с аллокацией памяти, и лишь
потом временная строка сравнивается с ”hello”, а потом ещё и деструктор и раскрутка стека на случай исключения.
При использовании же вместо `std::string``std::string_view`– код на C++ почти не меняется:
```cpp
bool is_part_buffer_equal_hello_view(const char* data, int start, int end) {
return std::string_view(data + start, end - start) == "hello";
}
```
Однако генерируемый машинный код значительно преобразуется, достигая уровня ручного С-кода — там просто сравнивается,
что end – start == 5 и дальше кусок начального буфера сравнивается через memcmp со строкой ”hello”
(при -O2 c константами 1819043176 (’hell’) и 111 (’o’)).
Ни создания временного объекта, ни копирования байтов, ни деструктора, ни раскрутки стека для исключений.
Убедится можно на https://godbolt.org/z/9fo188e7c
Казалось бы, ну вот же в С++17 появился `string_view`, пожалуйста, используй его в параметрах своих функций вместо `const std::string&`,
и будет счастье. Но тут тоже есть нюанс — всё отлично работает, пока нам не нужно передать строку в стороннее C-API: string_view не даёт
гарантий нуль-терминированности строки, поэтому его data() нельзя передать в стороннее C-API, и потому всё-равно придётся сначала
скопировать его в `std::string`. А раз нужен `std::string`, то и параметром функции оптимальнее cделать `const std::string&`
и далее по цепочке, все параметры вновь станут `const std::string&`.
### Конкатенация строк
Далее, после инициализации строки, самая частая мутабельная операция с ними, скорее всего конкатенация строк, либо в виде просто
сложения строк, либо добавления строки к строке. И именно она легко может вызывать как неоптимальную производительность при неграмотном
использовании, так и оверхед по памяти, даже при грамотном использовании.
Рассмотрим простой код ( https://godbolt.org/z/odx7W1Pv7 )
Заменяет в исходной строке вхождения Искать на Заменять.
Шаблоны поиска и замены - могут быть любыми строковыми объектами в рантайме.
#### empty_expr<ТипСимвола>
Выдает пустую строку. Сокращённая запись — eea, eeu, eew, eeuu. Применяется если формирование строки начинается с числа и строкового литерала:
```cpp
str = eea + count + " times.";
```
так как оператор сложения определён только для сложения строкового выражения и числа.
Также замечу, что существует `operator""_ss`, который превращает строковый литерал в объект `simple_str_nt`, который уже является строковым выражением:
```cpp
str = "Count = "_ss + count;
...
str = count + " times."_ss;
```
#### Свои строковые выражения
Вы можете сами создавать свои типы строковых выражений для оптимального формирования строк в нужных вам целях и алгоритмах.
Для этого просто создайте тип с методами `length`, `place` и `typename symb_type`.
Примеры создания и использования из реальных проектов:
```cpp
/* Сформировать строку в JSON формате, в 16 битных символах */