ref locais e retornos ref em C #: armadilhas de desempenho

Desde o início, o C # deu suporte à passagem de argumentos por valor ou por referência. Porém, antes da versão 7, o compilador C # suportava apenas uma maneira de retornar um valor de um método (ou propriedade) - retornar por valor. No C # 7, a situação mudou com a introdução de dois novos recursos: ref return e ref locals. Mais sobre eles e seu desempenho - sob o corte.



Razões


Existem muitas diferenças entre matrizes e outras coleções em termos do Common Language Runtime. Desde o início, o CLR suportou matrizes e elas podem ser consideradas como funcionalidade incorporada. O ambiente CLR e o compilador JIT podem trabalhar com matrizes e também possuem mais um recurso: o indexador da matriz retorna elementos por referência e não por valor.

Para demonstrar isso, teremos que recorrer ao método proibido - use o tipo de valor mutável:

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)); } 

Os testes serão bem-sucedidos porque o indexador de matriz é significativamente diferente do indexador de lista.

O compilador C # fornece uma instrução especial para o indexador da matriz - lelema, que retorna um link gerenciado para um elemento dessa matriz. Essencialmente, um indexador de matriz retorna um elemento por referência. No entanto, a Lista não pode se comportar da mesma maneira, porque em C # não era possível * retornar um alias de estado interno. Portanto, o indexador de lista retorna um elemento por valor, ou seja, retorna uma cópia desse elemento.

* Como veremos em breve, o indexador de lista ainda não pode retornar um elemento por referência.

Isso significa que ma [0] .IncrementX () chama o método que modifica o primeiro elemento da matriz, enquanto ml [0] .IncrementX () chama o método que modifica a cópia do elemento sem afetar a lista original.

Valores de retorno e variáveis ​​locais de referência: noções básicas


O significado dessas funções é muito simples: declarar o valor de referência retornado permite retornar o alias de uma variável existente, e a variável local de referência pode armazenar esse alias.

1. Um exemplo simples:

 [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. Valores de referência retornados e modificador readonly

O valor de referência retornado pode retornar o alias do campo da instância e, começando com o C # versão 7.2, você pode retornar o alias sem poder gravar no objeto correspondente usando o modificador 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; } 

  • Métodos e propriedades podem retornar um "alias" do estado interno. Nesse caso, o método da tarefa não deve ser definido para a propriedade
  • O retorno por referência quebra o encapsulamento, à medida que o cliente obtém controle total sobre o estado interno do objeto.
  • O retorno por meio de um link somente leitura evita a cópia desnecessária de tipos de valor, sem permitir que o cliente altere o estado interno.
  • Links somente leitura podem ser usados ​​para tipos de referência, embora isso não faça muito sentido em casos não padrão.

3. Restrições existentes. Retornar um alias pode ser perigoso: o uso de um alias para uma variável colocada na pilha após a conclusão do método irá travar o aplicativo. Para tornar essa função segura, o compilador C # aplica várias restrições:

  • Não foi possível retornar o link à variável local.
  • Não foi possível retornar uma referência a isso em estruturas.
  • Você pode retornar um link para uma variável localizada na pilha (por exemplo, para um membro da classe).
  • Você pode retornar um link para os parâmetros ref / out.

Para obter mais informações, recomendamos que você verifique a excelente publicação Safe to return rules for ref return . O autor do artigo, Vladimir Sadov, é o criador da função de referência de retorno do compilador C #.

Agora que temos uma idéia geral dos valores de referência retornados e das variáveis ​​locais referenciadas, vejamos como eles podem ser usados.

Usando valores de referência retornados em indexadores


Para testar o impacto dessas funções no desempenho, criaremos uma coleção exclusiva e imutável chamada NaiveImmutableList <T> e a compararemos com T [] e List para estruturas de tamanhos diferentes (4, 16, 32 e 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 

Um teste de desempenho é realizado para todas as coleções e adiciona todos os valores de propriedade N para cada item:

 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; } 

Os resultados são os seguintes:



Aparentemente, algo está errado! O desempenho da nossa coleção NaiveImmutableList <T> é o mesmo da lista. O que aconteceu?

Retornar valores com o modificador somente leitura: como funciona


Como você pode ver, o indexador NaiveImmutableList <T> retorna um link somente leitura usando o modificador ref readonly. Isso é totalmente justificado, pois queremos limitar a capacidade dos clientes de alterar o estado subjacente de uma coleção imutável. No entanto, as estruturas que usamos no teste de desempenho não são apenas legíveis.

Este teste nos ajudará a entender o comportamento básico:

 [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)); } 

O teste falhou! Mas porque? Como a estrutura dos “links somente leitura” é semelhante à estrutura dos campos modificadores e somente leitura em relação às estruturas: o compilador gera uma cópia protetora toda vez que um elemento da estrutura é usado. Isso significa que ml [0]. ainda cria uma cópia do primeiro elemento, mas não o indexador: a cópia é criada no ponto de chamada.

Esse comportamento realmente faz sentido. O compilador C # suporta a passagem de argumentos por valor, por referência e por "link somente leitura" usando o modificador in (para obter detalhes, consulte O modificador in e as estruturas readonly em C # ("As estruturas somente leitura no modificador e no C # ")). Agora, o compilador suporta três maneiras diferentes de retornar um valor de um método: por valor, por referência e por link somente leitura.

Links somente leitura são tão semelhantes aos links regulares que o compilador usa o mesmo InAttribute para distinguir entre seus valores de retorno:

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

Nesse caso, o método ByReadonlyRef compila eficientemente em:

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

A semelhança entre o modificador in e o link somente leitura significa que essas funções não são muito adequadas para estruturas regulares e podem causar problemas de desempenho. Considere um exemplo:

 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; 

Além da sintaxe incomum ao declarar uma variável para bigStruct, o código parece bom. O objetivo é claro: o BigStruct retorna por referência por razões de desempenho. Infelizmente, como a estrutura BigStruct é gravável, uma cópia protetora é criada cada vez que o item é acessado.

Usando valores de referência retornados em indexadores. Tentativa número 2


Vamos tentar o mesmo conjunto de testes para estruturas somente leitura de tamanhos diferentes:



Agora, os resultados fazem muito mais sentido. O tempo de processamento ainda está aumentando para estruturas grandes, mas isso é esperado, porque o processamento de mais de 100 mil estruturas maiores leva mais tempo. Mas agora o tempo de execução de NaiveimmutableList <T> está muito próximo do tempo T [] e muito melhor do que no caso de List.

Conclusão


  • Os valores de referência retornados devem ser tratados com cuidado, pois podem quebrar o encapsulamento.
  • Os valores de referência retornados com o modificador somente leitura são eficazes apenas para estruturas somente leitura. No caso de estruturas convencionais, podem ocorrer problemas de desempenho.
  • Ao trabalhar com estruturas graváveis, os valores de referência retornados com o modificador somente leitura criam uma cópia protetora toda vez que a variável é usada, o que pode causar problemas de desempenho.

Valores de referência retornados e variáveis ​​locais referenciadas são funções úteis para criadores de bibliotecas e desenvolvedores de códigos de infraestrutura. No entanto, eles são muito perigosos para usar no código da biblioteca: para usar uma coleção que efetivamente retorna itens usando um link somente leitura, cada usuário da biblioteca deve se lembrar: um link somente leitura para uma estrutura gravável cria uma cópia protetora “no ponto de chamada " Na melhor das hipóteses, isso negará um possível aumento de produtividade e, na pior das hipóteses, levará a uma grave deterioração se ao mesmo tempo um grande número de solicitações for feito para uma variável local de referência, somente leitura.

Os links somente leitura do PS aparecerão no BCL. Os métodos readonly ref para acessar itens em coleções imutáveis ​​foram apresentados na solicitação a seguir para incorporar as alterações no corefx repo ( Implementando a proposta da API ItemRef (“Proposta para incluir a API ItemRef”)). Portanto, é muito importante que todos compreendam os recursos do uso dessas funções e como e quando elas devem ser aplicadas.

Source: https://habr.com/ru/post/pt423061/


All Articles