C++

Вступ до наслідування: базові поняття та відношення «is-a»

Другий стовп ООП — наслідування (inheritance). Відношення «є різновидом» (is-a), побудова ієрархій, синтаксис public наслідування, ліквідація дублювання коду, фізична структура успадкованого об'єкта.

Чому композиція — це ще не вся історія

У попередніх статтях ми познайомилися з композицією — способом побудови складних класів шляхом включення інших класів як полів даних. Композиція ідеально моделює відношення «має» (has-a): Car має Engine, University має Department, Computer має CPU.

Але існує інший, не менш фундаментальний тип зв'язку між сутностями — відношення «є різновидом» (is-a). Розглянемо практичну задачу:

class Student {
public:
    std::string name;
    int age;
    std::string email;
    std::string phoneNumber;
    std::string address;
    
    void printInfo() {
        std::cout << "Name: " << name << ", Age: " << age << '\n';
    }
};

class Teacher {
public:
    std::string name;
    int age;
    std::string email;
    std::string phoneNumber;
    std::string address;
    int salary;
    
    void printInfo() {
        std::cout << "Name: " << name << ", Age: " << age << '\n';
    }
};

class Employee {
public:
    std::string name;
    int age;
    std::string email;
    std::string phoneNumber;
    std::string address;
    long employeeId;
    
    void printInfo() {
        std::cout << "Name: " << name << ", Age: " << age << '\n';
    }
};

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

  • Синхронізація вручну: додавання поля birthDate потребує змін у п'яти класах.
  • Поширення помилок: якщо printInfo() містить баг, його треба виправляти у п'яти місцях.
  • Розмивання логіки: немає єдиної точки відповідальності за «що означає бути Людиною».

Композиція тут не допоможе: Teacher не має (has-a) Person — Teacher є (is-a) Person. Для моделювання такого роду зв'язку існує наслідування (inheritance).

Наслідування — другий стовп ООП (поряд із інкапсуляцією). Третій стовп — поліморфізм — ми розглянемо в наступних статтях (віртуальні функції).

Відношення «is-a»: ієрархія класифікацій

Відношення «is-a» означає, що один об'єкт — це спеціалізований випадок іншого. У термінах біологічної таксономії:

  • Кіт є Ссавець
  • Ссавець є Тварина
  • Тварина є Організм

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

Тест на підстановку (Substitution Test)

Найкращий спосіб перевірити, чи коректне відношення is-a — це тест на підстановку Лісков (Liskov Substitution Principle, LSP):

✅ Коректне «is-a»

  • Студент є Людиною → Будь-яке місце, де можна використати Людину, можна використати Студента.
  • Квадрат є Прямокутник → Геометрично квадрат — окремий випадок прямокутника.
  • Електромобіль є Автомобіль → Поведінка автомобіля застосовна до електромобіля.

❌ Помилкове «is-a»

  • Автомобіль є Двигун → Автомобіль має двигун, але не є двигуном (це has-a, не is-a).
  • Працівник є Зарплата → Працівник отримує зарплату, але сам не є грошовою величиною.
  • Книга є Сторінки → Книга складається з сторінок (це композиція, а не наслідування).
Помилкове використання наслідування — одна з найчастіших причин важко підтримуваного коду. Якщо відношення — це has-a (має), uses-a (використовує) чи consists-of (складається з), то правильний вибір — композиція, асоціація або агрегація, а не наслідування.

Синтаксис наслідування в C++

Мова C++ надає синтаксис для явного позначення, що один клас успадковує (inherits) властивості іншого:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam classBorderColor black

class Person {
  + name: string
  + age: int
  + email: string
  + printInfo(): void
}

class Student {
  + studentId: int
  + major: string
}

class Teacher {
  + salary: int
  + department: string
}

Person <|-- Student
Person <|-- Teacher

@enduml
Термінологія успадкування (обидва варіанти терміну прийнятні):
РольАнглійський термінУкраїнський термін
Клас-джерелоBase class / Parent class / SuperclassБазовий клас / Батьківський клас
Клас-спадкоємецьDerived class / Child class / SubclassПохідний клас / Дочірній клас
На діаграмах UML стрілка з порожнім трикутником указує на базовий клас.

Базовий клас Person

Виділяємо спільні властивості, що належать будь-якій Людині:

#include <iostream>
#include <string>

class Person {
public:
    std::string name;
    int age;
    std::string email;
    
    Person(const std::string& name = "", int age = 0, 
           const std::string& email = "")
        : name(name), age(age), email(email)
    {
    }
    
    void printInfo() const {
        std::cout << "Name: " << name << ", Age: " << age 
                  << ", Email: " << email << '\n';
    }
};
Зверніть увагу: ми оголосили всі члени public для спрощення прикладу. У реальному коді поля зазвичай private, а доступ надається через геттери. Специфікатор protected та взаємодія наслідування зі специфікаторами доступу — тема наступної статті.

Похідний клас Student

Тепер створимо клас Student, який успадковує всі члени Person і додає свої власні:

// Student ПУБЛІЧНО успадковує Person
class Student : public Person {
public:
    int studentId;
    std::string major;  // спеціальність
    
    Student(const std::string& name = "", int age = 0,
            const std::string& email = "", int studentId = 0,
            const std::string& major = "")
        : Person(name, age, email),  // викликаємо конструктор базового класу
          studentId(studentId), major(major)
    {
    }
    
    void printStudentInfo() const {
        printInfo();  // використовуємо успадкований метод
        std::cout << "Student ID: " << studentId 
                  << ", Major: " << major << '\n';
    }
};

Розбір синтаксису:

  1. : public Person — специфікатор публічного наслідування. Це означає:
    • Всі public члени Person залишаються public у Student.
    • Всі protected члени Person залишаються protected у Student.
  2. Person(name, age, email) у списку ініціалізації — явний виклик конструктора базового класу. Це обов'язково, якщо у Person немає дефолтного конструктора без параметрів.
  3. printInfo() — успадкований метод, доступний безпосередньо, без префікса.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName sans-serif

package "Об'єкт типу Student" #F8FAFC {
  package "Під'об'єкт Person (базовий клас)" #EFF6FF {
    class "Person" as P {
      + name: "Alice"
      + age: 20
      + email: "alice@example.com"
    }
  }
  package "Поля Student (похідний клас)" #ECFDF5 {
    class "Student" as S {
      + studentId: 12345
      + major: "Computer Science"
    }
  }
}
@enduml

Використання успадкованого класу

int main() {
    Student alice("Alice", 20, "alice@example.com", 12345, "Computer Science");
    
    // Доступ до успадкованих полів
    std::cout << "Name: " << alice.name << '\n';
    std::cout << "Age: " << alice.age << '\n';
    
    // Доступ до власних полів
    std::cout << "ID: " << alice.studentId << '\n';
    
    // Виклик успадкованого методу
    alice.printInfo();
    
    // Виклик власного методу
    alice.printStudentInfo();
    
    return 0;
}

Вивід:

Name: Alice
Age: 20
ID: 12345
Name: Alice, Age: 20, Email: alice@example.com
Name: Alice, Age: 20, Email: alice@example.com
Student ID: 12345, Major: Computer Science
Local Variables
Filter
NameTypeValue
◢aliceStudent{...}
◢ namestd::string"Alice"
◢ ageint20
◢ emailstd::string"alice@example.com"
◢ studentIdint12345
◢ majorstd::string"Computer Science"
Running
Process: 12842
Об'єкт aliceфізично містить під'об'єкт Person всередині себе. Це не композиція (де поле має тип іншого класу), а розширення структури: пам'ять об'єкта Student починається з полів Person, а потім продовжується власними полями.

Множинні похідні класи від одного базового

Один базовий клас може мати багато похідних класів. Побудуємо ще два класи на основі Person:

Клас Teacher

class Teacher : public Person {
public:
    int salary;
    std::string department;
    
    Teacher(const std::string& name = "", int age = 0,
            const std::string& email = "", int salary = 0,
            const std::string& department = "")
        : Person(name, age, email),
          salary(salary), department(department)
    {
    }
    
    void printTeacherInfo() const {
        printInfo();
        std::cout << "Salary: " << salary 
                  << ", Department: " << department << '\n';
    }
};

Клас Employee

class Employee : public Person {
public:
    long employeeId;
    int wage;
    
    Employee(const std::string& name = "", int age = 0,
             const std::string& email = "", long employeeId = 0,
             int wage = 0)
        : Person(name, age, email),
          employeeId(employeeId), wage(wage)
    {
    }
    
    void printEmployeeInfo() const {
        printInfo();
        std::cout << "Employee ID: " << employeeId 
                  << ", Wage: " << wage << '\n';
    }
};

Діаграма взаємозв'язків:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam classBorderColor black

class Person {
  + name: string
  + age: int
  + email: string
  + printInfo(): void
}

class Student {
  + studentId: int
  + major: string
  + printStudentInfo(): void
}

class Teacher {
  + salary: int
  + department: string
  + printTeacherInfo(): void
}

class Employee {
  + employeeId: long
  + wage: int
  + printEmployeeInfo(): void
}

Person <|-- Student
Person <|-- Teacher
Person <|-- Employee

note right of Person
  Базовий клас —
  загальні властивості
  всіх людей
end note

note bottom of Student
  Похідні класи —
  спеціалізації з
  додатковими полями
end note

@enduml
Зверніть увагу: класи Student, Teacher та Employeeне пов'язані між собою напряму. Вони незалежні спадкоємці одного предка. Це відрізняється від багаторівневого наслідування, де один клас успадковує від іншого похідного класу.

Багаторівневе наслідування

Похідний клас сам може стати базовим для іншого класу, утворюючи ланцюжок спадкування:

class Supervisor : public Employee {
public:
    int teamSize;  // кількість підлеглих
    
    Supervisor(const std::string& name = "", int age = 0,
               const std::string& email = "", long employeeId = 0,
               int wage = 0, int teamSize = 0)
        : Employee(name, age, email, employeeId, wage),
          teamSize(teamSize)
    {
    }
    
    void printSupervisorInfo() const {
        printEmployeeInfo();
        std::cout << "Team Size: " << teamSize << '\n';
    }
};

Ієрархія наслідування:

Person
  ↑
  │
Employee
  ↑
  │
Supervisor

Об'єкт Supervisor містить:

  1. Поля Person (name, age, email)
  2. Поля Employee (employeeId, wage)
  3. Власні поля (teamSize)
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName sans-serif

package "Об'єкт типу Supervisor" #F8FAFC {
  package "Під'об'єкт Person" #EFF6FF {
    class "Person" as P {
      + name: "Bob"
      + age: 35
      + email: "bob@company.com"
    }
  }
  package "Під'об'єкт Employee" #FEF3C7 {
    class "Employee" as E {
      + employeeId: 987654
      + wage: 5000
    }
  }
  package "Поля Supervisor" #ECFDF5 {
    class "Supervisor" as Sup {
      + teamSize: 8
    }
  }
}
@enduml

Розмір об'єкта в пам'яті:
sizeof(Supervisor) = sizeof(Person) + sizeof(Employee-специфічні) + sizeof(Supervisor-специфічні)


Процеси узагальнення та спеціалізації

Наслідування підтримує два протилежні напрямки мислення:

Узагальнення (Generalization)

Рух від конкретного до загального: виявлення спільних властивостей кількох класів і винесення їх у базовий клас.

Приклад: маємо класи Dog, Cat, Horse. Помічаємо спільні поля: name, age, weight. Створюємо базовий клас Animal і виносимо в нього дублювання.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam classBorderColor black

class Dog {
  - name: string
  - age: int
  - weight: double
  - breed: string
}

class Cat {
  - name: string
  - age: int
  - weight: double
  - color: string
}

class Horse {
  - name: string
  - age: int
  - weight: double
  - height: double
}

note top of Dog
  ДО узагальнення:
  дублювання полів
  name, age, weight
end note

@enduml

Після узагальнення:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam classBorderColor black

class Animal {
  # name: string
  # age: int
  # weight: double
  + makeSound(): void
}

class Dog {
  - breed: string
  + makeSound(): void
}

class Cat {
  - color: string
  + makeSound(): void
}

class Horse {
  - height: double
  + makeSound(): void
}

Animal <|-- Dog
Animal <|-- Cat
Animal <|-- Horse

note right of Animal
  ПІСЛЯ узагальнення:
  спільні властивості
  винесені в базу
end note

@enduml

Спеціалізація (Specialization)

Рух від загального до конкретного: створення нових класів шляхом розширення існуючого базового класу додатковими властивостями.

Приклад: маємо базовий клас Shape з методом calculateArea(). Створюємо спеціалізовані класи Circle, Rectangle, Triangle, кожен з яких додає свої специфічні поля (radius, width/height, base/height).

Узагальнення (Generalization)

  • Напрямок: знизу вгору (від конкретного до абстрактного)
  • Мета: ліквідація дублювання, підвищення повторного використання
  • Результат: базовий клас містить загальні характеристики

Спеціалізація (Specialization)

  • Напрямок: зверху вниз (від абстрактного до конкретного)
  • Мета: додавання специфічної функціональності
  • Результат: похідні класи містять унікальні характеристики

Переваги наслідування

1. Усунення дублювання коду

Без наслідування клас Student міг би виглядати так:

// ❌ Антипаттерн: дублювання логіки
class Student {
public:
    std::string name;
    int age;
    std::string email;
    int studentId;
    std::string major;
    
    void printInfo() {  // ← ця логіка дублюється в Teacher, Employee...
        std::cout << "Name: " << name << ", Age: " << age 
                  << ", Email: " << email << '\n';
    }
};

З наслідуванням логіка printInfo() існує в одному місці — у класі Person.

2. Єдина точка змін (Single Point of Change)

Якщо потрібно додати поле phoneNumber до всіх людей:

// ✅ Зміна в одному місці
class Person {
public:
    std::string name;
    int age;
    std::string email;
    std::string phoneNumber;  // ← нове поле
    
    // ...
};

Автоматично всі похідні класи (Student, Teacher, Employee) отримують це поле без жодних змін у їхньому коді.

3. Поліморфна обробка (анонс)

Наслідування дозволяє обробляти різнорідні об'єкти через єдиний інтерфейс:

void printPersonInfo(const Person& person) {
    person.printInfo();
}

int main() {
    Student student("Alice", 20, "alice@example.com", 12345, "CS");
    Teacher teacher("Dr. Smith", 45, "smith@university.edu", 5000, "Physics");
    
    printPersonInfo(student);  // ✅ Student передається як Person
    printPersonInfo(teacher);  // ✅ Teacher передається як Person
    
    return 0;
}
Це приклад статичного поліморфізму через посилання на базовий клас. Повний динамічний поліморфізм через віртуальні функції ми розглянемо у статтях 75–80.

4. Повторне використання коду (Reusability)

Створивши один раз надійний базовий клас Person, ми можемо безкоштовно використовувати його функціональність у десятках похідних класів: Student, Teacher, Administrator, Guest, Parent, Alumni тощо.


Наслідування vs Композиція: коли що обирати?

КритерійНаслідування (is-a)Композиція (has-a)
ВідношенняStudent є PersonCar має Engine
Тест підстановкиПохідний клас може замінити базовийНеможливо замінити ціле на частину
Структура пам'ятіПід'об'єкт базового класу всередині похідногоПоле-об'єкт іншого класу
Доступ до членівБезпосередній доступ до public/protected членів базового класуЧерез інтерфейс об'єкта-поля
Приклад кодуclass Student : public Personclass Car { Engine engine; };
Найчастіша помилка новачків: спроба використовувати наслідування там, де потрібна композиція.Приклад помилки:
// ❌ НЕПРАВИЛЬНО: House не "є" Door
class House : public Door {
    // ...
};

// ✅ ПРАВИЛЬНО: House "має" Door
class House {
    Door mainDoor;
    Door backDoor;
};
Правило: якщо сумніваєтесь між is-a та has-a, віддавайте перевагу композиції. Надмірне використання наслідування призводить до негнучких ієрархій, важких для підтримки.

Обмеження та підводні камені наслідування

1. Похідний клас не може обмежити базовий

Якщо базовий клас має поле чи метод, похідний клас не може їх видалити:

class Bird {
public:
    void fly() { std::cout << "Flying...\n"; }
};

class Penguin : public Bird {
    // ❌ Пінгвін не літає, але метод fly() успадкований!
    // Немає способу "заборонити" fly() для Penguin
};

Рішення: переосмислити ієрархію (наприклад, створити FlyingBird і FlightlessBird).

2. Зв'язування на етапі компіляції

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

3. Проблема крихкого базового класу (Fragile Base Class)

Зміна в базовому класі може несподівано зламати всі похідні класи. Наприклад:

// Версія 1.0
class Person {
public:
    void printInfo() {
        std::cout << name << '\n';
    }
};

// Версія 2.0 — додали перевірку
class Person {
public:
    void printInfo() {
        if (name.empty()) return;  // ← нова логіка
        std::cout << name << '\n';
    }
};

Якщо похідний клас Student перевизначав printInfo() і покладався на обов'язковий вивід, код зламається.

Захист від крихкості:
  1. Документувати контракти методів (що гарантується, а що ні).
  2. Робити методи virtual (віртуальними), якщо передбачається їх перевизначення (стаття 75).
  3. Застосовувати принцип відкритості/закритості (Open/Closed Principle): клас відкритий для розширення, але закритий для модифікації.

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

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

Базовий клас Shape

#include <iostream>
#include <string>

class Shape {
public:
    std::string color;
    double posX;
    double posY;
    
    Shape(const std::string& color = "black", double x = 0.0, double y = 0.0)
        : color(color), posX(x), posY(y)
    {
        std::cout << "Shape constructor called\n";
    }
    
    void printPosition() const {
        std::cout << "Position: (" << posX << ", " << posY 
                  << "), Color: " << color << '\n';
    }
};

Похідний клас Circle

class Circle : public Shape {
public:
    double radius;
    
    Circle(const std::string& color = "black", double x = 0.0, 
           double y = 0.0, double radius = 1.0)
        : Shape(color, x, y), radius(radius)
    {
        std::cout << "Circle constructor called\n";
    }
    
    double calculateArea() const {
        return 3.14159 * radius * radius;
    }
    
    void printCircleInfo() const {
        printPosition();
        std::cout << "Radius: " << radius 
                  << ", Area: " << calculateArea() << '\n';
    }
};

Похідний клас Rectangle

class Rectangle : public Shape {
public:
    double width;
    double height;
    
    Rectangle(const std::string& color = "black", double x = 0.0,
              double y = 0.0, double width = 1.0, double height = 1.0)
        : Shape(color, x, y), width(width), height(height)
    {
        std::cout << "Rectangle constructor called\n";
    }
    
    double calculateArea() const {
        return width * height;
    }
    
    void printRectangleInfo() const {
        printPosition();
        std::cout << "Width: " << width << ", Height: " << height
                  << ", Area: " << calculateArea() << '\n';
    }
};

Багаторівневе наслідування: клас Square

Квадрат — це спеціальний випадок прямокутника, де width == height:

class Square : public Rectangle {
public:
    Square(const std::string& color = "black", double x = 0.0,
           double y = 0.0, double side = 1.0)
        : Rectangle(color, x, y, side, side)
    {
        std::cout << "Square constructor called\n";
    }
    
    void printSquareInfo() const {
        printRectangleInfo();
        std::cout << "This is a square (width == height)\n";
    }
};

Використання ієрархії

int main() {
    std::cout << "=== Creating Circle ===\n";
    Circle circle("red", 5.0, 10.0, 3.5);
    circle.printCircleInfo();
    
    std::cout << "\n=== Creating Rectangle ===\n";
    Rectangle rect("blue", 0.0, 0.0, 4.0, 6.0);
    rect.printRectangleInfo();
    
    std::cout << "\n=== Creating Square ===\n";
    Square square("green", 2.0, 2.0, 5.0);
    square.printSquareInfo();
    
    // Доступ до успадкованих членів
    std::cout << "\n=== Direct access to base members ===\n";
    std::cout << "Circle color: " << circle.color << '\n';
    std::cout << "Square position X: " << square.posX << '\n';
    
    return 0;
}

Вивід програми:

=== Creating Circle ===
Shape constructor called
Circle constructor called
Position: (5, 10), Color: red
Radius: 3.5, Area: 38.4845

=== Creating Rectangle ===
Shape constructor called
Rectangle constructor called
Position: (0, 0), Color: blue
Width: 4, Height: 6, Area: 24

=== Creating Square ===
Shape constructor called
Rectangle constructor called
Square constructor called
Position: (2, 2), Color: green
Width: 5, Height: 5, Area: 25
This is a square (width == height)

=== Direct access to base members ===
Circle color: red
Square position X: 2
Порядок виклику конструкторів:
При створенні об'єкта Square конструктори викликаються знизу вгору по ієрархії:
  1. Shape (найвищий предок)
  2. Rectangle (проміжний предок)
  3. Square (сам об'єкт)
Порядок виклику деструкторів буде зворотним: спочатку ~Square(), потім ~Rectangle(), потім ~Shape(). Детально це розглянемо у статті 71.

Діаграма класів:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam classBorderColor black

class Shape {
  + color: string
  + posX: double
  + posY: double
  + printPosition(): void
}

class Circle {
  + radius: double
  + calculateArea(): double
  + printCircleInfo(): void
}

class Rectangle {
  + width: double
  + height: double
  + calculateArea(): double
  + printRectangleInfo(): void
}

class Square {
  + printSquareInfo(): void
}

Shape <|-- Circle
Shape <|-- Rectangle
Rectangle <|-- Square

note right of Shape
  Базовий клас:
  загальні властивості
  всіх фігур
end note

note bottom of Square
  Багаторівневе
  наслідування:
  Square є Rectangle,
  який є Shape
end note

@enduml

Вправа: розробка ієрархії транспортних засобів


Резюме

Що ми вивчили

  • Наслідування — другий стовп ООП, що моделює відношення «is-a» (є різновидом).
  • Синтаксис: class Derived : public Base — публічне наслідування.
  • Структура пам'яті: похідний об'єкт фізично містить під'об'єкт базового класу.
  • Доступ: успадковані public члени доступні безпосередньо в похідному класі.
  • Багаторівневе наслідування: клас може успадковувати від іншого похідного класу.
  • Переваги: усунення дублювання, єдина точка змін, повторне використання.

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

  • Base class (базовий клас) — клас-джерело властивостей.
  • Derived class (похідний клас) — клас-спадкоємець.
  • Generalization (узагальнення) — винесення спільного в базу.
  • Specialization (спеціалізація) — розширення базового класу.
  • Substitution test (тест підстановки) — перевірка коректності is-a.
Частi помилки:
  1. Плутанина is-a та has-a: використання наслідування замість композиції.
  2. Забування виклику конструктора базового класу у списку ініціалізації похідного класу.
  3. Надмірне наслідування: створення глибоких ієрархій (5+ рівнів), які важко підтримувати.
  4. Наслідування для повторного використання коду, де семантично відношення не є is-a (краще композиція).

Що далі?

У наступній статті ми детально розберемо:

  • Порядок виклику конструкторів і деструкторів у ієрархіях наслідування
  • Як правильно передавати параметри від похідного класу до базового
  • Чому деструктори викликаються у зворотному порядку
  • Як діє RAII у контексті наслідування
  • Типові помилки: «no matching constructor» та «undefined reference»

Анонс статті 71: Конструктори, деструктори та порядок ініціалізації в ієрархіях.

Copyright © 2026