Приветствуем, Хабр.

Сегодня расскажем о нашей большой ноябрьской новинке – книге «Современный C#. Разработка настольных, облачных, мобильных и веб-приложений». Иэн Гриффитс продолжает многолетнюю работу над своим справочником по C#, и в новом издании осветил версию C#12. Вот ссылка на гитхаб-репозиторий с примерами к книге.

Более ранние издания этой книги (по версиям C#5 и C#8) уже выходили на русском языке, поэтому данное энциклопедическое пособие наверняка известно опытным .NET-разработчикам. Мы не претендуем на лавры «подхвативших упавшее знамя», но решили выпустить новую версию книги, так как она в подробностях затрагивает облачные и контейнерные аспекты работы.  Вот что появилось нового в этом издании по сравнению с «C# 8», опубликованной на русском языке в 2021 году.

  • Первичные конструкторы

  • Обработка аргументов по умолчанию для лямбда-выражений

  • Встраиваемые массивы

  • Ссылочные выражения

  • ref readonly (передача параметра по ссылке таким образом, что методу не разрешено изменять его значение)

  • Улучшенная поддержка генерации кода при опережающей компиляции (AOT)

  • Улучшенные возможности сопоставления с шаблонами

  • Асинхронное программирование

Также автор уделяет особое внимание тому, в каких случаях воспользоваться новыми возможностями языка, а в каких – придерживаться старых.

Далее предлагаем вам перевод статьи Иэна Гриффитса, написанной им в поддержку книги около года назад и посвящённой одной из вышеупомянутых фич: встраиваемым массивам.

От автора:

На протяжении большей части существования платформы .NET массивы представляли собой обособленные объекты в куче. В C# 12.0 появилась возможность создавать массивы фиксированного размера, целиком расположенные в пределах другого типа данных — подобно полям. Это полезно в некоторых сценариях, где особое внимание уделяется производительности, а также при необходимости наладить взаимодействие с некоторыми API операционной системы или библиотеками, написанными для других языков.

Классические массивы в .NET

Массивы всегда были неотъемлемой частью системы типов на платформе .NET. Первое время они были единственным видом сконструированных типов (таких, которые принимают некий другой тип в качестве параметра) — до тех пор, пока в  .NET 2.0 не были добавлены обобщённые типы (дженерики). Кроме того, массивы — это один из двух типов таких интерфейсов, применяемых в C#, в которых разные экземпляры могут отличаться по размеру (другой такой тип — string).

Именно по причине такого переменного размера под массив выделяется собственный блок в куче, где время от времени происходит сборка мусора (то же справедливо и для string). В среде выполнения .NET не предусмотрено общего механизма, который позволял бы различным экземплярам одного и того же типа занимать в памяти разное пространство. Поэтому и string, и массивы обрабатываются как особые случаи. Речь не о том, что подобное в принципе невозможно. Объекты строк и массивов просто получают в заголовке дополнительное поле, где содержится информация о длине. Несложно представить себе некий гипотетический вариант .NET, где и для других типов предусматривалась бы подобная возможность. Но на практике в .NET такого не делается.

Таким образом, хотя, мы и можем встраивать в объект другие значения, это неприменимо к массиву переменной длины. Если объявить класс Numbers, то среда выполнения .NET потребует, чтобы все экземпляры этого типа были ровно одного и того же размера. Поэтому, допустим, если в типе присутствует четыре поля, все типа int, то .NET известно, что под каждый экземпляр  Numbers требуется выделить 16 байт в памяти, чтобы разместить эти поля (по 4 байта на каждый int). Но, если я хочу, чтобы внутри Numbers находилось поле int[], то реально расположенные в массиве данные не поместятся внутри экземпляра Numbers. В противном случае размер объекта Numbers в куче зависел бы от того, сколько элементов находится в массиве. Чтобы гарантировать фиксированный размер у каждого экземпляра Numbers, записываем в каждое поле массива ссылку (8 байт в 64-битном процессе), а сами данные массива размещаем в отдельном объекте массива. Этот объект, в свою очередь, может иметь любой необходимый размер, поскольку массивы – особый случай.

Массивы фиксированного размера до C#12 и .NET 8

Но что если мне нужен такой тип данных, в котором длина массива всегда будет оставаться одинаковой? В сущности, именно таков предусмотренный в .NET тип Vector3: это просто массив из трёх элементов. Но, заглянув в его исходный код, вы увидите, что массив там не используется. На самом деле в нём объявляется три поля:

public partial struct Vector3 : IEquatable<Vector3>, IFormattable
{
    /// <summary>X-компонент вектора</summary>
    public float X;

    /// <summary>Y-компонент вектора</summary>
    public float Y;

    /// <summary>Z-компонент вектора</summary>
    public float Z;

Почему так? Ведь именно для такой работы и придуманы массивы. В некоторых учебниках по информатике словом «вектор» даже называется именно та структура данных, которую большинство программистов назвали бы «массивом».

В языке C# вплоть до версии 12.0 были доступны массивы всего одного рода — классические, такие, как описаны в предыдущем размеры. Такие массивы поддерживают работу с данными переменной длины независимо от того, требуется ли вам это. Таким образом, если бы Vector3 выглядел так:

// НЕ ТАК, как он на самом деле реализуется
public partial struct Vector3 : IEquatable<Vector3>, IFormattable
{
    /// <summary>компоненты вектора</summary>
    public float[] XYZ;

то это поле содержало бы ссылку на отдельный объект массива, находящийся в куче и подпадающий под сборку мусора.

Обратите внимание: это тип struct. Поэтому представьте себе, что изменится, если сделать так:

Vector3[] allFields = new Vector3[100, 100, 100];

Так объявляется трёхмерный прямоугольный массив, состоящий из элементов Vector3D. А вот что важно при работе с прямоугольными массивами, наполненными объектами типа struct: на целый массив мы получаем всего один объект. В этом массиве 1 000 000 элементов, но в куче ему соответствует лишь один объект. При таком подходе эффективно используется пространство — в то время, как у каждого находящегося в куче объекта предусмотрен заголовок (16 байт на объект в 64-битном процессе), среде CLR не приходится создавать отдельный заголовок для каждого значения в массиве. При работе с Vector3 в том виде, как он фактически определён (с тремя полями типа float) потребовалось бы 12 000 024 байт памяти. Размер каждого float — 4 байта, поэтому для Vector3, содержащего три таких объекта, требуется 12 байт. Таким образом, на содержание этих данных понадобится минимум 12 миллионов байт, плюс ещё немного сверх этого: у объектов-массивов, расположенных в куче, есть обычный для такого типа заголовок размером  16 байт, плюс ещё 8 байт, в которых хранится информация о длине массива. То есть, уже на 24 байта больше, чем абсолютный минимум. Но пропорционально к целому 24 байт издержек на массив в 12 000 000 байт — это довольно мало.

Теперь давайте рассмотрим, что было бы, если бы вместо трёх полей float у нас было единственное поле float[].

Теперь для каждого вектора Vector3 потребуется создать в куче (подпадающей под сборку мусора) трёхэлементный массив. На это потребуется 12 байт данных, 16-байтный заголовок объекта и ещё 8 байт информации о длине. Всего 36 байт. Кроме как в 64-битном процессе среда выполнения .NET выравнивает объекты кучи по границам в 64 бита (8 байт), что округляется до 40 байт. (В массиве на миллионы элементов, который мы только что рассмотрели, округление не применялось, так как число 12 000 024 уже кратно 8.) Дополнительно к этому массиву, самому экземпляру Vector3 потребуется ещё 8 байт (в котором будет содержаться его поле float[] XYZ, представляющее собой ссылку на объект в куче).

На этом рисунке показано, как именно отразится в памяти размещение в ней массива из элементов Vector3. Фактическая реализация показана слева. В середине показана изменённая версия, в которой используется массив float[]. Наконец, справа показаны построчные объекты массивов.

Давайте разберёмся, как отразится на потреблении памяти наш новый new Vector3[100, 100, 100], если реализовать его таким образом. На этот массив нам потребуется 800024 байта (миллион 8-байтных ссылок, плюс блок кучи и издержки на длину массива). Поскольку Vector3 — это значимый тип, весь 1 миллион экземпляров свободно разместится в массиве. Нам не требуется выделять в куче отдельный блок для каждого экземпляра Vector3. Пока тратится меньше места, поскольку одно поле массива занимает не так много места, как три поля float. Но для каждого Vector3 требуется выделить в куче собственный 3-элементный массив, на который будет указывать ссылка, расположенная в этом поле float[] XYZ. Как мы только что выяснили, для этого потребуется по 40 байт на каждый Vector3, а поскольку таких миллион — на это нам потребуется ещё 40 000 000 байт. Таким образом, добавляется ещё 48 000 024 байт.

Итак, расход памяти у нас вырос вчетверо. Кроме того, мы поручим огромный кусок дополнительной работы сборщику мусора. Ему понадобится гораздо больше работать, чтобы управиться с миллионами массивов в памяти, чем с одним массивом, с которым мы имели дело при использовании полей.

Поэтому неудивительно, что в Vector3 используются поля, а не массив.

Встраиваемые массивы в C# 12 и .NET 8.0

Объявить множество полей для некоторого объёма данных, который логически представляет собой единый список — это жизнеспособный вариант, правда, он требует кропотливой работы. Не получится написать циклы, в которых можно было бы перебирать множества полей. Чтобы  обеспечить такой перебор, можно было бы копировать поля в массив. Но, поскольку мы затевали всю эту работу только для того, чтобы не создавать в куче множества крошечных массивов, с точки зрения производительности мы получим здесь провал. Поэтому в C# 12.0 и .NET 8.0 появилась возможность определять такой тип как встраиваемый массив (inline array).

[InlineArray(3)]
public struct ThreeFloats
{
    private float element;
}

На первый взгляд это не очень похоже на массив. Но благодаря атрибуту InlineArray в среде выполнения .NET провоцируется следующее поведение (новинка в .NET 8.0): среда CLR знает, что тем самым мы требуем от неё развернуть поле float element в трёхэлементный массив.

Итак, благодаря этому новому поведению, предусмотренному в среде выполнения .NET, мы получим именно такой трёхэлементный массив, какой хотим, пусть он и выглядит странно. А язык C# 12.0 также понимает этот новый атрибут. Поэтому, объявляя переменную типа ThreeFloat, можно использовать обычный синтаксис, свойственный массивам. Например:

public struct Vector3
{
    private ThreeFloats xyz;

    public Vector3(float x, float y, float z)
    {
        xyz[0] = x;
        xyz[1] = y;
        xyz[2] = z;
    }

    public float X
    {
        get => xyz[0];
        // В принципе, изменяемые структуры использовать плохо, но реальный 
        // Vector3 является изменяемым, так что данный пример тоже из такого разряда.
        set => xyz[0] = value;
    }
}

Итак, используемый здесь синтаксис очень напоминает обычный синтаксис для работы с массивами. Но в том, что касается работы с памятью, этот код функционально полностью аналогичен оригинальной версии, в которой было три отдельных поля float. Как помните, в тех полях данные хранились непосредственно в экземпляре Vector3 (а не в отдельном объекте массива) — так же делается и здесь. Поэтому, сохранив возможность использовать это поле xyz точно как обычный массив, мы избавились от всяких издержек, возникающих при типичном использовании массива.

Синтаксис на основе атрибутов, применяемый для определения массива с фиксированным размером, немного странный. С первого взгляда понято, что по типу ThreeFloats — это массив. Достоинство этого подхода в том, что при нём не требуется прибегать ни к какому новому синтаксису, равно как вносить изменения в формат метаданных. Если программа работает в такой среде выполнения, где не поддерживается данная возможность (например, .NET 6.0 или .NET Framework) и загрузила компонент, в котором тип вроде ThreeFloats был определён только с применением метаданных (благодаря этому появляется возможность проверить содержимое компонента в любой среде выполнения, а не только в той, в которой вы сейчас работаете), то машина распознает это как структуру, к которой применён атрибут. То есть, как такую сущность, в которую можно записать информацию. Подобный смысл всегда можно было передать в .NET при помощи имеющихся механизмов метаданных, поэтому изменения потребовались только на уровне среды выполнения, но не на уровне каких-либо форматов файлов. В более старых средах выполнения вы не сможете использовать этот тип, но, как минимум, из-за его наличия не нарушится работа уже имеющегося инструментария. Аналогично, для этого не требуется добавлять в C#. какой-либо новый синтаксис. Единственное изменение заключается в том, что C# распознаёт это как ещё один тип массива и позволяет вам применять с ним синтаксис для работы с массивами — в более старых версиях языка здесь подобное было бы недопустимо.

Поскольку размер этого массива является фиксированным, для него больше не требуется собственный объект в куче. Все его значения умещаются в единственном поле внутри Vector3, точно как и при работе с любым другим значимым типом. Поэтому в итоге наш массив на миллион элементов в данном примере использует 12 000 024 байт, точно как было бы в случае с тремя отдельными полями float.

(Из этого следует, что встраиваемый массив всегда должен иметь фиксированный размер. Среда выполнения .NET до сих пор поддерживает переменную длину только для string и классических массивов).

Совместная работа

В некоторых API операционных систем используются структуры данных, включающие встраиваемые массивы фиксированного размера. Написать такой код всегда было просто в C и C++, а все широко применяемые сегодня современные операционные системы оборудованы API, предназначенными для использования из C.

Исторически для взаимодействия с API подобного типа из .NET требовалось выделять множество полей, чтобы предусмотреть место для массива фиксированного размера. Но с описанной здесь новой возможностью необходимость в этом отпала.   

В API, написанных под влиянием C, зачастую применяется такой трюк: они как бы определяют массив фиксированного размера в финальном поле некоторой структуры, но размер этого массива должен определяться динамически. Встраиваемые массивы в C# 12.0 не пытаются моделировать API такого стиля, поскольку работать с ними в C# до сих пор несколько неудобно.

Заключение

В C# 12.0 можно определять такие типы данных, которые поддерживают синтаксис для работы с массивами, но на практике работа с ними строится точно так же, как и с обычными значимыми типами. Поэтому под них не требуется специально выделять объекты в куче. Типы-массивы можно встраивать точно как локальные переменные или поля и  таким образом использовать память эффективнее, чем при работе с обычными массивами.

Комментарии (2)


  1. silent845
    07.11.2025 16:27

    Будет ли доступно электронном виде?


    1. OlegSivchenko
      07.11.2025 16:27

      Да, примерно через полгода