Зрізка об'єктів, віртуальне наслідування та поліморфний вивід
Коли поліморфізм перестає працювати
У попередніх статтях ми опанували віртуальні функції та поліморфізм — механізми, що дозволяють викликати правильний метод похідного класу через покажчик або посилання на базовий клас. Це працює елегантно та безпечно, якщо дотримуватися правил. Але що станеться, якщо ми порушимо головне правило поліморфізму — завжди працювати через покажчики або посилання?
#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;
}
Очікуваний результат: Рекс (німецька вівчарка) гавкає: Гав-гав!
Фактичний результат:
Що сталося? Чому собака перестала гавкати і породу втратила? Гавкіт зник, порода стерлася, об'єкт 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) — це фізична втрата частини об'єкта при копіюванні об'єкта похідного класу в змінну базового типу за значенням. Коли компілятор виконує таке копіювання, він:
- Виділяє пам'ять розміру базового класу (менший блок)
- Копіює лише базову частину похідного об'єкта
- Відсікає (slices off) всі додаткові поля та переписує вказівник віртуальної таблиці (vptr)
Подивимося на це під мікроскопом:
#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;
}
Що ми бачимо:
- Об'єкт
Derivedзаймає 32 байти (8 байт vptr + 4 байтbaseData+ вирівнювання + 4 байтderivedData+ 8 байтdouble extraValue) - Об'єкт
Baseзаймає лише 16 байт (8 байт vptr + 4 байтbaseData+ вирівнювання) - При копіюванні
derivedуslicedBaseполовина даних фізично втрачається —derivedDataтаextraValueвідсікаються - Вказівник віртуальної таблиці (vptr) перезаписується: замість вказування на vtable
Derived, він тепер вказує на vtableBase
Пам'ять до і після зрізки
У цьому дампі пам'яті перші 16 байтів (виділені) — це те, що залишилося в slicedBase. Решта даних (байти 16–31) існували лише в оригінальному об'єкті derived і були втрачені при копіюванні.
Коли відбувається зрізка
Зрізка відбувається у трьох основних сценаріях:
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; // ❌ Зрізка при поверненні
}
Base означає, що об'єкт конструюється як Base, тому частина Derived ніколи не існуватиме.Зрізка та контейнери STL
Особливо підступна зрізка проявляється при роботі з контейнерами стандартної бібліотеки. Початківці часто намагаються зберігати поліморфні об'єкти у std::vector за значенням:
#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;
}
Проблема: std::vector<Shape> зберігає об'єкти за значенням. Коли ми викликаємо push_back(Circle()), вектор виділяє пам'ять розміру sizeof(Shape) і копіює лише базову частину.
Розв'язання: використовуйте вектор покажчиків:
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;
}
Або, краще, використовуйте розумні покажчики (детальніше у статтях про 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"
void processShape(const Shape& shape) // Параметр за посиланням
{
std::cout << shape.getType() << "\n";
}
Circle circle;
processShape(circle); // ✅ Поліморфізм працює
// Виведе "Circle"
void processShape(const Shape* shape) // Параметр-покажчик
{
if (shape) { // Перевірка на nullptr
std::cout << shape->getType() << "\n";
}
}
Circle circle;
processShape(&circle); // ✅ Поліморфізм працює
// Виведе "Circle"
Переваги посилання над покажчиком:
- Не потребує перевірки на
nullptr(посилання завжди валідні) - Синтаксис виклику методів простіший (
.замість->) - Неможливо випадково переприсвоїти (
constпосилання)
Переваги покажчика над посиланням:
- Можна передати
nullptrдля позначення «відсутність об'єкта» - Можна змінювати, на що вказує покажчик (без
const) - Явність: одразу видно, що це не копія
Заборона копіювання базового класу
Якщо ваш базовий клас призначений виключно для поліморфізму і ніколи не повинен копіюватися, заборoніть копіювання через = delete:
#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;
}
Спроба скомпілювати Shape shape = c1; призведе до помилки:
error: use of deleted function 'Shape::Shape(const Shape&)'
note: declared here: Shape(const Shape&) = delete
Це компіляційний захист від зрізки — найкращий вид захисту.
= delete), або захищеним (protected). Це запобігає випадковій зрізці на етапі компіляції.Віртуальне базове наслідування: остаточне розв'язання проблеми ромба
Проблема дублювання спільного предка
У статті 74 ми розглянули множинне наслідування та проблему ромба (diamond problem): коли клас наслідує від двох класів, що мають спільного предка, виникає дублювання полів предка.
Пригадаємо класичний приклад:
#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;
}
Проблеми:
- Дублювання в пам'яті:
Copierмістить два окремих екземпляриDevice— один черезPrinter, другий черезScanner - Неоднозначність: виклик
copier.getPower()не компілюється — компілятор не знає, якийgetPower()викликати - Логічна неузгодженість: копіювальний пристрій фізично має два різних значення
powerRating, хоча логічно має бути лише один
Віртуальне наслідування: спільний екземпляр предка
Віртуальне базове наслідування (virtual base inheritance) — це механізм, що змушує проміжні класи (Printer, Scanner) спільно використовувати єдиний екземпляр базового класу (Device). Синтаксис: додати ключове слово virtual у специфікатор наслідування:
#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;
}
Ключові зміни:
virtual public DeviceуPrinterтаScanner— позначає віртуальне наслідуванняDeviceстворюється лише один раз — зверніть увагу, щоDevice конструкторвиводиться лише один раз- Neм неоднозначності —
copier.getPower()працює без явної кваліфікації - Розмір пам'яті менший — лише один екземпляр
Deviceзамість двох
Порядок конструювання віртуальних баз
Найважливіше правило: віртуальні базові класи завжди конструюються першими, ще до невіртуальних базових класів. Це гарантує, що спільний предок існує до того, як почнуть конструюватися класи, що від нього залежать.
Порядок конструювання для Copier:
- Віртуальна база
Device— конструюється найпершою - Невіртуальна база
Printer— конструюється другою (викликDevice(power)ігнорується) - Невіртуальна база
Scanner— конструюється третьою (викликDevice(power)ігнорується) - Сам
Copier— конструюється останнім
Call Stack під час конструювання Copier
| Name | Type | Value |
|---|---|---|
| ◢phase | string | |
| ◢powerRating | int | |
| ◢next | string | "Printer → Scanner → Copier" |
✅ Повний поліморфізм
Animal& або Animal* автоматично викликає правильну версію print() залежно від фактичного типу об'єкта.✅ Підтримка ланцюгових операцій
std::ostream& дозволяє писати std::cout << obj1 << " text " << obj2.✅ Консистентність з STL
std::string, std::vector тощо).::
operator<< + віртуальний print()». Це стандартна ідіома C++, яку застосовують у промисловому коді та стандартній бібліотеці (наприклад, std::exception::what()).Комбінування з базовою реалізацією
Часто похідний клас хоче доповнити, а не замінити вивід базового класу. Для цього можна викликати Base::print() з похідного методу:
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;
}
Виклик Employee::print(out) дозволяє уникнути дублювання коду — базовий клас відповідає за вивід своїх полів, похідний додає лише специфічну інформацію.
Практичні завдання
Наведений нижче код містить зрізку об'єктів. Знайдіть проблему та виправте її трьома способами:
- Передачею за посиланням
- Використанням покажчиків
- Забороною копіювання базового класу
#include <iostream>
#include <string>
class Vehicle
{
protected:
std::string brand;
int year;
public:
Vehicle(std::string b, int y) : brand(b), year(y) {}
virtual std::string getInfo() const
{
return brand + " (" + std::to_string(year) + ")";
}
virtual ~Vehicle() = default;
};
class Car : public Vehicle
{
private:
int doors;
public:
Car(std::string b, int y, int d) : Vehicle(b, y), doors(d) {}
std::string getInfo() const override
{
return brand + " (" + std::to_string(year) + "), "
+ std::to_string(doors) + " дверей";
}
};
// ❌ Проблемна функція
void displayVehicle(Vehicle vehicle)
{
std::cout << vehicle.getInfo() << "\n";
}
int main()
{
Car car("Toyota", 2023, 4);
displayVehicle(car); // Зрізка!
return 0;
}
Вимоги:
- Виправте код трьома різними способами
- Кожен спосіб має виводити повну інформацію про автомобіль з кількістю дверей
Спосіб 1: Передача за посиланням
// ✅ Виправлення: const посилання
void displayVehicle(const Vehicle& vehicle)
{
std::cout << vehicle.getInfo() << "\n";
}
int main()
{
Car car("Toyota", 2023, 4);
displayVehicle(car); // ✅ Поліморфізм працює
return 0;
}
Спосіб 2: Використання покажчиків
// ✅ Виправлення: покажчик
void displayVehicle(const Vehicle* vehicle)
{
if (vehicle) {
std::cout << vehicle->getInfo() << "\n";
}
}
int main()
{
Car car("Toyota", 2023, 4);
displayVehicle(&car); // ✅ Поліморфізм працює
return 0;
}
Спосіб 3: Заборона копіювання
class Vehicle
{
protected:
std::string brand;
int year;
public:
Vehicle(std::string b, int y) : brand(b), year(y) {}
// ✅ Заборона копіювання
Vehicle(const Vehicle&) = delete;
Vehicle& operator=(const Vehicle&) = delete;
virtual std::string getInfo() const
{
return brand + " (" + std::to_string(year) + ")";
}
virtual ~Vehicle() = default;
};
// Функція залишається з параметром за посиланням
void displayVehicle(const Vehicle& vehicle)
{
std::cout << vehicle.getInfo() << "\n";
}
int main()
{
Car car("Toyota", 2023, 4);
// ✅ Працює з посиланням
displayVehicle(car);
// ❌ Помилка компіляції при спробі копіювання
// Vehicle v = car;
return 0;
}
Реалізуйте систему електронних пристроїв з використанням віртуального наслідування для розв'язання проблеми ромба:
- Базовий клас
ElectronicDevice:- Поле:
powerConsumption(споживання енергії у ватах) - Метод:
getPowerInfo()— повертає рядок з інформацією про споживання
- Поле:
- Проміжні класи:
NetworkDevice— має методconnect(std::string network)StorageDevice— має методstore(std::string data)
- Кінцевий клас
SmartTV:- Наслідує обидва проміжні класи
- Має метод
playVideo(std::string url), що використовує і мережу, і накопичувач
Вимоги:
- Використовуйте віртуальне наслідування
SmartTVмає ініціалізуватиElectronicDeviceнапряму- Продемонструйте відсутність дублювання полів
#include <iostream>
#include <string>
class ElectronicDevice
{
protected:
int powerConsumption;
public:
ElectronicDevice(int power) : powerConsumption(power)
{
std::cout << "ElectronicDevice конструктор: " << power << "W\n";
}
std::string getPowerInfo() const
{
return "Споживання: " + std::to_string(powerConsumption) + "W";
}
virtual ~ElectronicDevice() = default;
};
// Віртуальне наслідування
class NetworkDevice : virtual public ElectronicDevice
{
public:
NetworkDevice(int power) : ElectronicDevice(power)
{
std::cout << "NetworkDevice конструктор\n";
}
void connect(std::string network)
{
std::cout << "Підключення до мережі: " << network << "\n";
}
};
// Віртуальне наслідування
class StorageDevice : virtual public ElectronicDevice
{
public:
StorageDevice(int power) : ElectronicDevice(power)
{
std::cout << "StorageDevice конструктор\n";
}
void store(std::string data)
{
std::cout << "Збереження даних: " << data << "\n";
}
};
class SmartTV : public NetworkDevice, public StorageDevice
{
private:
std::string model;
public:
// Найбільш похідний клас ініціалізує віртуальну базу
SmartTV(std::string m, int power)
: ElectronicDevice(power), // ✅ Пряма ініціалізація віртуальної бази
NetworkDevice(power), // Ігнорується при створенні SmartTV
StorageDevice(power), // Ігнорується при створенні SmartTV
model(m)
{
std::cout << "SmartTV конструктор: " << model << "\n";
}
void playVideo(std::string url)
{
std::cout << "\n=== Відтворення відео: " << url << " ===\n";
connect("WiFi Home");
store("Кеш відео");
std::cout << getPowerInfo() << "\n";
std::cout << "Відтворення розпочато на " << model << "\n";
}
};
int main()
{
std::cout << "Створення SmartTV:\n";
SmartTV tv("Samsung QLED 55\"", 120);
tv.playVideo("youtube.com/watch?v=example");
std::cout << "\n✅ ElectronicDevice існує в одному екземплярі\n";
std::cout << "Розмір SmartTV: " << sizeof(SmartTV) << " байт\n";
return 0;
}
Реалізуйте ієрархію геометричних фігур із поліморфним виводом у потік:
- Абстрактний базовий клас
Shape:- Поле:
color(колір фігури) - Чистий віртуальний метод:
getArea()— площа - Віртуальний метод:
print(std::ostream&)— вивід у потік - Дружня функція:
operator<<— делегує роботуprint()
- Поле:
- Похідні класи:
Circle— радіусradiusRectangle— ширинаwidthта висотаheightTriangle— основаbaseта висотаheight
- Кожен похідний клас має виводити:
[Колір] Тип: площа = X
Вимоги:
- Використовуйте патерн делегування
operator<<→print() - Базовий метод
print()виводить колір, похідні додають специфічну інформацію - Продемонструйте роботу через масив
Shape*
#include <iostream>
#include <string>
#include <vector>
class Shape
{
protected:
std::string color;
public:
Shape(std::string c) : color(c) {}
virtual ~Shape() = default;
// Невіртуальний оператор виводу — делегує print()
friend std::ostream& operator<<(std::ostream& out, const Shape& shape)
{
return shape.print(out);
}
// Чистий віртуальний метод: площа
virtual double getArea() const = 0;
// Віртуальний метод print() — базова реалізація виводить колір
virtual std::ostream& print(std::ostream& out) const
{
out << "[" << color << "] ";
return out;
}
};
class Circle : public Shape
{
private:
double radius;
public:
Circle(std::string c, double r) : Shape(c), radius(r) {}
double getArea() const override
{
return 3.14159 * radius * radius;
}
std::ostream& print(std::ostream& out) const override
{
Shape::print(out); // Виклик базової версії (колір)
out << "Коло: площа = " << getArea();
return out;
}
};
class Rectangle : public Shape
{
private:
double width, height;
public:
Rectangle(std::string c, double w, double h)
: Shape(c), width(w), height(h) {}
double getArea() const override
{
return width * height;
}
std::ostream& print(std::ostream& out) const override
{
Shape::print(out);
out << "Прямокутник: площа = " << getArea();
return out;
}
};
class Triangle : public Shape
{
private:
double base, height;
public:
Triangle(std::string c, double b, double h)
: Shape(c), base(b), height(h) {}
double getArea() const override
{
return 0.5 * base * height;
}
std::ostream& print(std::ostream& out) const override
{
Shape::print(out);
out << "Трикутник: площа = " << getArea();
return out;
}
};
int main()
{
std::vector<Shape*> shapes;
shapes.push_back(new Circle("червоний", 5.0));
shapes.push_back(new Rectangle("синій", 4.0, 6.0));
shapes.push_back(new Triangle("зелений", 8.0, 3.0));
std::cout << "=== Поліморфний вивід фігур ===\n";
for (const Shape* shape : shapes) {
std::cout << *shape << "\n"; // ✅ Викликає правильний print()
}
// Очищення пам'яті
for (Shape* shape : shapes) {
delete shape;
}
return 0;
}
- Чому зрізка об'єктів не викликає помилки компіляції?
- Чому віртуальний базовий клас ініціалізується найбільш похідним класом?
Printer, Scanner) ініціалізували віртуальну базу, виникла б неоднозначність: який параметр передати конструктору Device — від Printer чи від Scanner? Найбільш похідний клас (Copier) знає повний контекст і може прийняти єдине рішення про ініціалізацію спільного предка.- Чому
operator<<не може бути віртуальною функцією?
operator<< зазвичай реалізується як дружня функція (не метод класу), оскільки лівий операнд — це std::ostream&, а не об'єкт класу. Тільки методи можуть бути віртуальними. Крім того, версії operator<< для базового та похідного класів мають різні сигнатури (Animal& vs Dog&), тому це перевантаження, а не перевизначення.- Що станеться, якщо забути викликати конструктор віртуальної бази у найбільш похідному класі?
Device()). Якщо такого конструктора не існує (наприклад, у Device є лише параметризований конструктор), виникне помилка компіляції: «no matching function for call to 'Device::Device()'».- Чи можна використовувати
std::vector<Shape>для зберігання поліморфних об'єктів?
std::vector<Shape> зберігає об'єкти за значенням, що призводить до зрізки при додаванні похідних класів. Потрібно використовувати std::vector<Shape*> або, краще, std::vector<std::unique_ptr<Shape>> для збереження поліморфної поведінки.Резюме
У цій статті ми дослідили три критичні аспекти поліморфізму в C++, що часто стають джерелом тонких помилок: зрізку об'єктів, віртуальне базове наслідування та поліморфний вивід у потоки.
✅ Зрізка об'єктів
Причина: копіювання об'єкта похідного класу в змінну базового типу за значенням
Наслідки:
- Фізична втрата полів похідного класу
- Переписування vptr — порушення поліморфізму
- Виклик методів базового класу замість похідного
Захист:
- Завжди передавайте через
const T&абоT* - Заборoніть копіювання базового класу (
= delete) - Використовуйте покажчики у контейнерах STL
✅ Віртуальне базове наслідування
Проблема: дублювання спільного предка при множинному наслідуванні (проблема ромба)
Розв'язання: class Derived : virtual public Base
Ключові правила:
- Віртуальна база конструюється першою
- Найбільш похідний клас відповідає за ініціалізацію
- Виклики конструктора у проміжних класах ігноруються
- Єдиний екземпляр бази для всієї ієрархії
✅ Поліморфний вивід
Проблема: operator<< не може бути віртуальним
Патерн делегування:
- Невіртуальна дружня функція
operator<< - Віртуальний метод
print(std::ostream&) - Оператор делегує роботу
print()
Переваги:
- Єдина версія
operator<<для всієї ієрархії - Повний поліморфізм при виводі
- Підтримка ланцюгових операцій
- Передача поліморфних об'єктів за значенням у функції — завжди призводить до зрізки
- Зберігання об'єктів за значенням у
std::vector<Base>— втрата поліморфізму - Забування ініціалізації віртуальної бази у найбільш похідному класі
- Спроба зробити
operator<<віртуальним — технічно неможливо
У наступній статті ми опануємо динамічне приведення типів (dynamic_cast) та RTTI (Run-Time Type Information) — механізми безпечного спуску вниз по ієрархії наслідування (downcasting) та отримання інформації про тип об'єкта під час виконання програми. Ми дізнаємося, коли downcasting виправданий, а коли свідчить про погане архітектурне проєктування.
Абстрактні класи та чисті віртуальні функції
Чисті віртуальні функції як непорушні контракти поведінки. Абстрактні класи та заборона інстанціювання. Інтерфейси в C++ та принцип інверсії залежностей. Чисті віртуальні деструктори та їх особливості. Проєктування гнучких систем через абстракції.
Динамічне приведення типів (`dynamic_cast`) та RTTI
Механізм отримання інформації про типи під час виконання програми. Безпечний спуск вниз по ієрархії наслідування через dynamic_cast. Різниця між upcasting та downcasting. Оператор typeid та клас type_info. Критичний аналіз: коли RTTI виправданий, а коли свідчить про погане проєктування.