Основы C#

Полиморфизм в C#: виды и примеры кода

Обновлено: · 7 мин чтения

Полиморфизм в C# позволяет работать с объектами разных классов через общий тип и при этом получать правильное поведение для каждого из них. Переменная типа Shape может хранить круг или прямоугольник, а вызов Area() выполнит нужную формулу.

Разберём виртуальные методы с virtual и override, абстрактные классы, перегрузку методов, а также разницу между new и override, на которой спотыкаются чаще всего.

Полиморфизм в C#: виды и примеры кода
Содержание
  1. Что такое полиморфизм
  2. Виртуальные методы: virtual и override
  3. Абстрактные классы и методы
  4. Перегрузка методов
  5. Разница между new и override
  6. Полиморфизм с интерфейсами и приведением типов
  7. Типичные ошибки
  8. Частые вопросы

Что такое полиморфизм

Слово означает "много форм". В объектно-ориентированном программировании так называют способность одного и того же вызова вести себя по-разному в зависимости от реального типа объекта. Код, который работает с базовым типом, не знает, какой именно наследник лежит в переменной, и не должен этого знать. Благодаря этому в программу можно добавлять новые классы, не переписывая код, который ими пользуется.

В C# различают два вида полиморфизма. Статический (времени компиляции) реализуется перегрузкой методов и операторов: компилятор сам выбирает нужную версию по типам аргументов. Динамический (времени выполнения) строится на наследовании и виртуальных методах: версию метода выбирает среда выполнения по фактическому типу объекта. Обычно под полиморфизмом имеют в виду именно второй вид.

Для понимания нужна база по наследованию. Если тема новая, сначала прочитайте разбор наследования в C# и вернитесь к этой статье.

Виртуальные методы: virtual и override

Чтобы метод базового класса мог быть заменён в наследниках, его помечают ключевым словом virtual. В наследнике метод с той же сигнатурой объявляют с модификатором override. Тогда вызов через переменную базового типа попадёт в версию наследника.

class Shape
{
    public virtual double Area() => 0;
    public virtual string Name => "Фигура";
}

class Circle : Shape
{
    public double R { get; }
    public Circle(double r) => R = r;
    public override double Area() => Math.PI * R * R;
    public override string Name => "Круг";
}

class Rectangle : Shape
{
    public double W { get; }
    public double H { get; }
    public Rectangle(double w, double h) { W = w; H = h; }
    public override double Area() => W * H;
    public override string Name => "Прямоугольник";
}

Shape[] shapes = { new Circle(2), new Rectangle(3, 4), new Shape() };
foreach (Shape s in shapes)
    Console.WriteLine($"{s.Name}: {s.Area():F2}");

Результат: для круга выведется 12,57, для прямоугольника 12,00, для базовой фигуры 0,00. Переменная s везде имеет тип Shape, но вызываются разные реализации. Это и есть динамический полиморфизм: таблица виртуальных методов объекта хранит ссылки на актуальные версии, и вызов идёт по ней.

Внутри override можно вызвать реализацию родителя через base. Это удобно, когда наследник должен дополнить поведение, а не заменить его целиком.

class Animal
{
    public virtual string Describe() => "Животное";
}

class Dog : Animal
{
    public override string Describe() => base.Describe() + ": собака";
}

Правила простые. Переопределять можно только virtual, abstract или уже override-метод. Сигнатура, тип результата и уровень доступа должны совпадать. Методы без virtual, статические и приватные переопределить нельзя. Если нужно запретить дальнейшее переопределение, наследник ставит sealed: sealed override. Подробнее о приёмах переопределения читайте в материале про переопределение методов.

Абстрактные классы и методы

Иногда у базового класса нет разумной реализации метода. Площадь абстрактной "фигуры" посчитать нельзя, и версия, возвращающая 0, только вводит в заблуждение. В таком случае метод объявляют abstract: у него нет тела, а класс, содержащий хотя бы один такой метод, тоже должен быть abstract.

abstract class Employee
{
    public string Name { get; }
    protected Employee(string name) => Name = name;

    public abstract decimal CalcSalary();

    public override string ToString() => $"{Name}: {CalcSalary():N0}";
}

class Manager : Employee
{
    public Manager(string name) : base(name) { }
    public override decimal CalcSalary() => 150_000m;
}

class Developer : Employee
{
    private readonly int _hours;
    public Developer(string name, int hours) : base(name) => _hours = hours;
    public override decimal CalcSalary() => _hours * 1_500m;
}

List<Employee> staff = new() { new Manager("Анна"), new Developer("Игорь", 160) };
foreach (var e in staff) Console.WriteLine(e);

Экземпляр абстрактного класса создать нельзя, зато использовать его как общий тип для списков и параметров можно. Каждый конкретный наследник обязан реализовать все абстрактные члены, иначе компилятор выдаст ошибку. Это гарантирует, что в коде не окажется объекта с недостающим поведением.

Абстрактный класс может содержать и обычные члены: поля, конструкторы, готовые методы. Этим он отличается от интерфейса, который описывает только контракт. Выбор между ними зависит от задачи: если у наследников есть общее состояние и код, берите абстрактный класс, а если нужна лишь договорённость о возможностях, подойдёт интерфейс. Про это есть отдельная статья о реализации интерфейсов. Интерфейсы дают тот же полиморфизм, только без наследования реализации.

Перегрузка методов

Перегрузка (overloading) - это несколько методов с одним именем и разными параметрами в одном классе. Компилятор выбирает нужный вариант при сборке по количеству и типам аргументов. Это статический полиморфизм: решение принято до запуска программы.

class Printer
{
    public void Print(int value) => Console.WriteLine($"int: {value}");
    public void Print(double value) => Console.WriteLine($"double: {value}");
    public void Print(string value) => Console.WriteLine($"string: {value}");
    public void Print(string value, int times)
    {
        for (int i = 0; i < times; i++) Console.WriteLine(value);
    }
}

var p = new Printer();
p.Print(5);        // int
p.Print(5.5);      // double
p.Print("текст");  // string
p.Print("a", 2);   // две строки

Различать перегрузки только по типу возвращаемого значения нельзя: сигнатуру образуют имя и список параметров. Модификаторы ref, out и in тоже влияют на сигнатуру, а вот названия параметров нет. При неоднозначности вроде Print(null) для нескольких ссылочных типов компилятор сообщит об ошибке, и вызов придётся уточнить приведением.

Перегрузку легко перепутать с переопределением. Перегрузка создаёт новые методы рядом со старыми и разрешается на этапе компиляции. Переопределение заменяет метод родителя и разрешается во время работы программы. Обе техники часто идут вместе, например, у List<T> десятки перегрузок Add, Remove и Sort. Подробнее об устройстве методов см. в статье методы в C# простыми словами.

Разница между new и override

Если в наследнике объявить метод с тем же именем, что и в базовом классе, но без override, компилятор выдаст предупреждение: метод скрывает унаследованный. Ключевое слово new явно сообщает, что сокрытие задумано. При этом полиморфизма не будет, ведь выбор метода зависит от типа переменной, а не от типа объекта.

class Base
{
    public virtual string Who() => "Base.Who";
    public string Hello() => "Base.Hello";
}

class Derived : Base
{
    public override string Who() => "Derived.Who";
    public new string Hello() => "Derived.Hello";
}

Base b = new Derived();
Derived d = new Derived();

Console.WriteLine(b.Who());    // Derived.Who  (override, полиморфизм)
Console.WriteLine(d.Who());    // Derived.Who
Console.WriteLine(b.Hello());  // Base.Hello   (new, тип переменной Base)
Console.WriteLine(d.Hello());  // Derived.Hello

Здесь Who переопределён, и независимо от типа переменной срабатывает версия Derived. Hello скрыт: через переменную Base вызовется базовая версия, через переменную Derived - новая. Один и тот же объект отвечает по-разному, в зависимости от того, каким типом на него смотрят. Это источник трудноуловимых ошибок, поэтому new используют крайне редко, как правило, при работе с чужим классом, у которого нельзя добавить virtual.

Критерийoverridenew (скрытие)
Нужен virtual в базовом класседанет
Версию выбираетфактический тип объектатип переменной
Это полиморфизмданет
Типичное применениерасширение поведенияобход чужого кода

Практическое правило: если хотите заменить поведение родителя, делайте метод virtual и применяйте override. Если компилятор предупреждает о сокрытии, а вы не планировали его, скорее всего, вы забыли override.

Полиморфизм с интерфейсами и приведением типов

Полиморфная работа возможна и через интерфейсы. Разные классы реализуют общий контракт, а код принимает параметр интерфейсного типа. Это позволяет подменять реализации в тестах и менять поведение без правок вызывающего кода.

interface INotifier
{
    void Send(string message);
}

class EmailNotifier : INotifier
{
    public void Send(string message) => Console.WriteLine($"Email: {message}");
}

class SmsNotifier : INotifier
{
    public void Send(string message) => Console.WriteLine($"SMS: {message}");
}

static void Alert(INotifier notifier) => notifier.Send("Сервер недоступен");

Alert(new EmailNotifier());
Alert(new SmsNotifier());

Иногда нужно проверить реальный тип объекта, например, чтобы вызвать метод, которого нет в базовом классе. Для этого служат операторы is и as, а также сопоставление с образцом. Но частые проверки типов обычно сигнал, что поведение стоит перенести в виртуальный метод.

static string Info(Shape s) => s switch
{
    Circle c => $"Круг радиуса {c.R}",
    Rectangle r => $"Прямоугольник {r.W}x{r.H}",
    _ => "Неизвестная фигура"
};

Типичные ошибки

  • Забыт virtual в базовом классе: override не компилируется, а new выключает полиморфизм.
  • Вызов виртуального метода из конструктора базового класса. Наследник ещё не инициализирован, и его override может обратиться к пустым полям.
  • Слишком глубокая иерархия. Три-четыре уровня наследования уже тяжело читать, часто лучше композиция.
  • Цепочки if с проверкой типа вместо виртуального метода. При добавлении нового класса приходится править все такие места.
  • Разное поведение в override, нарушающее ожидания базового типа. Наследник должен подходить везде, где ждут родителя.

Последний пункт известен как принцип подстановки Лисков: объект наследника можно подставить вместо объекта базового класса без нарушения работы программы. Если для этого пришлось бы бросать исключения в override или молча возвращать заглушки, иерархия построена неудачно.

Частые вопросы

Чем полиморфизм отличается от наследования?

Наследование позволяет переиспользовать код и строить иерархию типов. Полиморфизм использует эту иерархию, чтобы один вызов выполнял разный код в зависимости от фактического типа объекта.

Можно ли переопределить не виртуальный метод?

Нет. Для override у базового метода должен быть модификатор virtual, abstract или override. Не виртуальный метод можно только скрыть через new, но это не даст полиморфного поведения.

Чем abstract отличается от virtual?

Virtual-метод имеет реализацию по умолчанию, и наследник может её не менять. Abstract-метод не имеет тела, и каждый конкретный наследник обязан его реализовать.

Является ли перегрузка полиморфизмом?

Да, это статический, или ad hoc, полиморфизм: один идентификатор при разных типах параметров. Решение принимается компилятором, а не во время выполнения.

Что будет, если в наследнике забыть override?

Компилятор выдаст предупреждение о сокрытии члена, и метод будет работать как с модификатором new. Вызов через переменную базового типа выполнит родительскую версию.

Можно ли переопределять свойства?

Да, свойства и индексаторы тоже бывают virtual и override, как показано в примере с Name выше. Переопределять можно и события.