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, вызовется оригинальный метод базового класса. Почему? Потому что метод не был объявлен как виртуальный! Ловушка номер один: если вы хотите использовать полиморфизм, не забывайте про ключевые слова 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. Проблемы с абстракцией

Абстракция — отличный инструмент для упрощения работы пользователя с объектом и ограничения доступа к внутреннему состоянию. Но и тут есть свои подводные камни!

Перегиб с уровнями абстракции (Over-Abstraction)

Некоторые начинающие (и не только) разработчики настолько увлекаются "правильным ООП", что создают целые слоеные торты из базовых классов, интерфейсов и абстрактных слоёв. В итоге разобраться, как оно работает, сложно даже самому автору.

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.
2
Задача
C# SELF, 25 уровень, 3 лекция
Недоступна
Использование ключевого слова new для скрытия метода
Использование ключевого слова new для скрытия метода
2
Задача
C# SELF, 25 уровень, 3 лекция
Недоступна
Использование абстрактных классов и принципа специализации
Использование абстрактных классов и принципа специализации
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ