1. Вступ
Повернімося до нашого зоопарку. Маємо базовий клас Animal (Тварина) та його нащадків: Dog (Собака), Cat (Кішка), Fish (Риба).
У попередніх лекціях ми додали до Animal віртуальний метод MakeSound():
public class Animal
{
public string Name { get; set; }
public int Age { get; set; }
public virtual void MakeSound() // Віртуальний метод
{
Console.WriteLine("Якась тварина видає звук."); // Реалізація за замовчуванням
}
public void Sleep() // Звичайний метод
{
Console.WriteLine($"{Name} спить.");
}
}
public class Dog : Animal
{
public override void MakeSound() // Перевизначаємо звук для собаки
{
Console.WriteLine("Гав-гав!");
}
}
public class Cat : Animal
{
public override void MakeSound() // Перевизначаємо звук для кішки
{
Console.WriteLine("Мяу!");
}
}
Це чудово працює! Коли ми створюємо Dog або Cat і викликаємо MakeSound(), чуємо їхні унікальні звуки. Але що, коли ми створимо просто Animal?
Animal genericAnimal = new Animal();
genericAnimal.Name = "Невідома істота";
genericAnimal.MakeSound(); // Виведе: "Якась тварина видає звук."
Виглядає логічно. Та інколи буває так, що базова реалізація за замовчуванням просто не має сенсу. Що, як Animal — це не конкретна тварина, а радше концепція? «Тварина» як така не видає конкретний звук — це вже роблять конкретні види тварин. Або уявімо, що є клас Shape (фігура) і в ньому є метод CalculateArea() (обчислити площу). Яку площу має обчислювати просто фігура? Жодну! Площа є в кола, у квадрата, але не в абстрактної «фігури».
У таких випадках, коли базовий клас не може (або не повинен) надати змістовну реалізацію за замовчуванням, але водночас зобов’язує всіх своїх нащадків реалізувати цей метод, у пригоді стають абстрактні методи і абстрактні класи.
2. Абстрактні класи: коли креслення ще не дім
Уявіть, що ви архітектор і створюєте креслення типового будинку. Але не просто будинку, а «концептуального дому». У нього є спільні риси: стіни, дах, фундамент. Та ви ще не знаєте, чи це буде одноповерховий котедж, чи багатоповерховий хмарочос. Деякі частини креслення будуть конкретними (наприклад, висота стелі на першому поверсі), а деякі — поки що лише натяком (скажімо, «кількість поверхів», яку визначать згодом).
У світі C# такий «концептуальний дім» називається абстрактним класом.
Абстрактний клас — це клас, позначений ключовим словом abstract.
public abstract class Animal // Тепер Animal — абстрактний клас
{
// ...
}
Ключова особливість абстрактних класів:
- Їх не можна створювати напряму. Ви не можете написати new Animal(). Чому? Адже Animal — це тепер щось невизначене, концепція. Ви не можете побудувати «концептуальний дім», ви можете звести лише конкретний котедж або хмарочос.
Якщо спробуєте викликати new Animal(), компілятор одразу повідомить про помилку:
Це дуже важливе обмеження!Cannot create an instance of the abstract type or interface 'Animal' (Неможливо створити екземпляр абстрактного типу або інтерфейсу 'Animal') - Вони можуть містити абстрактні члени. А ось це вже найцікавіше!
3. Абстрактні методи: контракт без реалізації
Якщо абстрактний клас — це «концептуальний дім», то абстрактний метод — це та його частина, яка на кресленні позначена як «зробити це», але без конкретних інструкцій «як». Наприклад, «Побудувати фундамент» — обов’язкова частина дому, та конкретні розміри й матеріали фундаменту залежать від типу будинку.
Абстрактний метод — це метод, який:
- позначений ключовим словом abstract;
- не має тіла (тобто немає блоку коду {}). Він закінчується крапкою з комою ;;
- може бути оголошений лише всередині абстрактного класу.
Зробімо наш метод MakeSound() абстрактним:
public abstract class Animal // Ми зробили Animal абстрактним
{
public string Name { get; set; }
public int Age { get; set; }
public abstract void MakeSound(); // Ось він, абстрактний метод! Немає тіла!
public void Sleep() // Цей метод залишився звичайним, «конкретним»
{
Console.WriteLine($"{Name} спить.");
}
}
Подивіться, як змінився MakeSound()! У нього більше немає фігурних дужок і реалізації за замовчуванням. Тепер він просто каже: «Будь-яка тварина зобов’язана уміти видавати звук. А як саме — нехай вирішують ті, хто наслідує мене».
Важливе правило: якщо ваш клас наслідується від абстрактного класу і сам не є абстрактним, він зобов’язаний перевизначити (за допомогою override) усі абстрактні методи базового класу. Це не опція, а вимога, контракт! Компілятор C# дуже суворий у цьому питанні. Якщо ви забудете, він одразу нагадає:
public class Dog : Animal // Звичайний, неабстрактний клас
{
// ПОМИЛКА КОМПІЛЯЦІЇ!
// 'Dog' does not implement inherited abstract member 'Animal.MakeSound()'
// (Клас 'Dog' не реалізує успадкований абстрактний член 'Animal.MakeSound()')
// Компілятор очікує від нас MakeSound() з override!
}
Щоб позбутися помилки, Dog і Cat мають обов’язково перевизначити MakeSound():
public class Dog : Animal
{
public override void MakeSound() // Обов’язково перевизначаємо!
{
Console.WriteLine("Гав-гав!");
}
}
public class Cat : Animal
{
public override void MakeSound() // І тут теж!
{
Console.WriteLine("Мяу!");
}
}
Порівняння virtual, abstract і override
| Характеристика | virtual метод | abstract метод | override ключове слово |
|---|---|---|---|
| Розташування | У звичайному або абстрактному класі | Тільки в абстрактному класі | У похідному (дочірньому) класі |
| Тіло методу | Має тіло (реалізацію за замовчуванням) | Не має тіла (закінчується на ;) | Має тіло (нову реалізацію) |
| Мета | Надає реалізацію за замовчуванням, але дозволяє похідним класам її змінити | Оголошує контракт: похідні класи зобов’язані надати свою реалізацію | Надає специфічну реалізацію для virtual або abstract методу базового класу |
| Створення екземпляра базового класу? | Так (якщо базовий клас не абстрактний) | Ні (якщо базовий клас абстрактний) | N/A (стосується методу, не класу) |
| Зобов’язання похідного класу | Опціонально перевизначити (override) | Обов’язково перевизначити (override), якщо похідний клас сам не абстрактний | N/A |
4. Поліморфізм у дії з абстрактними методами
Ось тепер починається найцікавіше! Ми вже знаємо, що поліморфізм дозволяє працювати з об’єктами різних похідних класів через спільне посилання на базовий клас. І це чудово працює навіть тоді, коли базовий клас — абстрактний!
Хоча ми не можемо створити екземпляр Animal напряму (пам’ятаєте, new Animal() видасть помилку), ми можемо використовувати тип Animal як посилання на об’єкти похідних класів. Це дуже потужно!
Продовжімо наш зоопарк. Уявімо, що маємо ферму, і на ній живуть різні тварини. Ми хочемо, щоб кожна тварина видала свій звук.
using System;
// Абстрактний клас Animal
public abstract class Animal
{
public string Name;
public int Age;
public Animal(string name, int age) { Name = name; Age = age; }
public abstract void MakeSound();
public void Sleep() { Console.WriteLine($"{Name} спить."); }
}
public class Dog : Animal
{
public Dog(string name, int age) : base(name, age) { }
public override void MakeSound() { Console.WriteLine("Гав-гав!"); }
}
public class Cat : Animal
{
public Cat(string name, int age) : base(name, age) { }
public override void MakeSound() { Console.WriteLine("Мяу!"); }
}
public class Fish : Animal
{
public Fish(string name, int age) : base(name, age) { }
public override void MakeSound() { Console.WriteLine("Буль-буль"); }
}
class Program
{
static void Main()
{
Animal[] animals = {
new Dog("Шарик", 3),
new Cat("Мурзик", 5),
new Fish("Немо", 1)
};
foreach (Animal animal in animals)
{
Console.WriteLine($"\nПривіт, я {animal.Name}, мені {animal.Age} років.");
animal.MakeSound();
animal.Sleep();
}
}
}
Що відбувається в цьому коді?
- Ми оголосили Animal як abstract class. Це каже компілятору: «Цей клас — шаблон, його самого не можна створити, але від нього можна наслідуватися».
- Ми оголосили public abstract void MakeSound(); всередині Animal. Це заявляє: «Будь-який клас, що наслідується від Animal (і не є абстрактним), зобов’язаний реалізувати метод MakeSound()». Це наш контракт!
- Dog, Cat і Fish беззаперечно виконують цей контракт, перевизначаючи MakeSound() зі своєю унікальною реалізацією. Якби ми забули зробити це хоча б для одного з них, компілятор не пропустив би наш код.
- У методі Main ми створюємо масив Animal[]. Попри те, що Animal абстрактний, масив може зберігати посилання на об’єкти похідних класів (Dog, Cat, Fish), бо вони є Animal!
- Коли ми проходимо по масиву в циклі foreach і викликаємо animal.MakeSound(), завдяки поліморфізму C# «знає», який саме MakeSound() викликати: Dog.MakeSound(), Cat.MakeSound() чи Fish.MakeSound(). Викликається той метод, який належить фактичному типу об’єкта, на який посилається Animal у даний момент, а не типу посилання. Ось у чому суть поліморфізму!
- А ось animal.Sleep() викликає конкретну реалізацію з базового класу Animal, бо цей метод не був позначений як virtual чи abstract, і його не перевизначали в дочірніх класах.
5. Навіщо це потрібно в реальному житті?
«Гаразд, зоопарк, тварини... А де це стане у пригоді, коли я писатиму реальний застосунок для банку чи магазину?» — запитаєте ви. І це слушне запитання! Абстрактні класи та методи — потужний інструмент для проєктування гнучких і розширюваних систем.
Примус до реалізації контракту: це головна перевага. Уявіть, що ви розробляєте фреймворк для платіжних систем. У вас є базовий abstract class PaymentProcessor (Обробник платежів). І ви точно знаєте, що будь-який обробник платежів має вміти ProcessPayment() (обробити платіж), RefundPayment() (повернути платіж) і CheckStatus() (перевірити статус). Але як саме це відбувається для PayPal, банківської картки чи Bitcoin — зовсім різні речі.
Ви оголошуєте ці методи як abstract у PaymentProcessor.
public abstract class PaymentProcessor
{
public abstract bool ProcessPayment(decimal amount, string currency, string cardNumber);
public abstract bool RefundPayment(string transactionId);
public abstract string CheckStatus(string transactionId);
// ... інші методи, які можуть бути і конкретними, наприклад, логування
public void LogTransaction(string message)
{
Console.WriteLine($"[LOG]: {message}");
}
}
public class PayPalProcessor : PaymentProcessor
{
public override bool ProcessPayment(decimal amount, string currency, string cardNumber)
{
// Тут складна логіка роботи з API PayPal
Console.WriteLine($"PayPal: обробляємо {amount} {currency}...");
return true;
}
public override bool RefundPayment(string transactionId) { /* ... */ return true; }
public override string CheckStatus(string transactionId) { /* ... */ return "Completed"; }
}
public class CreditCardProcessor : PaymentProcessor
{
public override bool ProcessPayment(decimal amount, string currency, string cardNumber)
{
// Тут логіка роботи з банком-еквайєром
Console.WriteLine($"CreditCard: {amount} {currency} з картки {cardNumber.Substring(0,4)}XXXX...");
return true;
}
public override bool RefundPayment(string transactionId) { /* ... */ return true; }
public override string CheckStatus(string transactionId) { /* ... */ return "Processing"; }
}
Тепер будь-який розробник, який захоче створити новий обробник платежів (наприклад, BitcoinProcessor), буде змушений реалізувати всі ці три методи. Він не зможе випадково забути про RefundPayment(), бо компілятор не дасть цього зробити! Це забезпечує узгодженість у вашій системі.
Гнучкість і розширюваність: ви можете писати код, який працює з PaymentProcessor, не знаючи, яка саме конкретна реалізація використовуватиметься. Наприклад, у коді кошика інтернет-магазину ви просто викликаєте currentProcessor.ProcessPayment(), а система сама підставляє потрібний обробник залежно від обраного користувачем способу оплати. Завтра з’явиться новий спосіб оплати — ви просто створюєте новий клас, наслідуєтеся від PaymentProcessor, реалізуєте абстрактні методи — і ваш основний код у магазині навіть не доведеться змінювати!
Уникнення порожніх реалізацій: якби ми використовували virtual-методи замість abstract, довелося б давати їм порожню реалізацію за замовчуванням, що могло б вводити в оману. abstract чітко каже: «Тут немає реалізації й не може бути — зверніться до нащадків!»
Покращення архітектури коду: абстрактні класи допомагають чітко розділити загальну логіку і специфічну. Загальна (наприклад, LogTransaction у PaymentProcessor) живе в базовому класі, а специфічна (як-от ProcessPayment) — у похідних. Це робить код більш читабельним, підтримуваним і тестованим. Це дає змогу авторам фреймворків і бібліотек визначати «точки розширення», які користувачі їхніх бібліотек мають заповнити.
6. Поширені помилки і тонкощі
Помилка № 1: спроба створити екземпляр абстрактного класу.
Це завжди помилка компіляції. Абстрактний клас — це концепція, а не конкретний об’єкт. Ви можете використовувати його як тип посилання, але не можете написати new Animal().
Помилка № 2: забули перевизначити абстрактний метод.
Якщо похідний клас не є абстрактним, він зобов’язаний реалізувати всі abstract-методи базового класу. Інакше — помилка компіляції. Єдиний спосіб обійти це — зробити похідний клас також абстрактним, але найчастіше це не те, чого ви прагнете.
Помилка № 3: плутанина між abstract і virtual.
abstract зобов’язує перевизначити метод і не має тіла. virtual надає реалізацію за замовчуванням і може бути перевизначений. Не можна оголошувати abstract-метод із тілом або virtual-метод без тіла — це синтаксична помилка.
Помилка № 4: використання new замість override.
Якщо ви не використовуєте override, а просто створюєте метод із тим самим ім’ям у нащадку, ви не перевизначаєте, а приховуєте метод базового класу. Це призведе до неочікуваної поведінки під час поліморфних викликів: викликатиметься метод із базового класу.
public class Base
{
public void DoSomething() { Console.WriteLine("Base"); }
}
public class Derived : Base
{
public new void DoSomething() { Console.WriteLine("Derived"); }
}
Base obj = new Derived();
obj.DoSomething(); // Виведе: Base
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ