Привет, Хабр!
Вы, вероятно, уже сталкивались с struct в C# и задумались: а зачем нужен readonly struct, когда он действительно спасает, а когда, наоборот, начинает всё тормозить? Сегодня рассмотрим эту тему — и, если вы тимлид, принимающий архитектурные решения в команде, этот материал поможет вам выстроить API так, чтобы и разработчики были счастливы, и нагрузка на систему оставалась минимальной.
Почему вообще появились readonly struct
В .NET все структуры по дефолту — value-типы, и это означает, что при любом присвоении или передаче в метод они копируются целиком. На первый взгляд копирование трёх полей по 8 байт не кажется большой проблемой, но представьте ситуацию, когда вы в горячем цикле выполняете миллионы вызовов:
for (int i = 0; i < 10_000_000; i++) result = vector.LengthSquared();
Каждый вызов без in или без readonly struct будет клонировать 24 байта данных: сначала на уровне C# передаётся по значению, а при заходе в метод-инстанс ещё может сработать defensive copy внутри CLR (с помощью constrained. + callvirt). В сумме это не просто несколько лишних байтов на стеке — это повышенная нагрузка на процессор и кеш, больше движений памяти, хуже оптимизация JIT и потенциально доп. аллокации промежуточных кадров стека.
Именно чтобы снять эти накладные расходы, в C# 7.2 ввели readonly struct. Объявляя структуру readonly, вы гарантируете двум вещам одновременно: во-первых, ни один метод не сможет изменить её поля (компилятор заблокирует любые присвоения внутри), во-вторых, вы даёте JIT-подсистеме понять, что структура полностью immutable. Это слово убирает необходимость defensive copy при передаче по in, и вместо нескольких MOV’ов на стек и обратно вы получаете одну ссылку на оригинальные данные:
public readonly struct Vector3 { public readonly double X; public readonly double Y; public readonly double Z; public Vector3(double x, double y, double z) { X = x; Y = y; Z = z; } public Vector3 WithX(double x) => new Vector3(x, Y, Z); public double LengthSquared() => X * X + Y * Y + Z * Z; } public static double Compute(in Vector3 v) => v.LengthSquared();
В IL-дизассембле для Compute(in Vector3) увидим всего две инструкции: ldarg.0 и прямой call instance, без constrained. и без скрытого копирования 24 байт. За счёт этого hot-path убыстряется, а нагрузка на кеш L1/L2 уменьшается.
Кроме того, readonly struct улучшает читаемость API: сразу очевидно, что тип immutable, а все методы, принимающие его по in, тоже zero-copy.
IL-разбор
Разберём что генерит компилятор для двух вариантов — без readonly и с readonly.
Без readonly struct
public struct BigStruct { public long A, B, C, D; public void Foo() { /* ... */ } } public static class Program { public static void CallFoo(BigStruct s) => Use(s); public static void Use(in BigStruct s) => s.Foo(); }
IL для Use (non-readonly):
.method public hidebysig static void Use(invaluetype BigStruct& s) cil managed { // Code size 7 (0x7) .maxstack 1 IL_0000: ldarg.0 IL_0001: constrained. [YourAssembly]BigStruct IL_0007: callvirt instance void [YourAssembly]BigStruct::Foo() IL_000C: ret }
ldarg.0 загружает адрес (managed pointer) на BigStruct в стеке.
constrained. BigStruct инструктирует CLR: следующий callvirt может вызываться на value-type. При обычном in-параметре CLR делает defensive copy всех полей в временную область, чтобы гарантировать, что метод не изменит оригинал.
callvirt instance void BigStruct::Foo() виртуальный вызов на value-type заставляет CLR проверить mutability и, при необходимости, скопировать всю структуру (32 байта).
Каждый вызов Use скрытно копирует BigStruct, даже если Foo не мутирует поля.
С readonly struct
public readonly struct BigStruct { public readonly long A, B, C, D; public void Foo() { /* ... */ } } public static class Program { public static void CallFoo(BigStruct s) => Use(s); public static void Use(in BigStruct s) => s.Foo(); }
IL для Use (readonly struct):
.method public hidebysig static void Use(invaluetype BigStruct& s) cil managed { // Code size 6 (0x6) .maxstack 1 IL_0000: ldarg.0 IL_0001: call instance void [YourAssembly]BigStruct::Foo() IL_0006: ret }
ldarg.0: тоже — загружает адрес s. call instance void BigStruct::Foo() прямой вызов без constrained. и без callvirt.
Поскольку структура помечена readonly, компилятор гарантирует, что Foo не изменит this, и defensive copy не нужен.
Выводы
У не-readonly struct для in+метода всегда генерируется constrained. + callvirt -> defensive copy. У readonly struct используется простой call ->никаких копий, более компактный IL, меньше расходов на стек и загрузку. Разница в Code size: 7 байтов против 6 байтов IL, но главное — пропадает скрытое копирование 32 байт.
Когда readonly struct вообще не нужен
Маленькие структуры (< 16 байт). CPU за кэшируемый копэйт в регистры больше заплатит усложнёнными инструкциями, чем выиграет на защите от копирования.
Редкие вызовы. Если структура передается редко или единожды при старте приложения, то оптимизации компилятора на это мало влияют.
Пример: Coords { double X, Y; } — 16 байт. Оптимизации против 16 байт копирования в большинстве случаев не стоят усложнения кода.
Примеры
Геометрия и векторная математика
Часто работают с большими матрицами Matrix4x4 и векторами Vector4. Можно сделать их readonly struct и передаватьin:
public struct BigStruct { public long A, B, C, D; public void Foo() { /* ... */ } } public static void CallFoo(BigStruct s) => Use(s); public static void Use(in BigStruct s) => s.Foo();
Фин. расчеты
TradeOrder { long Id; decimal Price; decimal Quantity; } — 24 байта. Передача по in, если метод вызывается тысячами раз в секунду, выгоды ощутимы.
// CallFoo IL_0000: ldloca.s s IL_0002: initobj BigStruct IL_0008: ldloc.0 IL_0009: call void <Program>::Use IL_000E: ret // Use (non-readonly) IL_0000: ldarg.0 IL_0001: constrained. BigStruct IL_0007: callvirt instance void BigStruct::Foo IL_000C: ret
Ограничения
Только readonly-поля и get/init-свойства
Запрещено иметь изменяемые поля:
public readonly struct Bad1 { public int X; // CS0230: «field must be read-only» }Запрещено автосвойства с
set:public readonly struct Bad2 { public int Y { get; set; } // CS8983: «auto-property must be readonly» }Разрешено только так:
public readonly struct Good { public int X { get; init; } private readonly int _y; }
Никаких ref-полей и ref struct внутри
В readonly struct нельзя хранить ref-like типы (Span<T>, указатели, ref struct):
public readonly struct Bad3 { private Span<int> _span; // CS8652: «Fields of ref-like type ... not permitted» }
Если нужен Span<T> или стековая семантика, используйте readonly ref struct, а не обычный readonly struct.
Параметрless-конструктор
До C# 10: любой пользовательский конструктор без параметров в struct запрещён. В C# 10+ можно добавить public S() { ... }, но:
public readonly struct S { public int X { get; init; } public S() => X = 42; } var a = new S(); // X == 42 var b = default(S); // X == 0 — конструктор не вызовется
default(T) всегда zero-инициализация
При default(MyStruct) никакой конструктор не выполняется, все поля заполняются нулями/null. Чтобы гарантированно получить логику конструктора, используйте new MyStruct(...), а не default.
Итого: прежде чем ставить readonly struct, проверьте, что
Нет мутирующих полей/свойств.
Вы не храните
Span<T>или другие ref-like типы.Вы понимаете поведение parameterless-конструктора в вашей версии C#.
Знаете, что
default(T)обходит конструкторы.
Итог
readonly struct спасает от defensive copy и ускоряет методы с in, но не панацея. Выгодно для структур >16–24 байт в контексте частых вызовов.
Как тимлид разработки, вы понимаете, насколько важно постоянно развивать команду. OTUS предлагает курсы, которые помогут повысить квалификацию ваших специалистов в таких областях, как программирование, DevOps, аналитика и другие ключевые IT-направления. Обучение можно организовать за счет компании, а гибкие форматы и акцент на практическую часть позволяют легко интегрировать обучение в рабочий процесс. Узнайте, как улучшить навыки вашей команды с OTUS, и что мы можем предложить для вашего бизнеса.
