Собеседование C#: структуры — полный разбор для Senior
Семантика копирования, readonly struct, ref struct, Span<T>, boxing через интерфейс, многопоточность — всё, на чём плавают senior-кандидаты.
Содержание
Структуры в 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.Value — 0, c2.Value — 1. Присваивание 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.
Правильный ответ: A — 12 байт. B — 8 байт. Поля выравниваются по размеру наибольшего поля. В 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. В Bar — notnull не ограничивает тип до 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() почти всегда плохая идея.