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 важливі два числа.
Ось невелика таблиця, яку корисно тримати в голові:
| Термін | Як отримати | Людський сенс |
|---|---|---|
|
|
скільки елементів реально існує |
|
|
скільки елементів може вміститися без розширення памʼяті |
Про «памʼять» сьогодні говоритимемо мʼяко, без низькорівневих деталей. Але сам факт існування 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, як ми й робили в циклах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ