Esse é o 18º post da série sobre C# 7, e o quarto sobre C# 7.2. Pra acompanhar a série você pode seguir a tag C#7 no blog ou voltar no post que agrega a série.

Lembrando que para utilizar as versões minor do C# (como a 7.1, ou 7.2) você precisa habilitá-la nos atributos do projeto. Veja neste post como fazê-lo e também como habilitar na solution inteira pra não ter que ficar configurando cada projeto individualmente.

Novidades do C# 7.2: semânticas de referência com tipos de valor

O C# 7.2 tem uma série de novidades que têm como principal foco a melhoria de desempenho. Não são construções que a maioria dos desenvolvedores utilizarão no dia a dia, mas que permitem que o .NET ganhe desempenho. O foco todo está em aliar programação com memória segura (memory safe) enquanto ganhamos mais efetividade na hora de passar valores, sem gerar mais garbage collection, e sem realizar cópia de valores de memória.

Passando valores entre funções

O problema é que, no C#, ao chamar um método e passar um argumento, tipos de valor são inteiramente copiados, enquanto tipos de referência têm apenas o ponteiro copiado. Assim, caso você tenha uma struct com três inteiros, ao passá-la como argumento, 12 bytes serão copiados na memória (4 para cada inteiro). Se fosse uma class, e o programa estiver rodando em x64, apenas 8 bytes seriam copiados (a referência para o objeto na heap). Quanto maior a struct, maior a cópia, algo que não acontece com classes, onde somente a referência é copiada.

Observação: Em uma arquitetura x86 a referência seria ainda menor, com somente 4 bytes.

Para resolver isso, temos no C# desde o começo a opção da palavra chave ref, que permite passar uma referência para um objeto. O problema dela é que ela permite que o método chamado altere o valor da variável de origem, algo que nem sempre é desejado.

Para ver isso acontecendo no código, veja o exemplo abaixo:

static void Main(string[] args)
{
    var aOriginal = 1;
    PorValor(aOriginal);
    WriteLine(aOriginal); // 1
    PorRef(ref aOriginal);
    WriteLine(aOriginal); // 2
}

static void PorRef(ref int a) => a = 2;
static void PorValor(int a) => a = 2;

Ao executar o código acima, o resultado será o valor 1, em seguida o valor 2. Na primeira chamada de método a variável aOriginal foi passada por valor, ou seja, foi copiada para a stack do método PorValor. Quanto este método alterou seu valor para o número 2, ele alterou somente sua cópia local. Ao retornar ao método Main, a variável aOriginal estava inalterada. Em seguida, a variável aOriginal é passada por referência ao parâmetro a do método PorRef, o que significa que as variáveis a e aOriginal referenciam o mesmo local da memória. Quando, no método PorRef alteraramos o valor de a, estamos ao mesmo tempo alterando o valor de aOriginal. Por esse motivo, o último WriteLine escreve o valor 2 no stdout.

Isso é muito legal, porque evita cópias desnecessárias, mas ao mesmo tempo, permite a alteração do valor, como visto anteriormente. Muitas vezes precisamos do desempenho que o ref nos dá, mas gostaríamos que o valor passado não fosse alterável. Não havia como fazer isso com código memory safe, até o C# 7.2.

O parâmetro in

Na versão 7.2 temos agora o parâmetro in. Que, na prática, cria um parâmetro que é passado por referência que só pode ser lido, nunca escrito. Inicialmente eles pensavem em utilizar as palavras chave readonly ref, porque é exatamente isso que in significa, mas mudaram para in porque gera um bom constraste com o modificador out.

Ficamos assim, então, no que toca estes modificadores:

  • out passa uma referência que deve ser escrita;
  • ref passa uma referência que pode ou não ser escrita;
  • in passa uma referência que não pode ser escrita;

Eles são tão parecidos que muitas das regras da linguagem que se aplicam a um se aplicam aos outros. Por exemplo, você não pode ter um overload de método que diferencia os parâmetros somente por out, ref ou in. Isso acontece porque na IL gerada eles são somente referências, a diferenciação entre os três só acontece no corpo do método gerado pelo compilador do C#, que os trata de forma diferente, além de garantir as regras da linguagem, se recusando a compilar um código que tenta alterar um parâmetro que tem o modificador in, por exemplo.

Outra diferença entre eles é que não precisamos escrever in para o argumento, somente para o parâmetro, ou seja, na chamada do método o in é opcional. Assim, os dois exemplos de chamada de método geram exatamente a mesma IL e assim vão executar de forma absolutamente idêntica:

static void M()
{
    var aOriginal = 1;
    PorRefReadonly(aOriginal);
    PorRefReadonly(in aOriginal);
}
static void PorRefReadonly(in int a) { }

A única exceção para esta regra é quando temos duas funções com a mesma assinatura, variando apenas pelo modificador in de um ou mais parâmetros. Neste caso, há duas opções: métodos virtuais e métodos não virtuais. Se o método for virtual você será obrigado a passar o in caso queira chamar o overload que o recebe, e a omiti-lo caso queira chamar o outro overload. Caso o método não seja virtual não há como chamar o método sem o modificador in, e isso é muito estranho, mas foi o que percebi nos meus estudos. O exemplo a seguir mostra um caso em que excluir ou incluir o modificador causará mudança no overload chamado:

public virtual void F(in int i) { }
public virtual void F(int i) { }
void M()
{
 int i = 1;
 F(in i); //obrigado a passar o in
}

Também é possível passar constantes ou expressões:

PorRefReadonly(1);
PorRefReadonly(ObterInteiro());

Essas opções diferem de out e ref, que exigem o modificador no local da chamada do método também e não permitem constantes ou expressões que não retornem referências (expressões que retornem referências, como métodos com ref return são permitidas – leia aqui sobre ref returns).

O modificador in super poderoso: com structs

No C#, um campo readonly não pode ser alterado. Mas quando ele é uma struct, nem mesmo os seus valores podem ser alterados. Isso já existe na linguagem desde o começo. Por exemplo, sendo Point uma struct, o código a seguir gera erros:

class Readonly
{
    readonly Point p;
    void M()
    {
        p = new Point(); // CS0191A readonly field cannot be assigned to (except in a constructor or a variable initializer)
        p.X = 1; // CS1648 Members of readonly field 'Readonly.p' cannot be modified (except in a constructor or a variable initializer)
    }
}

O primeiro erro é bastante conhecido e esperado, você não pode sobrescrever o valor de um campo readonly, e acontece para class e struct. Já o segundo erro só acontece com struct. Se Point fosse uma classe, aquele erro não existiria.

Quando você cria um parâmetro com o modificador in, as mesmas restrições são aplicadas: você não pode alterar a variável, e, caso seja uma struct, também não pode alterar seus campos e propriedades. Isso significa que o parâmetro é readonly.

E da mesma forma, não pode passar um parâmetro in por referência para outro método (válido para class e struct) ou mesmo um de seus campos (somente struct). Assim, o seguinte código é inválido:

void N(in Point p)
{
    p.Update(3); // compila, mas vai atuar sobre uma cópia de p
    P(ref p); // não compila: Error CS8329 Cannot use variable 'in Point' as a ref or out value because it is a readonly variable
    PorRef(ref p.x); // não compila: Error CS8330 Members of variable 'in Point' cannot be used as a ref or out value because it is a readonly variable
}

Observação: no exemplo acima que estou usando x minúsculo, pra deixar claro que é um campo. Se fosse uma propriedade o código seria inválido independentemente de p ser um parâmetro in ou não, porque propriedades possuem o accessor get, e não há uma forma de retornar uma propriedade por referência no momento, o que acaba sempre gerando uma cópia do valor, que não pode ser passado por referência.

Cuidado com as cópias não óbvias

Outro ponto importante é que não conseguimos controlar quando um método da própria struct altera um dado interno. Desta forma, caso um membro da classe seja chamado, o compilador do C# gera uma cópia, e a operação acontece na cópia, preservando o valor original da struct. Isso é problemático, porque essa cópia não é grátis, pagamos um preço no desempenho por causa dela. Falaremos no futuro como resolver este problema quando falarmos de readonly structs. Por enquanto, apenas lembre que no caso de acesso a membros há um custo adicional com structs padrão. Por exemplo, no caso a seguir, é feita uma cópia na chamada do método SetX, assim como na chamada da propriedade X:

public void M()
{
    var vOrigem = new Vector(1, 2, 3);
    F(vOrigem);
    WriteLine(vOrigem.X); // 1
}
void F(in Vector v)
{
    v.SetX(3); // creates copy
    WriteLine(v.X); // 1
}

Os dois WriteLine vão gerar o valor 1, já que mesmo a chamada de SetX dentro da função F operou sobre uma cópia do vetor v, e não sobre o vetor v, passado como argumento.

Mais uma vez, isso só vale para uma estrutura. Caso seja uma classe a cópia não é feita, mas também não temos imutabilidade nenhuma, uma vez que o que está sendo passado por referência é somente a referência para o objeto original (no caso, uma referência para vOrigem, que é somente outra referência para um objeto na heap). Ou seja, as alterações dos membros vão sempre alterar o objeto final.

Observando a IL gerada

Fui avaliar o que estava acontecendo na IL, e descobri que a IL gerada para um parâmetro in é muito parecida, às vezes idêntica, à IL gerada para um campo readonly, no que toca o acesso ao campo. Por exemplo, chamar um método sobre o parâmetro p no método acima geraria exatamente a mesma IL caso p fosse um campo readonly. Além disso, o parâmetro ganha o atributo System.Runtime.CompilerServices.IsReadOnlyAttribute. Fora isso, ela é igualzinha à IL gerada para ref.

O que está acontecendo é que o compilador do C# está gerando IL de acordo com sua própria semântica. A IL é bem mais flexível que o C#, e o C# usa isso a seu favor.

Você consegue ler um pouco mais sobre este assunto nos docs sobre semântica de referência com tipos de valor.

E você acha no Github a proposta da especificação inicial.

Giovanni Bassi

Arquiteto e desenvolvedor, agilista, escalador, provocador. É fundador e CSA da Lambda3. Programa porque gosta. Acredita que pessoas autogerenciadas funcionam melhor e por acreditar que heterarquia é mais eficiente que hierarquia. Foi reconhecido Microsoft MVP há mais de dez anos, dos mais de vinte que atua no mercado. Já palestrou sobre .NET, Rust, microsserviços, JavaScript, TypeScript, Ruby, Node.js, Frontend e Backend, Agile, etc, no Brasil, e no exterior. Liderou grupos de usuários em assuntos como arquitetura de software, Docker, e .NET.