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

Один интерфейс – множество реализаций

Бьерн Страуструп

Для работы с формальными абстракциями в 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
   }
}

Что в таком случае будет выводиться в консоль? Ответ очевиден:

  1. C
  2. 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
   }
}

Ответ может запутать:

  1. A
  2. A

Дело в том, что “B” заменяет метод на свой, и делает его виртуальным, “B” имеет по сути новый метод, а “C” его переопределяет, и к виртуальном методу типа “A” это уже не имеет значения. С точки зрения стилистики, чтобы было меньше путаницы, мы должны пометить метод в типе “B” модификатором new.

class B : A
{
    public new virtual Do() => Console.WriteLine("B");
}

Если вы нашли ошибку, пожалуйста выделите её и нажмите Ctrl+Enter.


Leave a Reply

Your email address will not be published. Required fields are marked *