Перевизначення методів та приховування імен
Додання власної поведінки похідним класам
У попередніх статтях ми побудували ієрархії наслідування, навчилися передавати параметри конструкторам базових класів та контролювати доступ до членів через специфікатори 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. Використання першого знайденого визначення
Компілятор використовує перше знайдене визначення методу і зупиняє пошук. Визначення з вищих рівнів ієрархії не розглядаються.
Розглянемо приклад:
#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;
}
Усі три виклики identify() використовують одну й ту саму реалізацію з класу Grandparent, бо ні Parent, ні Child не мають власних версій цього методу.
Перевизначення методу: заміна поведінки
Перевизначення методу (method overriding) — це створення нової реалізації методу в похідному класі, що замінює версію з базового класу. Для перевизначення достатньо оголосити метод з тим самим іменем та сигнатурою у похідному класі.
Розглянемо приклад з різними видами транспорту:
#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;
}
Що відбулося:
- При виклику
car.identify()компілятор шукає методidentify()у класіCar. Знаходить — використовує його. - При виклику
truck.identify()компілятор шукає у класіTruck. Знаходить — використовує його. - Версія
Vehicle::identify()не викликається, бо компілятор зупинився на першому знайденому визначенні (у похідному класі).
Специфікатор доступу перевизначеного методу
Важливий нюанс: перевизначений метод не успадковує специфікатор доступу з базового класу. Специфікатор визначається оголошенням у похідному класі.
Це означає, що метод, оголошений як private у базовому класі, може бути перевизначений як public у похідному, і навпаки:
#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;
}
public у базовому класі, він має бути public у похідному.Розширення функціоналу: виклик базової версії методу
Часто потрібно не замінити метод базового класу, а розширити його — додати власну логіку, але зберегти виконання базової реалізації. Це робиться через явний виклик методу базового класу за допомогою оператора дозволу області видимості ::.
Розглянемо приклад з логуванням:
#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;
}
Ключовий момент: виклик Person::introduce() всередині Student::introduce() явно вказує, що потрібно виконати метод з класу Person, а не з Student.
introduce() замість Person::introduce(), компілятор викликав би Student::introduce() — той самий метод, у якому ми перебуваємо. Це призвело б до нескінченної рекурсії та аварійного завершення програми (stack overflow).void introduce()
{
introduce(); // ❌ Рекурсивний виклик самого себе — помилка!
cout << "Додаткова інформація\n";
}
Base::method().Приховування імен: коли одне ім'я ховає всі інші
У C++ діє фундаментальне правило розв'язання імен: ім'я у похідному класі ховає всі однойменні члени базового класу, навіть якщо вони мають різні сигнатури.
Це означає, що якщо ви перевизначаєте метод у похідному класі, компілятор не бачить інші перевантаження цього методу з базового класу — вони приховані (hidden).
Розглянемо приклад:
#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;
}
Що відбулося:
- Клас
Baseмає три перевантаження методуprint(): без параметрів, зint, зdouble. - Клас
Derivedперевизначає лише одну версію —print()без параметрів. - Коли компілятор шукає метод
printу класіDerived, він знаходитьDerived::print()і зупиняє пошук. Він не переходить до базового класу, щоб знайти інші перевантаження. - Тому
derived.print(42)— це помилка: компілятор бачить лишеDerived::print()(без параметрів) і не може застосувати його до виклику з аргументом.
Вирішення проблеми приховування: using-оголошення
Щоб зробити приховані методи базового класу знову видимими у похідному класі, використовується using-оголошення (using declaration):
#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 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-метод базового класу публічним у похідному:
#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-оголошення:
- Не можна змінити специфікатор
private-члена базового класу, бо похідний клас взагалі не має до нього доступу. - Можна лише послабити специфікатор (
protected→public), але не посилити (public→protectedабоprivate— для цього потрібно перевизначити метод).
using — рідкісна практика. Зазвичай це сигнал про проблему проєктування: якщо похідний клас хоче зробити protected-метод публічним, можливо, він взагалі не повинен успадковувати цей базовий клас, або базовий клас має переглянути свій інтерфейс.Приховування методів через delete
Іноді потрібно заборонити виклик певного методу базового класу через об'єкти похідного класу. Це робиться через видалення методу (= delete, C++11):
#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;
}
Застосування = delete:
- Обмеження API похідного класу: коли похідний клас є «вузькою» версією базового і не повинен надавати повний інтерфейс базового класу.
- Заборона небезпечних операцій: наприклад, клас
SafeArrayможе видалити методremoveAt(int index)базового класуArray, щоб заборонити видалення елементів.
private, де метод доступний усередині класу. Видалений метод недоступний взагалі, навіть для членів похідного класу.Практичний приклад: ієрархія геометричних фігур
Закріпимо знання на реалістичному прикладі з геометричними фігурами:
#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;
}
Ключові моменти прикладу:
- Метод
describe()перевизначається в похідних класах, але розширюється через викликShape::describe()— зберігаємо базову функціональність і додаємо власну. - Метод
getArea()повністю перевизначається — кожна фігура має власну формулу обчислення площі. - Використано ключове слово
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): перевизначений метод має виконувати той самий контракт, що й базовий, або посилювати його, але не послаблювати.
Практичне завдання
Створіть ієрархію класів для моделювання банківських рахунків:
- Базовий клас
BankAccountмає:protectedполеbalance(баланс,double)protectedполеaccountNumber(номер рахунку,string)publicметодdeposit(double amount)— поповнює балансpublicметодgetBalance()— повертає балансpublicметодgetAccountNumber()— повертає номер рахункуpublicметодdisplayInfo()— виводить базову інформацію про рахунок
- Похідний клас
SavingsAccount(ощадний рахунок) має:privateполеinterestRate(відсоткова ставка,double)- Перевизначити
displayInfo()— викликати базову версію та додати інформацію про відсоткову ставку publicметодapplyInterest()— нараховує відсотки на баланс
- Похідний клас
CheckingAccount(поточний рахунок) має:privateполеoverdraftLimit(ліміт овердрафту,double)- Перевизначити
displayInfo()— викликати базову версію та додати інформацію про ліміт овердрафту - Перевизначити
deposit(double amount)— дозволити від'ємні суми (погашення овердрафту)
Тестовий код:
int main()
{
SavingsAccount savings("SA-001", 1000.0, 0.05);
savings.displayInfo();
savings.applyInterest();
cout << "Після нарахування відсотків: " << savings.getBalance() << " грн\n\n";
CheckingAccount checking("CA-001", 500.0, 1000.0);
checking.displayInfo();
checking.deposit(-200.0); // Зняття коштів
cout << "Після зняття 200 грн: " << checking.getBalance() << " грн\n";
return 0;
}
#include <iostream>
using namespace std;
class BankAccount
{
protected:
string accountNumber;
double balance;
public:
BankAccount(string accNum, double initialBalance)
: accountNumber(accNum), balance(initialBalance) {}
void deposit(double amount)
{
if (amount > 0) {
balance += amount;
}
}
double getBalance() const
{
return balance;
}
string getAccountNumber() const
{
return accountNumber;
}
void displayInfo()
{
cout << "Рахунок: " << accountNumber << "\n";
cout << "Баланс: " << balance << " грн\n";
}
};
class SavingsAccount : public BankAccount
{
private:
double interestRate;
public:
SavingsAccount(string accNum, double initialBalance, double rate)
: BankAccount(accNum, initialBalance), interestRate(rate) {}
void displayInfo()
{
BankAccount::displayInfo(); // Викликаємо базову версію
cout << "Відсоткова ставка: " << (interestRate * 100) << "%\n";
}
void applyInterest()
{
double interest = balance * interestRate;
balance += interest;
cout << "Нараховано відсотки: " << interest << " грн\n";
}
};
class CheckingAccount : public BankAccount
{
private:
double overdraftLimit;
public:
CheckingAccount(string accNum, double initialBalance, double limit)
: BankAccount(accNum, initialBalance), overdraftLimit(limit) {}
void displayInfo()
{
BankAccount::displayInfo();
cout << "Ліміт овердрафту: " << overdraftLimit << " грн\n";
}
void deposit(double amount)
{
// Перевизначаємо: дозволяємо від'ємні суми (зняття коштів)
balance += amount;
if (balance < -overdraftLimit) {
cout << "Увага: перевищено ліміт овердрафту!\n";
}
}
};
int main()
{
SavingsAccount savings("SA-001", 1000.0, 0.05);
savings.displayInfo();
savings.applyInterest();
cout << "Після нарахування відсотків: " << savings.getBalance() << " грн\n\n";
CheckingAccount checking("CA-001", 500.0, 1000.0);
checking.displayInfo();
checking.deposit(-200.0); // Зняття коштів
cout << "Після зняття 200 грн: " << checking.getBalance() << " грн\n";
return 0;
}
Ключові моменти розв'язку:
SavingsAccount::displayInfo()викликаєBankAccount::displayInfo()через::— розширює базовий метод.CheckingAccount::deposit()повністю перевизначає базовий метод — додає логіку роботи з овердрафтом.- Використано
protectedдляbalanceтаaccountNumber— похідні класи мають прямий доступ.
Резюме
У цій статті ми дослідили перевизначення методів та приховування імен — ключові механізми, що дозволяють похідним класам адаптувати поведінку базових класів під власні потреби.
🎯 Головні висновки
- Перевизначення методу — створення нової реалізації в похідному класі, що замінює версію з базового класу
- Розширення методу — виклик базової версії через
Base::method()з додаванням власної логіки - Приховування імен — ім'я в похідному класі ховає всі однойменні члени базового класу, навіть з іншими сигнатурами
using-оголошення — імпортує приховані члени базового класу або змінює їхні специфікатори доступу= delete— забороняє виклик методу базового класу через об'єкти похідного класу
⚠️ Що запам'ятати
- Завжди використовуйте
Base::method()для виклику базової версії — інакше рекурсія - При частковому перевизначенні перевантажених методів додавайте
using Base::method; - Перевизначений метод не успадковує специфікатор доступу — він визначається у похідному класі
- Дотримуйтесь принципу підстановки Лісков — не змінюйте семантику перевизначеного методу
- Приховування імен — не баг, а особливість мови C++ для безпеки та передбачуваності
🔜 Наступна стаття
Специфікатор `protected` та види наслідування
Третій специфікатор доступу protected та його роль у наслідуванні. Три види наслідування: public, private, protected. Як тип наслідування змінює доступ до успадкованих членів. Зведена таблиця специфікаторів та правила вибору типу наслідування.
Множинне наслідування та проблема ромба
Як клас може успадковувати від кількох базових класів одночасно. Синтаксис множинного наслідування, порядок виклику конструкторів. Проблема діамантового наслідування (ромб смерті) та її вирішення через віртуальні базові класи. Чому множинне наслідування варто використовувати обережно.