Вказівники на функції
Вказівники на функції
Функція як дані: парадигмальний стрибок
До цього моменту ми мали чітке розмежування у свідомості: код — це те, що виконується; дані — це те, що зберігається у змінних і передається між функціями. Вказівники на функції руйнують цю межу. Вони дозволяють трактувати функцію як значення — зберегти її адресу у змінній, передати як аргумент, повернути як результат іншої функції.
Це не просто технічна особливість мови. Це концептуальний стрибок, який відкриває принципово новий спосіб проектування програм — стиль, що в теорії програмування називається функціональним програмуванням (functional programming, FP).
Що таке функціональне програмування і до чого тут C++?
Перш ніж занурюватися в синтаксис, варто зрозуміти ширший контекст. Функціональне програмування — це парадигма, в якій функції є повноцінними «громадянами першого класу» (first-class citizens): їх можна передавати як аргументи, повертати з функцій і зберігати у структурах даних — точнісінько як звичайні числа чи рядки.
Мови на кшталт Haskell, F# або, частково, Python та JavaScript побудовані навколо цієї ідеї з самого початку. У C++ до C++11 функціональний стиль був можливий лише через вказівники на функції — саме тому їх розуміння є фундаментом для всього, що стосується гнучкого проектування на C++.
Процедурне програмування
Функції — це блоки коду з іменами. Вони приймають дані і повертають дані. Самі не є даними.
int add(int a, int b) { return a + b; }
int result = add(3, 5); // виклик функції
Функціональне програмування
Функції — це значення. Їх можна зберігати у змінних, передавати іншим функціям і отримувати як результат.
int (*operation)(int, int) = add; // функція як значення
int result = operation(3, 5); // виклик через вказівник
Практичні наслідки цього переходу колосальні. Ось лише деякі кейси, які стають можливими:
- Callback-функції (функції зворотного виклику) — передача «інструкції» у бібліотечний код.
- Таблиці диспетчеризації — заміна великих
if/elseабоswitchна масив функцій. - Стратегії — алгоритм, чия поведінка залежить від переданої функції (наприклад, спосіб сортування).
- Обробники подій — реакція на дію користувача через вказівник на функцію.
Усе це ми розглянемо детально в цій статті.
Функція як l-value: адреса у пам'яті
Перший крок до розуміння — прийняти факт: функція в пам'яті займає місце. Скомпільований машинний код функції зберігається у сегменті коду (code segment) процесу, і кожна функція має свою унікальну адресу — так само, як кожна змінна.
Ось простий спосіб це побачити:
#include <iostream>
int getValue()
{
return 7;
}
int main()
{
// getValue без дужок — це адреса функції, а не її виклик!
std::cout << reinterpret_cast<void*>(getValue) << '\n';
// Порівняйте:
std::cout << getValue() << '\n'; // 7 — це вже виклик функції
return 0;
}
Найпоширеніша помилка початківців — написати ім'я функції без дужок там, де очікується виклик, або навпаки — поставити дужки там, де потрібна адреса:
int * functionPtr = getValue; // ❌ getValue — адреса, а не int
std::cout << getValue; // ❌ Виводить адресу, а не результат
int result = getValue; // ❌ Присвоюємо адресу, а не значення
Ім'я функції без () — це її адреса. Ім'я функції з () — це її виклик. Ця різниця є основою всього матеріалу статті.
Синтаксис оголошення вказівника на функцію
Тут C++ демонструє один із найбільш неінтуїтивних синтаксисів у всій мові. Підготуйтеся — і прочитайте уважно.
Щоб оголосити вказівник на функцію, потрібно вказати:
- Тип повернення функції, на яку вказуватиме вказівник.
- Ім'я вказівника у дужках зі зірочкою:
(*ім'я). - Типи параметрів функції у дужках справа.
тип_повернення (*ім'я_вказівника)(типи_параметрів);
Розглянемо конкретні приклади:
// Вказівник на функцію без параметрів, що повертає int
int (*getterPtr)();
// Вказівник на функцію з двома int-параметрами, що повертає int
int (*calcPtr)(int, int);
// Вказівник на функцію з двома int-параметрами, що повертає bool
bool (*comparePtr)(int, int);
// Правильна ініціалізація (завжди ініціалізуйте вказівники!)
int (*safePtr)(int, int) = nullptr;
Чому дужки навколо *ім'я обов'язкові? Знову питання пріоритету операторів. Розберемо:
int (*calcPtr)(int, int); // ✅ вказівник на функцію, що повертає int
int *calcPtr (int, int); // ❌ оголошення функції calcPtr, що повертає int*
У другому рядку оператор () (виклик функції) має вищий пріоритет за *, тому без дужок int *calcPtr(int, int) компілятор читає це як: «функція calcPtr, що приймає два int і повертає int*» — тобто прототип функції, а не вказівник. Дужки (*) примушують компілятор спочатку прочитати «це вказівник», а вже потім — «на що він вказує».
Присвоєння та сумісність типів
Вказівник на функцію можна ініціалізувати або переприсвоїти — але лише функцією з абсолютно ідентичною сигнатурою (тип повернення + типи параметрів):
int add(int a, int b)
{
return a + b;
}
double addDouble(double a, double b)
{
return a + b;
}
int square(int x)
{
return x * x;
}
int main()
{
int (*calcPtr)(int, int);
calcPtr = add; // ✅ Сигнатури збігаються: int(int,int)
calcPtr = addDouble; // ❌ Помилка: double(double,double) ≠ int(int,int)
calcPtr = square; // ❌ Помилка: int(int) ≠ int(int,int) — різна к-сть параметрів
return 0;
}
calcPtr = &add; не обов'язково — але допустимо. Зворотне перетворення (вказівник → void*) автоматично не виконується, тому у прикладі вище ми використовували reinterpret_cast.Виклик через вказівник
Існує два синтаксично рівнозначні способи викликати функцію через вказівник:
#include <iostream>
int multiply(int a, int b)
{
return a * b;
}
int main()
{
int (*calcPtr)(int, int) = multiply;
// Спосіб 1: явне розіменування
int result1 = (*calcPtr)(3, 4); // (*calcPtr) — отримуємо функцію, (3, 4) — викликаємо
// Спосіб 2: неявне розіменування (рекомендований стиль)
int result2 = calcPtr(3, 4); // виглядає як звичайний виклик функції
std::cout << result1 << '\n'; // 12
std::cout << result2 << '\n'; // 12
return 0;
}
Обидва способи дають ідентичний результат. Спосіб 2 (неявний) є більш прийнятим у сучасному коді — він виглядає як звичайний виклик функції і не захаращує код зайвими операторами розіменування.
int add(int a, int b = 0), то при виклику через вказівник значення 0 для b не буде підставлено — потрібно завжди передавати всі аргументи явно. Це пов'язано з тим, що параметри за замовчуванням обробляються компілятором статично, а вказівники на функції вирішуються під час виконання.Callback-функції: серцевина патерну
Найважливіше застосування вказівників на функції — передача функції як аргументу іншій функції. Функція, що передається таким чином, називається callback (функція зворотного виклику). Назва відображає суть: ви «реєструєте» функцію, і бібліотечний або алгоритмічний код «повертається» до неї у потрібний момент.
Проблема: як зробити сортування гнучким?
Уявімо завдання: є функція selectionSort, яка сортує масив за зростанням. Тепер потрібно підтримати також сортування за спаданням. Як би ви це вирішили, знаючи лише те, що вивчали досі?
Підхід 1 — дублювання функції через перевантаження:
void selectionSortAscending(int* array, int size) { /* ... */ }
void selectionSortDescending(int* array, int size) { /* ... */ }
Проблема очевидна: дві функції містять ідентичний алгоритм з одним-єдиним рядком різниці — умовою порівняння. Якщо у алгоритмі знайдеться помилка, доведеться виправляти її в обох місцях. Якщо потрібна третя стратегія (evensFirst, сортування за модулем тощо) — додаємо третю копію. Це класичне порушення принципу DRY (Don't Repeat Yourself).
Підхід 2 — параметр-прапорець bool isAscending:
void selectionSort(int* array, int size, bool isAscending)
{
for (int startIndex = 0; startIndex < size; ++startIndex)
{
int bestIndex = startIndex;
for (int currentIndex = startIndex + 1; currentIndex < size; ++currentIndex)
{
bool shouldSwap;
if (isAscending)
shouldSwap = array[bestIndex] > array[currentIndex];
else
shouldSwap = array[bestIndex] < array[currentIndex];
if (shouldSwap)
bestIndex = currentIndex;
}
std::swap(array[startIndex], array[bestIndex]);
}
}
Дублювання коду зникло — але з'явилася нова проблема: алгоритм «знає» про конкретні стратегії. Кожна нова стратегія (наприклад, «парні числа першими») вимагає змінювати тіло selectionSort — додавати ще одне значення прапорця або перетворювати bool на int. Функція розростається, стає крихкою, і порушує ще один принцип проектування: відкритість до розширення, закритість до модифікації.
Крім того, bool isAscending погано масштабується: якщо стратегій стає 5, параметр перетворюється на int mode з магічними значеннями 0, 1, 2... — і ми повертаємося до магічних чисел, від яких щойно рятувалися через enum.
Рішення — передати саму логіку порівняння як функцію. Нехай selectionSort не знає що порівнювати — нехай вона отримує ззовні готову функцію-стратегію і просто викликає її у потрібний момент. Саме це і дають вказівники на функції.
Класичний приклад — сортування з кастомним порівнювачем. Ідея: алгоритм сортування визначає механізм (як переставляти елементи), а callback визначає стратегію (коли переставляти — за зростанням, за спаданням, за абсолютним значенням тощо).
#include <iostream>
#include <utility> // для std::swap
// Функція сортування вибором з кастомним порівнювачем
void selectionSort(int* array, int size, bool (*compare)(int, int))
{
for (int startIndex = 0; startIndex < size; ++startIndex)
{
int bestIndex = startIndex;
for (int currentIndex = startIndex + 1; currentIndex < size; ++currentIndex)
{
// Делегуємо рішення про перестановку callback-функції
if (compare(array[bestIndex], array[currentIndex]))
bestIndex = currentIndex;
}
std::swap(array[startIndex], array[bestIndex]);
}
}
// Стратегія 1: сортування за зростанням
bool ascending(int a, int b)
{
return a > b; // переставляти, якщо перший більший за другий
}
// Стратегія 2: сортування за спаданням
bool descending(int a, int b)
{
return a < b; // переставляти, якщо перший менший за другий
}
// Допоміжна функція виводу масиву
void printArray(int* array, int size)
{
for (int i = 0; i < size; ++i)
std::cout << array[i] << ' ';
std::cout << '\n';
}
int main()
{
int numbers[] = { 4, 8, 5, 6, 2, 3, 1, 7 };
// Використовуємо функцію descending як callback
selectionSort(numbers, 8, descending);
printArray(numbers, 8); // 8 7 6 5 4 3 2 1
// Тепер передаємо ascending — алгоритм не змінився, лише стратегія
selectionSort(numbers, 8, ascending);
printArray(numbers, 8); // 1 2 3 4 5 6 7 8
return 0;
}
Розбір архітектурного рішення. Зверніть увагу на рядок 5: bool (*compare)(int, int) — це параметр функції selectionSort. Він приймає будь-яку функцію з сигнатурою bool(int, int), не знаючи нічого про її конкретну реалізацію. У рядках 43 і 47 ми передаємо відповідно descending і ascending — не як виклики, а як адреси функцій.
Ключовий момент рядка 13: compare(array[bestIndex], array[currentIndex]) — алгоритм не знає чого саме він порівнює. Рішення про перестановку повністю делеговане зовнішньому коду. Саме це і є callback-патерн: алгоритмічний «каркас» залишається незмінним, а поведінка налаштовується ззовні.
Додаємо третю стратегію без зміни алгоритму
Потужність підходу стає особливо помітною, коли ми додаємо нову поведінку, жодного рядка в selectionSort не змінюючи:
// Стратегія 3: спочатку парні числа, потім непарні; всередині груп — за зростанням
bool evensFirst(int a, int b)
{
bool aIsEven = (a % 2 == 0);
bool bIsEven = (b % 2 == 0);
if (aIsEven && !bIsEven)
return false; // a парне, b непарне — a йде першим, не міняємо
if (!aIsEven && bIsEven)
return true; // a непарне, b парне — b має йти першим, міняємо
return ascending(a, b); // в одній групі — за зростанням
}
int main()
{
int numbers[] = { 4, 8, 6, 3, 1, 2, 5, 7 };
selectionSort(numbers, 8, evensFirst);
printArray(numbers, 8); // 2 4 6 8 1 3 5 7
return 0;
}
Нова логіка evensFirst не торкнулася жодного рядка в selectionSort. Це — відкритість до розширення і закритість до модифікації (один з фундаментальних принципів проектування software).
Фільтрація: callback-механізм у дії
Розглянемо ще один класичний кейс, де callback-патерн розкривається з усією силою — фільтрація масиву. Завдання просте: з вихідного масиву чисел відібрати лише ті елементи, що задовольняють певну умову, і записати їх у новий масив.
Умова фільтрації буде мінятися — саме її ми і передаватимемо як callback. В теорії функціонального програмування таку функцію-умову називають предикатом (predicate) — функцією, що повертає true або false.
Проблема: як зробити фільтрацію гнучкою?
Уявімо завдання: є функція filterEven, яка відбирає парні числа. Тепер клієнт хоче також фільтрувати непарні, позитивні, числа більше 10... Як би ви це вирішили без вказівників на функції?
Підхід 1 — окрема функція для кожного критерію:
int filterEven(int* source, int sourceSize, int* dest) { /* ... */ }
int filterOdd(int* source, int sourceSize, int* dest) { /* ... */ }
int filterPositive(int* source, int sourceSize, int* dest) { /* ... */ }
Тіло кожної функції ідентичне — for-цикл, if, запис у dest, лічильник. Єдина різниця — умова в if. Це порушення DRY: один алгоритм дубльований у кількох місцях. Додаємо новий критерій — додаємо нову копію.
Підхід 2 — параметр-рядок або числовий режим:
// Режими: 0 = парні, 1 = непарні, 2 = позитивні, 3 = більше 10
int filterArray(int* source, int sourceSize, int* dest, int mode)
{
int destCount = 0;
for (int i = 0; i < sourceSize; ++i)
{
bool keep = false;
if (mode == 0) keep = (source[i] % 2 == 0);
if (mode == 1) keep = (source[i] % 2 != 0);
if (mode == 2) keep = (source[i] > 0);
if (mode == 3) keep = (source[i] > 10);
if (keep)
dest[destCount++] = source[i];
}
return destCount;
}
Дублювання зникло, але виникла нова проблема: filterArray стала сховищем бізнес-логіки. Кожна нова умова фільтрації вимагає змінювати тіло функції. Рядок if (mode == 4) keep = ... — це і є магічне число. Якщо критеріїв стає десять, функція перетворюється на неохопний switch з умовами, про які вона «не повинна знати».
Крім того, int mode нічого не говорить читачу: filterArray(source, 10, dest, 3) — що означає 3? Щоб зрозуміти — потрібно шукати коментар або документацію.
Рішення — передати саму умову як функцію. Нехай filterArray взагалі не знає, за яким критерієм відбирати. Вона лише ітерується по масиву і запитує у переданої функції: «цей елемент залишати?». Умова повністю делегована зовнішньому коду — саме так і працює предикат-callback.
Крок 1: визначаємо сигнатуру предиката
Предикат для фільтрації цілих чисел — це функція, що приймає одне int і повертає bool:
bool (*predicate)(int)
Читається: «вказівник на функцію, що приймає int і повертає bool». Це і буде наш callback-параметр.
Крок 2: реалізуємо filterArray
Зупинімося і подумаємо над сигнатурою функції. Нам потрібно:
- Прийняти вхідний масив і його розмір.
- Прийняти вихідний масив (куди запишемо відфільтровані елементи).
- Прийняти предикат — умову відбору.
- Повернути кількість знайдених елементів, щоб викликаючий код знав, скільки з вихідного масиву реально заповнено.
#include <iostream>
// Фільтрує елементи масиву source за умовою predicate.
// Результати записуються у масив dest.
// Повертає кількість відфільтрованих елементів.
int filterArray(int* source, int sourceSize, int* dest, bool (*predicate)(int))
{
int destCount = 0; // лічильник знайдених елементів
for (int i = 0; i < sourceSize; ++i)
{
// Передаємо черговий елемент у predicate.
// predicate сам вирішує: залишати чи ні.
if (predicate(source[i]))
{
dest[destCount] = source[i]; // копіюємо елемент у вихідний масив
++destCount; // збільшуємо лічильник
}
}
return destCount; // повідомляємо, скільки елементів відібрано
}
// --- Предикати --- //
bool isEven(int n)
{
return n % 2 == 0; // парне число
}
bool isOdd(int n)
{
return n % 2 != 0; // непарне число
}
bool isPositive(int n)
{
return n > 0; // більше нуля
}
bool isGreaterThanTen(int n)
{
return n > 10; // більше 10
}
// Допоміжна функція для виводу масиву
void printArray(int* array, int size)
{
for (int i = 0; i < size; ++i)
std::cout << array[i] << ' ';
std::cout << '\n';
}
int main()
{
int source[] = { -5, 12, 3, 8, -1, 20, 7, 4, 15, -2 };
const int SOURCE_SIZE = 10;
int dest[SOURCE_SIZE]; // вихідний масив — максимум стільки ж, скільки у вхідному
int count = 0;
// Фільтруємо парні числа
count = filterArray(source, SOURCE_SIZE, dest, isEven);
std::cout << "Парні (" << count << "): ";
printArray(dest, count);
// Фільтруємо непарні числа
count = filterArray(source, SOURCE_SIZE, dest, isOdd);
std::cout << "Непарні (" << count << "): ";
printArray(dest, count);
// Фільтруємо позитивні числа
count = filterArray(source, SOURCE_SIZE, dest, isPositive);
std::cout << "Позитивні (" << count << "): ";
printArray(dest, count);
// Фільтруємо числа більше 10
count = filterArray(source, SOURCE_SIZE, dest, isGreaterThanTen);
std::cout << "Більше 10 (" << count << "): ";
printArray(dest, count);
return 0;
}
Розбір по рядках
Рядок 6. Сигнатура filterArray:
int* source, int sourceSize— вхідний масив і його розмір.int* dest— вихідний масив, куди ми записуємо відфільтровані елементи. Його виділяє викликаючий код — нам достатньо знати його адресу.bool (*predicate)(int)— callback-параметр.filterArrayне знає нічого про логіку фільтрації; вся умова відбору захована у цій функції.- Повертає
int— кількість елементів, що потрапили уdest.
Рядок 8. int destCount = 0 — ми будемо нарощувати цей лічильник щоразу, коли предикат повертає true. Він же слугує індексом запису у dest.
Рядок 13. if (predicate(source[i])) — це серце алгоритму. filterArray делегує рішення про те, чи включати елемент у результат, зовнішній функції. Сам алгоритм нічого не знає: він лише запитує предикат і діє відповідно до відповіді.
Рядок 15. dest[destCount] = source[i] — записуємо елемент у вихідний масив за поточним лічильником, потім збільшуємо лічильник. Таким чином destCount одночасно є і кількістю записаних елементів, і наступним вільним індексом.
Рядок 64. int dest[SOURCE_SIZE] — ми виділяємо масив максимально можливого розміру (рівного вхідному). У найгіршому випадку всі елементи пройдуть фільтр — тоді нам знадобиться стільки ж місця. Реальна кількість записаних елементів повернеться через count.
Візуалізація потоку даних
Потужність підходу: жоден рядок filterArray не змінюється
Додамо ще один предикат — «число ділиться на 3» — і переконаємося, що алгоритм фільтрації не потребує жодних змін:
bool isDivisibleByThree(int n)
{
return n % 3 == 0;
}
bool isBetweenFiveAndFifteen(int n)
{
return n >= 5 && n <= 15;
}
int main()
{
int source[] = { -5, 12, 3, 8, -1, 20, 7, 4, 15, -2 };
const int SOURCE_SIZE = 10;
int dest[SOURCE_SIZE];
int count = 0;
// Передаємо НОВІ предикати — filterArray не змінилась!
count = filterArray(source, SOURCE_SIZE, dest, isDivisibleByThree);
std::cout << "Ділиться на 3 (" << count << "): ";
printArray(dest, count);
count = filterArray(source, SOURCE_SIZE, dest, isBetweenFiveAndFifteen);
std::cout << "Від 5 до 15 (" << count << "): ";
printArray(dest, count);
return 0;
}
Саме так і виглядає відкритість до розширення: нові вимоги до фільтрації реалізуються через нові функції-предикати, а не через редагування filterArray. Алгоритм ізольований від бізнес-логіки.
Псевдонім для предиката
Як ми вивчили, сирий синтаксис bool (*predicate)(int) читається не дуже. Застосуємо using, щоб зробити код самодокументованим:
using IntPredicate = bool(*)(int);
// Тепер підпис filterArray читається як природна мова:
int filterArray(int* source, int sourceSize, int* dest, IntPredicate predicate);
IntPredicate — ємна назва, що одразу передає сенс: «умова (predicate) для цілого числа (int)». Порівняйте з bool (*predicate)(int) — сенс той самий, але читабельність принципово різна.
Значення за замовчуванням для вказівника на функцію
Так само, як звичайні параметри можуть мати значення за замовчуванням, параметр-вказівник на функцію теж може:
// За замовчуванням сортуємо за зростанням
void selectionSort(int* array, int size, bool (*compare)(int, int) = ascending)
{
// тіло функції
}
int main()
{
int numbers[] = { 5, 3, 1, 4, 2 };
selectionSort(numbers, 5); // використовує ascending за замовчуванням
selectionSort(numbers, 5, descending); // явно передаємо descending
return 0;
}
= ascending) — це нормально, але параметри за замовчуванням функції, на яку вказує вказівник, при виклику через нього — не спрацюють. Це різні речі.Таблиця диспетчеризації: масив функцій
Вказівники на функції можна зберігати у масивах. Це відкриває шлях до елегантної заміни великих if-else або switch-конструкцій — паттерн, відомий як таблиця диспетчеризації (dispatch table або jump table).
Класичний приклад — калькулятор, де операція вибирається оператором:
#include <iostream>
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) { return a / b; }
int main()
{
// Масив із 4 вказівників на функції з однаковою сигнатурою int(int,int)
int (*operations[4])(int, int) = { add, subtract, multiply, divide };
int a = 10;
int b = 3;
// Індекс 0 → add, 1 → subtract, 2 → multiply, 3 → divide
for (int i = 0; i < 4; ++i)
{
std::cout << operations[i](a, b) << '\n';
}
return 0;
}
Порівняйте, як виглядав би цей код зі switch:
int applyOperation(int a, int b, int opIndex)
{
switch (opIndex)
{
case 0: return add(a, b);
case 1: return subtract(a, b);
case 2: return multiply(a, b);
case 3: return divide(a, b);
default: return 0;
}
}
// Масив функцій — визначаємо один раз
int (*operations[4])(int, int) = { add, subtract, multiply, divide };
int applyOperation(int a, int b, int opIndex)
{
return operations[opIndex](a, b); // один рядок замість switch
}
З таблицею диспетчеризації додавання нової операції зводиться до додавання одного рядка в масив — без зміни логіки диспетчеризації.
Живий калькулятор: таблиця диспетчеризації у повній красі
Абстрактні приклади — добре, але давайте подивимося, як таблиця диспетчеризації виглядає у справжній інтерактивній програмі. Наступний калькулятор приймає введення від користувача у циклі і обирає операцію через масив вказівників — жодного switch, жодного if-else по операціях.
Ідея без вказівників — наївний підхід, який ви, можливо, написали б раніше:
// ❌ Підхід через switch: кожна нова операція — нова гілка
double calculate(double a, double b, char op)
{
switch (op)
{
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/': return a / b;
default: return 0;
}
}
Щоразу, коли треба додати % або ^ — відкриваємо calculate, правимо switch. Логіка диспетчеризації і логіка обчислення перемішані в одному місці.
Рішення — повноцінний калькулятор через таблицю диспетчеризації:
#include <iostream>
// --- Операції --- //
double add(double a, double b) { return a + b; }
double subtract(double a, double b) { return a - b; }
double multiply(double a, double b) { return a * b; }
double divide(double a, double b)
{
if (b == 0.0)
{
std::cout << "Помилка: ділення на нуль!\n";
return 0.0;
}
return a / b;
}
// --- Таблиця диспетчеризації --- //
const int OPERATION_COUNT = 4;
// Символи, що відповідають кожній операції
char operationSymbols[OPERATION_COUNT] = { '+', '-', '*', '/' };
// Масив вказівників на функції — індекс відповідає символу
double (*operations[OPERATION_COUNT])(double, double) =
{
add,
subtract,
multiply,
divide,
};
// Повертає індекс операції за символом, або -1 якщо невідомий
int findOperation(char op)
{
for (int i = 0; i < OPERATION_COUNT; ++i)
{
if (operationSymbols[i] == op)
return i;
}
return -1; // операція не знайдена
}
int main()
{
std::cout << "=== Калькулятор ===\n";
std::cout << "Підтримувані операції: + - * /\n";
std::cout << "Введіть 'q' замість операції для виходу.\n\n";
double a, b;
char op;
while (true)
{
std::cout << "Введіть: число1 операція число2\n> ";
std::cin >> a >> op;
if (op == 'q')
{
std::cout << "До побачення!\n";
break;
}
std::cin >> b;
int index = findOperation(op);
if (index == -1)
{
std::cout << "Невідома операція: " << op << "\n\n";
continue;
}
// Один рядок замість switch — викликаємо потрібну функцію через таблицю
double result = operations[index](a, b);
std::cout << a << " " << op << " " << b << " = " << result << "\n\n";
}
return 0;
}
Розбір ключових рішень.
- Рядки 7–10.
operationSymbols[]іoperations[]— два паралельних масиви однакового розміру. Індексiу першому відповідає індексуiу другому:'+'→add,'-'→subtractі так далі. Це і є таблиця диспетчеризації. - Рядки 30–39.
findOperation— єдине місце, де символ перетворюється на індекс. Вся логіка «який символ відповідає якій функції» сконцентрована тут, а не розкидана поswitch-гілках. - Рядок 69.
double result = operations[index](a, b)— це вся логіка диспетчеризації. Один рядок замість чотирьохcase. Індексindexвизначає, яку саме функцію викликати, алеmain()про це не знає — він просто використовує результат. - Додавання нової операції (наприклад,
%— залишок від ділення) вимагає три зміни: написати функціюmodulo, додати'%'уoperationSymbols, додатиmoduloуoperations. ЛогікуfindOperationіmain()— не чіпаємо.
OPERATION_COUNT та обидва масиви operationSymbols і operations винести в окремий файл конфігурації або структуру, то додавання нової операції взагалі не вимагатиме зміни жодної функції з бізнес-логікою. Це і є справжня розширюваність.Потворний синтаксис і як його приборкати: using
Синтаксис int (*compare)(int, int) є одним з найнечитабельніших у мові C++. Якщо такий тип з'являється кілька разів — це стає справжнім болем. Вирішення — псевдоніми типів через using (сучасний C++11-спосіб, кращий за старий typedef):
// Без псевдоніму — важкочитабельно:
void selectionSort(int* array, int size, bool (*compare)(int, int));
// З using — читається природно:
using CompareFunction = bool(*)(int, int);
// Тепер параметр виглядає як іменований тип:
void selectionSort(int* array, int size, CompareFunction compare);
// Можна зберігати у змінній:
CompareFunction currentStrategy = ascending;
// Масив стратегій:
CompareFunction strategies[2] = { ascending, descending };
Розбір рядка 5. using CompareFunction = bool(*)(int, int); — читається зліва направо: «CompareFunction — це псевдонім для bool(*)(int, int)», тобто вказівника на функцію, що приймає два int і повертає bool. Порівняйте з еквівалентним typedef bool (*CompareFunction)(int, int); — у typedef ім'я ховається всередині, що значно ускладнює читання.
using замість typedef для псевдонімів типів — це сучасна практика C++11 і новіше. Синтаксис using є більш однорідним і читабельним.std::function: сучасний стандарт
C++11 приніс ще більш потужну альтернативу — std::function із заголовку <functional>. Це узагальнена обгортка, що може зберігати будь-який callable об'єкт: звичайну функцію, лямбду (стаття 25), метод класу тощо.
Синтаксис std::function читається набагато природніше:
std::function<тип_повернення(типи_параметрів)>
#include <iostream>
#include <functional> // необхідно підключити
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int main()
{
// Оголошення: функція, що приймає два int і повертає int
std::function<int(int, int)> operation;
operation = add;
std::cout << operation(10, 3) << '\n'; // 13
operation = subtract;
std::cout << operation(10, 3) << '\n'; // 7
return 0;
}
Перепишемо selectionSort з std::function — і подивимося, наскільки читабельніше вийде підпис:
#include <functional>
#include <utility>
// Підпис функції тепер самодокументований
void selectionSort(int* array, int size, std::function<bool(int, int)> compare)
{
for (int startIndex = 0; startIndex < size; ++startIndex)
{
int bestIndex = startIndex;
for (int currentIndex = startIndex + 1; currentIndex < size; ++currentIndex)
{
if (compare(array[bestIndex], array[currentIndex]))
bestIndex = currentIndex;
}
std::swap(array[startIndex], array[bestIndex]);
}
}
Рядок 5 читається буквально: «selectionSort приймає масив, розмір і функцію, яка порівнює два int і повертає bool». Жодного незрозумілого (*).
Порівняння трьох підходів
| Підхід | Синтаксис (параметр) | Читабельність | Гнучкість |
|---|---|---|---|
| Сирий вказівник | bool (*compare)(int, int) | ❌ Складно | Лише звичайні функції |
using-псевдонім | CompareFunction compare | ✅ Добре | Лише звичайні функції |
std::function | std::function<bool(int,int)> compare | ✅ Відмінно | Функції + лямбди + методи |
std::function. Сирі вказівники на функції лишаються актуальними в системному коді, при взаємодії з C API або там, де накладні витрати std::function (мінімальні, але є) неприпустимі.Що відкриває розуміння цієї теми
Ось конкретні патерни і техніки, які стають доступними після опанування вказівників на функції:
selectionSort з compare. Той самий алгоритм — різна поведінка залежно від переданої стратегії.switch. Компілятор і сам іноді генерує jump tables для switch з послідовними значеннями — ви можете зробити це явно для максимального контролю.std::sort, std::find_if, std::transform і десятки інших алгоритмів стандартної бібліотеки приймають callable-параметри. Розуміння концепції callback дозволяє ефективно використовувати весь арсенал STL. Лямбди (стаття 25) ще більше спростять цей підхід.Практика та підсумок
Практичні завдання
Рівень 1 — Базовий
Завдання 1. Оголосіть вказівник calcPtr на функцію, що приймає два int і повертає int. Напишіть функції add, subtract і multiply з такою сигнатурою. Послідовно присвоюйте кожну функцію calcPtr і викликайте через нього з аргументами 10 і 4. Виведіть результати.
Завдання 2. Що виведе наступний код? Без запуску поясніть кожен рядок:
int double_val(int x) { return x * 2; }
int main()
{
int (*fn)(int) = double_val;
std::cout << fn(5) << '\n';
std::cout << (*fn)(7) << '\n';
fn = double_val;
std::cout << fn(fn(3)) << '\n'; // fn викликається двічі!
}
Завдання 3. Оголосіть using-псевдонім Transformer для функції int(int). Напишіть функцію applyToArray(int* array, int size, Transformer fn), що застосовує fn до кожного елемента масиву. Перевірте з функцією, що подвоює число.
Рівень 2 — Логіка
Завдання 4. Реалізуйте функцію findFirst(int* array, int size, bool (*predicate)(int)), що повертає індекс першого елемента, для якого predicate повертає true, або -1. Перевірте з предикатами «є парним» і «більше 10».
Завдання 5. Реалізуйте таблицю диспетчеризації для простого калькулятора:
- Функції
add,subtract,multiply,divide(з перевіркою ділення на нуль). - Масив вказівників
int (*operations[4])(int, int). - Функція
getOperation(char op), що повертає відповідний вказівник із масиву. - У
mainзчитайте два числа та символ операції, знайдіть та викличте відповідну функцію.
Рівень 3 — Архітектура
Завдання 6. Реалізуйте систему «конвеєра обробки даних» (pipeline):
using Transform = int (*)(int);
// Функція apply застосовує масив трансформацій послідовно до числа
int apply(int value, Transform* pipeline, int steps);
Напишіть щонайменше 3 трансформації (double_val, addTen, square). У main створіть конвеєри з різних комбінацій трансформацій і продемонструйте результати:
[double_val, addTen]для числа5→5*2=10→10+10=20[square, double_val]для числа3→3*3=9→9*2=18
Підсумок
Синтаксис
int (*ptr)(int, int) — вказівник ptr на функцію int(int,int). Дужки навколо *ptr обов'язкові через пріоритет операторів.Присвоєння і виклик
ptr = funcName; — зберігаємо адресу. ptr(a, b); або (*ptr)(a, b); — викликаємо. Ніколи не пишіть ptr = func(); — це виклик, а не адреса.Читабельний синтаксис
using MyFunc = int(*)(int, int); — псевдонім типу. std::function<int(int,int)> — гнучка обгортка C++11, що приймає також лямбди.Ключовий патерн
У наступній статті ми розглянемо лямбда-вирази — компактний синтаксис C++11 для визначення анонімних функцій прямо в місці їх використання, що є природним розвитком концепції callback і вказівників на функції.
Цикл for-each (Range-based for)
Вивчіть цикл for-each у C++ — елегантний синтаксис для ітерації по масивах і контейнерах. Дізнайтесь про auto, посилання, const auto&, обмеження з вказівниками та як отримати індекс без старого for.
Лямбда-вирази
Вивчіть лямбда-вирази у C++11 — анонімні функції, що визначаються прямо в місці використання. Синтаксис, типи, auto-параметри, trailing-тип повернення, std::function та функтори STL.