Программа курса← C#: Базовый курс
1Знакомство с C# и .NET
2Данные и типы
3Ветвления и циклы
4Методы и ошибки
5Коллекции и строки
6Классы и объекты

Наследование и полиморфизм

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

Наследование

public class Animal
{
    public string Name { get; set; }

    public void Sleep()
    {
        Console.WriteLine($"{Name} спит");
    }
}

public class Dog : Animal
{
    public void Fetch()
    {
        Console.WriteLine($"{Name} принёс палку");
    }
}

Двоеточие означает «наследует». Dog получает всё публичное содержимое Animal – и добавляет своё:

var dog = new Dog { Name = "Рекс" };

dog.Sleep();    // унаследовано
dog.Fetch();    // своё

Animal называют базовым классом, Dog – производным. Наследование описывает отношение «является»: собака является животным.

Базовый класс в C# может быть только один. Множественного наследования, как в C++, здесь нет: слишком много неоднозначностей возникает, когда два предка предлагают разные версии одного метода. Реализовать несколько интерфейсов при этом можно – об этом следующий урок.

Конструктор базового класса

Наследник обязан позаботиться о том, чтобы базовая часть объекта была построена:

public class Animal
{
    public string Name { get; }

    public Animal(string name)
    {
        Name = name;
    }
}

public class Dog : Animal
{
    public string Breed { get; }

    public Dog(string name, string breed) : base(name)
    {
        Breed = breed;
    }
}

Запись : base(name) вызывает конструктор базового класса. Он выполняется первым, до тела конструктора наследника: сначала собирается общая часть, потом уточнение.

Полиморфизм

Самое интересное начинается, когда наследники по-разному делают одно и то же.

public class Animal
{
    public virtual void Speak()
    {
        Console.WriteLine("Животное издаёт звук");
    }
}

public class Dog : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Гав");
    }
}

public class Cat : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Мяу");
    }
}

virtual в базовом классе разрешает переопределение, override в наследнике его выполняет.

Теперь объекты разных классов можно обрабатывать одинаково:

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

foreach (var animal in animals)
{
    animal.Speak();    // Гав, Мяу, Животное издаёт звук
}

Переменная объявлена как Animal, но вызывается метод того класса, объект которого лежит внутри. Решение принимается во время работы, а не при компиляции – это и называется полиморфизмом.

Практическая ценность здесь в том, что цикл не знает про Dog и Cat. Добавите класс Parrot – цикл не изменится ни на строчку. Именно ради этого свойства полиморфизм и применяют, а не ради красивого примера с животными.

Почему virtual обязателен

В Java любой метод виртуален по умолчанию, в C# – нет. Разница осмысленная: автор базового класса решает, что разрешено менять наследникам, а что является частью его гарантий.

Если написать в наследнике метод с тем же именем без override, компилятор предупредит о сокрытии. Сокрытый метод ведёт себя коварно: через переменную типа Animal вызовется базовая версия, а через переменную типа Dog – наследника. Одно и то же поведение зависит от типа переменной, а не от объекта. Так почти никогда не нужно.

base: дополнить, а не заменить

Переопределённый метод может вызвать базовую версию:

public override void Speak()
{
    base.Speak();                    // сначала общая часть
    Console.WriteLine("...и виляет хвостом");
}

Это частый приём: наследник не отменяет поведение предка, а расширяет его.

Абстрактные классы

Иногда базовый класс не имеет смысла сам по себе: «животное вообще» не существует, существуют собаки и кошки. Такой класс помечают abstract:

public abstract class Animal
{
    public string Name { get; set; }

    public abstract void Speak();       // без тела: наследник обязан реализовать

    public void Sleep()                 // обычный метод с реализацией
    {
        Console.WriteLine($"{Name} спит");
    }
}

Два следствия:

  • new Animal() не соберётся – абстрактный класс нельзя создать;
  • каждый наследник обязан реализовать Speak, иначе не соберётся уже он.

Абстрактный класс удобен, когда часть поведения общая (Sleep написан один раз), а часть обязана различаться (Speak).

Все классы – наследники object

Любой класс в C# неявно наследует object, даже если это не написано. Отсюда методы, которые есть у всего:

Метод Что делает
ToString() текстовое представление; по умолчанию – имя типа
Equals(object) сравнение с другим объектом
GetHashCode() число для словарей и множеств

ToString переопределяют чаще всего – он вызывается автоматически при выводе и в интерполяции:

public override string ToString() => $"{Name} ({Breed})";

После этого Console.WriteLine(dog) напечатает «Рекс (овчарка)» вместо имени типа.

Наследование – не единственный инструмент

Наследование связывает классы жёстко: изменение базового задевает всех потомков. Часто вместо «собака является животным» правильнее сказать «у машины есть двигатель» – и просто держать объект внутри поля. Это называется композицией.

Когда выбирать наследование, когда композицию, чем опасны глубокие иерархии и как это связано с принципами проектирования – тема курса «Объектно ориентированное программирование». Здесь мы разобрали механику языка; там разбирают, что с ней делать.