O C# 9.0 está tomando forma e gostaria de compartilhar sobre alguns dos principais recursos que a Microsoft adicionou a esta próxima versão da linguagem.
O anúncio oficial foi anunciado aqui: https://devblogs.microsoft.com/dotnet/welcome-to-c-9-0/ e neste post irei trazer os principais comentários feitos e algumas impressões que tenho sobre o lançamento
A cada nova versão do C#, a Microsoft busca maior clareza e simplicidade em cenários comuns de codificação e o C# 9.0 não é exceção. Um foco específico desta vez é o suporte à representação concisa e imutável de formas de dados.
Propriedades Init-only
Os inicializadores de objetos são impressionantes. Eles dão ao cliente de um tipo um formato muito flexível e legível para criar um objeto, e são especialmente ótimos para criação de objetos aninhados, onde uma árvore inteira de objetos é criada de uma só vez. Aqui está um exemplo simples:
new Person { FirstName = "Scott", LastName = "Hunter" }
Os inicializadores de objetos também liberam o autor de escrever muitos clichês de construção – tudo o que você precisam fazer é escrever algumas propriedades!
public class Person { public string FirstName { get; set; } public string LastName { get; set; } }
A única grande limitação hoje é que as propriedades precisam ser mutáveis para que os inicializadores de objetos funcionem: eles funcionam primeiro chamando o construtor do objeto (o padrão, sem parâmetros neste caso) e depois atribuindo aos configuradores de propriedades.
As propriedades somente de inicialização corrigem isso! Eles introduzem um acessador init
que é uma variante do acessador set
que só pode ser chamado durante a inicialização do objeto:
public class Person { public string FirstName { get; init; } public string LastName { get; init; } }
Com esta declaração, o código do cliente acima ainda é permitido, mas qualquer atribuição subsequente às propriedades FirstName
e LastName
é considerado um erro.
Acessadores de inicialização e campos somente leitura
Como os acessadores init
só podem ser chamados durante a inicialização, eles têm permissão para alterar os campos readonly
da classe envolvente, assim como você pode em um construtor.
public class Person { private readonly string firstName; private readonly string lastName; public string FirstName { get => firstName; init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName))); } public string LastName { get => lastName; init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName))); } }
Registros
As propriedades somente de inicialização são ótimas se você deseja tornar propriedades individuais imutáveis. Se você deseja que o objeto inteiro seja imutável e se comporte como um valor, considere declará-lo como um registro :
public data class Person { public string FirstName { get; init; } public string LastName { get; init; } }
A palavra-chave data
na declaração de classe a marca como um registro. Isso o imbui de vários comportamentos adicionais semelhantes aos valores, que serão abordados a seguir. De um modo geral, os registros devem ser vistos mais como “valores” – dados! – e menos como objetos. Eles não devem ter um estado encapsulado mutável. Em vez disso, você representa a mudança ao longo do tempo criando novos registros que representam o novo estado. Eles são definidos não por sua identidade, mas por seu conteúdo.
Esta funcionalidade traz ao C# ainda mais poder em relação à flexibilidade em que você trabalhar com valores, isto é, uma nova maneira de se enxergar classes, menos como objetos e mais como um conjunto de valores.
Com expressões
Ao trabalhar com dados imutáveis, um padrão comum é criar novos valores a partir dos existentes para representar um novo estado. Por exemplo, se nossa pessoa mudasse seu sobrenome, representá-lo-emos como um novo objeto que é uma cópia do antigo, exceto com um sobrenome diferente. Essa técnica é frequentemente chamada de mutação não destrutiva . Em vez de representar a pessoa ao longo do tempo , o registro representa o estado da pessoa em um determinado momento .
Para ajudar com esse estilo de programação, os registros permitem um novo tipo de expressão; a expressão with
:
var otherPerson = person with { LastName = "Hanselman" };
As expressões with
usam a sintaxe do inicializador de objetos para indicar o que há de novo no novo objeto e no antigo. Você pode especificar várias propriedades.
Um registro define implicitamente um “construtor de cópia” protected
– um construtor que pega um objeto de registro existente e o copia campo por campo para o novo:
protected Person(Person original) { /* copia todos os campos */ } // gerado
A expressão with
faz com que o construtor de cópias seja chamado e aplica o inicializador de objetos na parte superior para alterar as propriedades de acordo.
Se você não gostar do comportamento padrão do construtor de cópias gerado, poderá definir o seu próprio, e isso será captado pela expressão with
.
Mais uma grande evolução da linguagem em questões de flexibilidade nesta funcionalidade. Você gerar de uma maneira simples novos objetos que são gerados de objetos imutáveis, é algo que eu acredito que irei utilizar muito, já que em alguns cenários eu realizo este tipo de abordagem, podem de uma maneira um pouco mais custosa.
Igualdade baseada em valor
Todos os objetos herdam um método virtual Equals(object)
da classe object
. Isso é usado como base para o método estático Object.Equals(object, object)
quando ambos os parâmetros são não nulos.
As estruturas substituem isso para ter “igualdade baseada em valor”, comparando cada campo da estrutura chamando Equals
recursivamente. Os registros fazem o mesmo.
Isso significa que, de acordo com a sua “valorização”, dois objetos de registro podem ser iguais um ao outro sem ser o mesmo objeto. Por exemplo, se modificarmos o sobrenome da pessoa modificada novamente:
var originalPerson = otherPerson with { LastName = "Hunter" };
Agora teríamos ReferenceEquals(person, originalPerson)
= false (eles não são o mesmo objeto), mas Equals(person, originalPerson)
= true (eles têm o mesmo valor).
Se você não gostar do comportamento padrão de comparação campo a campo da sobrecarga Equals
gerada , poderá escrever o seu próprio. Você só precisa ter cuidado para entender como a igualdade baseada em valor funciona nos registros, especialmente quando a herança está envolvida, o que voltaremos a seguir.
Juntamente com o valor-baseado, Equals
há também uma sobrecarga baseada em valor chamada GetHashCode()
para ir junto com ele.
Membros de dados
Os registros têm como objetivo imutável a imutabilidade, com propriedades públicas somente de inicialização que podem ser modificadas de maneira não destrutiva por meio de expressões with
. Em vez de um campo implicitamente privado, como em outras declarações de classe e estrutura, nos registros isso é considerado uma abreviação para uma propriedade pública, somente de inicialização automática Assim, a declaração seria algo do tipo:
public data class Person { string FirstName; string LastName; }
Significa exatamente o mesmo que tínhamos antes:
public data class Person { public string FirstName { get; init; } public string LastName { get; init; } }
Achamos que isso contribui para declarações de registros bonitas e claras. Se você realmente deseja um campo privado, basta adicionar o modificador private
explicitamente:
private string firstName;
Registros posicionais
Às vezes, é útil ter uma abordagem mais posicional de um registro, onde seu conteúdo é fornecido por meio de argumentos construtores e pode ser extraído com desconstrução posicional.
É perfeitamente possível especificar seu próprio construtor e desconstrutor em um registro:
public data class Person { string FirstName; string LastName; public Person(string firstName, string lastName) => (FirstName, LastName) = (firstName, lastName); public void Deconstruct(out string firstName, out string lastName) => (firstName, lastName) = (FirstName, LastName); }
Mas há uma sintaxe muito mais curta para expressar exatamente a mesma coisa:
public data class Person(string FirstName, string LastName);
Isto declara as auto-propriedades de somente inicialização públicas e o construtor e o desconstrutor, de modo que você pode escrever:
var person = new Person("Scott", "Hunter"); // construtor posicional var (f, l) = person; // desconstrutor posicional
Se você não gostar da propriedade automática gerada, poderá definir sua própria propriedade com o mesmo nome, e o construtor e o desconstrutor gerados usarão apenas essa.
Registros e mutação
A semântica baseada em valor de um registro não combina bem com o estado mutável. Imagine colocar um objeto de registro em um dicionário. Encontrá-lo novamente depende de Equals
e (às vezes) GethashCode
. Mas se o registro mudar de estado, mudará o que é igual também! Em uma implementação de tabela de hash, ele pode até corromper a estrutura de dados, pois o posicionamento é baseado no código de hash “na chegada”!
Provavelmente, existem alguns usos avançados válidos do estado mutável dentro dos registros, principalmente para o cache. Mas o trabalho manual envolvido na substituição dos comportamentos padrão para ignorar esse estado provavelmente será algo consideravelmente complexo ou trabalhoso.
Com expressões e herança
Igualdade baseada em valor e mutação não destrutiva são notoriamente desafiadoras quando combinadas com herança. Vamos adicionar uma classe de registro derivada chamada Student
ao nosso exemplo em execução:
public data class Person { string FirstName; string LastName; } public data class Student : Person { int ID; }
E vamos começar nossa expressão with
como exemplo, criando um Student
, mas armazenando-o em uma variável Person
:
Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() }; otherPerson = person with { LastName = "Hanselman" };
No ponto dessa expressão with
na última linha, o compilador não tem ideia de que person
realmente contém a Student
. No entanto, a nova pessoa não seria uma cópia adequada se não fosse realmente um objeto Student
, completo com o mesmo ID
que o primeiro copiado.
O C# faz esse trabalho. Os registros têm um método virtual oculto ao qual é confiada a “clonagem” de todo o objeto. Todo tipo de registro derivado substitui esse método para chamar o construtor de cópia desse tipo e o construtor de cópia de uma cadeia de registros derivada para o construtor de cópia do registro base. Uma expressão with
simplesmente chama o método “clone” oculto e aplica o inicializador de objeto ao resultado.
Nesse momento, espero que você estude e se aprofunde bastante nestas funcionalidades antes de utilizá-las largamente em seus códigos. Pois elas executam “mágicas ocultas” que exige do programador saber como isso funciona por baixo dos panos. A funcionalidade é sensacional, mas fica aqui meu ponto de atenção.
Igualdade e herança baseadas em valor
Da mesma forma que o suporte à expressão with
, a igualdade baseada em valor também deve ser “virtual”, no sentido de que para construir Student
é necessário comparar todos os campos Student
, mesmo que o tipo estaticamente conhecido no ponto de comparação seja semelhante ao tipo base Person
. Isso é facilmente alcançado substituindo o já virtual método Equals
.
No entanto, há um desafio adicional com a igualdade: e se você comparar dois tipos diferentes de Person
? Não podemos realmente deixar que um deles decida qual igualdade aplicar: a igualdade deve ser simétrica; portanto, o resultado deve ser o mesmo, independentemente de qual dos dois objetos venha primeiro. Em outras palavras, eles precisam concordar com a igualdade que está sendo aplicada!
Um exemplo para ilustrar o problema:
Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" }; Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
Os dois objetos são iguais um ao outro? person1
pode pensar que sim, já que person2
tem todas as coisas certas de Person
mas person2
imploraria para diferir! Precisamos ter certeza de que ambos concordam que são objetos diferentes.
Mais uma vez, o C# cuida disso automaticamente. A maneira como isso é feito é que os registros têm uma propriedade protegida virtual chamada EqualityContract
. Todo registro derivado o substitui e, para comparar igual, os dois objetos devem ter o mesmo EqualityContract
.
Programas de nível superior
Escrever um programa simples em C# requer uma quantidade notável de código padrão:
using System; class Program { static void Main() { Console.WriteLine("Hello World!"); } }
Isso não é apenas impressionante para iniciantes em idiomas, mas desorganiza o código e adiciona níveis de indentação.
No C # 9.0, você pode optar por escrever seu programa principal no nível superior:
using System; Console.WriteLine("Hello World!");
Qualquer declaração é permitida. O programa deve ocorrer após as using
e antes de qualquer declaração de tipo ou espaço para nome no arquivo, e você só pode fazer isso em um arquivo, assim como só pode ter um método Main
hoje.
Se você deseja retornar um código de status, pode fazê-lo. Se você quiser fazer as chamadas com await
, pode fazer isso também. E se você deseja acessar argumentos de linha de comando, args
está disponível como um parâmetro “mágico”.
As funções locais são uma forma de declaração e também são permitidas no programa de nível superior. É um erro chamá-los de qualquer lugar fora da seção de instruções de nível superior.
Correspondência de padrões aprimorada
Vários novos tipos de padrões foram adicionados no C# 9.0. Vamos vê-los no contexto deste trecho de código do tutorial de correspondência de padrões :
public static decimal CalculateToll(object vehicle) => vehicle switch { ... DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m, DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m, DeliveryTruck _ => 10.00m, _ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle)) };
Padrões de tipo simples
Atualmente, um padrão de tipo precisa declarar um identificador quando o tipo corresponder – mesmo que esse identificador seja um descarte _
, como DeliveryTruck _
acima. Mas agora você pode apenas escrever o tipo:
DeliveryTruck => 10.00m,
Padrões relacionais
O C# 9.0 apresenta padrões correspondentes aos operadores relacionais <
, <=
e assim por diante. Agora você pode escrever a parte do padrão acima DeliveryTruck
como uma expressão de chave aninhada:
DeliveryTruck t when t.GrossWeightClass switch { > 5000 => 10.00m + 5.00m, < 3000 => 10.00m - 2.00m, _ => 10.00m, },
Aqui e são padrões relacionais.> 5000
< 3000
Padrões lógicos
Finalmente, você pode combinar padrões com operadores lógicos and
, or
e not
, enunciados como palavras para evitar confusão com os operadores usados em expressões. Por exemplo, os casos da opção aninhada acima podem ser colocados em ordem crescente assim:
DeliveryTruck t when t.GrossWeightClass switch { < 3000 => 10.00m - 2.00m, >= 3000 and <= 5000 => 10.00m, > 5000 => 10.00m + 5.00m, },
O caso intermediário usa and
para combinar dois padrões relacionais e formar um padrão representando um intervalo.
Um uso comum do padrão not
serão aplicá-lo para o padrão constante null
, ou not null
.Por exemplo, podemos dividir o tratamento de casos desconhecidos, dependendo de serem nulos:
not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)), null => throw new ArgumentNullException(nameof(vehicle))
Também not
será conveniente nas condições if contendo expressões is onde, em vez de parênteses duplos que normalmente são confusos:
// O que era assim: if (!(e is Customer)) { ... } //Você pode apenas dizer: if (e is not Customer) { ... }
Digitação de destino aprimorada
“Digitação de destino” é um termo usado para quando uma expressão obtém seu tipo no contexto em que está sendo usada. Por exemplo, null
as expressões lambda são sempre digitadas no destino.
No C# 9.0, algumas expressões que não foram previamente digitadas no destino podem ser guiadas pelo contexto.
Digitação de destino para novas expressões
Expressões new
em C# sempre exigiram que um tipo fosse especificado (exceto expressões de matriz com tipo implícito). Agora você pode deixar de fora o tipo se houver um tipo claro ao qual as expressões estão sendo atribuídas.
Point p = new (3, 5);
Digitação de destino ??
e?:
Às vezes, expressões condicionais ??
e ?:
não têm um tipo compartilhado óbvio entre os ramos. Esses casos falham hoje, mas o C# 9.0 os permitirá se houver um tipo de destino para o qual os dois ramos se convertam:
Person person = student ?? customer; // Shared base type int? result = b ? 0 : null; // nullable value type
Retornos covariantes
Às vezes, é útil expressar que uma sobrecarga de método em uma classe derivada tem um tipo de retorno mais específico que a declaração no tipo base. O C # 9.0 permite que:
abstract class Animal { public abstract Food GetFood(); ... } class Tiger : Animal { public override Meat GetFood() => ...; }
E muito mais…
O melhor lugar para verificar o conjunto completo de recursos futuros do C# 9.0 e seguir a conclusão deles é o Status de lançamentos da linguagem no repositório do GitHub do Roslyn (compilador de C# / VB).
Por isso é “só”!
O C# 9.0 nos trás muitas novidades e com certeza irei me aprofundar em algumas delas para trazer mais conteúdo aqui no blog.
Um grande abraço!