Esse é o 20º post da série sobre C# 7, e o sexto 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: readonly struct

A essa altura você já sabe como funcionam os parâmetros in e os retornos com ref readonly e como eles funcionam melhor com struct do que com class. No entanto, conforme mencionado no post sobre os parâmetros in, sempre que um membro da struct é acessado é feita uma cópia, para evitar efeitos colaterais. E há um custo pra criar essa cópia. A forma de resolver isso é utilizar uma estrutura imutável, algo que surgiu no C# 7.2. Ao utilizar uma readonly struct não existirá copia do objeto ao acessar um de seus membros. Essa estrutura imutável se torna especialmente necessária porque o uso de variáveis readonly crescerá com o uso do in e do retorno com ref readonly.

Para criar uma estrutura imutável basta acrescentar readonly à struct, simples assim. Todos os campos de uma estrutura imutável também devem ser imutáveis, o que garante a imutabilidade da estrutura como um todo. O compilador vai garantir essa regra, e esquecer de colocar readonly em um campo, ou criar uma propriedade com set vai gerar um erro de compilação.

Abaixo você vê um exemplo de uma estrutura imutável que representa um ponto. Ele possui uma referência para o ponto da origem, em 0,0, que é retornada por referência atravéz do método Origem, e um método estático de soma, que utiza o Ponto como parâmetro in.

readonly struct Ponto
{
    public Ponto(float x, float y)
    {
        X = x;
        Y = y;
    }
    public float X { get; }
    public float Y { get; }
    private readonly static Ponto origem = new Ponto();
    public static ref readonly Ponto Origem => ref origem;
    public static Ponto Soma(in Ponto p1, in Ponto p2) =>
        new Ponto(p1.X + p2.X, p1.Y + p2.Y);
}

Sempre que os valores das propriedades X e Y forem acessados no método Soma, o acesso será direto ao objeto que foi passado como argumento, não a uma cópia, resolvendo o problema de desempenho que mencionei antes.

Observando a IL

Nesta seção vamos observar como a cópia é feita e como pode ser evitada. Se você não quer entender esse mecanismo por dentro, pode pular para o próximo ponto.

Temos abaixo uma classe que tem um Ponto, e que o retorna este Ponto como uma referência imutável no método Obter. Estamos usando o Ponto da seção anterior, uma readonly struct.

class TemUmPonto
{
    private static readonly Ponto p = new Ponto();
    public static ref readonly Ponto Obter() => ref p;
}

Abaixo vemos uma chamada deste método, e o resultado (o Ponto obtido) é colocado na variável p, e na linha seguinte buscamos o valor da propriedade X:

ref readonly var p = ref Obter();
var x = p.X;

O acesso a essa propriedade X, se a a estrutura é imutável não gera uma cópia. Veja como fica a IL:

IL_0001: call     valuetype ConsoleApp2.Ponto& modreq ([System.Runtime.InteropServices]System.Runtime.InteropServices.InAttribute) ConsoleApp2.TemUmPonto::Obter()
IL_0006: stloc.0  // p
IL_0007: ldloc.0  // p
IL_0008: call     instance float32 ConsoleApp2.Ponto::get_X()
IL_000d: stloc.1  // x

Explicando linha a linha:

  • IL_0001: é a chamada do método, que, ao retornar, coloca a referência do Ponto obtido na stack;
  • IL_0006: é o armazenamento do valor da stack (a referência para o Ponto) na variável de nome p, que está na posição 0 (por isso você vê .0 no final da instrução);
  • IL_0007: já acontece na segunda linha código C#, e já está caminhando para obter o valor da propriedade, ela coloca o valor da variável p (uma referência ao Ponto) de volta na stack;
  • IL_0008: invoca o método get_X, que é o getter da propriedade, utilizando a referência do Ponto que está na stack e coloca o valor encontrado em X na stack;
  • IL_000d: pega o valor da stack (o retorno do acesso à propriedade) e coloca na variável x (é a segunda variável local, note o .1 no final).

Como você pode notar, não há cópia em local algum, a chamada do método acontece diretamente sobre a estrutura de dados que está na variável p. Note também que a IL anotada nos ajuda a entender os nomes das variáveis.

Abaixo você vê o mesmo código sem o modificador readonly na estrutura Ponto. Essa foi a única alteração realizada. Note as diferenças:

IL_0001: call     valuetype ConsoleApp2.Ponto& modreq ([System.Runtime.InteropServices]System.Runtime.InteropServices.InAttribute) ConsoleApp2.TemUmPonto::Obter()
IL_0006: stloc.0  // p
IL_0007: ldloc.0  // p
IL_0008: ldobj    ConsoleApp2.Ponto
IL_000d: stloc.2  // V_2
IL_000e: ldloca.s V_2
IL_0010: call     instance float32 ConsoleApp2.Ponto::get_X()
IL_0015: stloc.1  // x

Explicarei agora apenas os pontos que mudaram.

Logo depois da terceira instrução temos a IL_0008 com um ldobj. Essa intrução pega a referência ao Ponto, que está no topo da stack, segue a referência e coloca o valor na stack. Note que o valor no topo da stack agora não é mais uma referência ao valor do Ponto, mas o valor do Ponto em si mesmo, os dados do ponto (os valores de X e Y). Estes dados foram copiados para a stack.

Nesse momento, a instrução da linha IL_000d chama stloc.2, ou seja, guarda o valor do topo da stack (o valor do Ponto) na terceira variável local, no caso V_2 (veja o comentário na IL), que é uma variável local introduzida pelo compilador e que não existe no C#.

Em seguida, na instrução IL_000e com o ldloca.s V_2 é obtido o endereço do valor de V_2. Este endereço é colocadado na stack e depois utilizado na chamada de get_X na linha seguinte. Ou seja, no lugar de p, usamos esta cópia.

Caso você queira se aprofundar um pouco mais este post do Jon Skeet é uma boa referência para explicar o problema da cópia com campos readonly (essencialmente o mesmo problema).

Conclusão

Podemos notar, analisando a IL, que temos:

  1. uma cópia de dados do valor original da struct;
  2. a subsequente movimentação do dado da stack para uma variável.

Essas duas operações terão custo, que pode ser evitado simplesmente utilizando uma estrutura imutável.

Além disso, a readonly struct é a unica forma de termos imutabilidade garantida pelo compilador. Até então, um objeto imutável não tinha garantia alguma de que seria imutável, fora o cuidado da pessoa desenvolvendo sua class ou struct. Com isso agora temos a garantia do compilador nos auxiliando nesta tarefa.

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.