Написание безопасного и эффективного кода C#

C# позволяет создавать проверяемый безопасный код с более высокой производительностью. Если вы будете внимательно применять эти методы, у вас будет меньше сценариев, требующих небезопасного кода. Эти функции упрощают использование ссылок на типы значений в качестве аргументов метода и возвращаемых значений метода. При безопасном выполнении эти методики сводят к минимуму копирование типов значений. Используя типы значений, можно свести к минимуму число распределений и сборок мусора.

В большей части примера кода в этой статье демонстрируются функции, добавленные в C# 7.2. Чтобы использовать эти функции, убедитесь, что проект не настроен на использование более ранней версии. Дополнительные сведения см. в разделе Настройка языковой версии.

Преимущество использования типов значений заключается в том, что они часто позволяют избежать выделения памяти в кучах. Недостаток состоит в том, что они копируются по значению. Этот компромисс усложняет оптимизацию алгоритмов, работающих с большими объемами данных. Языковые функции, описываемые в этой статье, предоставляют механизмы, которые обеспечивают безопасный эффективный код с использованием ссылок на типы значений. При рациональном использовании этих функций можно свести к минимуму число операций выделения и копирования.

Некоторые рекомендации в этой статье относятся к методикам программирования, которые рекомендуется использовать всегда, а не только для повышения производительности. Используйте ключевое слово readonly, если оно точно отражает намерение проекта:

В статье также объясняются некоторые низкоуровневые оптимизации, которые рекомендуется использовать при запуске профилировщика и выявлении узких мест.

Эти методы поддерживают компромисс между двумя целями:

  • Сокращение количества выделений в куче.

    Переменные, являющиеся ссылочными типами, содержат ссылку на расположение в памяти и выделяются в управляемой куче. Ссылка копируется только когда ссылочный тип передается в качестве аргумента в метод или возвращается из метода. Каждый новый объект требует новое распределение и впоследствии должен быть освобожден. Сборка мусора занимает некоторое время.

  • Сокращение числа копирований значений.

    Переменные, которые являются типами значений, непосредственно содержат их значения, и значение обычно копируется при передаче в метод или возвращается из метода. Это поведение включает в себя копирование значения this при вызове членов типа значения. Операция копирования занимает некоторое время, в зависимости от размера типа.

В этой статье используется следующий пример концепции трехмерной структуры для объяснения этих рекомендаций:

public struct Point3D
{
    public double X;
    public double Y;
    public double Z;
}

Различные примеры использования других реализаций этой концепции.

Объявите неизменяемые структуры как readonly

Объявите readonly struct, чтобы указать, что тип readonly struct. Модификатор readonly сообщает компилятору, что ваша цель — создать неизменяемый тип. Компилятор указывает это решение со следующими правилами:

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

Этих двух правил достаточно, чтобы убедиться, что ни один из элементов readonly struct не изменяет состояние этой структуры. Объект struct является неизменяемым. Структура Point3D может быть определена как неизменяемая, как показано в следующем примере:

readonly public struct ReadonlyPoint3D
{
    public ReadonlyPoint3D(double x, double y, double z)
    {
        this.X = x;
        this.Y = y;
        this.Z = z;
    }

    public double X { get; }
    public double Y { get; }
    public double Z { get; }
}

Следуйте этим рекомендациям, когда планируете создать неизменяемый тип значения. Улучшения производительности являются дополнительным преимуществом. Ключевые слова readonly struct четко выражают намерение проекта.

Объявите элементы readonly для изменяемых структур.

В C# 8.0 и более поздних версиях, когда тип структуры является изменяемым, объявите элементы, которые не изменяют состояние как элементы .

Рассмотрим другое приложение, для которого требуется структура объемных точек, однако оно должно поддерживать изменяемость. Следующая версия структуры объемных точек добавляет модификатор readonly только к тем элементам, которые не изменяют структуру. Используйте этот пример, если проект должен поддерживать модификации структуры некоторыми членами, однако вам все равно нужны преимущества реализации через readonly для некоторых членов:

public struct Point3D
{
    public Point3D(double x, double y, double z)
    {
        _x = x;
        _y = y;
        _z = z;
    }

    private double _x;
    public double X
    {
        readonly get => _x;
        set => _x = value;
    }

    private double _y;
    public double Y
    {
        readonly get => _y;
        set => _y = value;
    }

    private double _z;
    public double Z
    {
        readonly get => _z;
        set => _z = value;
    }

    public readonly double Distance => Math.Sqrt(X * X + Y * Y + Z * Z);

    public readonly override string ToString() => $"{X}, {Y}, {Z}";
}

В предыдущем примере показаны многие расположения, в которых можно применить модификатор readonly: методы, свойства и методы доступа к свойствам. При использовании автоматически реализуемых свойств компилятор добавляет модификатор readonly к методу доступа get для свойств, предназначенных для чтения и записи. Компилятор добавляет модификатор readonly к автоматически реализуемым объявлениям свойств для свойств только с методом доступа get.

Добавление модификатора readonly к членам, которые не изменяют состояние, предоставляет два связанных преимущества. Во первых, компилятор применяет свое намерение. Этот член не может изменять состояние структуры. Во-вторых, компилятор не создает защитные копии параметров при доступе к члену readonly. Компилятор может безопасно выполнить эту оптимизацию, так как она гарантирует, что struct не изменяется членом readonly.

Использование инструкций ref readonly return

Используйте возвращаемое значение ref readonly, если выполняются оба следующих условия.

  • Возвращаемое значение struct больше, чем IntPtr.Size.
  • Время существования хранилища больше, чем значение, возвращаемое методом.

Вы можете возвращать значения по ссылке, когда возвращаемое значение не является локальным для возвращающего метода. Возврат по ссылке означает, что копируется только ссылка, не структура. В следующем примере свойство Origin не может использовать возврат ref, так как возвращаемое значение является локальной переменной:

public Point3D Origin => new Point3D(0,0,0);

Тем не менее, следующее определение свойства может возвращаться по ссылке, так как возвращаемое значение является статическим элементом:

public struct Point3D
{
    private static Point3D origin = new Point3D(0,0,0);

    // Dangerous! returning a mutable reference to internal storage
    public ref Point3D Origin => ref origin;

    // other members removed for space
}

Вы не хотите, чтобы вызывающие объекты изменяли источник, поэтому следует возвращать значение через ref readonly:

public struct Point3D
{
    private static Point3D origin = new Point3D(0,0,0);

    public static ref readonly Point3D Origin => ref origin;

    // other members removed for space
}

Возвращение ref readonly позволяет сохранить копирование больших структур и неизменность внутренних элементов данных.

Во время вызова вызывающие объекты выбирают использовать свойство Origin как ref readonly или как значение:

var originValue = Point3D.Origin;
ref readonly var originReference = ref Point3D.Origin;

При первом назначении в предыдущем примере кода создается и назначается копия константы Origin. При втором назначается ссылка. Обратите внимание, что модификатор readonly должен быть частью объявления переменной. Ссылку, на которую он ссылается, изменить невозможно. В противном случае возникнет ошибка времени компиляции.

В объявлении originReference требуется модификатор readonly.

Компилятор применяет правило, не позволяющее вызывающему объекту изменять ссылку. Попытки назначить значение напрямую вызывают ошибку времени компиляции. В других случаях компилятор выделяет защитную копию, если он не может безопасно использовать ссылку только для чтения. Правила статического анализа определяют, можно ли изменить структуру. Компилятор не создает защитную копию, если структура или ее член является readonly struct. Защитная копия не требуется для передачи структуры в качестве аргумента in.

Использование модификатора параметра in.

В следующих разделах объясняется, что делает модификатор in, как и когда его использовать для оптимизации производительности.

Ключевые слова out, ref и in

При передаче аргументов по ссылке можно использовать ключевое слово in как дополнение к ключевым словам ref и out. Ключевое слово in указывает, что аргумент передается по ссылке, но вызванный метод не изменяет это значение. Модификатор in может применяться к любому члену, который принимает параметры: к методам, делегатам, лямбда-выражениям, локальным функциям, индексаторам и операторам.

С добавлением ключевого слова in C# предоставляет полный словарь для выражения намерения проекта. Если в сигнатуре метода не указан ни один из следующих модификаторов, типы значений копируются при передаче в вызываемый метод. Каждый из этих модификаторов указывает, что переменная передается по ссылке, предотвращая копирование. Каждый модификатор выражает конкретное намерение.

  • out. этот метод задает значение аргумента, используемого в качестве этого параметра.
  • ref. Этот метод может изменять значение аргумента, используемого в качестве этого параметра.
  • in. этот метод не изменяет значение аргумента, используемого в качестве этого параметра.

При добавлении модификатора in для передачи аргумента по ссылке вы объявляете о своем намерении передавать аргументы по ссылке, чтобы избежать ненужных операций копирования. Вы не собираетесь изменять объект, используемый в качестве этого аргумента.

Существуют и другие способы, которыми модификатор in дополняет out и ref. Невозможно создать перегрузки метода, которые отличаются только наличием in, out или ref. Эти новые правила расширяют то же поведение, которое всегда действовало для параметров out и ref. Как и модификаторы out и ref, типы значений не упаковываются, так как применяется модификатор in. Еще одно преимущество параметров in состоит в том, что вы можете использовать литеральные значения или константы для аргумента в параметре in.

Модификатор in также можно использовать со ссылочными типами или числовыми значениями. Однако преимущества в этих случаях минимальны (если они вообще есть).

Существует несколько способов, когда компилятор гарантирует, что аргумент in доступен только для чтения. Во-первых, вызванный метод не может быть напрямую назначен параметру in. Его невозможно напрямую назначить полю параметра in, когда это значение имеет тип struct. Кроме того, параметр in невозможно передать какому-либо методу, использующему модификатор ref или out. Эти правила применяются к любому полю параметра in при условии, что данное поле имеет тип struct и параметр имеет тип struct. На самом деле эти правила применяются к нескольким уровням доступа к членам при условии, что все уровни доступа к членам являются structs. Компилятор принудительно указывает, что типы struct, передаваемые в качестве аргументов in, и их члены struct являются переменными, доступными только для чтения, когда используются в качестве аргументов для других методов.

Использование параметров in для больших структур

Модификатор in можно применить к любому параметру readonly struct, но такой подход может повысить производительность только для типов значений, которые значительно больше IntPtr.Size. Для простых типов (таких как sbyte, byte, short, ushort, int, uint, long, ulong, char, float, double, decimal, bool и enum) возможное повышение производительности минимально. Некоторые простые типы, например decimal размером 16 байт, имеют больший размер, чем 4-байтовые или 8-байтовые ссылки, но недостаточно большой для обеспечения значительной разницы в производительности в большинстве сценариев. Производительность может снизиться при использовании передачи по ссылке для типов, меньше чем IntPtr.Size.

Ниже приведен пример метода, который вычисляет расстояние между двумя точками в трехмерном пространстве.

private static double CalculateDistance(in Point3D point1, in Point3D point2)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

Аргументами являются две структуры, каждая из которых содержит три типа double. double имеет размер 8 байт, поэтому каждый аргумент равен 24 байтам. Указывая модификатор in, вы, в зависимости от архитектуры компьютера, передаете этим аргументам 4- или 8-байтовую ссылку. Разница в размере невелика, но она может вырасти, когда приложение вызывает этот метод в непрерывном цикле с помощью множества различных значений.

Однако для оценки выигрыша в производительности следует измерять влияние любых низкоуровневых оптимизаций, таких как использование модификатора in. Например, можно подумать, что использование in в параметре in может оказаться полезным. Размер типа Guid равен 16 байтам, что вдвое больше размера 8-байтовой ссылки. Но такое небольшое различие, скорее всего, не приведет к серьезному выигрышу в производительности, если оно не находится в методе, который находится на критическом пути для вашего приложения.

Необязательное использование in на сайте вызова

В отличие от параметра ref или out, не нужно применять модификатор in на сайте вызова. В следующем коде показаны два примера вызова метода CalculateDistance. В первом используются две локальные переменные, передаваемые по ссылке. Второй содержит временную переменную, созданную в рамках вызова метода.

var distance = CalculateDistance(pt1, pt2);
var fromOrigin = CalculateDistance(pt1, new Point3D());

Пропуск модификатора in в месте вызова сообщает компилятору, что он может сделать копию аргумента по следующим причинам:

  • Выполняется неявное преобразование, но не преобразование удостоверения из типа аргумента в тип параметра.
  • Аргумент является выражением, но не имеет известную переменную хранения.
  • Существует перегрузка, которая отличается наличием или отсутствием in. В этом случае перегрузка по значению подходит лучше.

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

Поскольку компилятор может создавать временную переменную для любого параметра in, вы можете также указать значения по умолчанию для любого параметра in. Следующий код указывает начало координат (точку 0,0,0) в качестве значения по умолчанию для второй точки:

private static double CalculateDistance2(in Point3D point1, in Point3D point2 = default)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

Чтобы велеть компилятору передавать аргументы, доступные только для чтения, по ссылке, укажите модификатор in для аргументов в месте вызова, как показано в следующем коде:

distance = CalculateDistance(in pt1, in pt2);
distance = CalculateDistance(in pt1, new Point3D());
distance = CalculateDistance(pt1, in Point3D.Origin);

Это упрощает постепенное внедрение параметров in в больших базах кода, где возможен выигрыш по производительности. Сначала нужно добавить модификатор in в сигнатуры методов. Затем можно добавить модификатор in в местах вызовов и создать типы readonly struct, чтобы разрешить компилятору не создавать защитные копии параметров in в дополнительных расположениях.

Избегание защитных копий

Передавайте struct в качестве аргумента для параметра in только в том случае, если он объявлен с модификатором readonly, или метод обращается к только элементам readonly этой структуры. В противном случае компилятор должен создавать защитные копии во многих ситуациях, чтобы гарантировать, что аргументы не изменяются. Рассмотрим следующий пример, который вычисляет расстояние до точки в трехмерном пространстве от начала координат:

private static double CalculateDistance(in Point3D point1, in Point3D point2)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

Структура Point3DPoint3D предоставляется только для чтения. В тексте этого метода есть шесть разных вызовов доступа к свойству. На первый взгляд может показаться, что эти доступы безопасны. В конце концов, метод доступа get не должен изменять состояние объекта. Но нет правила языка, которое это обеспечивает. Это просто общее соглашение. Любой тип может реализовывать метод доступа get, который изменил внутреннее состояние.

Без языковой гарантии компилятору необходимо создавать временную копию аргумента перед вызовом любого члена, не помеченного модификатором readonly. Временное хранилище создается на стеке, значения аргумента копируются во временном хранилище, и значение копируется в стек для каждого доступа к членам как аргумент this. Во многих случаях эти копии достаточно снижают производительность, так что передача по значению работает быстрее, чем передача по ссылке только для чтения, если тип аргумента не readonly struct и метод вызывает члены, не помеченные как readonly. Если пометить все методы, которые не изменяют состояние структуры как readonly, компилятор сможет безопасно определить, что состояние структуры не изменено и защитная копия не требуется.

Если вычисление расстояния использует неизменяемую структуру, ReadonlyPoint3D, временные объекты не требуются:

private static double CalculateDistance3(in ReadonlyPoint3D point1, in ReadonlyPoint3D point2 = default)
{
    double xDifference = point1.X - point2.X;
    double yDifference = point1.Y - point2.Y;
    double zDifference = point1.Z - point2.Z;

    return Math.Sqrt(xDifference * xDifference + yDifference * yDifference + zDifference * zDifference);
}

Компилятор создает более эффективный код, когда вызываются члены readonly struct. Ссылка this, а не копия приемника, всегда является параметром in, переданным по ссылке методу члена. Эта оптимизация позволяет избежать копирования при использовании readonly struct в качестве аргумента in.

Не передавайте тип значения, допускающий значения NULL, в качестве аргумента in. Тип Nullable<T> не объявлен как структура только для чтения. Это означает, что компилятор должен создавать защитные копии для любого аргумента типа, допускающего значение NULL и передаваемого в метод с помощью модификатора in в объявлении параметра.

Вы видите пример программы, который демонстрирует разницу в производительности с помощью BenchmarkDotNet в наших репозиториях примеров на сайте GitHub. Он сравнивает передачу изменяемых структур по значению и по ссылке с передачей неизменяемых структур по значению и по ссылке. Быстрее всего использовать неизменяемую структуру и передачу по ссылке.

Использование типов ref struct

Используйте ref struct или readonly ref struct, например Span<T> или ReadOnlySpan<T>, для работы с блоками памяти как последовательностью байтов. Объем памяти, используемой диапазоном только для чтения, будет ограничен одним кадром стека. Это ограничение позволяет компилятору кое-что оптимизировать. Главным стимулом для создания этой функции была структура Span<T> и связанные структуры. Вы получите повышение производительности благодаря этим усовершенствованиям, если будете использовать новые и обновленные интерфейсы API .NET, которые используют тип Span<T>.

Объявление структуры как readonly ref сочетает в себе преимущества и недостатки объявлений ref struct и readonly struct. Объем памяти, используемой диапазоном только для чтения, будет ограничен одним кадром стека, а объем памяти, используемой диапазоном только для чтения, невозможно изменить.

Похожие требования могут иметь место при работе с памятью, созданной с помощью stackalloc, или при использовании памяти из API взаимодействия. Для этих задач можно определить собственные типы ref struct.

Использование типов nint и nuint

Целочисленные типы собственного размера — это 32-разрядные целые числа в 32-разрядном процессе или 64-разрядные целые числа в 64-разрядном процессе. Используйте их для сценариев взаимодействия, с низкоуровневыми библиотеками и для оптимизации производительности в сценариях, где часто выполняются математические операции с целыми числами.

Выводы

Использование типов значений сводит к минимуму число операций распределения:

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

Сравните это со ссылочными типами в таких же ситуациях:

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

Чтобы свести распределения к минимуму, придется пойти на компромисс. Вы копируете больше памяти, если размер struct больше, чем размер ссылки. Ссылка обычно является 64- или 32-разрядной и зависит от ЦП целевого компьютера.

Эти компромиссы обычно имеют минимальное влияние на производительность. Однако для больших структур или больших коллекций влияние на производительность возрастает. Влияние может быть большим в плотных циклах и часто используемых путях для программ.

Эти усовершенствования языка C# предназначены для критических алгоритмов производительности, когда минимизация распределений памяти может иметь большое значение для достижения необходимой производительности. Может оказаться, что в создаваемом коде эти функции используются довольно редко. Тем не менее эти усовершенствования были реализованы в .NET. Поскольку с этими функциями работает все больше API-интерфейсов, повышение производительности приложений не останется незаметным.

См. также