virtual, override и new: что выведет код

«Что выведет этот код?» — и на экране два метода, отличающиеся одним словом. Разбираем самый популярный вопрос-ловушку по ООП в C#.

override — переопределение: метод выбирается по фактическому типу объекта в момент выполнения. new — сокрытие: метод выбирается по типу переменной ещё на этапе компиляции. Разница видна только тогда, когда типы переменной и объекта расходятся.

Вопрос №1: базовый вариант

Вот код, который просят прочитать глазами и назвать вывод. Не подглядывайте вниз — сначала ответьте сами.

using System;

class Base
{
    public virtual void Virt()
    {
        Console.WriteLine("Base.Virt");
    }

    public void NonVirt()
    {
        Console.WriteLine("Base.NonVirt");
    }
}

class Derived : Base
{
    public override void Virt()
    {
        Console.WriteLine("Derived.Virt");
    }

    public new void NonVirt()
    {
        Console.WriteLine("Derived.NonVirt");
    }
}

class Program
{
    static void Main()
    {
        Base b = new Derived();
        Derived d = new Derived();

        b.Virt();
        b.NonVirt();
        d.Virt();
        d.NonVirt();
    }
}

Результат:

Derived.Virt
Base.NonVirt
Derived.Virt
Derived.NonVirt

Ключевая строка — вторая. Объект в переменной b на самом деле Derived, но переменная объявлена как Base, а NonVirt не виртуальный. Компилятор ещё во время сборки решил: «здесь зовём Base.NonVirt» — и никакой рантайм это решение не отменит. Объект как бы «не знает», что его прячут.

С Virt всё иначе: он virtual, поэтому компилятор откладывает выбор («позднее связывание»), и в момент вызова CLR смотрит на фактический тип объекта — Derived.

Формулировка, которая нравится интервьюерам: override меняет поведение объекта, new меняет только то, что видит переменная.

Вопрос №2: злая версия на три уровня

Если базовый вариант вы прошли — дадут вот это. Здесь ошибаются даже опытные.

using System;

class A
{
    public virtual void Say()
    {
        Console.WriteLine("A");
    }
}

class B : A
{
    public new virtual void Say()
    {
        Console.WriteLine("B");
    }
}

class C : B
{
    public override void Say()
    {
        Console.WriteLine("C");
    }
}

class Program
{
    static void Main()
    {
        C c = new C();
        A a = c;
        B b = c;

        a.Say();
        b.Say();
        c.Say();
    }
}

Результат:

A
C
C

Один и тот же объект отвечает по-разному в зависимости от типа ссылки — хотя все методы виртуальные! Разгадка в слове new virtual у класса B: это не переопределение, а новая виртуальная цепочка. У объекта теперь два независимых виртуальных «слота» с именем Say: старый, идущий от A, и новый, начатый в B. Класс C переопределяет только второй.

Когда мы зовём a.Say(), компилятор адресуется к слоту A.Say. Этот слот никто не переопределял — B его спрятал, а не заменил. Значит, выполняется реализация из A. А b.Say() и c.Say() идут в слот B.Say, который C честно переопределил, — отсюда «C».

Вопрос №3: виртуальный вызов из конструктора

Это уже вопрос на senior. Что напечатает программа?

using System;

class Base
{
    public Base()
    {
        Console.WriteLine("Base ctor");
        Init();
    }

    protected virtual void Init()
    {
        Console.WriteLine("Base.Init");
    }
}

class Derived : Base
{
    private string _name;

    public Derived()
    {
        _name = "готово";
        Console.WriteLine("Derived ctor");
    }

    protected override void Init()
    {
        Console.WriteLine("Derived.Init, _name = " + (_name ?? "null"));
    }
}

class Program
{
    static void Main()
    {
        new Derived();
    }
}

Результат:

Base ctor
Derived.Init, _name = null
Derived ctor

Конструктор базового класса отрабатывает раньше тела конструктора наследника. Но виртуальный вызов уже диспетчеризуется по фактическому типу — то есть Base вызывает Derived.Init() на недостроенном объекте. Поле _name ещё null, хотя код наследника «очевидно» его заполняет.

Тонкость именно C#, которую стоит проговорить вслух: инициализаторы полей наследника (private string _name = "готово";) выполняются до вызова базового конструктора, а вот присваивания в теле конструктора — после. Так что в примере выше достаточно перенести значение в инициализатор поля, и null исчезнет. В Java, кстати, не спасёт и это — приятный повод показать, что вы знаете разницу.

Правило простое: не вызывайте virtual-методы из конструктора. Анализаторы кода ругаются на это не зря.

Как это работает под капотом

Для каждого типа CLR строит таблицу методов. Каждый virtual-метод занимает в ней слот. Когда наследник пишет override, он не создаёт новый слот — он подменяет содержимое унаследованного. Поэтому у объекта Derived в слоте «Virt» лежит адрес Derived.Virt, и не важно, через какую ссылку вы туда пришли.

Компилятор C# для вызова виртуального метода генерирует инструкцию callvirt с токеном метода, выбранного по статическому типу переменной: для b.Virt() это Base.Virt. Дальше CLR берёт номер слота этого метода и ищет реальный адрес в таблице фактического объекта — так и получается позднее связывание.

new в объявлении метода не трогает слоты вообще. Это просто указание компилятору: «я знаю, что скрываю метод предка, не ругайся». Скрывающий метод — отдельная сущность, вызов к нему прибивается на этапе компиляции. Кстати, если убрать new, код всё равно соберётся, но вы получите предупреждение CS0108 — «метод скрывает унаследованный член». Это подсказка компилятора: «ты правда хотел сокрытие, а не override?»

И пара смежных правил, которые вытекают из той же механики: override нельзя применить к невиртуальному методу (CS0506), нельзя менять при переопределении модификатор доступа, а sealed override закрывает слот — дальше по цепочке наследования его уже не переопределить (и JIT сможет девиртуализовать вызов).

Таблица-шпаргалка

МодификаторЧто делаетКогда выбирается реализация
virtualразрешает переопределение, есть телово время выполнения
abstractобязывает переопределить, тела нетво время выполнения
overrideподменяет реализацию в том же слотево время выполнения
newскрывает метод предка, слот не трогаетво время компиляции, по типу переменной
sealed overrideпереопределяет и запрещает дальнейшеево время выполнения (но цепочка закрыта)

Частые ошибки на собеседовании

  • «new — это тоже переопределение, просто по-другому». Нет. Переопределения не происходит: объект ведёт себя по-разному в зависимости от типа ссылки. Это не полиморфизм, а сокрытие.
  • Думать, что методы в C# виртуальны по умолчанию. Пришедшие из Java часто в этом уверены. В C# метод невиртуален, пока вы явно не написали virtual или abstract.
  • Путать overload и override. Перегрузка (overload) — несколько методов с одним именем и разными параметрами в одном классе, выбирается компилятором. К наследованию отношения не имеет.
  • Не заметить new virtual в трёхуровневом примере. Именно за этим слово туда и вставлено.
  • Отвечать «Derived» на всё подряд. Проговаривайте вслух: «смотрю на тип переменной, смотрю на модификатор, смотрю на фактический тип объекта». Интервьюер оценивает ход рассуждений не меньше, чем ответ.
  • Не знать про ловушку с конструктором. Вопрос «безопасно ли звать virtual-метод из конструктора?» — почти всегда проверка на боевой опыт.

Итоги-шпаргалка

  • virtual + override — один слот, выбор по фактическому типу объекта. Это и есть полиморфизм.
  • new — сокрытие. Выбор по типу переменной, на этапе компиляции. Разница проявляется только при Base b = new Derived();.
  • new virtual начинает новую виртуальную цепочку — старый слот предка остаётся жить своей жизнью.
  • Без new сокрытие даёт предупреждение CS0108; override невиртуального метода — ошибку CS0506.
  • Виртуальный вызов из конструктора попадает в наследника до того, как тот успел проинициализироваться в теле своего конструктора. Не делайте так.
  • Если сомневаетесь, что перед вами: спросите себя, отличается ли тип переменной от типа объекта. Если нет — virtual и new дадут одинаковый вывод, и весь вопрос теряет смысл.
Проверьте себя
1. class Base { public void NonVirt() => Console.WriteLine("Base"); } class Derived : Base { public new void NonVirt() => Console.WriteLine("Derived"); } Что выведет Base b = new Derived(); b.NonVirt(); ?
ADerived — потому что фактический тип объекта Derived
BBase — потому что метод не виртуальный и выбирается по типу переменной
CНичего: код не скомпилируется без override
DBase, а затем Derived
2. A объявляет virtual Say, B пишет new virtual Say, C пишет override Say. Что выведет A a = new C(); a.Say(); ?
AC — override всегда побеждает
BB — последняя виртуальная реализация в цепочке
CA — слот A.Say никто не переопределил, B его лишь скрыл
DКод не скомпилируется: нельзя писать new virtual