В языке C# с самого начала поддерживалась передача аргументов по значению или по ссылке. Но до версии 7 компилятор C# поддерживал только один способ возврата значения из метода (или свойства) — возврат по значению. В C# 7 ситуация изменилась с введением двух новых возможностей: ref returns и ref locals. Подробнее о них и об их производительности — под катом.
Причины
Между массивами и другими коллекциями существует множество различий с точки зрения среды CLR. Среда CLR с самого начала поддерживала массивы, и их можно рассматривать как встроенный функционал. Среда CLR и JIT-компилятор умеют работать с массивами, а также у них есть еще одна особенность: индексатор массива возвращает элементы по ссылке, а не по значению.
Чтобы продемонстрировать это, нам придется обратиться к запретному методу — воспользоваться изменяемым (mutable) типом значения:
public struct Mutable
{
private int _x;
public Mutable(int x) => _x = x;
public int X => _x;
public void IncrementX() { _x++; }
}
[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 не может вести себя таким же образом, потому что в C# было невозможно* вернуть псевдоним внутреннего состояния. Поэтому индексатор List возвращает элемент по значению, то есть возвращает копию данного элемента.
*Как мы скоро увидим, индексатор List по-прежнему не может возвращать элемент по ссылке.
Это значит, что ma[0].IncrementX() вызывает метод, изменяющий первый элемент массива, в то время как ml[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
Возвращаемое ссылочное значение может вернуть псевдоним поля экземпляра, а начиная с C# версии 7.2, можно возвращать псевдоним без возможности записи в соответствующий объект, используя модификатор ref readonly:
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 создают защитную копию при каждом использовании переменной, что может вызвать проблемы с производительностью.
Возвращаемые ссылочные значения и ссылочные локальные переменные — полезные функции для создателей библиотек и разработчиков кода инфраструктур. Впрочем, их весьма опасно использовать в коде библиотек: чтобы использовать коллекцию, эффективно возвращающую элементы с помощью ссылки, доступной только для чтения, каждый пользователь библиотеки должен помнить: ссылка только для чтения на структуру, доступную для записи, создает защитную копию «в точке вызова». В лучшем случае это сведет на нет возможный прирост производительности, а в худшем — приведет к ее серьезному ухудшению, если одновременно осуществляется большое количество запросов к одной ссылочной локальной переменной, доступной только для чтения.
P. S. Ссылки только для чтения появятся в BCL. Методы readonly ref для доступа к элементам неизменных коллекций были представлены в следующем запросе на включение внесенных изменений в corefx repo (Implementing ItemRef API Proposal («Предложение на включение ItemRef API»)). Поэтому очень важно, чтобы все понимали особенности использования этих функций и то, как и когда их следует применять.
Автор: Александр Гуреев