1. Вступ
Якщо ви лише починаєте, легко подумати: «Ну, у конструкторі ж можна написати field_ = value; — і все». Іноді справді можна. Але це небезпечне спрощення. Конструктор виконується тоді, коли обʼєкт ще тільки народжується, і в C++ є чіткий порядок: спочатку створюються поля, а вже потім виконується тіло конструктора. Список ініціалізації (: field_(value)) — це спосіб одразу створити поля з правильними значеннями, а не спершу створити їх «як доведеться», а потім змінювати присвоюванням.
Уявіть, що ви замовили піцу. Ініціалізація — це коли піца приїхала вже із сиром і начинкою. Присвоювання — це коли вам привезли порожнє тісто, а ви кажете: «О, зараз я сам усе зверху покладу». Іноді так теж можна, але навіщо ускладнювати собі життя? І за що тоді ви взагалі платите за доставку?
Мінісхема «що відбувається під час створення обʼєкта»
flowchart TD
A[Ви виділяєте місце під обʼєкт] --> B["Ініціалізуються поля (і базові частини)"]
B --> C[Виконується тіло конструктора]
C --> D[Обʼєкт готовий до використання]
Тут ключове ось що: поля ініціалізуються до тіла конструктора. Тому список ініціалізації — це не «синтаксичний цукор», а механізм, який спрацьовує в потрібний момент.
2. Синтаксис списку ініціалізації
Коли ви вперше бачите конструктор зі списком ініціалізації, він може виглядати так, ніби хтось випадково розставив двокрапку й коми. Насправді все доволі прямолінійно: після параметрів конструктора йде двокрапка, а далі — перелік того, як слід ініціалізувати кожне поле. Кожна частина field_(...) означає: «створи поле field_ одразу ось із таким значенням». Це саме створення поля, а не «потім присвоїмо».
Почнімо будувати наш мініпроєкт: просту модель задачі для CLI‑трекера завдань (щось на кшталт «найменшого Todo», який не обіцяє вам нового життя з понеділка).
Приклад 1: правильний конструктор Task зі списком ініціалізації
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title)
: id_(id)
, title_(title)
, done_(false)
{}
};
Тут важливо вловити суть: id_, title_, done_ не «отримують значення», а створюються одразу з потрібними значеннями.
Трохи пізніше ми додамо методи й виведемо задачу на екран, але спершу закріпімо головну думку: це «правильний стиль» не тому, що так гарніше, а тому, що так правильніше за змістом.
3. Ініціалізація та присвоювання в конструкторі
На простих типах (int, bool) часто здається, що різниці немає. І справді, у дуже простих випадках оптимізатор може «згорнути» зайве. Але щойно поля стають «живішими» (наприклад, std::string, std::vector), різниця стає не філософською, а практичною: або ви одразу створюєте поле потрібним, або спершу створюєте його «порожнім», а потім змушуєте перебудовуватися.
Важлива думка: якщо ви пишете в тілі конструктора title_ = title;, то на цей момент title_ уже якось створено — зазвичай як порожній рядок. І ви виконуєте другу операцію: присвоювання. Тобто робите два кроки замість одного.
Приклад 2: «працює, але не те, чого ми хочемо»
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title) {
id_ = id; // присвоювання
title_ = title; // присвоювання (рядок уже створено)
done_ = false; // присвоювання
}
};
Цей код компілюється й працює. Але тут ви ніби кажете компілятору: «Створи мені обʼєкт як-небудь, а потім я перебудую його зсередини». Для простих полів це не завжди критично. Але далі ми побачимо випадки, коли так робити взагалі не можна. І саме тут починається справжня «коректність, а не стиль».
4. Коли список ініціалізації обовʼязковий: const і посилання
Зараз буде момент, коли C++ дуже чітко скаже: «Ні». І це добрий момент, бо мова тут захищає вас від небезпечної моделі «потім присвою». Є поля, яким не можна спочатку дати «порожній» стан, а потім присвоїти значення. Класичні приклади — const‑поля й посилання.
Причина проста: const означає «це значення не можна змінювати після створення». А посилання не можна «перепривʼязати» до іншого обʼєкта: воно має посилатися на щось від самого початку.
Приклад 3: const‑поле — лише список ініціалізації
#include <string>
class Task {
const int id_;
std::string title_;
public:
Task(int id, const std::string& title)
: id_(id) // зобов’язано бути тут
, title_(title)
{}
};
Якщо ви спробуєте написати id_ = id; у тілі конструктора, компілятор цілком справедливо обуриться: id_ не можна присвоювати, бо він const.
У документах стандарту для ініціалізації членів є окрема термінологія про «ініціалізатори членів» і про те, що вони застосовуються до конкретних (direct) нестатичних членів. Навіть якщо ми не читаємо стандарт як роман перед сном, корисно знати: це не «порада», а частина строгих правил мови.
Приклад 4: поле‑посилання
#include <string>
class TaskView {
const std::string& title_ref_;
public:
TaskView(const std::string& title)
: title_ref_(title)
{}
};
Одразу попереджу: зберігати посилання як поля — майже завжди «тонкий лід», бо потрібно стежити за часом життя обʼєкта, на який посилаємося. Але як навчальний приклад це ідеально показує правило: посилання не можна спочатку «створити порожнім», а потім присвоїти.
5. Конструктор та інваріанти: валідний стан одразу
До цього моменту ми говорили переважно про механіку. Тепер — про зміст. Якщо ви проєктуєте клас, то конструктор — це місце, де ви зобовʼязані забезпечити мінімальну коректність: щоб обʼєкт не можна було створити «битим». І список ініціалізації допомагає в цьому, бо задає початкові значення саме там, де вони й мають зʼявлятися.
Уявімо правило нашого застосунку: задача не може мати відʼємний id. Ми можемо «нормалізувати» вхідні дані — наприклад, якщо прийшло відʼємне значення, зробити 0. Це не ідеальна бізнес-логіка, але як приклад інваріанта підійде.
Приклад 5: нормалізація у списку ініціалізації
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title)
: id_(id < 0 ? 0 : id)
, title_(title)
, done_(false)
{}
};
Тут ми явно показуємо: обʼєкт не може народитися з id_ = -50. Ми не відкладаємо це на потім із думкою «хтось перевірить». Це і є доросла модель: конструктор — точка входу у валідність.
6. Default member initializer і список ініціалізації
Поки ми будували Task, ви могли помітити: багато полів мають очевидні значення за замовчуванням. Наприклад, done_ логічно починати зі значення false. У C++ можна задавати значення за замовчуванням прямо під час оголошення поля. Тоді конструктор буде простішим, а клас — зрозумілішим: ви буквально читаєте поля й одразу бачите «звичний стан» обʼєкта.
Важливо розуміти пріоритет: якщо поле не зазначено у списку ініціалізації, тоді використовується задане для нього значення за замовчуванням (якщо воно є). Це дозволяє не дублювати одне й те саме в кожному конструкторі.
Приклад 6: значення за замовчуванням у полів
#include <string>
class Task {
int id_ = 0;
std::string title_ = "без назви";
bool done_ = false;
public:
Task(int id, const std::string& title)
: id_(id)
, title_(title)
{}
};
У цьому варіанті done_ не згадується у списку ініціалізації, бо для нього вже є розумне значення за замовчуванням. Такий код легше підтримувати: менше повторів, менше шансів забути ініціалізувати поле.
7. Мінівикористання в main
До цього ми писали класи «у вакуумі». Але новачкові важливо побачити, що обʼєкт справді створюється, справді «живе», і методи справді працюють. Тому додамо трохи функціональності: читання полів через const‑методи й виведення задачі. Це продовжує лінію попереднього дня: інкапсуляція й мінімальний інтерфейс.
Список ініціалізації тут лишається головним героєм: саме він робить так, що Task завжди зʼявляється в передбачуваному стані.
Приклад 7: Task з методами та друком
#include <iostream>
#include <string>
class Task {
int id_;
std::string title_;
bool done_;
public:
Task(int id, const std::string& title)
: id_(id)
, title_(title)
, done_(false)
{}
int id() const { return id_; }
bool done() const { return done_; }
void mark_done() { done_ = true; }
void print() const {
std::cout << "#" << id_ << " " << title_
<< (done_ ? " [зроблено]" : " [у планах]") << '\n';
// наприклад: #1 Купити молоко [у планах]
}
};
int main() {
Task t{1, "Купити молоко"};
t.print(); // #1 Купити молоко [у планах]
t.mark_done();
t.print(); // #1 Купити молоко [зроблено]
}
Зверніть увагу, наскільки простою стає логіка main: ми створюємо обʼєкт, і він одразу готовий до використання. Це «тонкий main» у мініатюрі: що більше логіки ініціалізації заховано в конструкторі, то менше хаосу в коді навколо.
8. Корисні нюанси
Mem-initializer — окрема частина конструктора
Іноді новачки сприймають список ініціалізації як «продовження тіла» або як «місце, де теж можна писати звичайний код». Краще дивитися на це інакше: у конструктора є дві частини — «ініціалізаційна» та та, що виконується. Список ініціалізації — це частина, яка говорить, як створювати члени. Тіло — це частина, яка говорить, що робити після створення.
У термінах стандарту ви можете натрапити на формулювання про mem-initializer і про ідентифікатори ініціалізованих членів. Ми не вивчаємо стандарт посторінково, але корисно знати, що синтаксис тут не випадковий: це окремий механізм мови, а не «хитрий стиль оформлення».
Практичний висновок дуже простий: якщо ви хочете задати стартові значення полям — робіть це у списку ініціалізації (або через значення за замовчуванням для полів). Якщо ви хочете виконати дії, які мають відбуватися після ініціалізації (наприклад, вивести повідомлення, щось порахувати тощо), — це вже тіло конструктора.
Таблиця-памʼятка: коли що використовувати
Щоб не тримати все в голові, корисно один раз зафіксувати різницю у вигляді маленької таблиці. Вона не замінює розуміння, але чудово допомагає, коли ви за тиждень відкриваєте код і думаєте: «Так… а чому тут двокрапка, і чому я не можу просто присвоїти?».
| Що ви робите | Де писати | Чому |
|---|---|---|
| Задаєте стартові значення полям | Список ініціалізації : field_(...) або значення за замовчуванням для поля | Це момент, коли поле справді створюється |
| Хочете «потім змінити стан» | У тілі конструктора (або методами) через присвоювання | Це вже робота з наявним обʼєктом |
| Поле const або посилання | Лише список ініціалізації (або значення за замовчуванням для поля) | Їх не можна «доініціалізувати» присвоюванням |
| Потрібно забезпечити інваріант під час створення | Список ініціалізації (іноді + перевірка в тілі) | Обʼєкт має народитися валідним |
Зверніть увагу: «значення за замовчуванням для поля» і «список ініціалізації» — це не конкуренти. Вони зазвичай працюють разом: значення за замовчуванням задають «звичну норму», а конструктор уточнює її, коли користувач передав параметри.
9. Типові помилки
Помилка № 1: плутати ініціалізацію та присвоювання й писати все в тілі конструктора.
На перших порах це здається логічним: «конструктор же виконується — отже, можна присвоїти». Але на момент виконання тіла конструктора поля вже створені. Для int це часто минає непомітно, а для std::string та інших складніших типів ви отримуєте зайву роботу. Для const і посилань ви взагалі отримаєте помилку компіляції.
Помилка № 2: вважати список ініціалізації «стилем», який можна ігнорувати.
Це одна з найшкідливіших установок. Список ініціалізації потрібен не для краси коду й не для «програмістського снобізму». Він потрібен тому, що в C++ є строгий порядок створення обʼєкта, і деякі поля можна коректно задати лише в момент їхнього створення.
Помилка № 3: намагатися забезпечити валідність обʼєкта «ззовні», а не всередині конструктора.
Якщо клас допускає створення «битих» обʼєктів, ви неминуче отримаєте помилки: десь забули викликати сеттер, десь переплутали порядок викликів, десь поклали обʼєкт у контейнер і забули додатково його налаштувати. Конструктор і список ініціалізації дозволяють зробити так, щоб обʼєкт був нормальним одразу після Task t{...};.
Помилка № 4: робити поля const або посиланнями й потім дивуватися, що «не присвоюється».
Це не примха компілятора, а логіка мови: const і посилання мають отримати значення під час ініціалізації. Іноді це змушує акуратніше проєктувати модель даних — і це теж плюс: мова мʼяко натякає, що «життя обʼєкта» починається в конструкторі, а не після нього.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ