Criar tipos de registro

C# 9 introduz registros, um novo tipo de referência que você pode criar em vez de classes ou structs. C# 10 adiciona structs de registro para que você possa definir registros como tipos de valor. Os registros são distintos das classes em que os tipos de registro usam igualdade baseada em valor. Duas variáveis de um tipo de registro são iguais se as definições de tipo de registro forem idênticas e, se para cada campo, os valores em ambos os registros forem iguais. Duas variáveis de um tipo de classe são iguais se os objetos referenciados forem do mesmo tipo de classe e as variáveis se referirem ao mesmo objeto. A igualdade baseada em valor implica outros recursos que você provavelmente desejará em tipos de registro. O compilador gera muitos desses membros quando você declara um record em vez de um class. O compilador gera esses mesmos métodos para tipos record struct.

Neste tutorial, você aprenderá como:

  • Decida se você deve declarar um class ou um record.
  • Declare tipos de registro e tipos de registro posicionais.
  • Substitua seus métodos por métodos gerados pelo compilador em registros.

Pré-requisitos

Você precisará configurar seu computador para executar o .NET 6 ou posterior, incluindo o compilador C# 10 ou posterior. O compilador C# 10 está disponível a partir do Visual Studio 2022 ou do .NET 6 SDK.

Características dos registros

Você define um registro declarando um tipo com a record palavra-chave, em vez da class palavra-chave ou struct. Opcionalmente, você pode declarar um record class para esclarecer que ele é um tipo de referência. Um registro é um tipo de referência e segue a semântica de igualdade baseada em valor. Você pode definir um record struct para criar um registro que seja um tipo de valor. Para impor semântica de valor, o compilador gera vários métodos para o tipo de registro (para tipos record class e record struct):

Os registros também fornecem uma substituição de Object.ToString(). O compilador sintetiza métodos para exibir registros usando Object.ToString(). Você explorará esses membros enquanto escreve o código para este tutorial. Os registros aceitam as expressões with para habilitar a mutação não destrutiva de registros.

Você também pode declarar registros posicionais usando uma sintaxe mais concisa. O compilador sintetiza mais métodos para você ao declarar registros posicionais:

  • Um construtor primário cujos parâmetros correspondem aos parâmetros posicionais na declaração de registro.
  • Propriedades públicas para cada parâmetro de um construtor primário. Essas propriedades são somente init para tipos record class e tipos readonly record struct. Para tipos record struct, elas são leitura-gravação.
  • Um método Deconstruct para extrair propriedades do registro.

Compilar dados de temperatura

Dados e estatísticas estão entre os cenários em que você deseja usar registros. Para este tutorial, você criará um aplicativo que computa dias de grau para usos diferentes. Os dias de grau são uma medida de calor (ou falta de calor) durante um período de dias, semanas ou meses. Os dias de grau acompanham e prevêem o uso de energia. Dias mais quentes significam mais ar condicionado, e dias mais frios significam mais uso de fornos. Os dias de grau ajudam a gerenciar as populações vegetais e a correlacionar-se com o crescimento das plantas à medida que as estações mudam. Os dias de grau ajudam a acompanhar as migrações de animais para espécies que viajam para corresponder ao clima.

A fórmula baseia-se na temperatura média em um determinado dia e em uma temperatura de linha de base. Para calcular dias de grau ao longo do tempo, você precisará da temperatura alta e baixa todos os dias por um período de tempo. Vamos começar criando um novo aplicativo. Crie um novo aplicativo de console Crie um novo tipo de registro em um novo arquivo chamado "DailyTemperature.cs":

public readonly record struct DailyTemperature(double HighTemp, double LowTemp);

O código anterior define um registro posicional. O registro DailyTemperature é um readonly record struct, porque você não pretende herdar dele, e deve ser imutável. As propriedades HighTemp e LowTemp são apenas propriedades init, o que significa que elas podem ser definidas no construtor ou usando um inicializador de propriedade. Se você quiser que os parâmetros posicionais sejam leitura-gravação, declare record struct em vez de readonly record struct. O tipo DailyTemperature também tem um construtor primário que tem dois parâmetros que correspondem às duas propriedades. Você usa o construtor primário para inicializar um registro DailyTemperature. O código a seguir cria e inicializa vários registros DailyTemperature. O primeiro usa parâmetros nomeados para esclarecer HighTemp e LowTemp. Os inicializadores restantes usam parâmetros posicionais para inicializar HighTemp e LowTemp:

private static DailyTemperature[] data = new DailyTemperature[]
{
    new DailyTemperature(HighTemp: 57, LowTemp: 30), 
    new DailyTemperature(60, 35),
    new DailyTemperature(63, 33),
    new DailyTemperature(68, 29),
    new DailyTemperature(72, 47),
    new DailyTemperature(75, 55),
    new DailyTemperature(77, 55),
    new DailyTemperature(72, 58),
    new DailyTemperature(70, 47),
    new DailyTemperature(77, 59),
    new DailyTemperature(85, 65),
    new DailyTemperature(87, 65),
    new DailyTemperature(85, 72),
    new DailyTemperature(83, 68),
    new DailyTemperature(77, 65),
    new DailyTemperature(72, 58),
    new DailyTemperature(77, 55),
    new DailyTemperature(76, 53),
    new DailyTemperature(80, 60),
    new DailyTemperature(85, 66) 
};

Você pode adicionar suas próprias propriedades ou métodos a registros, incluindo registros posicionais. Você precisará calcular a temperatura média para cada dia. Você pode adicionar essa propriedade ao registro DailyTemperature:

public readonly record struct DailyTemperature(double HighTemp, double LowTemp)
{
    public double Mean => (HighTemp + LowTemp) / 2.0;
}

Vamos garantir que você possa usar esses dados. Adicione o código a seguir ao método Main:

foreach (var item in data)
    Console.WriteLine(item);

Execute seu aplicativo e você verá uma saída semelhante à exibição a seguir (várias linhas removidas para espaço):

DailyTemperature { HighTemp = 57, LowTemp = 30, Mean = 43.5 }
DailyTemperature { HighTemp = 60, LowTemp = 35, Mean = 47.5 }


DailyTemperature { HighTemp = 80, LowTemp = 60, Mean = 70 }
DailyTemperature { HighTemp = 85, LowTemp = 66, Mean = 75.5 }

O código anterior mostra a saída da substituição de ToString sintetizado pelo compilador. Se preferir texto diferente, você poderá escrever sua própria versão que ToString impede que o compilador sintetize uma versão para você.

Computar dias de grau

Para computar dias de grau, você faz a diferença de uma temperatura de linha de base e da temperatura média em um determinado dia. Para medir o calor ao longo do tempo, descarte qualquer dia em que a temperatura média esteja abaixo da linha de base. Para medir o frio ao longo do tempo, descarte qualquer dia em que a temperatura média esteja acima da linha de base. Por exemplo, os EUA usam 65F como base para dias de aquecimento e grau de resfriamento. Essa é a temperatura em que nenhum aquecimento ou resfriamento é necessário. Se um dia tiver uma temperatura média de 70F, esse dia será de cinco dias de resfriamento e zero dias de grau de aquecimento. Por outro lado, se a temperatura média for 55F, esse dia será de 10 dias de aquecimento e 0 dias de resfriamento.

Você pode expressar essas fórmulas como uma pequena hierarquia de tipos de registro: um tipo de dia de grau abstrato e dois tipos concretos para dias de grau de aquecimento e dias de grau de resfriamento. Esses tipos também podem ser registros posicionais. Eles obtêm uma temperatura de linha de base e uma sequência de registros de temperatura diária como argumentos para o construtor primário:

public abstract record DegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords);

public sealed record HeatingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean < BaseTemperature).Sum(s => BaseTemperature - s.Mean);
}

public sealed record CoolingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean > BaseTemperature).Sum(s => s.Mean - BaseTemperature);
}

O registro abstrato DegreeDays é a classe base compartilhada para os registros HeatingDegreeDays e CoolingDegreeDays. As declarações de construtor primário nos registros derivados mostram como gerenciar a inicialização do registro base. Seu registro derivado declara parâmetros para todos os parâmetros no construtor primário do registro base. O registro base declara e inicializa essas propriedades. O registro derivado não os oculta, mas apenas cria e inicializa propriedades para parâmetros que não são declarados em seu registro base. Neste exemplo, os registros derivados não adicionam novos parâmetros de construtor primário. Teste seu código adicionando o seguinte código ao seu método Main:

var heatingDegreeDays = new HeatingDegreeDays(65, data);
Console.WriteLine(heatingDegreeDays);

var coolingDegreeDays = new CoolingDegreeDays(65, data);
Console.WriteLine(coolingDegreeDays);

Você obterá uma saída como a seguinte exibição:

HeatingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 85 }
CoolingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 71.5 }

Definir métodos sintetizados pelo compilador

Seu código calcula o número correto de dias de grau de aquecimento e resfriamento durante esse período de tempo. Mas este exemplo mostra por que talvez você queira substituir alguns dos métodos sintetizados para registros. Você pode declarar sua própria versão de qualquer um dos métodos sintetizados pelo compilador em um tipo de registro, exceto o método clone. O método clone tem um nome gerado pelo compilador e você não pode fornecer uma implementação diferente. Esses métodos sintetizados incluem um construtor de cópia, os membros da interface System.IEquatable<T>, os testes de igualdade e desigualdade, e GetHashCode(). Para essa finalidade, você sintetizará PrintMembers. Você também pode declarar sua própria ToString, mas PrintMembers fornece uma opção melhor para cenários de herança. Para fornecer sua própria versão de um método sintetizado, a assinatura deve corresponder ao método sintetizado.

O elemento TempRecords na saída do console não é útil. Ele exibe o tipo, mas nada mais. Você pode alterar esse comportamento fornecendo sua própria implementação do método sintetizado PrintMembers. A assinatura depende de modificadores aplicados à declaração record:

  • Se um tipo de registro for sealed, ou um record struct, a assinatura será private bool PrintMembers(StringBuilder builder);
  • Se um tipo de registro não sealed for e derivar de object (ou seja, ele não declara um registro base), a assinatura será protected virtual bool PrintMembers(StringBuilder builder);
  • Se um tipo de registro não for sealed e derivar de outro registro, a assinatura será protected override bool PrintMembers(StringBuilder builder);

Essas regras são mais fáceis de compreender por meio da compreensão da finalidade de PrintMembers. PrintMembers adiciona informações sobre cada propriedade em um tipo de registro a uma cadeia de caracteres. O contrato requer registros base para adicionar seus membros à exibição e pressupõe que os membros derivados adicionarão seus membros. Cada tipo de registro sintetiza uma substituição ToString semelhante ao exemplo a seguir para HeatingDegreeDays:

public override string ToString()
{
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("HeatingDegreeDays");
    stringBuilder.Append(" { ");
    if (PrintMembers(stringBuilder))
    {
        stringBuilder.Append(" ");
    }
    stringBuilder.Append("}");
    return stringBuilder.ToString();
}

Você declara um método PrintMembers no registro DegreeDays que não imprime o tipo da coleção:

protected virtual bool PrintMembers(StringBuilder stringBuilder)
{
    stringBuilder.Append($"BaseTemperature = {BaseTemperature}");
    return true;
}

A assinatura declara um método virtual protected para corresponder à versão do compilador. Não se preocupe se você tiver errado os acessadores; a linguagem impõe a assinatura correta. Se você esquecer os modificadores corretos para qualquer método sintetizado, o compilador emitirá avisos ou erros que o ajudarão a obter a assinatura certa.

No C# 10 e posterior, você pode declarar o método ToString como sealed em um tipo de registro. Isso impede que registros derivados forneçam uma nova implementação. Os registros derivados ainda conterão a substituição PrintMembers. Você selaria ToString se não quisesse que ele exibisse o tipo de runtime do registro. No exemplo anterior, você perderia as informações sobre onde o registro estava medindo dias de aquecimento ou grau de resfriamento.

Mutação não destrutiva

Os membros sintetizados em uma classe de registro posicional não modificam o estado do registro. A meta é que você possa criar registros imutáveis com mais facilidade. Lembre-se de declarar um readonly record struct para criar um struct de registro imutável. Examine novamente as declarações anteriores para HeatingDegreeDays e CoolingDegreeDays. Os membros adicionados executam cálculos nos valores do registro, mas não modificam o estado. Os registros posicionais facilitam a criação de tipos de referência imutáveis.

Criar tipos de referência imutáveis significa que você deseja usar mutação não destrutiva. Você cria novas instâncias de registro semelhantes às instâncias de registro existentes usando withexpressões. Essas expressões são uma construção de cópia com atribuições adicionais que modificam a cópia. O resultado é uma nova instância de registro em que cada propriedade foi copiada do registro existente e, opcionalmente, modificada. O registro original não foi alterado.

Vamos adicionar alguns recursos ao programa que demonstram expressões with. Primeiro, vamos criar um novo registro para calcular dias de grau crescente usando os mesmos dados. Dias de grau crescente normalmente usam 41F como a linha de base e mede as temperaturas acima da linha de base. Para usar os mesmos dados, você pode criar um novo registro semelhante ao coolingDegreeDays, mas com uma temperatura base diferente:

// Growing degree days measure warming to determine plant growing rates
var growingDegreeDays = coolingDegreeDays with { BaseTemperature = 41 };
Console.WriteLine(growingDegreeDays);

Você pode comparar o número de graus calculados com os números gerados com uma temperatura de linha de base mais alta. Lembre-se de que os registros são tipos de referência e essas cópias são cópias rasas. A matriz para os dados não é copiada, mas ambos os registros se referem aos mesmos dados. Esse fato é uma vantagem em um outro cenário. Para dias de grau crescente, é útil controlar o total dos cinco dias anteriores. Você pode criar novos registros com dados de origem diferentes usando expressões with. O código a seguir cria uma coleção desses acúmulos e exibe os valores:

// showing moving accumulation of 5 days using range syntax
List<CoolingDegreeDays> movingAccumulation = new();
int rangeSize = (data.Length > 5) ? 5 : data.Length;
for (int start = 0; start < data.Length - rangeSize; start++)
{
    var fiveDayTotal = growingDegreeDays with { TempRecords = data[start..(start + rangeSize)] };
    movingAccumulation.Add(fiveDayTotal);
}
Console.WriteLine();
Console.WriteLine("Total degree days in the last five days");
foreach(var item in movingAccumulation)
{
    Console.WriteLine(item);
}

Você também pode usar expressões with para criar cópias de registros. Não especifique nenhuma propriedade entre as chaves para a expressão with. Isso significa criar uma cópia e não alterar nenhuma propriedade:

var growingDegreeDaysCopy = growingDegreeDays with { };

Execute o aplicativo concluído para ver os resultados.

Resumo

Este tutorial mostrou vários aspectos dos registros. Os registros fornecem sintaxe concisa para tipos em que o uso fundamental é armazenar dados. Para classes orientadas a objeto, o uso fundamental é definir responsabilidades. Este tutorial se concentrou em registros posicionais, em que você pode usar uma sintaxe concisa para declarar as propriedades de um registro. O compilador sintetiza vários membros do registro para copiar e comparar registros. Você pode adicionar outros membros necessários para seus tipos de registro. Você pode criar tipos de registro imutáveis sabendo que nenhum dos membros gerados pelo compilador alteraria o estado. E as expressões with facilitam o suporte a mutações não destrutivas.

Os registros adicionam outra maneira de definir tipos. Você usa definições class para criar hierarquias orientadas a objeto que se concentram nas responsabilidades e no comportamento dos objetos. Você cria struct tipos para estruturas de dados que armazenam dados e são pequenos o suficiente para copiar com eficiência. Você cria tipos record quando deseja igualdade e comparação baseadas em valor, não deseja copiar valores e deseja usar variáveis de referência. Você cria tipos record struct quando deseja os recursos de registros para um tipo pequeno o suficiente para copiar com eficiência.

Você pode saber mais sobre registros no artigo de referência de linguagem C# para o tipo de registro, a especificação de tipo de registro proposto e a especificação do struct de registro.