C++

Вказівники типу void

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

Вказівники типу void

Проблема узагальнення: навіщо потрібен «вказівник без типу»?

Уявіть, що ви пишете бібліотеку для обробки даних і хочете реалізувати одну функцію, яка виводить значення змінної на екран, незалежно від її типу — int, double, char* чи будь-який інший. Перше, що спало б на думку, — написати окремі перевантажені версії функції для кожного типу. Але якщо типів десятки? Чи взагалі невідомо, які типи будуть передані? Саме тут у стандарті C (а пізніше і C++) з'явилася ідея «загального вказівника» — вказівника, який вміє зберегти адресу об'єкта будь-якого типу без жодних обмежень. Цей механізм отримав назву вказівник типу void (void pointer, або generic pointer).

Однак разом із гнучкістю void* приносить і серйозну відповідальність: компілятор більше не контролює коректність. Розуміння того, чому це так, і коли такий підхід є прийнятним, — ключова мета цієї статті.

Передумови. Перед читанням переконайтеся, що ви знайомі з основами вказівників (стаття 15), константними вказівниками та масивами (стаття 17), а також адресною арифметикою (стаття 18). Концепція static_cast коротко пояснена нижче, але загальне уявлення про явне перетворення типів буде корисним.

Що таке void*

Вказівник типу void (void*) — це вказівник, який може зберігати адресу об'єкта будь-якого типу даних. На відміну від int* (який може вказувати тільки на int) або double* (який вказує тільки на double), void* не прив'язаний до жодного конкретного типу.

Стандарт C++ гарантує, що будь-який вказівник на об'єкт може бути неявно перетворений у void* без втрати інформації про адресу. Тобто void* зберігає лише адресу — числове значення, що вказує на розміщення даних у пам'яті, — але не має уявлення про те, що саме зберігається за цією адресою.

Оголошення void* виглядає так само, як і для будь-якого іншого вказівника, тільки замість конкретного типу стоїть ключове слово void:

main.cpp
void* ptr;              // не ініціалізований — небезпечно!
void* safePtr = nullptr; // правильна ініціалізація нульовим вказівником
Завжди ініціалізуйте вказівники. Неініціалізований void* ptr; містить сміттєве значення і може призвести до невизначеної поведінки при будь-якій спробі його використання.

Неявне перетворення до void*

Перша і, мабуть, найзручніша властивість void* — це можливість неявно прийняти адресу об'єкта будь-якого типу. C++ дозволяє таке перетворення без жодного cast:

main.cpp
#include <iostream>

int main()
{
    int    intValue  = 42;
    double dblValue  = 3.14;
    char   charValue = 'Z';

    void* ptr;

    ptr = &intValue;  // int* → void*  (неявне, дозволено)
    ptr = &dblValue;  // double* → void* (неявне, дозволено)
    ptr = &charValue; // char* → void*  (неявне, дозволено)

    return 0;
}

Розглянемо детально рядок ptr = &intValue;. Оператор &intValue повертає int* — вказівник на ціле число. Коли ми присвоюємо його void*, компілятор виконує неявне (implicit) перетворення: адреса залишається тією самою, але інформація про тип «забувається». Після цього ptr знає лише де знаходяться дані, але не що саме там лежить.

Loading diagram...
graph LR
    A["int intValue = 42<br/>(адреса: 0x1000)"] --> B["&intValue → int*<br/>(0x1000)"]
    B --> C["void* ptr<br/>(зберігає: 0x1000)"]
    C --> D["❓ Що за цією адресою?<br/>void* не знає"]
    style A fill:#3b82f6,color:#fff
    style B fill:#64748b,color:#fff
    style C fill:#f59e0b,color:#000
    style D fill:#ef4444,color:#fff

Саме цей «амнезійний» характер void* є і його силою (прийме будь-який тип), і його головною небезпекою (не контролює коректність).


Неможливість прямого розіменування

Розіменування (dereference) — це операція отримання значення, на яке вказує вказівник. Для int* p вираз *p повертає int. Але що повертає *voidPtr? Компілятор не може відповісти на це питання, адже розмір і формат даних визначаються типом — а тип невідомий.

Саме тому розіменування void* напряму є забороненим і компілятор відкине такий код з помилкою:

main.cpp
int value = 7;
void* voidPtr = &value;

// std::cout << *voidPtr; // ❌ Помилка компіляції:
// error: 'void*' is not a pointer-to-object type

Щоб розіменувати void*, необхідно спочатку явно перетворити його до конкретного типу вказівника за допомогою static_cast. Лише після цього отриманий конкретний вказівник можна розіменувати:

main.cpp
#include <iostream>

int main()
{
    int value = 7;
    void* voidPtr = &value;

    int* intPtr = static_cast<int*>(voidPtr); // void* → int*
    std::cout << *intPtr << '\n';             // ✅ Виводить: 7

    // Або ж в один вираз:
    std::cout << *static_cast<int*>(voidPtr) << '\n'; // ✅ Виводить: 7

    return 0;
}

Розбір ключових рядків:

  • static_cast<int*>(voidPtr) — оператор явного перетворення типів часу компіляції. Ми «нагадуємо» компілятору, що за адресою, яку зберігає voidPtr, знаходиться саме int. Компілятор довіряє нам на слово.
  • *intPtr — тепер розіменування є законним: компілятор знає, що intPtr вказує на int розміром 4 байти, тому читає рівно 4 байти з пам'яті і інтерпретує їх як ціле число.
Якщо ви приведете void* до неправильного типу (наприклад, вказівник на double приведете до int*), компілятор не видасть помилки — але результат буде невизначеним. Це і є головна небезпека void*.
Local Variables — після static_cast
Filter
NameTypeValue
valueint7
voidPtrvoid*0x00CFFC44
intPtrint*0x00CFFC44
Running
Process: 12842

Зверніть увагу на рядок intPtr у дебаггері: адреса 0x00CFFC44 збігається з адресою у voidPtr. Перетворення типу змінює лише інтерпретацію компілятором, але не переміщує нічого в пам'яті.


Неможливість адресної арифметики

Адресна арифметика (pointer arithmetic) з void* також є забороненою. Щоб зрозуміти чому, згадаємо, як вона працює для звичайних вказівників.

Коли ми пишемо ptr++ для int* ptr, компілятор насправді додає до адреси значення sizeof(int) — тобто 4 байти. Він «знає», що наступний int у пам'яті розміщується рівно через 4 байти. Але для void* компілятор не знає розміру елемента — а значить, не може коректно обчислити зміщення:

main.cpp
int arr[] = {10, 20, 30};
void* vPtr = arr;

// vPtr++;    // ❌ Помилка: arithmetic on a pointer to void
// vPtr + 1;  // ❌ Те саме

Щоб виконати арифметику, необхідно спочатку привести void* до конкретного типу:

main.cpp
int arr[] = {10, 20, 30};
void* vPtr = arr;

int* iPtr = static_cast<int*>(vPtr);
std::cout << *(iPtr + 1) << '\n'; // ✅ Виводить: 20
У мові C (на відміну від C++) деякі компілятори (наприклад, GCC з розширеннями) дозволяють арифметику з void*, вважаючи крок рівним 1 байту. Однак це нестандартна поведінка, яка не гарантується стандартом C++ і не є переносимою.

Практичний приклад: «поліморфна» функція виводу

Класичний приклад застосування void* — функція, яка приймає значення довільного типу і виводить його на екран. Щоб функція знала, як інтерпретувати отримані дані, разом із void* передається додатковий цілочисельний параметр, що описує тип:

VoidPrint.cpp
#include <iostream>

// Константи для позначення типу (замість enum, який ми ще не вивчали)
const int TYPE_INT    = 0;
const int TYPE_DOUBLE = 1;
const int TYPE_CSTR   = 2;

void printValue(void* ptr, int type)
{
    if (type == TYPE_INT)
    {
        // Приводимо void* до int*, потім розіменовуємо
        std::cout << *static_cast<int*>(ptr) << '\n';
    }
    else if (type == TYPE_DOUBLE)
    {
        // Аналогічно для double
        std::cout << *static_cast<double*>(ptr) << '\n';
    }
    else if (type == TYPE_CSTR)
    {
        // char* не потребує розіменування: cout обробляє char* як рядок
        std::cout << static_cast<char*>(ptr) << '\n';
    }
}

int main()
{
    int    n = 42;
    double d = 9.81;
    char   s[] = "Привіт";

    printValue(&n, TYPE_INT);    // Виводить: 42
    printValue(&d, TYPE_DOUBLE); // Виводить: 9.81
    printValue(s,  TYPE_CSTR);   // Виводить: Привіт

    return 0;
}

Детальний розбір функції printValue:

  • Рядок 9. Функція приймає два аргументи: void* ptr (адреса об'єкта будь-якого типу) і int type (цілочисельний ярлик, що описує, що саме знаходиться за цією адресою).
  • Рядок 13. *static_cast<int*>(ptr) — двоетапна операція: спочатку static_cast<int*>(ptr) перетворює void* на int*, а потім * розіменовує результат, отримуючи значення типу int.
  • Рядок 22. Зверніть увагу: для char* ми не розіменовуємо. std::cout розпізнає, що char* — це C-style рядок, і виводить увесь рядок до нульового символу. Якби ми написали *static_cast<char*>(ptr), то отримали б лише перший символ.
./VoidPrint
$ ./VoidPrint
42
9.81
Привіт

Функція printValue виглядає елегантно і справді є корисною ілюстрацією принципу роботи void*. Але тепер розглянемо, чому саме такий підхід є анти-патерном у сучасному C++.


void* як анти-патерн: проблема втрати безпеки типів

Безпека типів (type safety) — це властивість мови або програми запобігати некоректним операціям з даними через контроль типів. C++ є мовою зі статичною типізацією: компілятор перевіряє коректність операцій над типами до виконання програми. void* повністю обходить цей механізм.

Розглянемо ситуацію з нашою функцією printValue:

bug.cpp
int n = 42;

// Ми передаємо int*, але вказуємо тип TYPE_CSTR.
// Компілятор не бачить жодної проблеми!
printValue(&n, TYPE_CSTR); // ❌ Невизначена поведінка

Що відбудеться? Функція виконає static_cast<char*>(&n) — і отримає вказівник char* на байти, що містять число 42. Далі std::cout почне читати байти, починаючи з адреси n, і виводити їх як символи, поки не натрапить на нульовий байт. На 64-бітній системі з little-endian порядком байтів перший байт числа 42 — це 0x2A (символ *), після чого йдуть три нульові байти — і рядок негайно «завершується». Програма виведе * або щось подібне замість очікуваного числа, але не впаде з помилкою — що набагато гірше: помилку буде важко відловити.

Найнебезпечніші помилки — це ті, що не проявляються одразу. Використання void* без належного контролю типів може призвести до тихої логічної помилки, яка проявиться лише за специфічних умов у продакшн-середовищі.

Сучасні альтернативи void*

Стандарт C++ пропонує кілька потужних механізмів, які вирішують ту ж задачу узагальнення, але зі збереженням безпеки типів:

Перевантаження функцій

Окрема функція для кожного типу. Компілятор сам обирає потрібну версію. Безпечно, читабельно.

void print(int v)         { std::cout << v; }
void print(double v)      { std::cout << v; }
void print(const char* v) { std::cout << v; }

Шаблони функцій

Один код для всіх типів, але компілятор генерує окрему безпечну версію для кожного типу автоматично.

template<typename T>
void print(const T& value)
{
    std::cout << value << '\n';
}

Шаблони функцій — найбільш поширена і потужна альтернатива. Порівняємо підходи:

// Потребує ручного відстеження типу — схильний до помилок
void printValue(void* ptr, int type)
{
    if (type == TYPE_INT)
        std::cout << *static_cast<int*>(ptr);
    // ... багато boilerplate коду
}

// Виклик: можна передати неправильний тип!
int n = 42;
printValue(&n, TYPE_DOUBLE); // Тихий UB!

Різниця разюча: шаблонна версія коротша, читабельніша і абсолютно безпечна — компілятор не дозволить передати некоректний аргумент, оскільки тип визначається автоматично.


void* vs nullptr vs нульовий вказівник

Студенти часто плутають три пов'язані поняття. Давайте їх чітко розмежуємо:

ПоняттяВизначенняТипЧи можна розіменувати?
void*Вказівник без типу, зберігає адресуvoid*Лише після static_cast
nullptrЛітерал нульового вказівника (C++11)std::nullptr_tНіколи — UB
Нульовий вказівникБудь-який вказівник зі значенням 0/nullptrT*Ніколи — UB

Ключова різниця між void* і nullptr:

  • void* — це тип вказівника. Він може мати як ненульове, так і нульове значення.
  • nullptr — це значення. Воно може бути присвоєне вказівнику будь-якого типу, зокрема і void*.
PointersComparison.cpp
#include <iostream>

int main()
{
    void* vp = nullptr; // void* зі значенням nullptr — цілком коректно

    int x = 5;
    void* vp2 = &x;     // void* з реальною адресою

    // nullptr можна присвоїти вказівнику будь-якого типу:
    int*    ip  = nullptr;
    double* dp  = nullptr;
    void*   vp3 = nullptr;

    // Перевірка на null перед розіменуванням:
    if (vp2 != nullptr)
    {
        std::cout << *static_cast<int*>(vp2) << '\n'; // ✅ Безпечно
    }

    return 0;
}
void* може бути нульовим вказівником — це цілком законний стан, що означає «вказівник без типу, який ні на що не вказує». Але розіменування нульового вказівника, незалежно від його типу, завжди є невизначеною поведінкою (undefined behavior).

Коли void* все ж таки виправданий

Попри те, що в сучасному C++ void* вважається анти-патерном, є кілька ситуацій, де він залишається необхідним або прийнятним:

Правило великого пальця: якщо ви пишете void* у новому C++-коді і у вас немає описаних вище причин — зупиніться і подумайте, чи не вирішить задачу шаблон або перевантаження функції. Майже напевно вирішить.

Практика та підсумок

Практичні завдання

Рівень 1 — Базовий

Завдання 1. Оголосіть вказівник типу void* і послідовно присвойте йому адреси змінних int, float та bool. Після кожного присвоєння виведіть значення, правильно привевши void* до відповідного типу через static_cast.

Завдання 2. Чому наступний код не компілюється? Виправте його:

double pi = 3.14159;
void* vp = &pi;
std::cout << *vp << '\n'; // Помилка. Що не так?

Завдання 3. Напишіть функцію bool isNull(void* ptr), що повертає true, якщо переданий вказівник є нульовим, і false інакше. Протестуйте її з nullptr і з адресою реальної змінної.

Рівень 2 — Логіка

Завдання 4. Реалізуйте функцію void swapMemory(void* a, void* b, int size), яка обмінює значення двох ділянок пам'яті через побайтове копіювання в тимчасовий буфер char. Протестуйте для обміну двох int та двох double.

Завдання 5. Напишіть функцію void printArray(void* arr, int length, int elementSize, int type), що виводить масив довільного типу. Використовуйте константи TYPE_INT, TYPE_DOUBLE для визначення типу. Всередині застосуйте адресну арифметику через char* (кожен байт — крок 1), а для зчитування — static_cast до потрібного типу.

Рівень 3 — Архітектура

Завдання 6. Реалізуйте спрощений пул пам'яті (memory pool): структура (яку ми поки що замінимо глобальними змінними) з масивом char buffer[1024], цілочисельним лічильником int offset = 0 і двома функціями:

  • void* poolAllocate(int size) — повертає вказівник на наступний вільний блок і збільшує offset.
  • void poolReset() — скидає offset у нуль.

Протестуйте: виділіть пам'ять для int, double і char[20], запишіть значення і зчитайте через static_cast.


Підсумок

Що таке void\*

void* — вказівник без типу, що може зберігати адресу об'єкта будь-якого типу. Неявне перетворення до void* дозволено, але зворотне потребує static_cast.

Що заборонено

  • Пряме розіменування *voidPtr — заборонено
  • Адресна арифметика voidPtr++ — заборонено
  • Посилання на void (void&) — не існує

Головна небезпека

void* обходить систему типів C++. Компілятор не перевірить коректність static_cast — відповідальність повністю на розробнику.

Сучасні альтернативи

Перевантаження функцій і шаблони (template<typename T>) вирішують ту ж задачу безпечно. Надавайте їм перевагу в будь-якому новому C++-коді.

У наступній статті ми розглянемо вказівники на вказівники (int**) — ще один рівень непрямості, що відкриває можливість створення динамічних двовимірних масивів та є основою для розуміння аргументу char** argv функції main.