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

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

Семантика копирования, readonly struct, ref struct, Span<T>, boxing через интерфейс, многопоточность — всё, на чём плавают senior-кандидаты.

csharpstructсобеседованиепамятьмногопоточностьref struct
Содержание

Структуры в C# — тема, где знание базы активно мешает. Чем увереннее кандидат говорит «struct в стеке, class в куче», тем быстрее интервьюер находит место, где это неверно. Разбираем от памяти до concurrency.


Память: стек, куча и где на самом деле живёт struct

Где живёт struct — полная картина

Правило «struct в стеке» — упрощение, которое ломается в нескольких сценариях.

Стек — только локальные переменные и параметры методов:

void Foo() {
    Point p = new Point(1, 2); // p — на стеке
}

Куча — во всех остальных случаях:

class Container {
    public Point Location; // Location — в куче, внутри объекта Container
}

Point[] points = new Point[100]; // массив в куче, struct-элементы — часть массива

Куча при boxing:

Point p = new Point(1, 2);
object o = p; // копия p уезжает в кучу

Куча при захвате в замыкание:

Point p = new Point(1, 2);
Action a = () => Console.WriteLine(p.X); // переменная p используется в лямбда-выражении — компилятор создаёт скрытый объект в куче, который хранит эту переменную

Куча при использовании как поле итератора или async-метода:

async Task Foo() {
    Point p = new Point(1, 2);
    await Task.Delay(100);
    Console.WriteLine(p.X); // p — в куче, в state machine объекте
}

Точная формулировка для собеса: «struct хранится там, где объявлена. Стек — лишь один из вариантов».


Управляется ли struct сборщиком мусора?

Зависит от того, где живёт:

  • Стек — не управляется GC вообще. Освобождается при выходе из метода автоматически, мгновенно.
  • Куча (поле класса, массив, boxing) — управляется GC как часть содержащего объекта. Отдельной сборки для struct не происходит — она собирается вместе с объектом-владельцем.
  • ref struct / Span — специальный случай: гарантированно только на стеке или в регистрах, GC не участвует никогда (подробнее ниже).

Вопрос: что дешевле аллоцировать — маленький class или struct?

Неправильный ответ: кандидат говорит «struct всегда дешевле, нет аллокации».

Правильный ответ: зависит от контекста. Локальная struct — да, нет аллокации. Но как только struct попадает в boxing, в поле класса, в замыкание, в async-метод или передаётся через интерфейс — аллокация происходит. При этом struct в куче может быть дороже маленького класса, если копируется часто: struct копируется целиком, class копирует только ссылку (8 байт на x64).

Benchmark-правило из Microsoft guidelines: struct оправдана, если размер ≤ 16 байт, не boxed, не передаётся часто.


Семантика копирования

Вопрос

struct Counter {
    public int Value;
    public void Increment() => Value++;
}

Counter c1 = new Counter();
Counter c2 = c1;
c2.Increment();

Console.WriteLine(c1.Value); // ?
Console.WriteLine(c2.Value); // ?

Неправильный ответ: казалось бы, тривиально.

Правильный ответ: c1.Value0, c2.Value1. Присваивание struct копирует все поля. c2 — полностью независимая копия. Но интервьюер продолжит: что если Counter — поле класса?

class Holder { public Counter C; }

var h = new Holder();
Counter local = h.C; // копия
local.Increment();

Console.WriteLine(h.C.Value); // ?

Ответ: 0. h.C возвращает копию. Изменение local не затрагивает h.C. Именно поэтому мутабельные struct — источник трудноуловимых багов.


Вопрос: копирование в foreach

struct Point {
    public int X;
    public void Move() => X++;
}

var points = new List<Point> {
    new Point { X = 1 },
    new Point { X = 2 }
};

foreach (var p in points) {
    p.Move(); // изменятся ли элементы списка?
}

Console.WriteLine(points[0].X); // ?

Неправильный ответ: кандидат думает, что p — ссылка на элемент списка.

Правильный ответ: 1. foreach по List<T> работает через enumerator, который возвращает копию struct. p — локальная копия, Move() изменяет её, список не затронут. Чтобы изменить — нужен индексный доступ:

for (int i = 0; i < points.Count; i++) {
    var p = points[i];
    p.Move();
    points[i] = p; // записываем обратно
}

Или использовать Span<Point> для прямого доступа без копий.


Вопрос: скрытое копирование при вызове метода

struct BigStruct {
    public long A, B, C, D; // 32 байта
    public void Process() { /* read-only операции */ }
}

void Handle(BigStruct s) {
    s.Process();
}

Что такое defensive copy

Когда вы передаёте struct через in и вызываете на ней метод, компилятор не уверен — изменит метод struct или нет. На всякий случай он создаёт скрытую копию и вызывает метод на ней, защищая оригинал. Эта копия и называется defensive copy.

struct Measurement {
    public double Value;
    public double Doubled() => Value * 2; // метод не помечен readonly
}

void Print(in Measurement m) {
    m.Doubled(); // компилятор создаст defensive copy — скрытую копию m
}

Пометьте метод readonly — и компилятор убедится, что он не меняет состояние, копия не нужна:

public readonly double Doubled() => Value * 2; // defensive copy устранена

Сколько копий BigStruct создаётся при вызове Handle?

Неправильный ответ: кандидат говорит «одна».

Правильный ответ: минимум одна при передаче в метод (копирование 32 байт). Если Process не readonly — компилятор создаёт ещё одну defensive copy при вызове, чтобы гарантировать, что метод не изменит оригинал (до C# 8 без readonly на методе). Итого: потенциально две копии на ровном месте. Решение: передавать через in + объявлять методы readonly.


readonly struct

Что это и зачем

readonly struct Vector3D {
    public readonly double X, Y, Z;

    public Vector3D(double x, double y, double z) =>
        (X, Y, Z) = (x, y, z);

    public double Length() => Math.Sqrt(X*X + Y*Y + Z*Z);
}

readonly struct гарантирует иммутабельность на уровне компилятора: все поля должны быть readonly, все методы автоматически считаются readonly. Следствие — компилятор устраняет defensive copies. (скрытые копии struct, которые компилятор создаёт перед вызовом метода, чтобы защитить оригинал от случайного изменения).


Вопрос: defensive copy без readonly

struct Measurement {
    public double Value;
    public double Doubled() => Value * 2; // не readonly метод
}

void Print(in Measurement m) {
    Console.WriteLine(m.Doubled()); // сколько копий?
}

Неправильный ответ: кандидат думает, in исключает копирование.

Правильный ответ: in передаёт по readonly-ссылке — копии при передаче нет. Но вызов Doubled() на in-параметре создаёт defensive copy: компилятор не знает, изменит ли Doubled состояние (метод не помечен readonly). Итого: одна скрытая копия при каждом вызове метода. Пометьте метод readonly — defensive copy исчезнет:

struct Measurement {
    public double Value;
    public readonly double Doubled() => Value * 2; // теперь без defensive copy
}

Вопрос: что не так с этим кодом?

readonly struct Temperature {
    public readonly double Celsius;
    public Temperature(double c) => Celsius = c;
    public double Fahrenheit => Celsius * 9 / 5 + 32;
}

Temperature t = new Temperature(100);
// можно ли так?
ref Temperature refT = ref t;
refT = new Temperature(200);
Console.WriteLine(t.Celsius); // ?

Правильный ответ: 200. readonly struct означает, что поля struct нельзя изменить изнутри. Но саму переменную t заменить можно — через ref присваивание. readonly на struct не делает переменную const. Тонкость, которую знают единицы.


ref struct и Span<T>

Что такое ref struct

ref struct OnlyStack {
    public int Value;
}

ref struct — struct, которая гарантированно существует только на стеке или в регистрах. Компилятор запрещает:

  • Boxing (нельзя присвоить object, dynamic, интерфейс)
  • Использование как поля в классе или обычной struct
  • Использование в async-методах
  • Использование в итераторах
  • Захват в замыкания

GC к ней не прикасается никогда.


Span<T> — почему это важно

void ProcessData(Span<byte> data) {
    for (int i = 0; i < data.Length; i++)
        data[i] ^= 0xFF;
}

// стек
Span<byte> stackBuffer = stackalloc byte[256];
ProcessData(stackBuffer); // нет аллокаций вообще

// существующий массив — Span без копирования
byte[] array = new byte[1024];
ProcessData(array.AsSpan(100, 256)); // slice без new

Span<T>ref struct. Хранит указатель и длину. Может ссылаться на стек (stackalloc), кучу (массив, строка) или unmanaged память — единый API без аллокаций.


Вопрос: почему Span нельзя в async?

async Task Process() {
    Span<byte> buffer = stackalloc byte[128];
    await DoSomething(); // ошибка компиляции
    Use(buffer);
}

Правильный ответ: async-метод компилируется в state machine — класс в куче. Все локальные переменные становятся полями этого класса. ref struct нельзя хранить как поле класса (она вышла бы за пределы стека). Компилятор запрещает это на этапе компиляции. Решение: Memory<byte> вместо Span<byte> для async-сценариев, или вынести stackalloc-логику в синхронный метод.


Вопрос: stackalloc без ref struct

void Foo() {
    Span<int> numbers = stackalloc int[10]; // ok
    int* ptr = stackalloc int[10];          // unsafe ok
    int[] arr = stackalloc int[10];         // ?
}

Неправильный ответ: кандидат думает — stackalloc всегда работает.

Правильный ответ: int[] arr = stackalloc int[10] — ошибка компиляции. stackalloc возвращает указатель (int*) или Span<T>. Присвоить в int[] (managed array) нельзя — массив всегда в куче. Это принципиальное различие между managed и unmanaged аллокацией.


Boxing через интерфейс — полный разбор

Вопрос

interface IValue {
    int Get();
    void Set(int v);
}

struct MutableValue : IValue {
    private int _v;
    public int Get() => _v;
    public void Set(int v) => _v = v;
}

IValue val = new MutableValue(); // boxing
val.Set(42);
Console.WriteLine(val.Get()); // ?

Правильный ответ: 42. Boxing создаёт объект в куче, val ссылается на него. Set изменяет этот boxed объект. Get читает из него же. Работает корректно — но есть продолжение.

MutableValue original = new MutableValue();
IValue boxed = original; // boxing — копия в куче
boxed.Set(42);
Console.WriteLine(original.Get()); // ?

Ответ: 0. boxed — независимая копия. Изменение через интерфейс не затрагивает original. Классическая ловушка мутабельных struct через интерфейс.


Вопрос: каждый boxing — новый объект

struct Point { public int X; }

Point p = new Point { X = 1 };
IComparable b1 = p; // boxing
IComparable b2 = p; // boxing

b1.Equals(b2);        // ?
object.ReferenceEquals(b1, b2); // ?

Правильный ответ: b1.Equals(b2)true (значения одинаковые). ReferenceEquals(b1, b2)false (два разных объекта в куче). Каждое присваивание в интерфейс/object создаёт новый boxed объект. Если в hot path — это реальная проблема для GC.


Многопоточность

Атомарность копирования struct

struct Point { public int X; public int Y; }

Point _shared = new Point { X = 0, Y = 0 };

// Thread 1:
_shared = new Point { X = 1, Y = 1 };

// Thread 2:
Point local = _shared;
Console.WriteLine($"{local.X}, {local.Y}");

Вопрос: какие значения может увидеть Thread 2?

Неправильный ответ: кандидат говорит «либо (0,0), либо (1,1)».

Правильный ответ: возможен torn read(0, 1) или (1, 0). Копирование struct не атомарно: CPU копирует поля последовательно. Если Thread 1 записывает новый Point в середине чтения Thread 2 — читаются частичные данные. Для структур больше 8 байт на x64 (или больше native word size) это реальная проблема.

Решение — Interlocked (не работает для struct напрямую), lock, или хранить данные в иммутабельном классе и менять ссылку через Interlocked.CompareExchange.


Вопрос: volatile и struct

struct Config { public int Timeout; public bool Enabled; }

volatile Config _config; // скомпилируется?

Неправильный ответ: кандидат думает, что volatile применим к любому полю.

Правильный ответ: нет, ошибка компиляции. volatile в C# работает только с примитивными типами (bool, int, char, etc.), reference types и указателями. Struct — не поддерживается. Для атомарного обновления struct нужен lock или паттерн с иммутабельным классом + volatile ссылкой.


Вопрос: struct в Interlocked

struct Counter { public int Value; }
Counter c = new Counter();
Interlocked.Increment(ref c.Value); // ok?

Правильный ответ: да, работает. Interlocked.Increment принимает ref int — мы передаём ссылку на конкретное поле внутри struct. Атомарность гарантируется для этого одного поля. Но если нужно атомарно обновить несколько полей — нужен lock.


Вопрос: struct в ThreadLocal и конкурентных коллекциях

struct WorkItem { public int Id; public byte[] Data; }

ConcurrentQueue<WorkItem> queue = new();
queue.Enqueue(new WorkItem { Id = 1, Data = new byte[100] });

queue.TryDequeue(out WorkItem item);

Есть ли проблема?

Неправильный ответ: кандидат говорит «нет, ConcurrentQueue thread-safe».

Правильный ответ: ConcurrentQueue thread-safe для операций enqueue/dequeue. Но WorkItem копируется при постановке и извлечении. Data — массив, reference type: копируется ссылка, не данные. Если несколько потоков получат одну и ту же WorkItem (при неправильной логике) — они разделяют один массив. Struct с reference-полями создаёт shallow copy: value-поля независимы, reference-поля — нет.


Дополнительные вопросы

Вопрос: struct наследует интерфейс — где метод?

interface IFoo { void Bar(); }

struct MyStruct : IFoo {
    public void Bar() => Console.WriteLine("struct Bar");
}

MyStruct s = new MyStruct();
s.Bar();           // вызов 1

IFoo foo = s;      // boxing
foo.Bar();         // вызов 2

Оба вызова печатают одно и то же. В чём разница в механике?

Правильный ответ: вызов 1 — прямой вызов метода на struct, без виртуальной диспетчеризации, без boxing. Компилятор знает точный тип. Вызов 2 — вызов через vtable boxed объекта, виртуальная диспетчеризация. Разница в производительности — особенно значима в tight loop.


Вопрос: GetHashCode и Equals по умолчанию

struct Point { public double X; public double Y; }

Point p1 = new Point { X = 0.0, Y = 0.0 };
Point p2 = new Point { X = double.NaN, Y = 0.0 };

Console.WriteLine(p1.GetHashCode() == p1.GetHashCode()); // ?
Console.WriteLine(p2.GetHashCode() == p2.GetHashCode()); // ?

Неправильный ответ: кандидат думает, что GetHashCode детерминирован.

Правильный ответ: p1.GetHashCode() == p1.GetHashCode() — скорее всего true, но не гарантировано. Дефолтная реализация ValueType.GetHashCode использует рефлексию и хэширует поля. В разных версиях CLR алгоритм менялся. NaN != NaN по IEEE 754 — это влияет на Equals: p2.Equals(p2) может вернуть false, что нарушает контракт Equals. Если struct используется в словарях/хэшсетах — всегда переопределяйте GetHashCode и Equals.


Вопрос: рекурсивная struct

struct Node {
    public int Value;
    public Node Next; // ?
}

Правильный ответ: ошибка компиляции. Struct не может содержать поле того же типа — это бесконечный размер. Для связного списка — только class. Но struct может содержать ссылку на себя через class-обёртку или Nullable<T> не поможет (та же проблема). Если нужно дерево/список — class.


Вопрос: sizeof и выравнивание

struct A { public byte B1; public int I; public byte B2; }
struct B { public int I; public byte B1; public byte B2; }

Console.WriteLine(Marshal.SizeOf<A>()); // ?
Console.WriteLine(Marshal.SizeOf<B>()); // ?

Неправильный ответ: кандидат считает 1+4+1 = 6 и 4+1+1 = 6.

Правильный ответ: A12 байт. B8 байт. Поля выравниваются по размеру наибольшего поля. В A: после B1 — 3 байта padding, потом I (4 байта), потом B2 (1 байт), потом 3 байта padding. В B: I (4), B1+B2 (2), 2 байта padding. Порядок полей влияет на размер struct — в performance-critical коде это важно. [StructLayout(LayoutKind.Sequential)] и [StructLayout(LayoutKind.Explicit)] дают контроль.


Вопрос: struct с финализатором

struct MyStruct {
    ~MyStruct() { } // скомпилируется?
}

Правильный ответ: нет. Финализаторы (деструкторы) — только для классов. Struct не управляется GC напрямую, нечего финализировать. Для unmanaged ресурсов в struct — реализуйте IDisposable и вызывайте Dispose явно или через using.


Вопрос: default(T) для struct

struct Config {
    public string Name;
    public int Timeout;
    public bool Enabled;
}

Config c = default;
Console.WriteLine(c.Name == null);    // ?
Console.WriteLine(c.Timeout == 0);    // ?
Console.WriteLine(c.Enabled == false); // ?

Правильный ответ: все три — true. default(struct) обнуляет все поля побитово: reference types → null, value types → 0/false. Struct в C# всегда имеет публичный конструктор по умолчанию (нельзя его скрыть). До C# 10 нельзя было объявить конструктор без параметров. С C# 10 — можно:

struct Config {
    public string Name;
    public int Timeout;
    public bool Enabled;

    public Config() {         // C# 10+
        Name = "default";
        Timeout = 30;
        Enabled = true;
    }
}

Config c1 = new Config();    // вызовет конструктор
Config c2 = default;         // всё равно обнулит поля — конструктор НЕ вызывается

default игнорирует пользовательский конструктор без параметров. Это важно знать — new Config() и default(Config) дают разные результаты.


Вопрос: struct в generic constraint

void Foo<T>(T value) where T : struct {
    T copy = value;         // копирование
    object boxed = value;   // boxing
}

void Bar<T>(T value) where T : notnull {
    object boxed = value; // boxing если T — struct?
}

Правильный ответ: в Foo — всё очевидно: копирование struct и явный boxing. В Barnotnull не ограничивает тип до reference type. Если T окажется struct — boxing произойдёт. Если хотите избежать boxing в generic — используйте where T : struct и работайте через ref или Span<T>. Для максимального контроля — inline interface dispatch через generics без boxing:

void Process<T>(T item) where T : struct, IProcessor {
    item.Process(); // прямой вызов, без boxing, без виртуальной диспетчеризации
}

Шпаргалка для Senior

ТемаТипичная ошибка SeniorПравда
Где живёт struct«В стеке»Зависит от контекста: поле класса — в куче
GC и struct«GC не трогает»Трогает, если struct в куче (поле класса, boxing, массив)
readonly struct«Просто иммутабельность»Устраняет defensive copies при in-параметрах
defensive copyНе знают о нейЛюбой не-readonly метод на in-параметре создаёт копию
Boxing через интерфейс«Без аллокации»Всегда аллокация; мутации не видны в оригинале
Torn read«Struct атомарна»Копирование struct не атомарно, возможен частичный read
volatile struct«Применимо»Ошибка компиляции — только примитивы и ref types
default vs new()«Одинаково»default игнорирует конструктор без параметров (C# 10+)
Padding и выравниваниеНе думают о порядке полейПорядок влияет на размер — оптимизируйте большие в маленькие
ref struct в async«Просто ref struct»Запрещено компилятором — state machine живёт в куче
NaN в struct Equals«Equals рефлексивен»NaN != NaN ломает контракт Equals, нужен override

Следующий шаг: C# на собеседовании: память и GC — поколения, LOH, finalizer queue и почему GC.Collect() почти всегда плохая идея.