C++

Структури (struct): агрегування даних

Що таке агрегатні типи даних у C++, навіщо потрібні структури, як оголошувати struct, ініціалізувати поля, працювати з оператором крапка, та чому вирівнювання пам

Структури (struct): агрегування даних

Межа примітивних типів

До цього моменту ми оперували примітивними типами даних: int, double, bool, char. Кожен із них чудово описує одиничне значення — ціле число, дійсне число, логічне значення, символ. Вони є атомарними будівельними блоками мови. Але реальний світ рідко складається з атомарних, ізольованих значень.

Уявімо конкретне завдання: ви розробляєте систему управління університетом. Кожен студент у цій системі характеризується набором атрибутів — ім'я, вік, середній бал успішності (GPA). Як ви представите одного студента у коді, маючи у своєму розпорядженні лише примітивні типи?

Перше, що спадає на думку — завести по одній змінній на кожен атрибут:

std::string studentName = "Олена Коваль";
int         studentAge  = 20;
double      studentGpa  = 3.9;

Цей підхід прийнятний рівно доти, поки студент один і операцій із ним небагато. Але вже при першій спробі передати студента у функцію картина змінюється:

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

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

Наступна проблема виникає при спробі зберегти колекцію студентів:

const int MAX_STUDENTS = 30;

std::string names[MAX_STUDENTS];
int         ages[MAX_STUDENTS];
double      gpas[MAX_STUDENTS];

Тепер три окремих масиви, пов'язаних виключно тим, що програміст тримає в голові: «індекс i у всіх трьох масивах відповідає одному студенту». Компілятор про цей зв'язок нічого не знає. Варто відсортувати names — і синхронність між масивами порушується: ім'я під індексом 0 більше не відповідає віку під індексом 0.

Описана проблема у програмуванні має назву розрив зчеплення даних (poor data cohesion). Коли дані, що логічно належать одній сутності, розосереджені по різних незалежних змінних, будь-яка операція над цією сутністю (копіювання, сортування, передача) вимагає ручної координації між усіма її частинами. Це — постійне джерело помилок.

Програмування як моделювання реальності

Щоб зрозуміти структури глибше, ніж просто «нова синтаксична конструкція», необхідно осмислити ширший принцип, на якому ґрунтується вся архітектура програмного забезпечення.

Програмування — це моделювання. Ми беремо явища, поняття або об'єкти реального (або уявного) світу і будуємо їх цифрові відображення у вигляді структур даних і алгоритмів. Студент, банківський рахунок, піксель на екрані, точка у тривимірному просторі, мережевий пакет — все це сутності (entities). Кожна сутність характеризується набором атрибутів (attributes) — властивостей, що описують її стан — і може виконувати певні дії (behavior).

Примітивні типи — це атоми нашої моделі. Вони ідеально описують прості атрибути: «вік — ціле число», «середній бал — дійсне число». Але сутність — це завжди сукупність атрибутів, і ця сукупність повинна бути представлена у коді як єдина, неподільна одиниця.

Мовам програмування потрібен механізм агрегування — об'єднання кількох значень різних типів під одним іменем, щоб вони поводилися як єдине ціле. Саме цей механізм у C++ реалізований через структуру (struct).

Коли ви записуєте Student s, компілятор і будь-який читач коду розуміє: s — це єдиний об'єкт, що несе ім'я, вік і середній бал разом і нероздільно. Передати студента у функцію — один аргумент. Зберегти групу студентів — один масив. Відсортувати — всі атрибути рухаються разом, як нерозривне ціле.

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

Що таке структура: академічне визначення

Структура (structure) — це агрегатний тип даних (aggregate data type), що об'єднує нуль або більше іменованих змінних, потенційно різних типів, в єдину одиницю зберігання з одним іменем. Кожна з цих внутрішніх змінних називається полем або членом-даних (data member, field).

Після оголошення структури вона стає повноправним типом даних: ви можете оголошувати змінні цього типу, передавати їх у функції та повертати з функцій, зберігати в масивах, створювати динамічно у вільній пам'яті — так само, як це робиться з вбудованими типами int або double. Компілятор обробляє структуру як єдину семантичну одиницю.

Принципово важливо розрізняти дві речі:

  • Оголошення типу (struct Student { ... };) — це опис форми, «шаблон». Воно не виділяє пам'яті. Компілятор лише дізнається, що існує тип з ім'ям Student і певним набором полів.
  • Визначення змінної (Student s;) — це створення конкретного екземпляра. Саме тут виділяється пам'ять для зберігання всіх полів.

Аналогія: оголошення структури — це кресленик будинку. Визначення змінної — це побудований будинок за цим кресленем. Один кресленик може породжувати скільки завгодно будинків, кожен із власним незалежним вмістом.


Синтаксис оголошення структури

Загальна форма оголошення структури:

struct ІменьТипу
{
    тип1 іменьПоля1;
    тип2 іменьПоля2;
    // ...
};  // крапка з комою — обов'язкова частина синтаксису

Два аспекти, що найчастіше викликають питання у новачків.

По-перше, крапка з комою після закриваючої фігурної дужки};. Це не помилка і не особливість форматування. Оголошення структури є декларацією типу, а всі декларації в C++ завершуються крапкою з комою. На відміну від тіла функції, що закінчується просто }, тіло структури — це частина декларації, яка повинна бути завершена ;. Пропуск цієї крапки з комою призводить до помилки компіляції, яку іноді важко ідентифікувати, бо компілятор може поскаржитися на наступний рядок після оголошення.

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

Розглянемо повноцінний перший приклад:

Student.cpp
#include <iostream>
#include <string>

// Оголошення типу Student — пам'ять не виділяється
struct Student
{
    std::string name; // ім'я студента
    int         age;  // вік у роках
    double      gpa;  // середній бал (Grade Point Average), 0.0–4.0
};

int main()
{
    // Визначення змінної типу Student — тут виділяється пам'ять
    Student s1;

    // Доступ до полів через оператор «крапка» (.)
    s1.name = "Олена Коваль";
    s1.age  = 20;
    s1.gpa  = 3.9;

    std::cout << s1.name << ", вік: " << s1.age
              << ", GPA: " << s1.gpa << "\n";

    // Другий екземпляр — незалежний набір даних
    Student s2;
    s2.name = "Максим Дяченко";
    s2.age  = 22;
    s2.gpa  = 3.4;

    std::cout << s2.name << ", вік: " << s2.age
              << ", GPA: " << s2.gpa << "\n";

    return 0;
}
./Student
$ ./Student
Олена Коваль, вік: 20, GPA: 3.9
Максим Дяченко, вік: 22, GPA: 3.4
Execution finished with exit code 0.

Зверніть на рядки 27–31: s2 — це окремий об'єкт у пам'яті. Присвоєння полів s1 жодним чином не впливає на s2. Кожен екземпляр структури отримує власний, незалежний набір значень для всіх полів.

Правила іменування

Ім'я структури є іменем нового типу. Відповідно до code style цього курсу (що наслідує Java-конвенції), типи іменуються у PascalCase — кожне слово з великої літери, без підкреслень:

  • Student, Point3D, BankAccount, NetworkPacket, ParseResult

Поля структури — це змінні, тому вони іменуються у camelCase — перше слово з малої літери, подальші з великої:

  • firstName, accountBalance, sequenceNumber, isActive

Ця конвенція відразу візуально відрізняє ім'я типу (Student) від імені змінної цього типу (student або s) і від імені поля (student.firstName).


Ініціалізація структур

Спосіб 1 — Присвоєння після оголошення

Найпростіший спосіб — оголосити змінну, а потім окремо присвоїти кожне поле. Саме цей спосіб демонстрував попередній приклад. Він завжди доступний і не залежить від стандарту C++.

Недолік: між оголошенням Student s; і першим присвоєнням поля s.name = ... об'єкт існує у невизначеному стані — числові поля містять «сміттєві» значення з пам'яті. Якщо код між оголошенням і ініціалізацією поверне результат або викине виключення, ви можете отримати некоректний об'єкт.

Спосіб 2 — Агрегатна ініціалізація (C++11 і раніше)

Агрегатна ініціалізація дозволяє задати значення всіх полів одразу, у момент оголошення змінної:

AggregateInit.cpp
#include <iostream>
#include <string>

struct Student
{
    std::string name;
    int         age;
    double      gpa;
};

int main()
{
    // Усі три поля ініціалізуються одразу — об'єкт одразу у коректному стані
    Student s1 = { "Олена Коваль", 20, 3.9 };

    // Починаючи з C++11 фігурні дужки без = також коректні:
    Student s2 { "Максим Дяченко", 22, 3.4 };

    std::cout << s1.name << " | GPA: " << s1.gpa << "\n";
    std::cout << s2.name << " | GPA: " << s2.gpa << "\n";

    return 0;
}
./AggregateInit
$ ./AggregateInit
Олена Коваль | GPA: 3.9
Максим Дяченко | GPA: 3.4
Execution finished with exit code 0.

Порядок значень у фігурних дужках повинен точно відповідати порядку оголошення полів у структурі. Якщо поля age і gpa поміняти місцями в ініціалізаторі, компілятор може не повідомити про помилку (типи int і double сумісні при неявній конвертації), але значення будуть некоректними.

Часткова ініціалізація: якщо ви вказуєте менше значень, ніж є полів, решта полів ініціалізується нулем (для числових типів) або конструктором за замовчуванням (для класів). Наприклад, Student s = { "Аліса" } дасть age = 0 і gpa = 0.0. Однак це правило стосується лише агрегатної ініціалізації — не простого оголошення Student s;.

Спосіб 3 — Значення за замовчуванням у полях (C++11)

Поля структури можуть мати значення за замовчуванням (default member initializers), що задаються безпосередньо в оголошенні структури. Якщо поле не вказано при ініціалізації — використовується значення за замовчуванням:

struct Student
{
    std::string name  = "Невідомо";
    int         age   = 0;
    double      gpa   = 0.0;
};

int main()
{
    Student s1;                      // name="Невідомо", age=0, gpa=0.0
    Student s2 = { "Аліса" };       // name="Аліса", age=0, gpa=0.0
    Student s3 = { "Боб", 25 };     // name="Боб", age=25, gpa=0.0

    return 0;
}

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

Спосіб 4 — Designated initializers (C++20)

Стандарт C++20 додав можливість явно вказувати ім'я поля при ініціалізації:

Student s = {
    .name = "Олена Коваль",
    .gpa  = 3.9,
    // .age не вказано → отримує значення за замовчуванням або нуль
};

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

Стан об'єкта після ініціалізації у дебаґері:

Local Variables — після ініціалізації
Filter
NameTypeValue
Running
Process: 12842

Доступ до полів: оператори . та ->

Для доступу до полів структури C++ пропонує два оператори — залежно від того, як ми «тримаємо» об'єкт.

Оператор крапка (.) використовується для об'єктів і посилань. Це найпоширеніший варіант:

Student s = { "Аліса", 20, 3.9 };
Student& ref = s;

s.name   = "Боб";     // змінюємо поле через об'єкт
ref.age  = 21;        // змінюємо поле через посилання — той самий об'єкт

Оператор стрілка (->) використовується для вказівників на об'єкти. Він є синтаксичним скороченням для (*ptr).field:

MemberAccess.cpp
#include <iostream>
#include <string>

struct Student
{
    std::string name;
    int         age;
    double      gpa;
};

void birthday(Student* s)
{
    // Обидва записи еквівалентні:
    (*s).age += 1;  // явне розіменування, потім крапка
    s->age  += 1;   // скорочення через стрілку — читабельніше
}

int main()
{
    Student student = { "Олена", 19, 3.8 };
    Student* ptr    = &student;

    std::cout << "До дня народження: " << ptr->age << "\n";

    birthday(ptr);

    std::cout << "Після дня народження: " << ptr->age << "\n";
    std::cout << "Через об'єкт: " << student.age << "\n"; // той самий об'єкт

    return 0;
}
./MemberAccess
$ ./MemberAccess
До дня народження: 19
Після дня народження: 21
Через об'єкт: 21
Execution finished with exit code 0.

Рядок 14 ((*s).age) і рядок 15 (s->age) виконують ідентичну операцію — -> є лише зручнішим записом. У промисловому коді завжди використовується -> для вказівників — це читабельніше і запобігає помилці пріоритету операторів.


Структури в пам'яті: sizeof і вирівнювання

Як компілятор розміщує поля у пам'яті

Здається логічним, що розмір структури дорівнює сумі розмірів її полів. Але це не так.

StructSize.cpp
#include <iostream>

struct Compact    // очікуємо: 1+4+8 = 13 байт
{
    char   letter; // 1 байт
    int    number; // 4 байти
    double value;  // 8 байт
};

struct Ordered    // очікуємо: 8+4+1 = 13 байт
{
    double value;  // 8 байт
    int    number; // 4 байти
    char   letter; // 1 байт
};

int main()
{
    std::cout << "sizeof(Compact) = " << sizeof(Compact) << "\n"; // 16, не 13!
    std::cout << "sizeof(Ordered) = " << sizeof(Ordered) << "\n"; // 16 або 13
    return 0;
}
./StructSize
$ ./StructSize
sizeof(Compact) = 16
sizeof(Ordered) = 16
Execution finished with exit code 0.

Чому 16, а не 13? Тому що процесор читає пам'ять вирівняними блоками. Щоб int (4 байти) читався ефективно, його адреса повинна бути кратна 4. Щоб double (8 байт) читався ефективно, його адреса повинна бути кратна 8.

Компілятор автоматично вставляє вирівнювальні байти (padding) між полями, щоб забезпечити ці вимоги. Для struct Compact:

Байт 0:     char letter    (1 байт)
Байти 1–3:  [padding]      (3 байти, щоб number почався з адреси, кратної 4)
Байти 4–7:  int number     (4 байти)
Байти 8–15: double value   (8 байт, адреса кратна 8 — ок)
Разом:      16 байт
struct Compact у пам'яті (16 байт)
Hex Dump / ASCII
Empty memory block
Offset: 0 bytes
Big Endian

Порядок полів має значення

Якщо оголосити поля у порядку від найбільшого до найменшого — padding зменшується:

struct EfficientStudent
{
    std::string name;  // зазвичай 24–32 байти (залежить від реалізації)
    double      gpa;   // 8 байт — вирівнювання 8
    int         age;   // 4 байти — вирівнювання 4
    // char padding[4] — компілятор додасть у кінці для вирівнювання розміру
};
На практиці варто замислюватися над порядком полів у структурах, що зберігаються у великих масивах. Для структур із кількома полями різниця несуттєва. Але якщо масив містить мільйони елементів, зайві байти padding множаться на мільйони — і можуть відчутно збільшити використання пам'яті та кількість промахів кешу.

Практика

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

Рівень 2 — Логіка та функції

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


Резюме

🧱 Агрегатний тип

  • struct об'єднує поля різних типів під одним іменем
  • Оголошення типу не виділяє пам'яті — лише Student s; це робить
  • Один тип — скільки завгодно незалежних екземплярів

⚙️ Ініціалізація

  • Присвоєння після оголошення: поля в невизначеному стані до присвоєння
  • Агрегатна: { "Аліса", 20, 3.9 } — у порядку полів
  • Default member init (C++11): завжди визначений стан
  • Designated (C++20): .name = "Аліса" — стійко до зміни порядку

🔍 Доступ до полів

  • s.field — для об'єктів і посилань
  • ptr->field — для вказівників (еквівалент (*ptr).field)
  • -> читабельніший і захищає від помилок пріоритету операторів

📦 Пам'ять і вирівнювання

  • sizeof(struct) ≥ сума sizeof полів (через padding)
  • Padding вставляється для вирівнювання кожного поля
  • Порядок полів від більшого до меншого мінімізує padding
Ця стаття розкрила базовий механізм агрегування даних. У наступній статті «Структури у функціях» ми детально дослідимо всі аспекти передачі структур у функції — за значенням, посиланням і вказівником — та познайомимося з фабричними функціями як патерном замість конструктора.
Copyright © 2026