C++

Зрізка об'єктів, віртуальне наслідування та поліморфний вивід

Небезпечний дефект зрізки об'єктів при передачі за значенням. Віртуальне базове наслідування як розв'язання проблеми ромба. Патерн поліморфного виводу через делегування operator<< віртуальному методу print(). Захист від втрати даних у поліморфних ієрархіях.

Коли поліморфізм перестає працювати

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

PolymorphismBreak.cpp
#include <iostream>

class Animal
{
protected:
    std::string name;

public:
    Animal(std::string n) : name(n) {}
    
    virtual std::string speak() const
    {
        return name + " видає звук";
    }
};

class Dog : public Animal
{
private:
    std::string breed;

public:
    Dog(std::string n, std::string b) : Animal(n), breed(b) {}
    
    std::string speak() const override
    {
        return name + " (" + breed + ") гавкає: Гав-гав!";
    }
};

// ❌ Функція приймає Animal ЗА ЗНАЧЕННЯМ
void printAnimalSound(Animal animal)
{
    std::cout << animal.speak() << "\n";
}

int main()
{
    Dog dog("Рекс", "німецька вівчарка");
    
    // Передаємо Dog у функцію, що очікує Animal за значенням
    printAnimalSound(dog);
    
    return 0;
}

Очікуваний результат: Рекс (німецька вівчарка) гавкає: Гав-гав!

Фактичний результат:

./PolymorphismBreak
Рекс видає звук

Що сталося? Чому собака перестала гавкати і породу втратила? Гавкіт зник, порода стерлася, об'єкт Dog перетворився на примітивного предка Animal. Це не помилка компілятора — це зрізка об'єктів (object slicing), один із найпідступніших дефектів C++, що часто залишається непоміченим до моменту виникнення серйозних проблем у production.

У цій статті ми детально розберемо механізм зрізки, дослідимо віртуальне базове наслідування як остаточне розв'язання проблеми ромба та опануємо патерн поліморфного виводу у потоки через делегування operator<< віртуальному методу.

🎯 Мета статті

  • Зрозуміти фізичну природу зрізки об'єктів: що відбувається в пам'яті при копіюванні за значенням
  • Навчитися виявляти та запобігати зрізці через передачу за посиланням або покажчиком
  • Опанувати віртуальне базове наслідування (virtual public Base) для розв'язання проблеми ромба
  • Дослідити обов'язок найбільш похідного класу (most derived class) ініціалізувати віртуальну базу
  • Реалізувати патерн поліморфного виводу: невіртуальний operator<< + віртуальний print(std::ostream&)

🔑 Ключові терміни

  • Зрізка об'єктів (object slicing): втрата даних похідного класу при копіюванні об'єкта в базовий тип за значенням
  • Віртуальне базове наслідування (virtual base inheritance): ключове слово virtual у списку наслідування для створення спільного екземпляра базового класу
  • Найбільш похідний клас (most derived class): «нижній» клас у ієрархії наслідування, що безпосередньо створюється
  • Проблема ромба (diamond problem): дублювання полів спільного предка при множинному наслідуванні через проміжні класи
  • Поліморфний вивід (polymorphic output): делегування виводу об'єкта в потік через віртуальний метод для збереження поліморфної поведінки

Зрізка об'єктів: анатомія втрати даних

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

Зрізка об'єктів (object slicing) — це фізична втрата частини об'єкта при копіюванні об'єкта похідного класу в змінну базового типу за значенням. Коли компілятор виконує таке копіювання, він:

  1. Виділяє пам'ять розміру базового класу (менший блок)
  2. Копіює лише базову частину похідного об'єкта
  3. Відсікає (slices off) всі додаткові поля та переписує вказівник віртуальної таблиці (vptr)

Подивимося на це під мікроскопом:

SlicingMemory.cpp
#include <iostream>

class Base
{
protected:
    int baseData;

public:
    Base(int data) : baseData(data)
    {
        std::cout << "Base конструктор: baseData = " << baseData << "\n";
    }
    
    virtual void display() const
    {
        std::cout << "[Base] baseData = " << baseData << "\n";
    }
    
    virtual ~Base() = default;
};

class Derived : public Base
{
private:
    int derivedData;
    double extraValue;

public:
    Derived(int base, int derived, double extra)
        : Base(base), derivedData(derived), extraValue(extra)
    {
        std::cout << "Derived конструктор: derivedData = " << derivedData 
                  << ", extraValue = " << extraValue << "\n";
    }
    
    void display() const override
    {
        std::cout << "[Derived] baseData = " << baseData 
                  << ", derivedData = " << derivedData 
                  << ", extraValue = " << extraValue << "\n";
    }
};

int main()
{
    std::cout << "=== Створення об'єкта Derived ===\n";
    Derived derived(10, 20, 3.14);
    
    std::cout << "\n=== Виклик через посилання (правильно) ===\n";
    Base& refBase = derived;
    refBase.display();  // ✅ Поліморфізм працює
    
    std::cout << "\n=== Присвоєння за значенням (зрізка!) ===\n";
    Base slicedBase = derived;  // ❌ Зрізка об'єкта
    slicedBase.display();  // Викличеться Base::display()
    
    std::cout << "\nРозмір Base:    " << sizeof(Base) << " байт\n";
    std::cout << "Розмір Derived: " << sizeof(Derived) << " байт\n";
    
    return 0;
}
./SlicingMemory
=== Створення об'єкта Derived ===
Base конструктор: baseData = 10
Derived конструктор: derivedData = 20, extraValue = 3.14
=== Виклик через посилання (правильно) ===
[Derived] baseData = 10, derivedData = 20, extraValue = 3.14
=== Присвоєння за значенням (зрізка!) ===
Base конструктор: baseData = 10
[Base] baseData = 10
Розмір Base: 16 байт
Розмір Derived: 32 байт

Що ми бачимо:

  1. Об'єкт Derived займає 32 байти (8 байт vptr + 4 байт baseData + вирівнювання + 4 байт derivedData + 8 байт double extraValue)
  2. Об'єкт Base займає лише 16 байт (8 байт vptr + 4 байт baseData + вирівнювання)
  3. При копіюванні derived у slicedBase половина даних фізично втрачається — derivedData та extraValue відсікаються
  4. Вказівник віртуальної таблиці (vptr) перезаписується: замість вказування на vtable Derived, він тепер вказує на vtable Base
Пам'ять до і після зрізки
Hex Dump / ASCII
0x00401000
100000000A000000140000001F85EB51
0x00401010
B81E0940000000000A00000000000000
Offset: 32 bytes
Big Endian

У цьому дампі пам'яті перші 16 байтів (виділені) — це те, що залишилося в slicedBase. Решта даних (байти 16–31) існували лише в оригінальному об'єкті derived і були втрачені при копіюванні.

Зрізка — це не помилка компіляції: компілятор C++ дозволяє присвоювати об'єкти похідних класів об'єктам базових класів. Це легальна операція з погляду мови, але вона призводить до втрати інформації та порушення поліморфізму. Компілятор навіть не попередить вас про зрізку — код скомпілюється без жодних попереджень.

Коли відбувається зрізка

Зрізка відбувається у трьох основних сценаріях:

1. Пряме присвоєння об'єктів

Derived derived(10, 20, 3.14);
Base base = derived;  // ❌ Зрізка: derived відсікається до Base

2. Передача параметра за значенням

void processAnimal(Animal animal)  // ❌ Параметр за значенням
{
    animal.speak();  // Завжди викликається Animal::speak()
}

Dog dog("Рекс", "лабрадор");
processAnimal(dog);  // Зрізка при копіюванні dog → animal

3. Повернення з функції за значенням (рідко)

Base createDerived()
{
    Derived d(10, 20, 3.14);
    return d;  // ❌ Зрізка при поверненні
}
Оптимізація RVO/NRVO: сучасні компілятори застосовують Return Value Optimization (RVO) та Named Return Value Optimization (NRVO), які елімінують копіювання при поверненні з функції. Але навіть з цими оптимізаціями тип повернення Base означає, що об'єкт конструюється як Base, тому частина Derived ніколи не існуватиме.

Зрізка та контейнери STL

Особливо підступна зрізка проявляється при роботі з контейнерами стандартної бібліотеки. Початківці часто намагаються зберігати поліморфні об'єкти у std::vector за значенням:

SlicingVector.cpp
#include <iostream>
#include <vector>

class Shape
{
public:
    virtual std::string getType() const { return "Shape"; }
    virtual ~Shape() = default;
};

class Circle : public Shape
{
public:
    std::string getType() const override { return "Circle"; }
};

class Rectangle : public Shape
{
public:
    std::string getType() const override { return "Rectangle"; }
};

int main()
{
    std::vector<Shape> shapes;  // ❌ Вектор зберігає Shape за значенням
    
    shapes.push_back(Circle());     // Зрізка: Circle → Shape
    shapes.push_back(Rectangle());  // Зрізка: Rectangle → Shape
    
    for (const Shape& shape : shapes) {
        std::cout << shape.getType() << "\n";  // Завжди виводить "Shape"
    }
    
    return 0;
}
./SlicingVector
Shape
Shape

Проблема: std::vector<Shape> зберігає об'єкти за значенням. Коли ми викликаємо push_back(Circle()), вектор виділяє пам'ять розміру sizeof(Shape) і копіює лише базову частину.

Розв'язання: використовуйте вектор покажчиків:

VectorPointers.cpp
std::vector<Shape*> shapes;

shapes.push_back(new Circle());
shapes.push_back(new Rectangle());

for (const Shape* shape : shapes) {
    std::cout << shape->getType() << "\n";  // ✅ Поліморфізм працює
}

// Обов'язкове очищення
for (Shape* shape : shapes) {
    delete shape;
}
./VectorPointers
Circle
Rectangle

Або, краще, використовуйте розумні покажчики (детальніше у статтях про move-семантику):

std::vector<std::unique_ptr<Shape>> shapes;

shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Rectangle>());

for (const auto& shape : shapes) {
    std::cout << shape->getType() << "\n";  // ✅ Безпечно і без витоків пам'яті
}

Захист від зрізки: правила безпечного поліморфізму

Передача за посиланням або покажчиком

Золоте правило поліморфізму: ніколи не передавайте поліморфні об'єкти за значенням. Завжди використовуйте посилання (const T&) або покажчики (T*).

void processShape(Shape shape)  // Параметр за значенням
{
    std::cout << shape.getType() << "\n";
}

Circle circle;
processShape(circle);  // Зрізка: Circle → Shape
// Виведе "Shape" замість "Circle"

Переваги посилання над покажчиком:

  • Не потребує перевірки на nullptr (посилання завжди валідні)
  • Синтаксис виклику методів простіший (. замість ->)
  • Неможливо випадково переприсвоїти (const посилання)

Переваги покажчика над посиланням:

  • Можна передати nullptr для позначення «відсутність об'єкта»
  • Можна змінювати, на що вказує покажчик (без const)
  • Явність: одразу видно, що це не копія

Заборона копіювання базового класу

Якщо ваш базовий клас призначений виключно для поліморфізму і ніколи не повинен копіюватися, заборoніть копіювання через = delete:

NoCopyBase.cpp
#include <iostream>

class Shape
{
public:
    // Заборона конструктора копіювання
    Shape(const Shape&) = delete;
    
    // Заборона оператора присвоювання
    Shape& operator=(const Shape&) = delete;
    
    // Віртуальний деструктор обов'язковий
    virtual ~Shape() = default;
    
    virtual std::string getType() const = 0;

protected:
    // Дозволити похідним класам викликати дефолтний конструктор
    Shape() = default;
};

class Circle : public Shape
{
public:
    std::string getType() const override { return "Circle"; }
};

int main()
{
    Circle c1;
    
    // ❌ Помилка компіляції: копіювання заборонене
    // Shape shape = c1;
    
    // ✅ Посилання та покажчики дозволені
    Shape& ref = c1;
    Shape* ptr = &c1;
    
    std::cout << ref.getType() << "\n";
    std::cout << ptr->getType() << "\n";
    
    return 0;
}
./NoCopyBase
Circle
Circle

Спроба скомпілювати Shape shape = c1; призведе до помилки:

error: use of deleted function 'Shape::Shape(const Shape&)'
note: declared here: Shape(const Shape&) = delete

Це компіляційний захист від зрізки — найкращий вид захисту.

Правило Effective C++: якщо клас призначений для наслідування та містить віртуальні функції, зробіть його копіювання або забороненим (= delete), або захищеним (protected). Це запобігає випадковій зрізці на етапі компіляції.

Віртуальне базове наслідування: остаточне розв'язання проблеми ромба

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

У статті 74 ми розглянули множинне наслідування та проблему ромба (diamond problem): коли клас наслідує від двох класів, що мають спільного предка, виникає дублювання полів предка.

Пригадаємо класичний приклад:

DiamondProblem.cpp
#include <iostream>

class Device
{
protected:
    int powerRating;

public:
    Device(int power) : powerRating(power)
    {
        std::cout << "Device конструктор: power = " << power << "\n";
    }
    
    int getPower() const { return powerRating; }
};

class Printer : public Device
{
public:
    Printer(int power) : Device(power)
    {
        std::cout << "Printer конструктор\n";
    }
};

class Scanner : public Device
{
public:
    Scanner(int power) : Device(power)
    {
        std::cout << "Scanner конструктор\n";
    }
};

class Copier : public Printer, public Scanner
{
public:
    Copier(int printerPower, int scannerPower)
        : Printer(printerPower), Scanner(scannerPower)
    {
        std::cout << "Copier конструктор\n";
    }
};

int main()
{
    Copier copier(100, 50);
    
    // ❌ Помилка компіляції: неоднозначність
    // std::cout << copier.getPower();
    
    // Потрібна явна кваліфікація
    std::cout << "Printer power: " << copier.Printer::getPower() << "\n";
    std::cout << "Scanner power: " << copier.Scanner::getPower() << "\n";
    
    return 0;
}
./DiamondProblem
Device конструктор: power = 100
Printer конструктор
Device конструктор: power = 50
Scanner конструктор
Copier конструктор
Printer power: 100
Scanner power: 50

Проблеми:

  1. Дублювання в пам'яті: Copier містить два окремих екземпляри Device — один через Printer, другий через Scanner
  2. Неоднозначність: виклик copier.getPower() не компілюється — компілятор не знає, який getPower() викликати
  3. Логічна неузгодженість: копіювальний пристрій фізично має два різних значення powerRating, хоча логічно має бути лише один
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

class Device {
    # powerRating: int
    + getPower(): int
}

class Printer {
}

class Scanner {
}

class Copier {
}

Device <|-- Printer
Device <|-- Scanner
Printer <|-- Copier
Scanner <|-- Copier

note right of Copier #FFE4E1
  Copier містить ДВА екземпляри Device:
  - один через Printer
  - один через Scanner
  
  Розмір пам'яті подвоюється!
end note

@enduml

Віртуальне наслідування: спільний екземпляр предка

Віртуальне базове наслідування (virtual base inheritance) — це механізм, що змушує проміжні класи (Printer, Scanner) спільно використовувати єдиний екземпляр базового класу (Device). Синтаксис: додати ключове слово virtual у специфікатор наслідування:

VirtualBaseInheritance.cpp
#include <iostream>

class Device
{
protected:
    int powerRating;

public:
    Device(int power) : powerRating(power)
    {
        std::cout << "Device конструктор: power = " << power << "\n";
    }
    
    int getPower() const { return powerRating; }
};

// virtual public Device — віртуальне наслідування
class Printer : virtual public Device
{
public:
    Printer(int power) : Device(power)
    {
        std::cout << "Printer конструктор\n";
    }
};

// virtual public Device — віртуальне наслідування
class Scanner : virtual public Device
{
public:
    Scanner(int power) : Device(power)
    {
        std::cout << "Scanner конструктор\n";
    }
};

class Copier : public Printer, public Scanner
{
public:
    // Найбільш похідний клас викликає конструктор віртуальної бази напряму
    Copier(int power)
        : Device(power),           // ✅ Copier відповідає за Device
          Printer(power),          // Цей виклик ігнорується
          Scanner(power)           // Цей виклик ігнорується
    {
        std::cout << "Copier конструктор\n";
    }
};

int main()
{
    Copier copier(120);
    
    // ✅ Тепер немає неоднозначності — Device існує в одному екземплярі
    std::cout << "Power: " << copier.getPower() << "\n";
    
    std::cout << "\nРозмір Copier: " << sizeof(Copier) << " байт\n";
    
    return 0;
}
./VirtualBaseInheritance
Device конструктор: power = 120
Printer конструктор
Scanner конструктор
Copier конструктор
Power: 120
Розмір Copier: 40 байт

Ключові зміни:

  1. virtual public Device у Printer та Scanner — позначає віртуальне наслідування
  2. Device створюється лише один раз — зверніть увагу, що Device конструктор виводиться лише один раз
  3. Neм неоднозначності — copier.getPower() працює без явної кваліфікації
  4. Розмір пам'яті менший — лише один екземпляр Device замість двох
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

class Device {
    # powerRating: int
    + getPower(): int
}

class Printer {
}

class Scanner {
}

class Copier {
}

Device <|-- Printer : <<virtual>>
Device <|-- Scanner : <<virtual>>
Printer <|-- Copier
Scanner <|-- Copier

note right of Copier #DCFCE7
  Copier містить ОДИН екземпляр Device,
  спільний для Printer і Scanner.
  
  Віртуальне наслідування розв'язує
  проблему ромба!
end note

@enduml

Порядок конструювання віртуальних баз

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

Порядок конструювання для Copier:

  1. Віртуальна база Device — конструюється найпершою
  2. Невіртуальна база Printer — конструюється другою (виклик Device(power) ігнорується)
  3. Невіртуальна база Scanner — конструюється третьою (виклик Device(power) ігнорується)
  4. Сам Copier — конструюється останнім
Call Stack під час конструювання Copier
Filter

Обов'язок найбільш похідного класу

Найбільш похідний клас (most derived class) — це клас на «дні» ієрархії наслідування, об'єкт якого безпосередньо створюється. У нашому прикладі це Copier.

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

  • Copier зобов'язаний викликати Device(power) напряму у списку ініціалізації
  • Виклики Device(power) у Printer та Scanner ігноруються при створенні Copier
  • Але ці виклики активуються при створенні Printer або Scanner самостійно
MostDerivedClass.cpp
int main()
{
    std::cout << "=== Створення Copier ===\n";
    Copier copier(120);
    // Device конструюється з 120 (викликається з Copier)
    
    std::cout << "\n=== Створення Printer окремо ===\n";
    Printer printer(80);
    // Device конструюється з 80 (викликається з Printer)
    
    std::cout << "\n=== Створення Scanner окремо ===\n";
    Scanner scanner(60);
    // Device конструюється з 60 (викликається з Scanner)
    
    return 0;
}
./MostDerivedClass
=== Створення Copier ===
Device конструктор: power = 120
Printer конструктор
Scanner конструктор
Copier конструктор
=== Створення Printer окремо ===
Device конструктор: power = 80
Printer конструктор
=== Створення Scanner окремо ===
Device конструктор: power = 60
Scanner конструктор

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

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

Поліморфний вивід у потоки: делегування через віртуальний метод

Проблема невіртуального operator<<

У статті 63 ми навчилися перевантажувати operator<< для виводу об'єктів класів у потік std::cout. Але виникає проблема: як поєднати перевантаження операторів із поліморфізмом?

Спроба перевизначити operator<< у похідному класі не працює:

NonVirtualOperator.cpp
#include <iostream>

class Animal
{
public:
    virtual std::string getName() const { return "Animal"; }
    
    // Перевантаження operator<< як дружня функція
    friend std::ostream& operator<<(std::ostream& out, const Animal& animal)
    {
        out << "Я " << animal.getName();
        return out;
    }
    
    virtual ~Animal() = default;
};

class Dog : public Animal
{
public:
    std::string getName() const override { return "Dog"; }
    
    // Спроба "перевизначити" operator<<
    friend std::ostream& operator<<(std::ostream& out, const Dog& dog)
    {
        out << "Я " << dog.getName() << " і гавкаю!";
        return out;
    }
};

int main()
{
    Dog dog;
    Animal& animalRef = dog;
    
    std::cout << dog << "\n";        // Виклик Dog::operator<<
    std::cout << animalRef << "\n";  // ❌ Виклик Animal::operator<<
    
    return 0;
}
./NonVirtualOperator
Я Dog і гавкаю!
Я Dog

Проблема: другий рядок виводу не викликає Dog::operator<<, хоча animalRef фактично посилається на Dog. Чому?

Причини:

  1. Оператори не можуть бути віртуальними методами: перевантаження операторів зазвичай реалізуються як дружні функції, які не є методами класу. Лише методи можуть бути віртуальними.
  2. Різні сигнатури: Animal::operator<< приймає const Animal&, а Dog::operator<< приймає const Dog&. Це перевантаження (overloading), а не перевизначення (overriding). Перевизначення вимагає однакових сигнатур.
  3. Вибір на етапі компіляції: компілятор обирає версію operator<< на основі типу посилання (Animal&), а не фактичного типу об'єкта (Dog). Це статичне зв'язування, а не динамічне.
Теоретична можливість: технічно можна зробити operator<< методом класу, але це порушує ідіоматичний C++ код. Стандартний підхід — дружня функція з лівим операндом std::ostream&. Таку функцію неможливо зробити віртуальною.

Патерн: делегування віртуальному методу print()

Рішення полягає у розділенні відповідальностей:

  1. Невіртуальна дружня функція operator<< — обробляє взаємодію з потоком
  2. Віртуальний метод print(std::ostream& out) — виконує фактичний вивід даних

Оператор << делегує роботу віртуальному методу print(), який вже коректно перевизначається у похідних класах:

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

class Animal
{
protected:
    std::string name;

public:
    Animal(std::string n) : name(n) {}
    
    virtual ~Animal() = default;
    
    // Невіртуальний оператор виводу — делегує роботу print()
    friend std::ostream& operator<<(std::ostream& out, const Animal& animal)
    {
        return animal.print(out);  // Делегування віртуальному методу
    }
    
    // Віртуальний метод print() — перевизначається у похідних класах
    virtual std::ostream& print(std::ostream& out) const
    {
        out << name << " (Animal)";
        return out;
    }
};

class Dog : public Animal
{
private:
    std::string breed;

public:
    Dog(std::string n, std::string b) : Animal(n), breed(b) {}
    
    // Перевизначення віртуального методу print()
    std::ostream& print(std::ostream& out) const override
    {
        out << name << " (" << breed << ", Dog)";
        return out;
    }
};

class Cat : public Animal
{
private:
    int lives;

public:
    Cat(std::string n, int l) : Animal(n), lives(l) {}
    
    std::ostream& print(std::ostream& out) const override
    {
        out << name << " (Cat із " << lives << " життями)";
        return out;
    }
};

int main()
{
    Dog dog("Рекс", "німецька вівчарка");
    Cat cat("Мурчик", 9);
    
    // Прямий вивід — працює очевидно
    std::cout << "Прямий вивід:\n";
    std::cout << "  " << dog << "\n";
    std::cout << "  " << cat << "\n\n";
    
    // Поліморфний вивід через посилання на базовий клас
    std::cout << "Поліморфний вивід:\n";
    Animal& animalRef1 = dog;
    Animal& animalRef2 = cat;
    
    std::cout << "  " << animalRef1 << "\n";  // ✅ Викликає Dog::print()
    std::cout << "  " << animalRef2 << "\n";  // ✅ Викликає Cat::print()
    
    return 0;
}
./PolymorphicOutput
Прямий вивід:
Рекс (німецька вівчарка, Dog)
Мурчик (Cat із 9 життями)
Поліморфний вивід:
Рекс (німецька вівчарка, Dog)
Мурчик (Cat із 9 життями)

Як це працює:

  1. Виклик std::cout << animalRef1:
    • Компілятор знаходить operator<<(std::ostream&, const Animal&) — єдина версія для всієї ієрархії
    • Оператор викликає animal.print(out) — посилання animal має тип Animal&, але фактично вказує на Dog
  2. Віртуальний виклик animal.print(out):
    • Компілятор перевіряє vptr об'єкта dog
    • Знаходить Dog::print() у віртуальній таблиці
    • Викликає Dog::print(), а не Animal::print()
  3. Повернення потоку:
    • Dog::print() повертає std::ostream&
    • Оператор << повертає той самий потік
    • Це дозволяє ланцюговий вивід: std::cout << a << b << c
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

actor "std::cout" as Cout
participant "operator<<" as Op
participant "vptr/vtable" as VTable
participant "Dog::print()" as DogPrint

Cout -> Op : operator<<(cout, animalRef)
activate Op

Op -> VTable : animalRef.print(cout)
note right #DBEAFE
  animalRef має тип Animal&,
  але вказує на об'єкт Dog
end note

VTable -> DogPrint : виклик через vtable
activate DogPrint

DogPrint -> DogPrint : out << name << breed
DogPrint --> VTable : return out
deactivate DogPrint

VTable --> Op : return out
Op --> Cout : return cout
deactivate Op

@enduml

Переваги патерну делегування

✅ Єдина версія operator<<

Не потрібно писати operator<< в кожному похідному класі. Одна дружня функція в базовому класі працює для всієї ієрархії.
NameTypeValue
◢phasestring
◢powerRatingint
◢nextstring"Printer → Scanner → Copier"
Running
Process: 12842

✅ Повний поліморфізм

Вивід через Animal& або Animal* автоматично викликає правильну версію print() залежно від фактичного типу об'єкта.

✅ Підтримка ланцюгових операцій

Повернення std::ostream& дозволяє писати std::cout << obj1 << " text " << obj2.

✅ Консистентність з STL

Патерн узгоджується зі стандартним способом виводу об'єктів у потоки C++ (як std::string, std::vector тощо).

::

Правило проєктування: якщо ваша ієрархія класів потребує поліморфного виводу, завжди використовуйте патерн «невіртуальний operator<< + віртуальний print()». Це стандартна ідіома C++, яку застосовують у промисловому коді та стандартній бібліотеці (наприклад, std::exception::what()).

Комбінування з базовою реалізацією

Часто похідний клас хоче доповнити, а не замінити вивід базового класу. Для цього можна викликати Base::print() з похідного методу:

InheritedPrint.cpp
class Employee
{
protected:
    std::string name;
    int id;

public:
    Employee(std::string n, int i) : name(n), id(i) {}
    
    virtual ~Employee() = default;
    
    friend std::ostream& operator<<(std::ostream& out, const Employee& emp)
    {
        return emp.print(out);
    }
    
    virtual std::ostream& print(std::ostream& out) const
    {
        out << "[ID: " << id << "] " << name;
        return out;
    }
};

class Manager : public Employee
{
private:
    int teamSize;

public:
    Manager(std::string n, int i, int size)
        : Employee(n, i), teamSize(size) {}
    
    std::ostream& print(std::ostream& out) const override
    {
        Employee::print(out);  // Виклик базової версії
        out << ", Team: " << teamSize << " осіб";
        return out;
    }
};

int main()
{
    Manager manager("Олена Коваль", 101, 8);
    
    Employee& empRef = manager;
    std::cout << empRef << "\n";
    
    return 0;
}
./InheritedPrint
[ID: 101] Олена Коваль, Team: 8 осіб

Виклик Employee::print(out) дозволяє уникнути дублювання коду — базовий клас відповідає за вивід своїх полів, похідний додає лише специфічну інформацію.

Практичні завдання

Резюме

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

✅ Зрізка об'єктів

Причина: копіювання об'єкта похідного класу в змінну базового типу за значенням

Наслідки:

  • Фізична втрата полів похідного класу
  • Переписування vptr — порушення поліморфізму
  • Виклик методів базового класу замість похідного

Захист:

  • Завжди передавайте через const T& або T*
  • Заборoніть копіювання базового класу (= delete)
  • Використовуйте покажчики у контейнерах STL

✅ Віртуальне базове наслідування

Проблема: дублювання спільного предка при множинному наслідуванні (проблема ромба)

Розв'язання: class Derived : virtual public Base

Ключові правила:

  • Віртуальна база конструюється першою
  • Найбільш похідний клас відповідає за ініціалізацію
  • Виклики конструктора у проміжних класах ігноруються
  • Єдиний екземпляр бази для всієї ієрархії

✅ Поліморфний вивід

Проблема: operator<< не може бути віртуальним

Патерн делегування:

  1. Невіртуальна дружня функція operator<<
  2. Віртуальний метод print(std::ostream&)
  3. Оператор делегує роботу print()

Переваги:

  • Єдина версія operator<< для всієї ієрархії
  • Повний поліморфізм при виводі
  • Підтримка ланцюгових операцій
Типові помилки, яких слід уникати:
  1. Передача поліморфних об'єктів за значенням у функції — завжди призводить до зрізки
  2. Зберігання об'єктів за значенням у std::vector<Base> — втрата поліморфізму
  3. Забування ініціалізації віртуальної бази у найбільш похідному класі
  4. Спроба зробити operator<< віртуальним — технічно неможливо

У наступній статті ми опануємо динамічне приведення типів (dynamic_cast) та RTTI (Run-Time Type Information) — механізми безпечного спуску вниз по ієрархії наслідування (downcasting) та отримання інформації про тип об'єкта під час виконання програми. Ми дізнаємося, коли downcasting виправданий, а коли свідчить про погане архітектурне проєктування.

Copyright © 2026