Tutorial: Expresse a sua intenção de design com tipos de referência anuláveis e não anuláveis

Tip

Novo em tipos de referência anuláveis? Leia primeiro tipos de referência anuláveis. Este tutorial assume que compreendes a diferença entre tipos de referência não anuláveis e nuláveis e como o compilador acompanha o estado nulo.

Vem de outra língua? Se já usou os tipos anuláveis de Kotlin, strictNullChecks do TypeScript ou os opcionais de Swift, o modelo conceptual mapeia diretamente. O exercício aqui é sobre expressar a intenção de design, não sobre aprender a sintaxe.

Neste tutorial, constróis uma pequena biblioteca que modela a execução de um inquérito. Os dados apresentam dois padrões distintos que os tipos de referência anuláveis permitem distinguir:

  • Uma pergunta de inquérito deve estar sempre presente. A lista de perguntas e o texto de cada uma nunca poderão ser null.
  • Pode faltar uma resposta a uma pergunta. Os inquiridos podem recusar-se a responder a algumas ou a todas as perguntas, e o modelo deve tornar isso explícito.

Declaras essas regras com tipos de referência não anuláveis e nuláveis. O compilador avisa então sempre que o comportamento do código não corresponde ao design.

Neste tutorial, você:

  • Crie a aplicação.
  • Constrói as perguntas do inquérito.
  • Crie um inquérito de perguntas.
  • Teste o requisito de valor não nulo.
  • Constrói tipos de resposta.
  • Crie inquiridos.
  • Gera uma resposta ao inquérito.
  • Constrói um conjunto de respostas a inquéritos.
  • Analise os resultados do inquérito.

Três classes modelam o inquérito:

  • SurveyQuestion: Uma pergunta. O texto e o tipo de pergunta são obrigatórios.
  • SurveyRun: a coleção de perguntas mais a lista de respondentes.
  • SurveyResponse: respostas de um dos inquiridos, que podem estar em falta.

Cada tipo usa tipos de referência não anuláveis para valores necessários e tipos de referência anuláveis para valores em falta.

Pré-requisitos

Este tutorial assume que estás familiarizado com C# e quer com o Visual Studio ou a CLI .NET.

Criar o aplicativo e habilitar tipos de referência anuláveis

Crie uma nova aplicação de consola chamada NullableIntroduction:

dotnet new console -n NullableIntroduction
cd NullableIntroduction

Construir as perguntas do inquérito

Adicione um novo ficheiro nomeado SurveyQuestion.cs ao projeto e substitua o seu conteúdo pelo seguinte código. O texto e o tipo de pergunta não são anuláveis, pelo que o construtor deve inicializar ambos:

namespace NullableIntroduction;

public enum QuestionType
{
    YesNo,
    Number,
    Text
}

public class SurveyQuestion(QuestionType typeOfQuestion, string text)
{
    public string QuestionText { get; } = text;
    public QuestionType TypeOfQuestion { get; } = typeOfQuestion;
}

Os parâmetros do construtor são tipos de referência não anuláveis, pelo que o compilador avisa o chamador se algum dos argumentos pode ser null.

Crie um levantamento de perguntas

De seguida, adicione um novo ficheiro nomeado SurveyRun.cs ao projeto e defina uma SurveyRun classe para conter a lista de perguntas:

namespace NullableIntroduction;

public class SurveyRun
{
    private List<SurveyQuestion> surveyQuestions = [];

    public void AddQuestion(QuestionType type, string question) =>
        AddQuestion(new SurveyQuestion(type, question));

    public void AddQuestion(SurveyQuestion surveyQuestion) =>
        surveyQuestions.Add(surveyQuestion);
}

O campo surveyQuestions é um List<SurveyQuestion> não anulável. Utiliza uma expressão de coleção para inicializar uma lista vazia. Ambas AddQuestion as sobrecargas aceitam parâmetros não anuláveis, pelo que o compilador impõe que os chamadores não passem null.

Em Program.cs, crie um SurveyRun e adicione três perguntas:

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?");

Teste a restrição de valor não nulo

Para ver como o compilador aplica parâmetros não anuláveis, tente adicionar a linha seguinte e reconstruir:

surveyRun.AddQuestion(QuestionType.Text, default);

O compilador emite o aviso CS8625 porque default é avaliado como null para um tipo de referência, e AddQuestion espera um string não anulável. Remova a linha antes de continuar.

Tipos de resposta de build

Os inquiridos podem recusar responder ao inquérito e, mesmo quando participam, podem saltar perguntas individuais. Ambas as formas de "faltar" são resultados válidos, e o sistema de tipos deve torná-los visíveis. Representas ambas as formas com null.

Adicione um novo ficheiro com nome SurveyResponse.cs ao projeto e defina uma SurveyResponse classe. Utilize um construtor primário (parâmetros declarados no próprio tipo, disponíveis em todo o corpo) para capturar o Id sempre obrigatório:

namespace NullableIntroduction;

public class SurveyResponse(int id)
{
    public int Id { get; } = id;
}

Criar inquiridos

Adicione um método de fábrica estática (um static método que cria e devolve uma nova instância do tipo, uma alternativa a chamar diretamente o construtor) que crie respondentes com um ID aleatório:

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

Gerar uma resposta ao inquérito

De seguida, adicione o método que faz o inquérito ao respondente. Armazene as respostas num dicionário anulável para que o próprio tipo indique que o respondente pode recusar-se a responder:

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 surveyResponses campo é Dictionary<int, string>?. Se desreferenciar o campo sem primeiro verificar se é null, o compilador emite um aviso. Dentro de AnswerSurvey, o compilador controla que surveyResponsesnão é nulo imediatamente após a expressão new, pelo que o corpo do ciclo não precisa de nenhuma verificação adicional.

Constrói um conjunto de respostas ao inquérito

Adicione um método SurveyRun que constrói uma lista de respondentes até haver consentimento suficiente para participar:

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

O respondents campo é List<SurveyResponse>? - é null até o levantamento começar.

Chamada PerformSurvey de Main:

surveyRun.PerformSurvey(50);

Analise os resultados do inquérito

Para comunicar os resultados, exponha algumas funções auxiliares a partir de SurveyResponse e SurveyRun. Em SurveyResponse, adicione membros com corpo de expressão (membros definidos com => e uma única expressão em vez de um bloco { ... }) que processam o dicionário anulável:

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

AnsweredSurvey verifica o campo contra null. Answer usa o ?. operador nulo-condicional (que avalia para null quando o lado esquerdo está null em vez de lançar) para desreferenciar em segurança, e o ?? operador nulo de coalescência (que substitui o operando direito quando o esquerdo é null) para fornecer um recuo não nulo. O tipo de retorno do método não é nullável string, por isso os chamadores não precisam de verificações nulas.

Em SurveyRun, adicione membros com corpo de expressão que expõem a lista de participantes e perguntas:

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

AllParticipants devolve uma sequência não anulável mesmo que respondents possa ser null. O ?? operador substitui Enumerable.Empty<SurveyResponse>() quando o campo ainda não está preenchido. Se remover a ?? cláusula, o compilador avisa que o método pode devolver null apesar de um tipo de retorno não anulável.

Finalmente, escreva o relatório no final de 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");
    }
}

Note que não é necessário verificar o valor nulo para participant, surveyRun.Questions, ou surveyRun.GetQuestion(i). Os tipos declaram esses valores como não nulos, pelo que o compilador os trata como não-nulos ao longo do ciclo.

Execute a aplicação:

dotnet run

A saída é diferente em cada execução porque os respondentes são gerados aleatoriamente, mas cada linha ou reporta as respostas de um participante ou regista que ele recusou.

Conclusion

A amostra finalizada encontra-se na pasta csharp/NullableIntroduction do repositório dotnet/samples . Experimente mudando os tipos entre nulos e não nulos. Remover um ? onde o design permite valores em falta produz avisos do compilador que apontam para todos os locais onde o valor em falta importa.