Один интерфейс – множество реализаций
Бьерн Страуструп
Для работы с формальными абстракциями в C# предусмотрено множество методов. Даже модификаторы доступа, так или иначе, помогают нам возводить абстракцию, но сейчас нас интересуют основные синтаксические методы для работы с полиморфизмом подтипов.
Когда мы наследуем некий тип “B” от типа “A”, мы можем не только расширить тип “A”, но и переопределить часть его поведения. В такой ситуации мы и получаем: тип “A” задаёт некий интерфейс, а мы можем, соблюдая его, переопределить поведение.
Для этого базовый класс должен указать, что некий его метод виртуальный, и тогда производный класс может переопределить поведение этого метода.
Хорошим тоном является не переопределять поведение базового класса, а лишь дополнять его. Про это говорит, например, один из принципов SOLID – LSP.
1.51
class Program
{
static void Main(string[] args)
{
Gun gun = new Gun();
Player player1 = new Player();
gun.Fire(player1);
}
}
class Player
{
private float _health;
public void ApplyDamage(float amount)
{
_health -= amount;
}
}
class Gun
{
private int _bullets;
private float _damage;
public void Fire(Player player)
{
if (_bullets <= 0)
return;
player.ApplyDamage(_damage);
_bullets--;
}
}
У нас есть некое оружие, которое стреляет в игрока. Оружие наносит игроку урон и отсчитывает патроны. Также у нас есть правило – оружие без патронов не стреляет.
Всё использование оружия заключено в методе Main. Но оружие может пользоваться, и какой-то тип, который может симулировать бой. Например:
1.52
class Battle
{
private Gun _gun;
private Player[] _players;
public Battle(Gun gun, Player[] players)
{
_gun = gun;
_players = players;
}
public void Simulate()
{
foreach (var player in _players)
{
_gun.Fire(player);
}
}
}
И тут мы захотели иметь разные реализации оружия:
- Бесконечный пистолет;
- Лук, у которого с каждым выстрелом уменьшается урон.
Как мы можем это выразить? На самом деле просто. И, я думаю, вы бы с лёгкостью с этим справились. Но я добавлю ещё одно условие: нужно сделать такое оружие, и при этом, чтобы алгоритм симуляции боя и какие-нибудь другие части программы не изменялись. Т.е. хотелось бы работать с этим извне, на уровне конфигурации, и не переписывать код каждый раз, когда нам добавляется оружие.
Мы можем удовлетворить условие с помощью виртуальных методов. Мы можем пометить метод Fire в базовый класс модификатором virtual, а в производных классах сделать точно такие же методы по сигнатуре, но с модификатором override.
Это не имеет смысла, если вы не узнаете, что ссылка базового типа может ссылаться на производный тип. И при этом, если по такой ссылке вызывается виртуальный метод, то фактически будет вызван метод производного типа, если он там переопределён.
1.53
class Program
{
static void Main(string[] args)
{
Gun gun = new Gun();
Player player1 = new Player();
gun.Fire(player1);
gun = new Bow();
gun.Fire(player1);
gun.Fire(player1);
Battle battle = new Battle(new Pistol(), new Player[] { player1 });
battle.Simulate();
}
}
class Battle
{
private Gun _gun;
private Player[] _players;
public Battle(Gun gun, Player[] players)
{
_gun = gun;
_players = players;
}
public void Simulate()
{
foreach (var player in _players)
{
_gun.Fire(player);
}
}
}
class Player
{
private float _health;
public void ApplyDamage(float amount)
{
_health -= amount;
}
}
class Gun
{
private int _bullets;
protected float Damage;
public virtual void Fire(Player player)
{
if (_bullets <= 0)
return;
player.ApplyDamage(Damage);
_bullets--;
}
}
class Pistol : Gun
{
public override void Fire(Player player)
{
player.ApplyDamage(Damage);
}
}
class Bow : Gun
{
public override void Fire(Player player)
{
base.Fire(player);
Damage /= 2;
}
}
Обратите внимание, что в этом коде добавилось два класса: Bow и Pistol, в которых произошло то, о чём мы говорили. В базовом классе Gun метод Fire помечен как virtual, а в производных Bow и Gun присутствуют эти же методы, но уже как override.
Потом посмотрите на метод Main. Обратите внимание, как в переменную с типом Gun мы кладём Bow, а потом при создании объекта типа Battle в конструктор, который требует тип Gun, я передаю тип Pistol. Синтаксически, когда мы указываем тип Gun, мы можем использовать только то, что определено в этом типе, но при этом, то, что там определено, может быть переопределено в производных типах. Поэтому иногда мы работаем, вроде бы, с Gun, но фактически это Pistol. Благодаря этому, мы имеем некоторую степень защищённости и, при этом гибкости.
Из важных мелочей:
- Damage стал protected для того, чтобы производные классы могли его изменить;
- В классе Bow мы используем строку, которая вызывает метод базового класса. В случае с Pistol мы полностью переопределяем код. Т.е. метод Fire базового класса не выполнится. А вот в случае с Bow мы вызываем метод базового класса и добавляем ему дополнительное поведение (уменьшение урона в два раза).
Задача с собеседования
На собеседовании вам могут задать интересный вопрос, который на практике встречается не часто, из соображений дизайна кода, но понимать это безусловно полезно. Суть вопроса заключается в том, как происходит переопределение методов на нескольких уровнях иерархии.
class A
{
public virtual Do() => Console.WriteLine("A");
}
class B : A
{
public override Do() => Console.WriteLine("B");
}
class C : B
{
public override Do() => Console.WriteLine("C");
}
class Example
{
public static void Main(string[] args)
{
A a = new C();
a.Do(); //1
a = new B();
a.Do(); //2
}
}
Что в таком случае будет выводиться в консоль? Ответ очевиден:
- C
- B
Наследники переопределяют метод, при этом вызывается тот, что будет ниже по иерархии. Наверное, не стоит говорить, что вызывается метод фактического типа, а не просто самого нижнего наследника.
То есть, если у нас есть ссылка типа “A” на объект типа “C”, то при вызове метода по типу “A” будет происходить поиск переопределённого метода сверху вниз до самого нижнего, а так как там тип “C”, то будет вызываться его метод. Если вместо “C” будет “B”, то будет вызываться метод “B”.
Учитывая это, что будет в таком случае?
class A
{
public virtual Do() => Console.WriteLine("A");
}
class B : A
{
public virtual Do() => Console.WriteLine("B");
}
class C : B
{
public override Do() => Console.WriteLine("C");
}
class Example
{
public static void Main(string[] args)
{
A a = new C();
a.Do(); //1
a = new B();
a.Do(); //2
}
}
Ответ может запутать:
- A
- A
Дело в том, что “B” заменяет метод на свой, и делает его виртуальным, “B” имеет по сути новый метод, а “C” его переопределяет, и к виртуальном методу типа “A” это уже не имеет значения. С точки зрения стилистики, чтобы было меньше путаницы, мы должны пометить метод в типе “B” модификатором new.
class B : A
{
public new virtual Do() => Console.WriteLine("B");
}