C# Lowering
O processo de compilação Você sabe como é o processo que seu código em C# passa para ser executado? Muita se fala sobre o processo de compilar o código, que transforma o código C# em IL, mas existe um passo anterior que transforma C# em ... C#. Esse processo é conhecido como Lowering. Ele é responsável por um processo que vai "otimizar" o código para o compilador e vai trocar algumas facilidades da linguagem por comandos que são entendidos mais facilmente pelo compilador. Isso tem ajudado muito na adição de funcionalidades no C#, como é o caso do record ou até mesmo como agora são permitidos top-level statements. Exemplos de Lowering O record é utilizado para representar um objeto de valores imutáveis. Porém isso já poderia ser feito usando classes com parâmetros no construtor e propriedades readonly. O que o record faz é facilitar essa implementação comum para o desenvolvedor. Por exemplo, quando você escreve: public record Record(int ID); O que o compilador vai receber é: [NullableContext(1)] [Nullable(0)] public class Record : IEquatable { [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private readonly int k__BackingField; [CompilerGenerated] protected virtual Type EqualityContract { [CompilerGenerated] get { return typeof(Record); } } public int ID { [CompilerGenerated] get { return k__BackingField; } [CompilerGenerated] init { k__BackingField = value; } } public Record(int ID) { k__BackingField = ID; base..ctor(); } [CompilerGenerated] public override string ToString() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("Record"); stringBuilder.Append(" { "); if (PrintMembers(stringBuilder)) { stringBuilder.Append(' '); } stringBuilder.Append('}'); return stringBuilder.ToString(); } [CompilerGenerated] protected virtual bool PrintMembers(StringBuilder builder) { RuntimeHelpers.EnsureSufficientExecutionStack(); builder.Append("ID = "); builder.Append(ID.ToString()); return true; } [NullableContext(2)] [CompilerGenerated] public static bool operator !=(Record left, Record right) { return !(left == right); } [NullableContext(2)] [CompilerGenerated] public static bool operator ==(Record left, Record right) { return (object)left == right || ((object)left != null && left.Equals(right)); } [CompilerGenerated] public override int GetHashCode() { return EqualityComparer.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer.Default.GetHashCode(k__BackingField); } [NullableContext(2)] [CompilerGenerated] public override bool Equals(object obj) { return Equals(obj as Record); } [NullableContext(2)] [CompilerGenerated] public virtual bool Equals(Record other) { return (object)this == other || ((object)other != null && EqualityContract == other.EqualityContract && EqualityComparer.Default.Equals(k__BackingField, other.k__BackingField)); } [CompilerGenerated] public virtual Record $() { return new Record(this); } [CompilerGenerated] protected Record(Record original) { k__BackingField = original.k__BackingField; } [CompilerGenerated] public void Deconstruct(out int ID) { ID = this.ID; } } Muito mais complexo, não é? Outro exemplo, que está na linguagem tem um bom tempo é o uso de foreach: Record[] records = [new Record(1), new Record(2), new Record(3)]; foreach (var x in records) { Console.WriteLine(x.ID); } Se torna: private static void $(string[] args) { Record[] array = new Record[3]; array[0] = new Record(1); array[1] = new Record(2); array[2] = new Record(3); Record[] array2 = array; Record[] array3 = array2; int num = 0; while (num index switch { 0 => ValueA, 1 => ValueB, _ => throw new Exception("") }; } Essa implementação foi necessária por conta de um tipo de serialização que leva em consideração a ordem dos campos, e foi levemente alterada para simplificar o exemplo. Como esse código é usado por uma biblioteca de serialização o tipo do valor retornado é bem importante (mesmo que esteja sofrendo boxing pelo tipo de retorno ser object). Agora a dúvida. Caso o seguinte código seja executado, o que será exibido no console? var example = new Example { ValueA = 2.0, ValueB = 50 }; Console.WriteLine(example.GetByIndex(0).GetType().FullName); Console.WriteLine(example.GetByIndex(1).GetType().FullName); Se você espera que seja System.Doub

O processo de compilação
Você sabe como é o processo que seu código em C# passa para ser executado?
Muita se fala sobre o processo de compilar o código, que transforma o código C# em IL, mas existe um passo anterior que transforma C# em ... C#.
Esse processo é conhecido como Lowering.
Ele é responsável por um processo que vai "otimizar" o código para o compilador e vai trocar algumas facilidades da linguagem por comandos que são entendidos mais facilmente pelo compilador.
Isso tem ajudado muito na adição de funcionalidades no C#, como é o caso do record ou até mesmo como agora são permitidos top-level statements.
Exemplos de Lowering
O record é utilizado para representar um objeto de valores imutáveis.
Porém isso já poderia ser feito usando classes com parâmetros no construtor e propriedades readonly.
O que o record faz é facilitar essa implementação comum para o desenvolvedor.
Por exemplo, quando você escreve:
public record Record(int ID);
O que o compilador vai receber é:
[NullableContext(1)]
[Nullable(0)]
public class Record : IEquatable<Record>
{
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private readonly int <ID>k__BackingField;
[CompilerGenerated]
protected virtual Type EqualityContract
{
[CompilerGenerated]
get
{
return typeof(Record);
}
}
public int ID
{
[CompilerGenerated]
get
{
return <ID>k__BackingField;
}
[CompilerGenerated]
init
{
<ID>k__BackingField = value;
}
}
public Record(int ID)
{
<ID>k__BackingField = ID;
base..ctor();
}
[CompilerGenerated]
public override string ToString()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append("Record");
stringBuilder.Append(" { ");
if (PrintMembers(stringBuilder))
{
stringBuilder.Append(' ');
}
stringBuilder.Append('}');
return stringBuilder.ToString();
}
[CompilerGenerated]
protected virtual bool PrintMembers(StringBuilder builder)
{
RuntimeHelpers.EnsureSufficientExecutionStack();
builder.Append("ID = ");
builder.Append(ID.ToString());
return true;
}
[NullableContext(2)]
[CompilerGenerated]
public static bool operator !=(Record left, Record right)
{
return !(left == right);
}
[NullableContext(2)]
[CompilerGenerated]
public static bool operator ==(Record left, Record right)
{
return (object)left == right || ((object)left != null && left.Equals(right));
}
[CompilerGenerated]
public override int GetHashCode()
{
return EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(<ID>k__BackingField);
}
[NullableContext(2)]
[CompilerGenerated]
public override bool Equals(object obj)
{
return Equals(obj as Record);
}
[NullableContext(2)]
[CompilerGenerated]
public virtual bool Equals(Record other)
{
return (object)this == other || ((object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<int>.Default.Equals(<ID>k__BackingField, other.<ID>k__BackingField));
}
[CompilerGenerated]
public virtual Record <Clone>$()
{
return new Record(this);
}
[CompilerGenerated]
protected Record(Record original)
{
<ID>k__BackingField = original.<ID>k__BackingField;
}
[CompilerGenerated]
public void Deconstruct(out int ID)
{
ID = this.ID;
}
}
Muito mais complexo, não é?
Outro exemplo, que está na linguagem tem um bom tempo é o uso de foreach:
Record[] records = [new Record(1), new Record(2), new Record(3)];
foreach (var x in records)
{
Console.WriteLine(x.ID);
}
Se torna:
private static void <Main>$(string[] args)
{
Record[] array = new Record[3];
array[0] = new Record(1);
array[1] = new Record(2);
array[2] = new Record(3);
Record[] array2 = array;
Record[] array3 = array2;
int num = 0;
while (num < array3.Length)
{
Record record = array3[num];
Console.WriteLine(record.ID);
num++;
}
}
No exemplo ainda conseguimos ver como a inicialização de listas, que foi adicionada mais recentemente, funciona por trás dos panos.
Outro ponto importante é que nesse caso podemos ver a diferença de código gerado quando se usa tipos diferentes.
O mesmo código anterior, usando List ao invés de Array gera a seguinte saída:
int num = 3;
List<Record> list = new List<Record>(num);
CollectionsMarshal.SetCount(list, num);
Span<Record> span = CollectionsMarshal.AsSpan(list);
int num2 = 0;
span[num2] = new Record(1);
num2++;
span[num2] = new Record(2);
num2++;
span[num2] = new Record(3);
num2++;
List<Record> list2 = list;
List<Record>.Enumerator enumerator = list2.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
Record current = enumerator.Current;
Console.WriteLine(current.ID);
}
}
finally
{
((IDisposable)enumerator).Dispose();
}
Tudo isso permitiu que várias facilitações e funcionalidades fossem feitas na linguagem ao longo dos anos, sem alterações muito significativas no compilador. Acredito que uma das maiores foi o acréscimo de async/await na linguagem, que facilitou muito a programação assíncrona em comparação com o modelo anterior.
Além dessas facilidades de escrita e funcionalidades, também são implementadas melhorias de performance. Saber que esse processo existe, e como ver o código gerado por ele, pode facilitar o nosso entendimento de alguns comportamentos que nosso código exibe, como podemos ver a seguir.
Comportamentos inesperados
O que me motivou esse post foi encontrar recentemente um comportamento inesperado em um método extremamente simples e que foi compreendido com mais facilidade através da análise do código gerado pelo lowering.
Vamos levar a seguinte classe em consideração:
public class Example
{
public double ValueA {get;set;}
public int ValueB {get;set;}
public object GetByIndex(int index)
=> index switch
{
0 => ValueA,
1 => ValueB,
_ => throw new Exception("")
};
}
Essa implementação foi necessária por conta de um tipo de serialização que leva em consideração a ordem dos campos, e foi levemente alterada para simplificar o exemplo.
Como esse código é usado por uma biblioteca de serialização o tipo do valor retornado é bem importante (mesmo que esteja sofrendo boxing pelo tipo de retorno ser object).
Agora a dúvida.
Caso o seguinte código seja executado, o que será exibido no console?
var example = new Example
{
ValueA = 2.0,
ValueB = 50
};
Console.WriteLine(example.GetByIndex(0).GetType().FullName);
Console.WriteLine(example.GetByIndex(1).GetType().FullName);
Se você espera que seja
System.Double
System.Int32
Eu e você estamos no mesmo barco.
Porém no barco errado....
O que é exibido é:
System.Double
System.Double
Agora vamos entender porque isso acontece, olhando o código gerado após o lowering para o método GetByIndex:
[NullableContext(1)]
public object GetByIndex(int index)
{
double num;
if (index != 0)
{
if (index != 1)
{
throw new Exception("");
}
num = ValueB;
}
else
{
num = ValueA;
}
return num;
}
Olhando esse código fica claro o motivo de o valor retornado está com o tipo double, agora falta entender o porquê.
Primeiro vamos olhar outros cenários para entender quando isso acontece e quando não acontece.
Quando usamos um switch...case ao invés de um switch expression o resultado é o esperado.
Antes do lowering:
public object GetByIndex(int index)
{
switch(index)
{
case 0: return ValueA;
case 1: return ValueB;
default: throw new Exception("");
}
}
Após lowering:
[NullableContext(1)]
public object GetByIndex(int index)
{
if (index != 0)
{
if (index == 1)
{
return ValueB;
}
throw new Exception("");
}
return ValueA;
}
O problema também não acontece caso os tipos das propriedades sejam double e decimal:
public class Example
{
public double ValueA {get;set;}
public decimal ValueB {get;set;}
public object GetByIndex(int index)
=> index switch
{
0 => ValueA,
1 => ValueB,
_ => throw new Exception("")
};
}
Após o lowering:
[NullableContext(1)]
public object GetByIndex(int index)
{
if (index != 0)
{
if (index == 1)
{
return ValueB;
}
throw new Exception("");
}
return ValueA;
}
Então o que acarreta na resultado inesperado?
Se olharmos o switch expression inicial, fora do contexto de implementação do método, fica um pouco mais fácil de entender.
int index = 1;
var result = index switch
{
0 => 2.0,
1 => 1,
_ => throw new Exception("")
};
Como podemos ver, o resultado da switch expression deve ser atribuído a uma variável e para isso o compilador escolhe um tipo em comum entre os retornos de todos as opções existentes. Como qualquer valor do tipo int pode ser representado no tipo double, o valor inteiro é convertido para double.
Caso adicionemos uma nova opção 2 => "outro valor"
, então o único tipo em comum entre as três opções é object. Desse modo os três valores vão sofrer boxing, e mantêm o tipo original de alguma forma.
O que posso fazer?
Conhecer a existência do processo de lowering e como ele se encaixa como etapa do processo de compilação nos ajuda a ter uma caixa de ferramentas maior na hora de entender e solucionar problemas.
Quer ver como partes do seu código está ficando após o lowering? Pode acessar aqui e ver: https://sharplab.io/
Além disso, se quiser ver um pouco mais: