C++

Конструктори: гарантована ініціалізація об'єктів

Як конструктори забезпечують коректний початковий стан класу. Конструктор за замовчуванням, параметризовані конструктори, зменшення кількості конструкторів через параметри за замовчуванням. Uniform initialization та неявний конструктор.

Проблема неініціалізованих об'єктів

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

Розглянемо клас, що моделює математичний дріб (fraction):

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
    
    void setNumerator(int n) { numerator = n; }
    void setDenominator(int d) 
    {
        if (d != 0) {
            denominator = d;
        }
    }
    
    double getValue() const 
    {
        return static_cast<double>(numerator) / denominator;
    }
};

int main()
{
    Fraction half;
    
    // Проблема: поля містять сміття!
    cout << half.getValue() << "\n";  // Невизначена поведінка
    
    // Потрібно вручну викликати сеттери
    half.setNumerator(1);
    half.setDenominator(2);
    
    cout << half.getValue() << "\n";  // Тепер коректно: 0.5
    
    return 0;
}
Після створення об'єкта half його поля numerator і denominator містять сміття — невизначені значення з пам'яті. Виклик getValue() у цей момент спричиняє невизначену поведінку (undefined behavior): програма може вивести будь-яке число, зависнути або аварійно завершитися.

Цю проблему можна вирішити вручну — викликати сеттери одразу після створення об'єкта. Але це незручно, ненадійно і вимагає дисципліни від кожного, хто використовує клас. Краще було б гарантувати коректну ініціалізацію автоматично під час створення об'єкта. Саме для цього існують конструктори (constructors).

Що таке конструктор

Конструктор — це спеціальний метод класу, який автоматично викликається під час створення об'єкта. Його призначення — виконати ініціалізацію змінних-членів класу та виконати будь-які підготовчі дії, необхідні для роботи об'єкта (наприклад, відкрити файл, встановити з'єднання з базою даних, виділити динамічну пам'ять).

Конструктори підпорядковуються особливим правилам іменування та сигнатури:

📝 Правила конструкторів

  • Ім'я конструктора збігається з ім'ям класу (з урахуванням регістру): клас Fraction → конструктор Fraction()
  • Немає типу повернення — навіть не void. Конструктор не повертає значення, бо його завдання — ініціалізувати вже створений об'єкт
  • Конструктор може бути перевантаженим — клас може мати кілька конструкторів з різними параметрами
  • Конструктор викликається автоматично під час створення об'єкта; не потрібно викликати його явно

⚠️ Важливе застереження

Конструктор призначений лише для ініціалізації нового об'єкта. Не можна викликати конструктор на вже існуючому об'єкті для «повторної ініціалізації». Спроба зробити це (half.Fraction(3, 5);) або не скомпілюється, або створить тимчасовий об'єкт і одразу його знищить — але не перезапише існуючий half.

Конструктор за замовчуванням

Конструктор за замовчуванням (default constructor) — це конструктор, що не має параметрів (або має параметри, всі з яких мають значення за замовчуванням). Він викликається, коли користувач створює об'єкт без аргументів.

Додамо конструктор за замовчуванням до класу Fraction:

Fraction.cpp
#include <iostream>

using namespace std;

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    // Конструктор за замовчуванням
    Fraction()
    {
        numerator = 0;
        denominator = 1;
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
    
    double getValue() const 
    {
        return static_cast<double>(numerator) / denominator;
    }
};

int main()
{
    Fraction zero;  // Викликається Fraction() — конструктор за замовчуванням
    
    cout << zero.getNumerator() << "/" << zero.getDenominator() << "\n";
    cout << "Value: " << zero.getValue() << "\n";
    
    return 0;
}
g++ Fraction.cpp -o fraction && ./fraction
$ ./fraction
0/1
Value: 0

Тепер об'єкт zero одразу після створення має коректні значення: чисельник 0, знаменник 1. Конструктор спрацював автоматично — нам не потрібно було нічого викликати вручну.

Конструктор за замовчуванням найчастіше використовується для встановлення безпечних початкових значень (safe defaults). Для класу Fraction це 0/1 (дріб, що дорівнює нулю). Для класу BankAccount це могло б бути баланс 0.0 і статус «активний». Для класу Timer — зафіксувати поточний момент часу.

Параметризовані конструктори

Конструктор за замовчуванням забезпечує безпечну ініціалізацію, але часто потрібно створити об'єкт із конкретними початковими значеннями. Для цього використовуються параметризовані конструктори (parameterized constructors) — конструктори, що приймають один або більше параметрів.

Додамо до класу Fraction параметризований конструктор:

Fraction.cpp
class Fraction
{
private:
    int numerator;
    int denominator;

public:
    // Конструктор за замовчуванням
    Fraction()
    {
        numerator = 0;
        denominator = 1;
    }

    // Параметризований конструктор
    Fraction(int num, int den)
    {
        numerator = num;
        denominator = (den != 0) ? den : 1;  // Захист від ділення на нуль
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
    
    double getValue() const 
    {
        return static_cast<double>(numerator) / denominator;
    }
};

Тепер у класі Fraction є два конструктори. Завдяки перевантаженню функцій (function overloading), компілятор вибере потрібний конструктор на основі переданих аргументів:

Fraction zero;          // Викликається Fraction() — без параметрів
Fraction half(1, 2);    // Викликається Fraction(int, int) — два параметра
Fraction third(1, 3);   // Викликається Fraction(int, int)
Використовуйте пряму ініціалізацію (direct initialization) з дужками () або uniform initialization (фігурні дужки {}, доступні з C++11) для створення об'єктів:
// Пряма ініціалізація (класичний синтаксис C++)
Fraction half(1, 2);

// Uniform initialization (C++11 і новіше)
Fraction third{1, 3};
Обидва підходи викликають конструктор однаково ефективно.

Зменшення кількості конструкторів через параметри за замовчуванням

У попередньому прикладі клас Fraction має два окремі конструктори: один без параметрів і один із двома параметрами. Це працює, але створює дублювання логіки. Обидва конструктори виконують схожу роботу — ініціалізацію полів класу.

Замість двох окремих конструкторів можна створити один універсальний за допомогою параметрів за замовчуванням (default parameters):

Fraction.cpp
class Fraction
{
private:
    int numerator;
    int denominator;

public:
    // Один конструктор із параметрами за замовчуванням
    // Може працювати як конструктор за замовчуванням (0 аргументів),
    // як конструктор з одним аргументом (тільки чисельник)
    // або з двома аргументами (чисельник і знаменник)
    Fraction(int num = 0, int den = 1)
    {
        numerator = num;
        denominator = (den != 0) ? den : 1;
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
    
    double getValue() const 
    {
        return static_cast<double>(numerator) / denominator;
    }
};

Тепер один конструктор обробляє всі три сценарії:

int main()
{
    Fraction zero;           // Викликається Fraction(0, 1) — обидва параметри за замовчуванням
    Fraction five(5);        // Викликається Fraction(5, 1) — другий параметр за замовчуванням
    Fraction half(1, 2);     // Викликається Fraction(1, 2) — обидва параметри явні
    
    cout << zero.getValue() << "\n";   // 0
    cout << five.getValue() << "\n";   // 5
    cout << half.getValue() << "\n";   // 0.5
    
    return 0;
}

✅ Переваги об'єднання конструкторів

  • Менше дублювання коду — логіка ініціалізації записана один раз
  • Легше підтримувати — зміни вносяться в одне місце
  • Гнучкість використання — один конструктор покриває кілька сценаріїв
  • Читабельність — менше методів для розуміння класу

⚖️ Коли залишати окремі конструктори

Іноді кілька конструкторів з різною логікою є доцільними:

  • Різні способи побудови об'єкта (наприклад, Date(int day, int month, int year) vs Date(string isoString))
  • Складна валідація параметрів залежно від їх типу
  • Різні джерела даних (з файлу, з мережі, з пам'яті)
Правило практики: Намагайтеся мінімізувати кількість конструкторів класу через розумне використання параметрів за замовчуванням. Це робить клас простішим у використанні та обслуговуванні.

Копіююча ініціалізація: чому її слід уникати

Окрім прямої ініціалізації з дужками () та uniform initialization з фігурними дужками {}, C++ дозволяє ще один синтаксис — копіюючу ініціалізацію (copy initialization) зі знаком =:

int age = 25;                     // Копіююча ініціалізація для фундаментального типу
Fraction half = Fraction(1, 2);   // Копіююча ініціалізація для класу (явно створюємо тимчасовий об'єкт)
Fraction third = 3;                // Копіююча ініціалізація з неявною конвертацією

Для фундаментальних типів (int, double, char) копіююча ініціалізація працює однаково з прямою. Проте для класів ці підходи мають принципові відмінності:

Fraction half(1, 2);

// Компілятор:
// 1. Виділяє пам'ять для об'єкта half
// 2. Викликає конструктор Fraction(1, 2) безпосередньо для half
Чому копіююча ініціалізація небажана для класів:
  • Може призвести до додаткових викликів конструктора копіювання (що ми розглянемо у статті 59)
  • Неявні конвертації типів можуть спричинити тонкі помилки — Fraction f = 5; компілюється без помилок, але створює об'єкт 5/1, що може бути несподіваним
  • Менш ефективна — навіть якщо компілятор оптимізує копіювання, читабельність коду гірша
  • Непередбачувана поведінка при складних типах із динамічною пам'яттю
Правило стилю: Завжди використовуйте пряму ініціалізаціюFraction half(1, 2); або uniform initializationFraction half{1, 2}; для об'єктів класів. Уникайте копіюючої ініціалізації Fraction half = ...;

Неявний конструктор за замовчуванням

Якщо ви не визначите жодного конструктора у класі, компілятор C++ автоматично створить для вас неявний конструктор за замовчуванням (implicitly-generated default constructor). Цей конструктор є публічним, не приймає параметрів і має пусте тіло.

Розглянемо клас без явних конструкторів:

class Date
{
private:
    int day = 15;
    int month = 9;
    int year = 2026;

public:
    int getDay() const { return day; }
    int getMonth() const { return month; }
    int getYear() const { return year; }
};

Хоча ми не написали жодного конструктора, компілятор неявно додає наступний:

class Date
{
private:
    int day = 15;
    int month = 9;
    int year = 2026;

public:
    // Неявний конструктор, згенерований компілятором
    Date()
    {
        // Пусте тіло — ініціалізація відбувається через значення за замовчуванням полів
    }

    int getDay() const { return day; }
    int getMonth() const { return month; }
    int getYear() const { return year; }
};

Цей неявний конструктор дозволяє створювати об'єкти:

int main()
{
    Date today;  // Викликається неявний Date() — поля отримають значення 15, 9, 2026
    
    cout << today.getDay() << "/" << today.getMonth() << "/" << today.getYear() << "\n";
    // Вивід: 15/9/2026
    
    return 0;
}
Неявний конструктор за замовчуванням зникає, як тільки ви визначите будь-який власний конструктор (навіть якщо це параметризований конструктор, а не конструктор за замовчуванням).

Зникнення неявного конструктора

Розглянемо модифікований клас Date з параметризованим конструктором:

class Date
{
private:
    int day;
    int month;
    int year;

public:
    // Параметризований конструктор
    Date(int d, int m, int y)
    {
        day = d;
        month = m;
        year = y;
    }

    int getDay() const { return day; }
    int getMonth() const { return month; }
    int getYear() const { return year; }
};

int main()
{
    Date birthday(16, 9, 2000);   // ✅ Працює — викликається Date(int, int, int)
    
    Date unknown;                  // ❌ Помилка компіляції!
    // Помилка: "no matching constructor for initialization of 'Date'"
    
    return 0;
}

Компілятор видає помилку при створенні unknown, бо:

  1. Ми визначили власний конструктор Date(int, int, int)
  2. Тому компілятор не створив неявний конструктор за замовчуванням Date()
  3. Виклик Date unknown; вимагає конструктора без параметрів, якого немає
Критичне правило: Як тільки ви визначаєте будь-який конструктор у класі (навіть із параметрами), компілятор припиняє генерувати неявний конструктор за замовчуванням. Якщо ваш код десь створює об'єкти без аргументів — виникне помилка компіляції.

Щоб виправити це, потрібно явно визначити конструктор за замовчуванням:

class Date
{
private:
    int day;
    int month;
    int year;

public:
    // Явний конструктор за замовчуванням
    Date()
    {
        day = 1;
        month = 1;
        year = 2000;
    }

    // Параметризований конструктор
    Date(int d, int m, int y)
    {
        day = d;
        month = m;
        year = y;
    }

    int getDay() const { return day; }
    int getMonth() const { return month; }
    int getYear() const { return year; }
};

int main()
{
    Date birthday(16, 9, 2000);   // ✅ Працює
    Date unknown;                  // ✅ Тепер теж працює — викликається Date()
    
    return 0;
}

Або, що простіше, об'єднати обидва конструктори через параметри за замовчуванням:

class Date
{
private:
    int day;
    int month;
    int year;

public:
    // Один універсальний конструктор
    Date(int d = 1, int m = 1, int y = 2000)
    {
        day = d;
        month = m;
        year = y;
    }

    int getDay() const { return day; }
    int getMonth() const { return month; }
    int getYear() const { return year; }
};

int main()
{
    Date birthday(16, 9, 2000);   // ✅ Викликається Date(16, 9, 2000)
    Date unknown;                  // ✅ Викликається Date(1, 1, 2000)
    Date newYear(1, 1);            // ✅ Викликається Date(1, 1, 2000)
    
    return 0;
}
Найкраща практика: Завжди явно визначайте хоча б один конструктор у кожному класі — навіть якщо це просто пустий конструктор за замовчуванням ClassName() {}. Це дає вам повний контроль над процесом створення об'єктів і запобігає несподіванкам при додаванні інших конструкторів пізніше.

Uniform Initialization: сучасний синтаксис C++11

Починаючи з C++11, мова пропонує єдиний універсальний синтаксис для ініціалізації будь-яких типів даних — uniform initialization (уніфікована ініціалізація) з фігурними дужками {}:

// Фундаментальні типи
int age{25};
double pi{3.14159};
char grade{'A'};

// Об'єкти класів
Fraction half{1, 2};
Date today{16, 9, 2026};

// Масиви
int numbers[]{10, 20, 30, 40, 50};

// Стандартні контейнери
std::vector<int> values{1, 2, 3, 4, 5};

Uniform initialization має кілька переваг над класичним синтаксисом з круглими дужками ():

🔒 Захист від звужуючих конвертацій

Uniform initialization не дозволяє неявні звужуючі конвертації (narrowing conversions), що можуть призвести до втрати даних:

int x = 3.7;      // ✅ Компілюється — значення обрізається до 3
int y{3.7};       // ❌ Помилка компіляції — звужуюча конвертація заборонена

Fraction f1(1.5, 2.5);  // ✅ Компілюється — дробові частини відкидаються
Fraction f2{1.5, 2.5};  // ❌ Помилка — параметри типу int, передано double

🎯 Уникнення проблеми Most Vexing Parse

Класичний синтаксис із круглими дужками може призвести до неоднозначності:

Date today();        // ❓ Це об'єкт чи оголошення функції?
// Компілятор розцінює це як оголошення функції today(), що повертає Date

Date today{};        // ✅ Однозначно — це об'єкт з викликом конструктора за замовчуванням

📦 Універсальність синтаксису

Один синтаксис для всіх типів даних:

int a{10};              // Фундаментальні типи
Fraction b{3, 5};       // Класи
int arr[]{1, 2, 3};     // Масиви
std::string s{"Hello"}; // Стандартні класи

Не потрібно пам'ятати різні правила для різних ситуацій.

Рекомендація стилю для курсу: У навчальних матеріалах ми використовуватимемо як класичний синтаксис (), так і uniform initialization {}, щоб ви були знайомі з обома підходами. У реальних проєктах вибір залежить від стандарту коду команди — обидва варіанти є прийнятними.

Класи, що містять інші класи

Одні класи можуть містити інші класи як поля (composition — композиція). Це фундаментальний принцип побудови складних систем із простіших компонентів. Важливо розуміти порядок виклику конструкторів у таких ситуаціях.

Розглянемо два класи: Engine (двигун) і Car (автомобіль):

#include <iostream>

using namespace std;

class Engine
{
private:
    int horsepower;

public:
    Engine()
    {
        horsepower = 150;
        cout << "Engine constructor called\n";
    }

    int getHorsepower() const { return horsepower; }
};

class Car
{
private:
    Engine engine;  // Car містить Engine як поле
    string model;

public:
    Car()
    {
        model = "Unknown";
        cout << "Car constructor called\n";
    }

    string getModel() const { return model; }
    int getEnginePower() const { return engine.getHorsepower(); }
};

int main()
{
    Car myCar;
    
    cout << "Model: " << myCar.getModel() << "\n";
    cout << "Engine power: " << myCar.getEnginePower() << " HP\n";
    
    return 0;
}
g++ Car.cpp -o car && ./car
$ ./car
Engine constructor called
Car constructor called
Model: Unknown
Engine power: 150 HP

Зверніть увагу на порядок виводу: спочатку Engine constructor called, потім Car constructor called. Це відбувається тому, що:

Крок 1: Виділення пам'яті для об'єкта Car

Коли ми пишемо Car myCar;, компілятор виділяє пам'ять для всього об'єкта myCar, включаючи простір для вкладеного поля engine.

Крок 2: Ініціалізація полів-об'єктів

До того, як виконається тіло конструктора Car(), компілятор автоматично викликає конструктори для всіх полів-об'єктів. У нашому випадку — конструктор Engine() для поля engine.

Крок 3: Виконання тіла конструктора Car

Тільки після ініціалізації всіх полів виконується тіло конструктора Car(), де ми встановлюємо model = "Unknown" і виводимо повідомлення.

Чому такий порядок логічний?Уявіть, що в конструкторі Car() потрібно викликати метод engine.start(). Якби поле engine ще не було ініціалізоване, це призвело б до невизначеної поведінки. Тому C++ гарантує, що всі поля-об'єкти будуть повністю сконструйовані до моменту виконання тіла конструктора зовнішнього класу.

Контроль ініціалізації вкладених об'єктів

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

Це вирішується за допомогою списку ініціалізації членів (member initializer list), який ми детально розглянемо у наступній статті. Поки що покажемо короткий приклад:

class Engine
{
private:
    int horsepower;

public:
    // Параметризований конструктор
    Engine(int hp)
    {
        horsepower = hp;
        cout << "Engine constructor: " << hp << " HP\n";
    }

    int getHorsepower() const { return horsepower; }
};

class Car
{
private:
    Engine engine;
    string model;

public:
    // Список ініціалізації: engine(200) викликає Engine(200)
    Car(string m, int hp) : engine(hp)
    {
        model = m;
        cout << "Car constructor: " << model << "\n";
    }

    string getModel() const { return model; }
    int getEnginePower() const { return engine.getHorsepower(); }
};

int main()
{
    Car sportsCar("Ferrari", 500);
    
    cout << sportsCar.getModel() << ": " << sportsCar.getEnginePower() << " HP\n";
    
    return 0;
}
g++ Car.cpp -o car && ./car
$ ./car
Engine constructor: 500 HP
Car constructor: Ferrari
Ferrari: 500 HP

Синтаксис : engine(hp) після сигнатури конструктора Car(string m, int hp) — це список ініціалізації, який дозволяє явно викликати конструктори полів із потрібними параметрами. Детальніше про це механізм ми поговоримо у статті 53 — «Списки ініціалізації та делегуючі конструктори».

Виклик сеттерів у конструкторах: централізація валідації

У попередній статті ми розглянули геттери та сеттери як механізм контрольованого доступу до приватних полів. Сеттери часто містять валідацію (validation) — перевірку коректності переданого значення. Виникає логічне питання: чи потрібно дублювати цю валідацію в конструкторах?

Розглянемо клас Fraction із валідацією знаменника:

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    void setNumerator(int num)
    {
        numerator = num;
    }

    void setDenominator(int den)
    {
        // Валідація: знаменник не може бути нулем
        if (den == 0) {
            cout << "Error: Denominator cannot be zero. Setting to 1.\n";
            denominator = 1;
        } else {
            denominator = den;
        }
    }

    // Конструктор з дублюванням валідації — НЕ найкращий підхід
    Fraction(int num = 0, int den = 1)
    {
        numerator = num;
        
        // Дублювання логіки з setDenominator
        if (den == 0) {
            cout << "Error: Denominator cannot be zero. Setting to 1.\n";
            denominator = 1;
        } else {
            denominator = den;
        }
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
};

У цьому коді валідація знаменника продубльована у двох місцях: у сеттері setDenominator() і в конструкторі. Це створює кілька проблем:

Проблеми дублювання валідації:
  • Порушення принципу DRY (Don't Repeat Yourself) — одна й та сама логіка записана двічі
  • Ускладнення підтримки — якщо правила валідації зміняться, потрібно оновлювати код у кількох місцях
  • Ризик розбіжностей — сеттер і конструктор можуть почати валідувати по-різному після змін
  • Більше коду для тестування — кожну валідацію потрібно перевіряти окремо

Краще рішення: виклик сеттерів з конструктора

Замість дублювання логіки, конструктор може викликати сеттери, делегуючи їм валідацію:

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    void setNumerator(int num)
    {
        numerator = num;
    }

    void setDenominator(int den)
    {
        if (den == 0) {
            cout << "Error: Denominator cannot be zero. Setting to 1.\n";
            denominator = 1;
        } else {
            denominator = den;
        }
    }

    // Конструктор делегує валідацію сеттерам
    Fraction(int num = 0, int den = 1)
    {
        setNumerator(num);
        setDenominator(den);  // Валідація всередині setDenominator
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
};

Тепер валідація знаменника записана в одному місці — у методі setDenominator(). Конструктор просто викликає цей сеттер, автоматично отримуючи всю логіку валідації.

✅ Переваги підходу

  • Єдине джерело істини — валідація в одному місці
  • Легко змінювати правила — оновлення в сеттері автоматично працює для конструктора
  • Менше коду — не потрібно дублювати логіку
  • Консистентність — гарантовано однакові правила при створенні і зміні об'єкта

⚠️ Обмеження підходу

  • Ефективність — виклик методу трохи повільніший за пряме присвоєння (хоча компілятор часто оптимізує це)
  • Складність налагодження — у стеку викликів з'являється додатковий рівень
  • Залежність від віртуальних методів — якщо сеттер віртуальний, поведінка може бути несподіваною в ієрархії класів (тема наслідування)
Найкраща практика: Якщо валідація проста і не змінюватиметься, можна обійтися без виклику сеттерів. Але якщо валідація складна (перевірка діапазону, форматування, обчислення) — використовуйте сеттери в конструкторах для уникнення дублювання.

Заборона створення некоректних об'єктів

У попередньому прикладі ми не блокували створення об'єкта при некоректних даних — просто виправляли значення на безпечне за замовчуванням (denominator = 1). Але іноді потрібно повністю заборонити створення об'єкта, якщо дані не валідні.

У C++ немає вбудованого механізму «відмінити конструктор» (на відміну від Java, де можна кинути виняток у конструкторі). Проте є кілька підходів:

Підхід 1: Викидання винятку (Exceptions)

Конструктор може викинути виняток (throw exception), щоб сигналізувати про неможливість створення об'єкта:

#include <iostream>
#include <stdexcept>  // Для std::invalid_argument

using namespace std;

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    Fraction(int num = 0, int den = 1)
    {
        if (den == 0) {
            // Викидаємо виняток — об'єкт не буде створено
            throw invalid_argument("Denominator cannot be zero");
        }
        
        numerator = num;
        denominator = den;
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
    
    double getValue() const 
    {
        return static_cast<double>(numerator) / denominator;
    }
};

int main()
{
    try {
        Fraction half(1, 2);   // ✅ Працює
        cout << half.getValue() << "\n";
        
        Fraction invalid(5, 0);  // ❌ Викине виняток
        cout << invalid.getValue() << "\n";  // Цей рядок не виконається
    }
    catch (const invalid_argument& e) {
        cerr << "Error: " << e.what() << "\n";
    }
    
    return 0;
}
g++ Fraction.cpp -o fraction && ./fraction
$ ./fraction
0.5
Error: Denominator cannot be zero

При спробі створити Fraction invalid(5, 0) виконання конструктора переривається, виняток викидається до виклику функції, і об'єкт invalid не створюється. Блок catch перехоплює помилку і виводить повідомлення.

Як працюють винятки у конструкторах:
  1. Виділяється пам'ять для об'єкта
  2. Починається виконання конструктора
  3. Виникає виняток (throw) — конструктор припиняє роботу
  4. Виділена пам'ять автоматично звільняється
  5. Об'єкт вважається не створеним — змінна не існує
Це безпечний механізм: якщо конструктор не завершився успішно, об'єкт не з'являється у програмі.

Підхід 2: Накопичення помилок валідації

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

Для цього можна використати колекцію map<string, string> (ключ — назва поля, значення — повідомлення про помилку):

#include <iostream>
#include <map>
#include <string>

using namespace std;

class User
{
private:
    string username;
    string email;
    int age;
    map<string, string> validationErrors;

    // Приватний метод валідації username
    bool validateUsername(const string& name)
    {
        if (name.empty()) {
            validationErrors["username"] = "Username cannot be empty";
            return false;
        }
        if (name.length() < 3) {
            validationErrors["username"] = "Username must be at least 3 characters";
            return false;
        }
        return true;
    }

    // Приватний метод валідації email
    bool validateEmail(const string& mail)
    {
        if (mail.empty()) {
            validationErrors["email"] = "Email cannot be empty";
            return false;
        }
        if (mail.find('@') == string::npos) {
            validationErrors["email"] = "Email must contain @ symbol";
            return false;
        }
        return true;
    }

    // Приватний метод валідації age
    bool validateAge(int a)
    {
        if (a < 0) {
            validationErrors["age"] = "Age cannot be negative";
            return false;
        }
        if (a > 120) {
            validationErrors["age"] = "Age must be less than 120";
            return false;
        }
        return true;
    }

public:
    User(const string& name, const string& mail, int a)
    {
        // Валідуємо всі поля — не зупиняємось на першій помилці
        validateUsername(name);
        validateEmail(mail);
        validateAge(a);

        // Якщо є помилки — викидаємо виняток із детальним повідомленням
        if (!validationErrors.empty()) {
            string errorMessage = "Validation failed:\n";
            for (const auto& error : validationErrors) {
                errorMessage += "  - " + error.first + ": " + error.second + "\n";
            }
            throw invalid_argument(errorMessage);
        }

        // Всі перевірки пройшли — ініціалізуємо об'єкт
        username = name;
        email = mail;
        age = a;
    }

    string getUsername() const { return username; }
    string getEmail() const { return email; }
    int getAge() const { return age; }
};

int main()
{
    try {
        // ✅ Коректні дані
        User validUser("john_doe", "john@example.com", 25);
        cout << "User created: " << validUser.getUsername() << "\n";

        // ❌ Множинні помилки — будуть показані всі одразу
        User invalidUser("ab", "invalid-email", -5);
    }
    catch (const invalid_argument& e) {
        cerr << e.what();
    }

    return 0;
}
g++ User.cpp -o user && ./user
$ ./user
User created: john_doe
Validation failed:
- age: Age cannot be negative
- email: Email must contain @ symbol
- username: Username must be at least 3 characters

✅ Переваги підходу

  • Комплексна валідація — всі помилки збираються разом, а не тільки перша
  • Краща UX — користувач одразу бачить весь список проблем
  • Структурованість — помилки організовані за полями (ключ = назва поля)
  • Легко розширювати — просто додати нові методи валідації для нових полів
  • Повторне використання — методи валідації можна викликати і в сеттерах

⚙️ Особливості реалізації

  • Кожен метод валідації (validateUsername, validateEmail) повертає bool і додає помилку до map, якщо перевірка не пройшла
  • Усі валідації виконуються до перевірки наявності помилок — не зупиняємось на першій
  • map<string, string> автоматично сортує ключі за алфавітом — помилки виводяться упорядковано
  • Після виявлення помилок конструктор викидає invalid_argument — об'єкт не створюється
Використання цього підходу в сеттерах:Ті самі методи валідації можна викликати в сеттерах, забезпечуючи консистентність правил:
void setUsername(const string& name)
{
    validationErrors.clear();  // Очищаємо попередні помилки
    if (validateUsername(name)) {
        username = name;
    } else {
        // Опціонально: вивести помилку або кинути виняток
        cerr << "Invalid username: " << validationErrors["username"] << "\n";
    }
}
Це гарантує, що об'єкт ніколи не переходить у некоректний стан — ані при створенні, ані при зміні через сеттери.

Підхід 3: Assertion для критичних помилок

Якщо некоректні параметри — це помилка програміста, а не користувача, можна використовувати assert:

#include <cassert>

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    Fraction(int num = 0, int den = 1)
    {
        assert(den != 0 && "Denominator cannot be zero");
        
        numerator = num;
        denominator = den;
    }

    // ...
};

int main()
{
    Fraction half(1, 2);     // ✅ Працює
    Fraction invalid(5, 0);  // ❌ Програма аварійно завершиться з повідомленням
    
    return 0;
}
Коли використовувати assert:assert призначений для виявлення помилок програміста під час розробки, а не для обробки помилок користувача. В release-збірках assert відключається (макрос NDEBUG), тому програма продовжить працювати з некоректними даними.Використовуйте assert для:
  • Перевірки інваріантів класу (умов, які завжди мають бути правдивими)
  • Виявлення логічних помилок під час розробки
  • Внутрішніх перевірок, які «ніколи не мають спрацювати»
НЕ використовуйте assert для:
  • Валідації введення користувача
  • Перевірки даних з файлів або мережі
  • Будь-яких ситуацій, які можуть виникнути у виробничому середовищі

Порівняння підходів

ПідхідКоли використовуватиПеревагиНедоліки
Виправлення на безпечне значенняНекритичні помилки, є розумне значення за замовчуваннямПросто, завжди маємо валідний об'єктМоже приховати помилки, неочевідно для користувача
Накопичення помилок (map)Складні об'єкти з багатьма полями, форми введенняПоказує всі помилки одразу, структурованоБільше коду, потрібна колекція для зберігання помилок
Винятки (throw)Критичні помилки, які клієнт має обробитиЧіткий контроль потоку, об'єкт не створюєтьсяOverhead продуктивності, потрібна підтримка винятків
Assertion (assert)Внутрішні інваріанти, помилки програмістаВиявлення багів під час розробкиВідключається в release, не для валідації даних
Рекомендація для курсу:
  • Для простих класів (1-2 поля) — використовуйте виправлення на безпечне значення через сеттери
  • Для складних класів (багато полів, як форми реєстрації) — накопичуйте помилки в map і викидайте виняток із повним списком
  • Для критичних інваріантів (знаменник ≠ 0) — використовуйте throw для негайної зупинки
  • Для внутрішньої логіки — використовуйте assert для виявлення помилок програміста
Винятки (throw) та складні патерни валідації детально розглядатимуться у майбутніх статтях.

Візуалізація стану об'єкта після конструктора

Розглянемо, як виглядає об'єкт Fraction у пам'яті одразу після виклику конструктора. Це допоможе краще зрозуміти, що саме робить конструктор.

class Fraction
{
private:
    int numerator;
    int denominator;

public:
    Fraction(int num = 0, int den = 1)
    {
        numerator = num;
        denominator = (den != 0) ? den : 1;
    }

    int getNumerator() const { return numerator; }
    int getDenominator() const { return denominator; }
};

int main()
{
    Fraction half(1, 2);      // Конструктор ініціалізує поля
    Fraction zero;             // Конструктор використовує значення за замовчуванням
    Fraction five(5);          // Конструктор використовує частково значення за замовчуванням
    
    return 0;
}

Стан об'єктів після виклику конструкторів:

Local Variables — after constructors
Filter
NameTypeValue
half.numeratorint1
half.denominatorint2
zero.numeratorint0
zero.denominatorint1
five.numeratorint5
five.denominatorint1
Running
Process: 12842

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

Конструктор = гарантія коректного стануБез конструктора поля містили б випадкові значення з пам'яті (сміття). Конструктор гарантує, що кожен об'єкт одразу після створення знаходиться у валідному стані — готовий до використання без додаткових маніпуляцій.

Практичне завдання: клас Ball

Закріпимо знання на практиці. Створіть клас Ball (куля), який моделює спортивний м'яч із заданим кольором і радіусом.

Резюме та ключові висновки

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

🎯 Призначення конструкторів

  • Конструктор викликається автоматично під час створення об'єкта
  • Ім'я конструктора збігається з ім'ям класу
  • Конструктор не має типу повернення (навіть не void)
  • Призначення: ініціалізація полів і підготовка об'єкта до використання

📂 Типи конструкторів

  • Конструктор за замовчуванням — без параметрів або з усіма параметрами за замовчуванням
  • Параметризований конструктор — приймає один або більше параметрів
  • Неявний конструктор — автоматично створюється компілятором, якщо ви не визначили жодного конструктора
  • Параметри за замовчуванням дозволяють зменшити кількість конструкторів

⚠️ Важливі правила

  • Неявний конструктор зникає, як тільки ви визначите будь-який власний конструктор
  • Завжди створюйте хоча б один конструктор явно — це дає контроль і запобігає помилкам
  • Використовуйте пряму () або uniform initialization {}; уникайте копіюючої =
  • Для полів-об'єктів конструктори викликаються до виконання тіла конструктора зовнішнього класу

🔜 Що далі

У наступній статті ми розглянемо списки ініціалізації членів (member initializer lists) — більш ефективний спосіб ініціалізації полів класу. Ви дізнаєтесь:

  • Чому присвоєння у тілі конструктора — не завжди оптимально
  • Як ініціалізувати const-поля та посилання
  • Що таке делегуючі конструктори (C++11)
Найкраща практика для початківців:
  1. Створюйте хоча б один конструктор у кожному класі
  2. Використовуйте параметри за замовчуванням для зменшення кількості конструкторів
  3. Встановлюйте безпечні значення за замовчуванням (наприклад, 0/1 для дробу, а не 0/0)
  4. Завжди валідуйте параметри (наприклад, перевіряйте знаменник на нуль)
  5. Віддавайте перевагу uniform initialization {} у сучасному коді C++11+
Copyright © 2026