Novidades do C# 9.0

O C# 9.0 adiciona os seguintes recursos e aprimoramentos à linguagem C#:

O C# 9.0 tem suporte no .NET 5. Para obter mais informações, consulte Controle de versão da linguagem C#.

Você pode baixar o SDK mais recente do .NET na página de downloads do .NET.

Tipos de registro

O C# 9.0 introduz tipos de registro. Você usa a palavra-chave record para definir um tipo de referência que fornece funcionalidade interna para encapsular dados. Você pode criar tipos de registro com propriedades imutáveis usando parâmetros posicionais ou sintaxe de propriedade padrão:

public record Person(string FirstName, string LastName);
public record Person
{
    public string FirstName { get; init; } = default!;
    public string LastName { get; init; } = default!;
};

Você também pode criar tipos de registros com propriedades e campos mutáveis:

public record Person
{
    public string FirstName { get; set; } = default!;
    public string LastName { get; set; } = default!;
};

Embora os registros possam ser mutáveis, eles se destinam principalmente a dar suporte a modelos de dados imutáveis. O tipo de registro oferece os seguintes recursos:

Você pode usar tipos de estrutura para criar tipos centrados em dados que fornecem igualdade de valor e pouco ou nenhum comportamento. Mas para modelos de dados relativamente grandes, os tipos de estrutura têm algumas desvantagens:

  • Eles não dão suporte à herança.
  • Eles são menos eficientes em determinar a igualdade de valor. Para tipos de valor, o método ValueType.Equals usa reflexão para localizar todos os campos. Para registros, o compilador gera o método Equals. Na prática, a implementação da igualdade de valor nos registros é mensuravelmente mais rápida.
  • Eles usam mais memória em alguns cenários, pois cada instância tem uma cópia completa de todos os dados. Os tipos de registro são tipos de referência, portanto, uma instância de registro contém apenas uma referência aos dados.

Sintaxe posicional para definição de propriedade

Você pode usar parâmetros posicionais para declarar propriedades de um registro e inicializar os valores de propriedade ao criar uma instância:

public record Person(string FirstName, string LastName);

public static void Main()
{
    Person person = new("Nancy", "Davolio");
    Console.WriteLine(person);
    // output: Person { FirstName = Nancy, LastName = Davolio }
}

Quando você usa a sintaxe posicional para definição de propriedade, o compilador cria:

  • Uma propriedade pública init-only implementada automaticamente para cada parâmetro posicional fornecido na declaração de registro. Uma propriedade init-only só pode ser definida no construtor ou usando um inicializador de propriedade.
  • Um construtor primário cujos parâmetros correspondem aos parâmetros posicionais na declaração de registro.
  • Um método Deconstruct com um parâmetro out para cada parâmetro posicional fornecido na declaração de registro.

Para obter mais informações, consulte Sintaxe posicional no artigo de referência de linguagem C# sobre registros.

Imutabilidade

Um tipo de registro não é necessariamente imutável. Você pode declarar propriedades com acessadores set e campos que não são readonly. Mas, embora os registros possam ser mutáveis, eles facilitam a criação de modelos de dados imutáveis. As propriedades criadas usando a sintaxe posicional são imutáveis.

A imutabilidade pode ser útil quando você deseja que um tipo centrado em dados seja thread-safe ou um código hash permaneça o mesmo em uma tabela de hash. Ela pode evitar bugs que ocorrem quando você passa um argumento por referência a um método e o método altera inesperadamente o valor do argumento.

Os recursos exclusivos para tipos de registro são implementados por métodos sintetizados pelo compilador e nenhum desses métodos compromete a imutabilidade por meio da modificação do estado do objeto.

Igualdade de valor

A igualdade de valor significa que duas variáveis de um tipo de registro são iguais se os tipos corresponderem e todos os valores de propriedade e campo corresponderem. Para outros tipos de referência, igualdade significa identidade. Ou seja, duas variáveis de um tipo de referência são iguais se referirem ao mesmo objeto.

O exemplo a seguir ilustra a igualdade de valores dos tipos de registro:

public record Person(string FirstName, string LastName, string[] PhoneNumbers);

public static void Main()
{
    var phoneNumbers = new string[2];
    Person person1 = new("Nancy", "Davolio", phoneNumbers);
    Person person2 = new("Nancy", "Davolio", phoneNumbers);
    Console.WriteLine(person1 == person2); // output: True

    person1.PhoneNumbers[0] = "555-1234";
    Console.WriteLine(person1 == person2); // output: True

    Console.WriteLine(ReferenceEquals(person1, person2)); // output: False
}

Em tipos class, você pode substituir manualmente métodos e operadores de igualdade para obter igualdade de valor, mas desenvolver e testar esse código seria demorado e propenso a erros. Ter essa funcionalidade interna impede que bugs resultantes do esquecimento atualizem o código de substituição personalizado quando propriedades ou campos forem adicionados ou alterados.

Para obter mais informações, consulte Sintaxe posicional no artigo de referência de linguagem C# sobre registros.

Mutação não destrutiva

Se você precisar modificar propriedades imutáveis de uma instância de registro, poderá usar uma expressão with para obter mutação não estruturativa. Uma expressão with faz uma nova instância de registro que é uma cópia de uma instância de registro existente, com propriedades e campos especificados modificados. Use a sintaxe do inicializador de objetos para especificar os valores a serem alterados, conforme mostrado no exemplo a seguir:

public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; }
}

public static void Main()
{
    Person person1 = new("Nancy", "Davolio") { PhoneNumbers = new string[1] };
    Console.WriteLine(person1);
    // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }

    Person person2 = person1 with { FirstName = "John" };
    Console.WriteLine(person2);
    // output: Person { FirstName = John, LastName = Davolio, PhoneNumbers = System.String[] }
    Console.WriteLine(person1 == person2); // output: False

    person2 = person1 with { PhoneNumbers = new string[1] };
    Console.WriteLine(person2);
    // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }
    Console.WriteLine(person1 == person2); // output: False

    person2 = person1 with { };
    Console.WriteLine(person1 == person2); // output: True
}

Para obter mais informações, consulte a mutação não estruturativa no artigo de referência da linguagem C# sobre registros.

Formatação interna para exibição

Os tipos de registro têm um método ToString gerado pelo compilador que exibe os nomes e valores de propriedades e campos públicos. O método ToString retorna uma cadeia de caracteres do seguinte formato:

<nome do tipo de registro> { <nome da propriedade> = <valor>, <nome da propriedade> = <valor>, ...}

Para tipos de referência, o nome do tipo do objeto ao qual a propriedade se refere é exibido em vez do valor da propriedade. No exemplo a seguir, a matriz é um tipo de referência, portanto System.String[] é exibida em vez dos valores reais do elemento de matriz:

Person { FirstName = Nancy, LastName = Davolio, ChildNames = System.String[] }

Para obter mais informações, consulte Sintaxe posicional no artigo de referência de linguagem C# sobre registros.

Herança

Um registro pode herdar de outro registro. No entanto, um registro não pode herdar de uma classe, e uma classe não pode herdar de um registro.

O exemplo a seguir ilustra a herança com sintaxe de propriedade posicional:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}

Para que duas variáveis de registro sejam iguais, o tipo de tempo de execução deve ser igual. Os tipos das variáveis de conteúdo podem ser diferentes. Isso é ilustrado no exemplo de código a seguir:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Person student = new Student("Nancy", "Davolio", 3);
    Console.WriteLine(teacher == student); // output: False

    Student student2 = new Student("Nancy", "Davolio", 3);
    Console.WriteLine(student2 == student); // output: True
}

No exemplo, todas as instâncias têm as mesmas propriedades e os mesmos valores de propriedade. Mas student == teacher retorna False, embora ambas sejam variáveis de tipo Person. E student == student2 retorna True, embora uma seja uma variável Person e outra seja uma variável Student.

Todas as propriedades públicas e campos de tipos derivados e base estão incluídos na saída ToString, conforme mostrado no exemplo a seguir:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}

Para obter mais informações, consulte Herança no artigo de referência de linguagem C# sobre registros.

Setters somente init

Os setters init apenas fornecem sintaxe consistente para inicializar membros de um objeto. Inicializadores de propriedade deixam claro qual valor está definindo qual propriedade. A desvantagem é que essas propriedades devem ser configuráveis. A partir do C# 9.0, você pode criar acessadores init m vez de set para propriedades e indexadores. Os chamadores podem usar a sintaxe do inicializador de propriedades para definir esses valores em expressões de criação, mas essas propriedades são lidas somente depois que a construção for concluída. Os setters init apenas fornecem uma janela para alterar o estado. Essa janela fecha quando a fase de construção termina. A fase de construção termina efetivamente após toda a inicialização, incluindo inicializadores de propriedades e com expressões concluídas.

Você pode declarar setters somente init em qualquer tipo que você escrever. Por exemplo, o struct a seguir define uma estrutura de observação meteorológica:

public struct WeatherObservation
{
    public DateTime RecordedAt { get; init; }
    public decimal TemperatureInCelsius { get; init; }
    public decimal PressureInMillibars { get; init; }

    public override string ToString() =>
        $"At {RecordedAt:h:mm tt} on {RecordedAt:M/d/yyyy}: " +
        $"Temp = {TemperatureInCelsius}, with {PressureInMillibars} pressure";
}

Os chamadores podem usar a sintaxe do inicializador de propriedades para definir os valores, preservando ainda a imutabilidade:

var now = new WeatherObservation 
{ 
    RecordedAt = DateTime.Now, 
    TemperatureInCelsius = 20, 
    PressureInMillibars = 998.0m 
};

Uma tentativa de alterar uma observação após a inicialização resulta em um erro do compilador:

// Error! CS8852.
now.TemperatureInCelsius = 18;

Somente setters init podem ser úteis para definir propriedades de classe base de classes derivadas. Eles também podem definir propriedades derivadas por meio de auxiliares em uma classe base. Registros posicionais declaram propriedades usando apenas setters init. Esses setters são usados em expressões with. Você pode declarar setters somente init para qualquer class, struct ou record que você define.

Para obter mais informações, confira init (Referência C#).

Instruções de nível superior

Instruções de nível superior removem a cerimônia desnecessária de muitos aplicativos. Considere o programa canônico "Olá, Mundo!":

using System;

namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}

Só há uma linha de código que faz qualquer coisa. Com instruções de nível superior, você pode substituir todo esse código clichê pela diretiva using e pela única linha que faz o trabalho:

using System;

Console.WriteLine("Hello World!");

Se você quisesse um programa de uma linha, poderia remover a diretiva using e usar o nome de tipo totalmente qualificado:

System.Console.WriteLine("Hello World!");

Somente um arquivo em seu aplicativo pode usar instruções de nível superior. Se o compilador encontrar instruções de nível superior em vários arquivos de origem, será um erro. Também será um erro se você combinar instruções de nível superior com um método de ponto de entrada de programa declarado, normalmente um método Main. De certa forma, você pode pensar que um arquivo contém as instruções que normalmente estariam no método Main de uma classe Program.

Um dos usos mais comuns para esse recurso é a criação de materiais didáticos. Os desenvolvedores iniciantes do C# podem escrever o "Olá, Mundo!" canônico em uma ou duas linhas de código. Nenhuma cerimônia extra é necessária. No entanto, os desenvolvedores experientes também encontrarão muitos usos para esse recurso. Instruções de nível superior permitem uma experiência semelhante a script para experimentação semelhante ao que os notebooks Jupyter fornecem. Instruções de nível superior são ótimas para pequenos programas de console e utilitários. Azure Functions é um caso de uso ideal para instruções de nível superior.

Mais importante, as instruções de nível superior não limitam o escopo ou a complexidade do aplicativo. Essas instruções podem acessar ou usar qualquer classe .NET. Eles também não limitam o uso de argumentos de linha de comando nem retornam valores. Instruções de nível superior podem acessar uma matriz de cadeias de caracteres nomeadas args. Se as instruções de nível superior retornarem um valor inteiro, esse valor se tornará o código de retorno inteiro de um método sintetizado Main. As instruções de nível superior podem conter expressões assíncronas. Nesse caso, o ponto de entrada sintetizado retorna um Taskou Task<int>.

Para obter mais informações, consulte instruções de nível superior no Guia de Programação em C#.

Melhorias na correspondência de padrões

O C# 9 inclui novas melhorias de correspondência de padrões:

  • Padrões de tipo correspondem a um objeto correspondente a um tipo específico
  • Padrões entre parênteses impõem ou enfatizam a precedência de combinações de padrões
  • Padrões conjuntivos and exigem que ambos os padrões correspondam
  • Padrões conjuntivos or exigem que ambos os padrões correspondam
  • Padrões conjuntivos not exigem que ambos os padrões correspondam
  • Os padrões relacionais exigem que a entrada seja menor que, maior que, menor ou igual ou maior que ou igual a uma determinada constante.

Esses padrões enriquecem a sintaxe para padrões. Considere estes exemplos:

public static bool IsLetter(this char c) =>
    c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';

Com parênteses opcionais para deixar claro que and tem precedência maior que or:

public static bool IsLetterOrSeparator(this char c) =>
    c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',';

Um dos usos mais comuns é uma nova sintaxe para uma verificação nula:

if (e is not null)
{
    // ...
}

Qualquer um desses padrões pode ser usado em qualquer contexto em que os padrões são permitidos: expressões de padrão is, expressõesswitch, padrões aninhados e o padrão de uma instrução switch do rótulo case.

Para obter mais informações, confira Padrões (referência de C#).

Para obter mais informações, consulte as seções padrões relacionais e padrões lógicos do artigo Padrões.

Desempenho e interoperabilidade

Três novos recursos melhoram o suporte para bibliotecas nativas de interoperabilidade e de baixo nível que exigem alto desempenho: inteiros de tamanho nativo, ponteiros de função e omitindo o sinalizador localsinit.

Inteiros de tamanho nativo, nint e nuint, são tipos inteiros. Eles são expressos pelos tipos subjacentes System.IntPtr e System.UIntPtr. O compilador apresenta conversões e operações adicionais para esses tipos como ints nativos. Inteiros de tamanho nativo definem propriedades para MaxValue ou MinValue. Esses valores não podem ser expressos como constantes de tempo de compilação porque dependem do tamanho nativo de um inteiro no computador de destino. Esses valores são lidos somente em tempo de execução. Você pode usar valores constantes no intervalo nint [int.MinValue .. int.MaxValue]. Você pode usar valores constantes no intervalo nuint [uint.MinValue .. uint.MaxValue]. O compilador executa a dobra constante para todos os operadores unários e binários usando e tipos System.Int32 e System.UInt32. Se o resultado não se encaixar em 32 bits, a operação será executada em tempo de execução e não será considerada uma constante. Inteiros de tamanho nativo podem aumentar o desempenho em cenários em que a matemática inteiro é usada extensivamente e precisa ter o desempenho mais rápido possível. Para obter mais informações, consulte nint e nuint tipos.

Os ponteiros de função fornecem uma sintaxe fácil para acessar os opcodes de IL ldftn e calli. Você pode declarar ponteiros de função usando uma nova sintaxe delegate*. Um tipo delegate* é um tipo de ponteiro. Invocar o tipo delegate* usa calli, em contraste com um delegado que usa callvirt no método Invoke(). Sintaticamente, as invocações são idênticas. A invocação do ponteiro de função usa a convenção de chamada managed. Você adiciona a palavra-chave unmanaged após a sintaxe delegate* para declarar que deseja a convenção de chamada unmanaged. Outras convenções de chamada podem ser especificadas usando atributos na declaração delegate*. Para obter mais informações, consulte Códigos não seguros e tipos de ponteiro.

Por fim, você pode adicionar o System.Runtime.CompilerServices.SkipLocalsInitAttribute para instruir o compilador a não emitir o sinalizador localsinit. Esse sinalizador instrui o CLR a inicializar zero todas as variáveis locais. O sinalizador localsinit tem sido o comportamento padrão para C# desde 1.0. No entanto, a inicialização extra zero pode ter impacto mensurável no desempenho em alguns cenários. Em particular, quando você usa o stackalloc. Nesses casos, você pode adicionar o atributo SkipLocalsInitAttribute. Você pode adicioná-lo a um único método ou propriedade, ou a um class, struct, interface ou até mesmo a um módulo. Esse atributo não afeta métodos abstract; ele afeta o código gerado para a implementação. Para obter mais informações, consulte atributo SkipLocalsInit.

Esses recursos podem melhorar o desempenho em alguns cenários. Elas devem ser usadas somente após um benchmarking cuidadoso antes e depois da adoção. O código que envolve inteiros de tamanho nativo deve ser testado em várias plataformas de destino com tamanhos inteiros diferentes. Os outros recursos exigem código não seguro.

Ajustar e concluir recursos

Muitos dos outros recursos ajudam você a escrever código com mais eficiência. No C# 9.0, você pode omitir o tipo em uma newexpressão quando o tipo do objeto criado já for conhecido. O uso mais comum está em declarações de campo:

private List<WeatherObservation> _observations = new();

O tipo de destino new também pode ser usado quando você precisa criar um novo objeto para passar como um argumento para um método. Considere um método ForecastFor() com a seguinte assinatura:

public WeatherForecast ForecastFor(DateTime forecastDate, WeatherForecastOptions options)

Você pode chamá-lo da seguinte maneira:

var forecast = station.ForecastFor(DateTime.Now.AddDays(2), new());

Outro bom uso para esse recurso é combiná-lo com propriedades somente init para inicializar um novo objeto:

WeatherStation station = new() { Location = "Seattle, WA" };

Você pode retornar uma instância criada pelo construtor padrão usando uma instrução return new();.

Um recurso semelhante melhora a resolução de tipo de destino de expressões condicionais. Com essa alteração, as duas expressões não precisam ter uma conversão implícita de uma para outra, mas podem ter conversões implícitas em um tipo de destino. Você provavelmente não observará essa alteração. O que você observará é que algumas expressões condicionais que anteriormente exigiam conversões ou não seriam compiladas agora apenas funcionam.

A partir do C# 9.0, você pode adicionar o modificador static a expressões lambda ou métodos anônimos. Expressões lambda estáticas são análogas às funções locais static: um método lambda estático ou anônimo não pode capturar variáveis locais ou o estado da instância. O modificador static impede a captura acidental de outras variáveis.

Tipos de retorno covariantes fornecem flexibilidade para os tipos de retorno de métodos de substituição. Um método de substituição pode retornar um tipo derivado do tipo de retorno do método base substituído. Isso pode ser útil para registros e para outros tipos que dão suporte a clones virtuais ou métodos de fábrica.

Além disso, o loop foreach reconhecerá e usará um método GetEnumerator de extensão que, de outra forma, satisfaz o padrão foreach. Essa alteração significa que foreach é consistente com outras construções baseadas em padrão, como o padrão assíncrono e a desconstrução baseada em padrão. Na prática, essa alteração significa que você pode adicionar suporte foreach a qualquer tipo. Você deve limitar seu uso ao enumerar um objeto faz sentido em seu design.

Em seguida, você pode usar descartes como parâmetros para expressões lambda. Essa conveniência permite que você evite nomear o argumento e o compilador pode evitar usá-lo. Você usa o _ para qualquer argumento. Para obter mais informações, consulte a seção Parâmetros de entrada de uma expressão lambda do artigo Expressões lambda.

Por fim, agora você pode aplicar atributos a funções locais. Por exemplo, você pode aplicar anotações de atributo anuláveis a funções locais.

Suporte para geradores de código

Dois recursos finais dão suporte a geradores de código C#. Geradores de código C# são um componente que você pode escrever que é semelhante a um analisador roslyn ou correção de código. A diferença é que os geradores de código analisam o código e gravam novos arquivos de código-fonte como parte do processo de compilação. Um gerador de código típico pesquisa código para atributos ou outras convenções.

Um gerador de código lê atributos ou outros elementos de código usando as APIs de análise Roslyn. Com base nessa informação, ele adiciona um novo código à compilação. Geradores de origem só podem adicionar código; eles não têm permissão para modificar nenhum código existente na compilação.

Os dois recursos adicionados para geradores de código são extensões à sintaxe parcial do método e inicializadores de módulo. Primeiro, as alterações em métodos parciais. Antes do C# 9.0, os métodos parciais são private, mas não podem especificar um modificador de acesso, ter um retorno void e não podem ter parâmetros out. Essas restrições significaram que, se nenhuma implementação de método for fornecida, o compilador removerá todas as chamadas para o método parcial. O C# 9.0 remove essas restrições, mas exige que as declarações de método parcial tenham uma implementação. Os geradores de código podem fornecer essa implementação. Para evitar a introdução de uma alteração significativa, o compilador considera qualquer método parcial sem um modificador de acesso para seguir as regras antigas. Se o método parcial incluir o modificador de acesso private, as novas regras regem esse método parcial. Para obter mais informações, consulte o método parcial (Referência de C#).

O segundo novo recurso para geradores de código são inicializadores de módulo. Inicializadores de módulo são métodos que têm o atributo ModuleInitializerAttribute anexado a eles. Esses métodos serão chamados pelo runtime antes de qualquer outro acesso de campo ou invocação de método dentro de todo o módulo. Um método inicializador de módulo:

  • Deve ser estático
  • Deve ser sem parâmetros
  • Deve retornar nulo
  • Não deve ser um método genérico
  • Não deve estar contido em uma classe genérica
  • Deve ser acessível por meio do módulo que o contém

Esse último ponto de marcador efetivamente significa que o método e sua classe de contenção devem ser internos ou públicos. O método não pode ser uma função local. Para obter mais informações, consulte o ModuleInitializeratributo .