1. Введение
Когда вы впервые видите std::vector, кажется, что всё просто: v[0], v[1], поехали. Но в реальной программе индекс почти никогда не «волшебно правильный». Он приходит из ввода, вычисляется формулой, зависит от условий, или просто оказывается неправильным из‑за ошибки “off-by-one”. Поэтому доступ к элементам — это не синтаксис, а дисциплина: как сделать так, чтобы программа не падала и не портила данные.
Представьте, что вектор — это ряд ячеек на складе. v.size() говорит, сколько ячеек реально занято коробками. Если вы пытаетесь взять коробку из ячейки, которой не существует, складской робот не будет вежливо объяснять, что вы ошиблись. Он просто поедет в стену. В программировании это выглядит ещё драматичнее.
2. Индексы и границы: где можно, а где нельзя
Прежде чем спорить, что лучше — [] или at(), нужно один раз очень чётко зафиксировать границы.
Если size() == n, то корректные индексы:
- от 0
- до n - 1
- и только если n > 0
Схематично это удобно держать в голове так:
v.size() == 5
индексы: 0 1 2 3 4
элементы: [ ] [ ] [ ] [ ] [ ]
v[5] — нельзя (это "после последнего")
v[-1] — нельзя (и в C++ это ещё и отдельная ловушка)
Давайте сделаем микро-пример, который печатает размер и напоминает, какой индекс у последнего элемента (если он есть):
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
std::cout << "size = " << v.size() << '\n'; // size = 3
std::cout << "last index = " << (v.size() - 1) << '\n'; // last index = 2
}
Этот код корректный только потому, что v не пустой. Если бы v был пуст, выражение v.size() - 1 стало бы плохой идеей. Запомним простое правило: не вычитать 1 из размера, пока не убедился, что размер > 0.
Аккуратная печать “последнего индекса”
Очень частая мысль: “хочу напечатать последний индекс — это size() - 1”. Да, но только если size() > 0. И вот тут empty() снова оказывается самым читабельным способом не вляпаться.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
if (!v.empty()) {
std::cout << "last index = " << (v.size() - 1) << '\n';
} else {
std::cout << "no last index: vector is empty\n"; // no last index: vector is empty
}
}
Почему это важно? Потому что пустые контейнеры — это не «редкость», а нормальное состояние программы. Пользователь ещё ничего не добавил, файл был пуст, ввод не пришёл — и вот уже size() == 0. Программа должна уметь жить в этом мире, а не только в мире «всё заполнено и идеально».
3. Доступ по индексу: operator[] и at()
operator[]: быстрый доступ без страховки
Сейчас будет важная мысль, которую легко недооценить.
v[i] (то есть operator[]) не проверяет границы. Он максимально быстрый и «доверчивый»: вы сказали “дай элемент i” — он пытается дать. Если i неверный, поведение программы становится некорректным: она может упасть, может начать печатать мусор, может «тихо» испортить данные. И это хуже всего: иногда ошибка проявляется не сразу, а через 20 строк кода, когда вы уже забыли, где именно ошиблись.
Мини-пример нормального доступа через []:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 6, 7};
std::cout << v[0] << '\n'; // 5
std::cout << v[2] << '\n'; // 7
}
А вот пример, который компилируется, но логически опасен (не запускайте как «норму жизни»):
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 6, 7};
std::cout << v[3] << '\n'; // индекс неверный: элемента с индексом 3 нет
}
Почему тогда [] вообще существует, если так опасно? Потому что:
- иногда индекс гарантированно корректный (например, вы перебираете i от 0 до v.size() строго правильно),
- иногда вам важна скорость (в серьёзных задачах это бывает),
- а иногда вы сознательно хотите «короткий» код и сами отвечаете за проверки.
Полезная модель:
[] — “я уверен, что индекс правильный”.
at(i): доступ с проверкой границ и явным сигналом об ошибке
Метод v.at(i) делает почти то же самое, что и v[i], но с одной принципиальной разницей: он проверяет границы. Если индекс неверный, стандартная библиотека явно сигнализирует об ошибке выполнения. Детали механизма (как именно это устроено) мы пока не разбираем; нам важно другое: ошибка не будет «тихой».
Пример, где at() работает как обычный доступ:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{100, 200, 300};
std::cout << v.at(1) << '\n'; // 200
}
А вот пример, где индекс неверный, и вы получите явную ошибку выполнения:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{100, 200, 300};
std::cout << v.at(3) << '\n'; // неверный индекс: программа аварийно завершится
}
Это не значит, что at() «спасает» от необходимости думать. Это значит, что если вы ошиблись, проблема всплывёт сразу и будет проще найти причину.
Полезная модель: at() — “я почти уверен, но хочу, чтобы при ошибке всё было очевидно”.
Главный безопасный паттерн: сначала проверка, потом доступ
Самый жизненный сценарий: индекс приходит «снаружи». Например, пользователь вводит номер элемента, который хочет посмотреть. В этом случае ни [], ни at() не должны вызываться «в лоб», пока вы не проверили границы.
Базовая проверка звучит так:
если i < v.size(), то индекс корректный
иначе — некорректный
Сделаем маленький фрагмент для учебного консольного приложения. Пусть у нас есть список цен в корзине (пока без всяких классов и функций), и пользователь хочет посмотреть цену по номеру:
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> prices{120, 80, 250};
std::size_t i = 0;
std::cin >> i;
if (i < prices.size()) {
std::cout << prices.at(i) << '\n'; // например, ввод 1 -> 80
} else {
std::cout << "Нет такого индекса\n";
}
}
Обратите внимание на стиль: мы сделали проверку i < prices.size(), и только потом берём prices.at(i). Казалось бы, зачем at(), если мы уже проверили? На практике это неплохая привычка: проверка — это «логика программы», а at() — дополнительный «контрольный ремень безопасности». Если вы где-то ошиблись в проверке или индекс поменялся, вы получите явный сигнал.
Если вы уверены, что проверка корректна (например, в цикле), можно использовать и []. Но на старте лучше держаться консервативного варианта: меньше загадочных падений.
Мини-таблица выбора: [] или at()
| Способ доступа | Проверка границ | Что будет при неверном индексе | Когда использовать |
|---|---|---|---|
|
нет | некорректное поведение (может быть всё что угодно) | когда индекс точно корректный (например, правильный цикл) |
|
да | явный сигнал об ошибке выполнения | когда индекс пришёл извне или вы отлаживаете код |
4. empty(), front() и back(): доступ к первому и последнему
empty(): как красиво спросить “вектор пустой?”
Проверка «вектор пуст?» встречается постоянно, особенно перед front()/back().
Технически v.empty() почти то же, что v.size() == 0. Но empty() читается как человеческий язык: “пустой?”. Это делает код понятнее, особенно в условиях.
Пример:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
if (v.empty()) {
std::cout << "Vector is empty\n"; // Vector is empty
}
}
И более практично: пусть у нас в приложении «корзина» может быть пустой, и мы хотим это корректно обработать:
#include <iostream>
#include <vector>
int main() {
std::vector<int> prices; // пустая корзина
if (!prices.empty()) {
std::cout << "First price = " << prices[0] << '\n';
} else {
std::cout << "Корзина пуста\n"; // Корзина пуста
}
}
Тут ключевой момент: prices[0] нельзя делать, пока вы не убедились, что вектор не пустой.
front() и back(): первый и последний элемент
Иногда вам не нужен индекс. Вам нужно «самый первый» или «самый последний» элемент. Например, в корзине первый товар или последний добавленный товар.
Для этого есть:
- v.front() — первый элемент
- v.back() — последний элемент
Звучит очень удобно, но есть строгий контракт: вектор должен быть непустым. Если вектор пуст, front() и back() использовать нельзя.
Пример с непустым вектором:
#include <iostream>
#include <vector>
int main() {
std::vector<int> prices{120, 80, 250};
std::cout << prices.front() << '\n'; // 120
std::cout << prices.back() << '\n'; // 250
}
Теперь аккуратный вариант, который не падает на пустом:
#include <iostream>
#include <vector>
int main() {
std::vector<int> prices;
if (!prices.empty()) {
std::cout << prices.front() << ' ' << prices.back() << '\n';
} else {
std::cout << "Нет цен: список пуст\n"; // Нет цен: список пуст
}
}
Это очень частый шаблон: if (!v.empty()) { … front()/back() … }.
front/back vs индексы: что читается лучше
Иногда новички пишут v[0] и v[v.size() - 1] везде, где нужен первый и последний. Это работает (если вектор непустой), но читается тяжелее и требует больше аккуратности.
Сравните:
// вариант А
int first = v.front();
int last = v.back();
и
// вариант Б
int first = v[0];
int last = v[v.size() - 1];
Вариант А короче и яснее. Вариант Б тоже нормальный, но в нём больше “острых углов”: нужно помнить про size() - 1, нужно помнить, что нельзя при size() == 0, и глазами сложнее проверить, что всё безопасно.
Поэтому практическое правило для сегодняшнего уровня: если вам нужен именно первый/последний — используйте front()/back() вместе с empty(). Индексы оставьте для случаев, когда вам нужен конкретный номер элемента.
5. Практический пример: “мини‑корзина” с просмотром элементов
Соберём небольшой, но связный фрагмент: у нас есть вектор цен, и мы хотим уметь:
- печатать первую и последнюю цену (если они есть),
- печатать цену по индексу, введённому пользователем,
- не падать на пустом векторе и неверном индексе.
Мы пока не делаем меню и не пишем функции (это будет позже по курсу). Просто прямолинейный main, чтобы было видно логику:
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> prices{120, 80, 250};
if (!prices.empty()) {
std::cout << "first=" << prices.front() << '\n'; // first=120
std::cout << "last=" << prices.back() << '\n'; // last=250
}
std::size_t i = 0;
std::cin >> i;
if (i < prices.size()) {
std::cout << "prices[" << i << "]=" << prices.at(i) << '\n';
// ввод 1 -> prices[1]=80
} else {
std::cout << "Index out of range\n";
}
}
В этом коде видно, как три инструмента (empty(), front()/back(), at()) играют в одной команде.
6. Типичные ошибки при доступе к элементам std::vector
Ошибка №1: обращаться к v[0], когда вектор пустой.
Это классика. Код компилируется, выглядит «логично», но в реальности вы берёте элемент, которого нет. Часто так происходит, когда вы забыли, что reserve() не создаёт элементы, или когда вы только что создали std::vector<int> v; и сразу полезли в v[0]. Лечится простым правилом: перед первым/последним элементом или перед фиксированным индексом всегда задайте себе вопрос “а точно ли size() > 0?” и не стесняйтесь писать if (!v.empty()).
Ошибка №2: путать “есть память” и “есть элементы”.
Если вы сделали v.reserve(100), у вас могла вырасти capacity(), но size() остался прежним. Это значит: элементов всё ещё нет, и доступ по индексу всё ещё незаконен. Новички иногда думают: “ну я же зарезервировал, значит вектор как бы на 100 элементов”. Нет, он как бы на 0 элементов, просто с большим складом. Для появления элементов нужен push_back() или resize().
Ошибка №3: считать, что at() “как-то безопасно вернёт что-нибудь”, даже если индекс неверный.
at() не возвращает “пустое значение” и не «исправляет» индекс. Он нужен, чтобы ошибка была явной. Поэтому правильный стиль: сначала проверка i < v.size(), потом at(i) (или [], если вы уверены). Если вы рассчитываете, что at() сам разрулит неверный индекс — программа будет аварийно завершаться в неожиданный момент.
Ошибка №4: использовать v.size() - 1, не проверив, что size() > 0.
Это особенно коварно, потому что код выглядит математически правильным. Но при пустом векторе “последнего индекса” не существует. Самый простой способ не попадать в эту ловушку — проверять empty() перед вычислением size() - 1 и перед любыми действиями, которые подразумевают существование элементов.
Ошибка №5: неверная проверка границ “включая последний”.
Иногда пишут if (i <= v.size()), думая “ну size() же последний”. Нет: size() — это количество элементов, а последний индекс — size() - 1 (если элементы есть). Поэтому корректная проверка — строго i < v.size(). Если вы ловите себя на желании написать <=, остановитесь и перепроверьте модель индексов: end‑позиция существует, а элемента там нет.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ