JavaRush /Курси /C++ SELF /std::vector:

std::vector: push_back, size, capacity

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

1. Масиви не завжди зручні

Якщо до цього ви працювали зі std::array<int, 10>, то вам знайома приємна стабільність: у масиву завжди 10 елементів, нічого не «переїжджає», індекси зрозумілі. Але на практиці часто трапляються задачі, де кількість даних наперед невідома. Наприклад, користувач вводить оцінки, доки не введе 0; ми зчитуємо числа, доки не завершиться введення; або складаємо список покупок, доки користувач не скаже «досить».

Із фіксованим масивом тут виникають дві типові незручності. Перша — потрібно заздалегідь вибрати максимальний розмір («ну хай буде 1000… раптом вистачить?»), а це або ризик, що місця не вистачить, або зайва памʼять «про всяк випадок». Друга — навіть якщо ви взяли великий масив, вам однаково доведеться окремо зберігати, скільки елементів уже справді записано. І саме це «справді записано» ми сьогодні зробимо вбудованою властивістю контейнера.

2. Знайомимося з std::vector<T>

std::vector<T> — це контейнер стандартної бібліотеки, який зберігає елементи типу T у памʼяті поспіль, як масив, але вміє змінювати їхню кількість під час роботи програми. Тобто це «масив, який зростає й зменшується», але без магії: усе чесно й під контролем стандартної бібліотеки.

Підключають його так само буденно, як iostream:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    std::cout << "ok\n"; // ok
}

Для нашої сьогоднішньої розмови у vector важливі два числа.

Ось невелика таблиця, яку корисно тримати в голові:

Термін Як отримати Людський сенс
size()
v.size()
скільки елементів реально існує
capacity()
v.capacity()
скільки елементів може вміститися без розширення памʼяті

Про «памʼять» сьогодні говоритимемо мʼяко, без низькорівневих деталей. Але сам факт існування capacity() і навіть розмови про його «складність» — частина стандартного інтерфейсу контейнерів.

3. Створення вектора

Коли ви вперше створюєте std::vector, важливо звикнути розрізняти дві думки: «змінна існує» і «усередині є елементи». Вектор може існувати й бути порожнім — це нормальний стан, а не помилка.

Порожній вектор

Порожній вектор — це коли size() == 0.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers;
    std::cout << numbers.size() << '\n'; // 0
}

Найпоширеніша помилка новачків тут — намагатися звернутися до numbers[0] «бо ж вектор є». Вектор є, а елемента з індексом 0 — немає. Це як зазирнути в порожній холодильник і запитати: «А де мій торт під номером 0?»

Ініціалізація списком {...}

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

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};
    std::cout << v.size() << '\n'; // 3
    std::cout << v[0] << '\n';     // 10
}

4. Додавання й доступ: push_back і size()

Найпростіший спосіб «збільшувати» вектор — метод push_back(value). Він додає один елемент у кінець, і після цього size() збільшується на 1. Ця дія настільки поширена, що в C++ її можна сприймати як еквівалент фрази «додай у список ще один елемент».

Мініприклад на 7 рядків: додамо два числа й перевіримо розмір.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.push_back(5);
    v.push_back(8);
    std::cout << v.size() << '\n'; // 2
}

Зверніть увагу на важливу деталь: push_back саме додає в кінець. Тобто порядок елементів зберігається: що додали раніше, те й буде лівіше, тобто матиме менший індекс.

size(): «скільки елементів є» і які індекси допустимі

Коли вектор зростає, найпрактичніша звичка — будувати всю логіку навколо size(). Не навколо «мені здається, там 10 елементів», не навколо «я ж додавав уже кілька разів», а навколо реального числа, яке контейнер зберігає сам.

Якщо size() == n, то коректні індекси — від 0 до n - 1. Це рівно те саме правило, що й для масивів, просто тепер n змінюється під час роботи програми.

Перевіримо це на простому прикладі:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.push_back(100);
    v.push_back(200);

    std::cout << "size=" << v.size() << '\n'; // size=2
    std::cout << v[0] << ' ' << v[1] << '\n'; // 100 200
}

А тепер ключовий момент дисципліни: якщо ви пишете цикл, то умова майже завжди має такий вигляд:

for (std::size_t i = 0; i < v.size(); ++i) {
    // ...
}

Не <=, не «до 10», не «до останнього». Лише строго i < v.size(). Це як пасок безпеки: здається нудним, зате іноді рятує від дуже неприємних помилок.

5. capacity(): чому у вектора «є місце», але ще «немає елементів»

Слово capacity звучить так, ніби йдеться про місткість: скільки влізе. У випадку std::vector це буквально так: capacity() показує, скільки елементів вектор може зберігати прямо зараз, не розширюючи внутрішню памʼять.

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

Важливо: capacity() завжди >= size(). Це один із базових інваріантів, тобто тверджень, які мають залишатися правдивими.

Подивімося, як це виглядає на практиці, на діагностичному прикладі:

#include <iostream>
#include <vector>

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

    v.push_back(1);
    std::cout << v.size() << ' ' << v.capacity() << '\n'; // наприклад: 1 1 (або 1 2)

    v.push_back(2);
    std::cout << v.size() << ' ' << v.capacity() << '\n'; // наприклад: 2 2 (або 2 4)

    v.push_back(3);
    std::cout << v.size() << ' ' << v.capacity() << '\n'; // наприклад: 3 4
}

Значення capacity() можуть відрізнятися на різних компіляторах і платформах — і це нормально. Тут важливо не «вгадувати формулу зростання», а розуміти сенс: size — це правда про елементи, а capacity — це правда про запас місця.

6. Інваріанти std::vector: що завжди має бути правдою

Слово «інваріант» звучить так, ніби це імʼя давнього дракона з фентезі, але на практиці все простіше: це список обіцянок контейнера самому собі. У формальну математику ми зараз не занурюємося. Нам достатньо розуміти кілька правил, які допомагають не писати «код-сюрприз».

Перший інваріант — 0 <= size() <= capacity(). Тобто елементів не може бути відʼємна кількість, і вектор не може містити більше елементів, ніж для нього зараз виділено місця.

Другий інваріант — елементи зберігаються поспіль, як у масиві. Це одна з причин, чому vector такий популярний: він швидко працює з індексами й зазвичай добре дружить із кешем процесора. Іноді це формулюють так: vector — це contiguous-контейнер, тобто контейнер із неперервним розміщенням елементів у памʼяті. Сьогодні ітератори ми ще не використовуємо, але цю позначку корисно тримати в голові: vector — це «майже масив, але розумніший».

Третій інваріант — порядок елементів зберігається. Якщо ви додаєте 10, потім 20, потім 30, то індекси відповідатимуть саме цьому порядку: v[0] == 10, v[1] == 20, v[2] == 30. Саме це робить vector хорошим контейнером «списку» в побутовому сенсі.

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

Невелика схема цієї моделі, без низькорівневих деталей, просто «як про це думати»:

flowchart LR
    A["вектор: size=3"] -->|"push_back"| B{"є місце? capacity > size"}
    B -->|так| C["додали елемент: size=4, capacity не змінюється"]
    B -->|ні| D["треба розширитися: capacity збільшиться, потім size=4"]

7. Мінізастосунок: «Скарбничка витрат» на std::vector<int>

Щоб не зводити std::vector лише до «теорії про контейнери», почнемо робити маленький застосунок, який розвиватиметься впродовж наступних лекцій. Сьогодні зробимо найпростішу частину: збиратимемо числа у вектор через push_back, а потім виведемо статистику й поспостерігаємо за size/capacity.

Сюжет простий: користувач вводить витрати, тобто цілі числа, а 0 означає «стоп». Ми складаємо все у std::vector<int>, бо кількість витрат наперед невідома.

Заготовка: привітання й порожній список

#include <iostream>
#include <vector>

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

    std::cout << "Введіть витрати (0 — стоп)\n"; // Введіть витрати (0 — стоп)
    std::cout << "size=" << expenses.size() << '\n'; // size=0
}

Тут корисно просто побачити, що список існує, але він порожній.

Читання чисел і push_back

Додамо цикл. Зауважте: ми поки не додаємо захист від некоректного введення. Це окрема важлива тема, але сьогодні наша мета — саме контейнер.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> expenses;
    int x = 0;

    std::cout << "Введіть витрати (0 — стоп)\n"; // Введіть витрати (0 — стоп)

    std::cin >> x;
    while (x != 0) {
        expenses.push_back(x);
        std::cin >> x;
    }

    std::cout << "кількість=" << expenses.size() << '\n'; // наприклад: кількість=3
}

Якщо ви ввели 10 25 7 0, то size() стане 3. Тобто size() — це «скільки значень справді було введено».

Порахуємо суму звичайним циклом

Поки що в нас немає алгоритмів STL — вони зʼявляться пізніше в курсі, — тому підсумовуємо простим for. І тут знову перемагає size(): цикл не залежить від того, скільки елементів ви ввели.

#include <cstddef>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> expenses{10, 25, 7};

    int sum = 0;
    for (std::size_t i = 0; i < expenses.size(); ++i) {
        sum += expenses[i];
    }

    std::cout << "сума=" << sum << '\n'; // сума=42
}

Додамо «діагностику памʼяті»: друкуємо capacity()

Для користувача це не обовʼязково, але для вас така діагностика дуже корисна: вона допомагає відчути, як саме зростає вектор.

#include <iostream>
#include <vector>

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

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

Запустіть програму й просто подивіться: size зростає на 1 щоразу, а cap — стрибками. І це нормально, бо capacity() відповідає саме за запас.

Зберемо поточну версію застосунку повністю

Це фінальна версія для цієї лекції: тут є лише те, що ми вже встигли вивчити, — push_back, size, capacity, індекси, цикл while і один цикл for.

#include <cstddef>
#include <iostream>
#include <vector>

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

    std::cout << "Введіть витрати (0 — стоп)\n"; // Введіть витрати (0 — стоп)

    int x = 0;
    std::cin >> x;

    while (x != 0) {
        expenses.push_back(x);

        // Діагностика для нас (не для користувача):
        std::cout << "[debug] size=" << expenses.size()
                  << " cap=" << expenses.capacity() << '\n';

        std::cin >> x;
    }

    int sum = 0;
    for (std::size_t i = 0; i < expenses.size(); ++i) {
        sum += expenses[i];
    }

    std::cout << "кількість=" << expenses.size() << '\n'; // наприклад: кількість=3
    std::cout << "сума=" << sum << '\n';                   // наприклад: сума=42
}

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

8. Типові помилки під час роботи з std::vector на старті

Помилка №1: плутати «вектор існує» і «вектор містить елементи».
Після std::vector<int> v; вектор створено, але v.size() дорівнює нулю. Якщо одразу спробувати прочитати v[0], ви звернетеся до елемента, якого немає. Це виправляється простою звичкою: спочатку думати в термінах size() і перевіряти, що елементів справді достатньо.

Помилка №2: думати, що «останній індекс — це size()».
У вектора розміру n останній індекс — n - 1, і то лише якщо n > 0. Особливо підступною ця помилка стає в циклах: i <= v.size() майже завжди означає вихід за межі на останньому кроці. Надійний шаблон — i < v.size().

Помилка №3: використовувати capacity() як «кількість елементів».
capacity() — це запас памʼяті, а не реальна кількість елементів. Друкувати capacity() можна для діагностики й кращого розуміння, але всю логіку — цикли, індексацію, перевірки — ми будуємо на size().

Помилка №4: очікувати, що capacity() зростає «за формулою», і завʼязувати на цьому логіку коду.
Іноді дуже хочеться написати щось на кшталт «якщо capacity подвоїлася — значить…». Не варто. Зростання capacity() — це деталь реалізації, і вона може відрізнятися. Єдине, що тут важливо розуміти: capacity() може змінюватися під час додавання елементів, а сам факт існування capacity() і обговорення його властивостей — частина інтерфейсу контейнера.

Помилка №5: зберігати індекси в int і бездумно порівнювати їх із v.size().
v.size() повертає беззнаковий тип розміру. Якщо змішувати int і size_t у порівняннях, інколи можна отримати дуже дивні ефекти, особливо якщо зʼявляються відʼємні значення. Тому як звичку для індексів краще використовувати std::size_t, як ми й робили в циклах.

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