Собеседование C#: классы и интерфейсы — полный разбор для Senior
Память, виртуальная диспетчеризация, default interface methods, абстрактные классы vs интерфейсы, ковариантность — всё, на чём плавают senior-кандидаты.
Содержание
Вопросы про классы и интерфейсы на Senior-собесе не про синтаксис. Они про память, про то как CLR диспетчеризирует вызовы, про подводные камни наследования и про архитектурные решения. Разбираем.
Память: где живёт класс
Объект класса — всегда в куче?
Почти всегда — да. Когда вы пишете new MyClass(), CLR аллоцирует память в managed heap, инициализирует object header и vtable pointer, вызывает конструктор.
class Point {
public int X;
public int Y;
}
Point p = new Point(); // объект в куче, p — ссылка на стеке (8 байт на x64)
В стеке живёт только ссылка (указатель, 8 байт на x64), не сам объект.
Можно ли разместить класс на стеке?
Напрямую — нет. Но есть несколько способов приблизиться к этому.
Способ 1: использовать struct вместо class
Самый очевидный. Если тип небольшой, иммутабельный и не нужно наследование — struct на стеке без аллокации.
Способ 2: stackalloc + Span для unmanaged типов
// только для unmanaged типов (нет ссылочных полей)
Span<Point> points = stackalloc Point[10]; // Point должен быть struct
Для class это невозможно — управляемые объекты с GC-заголовком не могут жить на стеке.
Способ 3: unsafe + fixed size buffer
unsafe struct Buffer {
public fixed byte Data[256]; // 256 байт прямо в struct, на стеке
}
Способ 4: NativeMemory / unmanaged heap
// .NET 6+
void* ptr = NativeMemory.Alloc(1024);
// ...
NativeMemory.Free(ptr);
Не стек и не managed heap — unmanaged память, GC не участвует. Нужен явный Free.
Вывод для собеса: объект класса на стеке разместить нельзя. Альтернативы — struct, stackalloc для unmanaged данных, NativeMemory для ручного управления.
Что хранится в куче для объекта класса
Каждый объект в managed heap содержит:
[ Object Header (8 байт) ][ vtable pointer (8 байт) ][ поля объекта ]
Object Header — используется для sync block (lock), hashcode и флагов GC.
vtable pointer — указатель на таблицу виртуальных методов типа. Один на все объекты одного типа, хранится в области Method Table.
class Animal {
public virtual void Speak() => Console.WriteLine("...");
}
class Dog : Animal {
public override void Speak() => Console.WriteLine("Woof");
}
Animal a = new Dog();
a.Speak(); // CLR читает vtable pointer → находит Dog.Speak → вызывает
Где хранится интерфейсная ссылка
Интерфейс — это не объект. Интерфейсная переменная — это ссылка на существующий объект в куче, плюс указатель на interface dispatch table (IDT) для этого типа.
interface IAnimal { void Speak(); }
class Dog : IAnimal { public void Speak() => Console.WriteLine("Woof"); }
IAnimal a = new Dog(); // в куче — объект Dog, на стеке — ссылка + указатель на IDT
Для struct — boxing: объект уезжает в кучу, интерфейсная переменная указывает на boxed копию.
struct Cat : IAnimal { public void Speak() => Console.WriteLine("Meow"); }
IAnimal c = new Cat(); // boxing — Cat в куче
Виртуальная диспетчеризация
Вопрос: сколько стоит виртуальный вызов?
Ловушка: кандидат говорит «немного медленнее обычного».
Правильный ответ: виртуальный вызов — это разыменование vtable pointer + indirect call. Стоимость: ~1–3 нс на современном CPU при cache hit. Но реальная проблема — девиртуализация: JIT может девиртуализировать вызов, если знает точный тип (sealed class, final method, инлайнинг). sealed — не только архитектурное решение, но и performance hint для JIT:
sealed class Dog : Animal {
public override void Speak() => Console.WriteLine("Woof");
// JIT может инлайнить — нет наследников
}
Вопрос: new vs override
class Base {
public virtual void Foo() => Console.WriteLine("Base.Foo");
public void Bar() => Console.WriteLine("Base.Bar");
}
class Derived : Base {
public override void Foo() => Console.WriteLine("Derived.Foo");
public new void Bar() => Console.WriteLine("Derived.Bar");
}
Base b = new Derived();
b.Foo(); // ?
b.Bar(); // ?
Derived d = new Derived();
d.Foo(); // ?
d.Bar(); // ?
Ловушка: кандидат путает override и new.
Правильный ответ:
b.Foo()→"Derived.Foo": виртуальный вызов, диспетчеризация по реальному типу.b.Bar()→"Base.Bar":newскрывает метод, но не переопределяет. Вызов черезBase-ссылку идёт вBase.Bar.d.Foo()→"Derived.Foo": то же.d.Bar()→"Derived.Bar": вызов черезDerived-ссылку — видит скрытый метод.
new на методе — это не полиморфизм. Это сокрытие имени. Использовать осторожно, почти всегда это архитектурная ошибка.
Вопрос: вызов виртуального метода в конструкторе
class Base {
public Base() {
Init(); // виртуальный вызов в конструкторе
}
public virtual void Init() => Console.WriteLine("Base.Init");
}
class Derived : Base {
private int _value = 42;
public override void Init() => Console.WriteLine($"Derived.Init: {_value}");
}
var d = new Derived();
Что выведет?
Ловушка: кандидат думает, что вызовется Base.Init — конструктор же базового класса.
Правильный ответ: "Derived.Init: 0". Виртуальный вызов всегда диспетчеризируется по реальному типу — даже из конструктора базового класса. Но _value ещё не инициализировано (инициализация полей Derived происходит после вызова базового конструктора) — получаем 0 вместо 42. Вызов виртуальных методов из конструкторов — антипаттерн, предупреждение в большинстве анализаторов кода.
Абстрактный класс vs интерфейс
Большинство кандидатов отвечают «интерфейс — контракт, абстрактный класс — частичная реализация». Это верно, но недостаточно.
Пример того, когда лучше применить абстрактный класс:
abstract class Stream {
// общее состояние для всех наследников
protected bool _disposed;
protected long _position;
// шаблонный метод — фиксирует алгоритм, детали в наследниках
public int Read(byte[] buffer, int offset, int count) {
if (_disposed) throw new ObjectDisposedException(nameof(Stream));
return ReadCore(buffer, offset, count); // abstract
}
protected abstract int ReadCore(byte[] buffer, int offset, int count);
// виртуальный метод с реализацией по умолчанию
public virtual void Flush() { }
}
Используйте абстрактный класс когда: есть общее состояние, есть Template Method паттерн, наследники семантически являются разновидностью базового типа (is-a), нужны protected члены, нужен конструктор с параметрами для инициализации общих полей.
Пример того, когда лучше применить интерфейс:
interface IDisposable { void Dispose(); }
interface IEnumerable<T> { IEnumerator<T> GetEnumerator(); }
// класс может реализовать оба — нет ограничения на одиночное наследование
class ManagedResource : IDisposable, IEnumerable<byte> { ... }
Используйте интерфейс когда: нужно множественное «наследование» поведения, тип из внешней библиотеки должен реализовать контракт, семантика «умеет делать X» (can-do), нет общего состояния, нужна утиная типизация.
Ключевое различие: класс может наследоваться только от одного абстрактного класса, но реализовывать любое количество интерфейсов.
Можно ли добавить метод в интерфейс не сломав реализации?
До C# 8 — нет. Добавление метода в интерфейс ломает все реализации — ошибка компиляции.
C# 8: Что такое Default Interface Methods (DIM) Default Interface Methods (DIM) — функция C# 8.0, позволяющая добавлять методы с реализацией прямо в интерфейс.
interface ILogger {
void Log(string message);
// default implementation — C# 8+
void LogWarning(string message) => Log($"[WARN] {message}");
void LogError(string message) => Log($"[ERROR] {message}");
}
class ConsoleLogger : ILogger {
public void Log(string message) => Console.WriteLine(message);
// LogWarning и LogError наследуются из интерфейса — реализация не сломана
}
**Зачем нужен:**
- Эволюция интерфейсов без поломки существующих реализаций
- Классы, реализующие `ILogger`, не обязаны переопределять `LogWarning`
- Можно переопределить при необходимости
Вопрос: DIM — где хранится реализация?
Ловушка: кандидат думает, что DIM работает как виртуальный метод базового класса.
Правильный ответ: DIM доступен только через интерфейсную ссылку, не через ссылку на класс:
ConsoleLogger logger = new ConsoleLogger();
logger.LogWarning("test"); // ошибка компиляции — ConsoleLogger не знает о LogWarning
ILogger iLogger = logger;
iLogger.LogWarning("test"); // ok — вызов через интерфейс
DIM не добавляет метод в класс. Это метод интерфейса с реализацией в vtable интерфейса. Разница принципиальная — и это частая ловушка на собесах.
Вопрос: DIM и алмаз наследования
interface IA {
void Foo() => Console.WriteLine("IA.Foo");
}
interface IB : IA {
void Foo() => Console.WriteLine("IB.Foo");
}
class C : IA, IB { }
IA a = new C();
a.Foo(); // ?
IB b = new C();
b.Foo(); // ?
Ловушка: алмаз наследования — классическая проблема C++.
Правильный ответ: a.Foo() → "IB.Foo". b.Foo() → "IB.Foo". C# выбирает наиболее специфичную реализацию — IB наследует от IA и переопределяет Foo, поэтому IB.Foo побеждает для обоих случаев. Если бы IA и IB были независимы (не связаны наследованием) — ошибка компиляции: неоднозначность.
Вопрос: static abstract members в интерфейсах (C# 11)
interface IAddable<T> where T : IAddable<T> {
static abstract T operator +(T left, T right);
static abstract T Zero { get; }
}
struct Vector2D : IAddable<Vector2D> {
public float X, Y;
public static Vector2D operator +(Vector2D a, Vector2D b) =>
new() { X = a.X + b.X, Y = a.Y + b.Y };
public static Vector2D Zero => new() { X = 0, Y = 0 };
}
T Sum<T>(IEnumerable<T> items) where T : IAddable<T> {
T result = T.Zero;
foreach (var item in items)
result = result + item;
return result;
}
Зачем это нужно?
Правильный ответ: до C# 11 нельзя было выразить «тип умеет складываться» через generic constraint — операторы статические, их нет в интерфейсах. static abstract решает это: generic-метод может использовать операторы и статические члены типа без boxing, без рефлексии. Это основа INumber<T> в .NET 7 — единый generic-код для всех числовых типов.
Наследование: извращённые вопросы
Вопрос: sealed и производительность
sealed class StringComparer : IComparer<string> {
public int Compare(string? x, string? y) =>
string.Compare(x, y, StringComparison.Ordinal);
}
Почему sealed важен здесь помимо архитектуры?
Правильный ответ: JIT видит sealed и знает, что у StringComparer нет наследников. Виртуальный вызов Compare через интерфейс может быть девиртуализирован и инлайнирован. В tight loop на сортировке миллиона строк разница ощутима. Это одна из причин, почему многие типы в BCL помечены sealed.
Вопрос: конструктор и наследование
class A {
public A() => Console.WriteLine("A()");
public A(int x) => Console.WriteLine($"A({x})");
}
class B : A {
public B() => Console.WriteLine("B()");
public B(int x) : base(x) => Console.WriteLine($"B({x})");
}
class C : B {
public C() => Console.WriteLine("C()");
}
new C();
Что выведет?
Правильный ответ:
A()
B()
C()
Конструкторы вызываются от базового к производному. C() неявно вызывает B(), B() неявно вызывает A(). Порядок: сначала выполняется тело A(), потом B(), потом C().
Вопрос: перегрузка vs переопределение в иерархии
class Printer {
public virtual void Print(object o) =>
Console.WriteLine($"object: {o}");
public virtual void Print(string s) =>
Console.WriteLine($"string: {s}");
}
class FancyPrinter : Printer {
public override void Print(object o) =>
Console.WriteLine($"fancy object: {o}");
}
Printer p = new FancyPrinter();
p.Print("hello"); // ?
Ловушка: кандидат думает — вызовется Print(string) из FancyPrinter.
Правильный ответ: "string: hello". Перегрузка разрешается на этапе компиляции по статическому типу ссылки — Printer. Компилятор выбирает Print(string) как наиболее специфичную перегрузку. FancyPrinter переопределяет только Print(object), поэтому Print(string) из Printer вызывается напрямую, без виртуальной диспетчеризации через FancyPrinter.
Вопрос: explicit interface implementation
interface IFoo { void Do(); }
interface IBar { void Do(); }
class MyClass : IFoo, IBar {
void IFoo.Do() => Console.WriteLine("IFoo.Do");
void IBar.Do() => Console.WriteLine("IBar.Do");
public void Do() => Console.WriteLine("MyClass.Do");
}
MyClass obj = new MyClass();
obj.Do(); // ?
((IFoo)obj).Do(); // ?
((IBar)obj).Do(); // ?
Правильный ответ:
obj.Do()→"MyClass.Do": вызывается публичный метод класса.((IFoo)obj).Do()→"IFoo.Do": explicit implementation доступна только через интерфейс.((IBar)obj).Do()→"IBar.Do".
Explicit interface implementation скрывает реализацию от публичного API класса. Используется для разрешения конфликтов имён и для сокрытия низкоуровневых контрактов (например, IEnumerable.GetEnumerator vs IEnumerable<T>.GetEnumerator).
Вопрос: explicit implementation и наследование
interface IValue {
int Get();
}
class Base : IValue {
int IValue.Get() => 1; // explicit
}
class Derived : Base {
// хочу переопределить IValue.Get
}
IValue v = new Derived();
v.Get(); // вернёт 1 или можно переопределить?
Ловушка: кандидат думает, что override решит проблему.
Правильный ответ: в Derived нельзя написать override int IValue.Get() — explicit implementation не виртуальна и не наследуется как переопределяемый метод. Чтобы переопределить поведение, нужно либо снова реализовать интерфейс явно в Derived:
class Derived : Base, IValue {
int IValue.Get() => 2; // переопределяет для Derived
}
Либо в Base сделать виртуальный protected метод и делегировать туда:
class Base : IValue {
int IValue.Get() => GetValue();
protected virtual int GetValue() => 1;
}
class Derived : Base {
protected override int GetValue() => 2;
}
Вопрос: covariance и contravariance интерфейсов
IEnumerable<string> strings = new List<string> { "a", "b" };
IEnumerable<object> objects = strings; // скомпилируется?
IComparer<object> objectComparer = Comparer<object>.Default;
IComparer<string> stringComparer = objectComparer; // скомпилируется?
Ловушка: кандидат думает — нет, нарушение типобезопасности.
Правильный ответ: оба компилируются. IEnumerable<T> объявлен как IEnumerable<out T> — ковариантный: T используется только на выходе (return), поэтому IEnumerable<string> можно присвоить IEnumerable<object>. IComparer<T> объявлен как IComparer<in T> — контравариантный: T используется только на входе (параметры), поэтому IComparer<object> можно присвоить IComparer<string>. Работает только для интерфейсов и делегатов — не для классов.
Вопрос: интерфейс как тип возврата и ковариантность возврата (C# 9)
class Animal {
public virtual Animal Create() => new Animal();
}
class Dog : Animal {
public override Dog Create() => new Dog(); // скомпилируется?
}
Ловушка: до C# 9 — нет, ковариантные возвращаемые типы не поддерживались.
Правильный ответ: в C# 9+ — да. Ковариантные возвращаемые типы: переопределённый метод может возвращать более производный тип. CLR поддерживал это всегда, C# наконец разрешил синтаксис. Полезно для Builder/Factory паттернов без лишних кастов.
Вопрос: интерфейс и object члены
interface IFoo {
bool Equals(object obj); // можно объявить?
string ToString(); // можно?
int GetHashCode(); // можно?
void ~IFoo(); // можно?
}
Правильный ответ: Equals, ToString, GetHashCode — объявить можно, но бессмысленно: любой объект уже реализует их через object. Компилятор выдаст предупреждение. Финализатор в интерфейсе — ошибка компиляции: финализаторы только в классах. Интересный edge case: если объявить new версию Equals в интерфейсе с другой сигнатурой — скроет object.Equals, что создаёт путаницу.
Вопрос: struct реализует интерфейс — generic без boxing
interface IProcessor {
void Process();
}
struct FastProcessor : IProcessor {
public void Process() => Console.WriteLine("fast");
}
// вариант A — boxing
void RunA(IProcessor p) => p.Process();
// вариант B — без boxing
void RunB<T>(T p) where T : IProcessor => p.Process();
FastProcessor fp = new FastProcessor();
RunA(fp); // boxing
RunB(fp); // нет boxing — JIT генерирует специализированный код для FastProcessor
Правильный ответ: RunA — boxing при каждом вызове (struct уезжает в кучу). RunB с generic constraint — JIT создаёт специализацию для FastProcessor, вызов прямой без boxing и без виртуальной диспетчеризации. В .NET 7+ с InlineArray и INumber<T> этот паттерн используется повсеместно в BCL для zero-overhead абстракций.
Вопрос: object header и lock
class MyClass { }
struct MyStruct { }
MyClass obj = new MyClass();
MyStruct s = new MyStruct();
lock (obj) { } // ok?
lock (s) { } // ok?
lock ("hello") { } // ok?
Ловушка: кандидат говорит «lock работает для любого объекта».
Правильный ответ: lock(obj) — ok: lock использует sync block в object header, который есть у каждого managed объекта. lock(s) — ошибка компиляции: struct — value type, lock требует reference type. lock("hello") — компилируется, но антипаттерн: строки интернируются, один и тот же литерал — один объект во всём AppDomain. Блокировка на интернированной строке может создать deadlock с чужим кодом, который использует ту же строку.
Шпаргалка
| Тема | Типичная ошибка Senior | Правда |
|---|---|---|
| Объект класса на стеке | «Невозможно» | Невозможно напрямую; struct/stackalloc/NativeMemory — альтернативы |
| Интерфейсная ссылка | «Просто указатель» | Ссылка + указатель на IDT; для struct — boxing |
| DIM доступность | «Как виртуальный метод» | Только через интерфейсную ссылку, не через ссылку на класс |
| new vs override | «Оба переопределяют» | new скрывает — не полиморфизм; вызов зависит от типа ссылки |
| Виртуальный вызов в конструкторе | «Вызовет базовый» | Диспетчеризация по реальному типу — поля наследника не инициализированы |
| sealed и производительность | «Только архитектура» | JIT девиртуализирует и инлайнит — реальный выигрыш в hot path |
| Explicit impl и override | «override решит» | Explicit не виртуальна — нужно переобъявить интерфейс в наследнике |
| lock на строке | «Работает» | Антипаттерн: интернирование → shared lock → потенциальный deadlock |
| struct через generic vs интерфейс | «Одинаково» | Generic без boxing — JIT специализация; через интерфейс — boxing каждый раз |
Следующий шаг: C# на собеседовании: память и GC — поколения, LOH, finalizer queue и почему GC.Collect() почти всегда плохая идея.