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

Mar 29, 2025 - 23:17
 0
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<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: