Привет, Хабр!
Вы, вероятно, уже сталкивались с 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, и что мы можем предложить для вашего бизнеса.