1. Довжина та індекс
Коли ви пишете програму, що працює з рядками, майже неминуче зʼявляються два схожі, але різні за змістом числа: довжина та індекс. Спочатку здається, що обидва можна зберігати в int, бо «це ж просто числа». Але C++ — це мова, у якій тип виражає контракт: що можливе, а що — ні.
І тут раптом зʼясовується: довжина не може бути відʼємною, а індекс цілком може виявитися відʼємним, наприклад через введення користувача. Тож ці властивості природно відобразити в типах.
Уявіть рядок "abcd". Його довжина — 4. Ви не можете чесно сказати: «довжина рядка дорівнює -4». Навіть якщо дуже захочете. А от індекс користувач цілком може ввести як -1, бо користувач — істота творча.
У C++ стандартна бібліотека намагається бути чесною: якщо значення за змістом не буває відʼємним, вона надає перевагу беззнаковому типу. Саме тому у std::string метод size() повертає беззнаковий тип std::size_t.
2. std::size_t: що це і навіщо він потрібен
Що таке std::size_t
Якщо дуже спростити, std::size_t — це «рідний» цілочисельний тип для розмірів: довжини рядка, кількості елементів, розміру блоку памʼяті. Він визначений у стандартній бібліотеці, зазвичай через заголовок <cstddef>, і його основна роль — уміти представляти, скільки чогось уміщується або скільки чогось існує в межах можливостей вашої платформи.
Тут важливо не завчити формулювання зі стандарту, а вловити суть:
- int — це «ціле число для обчислень, яке може бути відʼємним».
- std::size_t — це «ціле число для розмірів та індексів контейнерів; воно не повинно бути відʼємним».
У програмах початківця std::size_t найчастіше трапляється у трьох місцях:
- результат s.size() у std::string
- порівняння індексу з s.size()
- цикли for від 0 до s.size()
І саме на цих місцях ми сьогодні й зосередимося: не як на «страшному типі з <cstddef>», а як на практичному способі писати код без пасток.
Чому size() повертає size_t
Зараз буде важливий момент: бібліотека робить це не для того, щоб вам ускладнити життя. Вона робить це для того, щоб код був чеснішим, а багато помилок — помітнішими.
Довжина не буває відʼємною
Це найпростіша причина. Якби size() повертав int, то за типом було б припустимим значення -10. Так, за змістом його не буде, але за типом — буде. З std::size_t бібліотека каже: «розмір — це кількість; кількість не буває меншою за нуль».
Розмір має вміти бути достатньо великим
Друга причина практичніша. Тип int має обмежений діапазон. На деяких платформах int може бути помітно меншим, ніж максимально можливий розмір обʼєкта або контейнера. Тому стандарт використовує окремий тип для розмірів, узгоджений із тим, як на платформі влаштовані памʼять і адресація.
Узгодженість з утилітами «про розміри»
Третя причина — узгодженість. Довкола розмірів існують стандартні «межі» та утиліти, і все це працює у світі size_t. Наприклад, максимально можливе значення size_t логічно отримувати як std::numeric_limits<std::size_t>::max().
Щоб краще закріпити різницю, зручно мати невелику таблицю:
| Запитання | |
|
|---|---|---|
| Може бути відʼємним? | так | ні |
| Добре підходить для «температури, балансу, зсуву» | так | іноді так, але часто незручно |
| Добре підходить для «довжини, кількості, розміру» | «можна, але є пастки» | так, це його роль |
Часто повертається з |
ні | так |
Коли зберігати в size_t, а коли в int
Після цієї теми дуже хочеться вивести «правило на всі випадки життя»: «усе зберігаю в size_t». Але це вже інша крайність. Нам потрібна не релігія типів, а здоровий глузд.
Якщо значення за змістом є розміром — довжиною, кількістю або індексом у циклі від 0 до size() — то std::size_t — природний вибір. Якщо ж значення за змістом може бути відʼємним — дельта, зсув, «на скільки зсунути», введення користувача, який може помилитися, — то int або інший знаковий тип часто зручніші.
Хороша практика для початківця виглядає так:
- довжину рядка беремо як std::size_t n = s.size();
- у циклі «від 0 до довжини» використовуємо std::size_t i
- індекс від користувача читаємо в int idx, а потім перевіряємо його за двокроковим шаблоном
Приклад звичайного циклу за рядком:
#include <cstddef>
#include <iostream>
#include <string>
int main() {
std::string s = "Hi!";
for (std::size_t i = 0; i < s.size(); ++i) {
std::cout << i << ": " << s[i] << '\n';
// 0: H
// 1: i
// 2: !
}
}
А ось приклад, де auto може бути корисним, але вимагає уважності:
#include <iostream>
#include <string>
int main() {
std::string s = "abcd";
auto n = s.size(); // n має тип std::size_t, навіть якщо ви про це забули
std::cout << n << '\n'; // 4
}
auto тут не зло. Просто памʼятайте: якщо ви зробили auto n = s.size();, то n — беззнаковий тип. Отже, перевірки на кшталт n >= 0 беззмістовні, бо вони завжди істинні, а зворотні цикли «до -1» з таким типом легко перетворюються на пастку. Мораль проста: auto не скасовує мислення.
3. Головна пастка: порівняння int і size_t
Зараз буде саме та ситуація, через яку про std::size_t взагалі доводиться читати лекцію. Код «виглядає правильно», а поводиться як маленький зрадник.
Нехай у нас є рядок та індекс:
- рядок: "abcd"
- індекс: -1 (наприклад, його ввів користувач)
Початківець часто пише перевірку так:
#include <iostream>
#include <string>
int main() {
std::string s = "abcd";
int idx = -1;
if (idx < s.size()) {
std::cout << "Індекс у межах\n";
} else {
std::cout << "Індекс поза межами\n";
}
}
Людською мовою це читається так: «якщо індекс менший за довжину, то він усередині». Але проблема в тому, що s.size() має тип std::size_t — беззнаковий, і компілятор мусить порівняти значення одного типу. Тому він перетворює idx на беззнаковий тип.
І ось тут -1 перетворюється на «дуже велике додатне число». Вам не потрібно памʼятати точне значення. Достатньо розуміти суть: відʼємне число в беззнаковому типі перетворюється на велике додатне.
Підсумок: умова idx < s.size() при idx = -1 може виявитися хибною або просто поводитися не так, як ви очікували, бо порівнюються вже не -1 і 4, а «величезне число» і 4.
Важливо зафіксувати: проблема не в if, не в рядку і не в тому, що «компілятор тупий». Проблема в тому, що ви порівнюєте значення з різним знаком, а C++ зобовʼязаний звести їх до спільного типу.
4. Безпечна перевірка індексу, якщо індекс у int
Це найпрактичніша частина лекції. Її варто не просто зрозуміти, а й довести до автоматизму, бо вона справді рятує від безглуздих багів.
Ситуація така: індекс надійшов від користувача або був обчислений у логіці, де можливе відʼємне значення. Тому індекс ви зберігаєте як int. Але довжина рядка — це size_t. Тоді перевірка має складатися з двох кроків:
- спочатку переконатися, що індекс не відʼємний
- потім порівняти його з size() уже у світі size_t
#include <cstddef>
#include <iostream>
#include <string>
int main() {
std::string s = "abcd";
int idx = -1;
if (idx >= 0 && static_cast<std::size_t>(idx) < s.size()) {
std::cout << "Гаразд: " << s[static_cast<std::size_t>(idx)] << '\n';
} else {
std::cout << "Некоректний індекс\n"; // Некоректний індекс
}
}
Зверніть увагу на порядок умов: спочатку idx >= 0, і лише потім static_cast<std::size_t>(idx) < s.size(). Це не просто стиль. Це захист від перетворення відʼємного числа на величезне додатне.
Окрема деталь: ми двічі робимо static_cast<std::size_t>(idx). Це виглядає трохи шумно, але на ранньому етапі навчання навіть корисно: ви буквально бачите, де переходите зі світу знакових типів у світ беззнакових.
5. Мініпроєкт: TextInspector із безпечним індексом
Щоб тема не лишилася «теорією про типи», давайте вбудуємо її в маленький консольний застосунок. Нехай це буде утиліта TextInspector: вона читає рядок, показує його довжину, а потім дозволяє користувачу вводити індекс і отримувати символ або повідомлення про помилку. Сьогодні наша мета — зробити цю частину безпечною з погляду size()/size_t.
Ось мінімальна версія, у якій уже є вся потрібна ідея:
#include <cstddef>
#include <iostream>
#include <string>
int main() {
std::string text;
std::cout << "Введіть текст: ";
std::getline(std::cin, text);
std::size_t n = text.size();
std::cout << "Довжина = " << n << '\n';
int idx = 0;
std::cout << "Введіть індекс: ";
std::cin >> idx;
if (idx >= 0 && static_cast<std::size_t>(idx) < text.size()) {
std::cout << "text[" << idx << "] = "
<< text[static_cast<std::size_t>(idx)] << '\n';
// наприклад: text[1] = e
} else {
std::cout << "Некоректний індекс\n"; // Некоректний індекс
}
}
Тут одразу кілька правильних рішень, на які варто звернути увагу.
- Довжина зберігається в std::size_t, бо це розмір.
- Індекс зберігається в int, бо користувач може ввести мінус, і нам потрібно вміти це помітити.
- Порівняння виконується строго за двокроковим шаблоном, щоб не потрапити в пастку змішування знакових і беззнакових типів.
Якщо хочете зробити поведінку дружнішою, не ускладнюючи код, можна дати користувачу кілька спроб у циклі while і завершувати роботу за спеціальним індексом, наприклад -1.
#include <cstddef>
#include <iostream>
#include <string>
int main() {
std::string text;
std::cout << "Введіть текст: ";
std::getline(std::cin, text);
std::cout << "Довжина = " << text.size() << '\n';
while (true) {
int idx = 0;
std::cout << "Введіть індекс (-1 — вихід): ";
std::cin >> idx;
if (idx == -1) {
std::cout << "До побачення\n"; // До побачення
break;
}
if (idx >= 0 && static_cast<std::size_t>(idx) < text.size()) {
std::cout << text[static_cast<std::size_t>(idx)] << '\n';
} else {
std::cout << "Некоректний індекс\n";
}
}
}
Зверніть увагу, як це зручно: -1 цілком законно живе у світі int, і ми використовуємо його як сигнал «вийти». А от довжина та перевірка меж живуть у світі size_t рівно там, де їм і місце.
6. Типові помилки під час роботи з size_t та індексами
Помилка № 1: порівнювати int idx і s.size() безпосередньо, сподіваючись на «шкільну математику».
Найчастіша неприємність має вигляд if (idx < s.size()) і особливо боляче спрацьовує, коли idx < 0. У цей момент idx перетворюється на std::size_t, і відʼємне значення стає величезним додатним. Уникнути цього просто: спочатку перевіряємо idx >= 0, а вже потім порівнюємо static_cast<std::size_t>(idx) < s.size().
Помилка № 2: робити static_cast<std::size_t>(idx) без перевірки idx >= 0.
Це та сама проблема, тільки в іншій формі: cast лише фіксує перетворення, але не робить його безпечним за змістом. Якщо idx == -5, то cast перетворить його на велике число — і ви отримаєте безглузді результати порівняння.
Помилка № 3: зберігати індекс користувача в std::size_t і чекати, що «-1» спрацює як команда «вийти».
Якщо ви зробите std::size_t idx; std::cin >> idx; і користувач уведе -1, то це не стане «мінус один». Це стане великим додатним числом. Для введення користувача, де можливі відʼємні значення, використовуйте знаковий тип.
Помилка № 4: писати перевірки на кшталт if (n >= 0) для змінних типу std::size_t.
Така перевірка виглядає логічно, але для беззнакового типу вона беззмістовна: std::size_t не буває відʼємним, отже, умова завжди істинна. Часто це трапляється, коли студент пише auto n = s.size();, забуває тип і далі пише так, ніби працює з int.
Помилка № 5: плутати «останній індекс» і «довжину».
Іноді пишуть s[s.size()], очікуючи отримати «останній символ». Але s.size() — це кількість символів, а індекси йдуть від 0 до s.size() - 1. Якщо рядок порожній, то s.size() - 1 взагалі не можна обчислювати «в лоб» без попередньої перевірки. Довжина й останній індекс — це різні числа.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ