reserve/ resize: навіщо й як керувати розміром вектора

C++ SELF
Рівень 12 , Лекція 2
Відкрита

1. Чому std::vector перевиділяє памʼять

Коли ви тільки починаєте програмувати, масив здається чимось спокійним: ось int arr[10], ось 10 чисел — і нікуди вони не дінуться. Але std::vector живе значно динамічніше. Він уміє зростати, а отже, інколи потребує більше памʼяті, ніж було виділено спочатку. І саме тут починається найцікавіше.

Уявіть, що vector — це автобус із місцями. size() — скільки пасажирів уже всередині. capacity() — скільки місць є загалом. Поки є вільні місця, новий пасажир (push_back) заходить без проблем. Але коли автобус заповнений, диспетчер надсилає новий, більший автобус, пасажири пересідають, а старий відʼїжджає. Це і є перевиділення памʼяті (reallocation).

Мінідемо: спостерігаємо, як capacity() зростає стрибками

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;

    for (int i = 0; i < 10; ++i) {
        v.push_back(i);
        std::cout << "size=" << v.size()
                  << " capacity=" << v.capacity() << '\n';
    }
}

Тут варто побачити на власні очі: capacity() зазвичай збільшується не на 1, а «порціями». Так зроблено, щоб не «переїжджати» під час кожного push_back.

Чому «переїзд» дорогий

Перевиділення памʼяті зазвичай означає таке: виділити новий шматок памʼяті → перенести елементи → звільнити стару памʼять. Якщо елементів уже багато, переносити весь цей обсяг помітно накладно за часом. І саме тут reserve() стає нашим другом.

2. reserve(n): виділяємо памʼять заздалегідь

Якщо ви заздалегідь знаєте, скільки даних буде додано, наприклад у сценарії «введіть N чисел», то reserve() — це спосіб сказати вектору: «Нам знадобиться щонайменше n місць. Підготуємо їх заздалегідь і не переїжджатимемо кожні 5 хвилин».

Головне: reserve() керує памʼяттю, але не кількістю елементів. Тобто size() після reserve() не змінюється.

Приклад: reserve не змінює size

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;

    v.reserve(100);

    std::cout << v.size() << '\n';     // 0
    std::cout << v.capacity() << '\n'; // >= 100
}

Після reserve(100) елементів, як і раніше, немає. Це означає, що звертатися до v[0] усе ще не можна, навіть якщо capacity() велика. Про безпечний доступ поговоримо далі, а зараз варто зафіксувати просту інтуїцію: «місце є» не означає «елемент існує».

Приклад: reserve + push_back — менше «переїздів»

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.reserve(10);

    for (int i = 0; i < 10; ++i) {
        v.push_back(i);
    }

    std::cout << "size=" << v.size() << '\n';       // size=10
    std::cout << "capacity=" << v.capacity() << '\n';
}

Якщо ми заздалегідь знаємо, що додамо 10 елементів, то reserve(10) майже завжди зменшить кількість перевиділень, а іноді й зведе її до нуля.

Коли reserve() особливо корисний

Найчастіше reserve() використовують, коли програма накопичує дані: читає вхідні дані, збирає результати обчислень, зберігає історію дій. Тобто тоді, коли ми багато разів викликаємо push_back.

І так, reserve() — це не «магічна оптимізація заради оптимізації». Це радше підхід: «не змушуй програму робити зайву роботу, якщо вже знаєш план».

3. resize(n): змінюємо кількість елементів

resize(n) звучить схоже на reserve(n), і саме тому новачки так часто плутають ці два виклики. Але сенс у resize інший: resize змінює реальну кількість елементів. Тобто після resize(n) матимемо v.size() == n.

І ось тут зʼявляється важлива практична різниця: після збільшення через resize зʼявляються нові елементи, і вони отримують значення за замовчуванням (для int це 0, для double це 0.0, для std::string — порожній рядок).

Приклад: збільшили resize — отримали нові елементи

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    v.resize(5);

    std::cout << v.size() << '\n'; // 5
    std::cout << v[3] << ' ' << v[4] << '\n'; // 0 0
}

Так, тут ми використовуємо []. Це нормально для демонстрації, тому що індекси ми обираємо коректно, у межах 0..size()-1. Пізніше ми розберемо, які є альтернативи та як це впливає на безпеку.

Приклад: зменшили resize — елементи з кінця зникли

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30, 40};

    v.resize(2);

    std::cout << v.size() << '\n';  // 2
    std::cout << v[0] << ' ' << v[1] << '\n'; // 10 20
}

Під час зменшення resize вектор «обрізається». Це зручно, коли ви спочатку виділили місце із запасом за кількістю елементів, а потім зрозуміли, що реально потрібно менше.

Важливе спостереження: resize() теж може призвести до виділення памʼяті

Якщо ви робите resize(1'000'000), а у вектора capacity() була маленька, йому доведеться виділити памʼять під мільйон елементів. Тобто resize може стосуватися не лише «логічного розміру», а й реальної памʼяті.

Тому інколи використовують таку комбінацію: спочатку reserve(n), потім поступово push_back, а інколи — одразу resize(n) і далі заповнюють за індексами.

4. Як обрати між reserve і resize

Коли два слова відрізняються однією літерою, у програміста вмикається режим «ну це майже одне й те саме». Саме в цей момент багато хто й втрачає кілька годин життя. Тож зафіксуємо різницю максимально прямо.

Таблиця: reserve проти resize

Операція Що змінює Чого НЕ змінює Що фактично зʼявляється
v.reserve(n)
capacity()
стає
>= n
size()
не змінюється
Нових елементів немає
v.resize(n)
size()
стає
n
capacity()
може зрости (якщо не вистачає)
Елементи додаються або видаляються

Якщо вам потрібно запамʼятати одну фразу: reserve — «підготуй місце», resize — «створи елементи».

Практична модель: накопичуємо vs заповнюємо по місцях

Щоб не обирати між reserve і resize на рівні «мені здається…», корисно мислити двома сценаріями.

Перший сценарій — «накопичуємо». Ми не звертаємося до елементів за індексом одразу, а просто додаємо їх один за одним: push_back. Це схоже на те, як ви складаєте чеки в коробку: «ще один чек, ще один». Для такого сценарію reserve — ідеальний варіант.

Другий сценарій — «заповнюємо по місцях». Ми заздалегідь знаємо, що буде n елементів, і хочемо заповнити їх у циклі за індексами. Наприклад, зчитати n чисел і покласти їх на позиції 0..n-1. Тут зручно resize, тому що він створює елементи й робить ці індекси коректними.

Це не «правильний» і не «неправильний» шлях. Це два різні способи працювати, і ви обираєте той, що відповідає вашій задачі.

5. Практичний приклад: мінізастосунок «Облік витрат»

Продовжимо ідею навчального застосунку, який зберігає список чисел. Нехай це будуть денні витрати (у євро).

Зробимо два режими:

  1. користувач заздалегідь знає, скільки витрат буде введено (N чеків) → робимо reserve(N) і читаємо N чисел через push_back;
  2. користувач хоче ввести витрати за 7 днів тижня «по комірках» → робимо resize(7) і заповнюємо v[i].

Режим «введіть N витрат»: використовуємо reserve і push_back

Пояснення просте: якщо користувач одразу називає число N, а ми все одно не робимо reserve(N), то ніби добровільно погоджуємося на можливі зайві «переїзди». Компʼютер, звісно, упорається, але навряд чи подякує.

#include <iostream>
#include <vector>

int main() {
    std::size_t n = 0;
    std::cin >> n;

    std::vector<int> expenses;
    expenses.reserve(n);

    for (std::size_t i = 0; i < n; ++i) {
        int x = 0;
        std::cin >> x;
        expenses.push_back(x);
    }

    std::cout << "count=" << expenses.size() << '\n'; // count=n
}

Тут важливо, що reserve(n) не додає елементів, і ми справді додаємо їх через push_back. У результаті size() стає n.

Режим «7 днів тижня»: використовуємо resize і заповнення за індексами

Тепер інший сценарій: у нас фіксована кількість «слотів» — 7 днів. Нам не потрібно накопичувати невідому кількість значень. Нам потрібно створити 7 елементів і заповнити кожен.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> by_day;
    by_day.resize(7);

    for (std::size_t day = 0; day < by_day.size(); ++day) {
        std::cin >> by_day[day];
    }

    std::cout << by_day[0] << '\n'; // витрата в день 0 (умовно понеділок)
}

Сенс resize тут дуже предметний: ми створюємо 7 елементів, і тепер by_day[day] — це вже наявний елемент.

6. Корисні нюанси

Як reserve зменшує перевиділення

На цьому етапі легко подумати: «Усе, зрозуміло: reserve швидше». Але давайте будемо трохи обережнішими.

Коли ви багато разів викликаєте push_back без reserve, вектор зростає так: він виділяє памʼять, заповнюється, потім «розуміє», що місця вже немає, і розширюється. Через це частина викликів push_back відбувається звичайно, а частина — уже «з переїздом».

reserve(n) каже: «Одразу підготуй буфер щонайменше під n елементів». Тоді перші n додавань зазвичай відбудуться без розширень. Це особливо помітно на великих обсягах даних.

Головне правило таке: reserve має сенс, коли ви можете більш-менш оцінити розмір. Не обовʼязково точно. Навіть приблизна оцінка часто вже корисна.

Зменшення: що робить resize, і чого не робить reserve

Іноді виникає потреба: «я зібрав багато даних, а потім зрозумів, що частина зайва». Тут логіка така: якщо ви хочете видалити елементи з кінця, resize(smaller) — ваш базовий інструмент. Він зменшить size(), тобто кількість елементів.

Але важливо розуміти: зменшення size() не обовʼязково означає зменшення capacity(). Тобто у вектора може залишитися великий запас уже виділеної памʼяті. Це нормально: стандартна бібліотека не зобовʼязана тут «стискатися», тому що це може бути дорого, а іноді навіть шкідливо, якщо ви потім знову почнете додавати елементи.

До речі, у стандарті є окрема тема про те, де саме у специфікації мають описуватися речі на кшталт reserve і shrink_to_fit (що до чого належить: до «capacity» чи до «modifiers»). Це один із тих моментів, коли добре видно: навіть у стандарті часом доводиться «переставляти полиці» у шафі.

У межах цієї лекції ми не заглиблюємося в «стискання памʼяті» — це окрема тема й окремі нюанси. Нам достатньо простої дисципліни: resize керує кількістю елементів, reserve керує запасом.

7. Мінісхема вибору

Щоб закріпити ідею, корисно тримати в голові просту «блок-схему» рішення.

flowchart TD
    A[Потрібно зберігати дані у vector] --> B{Заздалегідь знаю кількість елементів?}
    B -- Ні --> C[Накопичую через push_back]
    C --> D["Якщо можу оцінити розмір: reserve(приблизний_n)"]
    B -- Так --> E{Хочу додавати поступово?}
    E -- Так --> F["reserve(n) + push_back"]
    E -- Ні, хочу заповнювати за індексами --> G["resize(n) + заповнення v[i]"]

Ця схема не «єдино правильна», але вона допомагає перестати плутати reserve і resize та почати обирати їх усвідомлено.

8. Типові помилки під час роботи з reserve() і resize()

Помилка № 1: вважати, що reserve(n) створює n елементів.
Це найпоширеніша плутанина. Після reserve(n) вектор усе ще може бути порожнім (size() == 0), а спроба звернутися до v[0] — це звернення до неіснуючого елемента. Ментальна модель має бути простою: reserve — «виділив місце в памʼяті», але «пасажири ще не зайшли».

Помилка № 2: використовувати capacity() як справжній розмір даних.
Іноді новачки пишуть цикли до capacity(), бо «раз місце вже є». Але «місце є» не дорівнює «елементи існують». У логіці програми майже завжди варто орієнтуватися на size(). capacity() — це радше технічний параметр, корисний для оптимізації, а не для змісту даних.

Помилка № 3: викликати reserve() у циклі на кожному кроці.
Виглядає це приблизно так: на кожній ітерації ви робите reserve(v.size() + 1), сподіваючись: «нехай завжди вистачає». На практиці це може лише погіршити ситуацію: ви самі провокуєте часті перевиділення, бо щоразу просите новий розмір. reserve зазвичай викликають один раз перед великим набором даних, а не на кожному кроці.

Помилка № 4: плутати сценарії «накопичуємо» і «заповнюємо за індексами».
Якщо ви хочете додавати значення по одному — використовуйте push_back, а reserve нехай лише зменшує кількість «переїздів». Якщо ви хочете заповнювати вектор за індексами — спочатку зробіть resize, щоб елементи зʼявилися, а потім уже заповнюйте. І навпаки: resize заради того, щоб потім усе одно викликати push_back, часто призводить до зайвих нулів наприкінці й до плутанини: «а чому елементи подвоїлися?».

Помилка № 5: очікувати, що зменшення resize() обовʼязково зменшить capacity().
Коли ви зменшили size(), елементи зникли з погляду логіки контейнера. Але памʼять може залишитися виділеною. Це не витік і не баг — просто у вектора є право «залишити більший автобус», раптом за хвилину ви знову повезете натовп пасажирів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ