Структури у функціях
Структури у функціях
Проблема масштабування сигнатур
У попередній статті ми побачили, що структура дозволяє об'єднати кілька атрибутів однієї сутності під одним іменем. Але що відбувається, коли ця сутність потрапляє у функцію?
Уявімо функцію, яка виводить повну інформацію про студента. До появи структур вона виглядала б так:
void printStudent(std::string name, int age, double gpa)
{
std::cout << name << ", " << age << " р., GPA: " << gpa << "\n";
}
Три параметри — для трьох атрибутів одного студента. Якщо ми вирішимо додати поле email до опису студента, сигнатуру функції доведеться змінювати. Якщо студент описується шістьма атрибутами — сигнатура стає нечитабельною. Кожне місце виклику функції у коді потребує передачі шести аргументів у правильному порядку — і компілятор не гарантує, що вони не переплутані.
Після введення структури Student сигнатура природно спрощується до:
void printStudent(Student s) { ... }
Але тут одразу виникає питання, яке є центральним у цій статті: як саме передається структура — яким чином вона потрапляє у функцію, що відбувається з її даними, і чи є різниця між різними способами передачі?
Спосіб 1: Передача за значенням
Що відбувається у пам'яті
Коли структура передається за значенням (void f(Student s)), компілятор створює повну копію об'єкта у стековому фреймі функції. Кожне поле оригіналу копіюється у відповідне поле копії. Після завершення функції копія знищується; оригінал залишається незмінним.
#include <iostream>
#include <string>
struct Student
{
std::string name;
int age;
double gpa;
};
// Функція отримує КОПІЮ студента
void celebrateBirthday(Student s)
{
s.age += 1; // змінюємо копію — оригінал не зачіпаємо
std::cout << "Вітаємо " << s.name
<< " з " << s.age << "-річчям!\n";
}
int main()
{
Student student = { "Олена Коваль", 20, 3.9 };
celebrateBirthday(student); // передаємо копію
// Оригінал не змінився
std::cout << "Вік у main(): " << student.age << "\n";
return 0;
}
Рядок 13: s.age += 1 — модифікується копія, яка живе у стеку celebrateBirthday. Рядок 26 підтверджує: вік у main() залишився незмінним — 20.
Вартість копіювання
Для структур із примітивними полями копіювання є тривіальним і дешевим — кілька інструкцій процесора. Але ситуація кардинально змінюється, якщо поле структури містить std::string або масив:
struct Employee
{
std::string firstName; // копіювання рядка: виділення пам'яті
std::string lastName; // ще одне виділення
std::string department; // і ще одне
int yearsWorked;
};
При передачі Employee за значенням компілятор копіює три std::string-об'єкти. Кожен std::string може виконати динамічне виділення пам'яті в хіп. Якщо функція викликається у циклі з тисячами ітерацій — сукупна вартість стає суттєвою.
std::string, динамічними масивами або вкладеними структурами) за значенням у функції, що лише читають дані. Це марна трата ресурсів. Для таких випадків існує const&.Коли передача за значенням доречна
Незважаючи на потенційну вартість, є ситуації, де передача за значенням є семантично правильним і часто оптимальним вибором:
- Невеликі структури з примітивними полями —
struct Point2D { double x, y; },struct Color { uint8_t r, g, b; }. Копіювання дешеве, і компілятор може передати таку структуру через регістри процесора, уникаючи стеку взагалі. - Функція потребує власної копії — якщо функція планує модифікувати дані для власних потреб (як
celebrateBirthdayвище), отримання копії є правильним дизайном. - Функція-трансформація — отримує структуру, створює нову, повертає нову. Тут «вхідна» копія і є основою для обчислення.
Спосіб 2: Передача за константним посиланням (const T&)
Семантика «лише читати»
Передача за константним посиланням (const Student&) є найпоширенішим і найрекомендованішим способом передачі структур у C++. Замість копії функція отримує адресу оригінального об'єкта, але з гарантією, що через цей доступ нічого не можна змінити — const забороняє будь-яке присвоєння полям.
#include <iostream>
#include <string>
struct Student
{
std::string name;
int age;
double gpa;
};
// Жодного копіювання. const гарантує незмінність оригіналу.
void printStudent(const Student& s)
{
std::cout << s.name << ", " << s.age << " р., GPA: " << s.gpa << "\n";
// s.age = 99; // ← помилка компіляції: const не дозволяє запис
}
double calcBonus(const Student& s)
{
if (s.gpa >= 3.5)
return s.gpa * 1000.0; // відмінник — підвищена стипендія
return s.gpa * 500.0;
}
int main()
{
Student student = { "Олена Коваль", 20, 3.9 };
printStudent(student);
std::cout << "Стипендія: " << calcBonus(student) << " грн\n";
return 0;
}
Жодного копіювання — функція отримує лише адресу (8 байт на 64-бітній платформі), незалежно від розміру структури. Жодного ризику випадкової модифікації — const перетворює спробу запису у помилку компіляції.
const T&. Якщо структура менша за розмір вказівника (тобто ≤ 8 байт на 64-бітній системі) — передача за значенням може бути ефективнішою через оптимізації регістрів. Але для структур з std::string чи будь-якими динамічними полями — const& завжди.Спосіб 3: Передача за посиланням (T&) — мутатор
Якщо функція повинна змінити оригінальний об'єкт — передається неконстантне посилання (Student&). Це явно сигналізує читачу коду: «ця функція модифікує об'єкт».
#include <iostream>
#include <string>
struct Student
{
std::string name;
int age;
double gpa;
};
// Функція-мутатор: явно змінює оригінал
void applyScholarship(Student& s, double bonus)
{
s.gpa = std::min(s.gpa + bonus * 0.1, 4.0); // максимум 4.0
}
void birthday(Student& s)
{
s.age += 1;
}
int main()
{
Student student = { "Максим Дяченко", 21, 3.2 };
std::cout << "До: GPA = " << student.gpa << "\n";
applyScholarship(student, 5.0); // scholarship bonus = 5
std::cout << "Після: GPA = " << student.gpa << "\n";
birthday(student);
std::cout << "Вік: " << student.age << "\n";
return 0;
}
Спосіб 4: Передача за вказівником (T*)
Передача за вказівником (Student*) семантично схожа на передачу за посиланням — жодного копіювання, оригінал доступний для модифікації. Ключова відмінність: вказівник може бути nullptr, що дозволяє явно виражати концепцію «відсутнього об'єкта».
#include <iostream>
#include <string>
struct Student
{
std::string name;
int age;
double gpa;
};
// Повертає nullptr якщо студент з таким ім'ям не знайдений
Student* findByName(Student* arr, int size, const std::string& name)
{
for (int i = 0; i < size; ++i)
{
if (arr[i].name == name)
return &arr[i]; // адреса знайденого студента
}
return nullptr; // не знайдено
}
int main()
{
Student group[3] =
{
{ "Олена Коваль", 20, 3.9 },
{ "Максим Дяченко", 22, 3.4 },
{ "Тетяна Бондар", 21, 3.7 },
};
Student* found = findByName(group, 3, "Максим Дяченко");
if (found != nullptr)
std::cout << "Знайдено: " << found->name << ", GPA: " << found->gpa << "\n";
else
std::cout << "Студента не знайдено.\n";
// Пошук неіснуючого студента
Student* notFound = findByName(group, 3, "Іван Петренко");
if (notFound != nullptr)
std::cout << "Знайдено: " << notFound->name << "\n";
else
std::cout << "Студента не знайдено.\n";
return 0;
}
Зверніть на рядок 33: перед використанням found->name обов'язкова перевірка found != nullptr. Пропуск цієї перевірки — невизначена поведінка (UB).
Порівняльна таблиця трьох способів
| Характеристика | За значенням T | За const T& | За T& або T* |
|---|---|---|---|
| Копіювання | ✅ Так | ❌ Ні | ❌ Ні |
| Може змінити оригінал | ❌ Ні | ❌ Ні | ✅ Так |
Може бути nullptr | — | ❌ Ні | Тільки T* |
| Рекомендований для читання | Малі struct | ✅ Будь-які | — |
| Семантика | «Моя копія» | «Лише дивлюсь» | «Змінюю оригінал» |
Повернення структури з функції
Базовий синтаксис
Функція може повертати структуру так само, як і будь-який інший тип — через return:
#include <iostream>
#include <string>
struct Student
{
std::string name;
int age;
double gpa;
};
// Функція повертає нову структуру за значенням
Student createStudent(const std::string& name, int age, double gpa)
{
Student s;
s.name = name;
s.age = age;
s.gpa = gpa;
return s; // повертаємо об'єкт — компілятор оптимізує
}
int main()
{
Student s1 = createStudent("Олена Коваль", 20, 3.9);
Student s2 = createStudent("Максим Дяченко", 22, 3.4);
std::cout << s1.name << " | GPA: " << s1.gpa << "\n";
std::cout << s2.name << " | GPA: " << s2.gpa << "\n";
return 0;
}
Named Return Value Optimization (NRVO)
Інтуїція підказує: повертати структуру за значенням — дорого, адже відбувається копіювання. Але це хибне уявлення. Сучасні компілятори реалізують оптимізацію під назвою NRVO (Named Return Value Optimization): якщо функція створює локальну змінну і повертає саме її — компілятор конструює об'єкт безпосередньо у пам'яті викликача, минаючи будь-яке копіювання.
Іншими словами, при Student s1 = createStudent(...) компілятор розуміє: «об'єкт s усередині createStudent і s1 у main() — це фактично одне місце у пам'яті». Копіювання не відбувається. Це обов'язкова оптимізація для випадку повернення безіменного тимчасового об'єкта (RVO), і практично завжди застосовується для іменованих об'єктів (NRVO).
void createStudent(Student* out, ...) заради «уникнення копіювання» — компілятор справляється без вас.Патерн «Фабрична функція»
Мотивація
Поки у структури немає конструктора (конструктори — це тема класів), виникає потреба у зручному способі створення «правильно ініціалізованих» об'єктів з перевіркою вхідних даних. Відповідь — фабрична функція (factory function): звичайна вільна функція, що перевіряє вхідні дані, конструює структуру і повертає її.
#include <iostream>
#include <string>
struct Student
{
std::string name;
int age;
double gpa;
};
// Фабрична функція: перевіряє і конструює
bool createStudent(const std::string& name, int age, double gpa, Student& out)
{
if (name.empty())
{
std::cout << "Помилка: ім'я не може бути порожнім\n";
return false;
}
if (age < 16 || age > 100)
{
std::cout << "Помилка: некоректний вік " << age << "\n";
return false;
}
if (gpa < 0.0 || gpa > 4.0)
{
std::cout << "Помилка: GPA поза діапазоном [0.0, 4.0]\n";
return false;
}
out = { name, age, gpa };
return true;
}
void printStudent(const Student& s)
{
std::cout << s.name << ", вік " << s.age << ", GPA: " << s.gpa << "\n";
}
int main()
{
Student s;
if (createStudent("Олена Коваль", 20, 3.9, s))
printStudent(s);
if (!createStudent("", 20, 3.9, s))
std::cout << "Студента не створено.\n";
if (!createStudent("Іван", 15, 3.0, s))
std::cout << "Студента не створено.\n";
return 0;
}
Фабрична функція виконує дві речі, яких не робить агрегатна ініціалізація: валідацію вхідних даних і єдину точку конструювання. Якщо правила валідації зміняться — міняємо лише фабрику, а не кожне місце, де створюється Student.
Патерн «Функція-трансформація»
Трансформація — функція, що приймає структуру і повертає нову структуру зі зміненими полями, не модифікуючи оригінал. Це особливо природно для математичних об'єктів:
#include <iostream>
#include <cmath>
struct Point3D
{
double x;
double y;
double z;
};
// Трансформація: повертає нову точку
Point3D translate(const Point3D& p, double dx, double dy, double dz)
{
return { p.x + dx, p.y + dy, p.z + dz };
}
Point3D scale(const Point3D& p, double factor)
{
return { p.x * factor, p.y * factor, p.z * factor };
}
double distanceTo(const Point3D& a, const Point3D& b)
{
double dx = a.x - b.x;
double dy = a.y - b.y;
double dz = a.z - b.z;
return std::sqrt(dx*dx + dy*dy + dz*dz);
}
void print(const Point3D& p)
{
std::cout << "(" << p.x << ", " << p.y << ", " << p.z << ")\n";
}
int main()
{
Point3D origin = { 0.0, 0.0, 0.0 };
Point3D p = { 1.0, 2.0, 3.0 };
Point3D moved = translate(p, 5.0, 0.0, -1.0);
Point3D scaled = scale(p, 2.0);
std::cout << "Оригінал: "; print(p);
std::cout << "Зміщена: "; print(moved);
std::cout << "Збільшена: "; print(scaled);
std::cout << "Відстань від origin до moved: "
<< distanceTo(origin, moved) << "\n";
return 0;
}
Оригінальна точка p залишається незмінною після будь-якої трансформації — функції повертають нові об'єкти. Завдяки NRVO ці повернення не коштують зайвих копіювань.
Практика
Рівень 1 — Базовий
Дано структуру:
struct BankAccount { std::string owner; uint32_t id; double balance; };
Напишіть три функції виключно з const BankAccount&:
void printAccount(const BankAccount& a)— виводить усю інформацію.bool isInDebt(const BankAccount& a)— повертаєtrueякщоbalance < 0.double taxAmount(const BankAccount& a, double rate)— повертаєbalance * rate.
У main() створіть два рахунки (один з від'ємним балансом) і перевірте всі три функції.
struct Point2D { double x; double y; };. Напишіть фабричну функцію Point2D makePoint(double x, double y), що повертає точку (без валідації — просто практика повернення за значенням). Напишіть double magnitude(const Point2D& p) — відстань від початку координат (√(x²+y²)). Напишіть Point2D normalize(const Point2D& p) — повертає нову точку, поділену на magnitude. Виведіть результати.Рівень 2 — Мутатори та трансформації
Дано структура:
struct Score { std::string subject; double points; double maxPoints; };
Напишіть:
double percentage(const Score& s)— відсоток від максимуму.void addBonus(Score& s, double bonus)— мутатор: додає бонусні бали (не більшеmaxPoints).Score combine(const Score& a, const Score& b)— трансформація: повертає новуScoreзі сумою балів і максимумів.
Продемонструйте всі три функції у main().
struct Employee { std::string name; int yearsExp; double salary; };. Напишіть фабричну функцію bool createEmployee(const std::string& name, int years, double salary, Employee& out) з перевірками: ім'я непорожнє, years >= 0, salary >= 0. Якщо перевірка провалилась — поверніть false і виведіть повідомлення. Продемонструйте коректне і некоректне створення.Рівень 3 — Архітектура
Оголосіть:
struct Matrix2x2
{
double a; double b; // перший рядок
double c; double d; // другий рядок
};
Реалізуйте виключно через вільні функції (без методів) весь алгебраїчний інтерфейс:
Matrix2x2 add(const Matrix2x2& m1, const Matrix2x2& m2)— сума.Matrix2x2 multiply(const Matrix2x2& m1, const Matrix2x2& m2)— добуток матриць.Matrix2x2 transpose(const Matrix2x2& m)— транспонована матриця.double determinant(const Matrix2x2& m)— визначникad - bc.void print(const Matrix2x2& m)— виведення у вигляді двох рядків.
Перевірте: добуток матриці на одиничну дає ту саму матрицю. Виведіть визначник матриці {1,2,3,4}.
Резюме
📤 Передача за значенням
- Функція отримує повну копію — оригінал недоторканий
- Дорого для великих структур із
std::string - Виправдано: маленькі struct, функція потребує своєї копії
🔍 const T& — для читання
- Жодного копіювання, лише адреса (8 байт)
constгарантує: поля не можна змінити- Рекомендовано для будь-яких функцій-читачів
✏️ T& і T* — для запису
T&— мутатор, не може бутиnullptrT*— може бутиnullptr(відсутній об'єкт)- Явно сигналізує: «ця функція змінює оригінал»
🏭 Патерни
- Фабрична функція: валідація + конструювання в одному місці
- Трансформація: повертає нову struct, не змінює оригінал
- NRVO робить повернення struct ефективним без додаткових зусиль
struct та побудуємо перший простий зв'язаний список через само-референтну структуру.Структури (struct): агрегування даних
Що таке агрегатні типи даних у C++, навіщо потрібні структури, як оголошувати struct, ініціалізувати поля, працювати з оператором крапка, та чому вирівнювання пам
Масиви структур і вкладені структури
Масиви структур як заміна паралельним масивам, пошук і сортування колекцій, вкладені struct, динамічні масиви структур та само-референтна структура як основа зв