C++

C-style рядки

Масив char з нуль-термінатором — фундамент рядкової обробки в C та C++. Детально: оголошення, ініціалізація, char[] vs const char*, бібліотека <cstring>, небезпеки та buffer overflow.

C-style рядки

«Що таке рядок насправді?»

Ви вже бачили рядки з першого дня знайомства з C++:

CStringHello.cpp
#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» у пам'яті
Hex Dump / ASCII
0x000000A0
43617400000000000000000000000000
Offset: 8 bytes
Big Endian

Чотири байти — 'C' (67), 'a' (97), 't' (116) та '\0' (0) — ось і весь рядок. Три видимих символи, але чотири байти у пам'яті. Нуль-термінатор є невід'ємною частиною рядка, хоча ми його ніколи не бачимо при виводі.

Цей документ розкриє механіку C-style рядків від нуля до дна — разом із усіма пастками, що підстерігають необережного програміста.


C-style рядок: визначення та нуль-термінатор

Формальне визначення

C-style рядок (C-string, null-terminated string) — це масив елементів типу char, в якому після останнього значущого символу стоїть нульовий байт'\0' (ASCII-код 0). Функції стандартної бібліотеки використовують цей термінатор як сигнал кінця рядка, оскільки масиви в C/C++ не зберігають свій розмір.

Цей підхід є прямим наслідком архітектурного рішення мови C: масив — це лише адреса початку в пам'яті. Без нуль-термінатора функція strlen або std::cout не знала б, де зупинитися — вона б продовжувала читати байти за межами масиву, поки не натрапила б на щось невизначене.

Навіщо саме нуль?

Код '\0' є єдиним значенням в ASCII, що гарантовано означає «нічого» — це керуючий символ NUL (код 0), який ніколи не є частиною звичайного тексту. Саме тому він є ідеальним сигнальним значенням: якщо ми зустріли байт 0x00 — рядок закінчився.

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title C-style рядок "Hello" у пам'яті — 6 байтів, 5 символів + '\\0'

rectangle "char name[] = \"Hello\"" as decl #3b82f6 {
  rectangle "name[0]\\n'H'\\n0x48\\n addr: 0x100" as b0 #2563eb
  rectangle "name[1]\\n'e'\\n0x65\\n addr: 0x101" as b1 #2563eb
  rectangle "name[2]\\n'l'\\n0x6C\\n addr: 0x102" as b2 #2563eb
  rectangle "name[3]\\n'l'\\n0x6C\\n addr: 0x103" as b3 #2563eb
  rectangle "name[4]\\n'o'\\n0x6F\\n addr: 0x104" as b4 #2563eb
  rectangle "name[5]\\n'\\\\0'\\n0x00\\n addr: 0x105" as b5 #f59e0b
}

note bottom of b5
  Нуль-термінатор!
  Кінець рядка.
  strlen() зупиняється тут.
end note

note bottom of decl
  sizeof(name) = 6  (байтів у масиві)
  strlen(name) = 5  (символів без '\\0')
end note

@enduml

sizeof vs strlen — принципова різниця

Одна з найперших точок плутанини:

SizeofVsStrlen.cpp
#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;
}
./SizeofVsStrlen
$ ./SizeofVsStrlen
sizeof(name) = 6
strlen(name) = 5
Різниця = 1
sizeofоператор часу компіляції: він повертає розмір типу або об'єкта в байтах, відомий до запуску програми. strlenфункція часу виконання: вона фізично обходить байти масиву від початку до '\0' і рахує їх. Для рядка з N символів strlen виконує N+1 читань з пам'яті — це O(N) операція.

Оголошення та ініціалізація

Спосіб 1: масив з рядковим літералом (рекомендований)

AutoSizeArray.cpp
// Компілятор автоматично визначає розмір масиву і додає '\0'
char name[] = "John";
// Еквівалентно: char name[5] = {'J', 'o', 'h', 'n', '\0'};
// sizeof(name) == 5, strlen(name) == 4

Це найбільш компактна та ідіоматична форма оголошення. Компілятор:

  1. Підраховує символи у літералі (4 символи)
  2. Додає 1 для нуль-термінатора
  3. Виділяє масив розміром 5 байтів на стеку
  4. Копіює всі 5 байтів (включно з '\0')

Спосіб 2: явний розмір масиву

FixedSizeArray.cpp
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: явна посимвольна ініціалізація

NullTerminatorRules.cpp
// ✅ Правильно: явний '\0' в кінці
char name[] = {'J', 'o', 'h', 'n', '\0'};

// ❌ Неправильно: масив char БЕЗ '\0' — це НЕ рядок!
char notAString[] = {'J', 'o', 'h', 'n'};
// Передача notAString у strlen або cout → невизначена поведінка!

Третій спосіб є абсолютно легальним, але вразливим: людина легко забуває додати '\0'. Саме тому перший спосіб (з рядковим літералом) — найбезпечніший.

Візуальне порівняння трьох способів

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title Три способи оголошення C-style рядка "John"

rectangle "char name[] = \"John\"" as s1 #22c55e {
  rectangle "'J'" as s1a #16a34a
  rectangle "'o'" as s1b #16a34a
  rectangle "'h'" as s1c #16a34a
  rectangle "'n'" as s1d #16a34a
  rectangle "'\\\\0'" as s1e #f59e0b
}
note bottom of s1
  sizeof = 5
  strlen = 4
  Рекомендовано!
end note

rectangle "char name[10] = \"John\"" as s2 #3b82f6 {
  rectangle "'J'" as s2a #2563eb
  rectangle "'o'" as s2b #2563eb
  rectangle "'h'" as s2c #2563eb
  rectangle "'n'" as s2d #2563eb
  rectangle "'\\\\0'" as s2e #f59e0b
  rectangle "'\\\\0'x5" as s2f #475569
}
note bottom of s2
  sizeof = 10
  strlen = 4
  Запас для зміни рядка
end note

rectangle "char bad[] = {'J','o','h','n'}" as s3 #ef4444 {
  rectangle "'J'" as s3a #dc2626
  rectangle "'o'" as s3b #dc2626
  rectangle "'h'" as s3c #dc2626
  rectangle "'n'" as s3d #dc2626
  rectangle "???" as s3e #991b1b
}
note bottom of s3
  sizeof = 4
  strlen = UB!
  НЕ рядок — немає '\\0'
end note

@enduml

char[] vs const char* — принципова різниця

Це одне з найважливіших розмежувань у всій темі C-style рядків.

char[] — масив на стеку (змінюваний)

CharArray.cpp
#include <iostream>

using namespace std;

int main()
{
    char greeting[] = "Hello";  // Масив: локальна копія на стеку

    greeting[0] = 'J';          // ✅ Дозволено: масив можна змінювати
    cout << greeting << "\n"; // Jello

    return 0;
}

Коли ми пишемо char greeting[] = "Hello", компілятор:

  1. Розміщує рядковий літерал "Hello\0" у read-only сегменті .rodata
  2. Виділяє масив з 6 байтів на стеку (в кадрі функції main)
  3. Копіює вміст літерала у стековий масив

Отже, greeting — це повноправна локальна копія, з якою можна робити що завгодно.

const char* — вказівник на літерал (незмінний)

ConstCharPtr.cpp
#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.

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title char[] vs const char* — модель пам'яті

rectangle "Стек (Stack)" as stack #3b82f6 {
  rectangle "char greeting[] = \"Hello\"" as arr #2563eb {
    rectangle "H | e | l | l | o | \\\\0" as arrdata #1d4ed8
  }
  rectangle "const char* ptr" as ptr #2563eb {
    rectangle "0x8041  (адреса)" as ptrval #1d4ed8
  }
}

rectangle "Read-Only Data (.rodata)" as rodata #64748b {
  rectangle "\"Hello\\\\0\"  (літерал)" as lit #475569 {
    rectangle "H | e | l | l | o | \\\\0" as litdata #334155
  }
}

ptr -right-> lit : "вказує на .rodata"
arr .. arrdata : "копія літерала"

note right of arr
  arr[0] = 'J' ✅
  Можна змінювати!
end note

note right of ptr
  ptr[0] = 'J' ❌ UB!
  Тільки читання
end note

note bottom of rodata
  Захищена пам'ять:
  спроба запису → SIGSEGV
end note

@enduml

Ключові відмінності у таблиці

ВластивістьЗначення
ТипМасив char на стеку
Пам'ятьЛокальна копія у стековому фреймі
Модифікація✅ Дозволена (елементи масиву)
Переназначення❌ Не можна (arr = "New" — помилка компіляції)
sizeofПовертає розмір масиву в байтах
Передача у функціюПеретворюється на вказівник (array decay)
ВикористанняБуфери для читання/запису
Навіть без const перед char* спроба змінити літерал є невизначеною поведінкою. Деякі компілятори не заперечують проти char* p = "Hello"; (з попередженням), але p[0] = 'J' — UB. Завжди використовуйте const char* для вказівників на літерали.

Ввід та вивід C-style рядків

Вивід через std::cout

PrintCString.cpp
#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;
}
./PrintCString
$ ./OutputDemo
Dr. Alice
name[0] = 'A' (код 65)
name[1] = 'l' (код 108)
name[2] = 'i' (код 105)
name[3] = 'c' (код 99)
name[4] = 'e' (код 101)

std::cout з char* виводить символи один за одним до зустрічі з '\0'. Це буквально: читати байт, якщо != 0 — вивести і перейти до наступного.

Ввід через std::cin >> — небезпечний спосіб

ReadCString.cpp
#include <iostream>

using namespace std;

int main()
{
    char name[20];

    cout << "Введіть ім'я: ";
    cin >> name;  // ⚠️ Зупиняється на пробілі, переповнення можливе!

    cout << "Привіт, " << name << "!\n";
    return 0;
}

Два критичних недоліки std::cin >> для char*:

  1. Зупиняється на першому пробілі"John Doe" читається лише як "John"
  2. Відсутня перевірка меж — якщо користувач введе 100 символів у буфер розміром 20, відбудеться buffer overflow (переповнення буфера)

Безпечний ввід через cin.getline

SafeReadLine.cpp
#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;
}
./SafeReadLine
$ ./SafeInput
Введіть повне ім'я: John Doe
Привіт, John Doe!
Довжина: 8 символів
cin.getline(buf, N)завжди безпечний: він ніколи не запишить більше N-1 символів (залишаючи місце для '\0'). Це єдиний рекомендований спосіб читання C-style рядків з клавіатури.

Бібліотека <cstring>: функції для роботи з рядками

Заголовок <cstring> (в C — <string.h>) містить набір функцій для маніпулювання C-style рядками. Усі вони дотримуються однієї угоди: кінець рядка визначається нуль-термінатором. Якщо '\0' відсутній — функція читатиме байти за межами масиву до невизначеності.

strlen — довжина рядка

StrlenDemo.cpp
#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;
}
./StrlenDemo
$ ./StrlenDemo
Рядок: Compiler
strlen: 8
sizeof: 9
Execution finished with exit code 0.

strlen є операцією O(n) — вона фізично обходить масив побайтово від початку до '\0'. Не викликайте її у кожній ітерації циклу: збережіть результат у змінну.

Ніколи не передавайте у strlen масив без нуль-термінатора — це невизначена поведінка. Функція читатиме за межами масиву, поки не натрапить на нуль-байт десь у пам'яті.

strcpy та strncpy — копіювання рядків

strcpy(dest, src) копіює рядок src (разом із '\0') у буфер dest. Функція не перевіряє розмір цільового буфера — це її головна небезпека.

strncpy(dest, src, n) — «захищений» варіант з обмеженням на кількість символів, але з неочевидною поведінкою щодо нуль-термінатора.

StrcpyDemo.cpp
#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;
}
./StrcpyDemo
$ ./StrcpyDemo
Hello
Hello
Execution finished with exit code 0.
strncpyне гарантує нуль-термінатор у dest, якщо src довший або рівний n. Завжди явно встановлюйте dest[n-1] = '\0' після виклику — або використовуйте snprintf, який завжди додає термінатор.

strcat та strncat — конкатенація рядків

strcat(dest, src) дописує рядок src у кінець рядка dest, починаючи від нуль-термінатора dest. Результуючий рядок отримує новий '\0' в кінці.

StrcatDemo.cpp
#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;
}
./StrcatDemo
$ ./StrcatDemo
Hello, World!
Довжина: 13
FooBarBazQu
Execution finished with exit code 0.
Перед викликом strcat переконайтеся, що dest має достатньо місця для результату: strlen(dest) + strlen(src) + 1 байтів. strcat не перевіряє межі — переповнення буфера гарантоване, якщо місця не вистачає.

strcmp та strncmp — порівняння рядків

Оператор == для char* порівнює адреси, а не вміст. Для лексикографічного порівняння рядків призначена функція strcmp.

strcmp(s1, s2) повертає:

  • 0, якщо рядки рівні
  • від'ємне число, якщо s1 < s2 лексикографічно
  • додатне число, якщо s1 > s2 лексикографічно
StrcmpDemo.cpp
#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;
}
./StrcmpDemo
$ ./StrcmpDemo
-1
1
0
Рядки однакові!
Починаються однаково
Execution finished with exit code 0.
Точне значення, яке повертає strcmp при нерівності, залежить від реалізації — стандарт гарантує лише знак результату (від'ємний / нуль / додатний), а не конкретне число. Завжди перевіряйте < 0, == 0 або > 0, а не == -1 чи == 1.

strchr та strstr — пошук у рядку

strchr(str, ch) — повертає вказівник на перше входження символу ch у рядку str, або nullptr, якщо символ не знайдений.

strstr(haystack, needle) — повертає вказівник на початок першого входження підрядка needle у рядку haystack.

SearchDemo.cpp
#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;
}
./SearchDemo
$ ./SearchDemo
Знайдено 'W' на позиції: 7
Останнє 'H' на позиції: 14
Знайдено підрядок: World! Hello, C++!
Підрядок не знайдено
Execution finished with exit code 0.
Різниця між двома вказівниками pos - text дає байтове зміщення (індекс) символу від початку рядка. Ця арифметика вказівників — один з найбільш ідіоматичних патернів при роботі з C-style рядками.

Зведена таблиця функцій <cstring>

ФункціяСигнатураЩо робить
strlensize_t strlen(const char* s)Повертає кількість символів до '\0'

Небезпеки та buffer overflow

Що таке переповнення буфера

Buffer overflow (переповнення буфера) — одна з найнебезпечніших уразливостей у комп'ютерній безпеці та одна з найпоширеніших причин аварійного завершення програм. Вона виникає, коли запис виходить за межі виділеного масиву й перезаписує сусідні ділянки пам'яті.

На стеці ці «сусідні ділянки» — це адреса повернення функції, збережені регістри, локальні змінні. Атакуючий, що може контролювати вміст буфера, може підмінити адресу повернення і змусити програму виконати довільний код.

Переповнення буфера через небезпечні рядкові функції C було вектором атаки в таких знакових інцидентах, як хробак Morris (1988), Code Red (2001), та Blaster (2003). Ці події докорінно змінили підхід індустрії до безпеки програмного забезпечення.

Приклад: атака через cin >> без обмеження

BufferOverflowDemo.cpp
#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 буфер без вказівки обмеження розміру.

Візуалізація переповнення стеку

Loading diagram...
@startuml
skinparam style plain
skinparam defaultFontName "JetBrains Mono"
skinparam backgroundColor #f8fafc
skinparam defaultFontSize 13

title Buffer Overflow: перезапис сусідніх змінних на стеку

rectangle "Стек (зростає донизу)" as stack {

  rectangle "char password[8] = \"secret\"" as pw #22c55e {
    rectangle "'s','e','c','r','e','t','\0','\0'" as pwdata #16a34a
  }

  rectangle "char input[32] — зчитується з cin" as inp #3b82f6 {
    rectangle "A A A A A A A A" as in1 #2563eb
    rectangle "A A A A A A A A ← перезаписуємо password!" as in2 #f59e0b
    rectangle "A A A A A A A A" as in3 #ef4444
    rectangle "A A A A A A A A ← руйнуємо адресу повернення!" as in4 #991b1b
  }

}

note right of pw
  До запису: "secret\0\0"
  Після введення 32x 'A':
  password == "AAAAAAAA" 💥
end note

note right of in4
  ret address = 0x41414141
  ('A' у hex = 0x41)
  Програма перейде на
  адресу 0x41414141 → CRASH
end note

@enduml

Безпечні альтернативи

Сучасний підхід до безпечної роботи з C-style рядками — завжди використовувати функції, що приймають обмеження розміру:

SafeStringOps.cpp
#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-потоку
Компілятори GCC та Clang видають попередження або помилку при використанні 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) == 0s1 == s2
Пошук символуstrchr(s, 'c')s.find('c')
Пошук підрядкаstrstr(s, "sub")s.find("sub")
Ввід рядкаcin.getline(buf, N)getline(cin, s)
ЗростанняНеможливе (фіксований буфер)Автоматичне
БезпекаРучна відповідальністьВбудована
Наступна стаття детально розкриває клас std::string — сучасну, безпечну та виразну альтернативу C-style рядкам, яка повністю вирішує перераховані проблеми.
Copyright © 2026