C++

Перевизначення методів та приховування імен

Як похідні класи змінюють поведінку успадкованих методів. Перевизначення методів, розширення функціоналу через виклик базової версії. Приховування імен та using-оголошення для зміни специфікаторів доступу. Видалення небажаних методів через delete.

Додання власної поведінки похідним класам

У попередніх статтях ми побудували ієрархії наслідування, навчилися передавати параметри конструкторам базових класів та контролювати доступ до членів через специфікатори public, protected, private. Похідні класи успадковували поля та методи базових класів і могли додавати власні.

Проте до цього моменту похідні класи лише розширювали базовий клас новими методами, але не змінювали поведінку успадкованих методів. Що, якщо нам потрібно, щоб метод print() виводив різну інформацію для Person та Student? Або щоб метод calculateArea() обчислював площу по-різному для Circle та Rectangle?

Це фундаментальна можливість об'єктно-орієнтованого програмування: перевизначення методів (method overriding) — заміна реалізації успадкованого методу на нову, специфічну для похідного класу.

🎯 Мета статті

  • Навчитися перевизначати методи базового класу в похідному класі для зміни їхньої поведінки
  • Розширювати функціонал базових методів через виклик батьківської версії (Base::method())
  • Зрозуміти правила розв'язання імен при виклику методів у ієрархіях
  • Використовувати using-оголошення для зміни специфікаторів доступу успадкованих членів
  • Застосовувати delete для заборони виклику небажаних успадкованих методів

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

  • Перевизначення методу (method overriding): створення нової реалізації методу в похідному класі, що замінює версію з базового класу
  • Розширення методу (method extension): виклик базової версії методу з похідного класу з додаванням власної логіки
  • Оператор дозволу області видимості (scope resolution operator): :: — для явного виклику методу конкретного класу
  • Приховування імен (name hiding): коли метод у похідному класі ховає всі однойменні методи з базового класу

Механізм пошуку методів: від нащадка до предка

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

Коли ви викликаєте метод через об'єкт, компілятор виконує пошук методу (method lookup) у такому порядку:

Крок 1. Пошук у класі об'єкта

Компілятор спочатку перевіряє, чи існує метод у класі самого об'єкта. Якщо так — використовує його.

Крок 2. Пошук у базовому класі

Якщо метод не знайдено в класі об'єкта, компілятор переходить до безпосереднього базового класу і шукає там.

Крок 3. Рекурсивний підйом по ієрархії

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

Крок 4. Використання першого знайденого визначення

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

Розглянемо приклад:

MethodLookup.cpp
#include <iostream>

using namespace std;

class Grandparent
{
public:
    void identify()
    {
        cout << "Я Grandparent\n";
    }
};

class Parent : public Grandparent
{
public:
    // У Parent немає власного методу identify()
};

class Child : public Parent
{
public:
    // У Child немає власного методу identify()
};

int main()
{
    Grandparent grandparent;
    grandparent.identify();  // Виклик Grandparent::identify()
    
    Parent parent;
    parent.identify();  // Пошук: Parent (немає) → Grandparent (є!) → використовуємо
    
    Child child;
    child.identify();  // Пошук: Child (немає) → Parent (немає) → Grandparent (є!)
    
    return 0;
}
./method_lookup
Я Grandparent
Я Grandparent
Я Grandparent

Усі три виклики identify() використовують одну й ту саму реалізацію з класу Grandparent, бо ні Parent, ні Child не мають власних версій цього методу.

Пошук методів відбувається під час компіляції (compile-time), а не під час виконання. Компілятор аналізує тип змінної (статичний тип) і визначає, який метод викликати, ще до запуску програми. Це називається ранньою зв'язкою (early binding) або статичною зв'язкою (static binding).Пізніше ми розглянемо віртуальні функції та динамічну зв'язку (dynamic binding), де вибір методу відбувається під час виконання залежно від реального типу об'єкта.

Перевизначення методу: заміна поведінки

Перевизначення методу (method overriding) — це створення нової реалізації методу в похідному класі, що замінює версію з базового класу. Для перевизначення достатньо оголосити метод з тим самим іменем та сигнатурою у похідному класі.

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

VehicleOverride.cpp
#include <iostream>

using namespace std;

class Vehicle
{
protected:
    string brand;

public:
    Vehicle(string b) : brand(b) {}
    
    void identify()
    {
        cout << "Я транспортний засіб марки " << brand << "\n";
    }
};

class Car : public Vehicle
{
public:
    Car(string b) : Vehicle(b) {}
    
    // Перевизначення методу identify()
    void identify()
    {
        cout << "Я легковий автомобіль марки " << brand << "\n";
    }
};

class Truck : public Vehicle
{
public:
    Truck(string b) : Vehicle(b) {}
    
    // Перевизначення методу identify()
    void identify()
    {
        cout << "Я вантажівка марки " << brand << "\n";
    }
};

int main()
{
    Vehicle vehicle("Generic");
    vehicle.identify();
    
    Car car("Toyota");
    car.identify();  // Викликається Car::identify(), а не Vehicle::identify()
    
    Truck truck("Volvo");
    truck.identify();  // Викликається Truck::identify()
    
    return 0;
}
./vehicle_override
Я транспортний засіб марки Generic
Я легковий автомобіль марки Toyota
Я вантажівка марки Volvo

Що відбулося:

  1. При виклику car.identify() компілятор шукає метод identify() у класі Car. Знаходить — використовує його.
  2. При виклику truck.identify() компілятор шукає у класі Truck. Знаходить — використовує його.
  3. Версія Vehicle::identify() не викликається, бо компілятор зупинився на першому знайденому визначенні (у похідному класі).
Перевизначення методів — це фундаментальний механізм поліморфізму. Кожен клас у ієрархії може надати власну реалізацію методу, що відповідає його специфіці. Саме це дозволяє будувати гнучкі, розширювані системи.

Специфікатор доступу перевизначеного методу

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

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

AccessSpecifierOverride.cpp
#include <iostream>

using namespace std;

class Base
{
private:
    void secretMethod()
    {
        cout << "Base::secretMethod() (private)\n";
    }

public:
    void callSecret()
    {
        secretMethod();  // ✅ Дозволено всередині Base
    }
};

class Derived : public Base
{
public:
    // Перевизначаємо secretMethod() як public
    void secretMethod()
    {
        cout << "Derived::secretMethod() (public)\n";
    }
};

int main()
{
    Base base;
    // base.secretMethod();  // ❌ Помилка: метод private у Base
    
    Derived derived;
    derived.secretMethod();  // ✅ Дозволено: метод public у Derived
    
    return 0;
}
./access_override
Derived::secretMethod() (public)
Змінювати специфікатори доступу при перевизначенні — погана практика, що порушує принцип найменшого здивування (principle of least surprise). Користувачі класу очікують, що похідний клас зберігає або посилює специфікатори доступу, а не послаблює їх.Золоте правило: перевизначений метод повинен мати той самий або більш доступний специфікатор, ніж у базовому класі. Наприклад, якщо метод public у базовому класі, він має бути public у похідному.

Розширення функціоналу: виклик базової версії методу

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

Розглянемо приклад з логуванням:

MethodExtension.cpp
#include <iostream>

using namespace std;

class Person
{
protected:
    string name;
    int age;

public:
    Person(string n, int a) : name(n), age(a) {}
    
    void introduce()
    {
        cout << "Мене звати " << name << ", мені " << age << " років.\n";
    }
};

class Student : public Person
{
private:
    string university;
    double gpa;

public:
    Student(string n, int a, string uni, double g)
        : Person(n, a), university(uni), gpa(g) {}
    
    void introduce()
    {
        // Спочатку викликаємо базову версію
        Person::introduce();
        
        // Потім додаємо власну інформацію
        cout << "Я студент університету " << university 
             << " із середнім балом " << gpa << ".\n";
    }
};

int main()
{
    Person person("Олег Коваль", 45);
    person.introduce();
    
    cout << "\n";
    
    Student student("Марія Сидоренко", 20, "КПІ", 4.7);
    student.introduce();
    
    return 0;
}
./method_extension
Мене звати Олег Коваль, мені 45 років.
Мене звати Марія Сидоренко, мені 20 років.
Я студент університету КПІ із середнім балом 4.7.

Ключовий момент: виклик Person::introduce() всередині Student::introduce() явно вказує, що потрібно виконати метод з класу Person, а не з Student.

Небезпека рекурсії: якби ми написали просто introduce() замість Person::introduce(), компілятор викликав би Student::introduce() — той самий метод, у якому ми перебуваємо. Це призвело б до нескінченної рекурсії та аварійного завершення програми (stack overflow).
void introduce()
{
    introduce();  // ❌ Рекурсивний виклик самого себе — помилка!
    cout << "Додаткова інформація\n";
}
Завжди використовуйте повне ім'я методу базового класу: Base::method().

Приховування імен: коли одне ім'я ховає всі інші

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

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

Розглянемо приклад:

NameHiding.cpp
#include <iostream>

using namespace std;

class Base
{
public:
    void print()
    {
        cout << "Base::print() без параметрів\n";
    }
    
    void print(int value)
    {
        cout << "Base::print(int): " << value << "\n";
    }
    
    void print(double value)
    {
        cout << "Base::print(double): " << value << "\n";
    }
};

class Derived : public Base
{
public:
    // Перевизначаємо лише одну версію print()
    void print()
    {
        cout << "Derived::print() без параметрів\n";
    }
};

int main()
{
    Base base;
    base.print();        // ✅ Викликається Base::print()
    base.print(42);      // ✅ Викликається Base::print(int)
    base.print(3.14);    // ✅ Викликається Base::print(double)
    
    Derived derived;
    derived.print();     // ✅ Викликається Derived::print()
    // derived.print(42);   // ❌ Помилка компіляції!
    // derived.print(3.14); // ❌ Помилка компіляції!
    
    return 0;
}
g++ NameHiding.cpp -o hiding
$ g++ NameHiding.cpp -o hiding
error: no matching function for call to 'Derived::print(int)'
derived.print(42);
^
note: candidate: 'void Derived::print()'
note: candidate expects 0 arguments, 1 provided

Що відбулося:

  • Клас Base має три перевантаження методу print(): без параметрів, з int, з double.
  • Клас Derived перевизначає лише одну версію — print() без параметрів.
  • Коли компілятор шукає метод print у класі Derived, він знаходить Derived::print() і зупиняє пошук. Він не переходить до базового класу, щоб знайти інші перевантаження.
  • Тому derived.print(42) — це помилка: компілятор бачить лише Derived::print() (без параметрів) і не може застосувати його до виклику з аргументом.
Це фундаментальна відмінність від перевантаження функцій у одному класі. У одному класі всі перевантаження методу видимі одночасно, і компілятор вибирає найкращу відповідність. Але при наслідуванні перше знайдене ім'я ховає всі інші.

Вирішення проблеми приховування: using-оголошення

Щоб зробити приховані методи базового класу знову видимими у похідному класі, використовується using-оголошення (using declaration):

UsingDeclaration.cpp
#include <iostream>

using namespace std;

class Base
{
public:
    void print()
    {
        cout << "Base::print()\n";
    }
    
    void print(int value)
    {
        cout << "Base::print(int): " << value << "\n";
    }
    
    void print(double value)
    {
        cout << "Base::print(double): " << value << "\n";
    }
};

class Derived : public Base
{
public:
    using Base::print;  // Робимо всі версії print() з Base видимими
    
    // Перевизначаємо лише одну версію
    void print()
    {
        cout << "Derived::print()\n";
    }
};

int main()
{
    Derived derived;
    derived.print();      // ✅ Викликається Derived::print()
    derived.print(42);    // ✅ Викликається Base::print(int) — тепер видима!
    derived.print(3.14);  // ✅ Викликається Base::print(double)
    
    return 0;
}
./using_declaration
Derived::print()
Base::print(int): 42
Base::print(double): 3.14

Що робить using Base::print;:

  • Оголошення using Base::print; імпортує всі перевантаження методу print() з базового класу у простір імен похідного класу.
  • Тепер компілятор бачить чотири версії print() у класі Derived:
    • Derived::print() (власна перевизначена версія)
    • Base::print() (імпортована через using)
    • Base::print(int) (імпортована через using)
    • Base::print(double) (імпортована через using)
  • При виклику derived.print() компілятор використовує Derived::print() (власна версія має пріоритет).
  • При виклику derived.print(42) компілятор використовує Base::print(int) (імпортована версія).
Коли використовувати using-оголошення:
  • Якщо ви перевизначаєте лише деякі перевантаження методу, але хочете зберегти доступ до решти.
  • Якщо базовий клас має багато перевантажень методу, і вам потрібні всі, крім одного-двох, які ви перевизначаєте.
Альтернатива: явно перевизначити всі потрібні перевантаження у похідному класі (громіздко, але іноді необхідно для повного контролю).

Зміна специфікаторів доступу через using

using-оголошення має ще одну функцію: зміна специфікатора доступу члена базового класу у похідному класі.

Наприклад, можна зробити protected-метод базового класу публічним у похідному:

UsingAccessChange.cpp
#include <iostream>

using namespace std;

class Base
{
protected:
    void protectedMethod()
    {
        cout << "Base::protectedMethod() — захищений метод\n";
    }
};

class Derived : public Base
{
public:
    using Base::protectedMethod;  // Робимо protectedMethod() публічним у Derived
};

int main()
{
    Base base;
    // base.protectedMethod();  // ❌ Помилка: метод protected у Base
    
    Derived derived;
    derived.protectedMethod();  // ✅ Дозволено: метод public у Derived
    
    return 0;
}
./using_access
Base::protectedMethod() — захищений метод

Обмеження using-оголошення:

  1. Не можна змінити специфікатор private-члена базового класу, бо похідний клас взагалі не має до нього доступу.
  2. Можна лише послабити специфікатор (protected → public), але не посилити (public → protected або private — для цього потрібно перевизначити метод).
Зміна специфікаторів доступу через using — рідкісна практика. Зазвичай це сигнал про проблему проєктування: якщо похідний клас хоче зробити protected-метод публічним, можливо, він взагалі не повинен успадковувати цей базовий клас, або базовий клас має переглянути свій інтерфейс.

Приховування методів через delete

Іноді потрібно заборонити виклик певного методу базового класу через об'єкти похідного класу. Це робиться через видалення методу (= delete, C++11):

DeleteMethod.cpp
#include <iostream>

using namespace std;

class Base
{
public:
    void allowedMethod()
    {
        cout << "Base::allowedMethod() — дозволено\n";
    }
    
    void restrictedMethod()
    {
        cout << "Base::restrictedMethod() — обмежено\n";
    }
};

class Derived : public Base
{
public:
    // Забороняємо виклик restrictedMethod() через об'єкти Derived
    void restrictedMethod() = delete;
};

int main()
{
    Base base;
    base.allowedMethod();      // ✅ Дозволено
    base.restrictedMethod();   // ✅ Дозволено: метод доступний через Base
    
    Derived derived;
    derived.allowedMethod();      // ✅ Дозволено: метод успадкований і не видалений
    // derived.restrictedMethod();  // ❌ Помилка компіляції: метод видалений у Derived
    
    return 0;
}
g++ DeleteMethod.cpp -o delete_method
$ g++ DeleteMethod.cpp -o delete_method
error: use of deleted function 'void Derived::restrictedMethod()'
derived.restrictedMethod();
^
note: declared here: 'void restrictedMethod() = delete'

Застосування = delete:

  • Обмеження API похідного класу: коли похідний клас є «вузькою» версією базового і не повинен надавати повний інтерфейс базового класу.
  • Заборона небезпечних операцій: наприклад, клас SafeArray може видалити метод removeAt(int index) базового класу Array, щоб заборонити видалення елементів.
Видалений метод залишається частиною класу — він просто не може бути викликаний. Це відрізняється від приховування через private, де метод доступний усередині класу. Видалений метод недоступний взагалі, навіть для членів похідного класу.

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

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

ShapeHierarchy.cpp
#include <iostream>
#include <cmath>

using namespace std;

class Shape
{
protected:
    string name;

public:
    Shape(string n) : name(n) {}
    
    void describe()
    {
        cout << "Фігура: " << name << "\n";
    }
    
    // Базова версія — для фігур без конкретних розмірів
    virtual double getArea()
    {
        cout << "Площа не визначена для базового класу Shape\n";
        return 0.0;
    }
};

class Circle : public Shape
{
private:
    double radius;

public:
    Circle(double r) : Shape("Коло"), radius(r) {}
    
    // Перевизначаємо метод describe() — розширюємо його
    void describe()
    {
        Shape::describe();  // Викликаємо базову версію
        cout << "Радіус: " << radius << " см\n";
    }
    
    // Перевизначаємо метод getArea()
    double getArea()
    {
        return M_PI * radius * radius;
    }
};

class Rectangle : public Shape
{
private:
    double width;
    double height;

public:
    Rectangle(double w, double h) : Shape("Прямокутник"), width(w), height(h) {}
    
    void describe()
    {
        Shape::describe();
        cout << "Ширина: " << width << " см, Висота: " << height << " см\n";
    }
    
    double getArea()
    {
        return width * height;
    }
};

int main()
{
    Circle circle(5.0);
    circle.describe();
    cout << "Площа: " << circle.getArea() << " см²\n\n";
    
    Rectangle rectangle(4.0, 6.0);
    rectangle.describe();
    cout << "Площа: " << rectangle.getArea() << " см²\n\n";
    
    return 0;
}
./shape_hierarchy
Фігура: Коло
Радіус: 5 см
Площа: 78.5398 см²
Фігура: Прямокутник
Ширина: 4 см, Висота: 6 см
Площа: 24 см²

Ключові моменти прикладу:

  1. Метод describe() перевизначається в похідних класах, але розширюється через виклик Shape::describe() — зберігаємо базову функціональність і додаємо власну.
  2. Метод getArea() повністю перевизначається — кожна фігура має власну формулу обчислення площі.
  3. Використано ключове слово virtual перед getArea() у базовому класі — підготовка до поліморфізму, який ми розглянемо в наступних статтях.

Типові помилки та як їх уникнути

❌ Помилка 1: Рекурсивний виклик замість базової версії

class Derived : public Base
{
public:
    void method()
    {
        method();  // ❌ Викликає Derived::method() — рекурсія!
        cout << "Додаткова логіка\n";
    }
};

Наслідок: нескінченна рекурсія, переповнення стеку (stack overflow), аварійне завершення.

Виправлення: явно вказати базовий клас:

void method()
{
    Base::method();  // ✅ Викликає базову версію
    cout << "Додаткова логіка\n";
}

❌ Помилка 2: Забули `using` при частковому перевизначенні

class Base
{
public:
    void func(int x) { /* ... */ }
    void func(double x) { /* ... */ }
};

class Derived : public Base
{
public:
    void func(int x) { /* ... */ }  // Перевизначили лише одну версію
};

int main()
{
    Derived d;
    d.func(3.14);  // ❌ Помилка: func(double) приховано!
}

Наслідок: компілятор не бачить func(double) з базового класу — помилка компіляції.

Виправлення: додати using Base::func;:

class Derived : public Base
{
public:
    using Base::func;  // ✅ Імпортуємо всі версії func()
    void func(int x) { /* ... */ }  // Перевизначаємо одну версію
};

❌ Помилка 3: Неправильне розуміння приховування імен

class Base
{
public:
    void print(int x) { cout << "Base: " << x << "\n"; }
};

class Derived : public Base
{
public:
    void print() { cout << "Derived\n"; }
};

int main()
{
    Derived d;
    d.print(42);  // ❌ Помилка компіляції — очікували Base::print(int)
}

Наслідок: програміст очікує, що print(42) викличе Base::print(int), але компілятор бачить лише Derived::print() без параметрів.

Виправлення: усвідомити правило приховування та додати using:

class Derived : public Base
{
public:
    using Base::print;  // ✅ Імпортуємо Base::print(int)
    void print() { cout << "Derived\n"; }
};

❌ Помилка 4: Зміна семантики методу при перевизначенні

class Account
{
public:
    void withdraw(double amount)
    {
        if (balance >= amount) {
            balance -= amount;
        }
    }
};

class UnlimitedAccount : public Account
{
public:
    void withdraw(double amount)
    {
        balance -= amount;  // ❌ Забули перевірку — змінили семантику!
    }
};

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

Виправлення: дотримуватись принципу підстановки Лісков (Liskov Substitution Principle): перевизначений метод має виконувати той самий контракт, що й базовий, або посилювати його, але не послаблювати.

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

Резюме

У цій статті ми дослідили перевизначення методів та приховування імен — ключові механізми, що дозволяють похідним класам адаптувати поведінку базових класів під власні потреби.

🎯 Головні висновки

  • Перевизначення методу — створення нової реалізації в похідному класі, що замінює версію з базового класу
  • Розширення методу — виклик базової версії через Base::method() з додаванням власної логіки
  • Приховування імен — ім'я в похідному класі ховає всі однойменні члени базового класу, навіть з іншими сигнатурами
  • using-оголошення — імпортує приховані члени базового класу або змінює їхні специфікатори доступу
  • = delete — забороняє виклик методу базового класу через об'єкти похідного класу

⚠️ Що запам'ятати

  • Завжди використовуйте Base::method() для виклику базової версії — інакше рекурсія
  • При частковому перевизначенні перевантажених методів додавайте using Base::method;
  • Перевизначений метод не успадковує специфікатор доступу — він визначається у похідному класі
  • Дотримуйтесь принципу підстановки Лісков — не змінюйте семантику перевизначеного методу
  • Приховування імен — не баг, а особливість мови C++ для безпеки та передбачуваності

🔜 Наступна стаття

На наступному етапі ми розглянемо множинне наслідування — як клас може успадковувати від кількох базових класів одночасно, проблему діамантового наслідування та віртуальні базові класи для її вирішення.
Copyright © 2026