C++

Структури у функціях

Три способи передачі структур у функції C++: за значенням, константним посиланням і вказівником. Фабричні функції, функції-трансформації, NRVO та патерни проектування з struct.

Структури у функціях

Проблема масштабування сигнатур

У попередній статті ми побачили, що структура дозволяє об'єднати кілька атрибутів однієї сутності під одним іменем. Але що відбувається, коли ця сутність потрапляє у функцію?

Уявімо функцію, яка виводить повну інформацію про студента. До появи структур вона виглядала б так:

void printStudent(std::string name, int age, double gpa)
{
    std::cout << name << ", " << age << " р., GPA: " << gpa << "\n";
}

Три параметри — для трьох атрибутів одного студента. Якщо ми вирішимо додати поле email до опису студента, сигнатуру функції доведеться змінювати. Якщо студент описується шістьма атрибутами — сигнатура стає нечитабельною. Кожне місце виклику функції у коді потребує передачі шести аргументів у правильному порядку — і компілятор не гарантує, що вони не переплутані.

Після введення структури Student сигнатура природно спрощується до:

void printStudent(Student s) { ... }

Але тут одразу виникає питання, яке є центральним у цій статті: як саме передається структура — яким чином вона потрапляє у функцію, що відбувається з її даними, і чи є різниця між різними способами передачі?

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

Спосіб 1: Передача за значенням

Що відбувається у пам'яті

Коли структура передається за значенням (void f(Student s)), компілятор створює повну копію об'єкта у стековому фреймі функції. Кожне поле оригіналу копіюється у відповідне поле копії. Після завершення функції копія знищується; оригінал залишається незмінним.

ByValue.cpp
#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;
}
./ByValue
$ ./ByValue
Вітаємо Олена Коваль з 21-річчям!
Вік у main(): 20
Execution finished with exit code 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&.

Коли передача за значенням доречна

Незважаючи на потенційну вартість, є ситуації, де передача за значенням є семантично правильним і часто оптимальним вибором:

  1. Невеликі структури з примітивними полямиstruct Point2D { double x, y; }, struct Color { uint8_t r, g, b; }. Копіювання дешеве, і компілятор може передати таку структуру через регістри процесора, уникаючи стеку взагалі.
  2. Функція потребує власної копії — якщо функція планує модифікувати дані для власних потреб (як celebrateBirthday вище), отримання копії є правильним дизайном.
  3. Функція-трансформація — отримує структуру, створює нову, повертає нову. Тут «вхідна» копія і є основою для обчислення.

Спосіб 2: Передача за константним посиланням (const T&)

Семантика «лише читати»

Передача за константним посиланням (const Student&) є найпоширенішим і найрекомендованішим способом передачі структур у C++. Замість копії функція отримує адресу оригінального об'єкта, але з гарантією, що через цей доступ нічого не можна змінити — const забороняє будь-яке присвоєння полям.

ByConstRef.cpp
#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;
}
./ByConstRef
$ ./ByConstRef
Олена Коваль, 20 р., GPA: 3.9
Стипендія: 3900 грн
Execution finished with exit code 0.

Жодного копіювання — функція отримує лише адресу (8 байт на 64-бітній платформі), незалежно від розміру структури. Жодного ризику випадкової модифікації — const перетворює спробу запису у помилку компіляції.

Емпіричне правило: якщо функція лише читає дані структури — завжди передавайте const T&. Якщо структура менша за розмір вказівника (тобто ≤ 8 байт на 64-бітній системі) — передача за значенням може бути ефективнішою через оптимізації регістрів. Але для структур з std::string чи будь-якими динамічними полями — const& завжди.

Спосіб 3: Передача за посиланням (T&) — мутатор

Якщо функція повинна змінити оригінальний об'єкт — передається неконстантне посилання (Student&). Це явно сигналізує читачу коду: «ця функція модифікує об'єкт».

ByRef.cpp
#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;
}
./ByRef
$ ./ByRef
До: GPA = 3.2
Після: GPA = 3.7
Вік: 22
Execution finished with exit code 0.

Спосіб 4: Передача за вказівником (T*)

Передача за вказівником (Student*) семантично схожа на передачу за посиланням — жодного копіювання, оригінал доступний для модифікації. Ключова відмінність: вказівник може бути nullptr, що дозволяє явно виражати концепцію «відсутнього об'єкта».

ByPointer.cpp
#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;
}
./ByPointer
$ ./ByPointer
Знайдено: Максим Дяченко, GPA: 3.4
Студента не знайдено.
Execution finished with exit code 0.

Зверніть на рядок 33: перед використанням found->name обов'язкова перевірка found != nullptr. Пропуск цієї перевірки — невизначена поведінка (UB).

Порівняльна таблиця трьох способів

ХарактеристикаЗа значенням TЗа const T&За T& або T*
Копіювання✅ Так❌ Ні❌ Ні
Може змінити оригінал❌ Ні❌ Ні✅ Так
Може бути nullptr❌ НіТільки T*
Рекомендований для читанняМалі struct✅ Будь-які
Семантика«Моя копія»«Лише дивлюсь»«Змінюю оригінал»

Повернення структури з функції

Базовий синтаксис

Функція може повертати структуру так само, як і будь-який інший тип — через return:

ReturnStruct.cpp
#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;
}
./ReturnStruct
$ ./ReturnStruct
Олена Коваль | GPA: 3.9
Максим Дяченко | GPA: 3.4
Execution finished with exit code 0.

Named Return Value Optimization (NRVO)

Інтуїція підказує: повертати структуру за значенням — дорого, адже відбувається копіювання. Але це хибне уявлення. Сучасні компілятори реалізують оптимізацію під назвою NRVO (Named Return Value Optimization): якщо функція створює локальну змінну і повертає саме її — компілятор конструює об'єкт безпосередньо у пам'яті викликача, минаючи будь-яке копіювання.

Іншими словами, при Student s1 = createStudent(...) компілятор розуміє: «об'єкт s усередині createStudent і s1 у main() — це фактично одне місце у пам'яті». Копіювання не відбувається. Це обов'язкова оптимізація для випадку повернення безіменного тимчасового об'єкта (RVO), і практично завжди застосовується для іменованих об'єктів (NRVO).

Завдяки NRVO/RVO, повертати структури за значенням у C++ — не лише зручно, а й ефективно. Не пишіть функції, що приймають вихідний параметр-вказівник void createStudent(Student* out, ...) заради «уникнення копіювання» — компілятор справляється без вас.

Патерн «Фабрична функція»

Мотивація

Поки у структури немає конструктора (конструктори — це тема класів), виникає потреба у зручному способі створення «правильно ініціалізованих» об'єктів з перевіркою вхідних даних. Відповідь — фабрична функція (factory function): звичайна вільна функція, що перевіряє вхідні дані, конструює структуру і повертає її.

Factory.cpp
#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;
}
./Factory
$ ./Factory
Олена Коваль, вік 20, GPA: 3.9
Помилка: ім'я не може бути порожнім
Студента не створено.
Помилка: некоректний вік 15
Студента не створено.
Execution finished with exit code 0.

Фабрична функція виконує дві речі, яких не робить агрегатна ініціалізація: валідацію вхідних даних і єдину точку конструювання. Якщо правила валідації зміняться — міняємо лише фабрику, а не кожне місце, де створюється Student.


Патерн «Функція-трансформація»

Трансформація — функція, що приймає структуру і повертає нову структуру зі зміненими полями, не модифікуючи оригінал. Це особливо природно для математичних об'єктів:

Transform.cpp
#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;
}
./Transform
$ ./Transform
Оригінал: (1, 2, 3)
Зміщена: (6, 2, 2)
Збільшена: (2, 4, 6)
Відстань від origin до moved: 6.63325
Execution finished with exit code 0.

Оригінальна точка p залишається незмінною після будь-якої трансформації — функції повертають нові об'єкти. Завдяки NRVO ці повернення не коштують зайвих копіювань.


Практика

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

Рівень 2 — Мутатори та трансформації

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


Резюме

📤 Передача за значенням

  • Функція отримує повну копію — оригінал недоторканий
  • Дорого для великих структур із std::string
  • Виправдано: маленькі struct, функція потребує своєї копії

🔍 const T& — для читання

  • Жодного копіювання, лише адреса (8 байт)
  • const гарантує: поля не можна змінити
  • Рекомендовано для будь-яких функцій-читачів

✏️ T& і T* — для запису

  • T& — мутатор, не може бути nullptr
  • T* — може бути nullptr (відсутній об'єкт)
  • Явно сигналізує: «ця функція змінює оригінал»

🏭 Патерни

  • Фабрична функція: валідація + конструювання в одному місці
  • Трансформація: повертає нову struct, не змінює оригінал
  • NRVO робить повернення struct ефективним без додаткових зусиль
У наступній статті «Масиви структур і вкладені структури» ми замінимо три паралельних масиви одним масивом структур, дослідимо вкладені struct та побудуємо перший простий зв'язаний список через само-референтну структуру.
Copyright © 2026