A Microsoft vem se empenhando em facilitar o desenvolvimento de código com alta-performance usando C#. Uma evidência disso é a inclusão de “value semantics” (o que é comum em C++) na linguagem.
Sem querer complicar demais as coisas, todos sabemos que, em C#, valores que ficam na stack, por padrão, são “copiados” toda vez que são atribuídos a uma nova variável, retornados por uma função ou quando passados por parâmetro. Obviamente, “copiar” tem um custo para a performance que poderia (e deveria, sempre que possível) ser evitado. No passado, isso não era possível. Agora é bem fácil.
Nesse post, examinaremos alguns cenários onde podemos aplicar alguns conceitos de value semantics em C# e como estes são suportados pelo runtime.
Referenciando valores em Stack localmente (Ref Local)
Como indicado no exemplo abaixo, agora podemos referenciar valores na stack explicitamente, sem fazer cópias.
using System; class Program { static void Main(string[] args) { var x = 42; ref var y = ref x; y++; Console.WriteLine(x); } }
No exemplo, a variável X e a variável Y apontam para um mesmo valor na stack. Assim, quando o valor em Y é alterado, o valor em X, que é o mesmo, também é. O curioso, entretanto, é que o suporte para executar esse código é novidade apenas para C# e não para .NET.
.method private hidebysig static void Main ( string[] args ) cil managed { // Method begins at RVA 0x2050 // Code size 17 (0x11) .maxstack 3 .entrypoint .locals init ( [0] int32 x ) IL_0000: ldc.i4.s 42 IL_0002: stloc.0 IL_0003: ldloca.s 0 IL_0005: dup IL_0006: ldind.i4 IL_0007: ldc.i4.1 IL_0008: add IL_0009: stind.i4 IL_000a: ldloc.0 IL_000b: call void [System.Console]System.Console::WriteLine(int32) IL_0010: ret } // end of method Program::Main
As instruções ldloca.s (que recupera o endereço de memória de uma variável local armazenda na stack), ldind.i4 (que carrega um inteiro, indiretamente [por sua referência]) e stind.i4 (que armazena um inteiro, indiretamente [por sua referência]) são suportados desde a versão 1.1 do .NET.
Esta capacidade, aparentemente ingênua, é bem poderosa e permite que façamos coisas incríveis.
Passando valores por referência (Ref em parâmetros)
Quando escrevemos uma função para receber um parâmetro por referência (recurso existente há muito tempo na linguagem), as mesmas instruções que vimos acima, no ref local, são empregadas.
using System; class Program { static void Main(string[] args) { var x = 42; Console.WriteLine(x); // 42 Increment(x); Console.WriteLine(x); // 42 IncrementRef(ref x); Console.WriteLine(x); // 43 } static void Increment(int value) { value++; } static void IncrementRef(ref int value) { value++; } }
No exemplo, uma das funções está preparada para receber uma referência para um valor em stack e a outra não. Quando a função que recebe o valor por referência o modifica, altera o valor da variável no contexto onde a função é chamada.
As instruções em IL que suportam isso são, novamente, ldloca.s (que recupera o endereço de memória de uma variável local armazenda na stack), ldind.i4 (que carrega um inteiro, indiretamente [por sua referência]) e stind.i4 (que armazena um inteiro, indiretamente [por sua referência]) são suportados desde a versão 1.1 do .NET.
.method private hidebysig static void Main ( string[] args ) cil managed { // Method begins at RVA 0x2050 // Code size 35 (0x23) .maxstack 1 .entrypoint .locals init ( [0] int32 x ) IL_0000: ldc.i4.s 42 IL_0002: stloc.0 IL_0003: ldloc.0 IL_0004: call void [System.Console]System.Console::WriteLine(int32) IL_0009: ldloc.0 IL_000a: call void Program::Increment(int32) IL_000f: ldloc.0 IL_0010: call void [System.Console]System.Console::WriteLine(int32) IL_0015: ldloca.s 0 IL_0017: call void Program::IncrementRef(int32&) IL_001c: ldloc.0 IL_001d: call void [System.Console]System.Console::WriteLine(int32) IL_0022: ret } // end of method Program::Main .method private hidebysig static void Increment ( int32 'value' ) cil managed { // Method begins at RVA 0x207f // Code size 6 (0x6) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldc.i4.1 IL_0002: add IL_0003: starg.s 'value' IL_0005: ret } // end of method Program::Increment .method private hidebysig static void IncrementRef ( int32& 'value' ) cil managed { // Method begins at RVA 0x2086 // Code size 7 (0x7) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldarg.0 IL_0002: ldind.i4 IL_0003: ldc.i4.1 IL_0004: add IL_0005: stind.i4 IL_0006: ret } // end of method Program::IncrementRef
Retornando valores por referência (ref returns)
Começamos agora a tratar de cenários k de utilização de referências para valores na stack fazem a diferença.
Para entender ref return, vamos recorrer a um exemplo simples.
using System; class Program { static void Main() { var numbers = new[] {2, 8, 12, 7, 20 }; MakeItEven(numbers, IndexOfFirstOdd(numbers)); Console.WriteLine(string.Join(", ", numbers)); // 2, 8, 12, 8, 20 } public static int IndexOfFirstOdd(int[] numbers) { for (var i = 0; i < numbers.Length; i++) { if (numbers[i] % 2 != 0) { return i; } } return -1; } public static void MakeItEven(int[] numbers, int indexOfFirstOdd) { if (indexOfFirstOdd == -1) { return; } numbers[indexOfFirstOdd]++; } }
O código acima procura, em um array, por um número ímpar. Em seguida, uma função pega esse número, no array, e o torna par (incrementando em um).
C# não possuia formas de retornar uma referência para a posição do array forçando-nos a mover o “array” como parâmetro, inclusive para a função MakeItEven.
Agora, podemos escrever esse mesmo código usando apenas referências para os valores apontados pelo array.
using System; class Program { static void Main() { var numbers = new[] {2, 8, 12, 7, 20 }; MakeItEven(ref FirstOdd(numbers)); Console.WriteLine(string.Join(", ", numbers)); // 2, 8, 12, 8, 20 } public static ref int FirstOdd(int[] numbers) { for (var i = 0; i < numbers.Length; i++) { if (numbers[i] % 2 != 0) { return ref numbers[i]; } } return ref numbers[0]; } public static void MakeItEven(ref int oddNumber) { if (oddNumber % 2 != 0) { oddNumber++; } } }
A função FirstOdd agora retorna uma referência para o primeir número ímpar (não mais o índice). Repare que, como trata-se um um value type não é possível retornar null quando não encontro um valor que atenda o critério.
Observando o código em IL, vimos que o valor retornado é, efetivamente, o endereço correspondente ao valor no array (instrução ldelema)
.method public hidebysig static int32& FirstOdd ( int32[] numbers ) cil managed { // Method begins at RVA 0x208c // Code size 37 (0x25) .maxstack 2 .locals init ( [0] int32 i ) IL_0000: ldc.i4.0 IL_0001: stloc.0 IL_0002: br.s IL_0017 // loop start (head: IL_0017) IL_0004: ldarg.0 IL_0005: ldloc.0 IL_0006: ldelem.i4 IL_0007: ldc.i4.2 IL_0008: rem IL_0009: brfalse.s IL_0013 IL_000b: ldarg.0 IL_000c: ldloc.0 IL_000d: ldelema [System.Runtime]System.Int32 IL_0012: ret IL_0013: ldloc.0 IL_0014: ldc.i4.1 IL_0015: add IL_0016: stloc.0 IL_0017: ldloc.0 IL_0018: ldarg.0 IL_0019: ldlen IL_001a: conv.i4 IL_001b: blt.s IL_0004 // end loop IL_001d: ldarg.0 IL_001e: ldc.i4.0 IL_001f: ldelema [System.Runtime]System.Int32 IL_0024: ret } // end of method Program::FirstOdd
Em termos práticos, essa capacidade permite, por exemplo, a utilização de structs (que são value types) em cenários mais avançados. O código que segue autoriza o valor atributo X da struct (o que não era possível até agora). Afinal, o valor da struct não está sendo copiado ao retornar a propriedade.
using System; class Program { static void Main() { var thing = new Thing(); ref var position = ref thing.Position; position.X = 10; Console.WriteLine(thing.Position); } } public class Thing { Point3 _position = new Point3 {X = 0, Y = 0, Z = 0}; public ref Point3 Position => ref _position; } public struct Point3 { public int X, Y, Z; public override string ToString() => $"{X}, {Y}, {Z}"; }
Eu sei que structs deveriam ser imutáveis e que este talvez não seja o melhor exemplo possivel. Mas, perceba que, antes, mesmo que desejássemos, não seria possível modificar o valor da struct com a posição porque estaríamos trabalhando um uma cópia.
Concluindo … por enquanto
A possibilidade de referênciar estruturas de dados na stack, mais baratas, abre espaço para escrita de códigos consideravelmente mais performáticos sem sacrificar a expressividade.
A utilização de structs fica autorizada para cenários mais complexos e torna seu entendimento ainda mais relevante.
Cada vez é menos necessário recorrer a linguagens como C++ para obter código, ao mesmo tempo, fácil de entender e de manter.