1. Введение
Представим мир без иерархий: тысячи классов Person, Animal, Vehicle и всё это абсолютно отдельно. Неудивительно, что программисты в таком мире не смогли бы дожить даже до обеда — запутались бы окончательно! В реальных проектах нам часто нужны объекты, которые умеют делать что-то общее (например, все животные могут двигаться), но при этом у каждого свои особенности (рыба плавает, а птица летает).
Именно иерархии классов позволяют выразить связи между сущностями, чтобы программирование стало творческой работой, а не бесконечной битвой с копипастой.
Рассмотрим пример. Допустим, у нас есть базовый класс Animal. Все животные могут издавать звук. Но только кошки мяукают, собаки лают, а попугаи могут даже пару анекдотов рассказать. Мы хотим это выразить в коде иерархически.
Базовый класс
public class Animal
{
public string Name { get; set; }
public Animal(string name)
{
Name = name;
}
// Базовый метод: можно переопределить в потомках
public virtual void Speak()
{
Console.WriteLine("Животное издаёт какой-то звук...");
}
}
Здесь мы добавили слово virtual к методу Speak(). Это как пометка: "Эй, дочерние классы, если захотите — можете переопределить этот метод".
Создаём иерархию: производные классы
Теперь пусть у нас будет класс Cat, который будет наследоваться от Animal:
public class Cat : Animal
{
public Cat(string name) : base(name) { }
// Переопределяем Speak — кошки же не могут просто рычать!
public override void Speak()
{
Console.WriteLine($"{Name} говорит: Мяу!");
}
}
И класс Dog:
public class Dog : Animal
{
public Dog(string name) : base(name) { }
public override void Speak()
{
Console.WriteLine($"{Name} говорит: Гав!");
}
}
А если у нас будет обычное животное, которое не умеет говорить? Тогда можно использовать базовый класс, ничего не переопределяя.
Визуализация — дерево иерархии
. Animal
/ \
Cat Dog
- Animal — базовый класс
- Cat, Dog — дочерние (производные) классы
2. Давайте напишем код, который использует такую иерархию
Продолжаем работу над нашим консольным приложением.
Допустим, у нас есть коллекция животных и мы хотим, чтобы каждое из них сказало что-то характерное:
Animal[] zoo = new Animal[]
{
new Cat("Барсик"),
new Dog("Рекс"),
new Animal("Загадочное существо")
};
foreach (Animal animal in zoo)
{
animal.Speak();
}
Ожидаемый вывод:
Барсик говорит: Мяу!
Рекс говорит: Гав!
Животное издаёт какой-то звук...
Вот так, благодаря иерархии и полиморфизму (мы его скоро подробно разберём, но суть — вызывается правильная версия метода в зависимости от реального типа объекта), ваше приложение становится гибким и расширяемым.
3. Добавляем новые методы и поля
Итак, с "озвучкой" разобрались. Но все животные — это слишком скучно. Например, у кошки может быть девять жизней, а собака умеет приносить палку.
Добавляем уникальное поведение
В классе-потомке можно добавлять свои собственные методы и поля:
public class Cat : Animal
{
public int Lives { get; private set; } = 9;
public Cat(string name) : base(name) { }
public override void Speak()
{
Console.WriteLine($"{Name} говорит: Мяу! У меня {Lives} жизней.");
}
public void LoseLife()
{
if (Lives > 0)
{
Lives--;
Console.WriteLine($"{Name} потерял одну жизнь. Осталось: {Lives}");
}
else
{
Console.WriteLine($"{Name} уже использовал все жизни!");
}
}
}
Применяем в коде:
var barsik = new Cat("Барсик");
barsik.Speak(); // Барсик говорит: Мяу! У меня 9 жизней.
barsik.LoseLife(); // Барсик потерял одну жизнь. Осталось: 8
Добавляем новые классы: расширяем "зоопарк"
Вы уже умеете создавать производные классы. Добавим, например, попугая:
public class Parrot : Animal
{
public Parrot(string name) : base(name) { }
public override void Speak()
{
Console.WriteLine($"{Name} говорит: Привет, человек!");
}
public void Repeat(string phrase)
{
Console.WriteLine($"{Name} повторяет: {phrase}");
}
}
Теперь вы легко расширяете систему, не трогая старый код:
var keshka = new Parrot("Кеша");
keshka.Speak(); // Кеша говорит: Привет, человек!
keshka.Repeat("Учись, студент!"); // Кеша повторяет: Учись, студент!
4. Сравнение поведения животных
| Тип | Метод Speak() | Собственное поле | Дополнительное поведение |
|---|---|---|---|
| Animal | Да (virtual) | Name | — |
| Cat | Да (override) | Lives | LoseLife() |
| Dog | Да (override) | — | — |
| Parrot | Да (override) | — | Repeat(string) |
Как выглядит иерархия классов в памяти (блок-схема)
Animal (Name)
├── Cat (Lives)
├── Dog
└── Parrot (Repeat)
5. Практика в нашем приложении
Давайте свяжем идею иерархии с приложением — например, у нас есть задачи разного типа:
- Task (базовый класс): Любая задача — у неё есть название и статус выполнения.
- WorkTask (рабочая): Помимо всего прочего, имеет дедлайн.
- HomeTask (домашняя): Может иметь приоритет ("Очень важно", "Так себе").
Начнем с базового класса:
public class Task
{
public string Title { get; set; }
public bool IsCompleted { get; private set; }
public Task(string title)
{
Title = title;
}
public virtual void Complete()
{
IsCompleted = true;
Console.WriteLine($"Задача \"{Title}\" выполнена!");
}
}
Теперь добавляем рабочую задачу:
public class WorkTask : Task
{
public DateTime Deadline { get; set; }
public WorkTask(string title, DateTime deadline)
: base(title)
{
Deadline = deadline;
}
public override void Complete()
{
base.Complete();
Console.WriteLine($"Срок исполнения: {Deadline:d}");
}
}
И домашнюю задачу:
public class HomeTask : Task
{
public string Priority { get; set; }
public HomeTask(string title, string priority)
: base(title)
{
Priority = priority;
}
// Можно не переопределять Complete, если поведения достаточно из базового класса
}
Заводим список задач в программе:
List<Task> tasks = new List<Task>
{
new WorkTask("Отправить отчёт", DateTime.Today.AddDays(2)),
new HomeTask("Помыть посуду", "Очень важно"),
new Task("Прочитать лекцию по наследованию")
};
foreach (Task task in tasks)
{
Console.WriteLine($"Задача: {task.Title}");
task.Complete();
}
Ожидаемый вывод:
Задача: Отправить отчёт
Задача "Отправить отчёт" выполнена!
Срок исполнения: 13.07.2025
Задача: Помыть посуду
Задача "Помыть посуду" выполнена!
Задача: Прочитать лекцию по наследованию
Задача "Прочитать лекцию по наследованию" выполнена!
Видите, насколько удобно: все задачи хранятся вместе, мы их обрабатываем единообразно, а специфика проявляется там, где нужно.
6. Типичные ошибки при использовании наследования
Ошибка №1: попытка переопределить метод, который не объявлен как virtual.
Если метод в базовом классе не помечен как virtual, его нельзя переопределить в производных классах. В результате вся гибкость полиморфизма теряется, и иерархия становится бесполезной.
Ошибка №2: наследование без логической связи между сущностями.
Не стоит использовать наследование, если объекты не связаны по смыслу. Например, Круг действительно является Фигурой, но Конь как Транспортное средство — это уже натяжка. Исключение — специфические контексты (например, средневековая игра), где такая связь может быть оправдана.
Ошибка №3: чрезмерно глубокие иерархии.
Когда структура классов уходит глубоко (5–6 и более уровней), код становится сложно читать, сопровождать и тестировать. Это сигнал, что стоит рассмотреть композицию как альтернативу наследованию.
Ошибка №4: забыли вызвать базовый конструктор.
При добавлении новых свойств в производном классе легко забыть явно вызвать base(...) в конструкторе. Это может привести к неполной или неправильной инициализации базовой части объекта и к трудноуловимым багам.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ