1. Передісторія появи struct
Коли ви тільки починаєте, легко подумати: «Ну, подумаєш, у завданні є id і title, заведу дві змінні — і готово». Але щойно завдань стає два, три чи десять, ви раптом розумієте: у вас уже не «список завдань», а «список наборів змінних», які легко переплутати й зламати. struct потрібен, щоб обʼєднати дані, які логічно утворюють одне ціле, під спільним імʼям типу.
Уявімо, що ми хочемо зберігати одне завдання для майбутнього мінізастосунку «список справ» (ми розвиватимемо його далі в наступних лекціях). Без struct часто виходить щось на кшталт:
int id1 = 1;
std::string title1 = "Buy milk";
int id2 = 2;
std::string title2 = "Pay rent";
Поки завдань лише два, це ще виглядає прийнятно… Але ненадовго. Потім зʼявляться id3/title3, далі — ознака «чи виконано» (done1/done2/…), потім — «дата створення», і ви почнете підозрювати, що код підозріло швидко починає нагадувати бухгалтерію в Excel.
Коли ми використовуємо struct, то фактично кажемо компілятору: «Ось сутність Task: у неї є поля id, title, …, і це одне ціле». Тоді можна оголосити Task t;, створити std::vector<Task> tasks;, передавати Task у функції й не розривати сутність на окремі частини.
2. Базовий синтаксис і робота з полями
Оголошення struct та обовʼязкова ;
Синтаксис struct дуже простий, але тут є одна класична пастка: після закривальної фігурної дужки треба поставити крапку з комою. Це може здаватися трохи нелогічним («я ж уже закрив блок!»), але такі правила мови: оголошення типу — це інструкція для компілятора, і вона закінчується ;.
Мінімальне оголошення моделі Task:
#include <string>
struct Task {
int id;
std::string title;
};
Зверніть увагу на дві речі.
Перша: усередині struct ми перелічуємо поля (їх ще називають members). По суті, це звичайні змінні, які «живуть» усередині типу.
Друга: Task — це тепер назва типу, як int або std::string. Отже, можна писати: Task t;.
І так, оце ; після } — не декоративна деталь. Якщо ви його забудете, компілятор цілком чесно скаже «expected ; …» — і матиме рацію.
Доступ до полів через крапку: obj.field
Коли в нас є обʼєкт структури, доступ до його полів здійснюється через оператор .. У реальному коді це один із найуживаніших операторів узагалі: ви постійно писатимете task.id, task.title, user.name, order.total й так далі. Це майже як «імʼя.властивість» у звичайній мові, тому модель даних помітно підвищує читабельність.
Створімо одне завдання й виведімо його поля:
#include <iostream>
#include <string>
struct Task {
int id;
std::string title;
};
int main() {
Task t{1, "Buy milk"};
std::cout << t.id << "\n"; // 1
std::cout << t.title << "\n"; // Buy milk
}
Тут важливе не лише читання, а й можливість змінювати значення:
#include <string>
struct Task {
int id;
std::string title;
};
int main() {
Task t{1, "Buy milk"};
t.title = "Buy oat milk"; // змінюємо поле
}
Тобто t.title — це не «копія значення», а саме поле всередині t. Ми звертаємося до нього безпосередньо й можемо змінювати його значення (якщо обʼєкт не const).
Агрегатна ініціалізація {...} і порядок полів
Коли ви пишете Task t{1, "Buy milk"};, ви використовуєте дуже зручний механізм — агрегатну ініціалізацію. У стандарті C++ фігурні дужки такого вигляду називаються braced-init-list — буквально «список ініціалізації у фігурних дужках».
Головна ідея проста: ви перелічуєте значення для полів, а компілятор розподіляє їх за полями структури. Але за цю зручність доводиться «платити»: значення йдуть строго в порядку оголошення полів.
Нехай Task виглядає так:
#include <string>
struct Task {
int id;
std::string title;
};
Тоді:
Task t{1, "Buy milk"};
означає: «id = 1, title = "Buy milk"».
Якщо ви переплутаєте порядок, компілятор може насваритися — або зробить це не так, як ви очікували. Наприклад:
Task t{"Buy milk", 1}; // помилка компіляції: типи не збігаються
У цьому випадку помилка навіть корисна: типи різні, тож компілятор не дасть вам випадково «поміняти їх місцями».
Але інколи типи збігаються — і тоді починається тихий жах. Наприклад, якщо у вас два int:
#include <string>
struct Task {
int id;
int priority;
std::string title;
};
Тоді записи Task t{10, 1, "Buy milk"}; і Task t{1, 10, "Buy milk"}; обидва коректні, але сенс у них різний. Тому під час проєктування структур корисно розташовувати поля так, щоб порядок був природним: спочатку ідентифікатор, потім основна назва або текст, а далі — додаткові деталі.
Значення полів за замовчуванням
Коли ви створюєте багато обʼєктів, ті самі значення часто повторюються. Наприклад: «завдання за замовчуванням не виконано». Щоразу писати false швидко набридає, а ще швидше призводить до помилок: десь забули, десь переплутали.
У struct можна задати значення поля за замовчуванням прямо в його оголошенні. Це називається default member initializer («ініціалізатор поля за замовчуванням»).
Додаймо до Task прапорець done:
#include <string>
struct Task {
int id;
std::string title;
bool done = false; // за замовчуванням завдання НЕ виконано
};
Тепер можна створювати завдання так:
#include <iostream>
#include <string>
struct Task {
int id;
std::string title;
bool done = false;
};
int main() {
Task a{1, "Buy milk"}; // done = false
Task b{2, "Pay rent", true}; // done = true
std::cout << a.done << "\n"; // 0
std::cout << b.done << "\n"; // 1
}
Зверніть увагу на баланс: значення за замовчуванням мають бути простими й безпечними. Якщо ви задасте за замовчуванням щось «хитре», налагодження потім перетвориться на квест: «Чому воно вирішило саме так?»
struct і функції: передаємо модель цілком
З появою структур виникає дуже корисна можливість: функції починають приймати цілі сутності, а не набір розрізнених параметрів. Код стає читабельнішим: замість PrintTask(id, title, done) у вас буде PrintTask(task).
Водночас памʼятаємо правило з попередніх лекцій: великі обʼєкти краще не копіювати без потреби. Тому для читання ми зазвичай передаємо const T&.
Створімо функцію друку одного завдання (поки без красивого форматування — до нього ми повернемося пізніше):
#include <iostream>
#include <string>
struct Task {
int id;
std::string title;
bool done = false;
};
void PrintTask(const Task& t) {
std::cout << t.id << ": " << t.title << " (done=" << t.done << ")\n";
}
І використаймо її:
#include <iostream>
#include <string>
struct Task {
int id;
std::string title;
bool done = false;
};
void PrintTask(const Task& t) {
std::cout << t.id << ": " << t.title << " (done=" << t.done << ")\n";
}
int main() {
Task t{1, "Buy milk"};
PrintTask(t); // 1: Buy milk (done=0)
}
Зауважте: PrintTask лише читає дані. Це хороша звичка: якщо функція називається Print..., дивно, коли вона раптом щось змінює.
3. Мінізастосунок TaskTracker і погляд на модель
Практичний приклад: заготовка «TaskTracker»
Щоб практика не перетворилася на набір розрізнених фрагментів, почнімо збирати основу застосунку, який розвиватимемо далі. Сьогодні наша мета скромна: навчитися описувати завдання як модель даних і впевнено створювати обʼєкти. Ми поки не робимо повноцінний менеджер завдань, не пишемо складне введення й не реалізуємо пошук чи видалення — це буде в наступних лекціях.
Спочатку зробімо невелику «фабрику» створення завдання: функцію, яка створює Task зі зрозумілих аргументів і повертає готовий обʼєкт. Тут знову стане в пригоді агрегатна ініціалізація.
#include <string>
struct Task {
int id;
std::string title;
bool done = false;
};
Task MakeTask(int id, const std::string& title) {
return Task{id, title}; // done буде взято зі значення за замовчуванням
}
Тепер main() може виглядати так (так, зі std::vector ви вже знайомі, але тут використаємо його максимально мʼяко — просто щоб побачити, чим корисна модель):
#include <iostream>
#include <string>
#include <vector>
struct Task {
int id;
std::string title;
bool done = false;
};
Task MakeTask(int id, const std::string& title) {
return Task{id, title};
}
void PrintTask(const Task& t) {
std::cout << t.id << ": " << t.title << " (done=" << t.done << ")\n";
}
int main() {
std::vector<Task> tasks;
tasks.push_back(MakeTask(1, "Buy milk"));
tasks.push_back(MakeTask(2, "Pay rent"));
for (const Task& t : tasks) {
PrintTask(t);
}
}
Виведення буде приблизно таким:
1: Buy milk (done=0)
2: Pay rent (done=0)
Тут і видно головне: vector зберігає багато завдань, і кожне з них — цілісний обʼєкт. У нас немає паралельних масивів ids[], titles[], dones[], які треба постійно тримати узгодженими. Ми не граємося в «прибери елемент із titles і не забудь прибрати його з ids». Ми просто працюємо із завданнями.
Як думати про struct як про модель
Коли ми кажемо «модель», то маємо на увазі не якісь «ООП-мантри», а цілком практичну річ: struct описує одну сутність вашої предметної області. Важливо, щоб поля були зрозумілими, імена — промовистими, а сама структура не перетворювалася на мішок для всього підряд.
Невелика схема того, що відбувається в програмі, часто виглядає так:
flowchart TD
A["Сутність предметної області
наприклад: завдання"] --> B["Модель даних: struct Task"]
B --> C["Поля: id, title, done"]
C --> D["Обʼєкти: Task{...}"]
D --> E["Колекція: vector<Task> (багато завдань)"]
І ще одна корисна памʼятка у вигляді таблиці. Вона допомагає відрізнити «одне завдання» від «набору змінних»:
| Підхід | Як виглядає | Що легко зламати |
|---|---|---|
| Розсип змінних | |
легко переплутати повʼязані дані й забути оновити одну з частин |
|
|
треба стежити за порядком полів у , але сама сутність лишається цілісною |
| Багато задач | |
помилок менше: колекція зберігає цілісні обʼєкти |
9. Типові помилки
Помилка № 1: забули ; після оголошення struct.
Це та сама «ритуальна» помилка, на якій бодай раз спотикаються всі. Причина проста: оголошення типу — це теж інструкція, і вона закінчується крапкою з комою. Коли бачите } після struct, подумки запитайте себе: «А де ;?» За кілька днів це стане звичкою.
Помилка № 2: переплутали порядок значень в агрегатній ініціалізації {...}.
Агрегатна ініціалізація зручна, але вона вимагає правильного порядку. Якщо два поля мають один тип (наприклад, два int), компілятор не зможе здогадатися, що саме ви мали на увазі. У таких структурах особливо важливо розташовувати поля логічно й уважно стежити за тим, що саме ви передаєте в {...}.
Помилка № 3: задали для полів «дивні» значення за замовчуванням.
Значення за замовчуванням має бути передбачуваним: done = false, count = 0, порожній рядок — це нормально. Якщо поставити щось на кшталт priority = 999 «бо так зручно», ви потім забудете, що воно має «особливе значення», і отримаєте поведінку, яка здаватиметься містикою, хоча насправді це просто погано задокументоване значення за замовчуванням.
Помилка № 4: передають Task за значенням там, де можна передати через const Task&.
Для невеликих структур це не завжди критично, але щойно всередині зʼявляється std::string (а вона в нас уже є), копіювання стає помітним і, що важливіше, зайвим. Якщо функція лише читає завдання, робіть const Task& t: так ви і покажете свій намір, і не створюватимете зайвих копій.
Помилка № 5: перетворюють модель на «звалище всього підряд».
Іноді хочеться додати в Task ще щось: шматок тексту для логів, якийсь тимчасовий прапорець для інтерфейсу, проміжні дані для пошуку. У результаті структура перестає описувати сутність і починає відображати ваш поточний безлад. Хороша модель містить лише ті поля, які справді є частиною сутності. Усе інше краще зберігати окремо й передавати у функції як тимчасові параметри.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ