segunda-feira, 9 de outubro de 2017

Novidades do C# 7: Ref locals and returns

por  | out 5, 201 no site iMasters

Resultado de imagem para c# 7.0


Esse texto faz parte de uma série de conteúdos sobre C# 7. Para acompanhá-la você pode seguir a tag C#7 no blog ou voltar no post que agrega a série.
Uma boa parte das funcionalidades presentes no C# 7 nasceu da necessidade de maior desempenho na linguagem. É exatamente o caso desta funcionalidade que vamos falar agora, que é a capacidade de retornar valores por referência e de poder ter referências internas entre variáveis, algo que já era possível antes no C#, mas não com código seguro, só com código unsafe.
Imagine que você quer retornar a referência para um ponto em uma matriz de inteiros. Até o C# 6 o retorno de métodos sempre foi por valor, nunca por referência (pense na referência como um ponteiro gerenciado). Mesmo no caso de um tipo de referência (uma classe) a referência é copiada. Você poderia utilizar parâmetros out, ou tuplas para informar o ponto, ou criar uma classe ou struct que representa o ponto e retornar. Todas possibilidades válidas, mas sempre com alto custo, tanto de legibilidade do código quanto de desempenho. Vamos avaliar essas opções com C# 6 e depois veremos como resolver o problema com C# 7.

Alternativas com C# 6

Se quiser ir direto para o C# 7 pode pular os próximos tópicos e ir direto até o tópico Usando ref return, mas é importante ressaltar que vamos tocar em pontos interessantes para a linguagem nos tópicos a seguir.

Criando uma classe ou struct para retornar os valores

Uma forma de retornar a informação desejada é criar uma classe ou struct, setar os campos ou propriedades e então retornar o objeto. Se usarmos uma classe estamos falando de alocação e em seguida GC, e se usarmos uma struct teremos cópia dos valores ao retornar.
Vamos ver um exemplo com a classe. Seria assim, primeiro definimos a classe que vamos retornar:
1
2
3
4
5
class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}
Em seguida fazemos a função de busca na matriz e retornamos um Point, note o return new Point:
1
2
3
4
5
6
7
8
Point Find(int[,] matrix, Func<int, bool> predicate)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
                return new Point { X = i, Y = j };
    throw new InvalidOperationException("Not found");
}
Em seguida consumimos a função:
1
2
3
4
5
var matrix = new[,] { { 1, 2, 3, 4, 5 }, { 6, 7, 8, 42, 10 } };
var point = Find(matrix, val => val == 42);
WriteLine(matrix[point.X, point.Y]);
matrix[point.X, point.Y] = 24;
WriteLine(matrix[1, 3]);
O resultado será a impressão de duas linhas, primeiro com o valor 42, depois com o valor 24. Vamos usar o mesmo retorno em todos os exemplos a seguir.
Ao final desta execução o objeto point sai de escopo e será futuramente coletado pelo GC.
Se fosse uma struct, a única diferença que eu faria, pra deixar ainda mais simples, é usar uma struct com campos no lugar de propriedades:
1
2
3
4
5
struct Point
{
    public int x;
    public int y;
}
Nesse caso, ou mesmo com propriedades, haveria cópia dos valores (os 2 inteiros) no retorno do método Find. Utilizando classe o que é retornado (também por cópia) é a referência do objeto, ou seja, é uma operação mais leve, mas que seria depois seguida de GC do objeto, impactando de outra forma o desempenho.

Usando parâmetros de saída (out)

Se usarmos parâmetros out não teremos cópia dos valores, apenas das referências dos valores passados. Ou seja, um parâmetro de saída não cria um novo local de armazenamento, apenas uma referência ao local original é passada para o método. Mas, ainda assim, estamos obtendo os valores dos índices da matriz, para guardá-los nos inteiros depois, através da sua referência.
O código da função passa a receber dois parâmetros adicionais e a retornar void, tendo os valores guardados nos parâmetros de saída m e n:
1
2
3
4
5
6
7
8
void Find(int[,] matrix, Func<int, bool> predicate, out int m, out int n)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
            { m = i; n = j; return; }
    throw new InvalidOperationException("Not found");
}
Obter os valores é simples, podemos usar out vars e já guardar os dados nelas:
1
2
3
4
5
var matrix = new[,] { { 1, 2, 3, 4, 5 }, { 6, 7, 8, 42, 10 } };
Find(matrix, val => val == 42, out var i, out var j);
WriteLine(matrix[i, j]);
matrix[i, j] = 24;
WriteLine(matrix[1, 3]);
Essa era a maneira de fazer retorno múltiplo de uma função no C# até as tuplaschegarem. E sim, o resultado estático não é muito agradável.

Usando código unsafe

Por fim, podemos usar código inseguro (unsafe). Nesse caso, obtemos um ponteiro para a posição do array, e o retornamos. Atente para o retorno da função, que é um ponteiro para um inteiro, e note ele sendo obtido a partir da referência de uma posição de um array (onde se vê a &matrix):
1
2
3
4
5
6
7
8
9
unsafe int* Find(int[,] matrix, Func<int, bool> predicate)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
                fixed (int* p = &matrix[i, j])
                    return p;
    throw new InvalidOperationException("Not found");
}
Para então depois utilizar o ponteiro, seguindo a referência e exibindo o valor (veja o primeiro WriteLine) ou para setar o valor (note o *item = 24).
1
2
3
4
5
var matrix = new[,] { { 1, 2, 3, 4, 5 }, { 6, 7, 8, 42, 10 } };
var item = Find(matrix, val => val == 42);
WriteLine(*item);
*item = 24;
WriteLine(matrix[1, 3]);
Só que existe um problema nesse código, que eu não mencionei antes de propósito. Esse código pode quebrar sua aplicação. Você pegou o problema? Ele compila perfeitamente, mas tem um bug grave. O interessante é que o compilador não vai reclamar, foi você quem decidiu trabalhar com código inseguro. Ele inclusive nem deixa você compilar sua aplicação se não marcar a opção de permitir código inseguro nas opções do projeto (ou passar a opção correta pro compilador na linha de comando). Ou seja, você disse pro compilador que sabe o que está fazendo.
O problema está no fato de que estamos fixando um endereço para uma variável gerenciada quando utilizamos o fixed, obtendo seu ponteiro, e depois retornado-o. No entanto, o endereço a que o ponteiro se refere só será válido dentro do escopo do fixed. Ao retornar o ponteiro e sair do bloco fixed os dados para onde o ponteiro apontava podem ter sido movidos de lugar por alguma operação do GC. Isso pode resultar em uma falha grave e totalmente inesperada do programa. Você pode acessar memória indevida, criando, por exemplo, uma vulnerabilidade que poderia ser explorada por um invasor. Ninguém mandou usar código inseguro! 🙂
O interessante é que o código não necessariamente vai falhar. Ele pode falhar, mas nada garante que vai, ou quando. Ou seja, você pode acabar descobrindo somente depois de a aplicação quebrar ou ser invadida.
Vamos corrigir esse código. Dá pra ser inseguro com segurança!

Usando código unsafe (direito dessa vez)

A ideia passa por, em vez de retornar o ponteiro, manter o ponteiro fixado (dentro do fixed) enquanto executamos o código que precisamos. Como? Chamando uma lambda. Vamos mudar a função, que agora recebe um delegate, veja com ela fica:
Primeiro precisamos criar um PointerAction:
1
unsafe delegate void PointerAction(int* ptr);
Isso é necessário porque não podemos usar um Action<int*>.
Em seguida criamos a função utilizando-o. Note que a action é chamada dentro do escopo de fixed, ou seja, o ponteiro ainda está fixo e o GC não vai mudar ele de lugar até que o escopo de fixed termine.
1
2
3
4
5
6
7
8
unsafe void Find(int[,] matrix, Func<int, bool> predicate, PointerAction action)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
                fixed (int* p = &matrix[i, j])
                    action(p);
}
E para terminar, o restante da função deve ser chamada passando a action, com uma lambda (pra ficar menos feio).
1
2
3
4
5
6
7
var matrix = new[,] { { 1, 2, 3, 4, 5 }, { 6, 7, 8, 42, 10 } };
Find(matrix, val => val == 42, item =>
{
    WriteLine(*item);
    *item = 24;
    WriteLine(matrix[1, 3]);
});
Esse código está correto e sem vulnerabilides. Viu como dava trabalho pra ter desempenho até o C# 6?
E vale lembrar que o código também vai gerar uma coleta no GC, já que toda lambdagera uma instância de um objeto pro delegate, que vai ser coletado depois. Não sai 100% grátis. Será que sempre temos que pagar uma taxa? Não, como veremos mais pra frente.

Alternativas com C# 7

Retornando tuplas

Se você não viu tuplas ainda, já falei delas antes aqui no blog, basta acessar o conteúdo aqui.
Tuplas vão gerar cópias, da mesma forma que um retorno de um inteiro simples. Mas, pelo menos, o código fica mais bonito, então é uma alternativa de estilo e legibilidade, não de desempenho. O código ficará com o desempenho praticamente idêntico ao que vimos com o uso da struct de Point acima. É importante atentar que Tuplas tem diversas outras vantagens, estamos focando nas vantagens delas nos exemplos progressivos que estamos avaliando.
Aqui você vê o método Find utilizando tuplas no retorno:
1
2
3
4
5
6
7
8
(int i, int j) Find(int[,] matrix, Func<int, bool> predicate)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
                return (i, j);
    throw new InvalidOperationException("Not found");
}
E aqui elas sendo usadas desconstruindo o retorno:
1
2
3
4
5
var matrix = new[,] { { 1, 2, 3, 4, 5 }, { 6, 7, 8, 42, 10 } };
var (i, j) = Find(matrix, val => val == 42);
WriteLine(matrix[i, j]);
matrix[i, j] = 24;
WriteLine(matrix[1, 3]);
Bem melhor, sem dúvida a mais bonita até agora, mas ainda com os problemas de desempenho.

C#7: Usando ref return

Agora o negócio vai ficar mais legível, com uma estética bem melhor, e, lógico, com o melhor desempenho de todos.
A ideia é muito parecida com o uso do ponteiro não gerenciado. Lembre-se que acima obtivemos um ponteiro unsafe que foi fixed dessa forma:
1
&matrix[i, j]
Leia “o endereço do valor que está na posição marcada pelos valores de i e j na matriz”.
Pois bem, vamos fazer a mesma coisa, mas vamos usar uma referência gerenciada. Em vez do &, a nova construção utiliza a palavra ref que já estava na linguagem, fica assim:
1
ref matrix[i, j]
Leia “uma referência ao valor que está na posição marcada pelos valores de i e j na matriz”.
Essa referência não gera código unsafe e possui restrições que falaremos mais tarde, que na verdade existem por motivos importante. O código final será seguro e rápido.
O método Find, alterado, fica assim:
1
2
3
4
5
6
7
8
ref int Find(int[,] matrix, Func<int, bool> predicate)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
                return ref matrix[i, j];
    throw new InvalidOperationException("Not found");
}
Note o uso de ref na assinatura do método, indicado que o retorno é um ref int e na obtenção à referência da matriz, que já discutimos antes.
O consumo será feito através de um outro conceito novo, o “ref local”, que é uma variável que referencia outra. Fica assim:
1
2
3
4
5
var matrix = new[,] { { 1, 2, 3, 4, 5 }, { 6, 7, 8, 42, 10 } };
ref var item = ref Find(matrix, val => val == 42);
WriteLine(item);
item = 24;
WriteLine(matrix[1, 3]);
Note o uso de ref var para declarar a variável item. A variável item é uma referência à posição da matriz. No entanto, a semântica de uso não demanda, como no caso do ponteiro gerenciado, que ela seja desreferenciada. Seu uso é bem mais simples. Você vê isso no primeiro WriteLine, quanto a referência a um inteiro é passada diretamente para um método que esperava um inteiro. Isso porque o compilador é esperto o suficiente pra perceber que o método WriteLine não espera uma referência, mas um inteiro, e ele mesmo desreferencia a variável pra você!
E note também o uso de ref ao invocar o método, ficando ref Find(...). Sem isso o retorno de Find, mesmo que seja uma referência, é imediatamente desreferenciado e armazenado diretamente na variável de destino (o que resultaria em um erro, já que ela também é uma referência). De qualquer forma, é importante você saber que pode chamar métodos que retornam referências e armazenar o resultado em um valor, não em uma referência.
Esse código é totalmente seguro, e não depende do delegate e da lambda que o código inseguro exigiam, sendo assim, não existe o GC adicional que viria do uso da alocação do objeto gerado pela lambda. Ou seja, no que toca a manipulação do item da matriz, o código tem um desempenho completamente otimizado.

Simplificando

Para um exemplo mais simples de ref local, veja o exemplo abaixo:
1
2
3
4
5
6
7
var a = 1;
ref var b = ref a;
WriteLine(a);
WriteLine(b);
b = 2;
WriteLine(a);
WriteLine(b);
Ele vai escrever:
1
1
2
2
Porque b é só uma referência para a.

Pontos de atenção: o que não pode?

Não se pode pegar um valor e colocar uma referência. Por exemplo, esse código é inválido, porque Count não retorna uma referência. E mesmo se você tentar colocar o ref antes de sequence você não consegue mais compilar:
1
ref int i = sequence.Count();
Esse código também não compila (faltou o ref antes do a na segunda linha):
1
2
var a = 1;
ref var b = a;
Não é possível retornar uma referência além do seu tempo de vida. Ou seja, você não pode retornar uma referência para uma variável local. Esse código é inválido:
1
2
3
var a = 1;
ref var b = ref a;
return b;
Não é possível retornar uma referência a partir de métodos assíncronos (com o modificador async). Métodos assíncronos retornam antes de a execução terminar e não haveria garantia de que o valor seria setado.

Curiosidades

Eu andei olhando a IL (Intermediate Language – resultado do trabalho do compilador do C#) gerada pelo código C# de uma ref local e percebi que, após compilada, a ref local é somente um ponteiro. No entanto, é um ponteiro que o compilador garantiu que não vai causar problemas.
Chamar, por exemplo, se item for um ponteiro:
1
Console.WriteLine(*item);
Resulta na IL:
IL_003a: ldloc.1
IL_003b: ldind.i4
IL_003c: call void [System.Console]System.Console::WriteLine(int32)
Na primeira linha ele coloca a variável 1 na stack (nada de mais). É na segunda linha que a mágica acontece, nela o Int32 é desreferenciado e colocado na stack. E na última linha WriteLine é chamado passando o valor do topo da stack, no caso, o valor do inteiro.
O código gerado, se usarmos um ref local, por exemplo:
1
Console.WriteLine(item);
É exatamente idêntico. Interessante, não?
Outro ponto que achei interessante é que o compilador soube obter o valor de um ponto no array. Descobri que arrays possuem um método não visível para o código C# chamado Address.
Então, fazer:
1
ref matrix[i, j];
Para colocar uma referência em uma variável, ou retorná-la, vai resultar na chamada do método Address. Veja a IL:
IL_001b: ldarg.0
IL_001c: ldloc.0
IL_001d: ldloc.1
IL_001e: call instance int32& int32[0...,0...]::Address(int32, int32)
Nas 3 primeiras linhas (1) a matriz é colocada na stack, (2) o argumento i é colocado na stack e (3) o argumento j é colocado na stack, então o método de instância é chamado sobre o terceiro item da stack (a matriz) passando os dois parâmetros. E notem o retorno, é uma referência a um Int32. Não é muito diferente do que fazíamos com código unsafe.

Conclusão

Imagino que vamos começar a ver um uso bastante intenso das referências no .NET Standard e implementado no .NET Core e .NET Framework. Isso vai ajudar a impulsionar o desempenho do .NET ainda mais. Imagino que veremos ganhos de desempenho interessantes nos próximos anos.
Você acha mais informações sobre ref locals e ref returns online [na seção de novidades do C# 7 site de docs da Microsoft.
E caso tenha se interessado por código unsafe, você acha mais informações na spec do C#.

Nenhum comentário:

Postar um comentário