Как стать автором
Обновить
603.3
OTUS
Цифровые навыки от ведущих экспертов

readonly struct в C#

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров3.2K

Привет, Хабр!

Вы, вероятно, уже сталкивались с 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, проверьте, что

  1. Нет мутирующих полей/свойств.

  2. Вы не храните Span<T> или другие ref-like типы.

  3. Вы понимаете поведение parameterless-конструктора в вашей версии C#.

  4. Знаете, что default(T) обходит конструкторы.

Итог

readonly struct спасает от defensive copy и ускоряет методы с in, но не панацея. Выгодно для структур >16–24 байт в контексте частых вызовов.


Как тимлид разработки, вы понимаете, насколько важно постоянно развивать команду. OTUS предлагает курсы, которые помогут повысить квалификацию ваших специалистов в таких областях, как программирование, DevOps, аналитика и другие ключевые IT-направления. Обучение можно организовать за счет компании, а гибкие форматы и акцент на практическую часть позволяют легко интегрировать обучение в рабочий процесс. Узнайте, как улучшить навыки вашей команды с OTUS, и что мы можем предложить для вашего бизнеса.

Теги:
Хабы:
+8
Комментарии5

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS