JavaRush /Курси /C# SELF /Проблеми з поліморфізмом та абстракцією

Проблеми з поліморфізмом та абстракцією

C# SELF
Рівень 25 , Лекція 3
Відкрита

1. Поліморфізм — це не завжди магія

Якщо дивитися на початкові приклади, поліморфізм у C# здається абсолютною перемогою здорового глузду: наслідуйте, перевизначайте, викликайте все через базовий тип — і все працює. Але на практиці зʼявляються нюанси. Розгляньмо їх ближче.

Приховування методів і ключове слово new

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

class Animal
{
    public void Speak()
    {
        Console.WriteLine("Тварина видає звук.");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("Няв!");
    }
}

// Використання
Animal animal = new Cat();
animal.Speak(); // Виведе: "Тварина видає звук."

Зверніть увагу! Навіть якщо ви створюєте обʼєкт типу Cat і зберігаєте його у змінній типу Animal, викличеться оригінальний метод базового класу. Чому? Бо метод не був оголошений як віртуальний! Пастка № 1: якщо хочете використовувати поліморфізм, не забувайте про ключові слова virtual і override. Використовуйте new лише тоді, коли свідомо хочете приховати, а не перевизначити метод (це, до речі, потрібно дуже рідко й лише з вагомих причин).

Виклики конструкторів і поліморфізм

Ще одна неочевидна особливість: конструктори не є віртуальними. Якщо ви у базовому класі оголосите конструктор, а в похідному — свій, вони не будуть поліморфними. Ось приклад:

class Animal
{
    public Animal()
    {
        Console.WriteLine("Конструктор Animal");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Конструктор Cat");
    }
}

// Використання
Animal animal = new Cat();
// Виведе:
// Конструктор Animal
// Конструктор Cat

Але якщо ви викликаєте з конструктора базового класу методи, які можуть бути перевизначені в похідному, результат може бути неочікуваним — віртуальний метод буде викликано до ініціалізації нащадка. Це вагома причина не викликати віртуальні/абстрактні методи в конструкторах.

Проблема «зламаної» інкапсуляції при override

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

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("Тварина їсть.");
    }

    public void Live()
    {
        Eat(); // Може викликати будь-яку перевизначену версію!
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("Кіт їсть рибу.");
    }
}

Animal a = new Cat();
a.Live(); // Виведе "Кіт їсть рибу."

Якщо в базовому класі Animal метод Eat() виводив рядок «Тварина їсть.», а згодом у похідному класі в перевизначеному Eat() додали щось небезпечне, це може зламати роботу всього класу. Цю проблему називають порушенням принципу підстановки Барбари Лісков (Liskov Substitution Principle, LSP). Під час проєктування завжди зважайте, чи залишиться поведінка похідних класів логічною щодо базового.

Приведення типів і повʼязані помилки

Поліморфізм дозволяє зберігати різні обʼєкти в одній колекції: наприклад, список обʼєктів базового типу, у якому можуть бути і собаки, і коти (обидва наслідують Animal). Але якщо ви захочете викликати щось специфічне:

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

Якщо забути про перевірку типу й зробити необережне приведення, отримаєте неприємну помилку InvalidCastException. Іноді це призводить до надмірної кількості перевірок типів, ускладнює код і натякає, що, можливо, ваше проєктування потребує доопрацювання.

2. Проблеми з абстракцією

Абстракція — потужний інструмент для спрощення роботи користувача з обʼєктом і обмеження доступу до внутрішнього стану. Але і тут є свої підводні камені.

Надмірність рівнів абстракції

Деякі розробники-початківці (і не лише вони) так захоплюються «правильним» ООП, що створюють цілі багатошарові конструкції з базових класів, інтерфейсів і абстрактних шарів. У результаті розібратися, як усе працює, складно навіть самому автору.

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("Няв!");
    }
}

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

Непродумана ієрархія

Замисліться, що буде, якщо ви винесете якусь дію на самий верх ієрархії, а насправді вона пасує не всім:

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("Коти не літають!");
    }
}

Доведеться або заповнювати похідні класи заглушками, що кидають винятки, або миритися з тим, що ваш інтерфейс не відображає реальність. Це типовий антипатерн «неправильна ієрархія». У таких випадках краще винести подібні методи до окремих інтерфейсів (наприклад, IFlyable).

Порада: не намагайтеся зробити одну абстракцію «на всі випадки життя».

Проблеми з абстрактними класами і змінами API

Щойно абстрактний клас потрапляє у продуктивне середовище і від нього починають наслідувати, будь-які зміни стають ризикованими. Додавання нового абстрактного методу вимагає від усіх нащадків його обовʼязкової реалізації — інакше код просто не скомпілюється. Це суттєво ускладнює підтримку бібліотек і публічних API.

Саме для таких випадків було запроваджено інтерфейси з реалізацією за замовчуванням (Default Interface Methods — див. лекцію 116): вони дозволяють розширювати інтерфейси без необхідності одразу змінювати весь наявний код.

Порушення інкапсуляції при абстракції

Коли ви робите клас абстрактним, часто доводиться позначати його члени як protected, щоб похідні класи могли до них звертатися. Це нерідко призводить до витоку внутрішньої логіки, яку краще було б приховати. У результаті нащадки отримують доступ до даних і операцій, втручання в які може порушити внутрішню цілісність базового класу.

3. Практичні сценарії помилок

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

Приклад із методами, не оголошеними як virtual

Припустімо, ми розширюємо наш навчальний застосунок для логування (дивіться День 24). Нехай у нас є базовий логер:

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // Записуємо у файл
        Console.WriteLine("У файл: " + message);
    }
}

// Використання:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Очікування: "У файл: Hello!", реальність: "Hello!"

Автор FileLogger вважав, що перевизначив метод, але забув додати override і не зробив базовий метод віртуальним. У результаті виклик іде до базової версії.

Рекомендація: завжди позначайте методи, які треба перевизначати, як virtual у базовому класі та override у похідних.

Приклад із неправильною абстракцією: «Гнучкі» тварини

Продовжімо тему тварин. Виділимо інтерфейс IFlyable, щоб не змушувати всіх тварин реалізовувати метод Fly:

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("Пташка летить!");
}

class Cat
{
    // Кіт не реалізує IFlyable
}

Тепер можна написати функцію, що працює з „літаючими“, не зачіпаючи котів:

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("Ця тварина літати не вміє.");
    }
}

Такий підхід дозволяє не псувати архітектуру фіктивними абстрактними методами.

Проблеми з «жорсткими» абстрактними класами у розширюваності

Уявімо, що ви випустили бібліотеку з таким абстрактним класом:

public abstract class Creature
{
    public abstract void DoAction();
}

Користувачі вашої бібліотеки почали створювати свої класи, наслідуючи цей. Через рік ви вирішили розширити API й додали:

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // Новий метод!
}

Тепер усі користувацькі класи не компілюються, бо зобовʼязані реалізувати новий абстрактний метод. Це привід бути дуже обережними під час дизайну абстракцій і, по можливості, віддавати перевагу інтерфейсам із методами за замовчуванням.

4. Поради, як уникати типових граблів

Нехай ваші застосунки будуть гнучкими, як гімнасти, але не ламайте собі ноги!

  • Не зловживайте наслідуванням: якщо можна обійтися композицією (вбудовування одного обʼєкта в інший), робіть так.
  • Робіть методи віртуальними лише тоді, коли точно знаєте, що їх треба перевизначати.
  • Не оголошуйте безглуздих абстрактних класів і не створюйте ієрархію «на виріст».
  • Перевіряйте коректність логіки під час перевизначення методів: не порушуйте інваріантів (правил) базових класів.
  • Не додавайте нових абстрактних методів у публічні базові класи й інтерфейси після публікації бібліотеки.
  • Використовуйте інтерфейси для слабкого звʼязування між частинами програми.
  • Для розширення API використовуйте Default Interface Methods.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ