DEV Community

Cover image for C# Lowering
Pedro Drewanz
Pedro Drewanz

Posted on

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);
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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++;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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("")         
     };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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("");
    }
}
Enter fullscreen mode Exit fullscreen mode

Após lowering:


[NullableContext(1)]
public object GetByIndex(int index)
{
    if (index != 0)
    {
        if (index == 1)
        {
            return ValueB;
        }
        throw new Exception("");
    }
    return ValueA;
}
Enter fullscreen mode Exit fullscreen mode

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("")         
    };
}
Enter fullscreen mode Exit fullscreen mode

Após o lowering:

[NullableContext(1)]
public object GetByIndex(int index)
{
    if (index != 0)
    {
        if (index == 1)
        {
            return ValueB;
        }
        throw new Exception("");
    }
    return ValueA;
}
Enter fullscreen mode Exit fullscreen mode

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("")         
     };
Enter fullscreen mode Exit fullscreen mode

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:

Image of Timescale

PostgreSQL for Agentic AI — Build Autonomous Apps on One Stack ☝️

pgai turns PostgreSQL into an AI-native database for building RAG pipelines and intelligent agents. Run vector search, embeddings, and LLMs—all in SQL

Build Today

Top comments (0)

Playwright CLI Flags Tutorial

5 Playwright CLI Flags That Will Transform Your Testing Workflow

  • 0:56 --last-failed
  • 2:34 --only-changed
  • 4:27 --repeat-each
  • 5:15 --forbid-only
  • 5:51 --ui --headed --workers 1

Learn how these powerful command-line options can save you time, strengthen your test suite, and streamline your Playwright testing experience. Click on any timestamp above to jump directly to that section in the tutorial!

Watch Full Video 📹️

👋 Kindness is contagious

DEV is better (more customized, reading settings like dark mode etc) when you're signed in!

Okay