C++

Довжина, ємність та доступ до символів std::string

Різниця між length() та capacity(), механізм подвоєння буфера при перевиділенні памяті, Small String Optimization, методи reserve() та shrink_to_fit() для оптимізації, доступ до символів через [], at(), front(), back() та ітерація рядком.

Довжина, ємність та доступ до символів std::string

Чому порожній рядок займає 32 байти?

Запустіть цей фрагмент коду і придивіться до результатів:

SizeVsCapacity.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "Hi";

    cout << "length():   " << s.length()   << "\n"; // 2
    cout << "capacity(): " << s.capacity() << "\n"; // ???
    cout << "sizeof(s):  " << sizeof(s)    << "\n"; // ???

    return 0;
}
./SizeVsCapacity (GCC 13, Linux)
$ ./SizeVsCapacity
length(): 2
capacity(): 15
sizeof(s): 32
Execution finished with exit code 0.

Рядок містить 2 символи, але capacity() повертає 15, а розмір самого об'єкта — 32 байти. Де решта? Чому sizeof дає 32, а не 2?

Відповідь криється у двох явищах: моделі пам'яті std::string із запасним буфером для зростання і Small String Optimization — хитрій оптимізації компілятора, що дозволяє зберігати короткі рядки прямо всередині об'єкта, без виходу на купу.


Довжина та ємність: дві різні характеристики

.length() та .size() — кількість байтів у рядку

Методи .length() і .size()синоніми, обидва повертають кількість символів (байтів) у рядку, не рахуючи нуль-термінатор:

LengthAndSize.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "Hello";

    cout << s.length() << "\n"; // 5
    cout << s.size()   << "\n"; // 5 — те саме

    s += ", World!";
    cout << s.length() << "\n"; // 13

    string empty;
    cout << empty.length() << "\n"; // 0
    cout << empty.empty()  << "\n"; // true (1)

    return 0;
}
./LengthAndSize
$ ./LengthSize
5
5
13
0
1
Execution finished with exit code 0.
.length() — з'явився першим і ближчий до природної мови («довжина рядка»). .size() — доданий пізніше для сумісності з іншими контейнерами STL (vector, list тощо). У промисловому коді зустрічаються обидва — вони рівнозначні.

Тип, який повертають обидва методи — std::string::size_type, що є беззнаковим цілим типом (зазвичай size_t). Це важливо: не порівнюйте результат .length() зі знаковими цілими без явного приведення, інакше може виникнути попередження або несподіваний результат при від'ємних значеннях.

.capacity() — скільки пам'яті виділено

Ємність — це максимальна кількість символів, яку рядок може зберігати без перевиділення пам'яті. На відміну від довжини, ємність зазвичай більша:

Capacity.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "0123456789"; // 10 символів

    cout << "length:   " << s.length()   << "\n"; // 10
    cout << "capacity: " << s.capacity() << "\n"; // >= 10, зазвичай 15

    return 0;
}
./Capacity
$ ./Capacity
length: 10
capacity: 15
Execution finished with exit code 0.

Аналогія: length — це скільки води зараз у склянці, capacityрозмір склянки. Якщо води налили більше, ніж вміщає склянка, потрібна нова, більша склянка (перевиділення).

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title length() vs capacity() — аналогія з склянкою

rectangle "std::string s = \"Hello\"" as obj #334155 {
  rectangle "length() = 5\n(символів реально)" as len #3b82f6
  rectangle "capacity() = 15\n(місця виділено)" as cap #22c55e
  rectangle "          (вільно: 10 байтів)          " as free #1e293b
}

note bottom of obj
  Рядок НЕ перевиділяє пам'ять, доки
  length() не перевищить capacity().
  Запас — безкоштовне зростання.
end note

@enduml

.max_size() — теоретична максимальна довжина

Метод .max_size() повертає максимальну кількість символів, яку може зберігати std::string на даній платформі. На 64-бітних системах це значення величезне — порядку мільярдів:

string s;
cout << s.max_size() << "\n"; // ~4611686018427387903 (64-bit Linux)

На практиці обмеженням є доступна пам'ять, а не max_size.


Модель пам'яті: як рядок зберігається всередині

Три поля всередині об'єкта

Концептуально std::string складається з трьох частин (точна реалізація залежить від бібліотеки):

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title Внутрішня структура std::string (без SSO)

rectangle "Об'єкт std::string (на стеку)" as obj #1e293b {
  rectangle "char* ptr\n(8 байтів)" as ptr #3b82f6
  rectangle "size_t size\n(8 байтів)" as sz #3b82f6
  rectangle "size_t capacity\n(8 байтів)" as cap #3b82f6
}

rectangle "Купа (heap)" as heap #15803d {
  rectangle "'H' 'e' 'l' 'l' 'o' '\\0' _ _ _ _\n(виділено capacity+1 байтів)" as buf #16a34a
}

ptr -right-> heap : "вказує на буфер"

note bottom of obj
  sizeof(std::string) = 24-32 байти
  (залежить від реалізації та SSO)
end note

note bottom of heap
  Реальні дані рядка.
  При перевиділенні — нова адреса!
end note

@enduml

Об'єкт зберігається на стеку (24–32 байти), а самі символи — на купі. При операції s += " World" може виникнути потреба у більшому буфері — тоді std::string виділяє нову ділянку купи, копіює туди символи, і звільняє стару.

Механізм подвоєння при перевиділенні

Коли довжина рядка перевищує ємність, бібліотека виконує перевиділення пам'яті (reallocation). Класична стратегія — подвоєння поточної ємності. Це забезпечує амортизовано O(1) для операції push_back/+=:

CapacityGrowth.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s;

    cout << "size | capacity\n";
    cout << "-----+---------\n";

    for (int i = 0; i < 20; ++i)
    {
        s += 'x';
        cout << s.size() << "    | " << s.capacity() << "\n";
    }

    return 0;
}
./GrowthDemo (GCC 13)
$ ./GrowthDemo
size | capacity
-----+---------
1 | 15
2 | 15
...
15 | 15
16 | 30 ← перевиділення!
17 | 30
...
30 | 30
Execution finished with exit code 0.

Ємність зростає стрибками: після SSO-буфера (15 символів) наступне перевиділення дає 30, потім 60, 120 і так далі. Завдяки цьому кількість перевиділень логарифмічна по відношенню до кількості символів.

При кожному перевиділенні всі вказівники, посилання та ітератори на елементи рядка стають недійсними (dangling). Якщо ви зберегли char* p = s.data() і після цього виконали s += "more" — вказівник p може вказувати на вже звільнену пам'ять.

Small String Optimization (SSO)

Ідея: короткі рядки не потребують купи

Виділення пам'яті на купі — відносно повільна операція. Якщо кожен маленький рядок вимагав би new[], програма з тисячами рядків витрачала б багато часу лише на їх виділення та звільнення.

Рішення: Small String Optimization — більшість реалізацій std::string зберігають короткі рядки прямо всередині самого об'єкта, використовуючи ті ж 32 байти, що займає сам об'єкт. Пам'ять на купі не виділяється взагалі.

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title SSO — два режими зберігання std::string

rectangle "Короткий рядок (SSO режим)" as short #22c55e {
  rectangle "SSO буфер: 'H' 'i' '\\0' _ _ _ _ _ _ _ _ _ _\n(до 15 символів — всередині об'єкта)" as sbuf #16a34a
  rectangle "size = 2" as ssize #15803d
  rectangle "SSO flag" as sflag #15803d
}

note right of short
  Купа НЕ задіяна.
  Швидко, без malloc/new.
end note

rectangle "Довгий рядок (heap режим)" as long #3b82f6 {
  rectangle "char* ptr ─────────────►" as lptr #2563eb
  rectangle "size = 50" as lsize #1d4ed8
  rectangle "capacity = 63" as lcap #1d4ed8
}

rectangle "Купа (heap)" as heap #475569 {
  rectangle "'H' 'e' 'l' 'l' 'o' ... '\\0'\n(50+ символів)" as hbuf #334155
}

lptr -right-> heap

note right of long
  Пам'ять виділена через new[].
  Деструктор звільнить її.
end note

@enduml

Розміри SSO-буфера у різних компіляторах

Розмір SSO-буфера залежить від реалізації і не визначений стандартом:

РеалізаціяSSO-буферРозмір об'єкта
GCC libstdc++15 символів32 байти
Clang libc++22 символи24 байти
MSVC15 символів32 байти (32-bit: 28 байтів)
SsoProbe.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    // Перевіряємо, де проходить межа SSO
    for (int n = 14; n <= 18; ++n)
    {
        string s(n, 'x'); // рядок з n символів 'x'
        cout << "n=" << n
                  << "  capacity=" << s.capacity() << "\n";
    }

    return 0;
}
./SSOProbe (GCC 13)
$ ./SSOProbe
n=14 capacity=14
n=15 capacity=15 ← SSO межа у GCC
n=16 capacity=16 ← перехід на купу
n=17 capacity=17
n=18 capacity=18
Execution finished with exit code 0.
SSO — це деталь реалізації, а не частина стандарту. Не покладайтеся на конкретні числа у переносимому коді. Однак наявність SSO у сучасних реалізаціях є практично гарантованою — це одна з ключових оптимізацій продуктивності std::string.

Управління ємністю: reserve та shrink_to_fit

.reserve(n) — заздалегідь виділити пам'ять

Якщо ви знаєте, що рядок зростатиме до певного розміру, можна заздалегідь виділити пам'ять, щоб уникнути кількох перевиділень у процесі роботи:

Reserve.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s;
    s.reserve(100); // виділяємо місце для 100 символів одразу

    cout << "після reserve(100):\n";
    cout << "  length:   " << s.length()   << "\n"; // 0
    cout << "  capacity: " << s.capacity() << "\n"; // >= 100

    // Тепер наповнюємо без зайвих перевиділень
    for (int i = 0; i < 80; ++i)
        s += 'a' + i % 26;

    cout << "після наповнення:\n";
    cout << "  length:   " << s.length()   << "\n"; // 80
    cout << "  capacity: " << s.capacity() << "\n"; // >= 100

    return 0;
}
./Reserve
$ ./Reserve
після reserve(100):
length: 0
capacity: 100
після наповнення:
length: 80
capacity: 100
Execution finished with exit code 0.

Порівняємо продуктивність з reserve і без:

ReserveBenchmark.cpp
#include <iostream>
#include <string>
#include <chrono>

using namespace std;

string buildWithout(int n)
{
    string s;
    for (int i = 0; i < n; ++i)
        s += 'x'; // може перевиділяти log(n) разів
    return s;
}

string buildWith(int n)
{
    string s;
    s.reserve(n); // одне виділення
    for (int i = 0; i < n; ++i)
        s += 'x';
    return s;
}

int main()
{
    const int N = 1'000'000;

    auto t1 = chrono::steady_clock::now();
    auto r1 = buildWithout(N);
    auto t2 = chrono::steady_clock::now();
    auto r2 = buildWith(N);
    auto t3 = chrono::steady_clock::now();

    auto ms1 = chrono::duration_cast<chrono::microseconds>(t2 - t1).count();
    auto ms2 = chrono::duration_cast<chrono::microseconds>(t3 - t2).count();

    cout << "Без reserve:  " << ms1 << " мкс\n";
    cout << "З reserve:    " << ms2 << " мкс\n";

    return 0;
}
./ReserveBenchmark
$ ./ReserveBenchmark
Без reserve: 4821 мкс
З reserve: 1203 мкс
Execution finished with exit code 0.
.reserve(n) — не зобов'язання. Якщо n менше поточної ємності, виклик ігнорується (стандарт дозволяє не зменшувати). Ємність гарантовано буде не меншеn, але може бути більшою — бібліотека округляє до зручних значень.

.shrink_to_fit() — повернути надлишкову пам'ять

Після видалення великої кількості символів ємність залишається великою. Метод .shrink_to_fit()запит (не гарантія) до бібліотеки зменшити ємність до довжини:

ShrinkToFit.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s(1000, 'x'); // 1000 символів
    cout << "length: " << s.length()
              << "  capacity: " << s.capacity() << "\n";

    s.resize(10); // скорочуємо до 10 символів
    cout << "після resize(10):\n";
    cout << "  length: " << s.length()
              << "  capacity: " << s.capacity() << "\n"; // capacity лишається великою!

    s.shrink_to_fit(); // просимо звільнити надлишок
    cout << "після shrink_to_fit():\n";
    cout << "  length: " << s.length()
              << "  capacity: " << s.capacity() << "\n";

    return 0;
}
./ShrinkToFit
$ ./ShrinkToFit
length: 1000 capacity: 1000
після resize(10):
length: 10 capacity: 1000
після shrink_to_fit():
length: 10 capacity: 15
Execution finished with exit code 0.
capacity: 15 після shrink_to_fit() — це SSO в дії: рядок із 10 символів вміщається у вбудований буфер, тому бібліотека повертається до SSO-режиму і взагалі звільняє пам'ять купи.

Доступ до символів

Оператор [] — швидкий, без перевірки

Оператор [] повертає посилання на символ за індексом — аналогічно до масиву. Перевірка меж не виконується: некоректний індекс дає невизначену поведінку (UB):

BracketAccess.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "Hello";

    // Читання символу
    char first = s[0]; // 'H'
    char last  = s[4]; // 'o'
    cout << first << last << "\n"; // Ho

    // Запис через посилання
    s[0] = 'J';
    cout << s << "\n"; // Jello

    // Отримати ASCII-код
    cout << static_cast<int>(s[1]) << "\n"; // 101 (код 'e')

    return 0;
}
./BracketAccess
$ ./BracketAccess
Ho
Jello
101
Execution finished with exit code 0.
Звернення через s[s.length()] для неконстантного рядка є невизначеною поведінкою. Для константного рядка стандарт дозволяє звертатися до s[s.length()] і обіцяє повернути '\0', але змінювати цей символ забороняється.

.at(i) — безпечний доступ з перевіркою

Метод .at(i) виконує перевірку меж і кидає виняток std::out_of_range при некоректному індексі. Це повільніше за [], але безпечніше:

AtAccess.cpp
#include <iostream>
#include <string>
#include <stdexcept>

using namespace std;

int main()
{
    string s = "Hello";

    // Нормальний доступ
    cout << s.at(0) << "\n"; // H
    s.at(1) = 'a'; // можна змінювати
    cout << s << "\n"; // Hallo

    // Вихід за межі → виняток
    try
    {
        char c = s.at(100); // індекс 100 у рядку довжиною 5
        (void)c;
    }
    catch (const out_of_range& e)
    {
        cout << "Помилка: " << e.what() << "\n";
    }

    return 0;
}
./AtAccess
$ ./AtAccess
H
Hallo
Помилка: basic_string::at: __n (which is 100) >= this->size() (which is 5)
Execution finished with exit code 0.

.front() і .back() — перший і останній символ

Зручні методи для доступу до крайніх символів без арифметики індексів:

FrontBack.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "Hello";

    cout << s.front() << "\n"; // H — перший символ
    cout << s.back()  << "\n"; // o — останній символ

    // Зміна
    s.front() = 'J';
    s.back()  = '!';
    cout << s << "\n"; // Jell!

    // Типовий патерн: видалити останній символ
    if (s.back() == '!')
        s.pop_back(); // видаляє 'l'... ні — видаляє останній символ '!'
    cout << s << "\n"; // Jell

    return 0;
}
./FrontBack
$ ./FrontBack
H
o
Jell!
Jell
Execution finished with exit code 0.
.front() і .back() на порожньому рядку — невизначена поведінка. Завжди перевіряйте .empty() перед викликом.

Порівняльна таблиця методів доступу

s[i]
char&
Доступ за індексом без перевірки меж. Найшвидший варіант. Некоректний індекс — UB.
s.at(i)
char&
Доступ за індексом з перевіркою. Кидає std::out_of_range. Використовуйте, коли індекс надходить зовні (з вводу, з файлу).
s.front()
char&
Посилання на перший символ. Еквівалент s[0]. UB на порожньому рядку.
s.back()
char&
Посилання на останній символ. Еквівалент s[s.length()-1]. UB на порожньому рядку.
s.data()
char\*
Вказівник на внутрішній буфер (C++17: неконстантний). Без гарантії нуль-термінатора у старих стандартах. Стає невалідним після перевиділення.
s.c_str()
const char\*
Вказівник на буфер із гарантованим '\0' в кінці. Тільки читання. Для передачі у C-API.

Ітерація рядком

Range-based for: найпростіший спосіб

Для перебору всіх символів рядка найзручніший і найчитабельніший варіант — for (char ch : s):

RangeFor.cpp
#include <iostream>
#include <string>
#include <cctype>

using namespace std;

int main()
{
    string s = "Hello, World!";

    // Читання кожного символу
    int uppercaseCount = 0;
    for (char ch : s)
    {
        if (isupper(static_cast<unsigned char>(ch)))
            ++uppercaseCount;
    }
    cout << "Великих літер: " << uppercaseCount << "\n"; // 2

    // Зміна кожного символу (потрібне посилання!)
    for (char& ch : s)
        ch = static_cast<char>(tolower(static_cast<unsigned char>(ch)));
    cout << s << "\n"; // hello, world!

    return 0;
}
./RangeFor
$ ./RangeFor
Великих літер: 2
hello, world!
Execution finished with exit code 0.
Різниця між for (char ch : s) і for (char& ch : s) принципова: без& — копія символу, зміни не впливають на рядок. З& — посилання на реальний символ усередині рядка, зміна діє.

Ітерація за індексом: коли потрібна позиція

Якщо в тілі циклу потрібен індекс символу, використовується класичний for з лічильником:

IndexFor.cpp
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "abcdef";

    // Вивести кожен символ з позицією
    for (size_t i = 0; i < s.length(); ++i)
        cout << "[" << i << "]=" << s[i] << " ";
    cout << "\n";

    // Замінити символи на парних позиціях
    for (size_t i = 0; i < s.length(); i += 2)
        s[i] = static_cast<char>(s[i] - 32); // мала → велика
    cout << s << "\n"; // AbCdEf

    return 0;
}
./IndexFor
$ ./IndexFor
[0]=a [1]=b [2]=c [3]=d [4]=e [5]=f
AbCdEf
Execution finished with exit code 0.
Тип лічильника — size_t (або std::string::size_type), а не int. Причина: .length() повертає беззнаковий тип. Порівняння знакового int i з беззнаковим s.length() при від'ємному i дасть некоректний результат через неявне перетворення.

Ітератори: стиль STL

std::string надає ітератори — як і решта контейнерів STL. Це дозволяє передавати рядок у стандартні алгоритми (std::sort, std::find, std::transform):

Iterators.cpp
#include <iostream>
#include <string>
#include <algorithm> // sort, reverse

using namespace std;

int main()
{
    string s = "hello";

    // reverse через ітератори
    reverse(s.begin(), s.end());
    cout << s << "\n"; // olleh

    // sort
    string t = "dcba";
    sort(t.begin(), t.end());
    cout << t << "\n"; // abcd

    // Пошук символу через find
    auto it = find(s.begin(), s.end(), 'l');
    if (it != s.end())
        cout << "Знайдено на позиції: "
                  << (it - s.begin()) << "\n"; // 2

    return 0;
}
./Iterators
$ ./Iterators
olleh
abcd
Знайдено на позиції: 2
Execution finished with exit code 0.

Практика

Рівень 1 — Інспекція рядка

Напишіть програму, що зчитує рядок і виводить: довжину в байтах, ємність, кожен символ з його індексом та ASCII-кодом у форматі [0] 'H' = 72.

Рівень 2 — Підрахунок символів за категоріями

Напишіть функцію, що підраховує кількість великих літер, малих літер, цифр та інших символів у рядку. Виведіть результат у вигляді таблиці.

Рівень 3 — Функція capitalize

Напишіть функцію capitalize(std::string& s), що робить першу літеру кожного слова великою, а решту — малими. Слова розділені пробілами.


Резюме

length() vs capacity()

length() / size() — кількість байтів зараз. capacity() — скільки виділено без перевиділення. capacity()length() завжди. Перевиділення відбувається при перевищенні ємності — зазвичай подвоєнням.

Small String Optimization

Рядки до ~15–22 символів зберігаються всередині самого об'єкта (на стеку). Без виділення купи. Конкретний поріг — деталь реалізації: GCC/MSVC = 15, Clang = 22.

reserve() та shrink_to_fit()

reserve(n) — заздалегідь виділити місце, уникнути перевиділень у циклі. shrink_to_fit()запит (не гарантія) звільнити надлишкову пам'ять після скорочення рядка.

Доступ: [] vs at()

s[i] — швидко, без перевірки, UB при помилці. s.at(i) — повільніше, кидає std::out_of_range. front() / back() — перший/останній символ. Всі повертають char& — можна змінювати.

Ітерація

for (char ch : s) — читання. for (char& ch : s) — зміна. for (size_t i = 0; ...) — коли потрібен індекс. begin()/end() — для алгоритмів STL: std::sort, std::reverse, std::find.

Обережно з UB

s[i] з некоректним i — UB. front()/back() на порожньому — UB. Збережений char* після += або append — dangling pointer. Використовуйте at() або перевіряйте межі самостійно.

Що далі? Наступна стаття повністю присвячена модифікації рядків: присвоювання (assign), додавання (append, push_back), вставка (insert), видалення (erase), заміна (replace), зміна розміру (resize), а також порівняння рядків та операція substr.

Copyright © 2026