Tutorial: Expressar sua intenção de design mais claramente com tipos de referência que permitem valor nulo e tipos que não permitem valor nulo

Tipos de referência que permitem valor nulo complementam os tipos de referência da mesma maneira que os tipos de valor que permitem valor nulo complementam os tipos de valor. Para declarar que uma variável é um tipo de referência que permite valor nulo, anexe um ? ao tipo. Por exemplo, string? representa uma string que permite valor nulo. Você pode usar esses novos tipos para expressar mais claramente sua intenção de design: algumas variáveis devem sempre ter um valor , outras podem ter um valor ausente.

Neste tutorial, você aprenderá como:

  • Incorporar tipos de referência que permitem valores nulos e tipos de referência que não permitem valores nulos aos designs
  • Habilitar verificações de tipo de referência que permitem valor nulo em todo o código.
  • Gravar código em locais onde o compilador imponha essas decisões de design.
  • Usar o recurso de referência que permite valor nulo em seus próprios designs

Pré-requisitos

Você precisará configurar o computador para executar o .NET, incluindo o compilador C#. O compilador C# está disponível com o Visual Studio 2022 ou o SDK do .NET.

Este tutorial pressupõe que você esteja familiarizado com o C# e .NET, incluindo o Visual Studio ou a CLI do .NET.

Incorporar tipos de referência que permitem valor nulo aos designs

Neste tutorial, você criará uma biblioteca para modelar a executar uma pesquisa. O código usa tipos de referência que permitem valores nulos e tipos de referência que não permitem valores nulos para representar os conceitos do mundo real. As perguntas da pesquisa nunca podem ser um valor nulo. Um entrevistado pode optar por não responder a uma pergunta. As respostas podem ser null nesse caso.

O código que você gravará para este exemplo expressa essa intenção e o compilador a aplica.

Criar o aplicativo e habilitar os tipos de referência que permitem valor nulo

Crie um novo aplicativo de console no Visual Studio ou na linha de comando usando dotnet new console. Dê o nome NullableIntroduction ao aplicativo. Depois de criar o aplicativo, você precisará especificar que todo o projeto é compilado em um contexto de anotação anulável habilitado. Abra o arquivo .csproj e adicione um elemento Nullable ao elemento PropertyGroup. Defina seu valor como enable. Você precisa aceitar o recurso de tipos de referência que permitem valor nulo em projetos anteriores ao C# 11. Isso porque, quando o recurso é ativado, as declarações de variáveis de referência existentes tornam-se tipos de referência que não permitem valor nulo. Embora essa decisão auxilie na localização de problemas em que o código existente pode não ter verificações de valores nulos adequadas, ela pode não refletir com precisão a intenção original do design:

<Nullable>enable</Nullable>

Antes do .NET 6, novos projetos não incluem o elemento Nullable. Do .NET 6 em diante, os novos projetos incluem o elemento <Nullable>enable</Nullable> no arquivo de projeto.

Criar os tipos para o aplicativo

Este aplicativo de pesquisa requer a criação de várias classes:

  • Uma classe que modela a lista de perguntas.
  • Uma classe que modela uma lista de pessoas contatadas para a pesquisa.
  • Uma classe que modela as respostas de uma pessoa que participou da pesquisa.

Esses tipos usarão os tipos de referência que permitem valor nulo e tipos de referência que não permitem valor nulo para expressar quais membros são obrigatórios e quais são opcionais. Os tipos de referência que permitem valor nulo informam claramente essa intenção de design:

  • As perguntas que fazem parte da pesquisa nunca podem ser valores nulos: não faz sentido fazer uma pergunta vazia.
  • Os entrevistados nunca poderão ser nulos. Convém controlar as pessoas contatadas, mesmo os entrevistados que se recusaram a participar.
  • Qualquer resposta a uma pergunta pode ser um valor nulo. Os entrevistados podem se recusar a responder a algumas ou a todas as perguntas.

Se já tiver programado em C#, pode estar tão acostumado a fazer referência a tipos que permitem valores null que poderá ter perdido outras oportunidades de declarar instâncias não anuláveis:

  • O conjunto de perguntas não deve permitir um valor nulo.
  • O conjunto de entrevistados não deve permitir um valor nulo.

Ao escrever o código, você verá que um tipo de referência não anulável como o padrão para referências evita erros comuns que poderiam levar a NullReferenceExceptions. Uma das lições retirada deste tutorial é que você tomou decisões sobre quais variáveis poderiam ou não ser null. O idioma não forneceu sintaxe para expressar essas decisões. Agora ele já fornece.

O aplicativo que você criará executa as seguintes etapas:

  1. Cria uma pesquisa e adiciona perguntas a ela.
  2. Cria um conjunto pseudo aleatório de entrevistados para a pesquisa.
  3. Entre em contato com os entrevistados até que o tamanho da pesquisa preenchida atinja o número da meta.
  4. Grava estatísticas importantes nas respostas da pesquisa.

Criar a pesquisa com tipos de referência anuláveis e não anuláveis

O primeiro código gravado criará a pesquisa. Você escreverá classes para modelar uma pergunta da pesquisa e uma execução da pesquisa. A pesquisa tem três tipos de perguntas, diferenciadas pelo formato da resposta: respostas do tipo Sim/Não, respostas com números e respostas com texto. Crie uma classe public SurveyQuestion:

namespace NullableIntroduction
{
    public class SurveyQuestion
    {
    }
}

O compilador interpreta cada declaração de variável de tipo de referência como um tipo de referência não anulável para o código em um contexto de anotação anulável habilitado. Para ver seu primeiro aviso, adicione propriedades ao texto da pergunta e tipo de pergunta, conforme mostrado no código a seguir:

namespace NullableIntroduction
{
    public enum QuestionType
    {
        YesNo,
        Number,
        Text
    }

    public class SurveyQuestion
    {
        public string QuestionText { get; }
        public QuestionType TypeOfQuestion { get; }
    }
}

Como você não inicializou QuestionText, o compilador emitirá um aviso informando que uma propriedade que não permite valor nulo não foi inicializada. Seu design exige que o texto da pergunta não seja um valor nulo, portanto, você inclui um construtor para inicializá-lo e o valor QuestionType também. A definição da classe concluída se parece com o código a seguir:

namespace NullableIntroduction;

public enum QuestionType
{
    YesNo,
    Number,
    Text
}

public class SurveyQuestion
{
    public string QuestionText { get; }
    public QuestionType TypeOfQuestion { get; }

    public SurveyQuestion(QuestionType typeOfQuestion, string text) =>
        (TypeOfQuestion, QuestionText) = (typeOfQuestion, text);
}

A adição do construtor removerá o aviso. O argumento do construtor também é um tipo de referência que não permite valor nulo, portanto, o compilador não emite avisos.

Em seguida, crie uma classe public chamada SurveyRun. Esta classe contém uma lista de métodos e objetos SurveyQuestion para adicionar perguntas à pesquisa, conforme mostrado no código a seguir:

using System.Collections.Generic;

namespace NullableIntroduction
{
    public class SurveyRun
    {
        private List<SurveyQuestion> surveyQuestions = new List<SurveyQuestion>();

        public void AddQuestion(QuestionType type, string question) =>
            AddQuestion(new SurveyQuestion(type, question));
        public void AddQuestion(SurveyQuestion surveyQuestion) => surveyQuestions.Add(surveyQuestion);
    }
}

Como foi feito anteriormente, você deve inicializar o objeto de lista com um valor não nulo ou o compilador emitirá um aviso. Não há verificações de valores nulos na segunda sobrecarga de AddQuestion, pois elas são desnecessárias: você declarou que a variável não permite valor nulo. Seu valor não pode ser null.

Alterne para Program.cs em seu editor e substitua o conteúdo de Main pelas seguintes linhas de código:

var surveyRun = new SurveyRun();
surveyRun.AddQuestion(QuestionType.YesNo, "Has your code ever thrown a NullReferenceException?");
surveyRun.AddQuestion(new SurveyQuestion(QuestionType.Number, "How many times (to the nearest 100) has that happened?"));
surveyRun.AddQuestion(QuestionType.Text, "What is your favorite color?");

Como o projeto inteiro está em um contexto de anotação anulável habilitado, você receberá avisos quando passar null para qualquer método que espera um tipo de referência não anulável. Experimente adicionar a seguinte linha a Main:

surveyRun.AddQuestion(QuestionType.Text, default);

Criar entrevistados e obter respostas para a pesquisa

Em seguida, grave o código que gerará respostas para a pesquisa. Esse processo envolve várias tarefas pequenas:

  1. Criar um método para gerar objetos dos entrevistados. Eles representam pessoas solicitadas a preencher a pesquisa.
  2. Criar lógica para simular a realização de perguntas para um pesquisado e coletar respostas ou perceber que um pesquisado não respondeu.
  3. Repetir até que entrevistados suficientes tenham respondido à pesquisa.

Será necessária uma classe para representar uma resposta da pesquisa. Adicione-a agora. Habilitar o suporte para tipos que permitem valor nulo. Adicione uma propriedade Id e um construtor para inicializá-la, conforme mostrado no código a seguir:

namespace NullableIntroduction
{
    public class SurveyResponse
    {
        public int Id { get; }

        public SurveyResponse(int id) => Id = id;
    }
}

Em seguida, adicione um método static para criar novos participantes ao gerar uma ID aleatória:

private static readonly Random randomGenerator = new Random();
public static SurveyResponse GetRandomId() => new SurveyResponse(randomGenerator.Next());

A principal responsabilidade dessa classe é gerar as respostas de um participante para as perguntas da pesquisa. Essa responsabilidade conta com algumas etapas:

  1. Peça para participar da pesquisa. Se a pessoa não consentir, retorne uma resposta de ausente (ou de valor nulo).
  2. Faça as perguntas e registre a resposta. As respostas também pode ser ausentes (ou de valor nulo).

Adicione o seguinte código à classe SurveyResponse:

private Dictionary<int, string>? surveyResponses;
public bool AnswerSurvey(IEnumerable<SurveyQuestion> questions)
{
    if (ConsentToSurvey())
    {
        surveyResponses = new Dictionary<int, string>();
        int index = 0;
        foreach (var question in questions)
        {
            var answer = GenerateAnswer(question);
            if (answer != null)
            {
                surveyResponses.Add(index, answer);
            }
            index++;
        }
    }
    return surveyResponses != null;
}

private bool ConsentToSurvey() => randomGenerator.Next(0, 2) == 1;

private string? GenerateAnswer(SurveyQuestion question)
{
    switch (question.TypeOfQuestion)
    {
        case QuestionType.YesNo:
            int n = randomGenerator.Next(-1, 2);
            return (n == -1) ? default : (n == 0) ? "No" : "Yes";
        case QuestionType.Number:
            n = randomGenerator.Next(-30, 101);
            return (n < 0) ? default : n.ToString();
        case QuestionType.Text:
        default:
            switch (randomGenerator.Next(0, 5))
            {
                case 0:
                    return default;
                case 1:
                    return "Red";
                case 2:
                    return "Green";
                case 3:
                    return "Blue";
            }
            return "Red. No, Green. Wait.. Blue... AAARGGGGGHHH!";
    }
}

O armazenamento das respostas da pesquisa é um Dictionary<int, string>?, indicando que ele pode ser um valor nulo. Você está usando o novo recurso de idioma para declarar sua intenção de design, tanto para o compilador quanto para qualquer pessoa que leia seu código posteriormente. Se, em algum momento, você desreferenciar surveyResponses sem primeiro verificar o valor de null, será gerado um aviso do compilador. Você não receberá um aviso no método AnswerSurvey porque o compilador pode determinar que a variável surveyResponses foi definida como um valor não nulo acima.

O uso de null para respostas ausentes destaca um ponto importante para trabalhar com tipos de referência anuláveis: seu objetivo não é remover todos os valores null de seu programa. Em vez disso, sua meta é garantir que o código escrito expresse a intenção do seu design. Os valores ausentes representam um conceito que precisa ser expresso em seu código. O valor null é uma forma clara de expressar esses valores ausentes. Tentar remover todos os valores null leva somente à definição de alguma outra maneira de expressar esses valores ausentes sem null.

Em seguida, é necessário gravar o método PerformSurvey na classe SurveyRun. Adicione o seguinte código à classe SurveyRun:

private List<SurveyResponse>? respondents;
public void PerformSurvey(int numberOfRespondents)
{
    int respondentsConsenting = 0;
    respondents = new List<SurveyResponse>();
    while (respondentsConsenting < numberOfRespondents)
    {
        var respondent = SurveyResponse.GetRandomId();
        if (respondent.AnswerSurvey(surveyQuestions))
            respondentsConsenting++;
        respondents.Add(respondent);
    }
}

Aqui, novamente, sua opção por uma List<SurveyResponse>? que permite valor nulo indica que a resposta pode ser um valor nulo. Isso indica que a pesquisa ainda não foi entregue a nenhum pesquisado. Observe que os entrevistados são adicionados até que um suficiente de pessoas tiver consentido.

A última etapa para executar a pesquisa é adicionar uma chamada para executar a pesquisa no final do método Main:

surveyRun.PerformSurvey(50);

Examinar as respostas da pesquisa

A última etapa é exibir os resultados da pesquisa. Você adicionará código a várias classes gravadas. Este código demonstra o valor da distinção dos tipos de referência que permitem valor nulo e tipos de referência que não permitem valor nulo. Comece adicionando os dois membros com corpo de expressão à classe SurveyResponse:

public bool AnsweredSurvey => surveyResponses != null;
public string Answer(int index) => surveyResponses?.GetValueOrDefault(index) ?? "No answer";

Como surveyResponses é um tipo de referência anulável, verificações de nulo são necessárias antes de desreferenciá-la. O método Answer retorna uma cadeia de caracteres não anulável, portanto, precisamos cobrir o caso de ausência de resposta usando o operador de avaliação de nulo.

Em seguida, adicione esses três membros com corpo de expressão à classe SurveyRun:

public IEnumerable<SurveyResponse> AllParticipants => (respondents ?? Enumerable.Empty<SurveyResponse>());
public ICollection<SurveyQuestion> Questions => surveyQuestions;
public SurveyQuestion GetQuestion(int index) => surveyQuestions[index];

O membro AllParticipants deve levar em conta que a variável respondents pode ser um valor nulo, mas o valor de retorno não pode ser nulo. Se você alterar essa expressão removendo ?? e a sequência vazia que se segue, o compilador avisará que o método poderá retornar null e sua assinatura de retorno retornará um tipo que não permite valor nulo.

Por fim, adicione o seguinte loop à parte inferior do método Main:

foreach (var participant in surveyRun.AllParticipants)
{
    Console.WriteLine($"Participant: {participant.Id}:");
    if (participant.AnsweredSurvey)
    {
        for (int i = 0; i < surveyRun.Questions.Count; i++)
        {
            var answer = participant.Answer(i);
            Console.WriteLine($"\t{surveyRun.GetQuestion(i).QuestionText} : {answer}");
        }
    }
    else
    {
        Console.WriteLine("\tNo responses");
    }
}

Você não precisa de verificações de null neste código porque criou as interfaces subjacentes para que todas elas retornem tipos de referência que não permitem valor nulo.

Obter o código

Obtenha o código do tutorial concluído em nosso repositório de amostras na pasta csharp/NullableIntroduction.

Experimente alterar as declarações de tipo entre os tipos de referência que permitem valor nulo e tipos de referência que não permitem valor nulo. Veja como isso gera avisos diferentes para garantir que um null não será acidentalmente cancelado.

Próximas etapas

Saiba como usar o tipo de referência anulável ao trabalhar com o Entity Framework: