Причины
Между массивами и другими коллекциями существует множество различий с точки зрения среды 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»)). Поэтому очень важно, чтобы все понимали особенности использования этих функций и то, как и когда их следует применять.
Комментарии (12)
Lofer
22.10.2018 12:08Зачем эти навороты в зоопарке? Все возращается к тому, с чего начинали: указатели это плохо, потому что сложно контролировтаь время жизни объектов и могут быть утечки памяти, а программисту сложно будет, да и сборщик мусора простой и быстрый. Сейчас вам не надо об этом думать — .Net нет все сделает сам.
Прошло 18 лет — и те же вопросы и механизмы из С++ тянут в .Net. Лучше бы множественное наследование вернули :)mayorovp
22.10.2018 15:39Все эти ref — это управляемые указатели, про которые сборщик мусора знает.
Тут наоборот тенденция, появляются способы делать безопасно то, что раньше делалось только через unsafe.
nsinreal
23.10.2018 01:22+1Как мне кажется, большинство этих вещей программисту средней руки не особо пригодится. Зато эти вещи сделают код библиотек более быстрым и менее требовательным к ресурсам. Сплошная выгода.
qw1
23.10.2018 20:13ссылка только для чтения на структуру, доступную для записи, создает защитную копию «в точке вызова»
Это ли не ошибка проектирования спецификации языка?
Почему в c++ можно присвоить const-ссылку на переменную, доступную для записи или скопировать указатель на const-значение из указателя на изменяемое значение?
Может, есть какие-то подводные камни, но я их не вижу сходу.qw1
23.10.2018 20:17Хотя, возможно, под кейвордом
readonly
создатели C# понимали не запрет записи, а гарантию, что повторное чтение всегда даст тот же результат, тогда логично.
Тогда, в отличие от c++, нельзя на одну структуру сделать две ссылки — одну обычную, а вторуюreadonly
. Для второй всегда будет создана копия структуры.Lofer
23.10.2018 21:09Думаю просто решили не заморачиваться ни с конструктором копирования, ни конструктором перемещения, ни с умными указателями. Похоже просто переложили на компилятор работу, наболее простым способом, вместо нормального дизайна…
Вообще странно ориентация на такие нюансы производительности, для высокоуровневого языка разработки бизнес решений.
mayorovp
24.10.2018 06:34+1Нет, копия создается в другом месте.
ref readonly foo = ...; foo.Bar(); // вот тут создается копия, потому что метод Bar может изменить содержимое foo
qw1
24.10.2018 13:21Есть ли возможность у функции Bar указать, что она может вызываться на readonly-ссылке?
AnarchyMob
24.10.2018 17:15+1Есть ли возможность у функции Bar указать, что она может вызываться на readonly-ссылке?
Такой возможности нет, лучше просто пометить структуру как readonly и не заморачиваться, смело передавать структуру в метод через in и возвращать в ref readonly
kekekeks
Уважаемые представители Microsoft. Вы когда технические статьи сюда кидаете по .NET, найдите переводчика, который хоть немного знаком с платформой и не будет переводить "value type" как "тип значения". И таких "ляпов" по тексту десятки. Единственный способ понять о чём речь — перевести обратно на английский.
Выглядит как полное пренебрежение к аудитории, которая будет это читать.