В языке 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»)). Поэтому очень важно, чтобы все понимали особенности использования этих функций и то, как и когда их следует применять.

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


  1. kekekeks
    22.10.2018 11:09
    +1

    воспользоваться изменяемым (mutable) типом значения:

    Уважаемые представители Microsoft. Вы когда технические статьи сюда кидаете по .NET, найдите переводчика, который хоть немного знаком с платформой и не будет переводить "value type" как "тип значения". И таких "ляпов" по тексту десятки. Единственный способ понять о чём речь — перевести обратно на английский.


    Выглядит как полное пренебрежение к аудитории, которая будет это читать.


  1. Lofer
    22.10.2018 12:08

    Зачем эти навороты в зоопарке? Все возращается к тому, с чего начинали: указатели это плохо, потому что сложно контролировтаь время жизни объектов и могут быть утечки памяти, а программисту сложно будет, да и сборщик мусора простой и быстрый. Сейчас вам не надо об этом думать — .Net нет все сделает сам.
    Прошло 18 лет — и те же вопросы и механизмы из С++ тянут в .Net. Лучше бы множественное наследование вернули :)


    1. dimka11
      22.10.2018 15:30

      default interface implementation в разработке.


    1. mayorovp
      22.10.2018 15:39

      Все эти ref — это управляемые указатели, про которые сборщик мусора знает.

      Тут наоборот тенденция, появляются способы делать безопасно то, что раньше делалось только через unsafe.


    1. Exponent
      22.10.2018 18:12

      Против множественного наследования, потом проследить ничего нельзя.


    1. nsinreal
      23.10.2018 01:22
      +1

      Как мне кажется, большинство этих вещей программисту средней руки не особо пригодится. Зато эти вещи сделают код библиотек более быстрым и менее требовательным к ресурсам. Сплошная выгода.


  1. qw1
    23.10.2018 20:13

    ссылка только для чтения на структуру, доступную для записи, создает защитную копию «в точке вызова»
    Это ли не ошибка проектирования спецификации языка?

    Почему в c++ можно присвоить const-ссылку на переменную, доступную для записи или скопировать указатель на const-значение из указателя на изменяемое значение?

    Может, есть какие-то подводные камни, но я их не вижу сходу.


    1. qw1
      23.10.2018 20:17

      Хотя, возможно, под кейвордом readonly создатели C# понимали не запрет записи, а гарантию, что повторное чтение всегда даст тот же результат, тогда логично.

      Тогда, в отличие от c++, нельзя на одну структуру сделать две ссылки — одну обычную, а вторую readonly. Для второй всегда будет создана копия структуры.


      1. Lofer
        23.10.2018 21:09

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


      1. mayorovp
        24.10.2018 06:34
        +1

        Нет, копия создается в другом месте.


        ref readonly foo = ...;
        foo.Bar(); // вот тут создается копия, потому что метод Bar может изменить содержимое foo


        1. qw1
          24.10.2018 13:21

          Есть ли возможность у функции Bar указать, что она может вызываться на readonly-ссылке?


          1. AnarchyMob
            24.10.2018 17:15
            +1

            Есть ли возможность у функции Bar указать, что она может вызываться на readonly-ссылке?

            Такой возможности нет, лучше просто пометить структуру как readonly и не заморачиваться, смело передавать структуру в метод через in и возвращать в ref readonly


            Struct и readonly: как избежать падения производительности