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дадут одинаковый вывод, и весь вопрос теряет смысл.