C-style рядки
C-style рядки
«Що таке рядок насправді?»
Ви вже бачили рядки з першого дня знайомства з C++:
#include <iostream>
using namespace std;
int main()
{
cout << "Hello, World!\n";
return 0;
}
Але що насправді являє собою "Hello, World!"? Це не примітивний тип, як int чи double. Це не об'єкт, як std::string. У фундаменті мови C, успадкованому C++, рядок — це масив символів з особливим стоп-сигналом в кінці. Цей «стоп-сигнал» — нульовий байт '\0' (нуль-термінатор), і саме він перетворює звичайний масив char на рядок.
Подивіться, що відбувається у пам'яті, коли компілятор обробляє рядковий літерал "Cat":
Рядок «Cat» у пам'яті
Чотири байти — 'C' (67), 'a' (97), 't' (116) та '\0' (0) — ось і весь рядок. Три видимих символи, але чотири байти у пам'яті. Нуль-термінатор є невід'ємною частиною рядка, хоча ми його ніколи не бачимо при виводі.
Цей документ розкриє механіку C-style рядків від нуля до дна — разом із усіма пастками, що підстерігають необережного програміста.
C-style рядок: визначення та нуль-термінатор
Формальне визначення
char, в якому після останнього значущого символу стоїть нульовий байт'\0' (ASCII-код 0). Функції стандартної бібліотеки використовують цей термінатор як сигнал кінця рядка, оскільки масиви в C/C++ не зберігають свій розмір.Цей підхід є прямим наслідком архітектурного рішення мови C: масив — це лише адреса початку в пам'яті. Без нуль-термінатора функція strlen або std::cout не знала б, де зупинитися — вона б продовжувала читати байти за межами масиву, поки не натрапила б на щось невизначене.
Навіщо саме нуль?
Код '\0' є єдиним значенням в ASCII, що гарантовано означає «нічого» — це керуючий символ NUL (код 0), який ніколи не є частиною звичайного тексту. Саме тому він є ідеальним сигнальним значенням: якщо ми зустріли байт 0x00 — рядок закінчився.
sizeof vs strlen — принципова різниця
Одна з найперших точок плутанини:
#include <iostream>
#include <cstring> // для strlen
using namespace std;
int main()
{
char name[] = "Hello";
// sizeof — розмір масиву в байтах (compile-time)
cout << "sizeof(name) = " << sizeof(name) << "\n"; // 6
// strlen — кількість символів до '\0' (runtime, обходить масив)
cout << "strlen(name) = " << strlen(name) << "\n"; // 5
// Різниця: sizeof рахує '\0', strlen — ні
cout << "Різниця = " << sizeof(name) - strlen(name) << "\n"; // 1
return 0;
}
sizeof — оператор часу компіляції: він повертає розмір типу або об'єкта в байтах, відомий до запуску програми. strlen — функція часу виконання: вона фізично обходить байти масиву від початку до '\0' і рахує їх. Для рядка з N символів strlen виконує N+1 читань з пам'яті — це O(N) операція.Оголошення та ініціалізація
Спосіб 1: масив з рядковим літералом (рекомендований)
// Компілятор автоматично визначає розмір масиву і додає '\0'
char name[] = "John";
// Еквівалентно: char name[5] = {'J', 'o', 'h', 'n', '\0'};
// sizeof(name) == 5, strlen(name) == 4
Це найбільш компактна та ідіоматична форма оголошення. Компілятор:
- Підраховує символи у літералі (4 символи)
- Додає 1 для нуль-термінатора
- Виділяє масив розміром 5 байтів на стеку
- Копіює всі 5 байтів (включно з
'\0')
Спосіб 2: явний розмір масиву
char name[10] = "John";
// name[0]='J', name[1]='o', name[2]='h', name[3]='n', name[4]='\0'
// name[5]..name[9] = '\0' (решта байтів — нулі)
// sizeof(name) == 10, strlen(name) == 4
При ініціалізації масиву меншим значенням, решта байтів заповнюється нулями — це гарантує стандарт C++. Це важливо: масив на 10 байтів, але рядок у ньому займає лише 5 (включно з термінатором).
'\0') — це помилка компіляції або, у деяких випадках, мовчазне усічення без нуль-термінатора:char name[3] = "John"; // ❌ Помилка: "John" потребує 5 байтів
char name[4] = "John"; // ⚠️ У деяких компіляторах: 'J','o','h','n' БЕЗ '\0'!
Спосіб 3: явна посимвольна ініціалізація
// ✅ Правильно: явний '\0' в кінці
char name[] = {'J', 'o', 'h', 'n', '\0'};
// ❌ Неправильно: масив char БЕЗ '\0' — це НЕ рядок!
char notAString[] = {'J', 'o', 'h', 'n'};
// Передача notAString у strlen або cout → невизначена поведінка!
Третій спосіб є абсолютно легальним, але вразливим: людина легко забуває додати '\0'. Саме тому перший спосіб (з рядковим літералом) — найбезпечніший.
Візуальне порівняння трьох способів
char[] vs const char* — принципова різниця
Це одне з найважливіших розмежувань у всій темі C-style рядків.
char[] — масив на стеку (змінюваний)
#include <iostream>
using namespace std;
int main()
{
char greeting[] = "Hello"; // Масив: локальна копія на стеку
greeting[0] = 'J'; // ✅ Дозволено: масив можна змінювати
cout << greeting << "\n"; // Jello
return 0;
}
Коли ми пишемо char greeting[] = "Hello", компілятор:
- Розміщує рядковий літерал
"Hello\0"у read-only сегменті.rodata - Виділяє масив з 6 байтів на стеку (в кадрі функції
main) - Копіює вміст літерала у стековий масив
Отже, greeting — це повноправна локальна копія, з якою можна робити що завгодно.
const char* — вказівник на літерал (незмінний)
#include <iostream>
using namespace std;
int main()
{
const char* ptr = "Hello"; // Вказівник на літерал у .rodata
// ptr[0] = 'J'; // ❌ Невизначена поведінка (UB)! Crash на більшості систем
// *(ptr) = 'J'; // ❌ Те саме UB
ptr = "World"; // ✅ Дозволено: можна переключити вказівник на інший літерал
cout << ptr << "\n"; // World
return 0;
}
При const char* ptr = "Hello" жодного копіювання не відбувається. Компілятор розміщує літерал у захищеній read-only пам'яті (.rodata), а ptr отримує адресу цього літерала. Спроба записати через цей вказівник — це звернення до захищеної сторінки пам'яті, що призводить до SIGSEGV (segmentation fault) або, у кращому випадку, до тихого UB.
Ключові відмінності у таблиці
| Властивість | Значення |
|---|---|
| Тип | Масив char на стеку |
| Пам'ять | Локальна копія у стековому фреймі |
| Модифікація | ✅ Дозволена (елементи масиву) |
| Переназначення | ❌ Не можна (arr = "New" — помилка компіляції) |
| sizeof | Повертає розмір масиву в байтах |
| Передача у функцію | Перетворюється на вказівник (array decay) |
| Використання | Буфери для читання/запису |
| Властивість | Значення |
|---|---|
| Тип | Вказівник на const char |
| Пам'ять | Адреса у .rodata — жодного копіювання |
| Модифікація | ❌ UB (звернення до read-only) |
| Переназначення | ✅ Дозволено (ptr = "New" — змінює адресу) |
| sizeof | Повертає розмір вказівника (4 або 8 байтів) |
| Передача у функцію | Вже є вказівником, передається як є |
| Використання | Рядкові константи, строкові літерали |
const перед char* спроба змінити літерал є невизначеною поведінкою. Деякі компілятори не заперечують проти char* p = "Hello"; (з попередженням), але p[0] = 'J' — UB. Завжди використовуйте const char* для вказівників на літерали.Ввід та вивід C-style рядків
Вивід через std::cout
#include <iostream>
using namespace std;
int main()
{
char name[] = "Alice";
const char* title = "Dr.";
cout << title << " " << name << "\n"; // Dr. Alice
// Вивід окремих символів з ASCII-кодами
for (int i = 0; name[i] != '\0'; ++i)
{
cout << "name[" << i << "] = '"
<< name[i] << "' (код "
<< static_cast<int>(name[i]) << ")\n";
}
return 0;
}
std::cout з char* виводить символи один за одним до зустрічі з '\0'. Це буквально: читати байт, якщо != 0 — вивести і перейти до наступного.
Ввід через std::cin >> — небезпечний спосіб
#include <iostream>
using namespace std;
int main()
{
char name[20];
cout << "Введіть ім'я: ";
cin >> name; // ⚠️ Зупиняється на пробілі, переповнення можливе!
cout << "Привіт, " << name << "!\n";
return 0;
}
Два критичних недоліки std::cin >> для char*:
- Зупиняється на першому пробілі —
"John Doe"читається лише як"John" - Відсутня перевірка меж — якщо користувач введе 100 символів у буфер розміром 20, відбудеться buffer overflow (переповнення буфера)
Безпечний ввід через cin.getline
#include <iostream>
using namespace std;
int main()
{
char fullName[50];
cout << "Введіть повне ім'я: ";
cin.getline(fullName, sizeof(fullName)); // Безпечно!
// Аргументи: (буфер, максимальний розмір включно з '\0')
// Читає до '\n' або до (sizeof - 1) символів — завжди додає '\0'
cout << "Привіт, " << fullName << "!\n";
cout << "Довжина: " << strlen(fullName) << " символів\n";
return 0;
}
cin.getline(buf, N) — завжди безпечний: він ніколи не запишить більше N-1 символів (залишаючи місце для '\0'). Це єдиний рекомендований спосіб читання C-style рядків з клавіатури.Бібліотека <cstring>: функції для роботи з рядками
Заголовок <cstring> (в C — <string.h>) містить набір функцій для маніпулювання C-style рядками. Усі вони дотримуються однієї угоди: кінець рядка визначається нуль-термінатором. Якщо '\0' відсутній — функція читатиме байти за межами масиву до невизначеності.
strlen — довжина рядка
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
char word[] = "Compiler";
size_t len = strlen(word); // 8 — символів без '\0'
cout << "Рядок: " << word << "\n";
cout << "strlen: " << len << "\n"; // 8
cout << "sizeof: " << sizeof(word) << "\n"; // 9
return 0;
}
strlen є операцією O(n) — вона фізично обходить масив побайтово від початку до '\0'. Не викликайте її у кожній ітерації циклу: збережіть результат у змінну.
strlen масив без нуль-термінатора — це невизначена поведінка. Функція читатиме за межами масиву, поки не натрапить на нуль-байт десь у пам'яті.strcpy та strncpy — копіювання рядків
strcpy(dest, src) копіює рядок src (разом із '\0') у буфер dest. Функція не перевіряє розмір цільового буфера — це її головна небезпека.
strncpy(dest, src, n) — «захищений» варіант з обмеженням на кількість символів, але з неочевидною поведінкою щодо нуль-термінатора.
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
// === strcpy ===
char src[] = "Hello";
char dest1[10];
strcpy(dest1, src); // ✅ Безпечно: dest1 достатньо великий
cout << dest1 << "\n"; // Hello
// === strncpy ===
char dest2[10];
strncpy(dest2, src, sizeof(dest2) - 1); // Копіюємо не більше 9 символів
dest2[sizeof(dest2) - 1] = '\0'; // ⚠️ strncpy може не додати '\0'!
cout << dest2 << "\n"; // Hello
// === Типова пастка strncpy ===
char short_dest[4];
strncpy(short_dest, "Hello", sizeof(short_dest)); // Копіює 'H','e','l','l'
// short_dest[3] == 'l', а не '\0' — нуль-термінатор відсутній!
// cout << short_dest; // UB: strlen виходить за межі масиву
return 0;
}
strncpyне гарантує нуль-термінатор у dest, якщо src довший або рівний n. Завжди явно встановлюйте dest[n-1] = '\0' після виклику — або використовуйте snprintf, який завжди додає термінатор.strcat та strncat — конкатенація рядків
strcat(dest, src) дописує рядок src у кінець рядка dest, починаючи від нуль-термінатора dest. Результуючий рядок отримує новий '\0' в кінці.
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
// Буфер достатнього розміру для результату
char greeting[30] = "Hello";
strcat(greeting, ", ");
strcat(greeting, "World");
strcat(greeting, "!");
cout << greeting << "\n"; // Hello, World!
cout << "Довжина: " << strlen(greeting) << "\n"; // 13
// Безпечний варіант: strncat
char safe[20] = "Foo";
strncat(safe, "BarBazQux", sizeof(safe) - strlen(safe) - 1);
safe[sizeof(safe) - 1] = '\0'; // на всяк випадок
cout << safe << "\n"; // FooBarBazQu (обрізано до 19 символів)
return 0;
}
strcat переконайтеся, що dest має достатньо місця для результату: strlen(dest) + strlen(src) + 1 байтів. strcat не перевіряє межі — переповнення буфера гарантоване, якщо місця не вистачає.strcmp та strncmp — порівняння рядків
Оператор == для char* порівнює адреси, а не вміст. Для лексикографічного порівняння рядків призначена функція strcmp.
strcmp(s1, s2) повертає:
0, якщо рядки рівні- від'ємне число, якщо
s1 < s2лексикографічно - додатне число, якщо
s1 > s2лексикографічно
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
const char* a = "apple";
const char* b = "banana";
const char* c = "apple";
cout << strcmp(a, b) << "\n"; // від'ємне: 'a' < 'b'
cout << strcmp(b, a) << "\n"; // додатне: 'b' > 'a'
cout << strcmp(a, c) << "\n"; // 0: рядки рівні
// Правильне порівняння на рівність
if (strcmp(a, c) == 0)
cout << "Рядки однакові!\n";
// strncmp — порівнює перші n символів
const char* s1 = "Hello, World";
const char* s2 = "Hello, C++";
if (strncmp(s1, s2, 7) == 0) // перші 7: "Hello, "
cout << "Починаються однаково\n";
return 0;
}
strcmp при нерівності, залежить від реалізації — стандарт гарантує лише знак результату (від'ємний / нуль / додатний), а не конкретне число. Завжди перевіряйте < 0, == 0 або > 0, а не == -1 чи == 1.strchr та strstr — пошук у рядку
strchr(str, ch) — повертає вказівник на перше входження символу ch у рядку str, або nullptr, якщо символ не знайдений.
strstr(haystack, needle) — повертає вказівник на початок першого входження підрядка needle у рядку haystack.
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
const char* text = "Hello, World! Hello, C++!";
// strchr: пошук символу
const char* pos = strchr(text, 'W');
if (pos)
cout << "Знайдено 'W' на позиції: " << (pos - text) << "\n"; // 7
// strrchr: останнє входження символу
const char* last = strrchr(text, 'H');
if (last)
cout << "Останнє 'H' на позиції: " << (last - text) << "\n"; // 14
// strstr: пошук підрядка
const char* sub = strstr(text, "World");
if (sub)
cout << "Знайдено підрядок: " << sub << "\n"; // World! Hello, C++!
// Пошук без результату
const char* notFound = strstr(text, "Python");
if (!notFound)
cout << "Підрядок не знайдено\n";
return 0;
}
pos - text дає байтове зміщення (індекс) символу від початку рядка. Ця арифметика вказівників — один з найбільш ідіоматичних патернів при роботі з C-style рядками.Зведена таблиця функцій <cstring>
| Функція | Сигнатура | Що робить |
|---|---|---|
strlen | size_t strlen(const char* s) | Повертає кількість символів до '\0' |
| Функція | Сигнатура | Що робить |
|---|---|---|
strcpy | char* strcpy(char* dst, const char* src) | Копіює src у dst разом із '\0' |
strncpy | char* strncpy(char* dst, const char* src, size_t n) | Копіює не більше n байтів; може не додати '\0' |
| Функція | Сигнатура | Що робить |
|---|---|---|
strcat | char* strcat(char* dst, const char* src) | Дописує src у кінець dst |
strncat | char* strncat(char* dst, const char* src, size_t n) | Дописує не більше n символів src |
| Функція | Сигнатура | Що робить |
|---|---|---|
strcmp | int strcmp(const char* s1, const char* s2) | Лексикографічне порівняння: <0, 0, >0 |
strncmp | int strncmp(const char* s1, const char* s2, size_t n) | Порівнює перші n символів |
| Функція | Сигнатура | Що робить |
|---|---|---|
strchr | char* strchr(const char* s, int ch) | Перше входження символу ch |
strrchr | char* strrchr(const char* s, int ch) | Останнє входження символу ch |
strstr | char* strstr(const char* hay, const char* needle) | Перше входження підрядка |
Небезпеки та buffer overflow
Що таке переповнення буфера
Buffer overflow (переповнення буфера) — одна з найнебезпечніших уразливостей у комп'ютерній безпеці та одна з найпоширеніших причин аварійного завершення програм. Вона виникає, коли запис виходить за межі виділеного масиву й перезаписує сусідні ділянки пам'яті.
На стеці ці «сусідні ділянки» — це адреса повернення функції, збережені регістри, локальні змінні. Атакуючий, що може контролювати вміст буфера, може підмінити адресу повернення і змусити програму виконати довільний код.
Приклад: атака через cin >> без обмеження
#include <iostream>
#include <cstring>
using namespace std;
int main()
{
char password[8] = "secret"; // Буфер лише на 8 байтів
char input[32];
cout << "Введіть ім'я користувача: ";
cin >> input; // ❌ Без обмеження! Якщо ввести 30+ символів — UB
// Уявіть: введено 20 символів 'A'.
// input[0..7] — масив input (нормально)
// input[8..19] — але ці байти вже поза межами input!
// вони можуть перекривати password або адресу повернення
cout << "Пароль: " << password << "\n"; // Може вивести сміття або crash
return 0;
}
cin >> buf або gets() для зчитування у фіксований C-style буфер без вказівки обмеження розміру.Візуалізація переповнення стеку
Безпечні альтернативи
Сучасний підхід до безпечної роботи з C-style рядками — завжди використовувати функції, що приймають обмеження розміру:
#include <iostream>
#include <cstdio> // snprintf
#include <cstring>
#include <iomanip> // setw
using namespace std;
int main()
{
char buf[20];
// ✅ snprintf — завжди додає '\0', ніколи не виходить за межі
int written = snprintf(buf, sizeof(buf), "Hello, %s!", "World");
cout << buf << "\n"; // Hello, World!
cout << written << "\n"; // 13 (кількість записаних символів)
// ✅ strncpy + явний нуль-термінатор
char dest[10];
strncpy(dest, "LongString", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';
cout << dest << "\n"; // LongStrin
// ✅ cin >> з обмеженням ширини поля (маніпулятор setw)
char name[10];
cin >> setw(sizeof(name)) >> name; // зчитує не більше 9 символів + '\0'
return 0;
}
snprintf(buf, size, format, ...) — найбезпечніший інструмент для формування C-style рядків. На відміну від sprintf, вона ніколи не перевищує вказаний розмір size і завжди додає нуль-термінатор (навіть якщо рядок обрізано). Використовуйте її як заміну strcpy + strcat.Ніколи не використовуйте gets
Функція gets() — синонім катастрофи. Вона читає рядок до '\n' без жодного обмеження на розмір буфера. У стандарті C11 функцію офіційно видалено. У C++ вона також є застарілою.
// ❌ НЕБЕЗПЕЧНО — НІКОЛИ НЕ ВИКОРИСТОВУЙТЕ:
char buf[10];
gets(buf); // Зчитує скільки завгодно символів — гарантоване UB
// ✅ БЕЗПЕЧНА ЗАМІНА:
fgets(buf, sizeof(buf), stdin); // Обмежує читання
// або:
cin.getline(buf, sizeof(buf)); // Для cin-потоку
gets. Якщо ви бачите gets у чужому коді — це сигнал: код написаний без уваги до безпеки.Підсумок
C-style рядки: коли і навіщо
C-style рядки (char[] / const char*) — фундаментальна концепція, без розуміння якої неможливо повноцінно працювати ні з C, ні зі спадщиною C++ API. Навіть якщо у власному коді ви використовуєте std::string, розуміння C-style рядків необхідне для:
- роботи з системними викликами POSIX та WinAPI
- читання і написання C-коду
- розуміння того, чому
std::stringспроектований саме так - аналізу помилок і вразливостей у чужому коді
Сильні сторони C-style рядків
- Нульова надбудова: немає об'єкта, немає накладних витрат
- Сумісність із будь-яким C-API та системними функціями
- Рядкові літерали — найефективніші константи (у
.rodata) - Арифметика вказівників дає гнучкий низькорівневий доступ
Слабкі сторони C-style рядків
- Ручне управління буфером і розміром
- Відсутність перевірки меж у більшості функцій
=і==мають «неочікувану» семантику для вказівників- Немає автоматичного зростання рядка
- Buffer overflow — реальна загроза безпеці
Ключові правила безпечної роботи
Завжди залишайте місце для '\0'
Якщо рядок має N символів — буфер повинен мати щонайменше N+1 байт.
Перевіряйте розмір перед кожним записом
Перед strcpy, strcat обчисліть: strlen(dst) + strlen(src) + 1 <= sizeof(buf).
Надавайте перевагу n-версіям функцій
strncpy, strncat, strncmp — завжди краще, ніж strcpy, strcat, strcmp. Але пам'ятайте про обов'язковий явний '\0' після strncpy.
Використовуйте snprintf для форматування
Замість комбінації strcpy + strcat використовуйте snprintf — він безпечний і компактний.
Для нового коду — використовуйте std::string
C-style рядки актуальні при взаємодії з legacy API. Для власної бізнес-логіки завжди надавайте перевагу std::string.
Порівняльна таблиця: C-style vs std::string
| Операція | C-style рядок | std::string |
|---|---|---|
| Оголошення | char s[20] = "Hello"; | string s = "Hello"; |
| Довжина | strlen(s) — O(n) | s.length() — O(1) |
| Копіювання | strcpy(dst, src) — небезпечно | dst = src — безпечно |
| Конкатенація | strcat(dst, src) — небезпечно | s1 + s2 або s1 += s2 |
| Порівняння | strcmp(s1, s2) == 0 | s1 == s2 |
| Пошук символу | strchr(s, 'c') | s.find('c') |
| Пошук підрядка | strstr(s, "sub") | s.find("sub") |
| Ввід рядка | cin.getline(buf, N) | getline(cin, s) |
| Зростання | Неможливе (фіксований буфер) | Автоматичне |
| Безпека | Ручна відповідальність | Вбудована |
std::string — сучасну, безпечну та виразну альтернативу C-style рядкам, яка повністю вирішує перераховані проблеми.Unicode та кодування UTF
Що таке Unicode, чим відрізняється код-поінт від кодування, як влаштовані UTF-8, UTF-16 та UTF-32 на рівні байтів, чому char у C++ — це не символ, та які символьні типи існують для роботи з Unicode.
Вступ до std::string
Навіщо std::string замінив C-style рядки, як клас basic_string організований в стандартній бібліотеці, способи створення та ініціалізації рядків, конвертація в C-style, ввід/вивід та типова пастка з getline — повний фундамент для роботи з текстом у C++.