Привет, Хабр! Однажды я подумал, что вот не умеют разработчики жить. В каждом из языков есть свои напасти (от которых, в принципе, спасаются разве что разработчики‑полиглоты). Возьмем хотя бы C++ — даже без его шаблонов, исключений, и так далее, там все еще много мраков. Возьмем хотя бы iostream — он, блин, весит 2 МБ в последней версии GCC при компиляции под Windows! Или, вот, std::string — динамическая строка. Звучит интересно на бумаге, учитывая, что язык не из добрейших, но на практике...
std::string — что за зверь и с чем его едят
(Внимание: данное объяснение предполагает, что вы знаете, что такое стек и куча)
std::string спроектирован довольно умно для такого языка, как C++. Выглядит же он примерно так:
class std::string { char* start; size_t len; union { char smolbuf[16]; size_t capacity; } }
Зарисовка пусть и неофициальная, но довольно наглядная. Разбираем на запчасти:
char* start— указатель на первый символ в куче. Весит log(n) байт, где n = разрядность вашей ОС: допустим, на 32-битной ОС будет 4 байта, на 64-битной — 8 байт, и так далееsize_t len— длина текущей строки. Показывает, сколько сейчас в строке символов, чтобы можно было без проблем выполнять O(1) операции со строками (нахождение символов и т.п). Размер — log(n) байт, как в start.union— умный механизм языка C, позволяющий упаковывать байты вместе, позволяя не тратить места в структуре под опциональные поля.char smolbuf[16]— буфер из 16 символов, созданный для оптимизации работы с короткими строками (этот алгоритм назван SSO — Small String Optimization), позволяющий вместить в себя 15 символов + нуль‑терминатор без необходимости аллоцировать память (короткие строки лежат на стеке)size_t capacity— используется, если размер строки превышает 15 символов. В этом случае активируется аллокация памяти с кучи, и буфер уступает место переменнойcapacity, определяющей, сколько места уступить строке. В случае, если строка закончится (например, при конкатенации строк), C++ отстегивает больше памяти с кучи, увеличивая переменнуюcapacityв полтора/два раза (зависит от компилятора). Именноcapacityопределяет, сколько байт занимает строка в ОЗУ, а не size (то есть, может так статься, что при строке в 17 символов у вас будет занято 48 байт — 8 на указатель, 8 на размер, и 32 на строку — ибо ваша строка перешла отметку в 16 байт, заданную прошлымcapacity, и ЦПУ умножил capacity на 2)
Четко. Понятно. Абсолютно не восхищает. Меня от такой расточительности чуть удар не хватил, пока я это изучал. Я невольно задумался, как именно бы выглядел std::string, если бы его писал кто‑то действительно вдумчивый?
Великий план
Я начал прикидывать варианты оптимизации этого неугодного цифрового эквивалента жировой складки. После получаса раздумий я продумал следующую структуру:
typedef struct { union { // Длинные строки (куча) struct { char* ptr; uint32_t len; uint32_t capacity; }; // SSO (стек) struct { char small[15]; uint8_t sso_len; }; }; } string; // всего 16 байт на 64 бит
union я переместил в начало: теперь строка или короткая, или длинная. Нет общих переменных.
uint32_t lenхранит длину строки: эта переменная ограничена до 2³², но кому вообще понадобится создавать одну строку в 4 ГБ? Размер: 4 байта.uint32_t capacity— на деле, эта переменная — огрызок от одного из моих отвергнутых дизайнов. Не делает ничего, ибо я пока не придумал ей назначения. Считайте это просветом в 4 байта, которые можно залепить... да хотя бы хэшем для сравнения строк. Или расширениемlenдо 64 бит, ибо хэш все равно у меня вычисляется за пару тактов процессора... Но об этом позже. Кстати, если уменьшить буфер SSO и убратьcapacity, то строка будет весить только 8 байт на 32-бит=Dchar small[15]— если строка состоит из меньше, чем 15 символов, то все предыдущие переменные опускаются во имя этого буфера. Этот буфер может вместить максимум 14 символов + нуль‑терминатор. Подумывал о том, как бы приписывать его на лету и освободить 15-й слот, но идей не нашлось.uint8_t sso_len— число от 0 до 255, используемое для вычисления размера короткой строки. Так как мне нужны биты 0–3 (дают числа от 0 до 15), биты 4–7 можно использовать для флагов (например, старший бит может хранить флаг об ошибке). Занимает 1 байт.
Если кому‑то вздумается ознакомиться с проектом, ссылка на гитхаб здесь: Ссылка в Сибирь
Различные навороты
Конечно же, не может все закончиться на объявлении типа! Мне удалось не только воссоздать похудевшую версию неугодного std::string, но и создать некоторые фичи. Например, хэширование для быстрого сравнения строк.
static inline uint32_t strhash(const string* s) { if (s == NULL || !strok(s)) return 0; const char* data = strdata(s); uint32_t len = strlen_s(s); uintptr_t data_ptr = (uintptr_t)data; uintptr_t len_ptr = (uintptr_t)(is_sso(s) ? (const void*)&s->sso_len : (const void*)&s->len); uint32_t hash = (uint32_t)(data_ptr ^ len_ptr ^ (uintptr_t)len); if (len >= 2) { hash ^= (uint8_t)data[0] | ((uint8_t)data[1] << 8); } else if (len == 1) { hash ^= (uint8_t)data[0]; } hash ^= hash >> 16; hash *= 0x9e3779b9; hash ^= hash >> 16; return hash; }
Работает примерно так:
Сначала идут проверки ошибок. В случае чего возвращается 0.
Потом получаем указатели. Проверяем режим строки.
Вычисляется хэш за счет XOR указателей на данные, длину строки и самой длины.
Дополнительно примешиваются первые 2 символа. Сделано это, потому что в большинстве случаев SSO лежат на стеке рядом друг с другом, и хэш выходит одинаковым.
Хэш дополнительно перемешивается через константу золотого сечения.
Хэш готов.
Хэш на выходе можно использовать для сравнения строк или закинуть в хэш‑таблицу как ключ для O(1) поиска. Стоит отметить, что ни std::string, ни даже SDS не владеют подобными функциями. Я все же склоняюсь к тому, чтобы отдать те 4 байта из capacity хэшу, чтобы не вычислять каждый раз (мне нужны эти 10 тактов процессора, верьте мне)
Помимо этого, я также имплементировал конкатенацию... ладно, «реализовал сложение строк», тут же все свои.
Также я создал и другие интересные алгоритмы, по типу вырезания подстроки из уже существующей строки...
string foo = strsub("Hello, World", 7, 5); // foo: "World" // O(1)
..инициализацию строки без длины и с длиной...
string foo = initstr("Hello"); // O(n) — используется strlen() string bar = initstr_len("World", 5); // O(1) — длина известна
..и так далее. Но это вы сами посмотрите, ссылку на гитхаб я уже дал. Там, к слову, есть и бенчмарк против std::string и библиотеки SDS от Redis. Который вы, кстати, можете сами скомпилировать и запустить — мой Intel Pentium 2010 года все равно не самый лучший для этого (пусть я и писал с намерением запуска везде, где есть С11). Если запустите бенч — поделитесь результатами в комментах.
Единомышленики
Но не един я оказался в своем презрении к STL! Я совершил еще одно исследование, и оказалось, что большинство компаний выбрасывают std::string из своего кода, заменяя его своими велосипедами.

Начнем с самого страшного имени в истории программирования: Линус Торвальдс. Всем известна его ненависть к C++ (которую я, кстати, не одобряю — не взирая на мои высказывания, C++ на деле ни в коем случае не плохой язык, он просто действительно не подходит для низкоуровневых задач), но не всем известно, что в ядре Linux есть свои динамические строки, которые выглядят примерно так:
// Из include/linux/dcache.h struct qstr { union { struct { u32 hash; u32 len; }; u64 hash_len; }; const unsigned char *name; };
Я впал в ступор, когда увидел схожесть. Я даже поклянусь обоими руками на отсечение, что я не лез в исходники Linux за вдохновением.
Сама структура представляет указатель на первый символ строки (8 байт на 64-битной ОС — но Linux сейчас пихают везде, так что и не исключены микроконтроллеры с 16-битными ОС), а также union хэша (4 байта) и длины (4 байта), смешанную в одну 8-байтовую переменную, отвечающую за обе 4-байтовые.
Есть также технология FBString в Facebook. Слишком сложна для понимания с разбега, так что приведу псевдокод:
FBString { // 1. Режим: "Короткая" (≤ 23 символа) if (длина <= 23) { // Всё лежит прямо в объекте: сам массив байт // + один байт на длину. БЕЗ malloc(). } // 2. Режим: "Средняя" (24–255 символов) if (длина <= 255) { // Указывает на КУСОК памяти. При копировании // создаётся НОВАЯ копия (не разделяется). // Просто: malloc(memcpy) + указатель. } // 3. Режим: "Очень длинная" (> 255 символов) else { // Указывает на СЧЁТЧИК-ссылку. // При копировании увеличиваем счётчик, данные не копируем. // Счётчик ссылок атомарный, чтобы не упасть в многопоточке. } }
На вид очень развитая надстройка для многомиллионного продакшена.
Roblox и EA используют SIMDString: грубо говоря, это строка, которую можно настроить под себя и отдать на растерзание ЦПУ:
SIMDString = Шаблон <Размер_внутреннего_буфера, Аллокатор> { // 1. Режим: "Супер-короткая" // Использует внутренний массив размером, который указал ты. // Обычно ставят 64 байта, чтобы влезало много мелких строк. Если данные лезут во внутренний буфер: Копируем туда и ставим флаг. malloc() не вызывается. // 2. Режим: "Длинная" Иначе: malloc() + копирование. // Секретная соль: // Копирование, конкатенация — используют SIMD-инструкции (SSE, AVX). // Это значит, что процессор копирует по 16/32 байта за такт. }
Есть еще и технология Abseil Cord от Google, но тут я уже не буду вдаваться в подробности. Разве что скажу, что это не строка, а скорее структура данных для огромных текстов. Этакое дерево из чанков, которые хранят либр указатель на внешнюю память, либо часть строки. Если надо склеить — просто создается новый узел. Если надо прочитать всю строку ‑алгоритм проходит по дереву и считывает чанки на лету.
Итоги
За один день я:
Создал динамические строки на C
Сделал их почти по всем фронтам лучше, чем в C++ (см. бенчмарк на гитхабе)
Провел исследование технологий крупных компаний
Поделился своими трудами со внешним миром
Буду признателен, если вы оцените мою работу звездой на гитхабе или плюсиком в карму. До свидания.
Комментарии (26)

Sazonov
21.08.2026 18:56Повсеместно используем std::string в очень большом, с элементами легаси кросс-платформенном десктопном софте. Периодически делаем профилирование. Да, иногда строки становятся узким местом, но настолько редко что хватает точечных оптимизаций через string_view. В 99% случаев проблемы с перформансом из-за других вещей.

Garantia_Tsverga Автор
21.08.2026 18:56Я никогда не говорил, что std::string тормозят код. Я сказал, что для такого языка, как C++, std::string сделан плохо. А на string_view у меня свои планы, его я тоже когда-нибудь реализую.

BorisU
21.08.2026 18:56шок, есть разные реализации std::string, идите изучайте другие варианты :)

Garantia_Tsverga Автор
21.08.2026 18:56Еще одна не повредит. И вообще философия моих строк заключается в том, что они занимают как раз столько места, сколько им дали. Можно посмотреть в string.len и узнать точный размер. А еще они гораздо быстрее SDS и std::string почти во всем

Hardened_Steel
21.08.2026 18:56исходя из вашей же табличке сравнения я не могу сделать такого же вывода
- хеш вы считаете неправильно, в отличии от std::string
- в операции конкатенации проигрываете на три порядка
- исходников бенча для std::string не видно

Janycz
21.08.2026 18:56И кстати, заявлено, что собирается любым C99/C11 компилятором. Но это не так, например: gcc 16.1.0 на Windows падает с ошибкой, ибо strcat_s это нестандартная функция из состава стандартной C библиотеки под Windows, а перегрузки функций в C нет. Проект, однако, собирается на g++ под Windows, но это компилятор языка C++, а не C.

Garantia_Tsverga Автор
21.08.2026 18:56Буду чинить, благодарю за репорт. Я предполагал, что использование исключительно libc гарантирует кроссплатформенность. Но нет, Windows опять надо было встрять¯\_(ツ)_/¯

Janycz
21.08.2026 18:56А вот на самом деле, тут Windows права. Ибо стандарт ISO C11 (Annex K) резервирует функции типа
strcat_s,strcpy_sиstrcmp_s. Строго следуя стандарту языка языка С, программы не имеют права определять собственные функции с этими именами. В libc для Linux по умолчанию эти функции скрыты. Чтобы gcc с libc их увидел, надо писать#define __STDC_WANT_LIB_EXT1__ 1. В Windows эти функции были давно, до С11, как нестандартные расширения.

Kotofay
21.08.2026 18:563. Вычисляется хэш за счет XOR указателей на данные, длину строки и самой длины.
Одинаковые строки разве не должны иметь одинаковый хэш?

Garantia_Tsverga Автор
21.08.2026 18:56Я строил проект не на самую бодрую голову, а потому все может быть. Мне стоит все пересмотреть позже

tenzink
21.08.2026 18:56Уровень реализации не соответствует уровню пафоса. Хеш одинаковых строк должен быть одинаковым - в вашей реализации это не так. Пока, к сожалению, не тянет даже на учебную поделку.

Garantia_Tsverga Автор
21.08.2026 18:56Изо всех претензий, вы единогласно выбрали именно хэш. Впрочем, стоило отметить, что я вообще не тестировал функцию толком, виноват.

Hardened_Steel
21.08.2026 18:56А как вы различаете SSO строки от длинных в вашей структуре?
Хеш от указателя считать такое себе. Т.е. две одинаковые строки будут иметь разный хеш? Поведение, мягко говоря, нестандартное.
Цифры бенча на гихабе конечно интересные, в операции сложения вы проиграли std::string на три порядка, а это, мне кажется, наиболее частая операция над строками.
Garantia_Tsverga Автор
21.08.2026 18:56SSO:
static inline bool is_sso(const string* s) { return s != NULL && (s->sso_len & STR_SSO_FLAG) != 0; }Про хеши от указателей уже писали
Бенч конкатенации подразумевал добавление 1 символа 100000 раз в цикле. C++ и SDS тащат за счет геометрического роста capacity, а у меня malloc() на каждое расширение. Даже в документации напрямую указано, что так делать нельзя, надо делать одну точечную и большую.

Hardened_Steel
21.08.2026 18:56Хотелось бы комментария, чем обосновано ваше решение.
Потому, что я вижу тут следующее:
- предположим мы на 64 битной системе, форма хранения числе - LE
- sso_len ложится на capacity, причём это последний байт всей структуры
- последний байт всей структуры - самый младший байт capacity
- флаг 0x80 = 128, т.е. если мы выделим 128 байт под буфер для длинной строки (или любое другое число байт где нужный бит будет установлен в 1), мы спутаем её с короткой?

Hardened_Steel
21.08.2026 18:56Бенч конкатенации подразумевал добавление 1 символа 100000 раз в цикле. C++ и SDS тащат за счет геометрического роста capacity, а у меня malloc() на каждое расширение. Даже в документации напрямую указано, что так делать нельзя, надо делать одну точечную и большую.
std::string тоже позволяет сделать reserve(size), а вас альтернативы как бы нет.

Garantia_Tsverga Автор
21.08.2026 18:56Вы сравниваете проект, которому 37 лет, с проектом, набросанным вчера на коленке, да еще и без глубокого понимания C. Ну не понимаю я Вас.

Mingun
21.08.2026 18:56union вы конечно сделали, но кажется забыли о том, а как теперь понять, какой из вариантов активный. Ладно, допустим, пока
capacityне используется, флаги изsso_lenоказываются там, в них и можно хранить признак, но как только вы туда что-то засуните (вот хеш хотя бы), то все.

mbait
21.08.2026 18:56Тот факт, что вы ставите под сомнение устои - хорошо, когда мы прекращаем это делать, общество скатывается до поклонения авторитетам. У вас просматривается огромное желание что-то привнести в мир программирования, но вам не хватает а - знаний, б - желания сперва изучить то, что было создано до вас. Без первого ваши заявления будут вызывать в лучшем случае - улыбку и непонимание, как это случилось в хешированием. Без второго вы обречены изобретать колесо.
По первому пункту я рекомендую, как минимум, ознакомиться с методиками тестирования реализаций строк. Ваше тестирование исключительно синтетическое, никто в реальных программах не вызывает конструктор миллион раз в одном месте. В реальности программы оперируют какими-то кусками строк, конкатенируют их, обрезают, накапливают и так далее, и измерять производительности нужно на тестах, близких к реальному сценарию использования: лексически разбор, поиск вхождений регулярных выражений, сжатие.
По второму пункту я рекомендую всё таки внимательнее изучить реализацю, как минимум, трёх проектов: LLVV, Facebook/Meta и Qt. А ещё посмотреть прекрасное выступление https://www.youtube.com/watch?v=kPR8h4-qZdk
P.S. Что касается "философия моих строк заключается в том, что они занимают как раз столько места, сколько им дали" - накладные расходы в десяток байт для современного мира это ничто по сравнению с проигрышем, который будет вызван промахом кэша или неправильным/отсутствием предсказания ветвления. Непонимание этого ведёт к тому, что создаются проекты, которые умещаются на дискету, но совершенно никому не интересны. Оптимизации в стиле демосцены это сегодня удел исключительно встроенных систем, да и те постепенно приближаются по производительности к настольным.

Garantia_Tsverga Автор
21.08.2026 18:56Здоровая критика от людей вроде Вас — лучшее, что может со мной случиться. Благодарю. Но это был не столь серьезный продукт, сколь эксперимент, направленный на то, чтобы показать, насколько C++ для языка, позиционирующего себя как (среди прочих назначений) низкоуровневый, раздут. А еще мне просто было интересно его проводить.
Janycz
Ну мне понадобиться. Прочитать > 4 GiB файл полностью.
Garantia_Tsverga Автор
Уж не знаю, зачем Вам тратить 4+ ГБ ОЗУ на 1 строку (и не закончится ли запись на первом же '\0' — а она закончится, если использовать версию с
strlen()), но я все равно подумывал над разными версиями string (например, string64, которая бы потенциально включала в себя 2^64 символов — таков лимит как раз у SDS иstd::stringна 64-битной ОС.Janycz
Памяти много, 4+ GiB не жалко. Распарсить какой-нибудь большой датасет там: раз памяти много, то чтобы быстрее обработать можно загрузить сразу все. Стандартный std::string может содержать '\0' в середине строки. ReadFile из WinAPI или std::fread из <cstdio> спокойно прочитают контент с символом '\0'. WriteFile из WinAPI или std::fwrite из <cstdio> спокойно запишут контент с символом '\0' в середине. Правда, следует проявлять осторожность при подаче результата от .data() в функцию, которая ожидает нуль-терминированную строку типа char*.