· 25 мин 👁 1.5k Продвинутый

Собеседование C#: классы и интерфейсы — полный разбор для Senior

Память, виртуальная диспетчеризация, default interface methods, абстрактные классы vs интерфейсы, ковариантность — всё, на чём плавают senior-кандидаты.

csharpclassinterfaceпамятьвиртуальная диспетчеризациясобеседование
Содержание

Вопросы про классы и интерфейсы на 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() почти всегда плохая идея.