Хабрахабр

[Перевод] ref locals и ref returns в C#: подводные камни производительности

В языке C# с самого начала поддерживалась передача аргументов по значению или по ссылке. Но до версии 7 компилятор C# поддерживал только один способ возврата значения из метода (или свойства) — возврат по значению. В C# 7 ситуация изменилась с введением двух новых возможностей: ref returns и ref locals. Подробнее о них и об их производительности — под катом.

Причины

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

Чтобы продемонстрировать это, нам придется обратиться к запретному методу — воспользоваться изменяемым (mutable) типом значения:

public struct Mutable

} [Test]
public void CheckMutability()
{ var ma = new[] {new Mutable(1)}; ma[0].IncrementX(); // X has been changed! Assert.That(ma[0].X, Is.EqualTo(2)); var ml = new List<Mutable> {new Mutable(1)}; ml[0].IncrementX(); // X hasn't been changed! Assert.That(ml[0].X, Is.EqualTo(1));
}

Тестирование пройдет успешно, потому что индексатор массива значительно отличается от индексатора List.

По сути, индексатор массива возвращает элемент по ссылке. Компилятор C# дает специальную инструкцию индексатору массивов — ldelema, которая возвращает управляемую ссылку на элемент данного массива. Поэтому индексатор List возвращает элемент по значению, то есть возвращает копию данного элемента. Однако List не может вести себя таким же образом, потому что в C# было невозможно* вернуть псевдоним внутреннего состояния.

*Как мы скоро увидим, индексатор List по-прежнему не может возвращать элемент по ссылке.

IncrementX() вызывает метод, изменяющий первый элемент массива, в то время как ml[0]. Это значит, что ma[0]. IncrementX() вызывает метод, изменяющий копию элемента, не затрагивая исходный список.

Возвращаемые ссылочные значения и ссылочные локальные переменные: основы

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

Простой пример: 1.

[Test]
public void RefLocalsAndRefReturnsBasics()
{ int[] array = { 1, 2 }; // Capture an alias to the first element into a local ref int first = ref array[0]; first = 42; Assert.That(array[0], Is.EqualTo(42)); // Local function that returns the first element by ref ref int GetByRef(int[] a) => ref a[0]; // Weird syntax: the result of a function call is assignable GetByRef(array) = -1; Assert.That(array[0], Is.EqualTo(-1));
}

2. Возвращаемые ссылочные значения и модификатор readonly

2, можно возвращать псевдоним без возможности записи в соответствующий объект, используя модификатор ref readonly: Возвращаемое ссылочное значение может вернуть псевдоним поля экземпляра, а начиная с C# версии 7.

class EncapsulationWentWrong
{ private readonly Guid _guid; private int _x; public EncapsulationWentWrong(int x) => _x = x; // Return an alias to the private field. No encapsulation any more. public ref int X => ref _x; // Return a readonly alias to the private field. public ref readonly Guid Guid => ref _guid;
} [Test]
public void NoEncapsulation()
{ var instance = new EncapsulationWentWrong(42); instance.X++; Assert.That(instance.X, Is.EqualTo(43)); // Cannot assign to property 'EncapsulationWentWrong.Guid' because it is a readonly variable // instance.Guid = Guid.Empty;
}

  • Методы и свойства могут возвращать «псевдоним» внутреннего состояния. Для свойства в этом случае не должен быть определен метод задания.
  • Возврат по ссылке разрывает инкапсуляцию, так как клиент получает полный контроль над внутренним состоянием объекта.
  • Возврат с помощью ссылки только для чтения позволяет избежать излишнего копирования типов значений, при этом не разрешая клиенту изменять внутреннее состояние.
  • Ссылки только для чтения можно использовать для ссылочных типов, хотя это и не имеет особого смысла при нестандартных случаях.

3. Существующие ограничения. Возвращать псевдоним может быть опасно: использование псевдонима размещаемой в стеке переменной после завершения метода приведет к аварийному завершению приложения. Чтобы сделать эту функцию безопасной, компилятор C# применяет различные ограничения:

  • Невозможно вернуть ссылку на локальную переменную.
  • Невозможно вернуть ссылку на this в структурах.
  • Можно вернуть ссылку на переменную, размещенную в куче (например, на член класса).
  • Можно вернуть ссылку на параметры ref/out.

Для получения дополнительной информации рекомендуем ознакомиться с отличной публикацией Safe to return rules for ref returns («Безопасные правила возврата ссылочных значений»). Автор статьи, Владимир Садов, является создателем функции возвращаемых ссылочных значений для компилятора C#.

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

Использование возвращаемых ссылочных значений в индексаторах

Чтобы проверить влияние этих функций на производительность, мы создадим уникальную неизменяемую коллекцию по названием NaiveImmutableList и сравним ее с T[] и List для структур разного размера (4, 16, 32 и 48).

public class NaiveImmutableList<T>
{ private readonly int _length; private readonly T[] _data; public NaiveImmutableList(params T[] data) => (_data, _length) = (data, data.Length); public ref readonly T this[int idx] // R# 2017.3.2 is completely confused with this syntax! // => ref (idx >= _length ? ref Throw() : ref _data[idx]); { get { // Extracting 'throw' statement into a different // method helps the jitter to inline a property access. if ((uint)idx >= (uint)_length) ThrowIndexOutOfRangeException(); return ref _data[idx]; } } private static void ThrowIndexOutOfRangeException() => throw new IndexOutOfRangeException();
} struct LargeStruct_48
{ public int N { get; } private readonly long l1, l2, l3, l4, l5; public LargeStruct_48(int n) : this() => N = n;
} // Other structs like LargeStruct_16, LargeStruct_32 etc

Тест производительности выполняется для всех коллекций и складывает все значения свойств N для каждого элемента:

private const int elementsCount = 100_000;
private static LargeStruct_48[] CreateArray_48() => Enumerable.Range(1, elementsCount).Select(v => new LargeStruct_48(v)).ToArray();
private readonly LargeStruct_48[] _array48 = CreateArray_48(); [BenchmarkCategory("BigStruct_48")]
[Benchmark(Baseline = true)]
public int TestArray_48()
{ int result = 0; // Using elementsCound but not array.Length to force the bounds check // on each iteration. for (int i = 0; i < elementsCount; i++) { result = _array48[i].N; } return result;
}

Результаты таковы:

Производительность нашей коллекции NaiveImmutableList<Т> такая же, как и у List. Видимо, что-то не так! Что же произошло?

Возвращаемые ссылочные значения с модификатором readonly: как это работает

Как можно заметить, индексатор NaiveImmutableList возвращает ссылку, доступную только для чтения, с помощью модификатора ref readonly. Это полностью оправданно, так как мы хотим ограничить возможности клиентов в плане изменения основного состояния неизменяемой коллекции. Однако используемые нами в тесте производительности структуры доступны не только для чтения.

Данный тест поможет нам понять базовое поведение:

[Test]
public void CheckMutabilityForNaiveImmutableList()
{ var ml = new NaiveImmutableList<Mutable>(new Mutable(1)); ml[0].IncrementX(); // X has been changed, right? Assert.That(ml[0].X, Is.EqualTo(2));
}

Тест прошел неудачно! Но почему? Потому что структура «ссылок, доступных только для чтения» похожа на структуру модификаторов in и полей readonly в отношении структур: компилятор генерирует защитную копию каждый раз, когда используется элемент структуры. Это значит, что ml[0]. по-прежнему создает копию первого элемента, но это делает не индексатор: копия создается в точке вызова.

Компилятор C# поддерживает передачу аргументов по значению, по ссылке и по «ссылке только для чтения», используя модификатор in (подробную информацию см. Такое поведение на самом деле имеет смысл. Теперь компилятор поддерживает три разных способа возврата значения из метода: по значению, по ссылке и по ссылке только для чтения. в публикации The in-modifier and the readonly structs in C# («Модификатор in и структуры только для чтения в C#»)).

«Ссылки только для чтения» настолько похожи на обычные, что компилятор использует один и тот же InAttribute для различения их возвращаемых значений:

private int _n;
public ref readonly int ByReadonlyRef() => ref _n;

В этом случае метод ByReadonlyRef эффективно компилируется в:

[InAttribute]
[return: IsReadOnly]
public int* ByReadonlyRef()
{ return ref this._n;
}

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

public struct BigStruct
{ // Other fields public int X { get; } public int Y { get; }
} private BigStruct _bigStruct;
public ref readonly BigStruct GetBigStructByRef() => ref _bigStruct; ref readonly var bigStruct = ref GetBigStructByRef();
int result = bigStruct.X + bigStruct.Y;

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

Использование возвращаемых ссылочных значений в индексаторах. Попытка № 2

Давайте опробуем тот же набор тестов в отношении структур только для чтения разных размеров:

Время обработки по-прежнему увеличивается для больших структур, но это ожидаемо, потому что обработка более 100 тысяч структур большего размера занимает больше времени. Теперь в результатах гораздо больше смысла. Но теперь время работы для NaiveimmutableList<Т> очень близко ко времени T[] и значительно лучше, чем в случае с List.

Заключение

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

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

S. P. Методы readonly ref для доступа к элементам неизменных коллекций были представлены в следующем запросе на включение внесенных изменений в corefx repo (Implementing ItemRef API Proposal («Предложение на включение ItemRef API»)). Ссылки только для чтения появятся в BCL. Поэтому очень важно, чтобы все понимали особенности использования этих функций и то, как и когда их следует применять.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть