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

Содержание
Что такое полиморфизм
Слово означает "много форм". В объектно-ориентированном программировании так называют способность одного и того же вызова вести себя по-разному в зависимости от реального типа объекта. Код, который работает с базовым типом, не знает, какой именно наследник лежит в переменной, и не должен этого знать. Благодаря этому в программу можно добавлять новые классы, не переписывая код, который ими пользуется.
В 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.
| Критерий | override | new (скрытие) |
|---|---|---|
| Нужен 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 выше. Переопределять можно и события.





