Tutorial: Esprimi l'intento di progettazione con tipi di riferimento che ammettono valori null e che non ammettono valori null

Tip

Non conosci ancora i tipi riferimento nullable? Leggere prima i tipi di riferimento nullable. Questa esercitazione presuppone che tu conosca la differenza tra i tipi di riferimento non annullabili e annullabili e il modo in cui il compilatore tiene traccia dello stato di null.

Venire da un'altra lingua? Se hai usato i tipi annullabili di Kotlin, TypeScript strictNullChecks, o gli optional di Swift, il modello concettuale corrisponde direttamente. L'esercizio è relativo all'espressione della finalità di progettazione, non all'apprendimento della sintassi.

In questa esercitazione si crea una piccola libreria che modella l'esecuzione di un sondaggio. I dati presentano due schemi distinti che i tipi di riferimento nullable consentono di distinguere:

  • Una domanda di sondaggio deve essere sempre presente. L'elenco di domande e il testo di ogni domanda non possono mai essere null.
  • Una risposta a una domanda potrebbe non essere presente. Gli intervistati possono rifiutare di rispondere ad alcune o a tutte le domande e il modello dovrebbe renderlo esplicito.

Queste regole si dichiarano con i tipi di riferimento non-nullable e nullable. Il compilatore avvisa quindi ogni volta che il comportamento del codice non corrisponde alla progettazione.

In questa esercitazione, imparerai a:

  • Creare l'applicazione.
  • Compilare le domande del sondaggio.
  • Creare un sondaggio sulle domande.
  • Testare il requisito not-null.
  • Compilare i tipi di risposta.
  • Creare gli intervistati.
  • Generare una risposta al sondaggio.
  • Creare un set di risposte al sondaggio.
  • Esaminare i risultati del sondaggio.

Tre classi modellano il sondaggio:

  • SurveyQuestion: una domanda. Il testo e il tipo di domanda sono obbligatori.
  • SurveyRun: raccolta di domande più l'elenco degli intervistati.
  • SurveyResponse: le risposte di un intervistato, che potrebbero non essere presenti.

Ogni tipo usa tipi di riferimento non annullabili per i valori richiesti e tipi di riferimento annullabili per i valori mancanti.

Prerequisiti

Questa esercitazione presuppone che si abbia familiarità con C# e Visual Studio o l'interfaccia della riga di comando di .NET.

Creare l'applicazione e abilitare i tipi di riferimento nullable

Creare una nuova applicazione console denominata NullableIntroduction:

dotnet new console -n NullableIntroduction
cd NullableIntroduction

Compilare le domande del sondaggio

Aggiungere un nuovo file denominato SurveyQuestion.cs al progetto e sostituirlo con il codice seguente. Il testo e il tipo di domanda non possono essere null, quindi il costruttore deve inizializzare entrambi:

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;
}

I parametri del costruttore sono tipi di riferimento non annullabili, pertanto il compilatore avvisa il chiamante se uno dei due argomenti potrebbe essere null.

Creare un sondaggio sulle domande

Aggiungere quindi un nuovo file denominato SurveyRun.cs al progetto e definire una SurveyRun classe per contenere l'elenco di domande:

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);
}

Il campo surveyQuestions è un List<SurveyQuestion> che non ammette valori null. Usa un'espressione di raccolta per inizializzare un elenco vuoto. Entrambi gli overload AddQuestion accettano parametri che non ammettono valori null, quindi il compilatore impedisce che chi effettua le chiamate passi null.

In Program.cs, crea SurveyRun e aggiungi tre domande:

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

Verifica il vincolo di non nullo

Per vedere come il compilatore fa rispettare i parametri non nullable, prova ad aggiungere la riga seguente e a ricompilare:

surveyRun.AddQuestion(QuestionType.Text, default);

Il compilatore genera l'avviso CS8625 perché default restituisce null un tipo di riferimento e AddQuestion prevede un valore non nullable string. Rimuovere la riga prima di continuare.

Creare tipi di risposta

Gli intervistati possono rifiutare di partecipare al sondaggio e, anche quando partecipano, possono ignorare le singole domande. Entrambe le forme di "mancante" sono risultati validi e il sistema dei tipi deve renderli visibili. Entrambe le forme vengono espresse con null.

Aggiungere un nuovo file denominato SurveyResponse.cs al progetto e definire una SurveyResponse classe. Usare un costruttore primario (parametri dichiarati nel tipo stesso, disponibile in tutto il corpo) per acquisire il valore sempre richiesto Id:

namespace NullableIntroduction;

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

Creare gli intervistati

Aggiungere un metodo di fabbrica statico (un static metodo che crea e restituisce una nuova istanza del tipo, un'alternativa alla chiamata diretta del costruttore) che crea i rispondenti con un ID casuale:

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

Generare una risposta al sondaggio

Aggiungere quindi il metodo che chiede il sondaggio a un risponditore. Archiviare le risposte in un dizionario nullable in modo che il tipo stesso comunichi che il risponditore potrebbe rifiutare:

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!";
    }
}

Il surveyResponses campo è Dictionary<int, string>?. Se si dereferenzia il campo senza prima verificare la presenza nulldi , il compilatore genera un avviso. All'interno di AnswerSurvey, il compilatore tiene traccia del fatto che surveyResponses sia non nullo subito dopo l'espressione new, quindi il corpo del ciclo non richiede alcun controllo aggiuntivo.

Creare un set di risposte al sondaggio

Aggiungere un metodo su SurveyRun che crea un elenco di intervistati finché un numero sufficiente di persone non acconsente a partecipare:

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);
    }
}

Il campo respondents è List<SurveyResponse>?: è null finché non viene eseguito il sondaggio.

Chiamare PerformSurvey da Main:

surveyRun.PerformSurvey(50);

Esaminare i risultati del sondaggio

Per riportare i risultati, esporre alcune funzioni di supporto da SurveyResponse e SurveyRun. In SurveyResponseaggiungere membri con corpo di espressione (membri definiti con => e una singola espressione anziché un { ... } blocco) che gestiscono il dizionario nullable:

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

AnsweredSurvey controlla il campo rispetto a null. Answerusa l'operatore?. condizionale Null (che restituisce null quando il lato sinistro è null invece di generare) per dereferenziare in modo sicuro e l'operatore?? null-coalescing (che sostituisce l'operando destro quando il lato sinistro è null) per fornire un fallback non Null. Il tipo restituito dal metodo non ammette valori null string, quindi il codice chiamante non deve eseguire controlli di null.

In SurveyRun, aggiungere membri con corpo di espressione che espongono l'elenco dei partecipanti e delle domande:

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

AllParticipants restituisce una sequenza non annullabile anche se respondents potrebbe essere null. L'operatore ?? sostituisce Enumerable.Empty<SurveyResponse>() quando il campo non è ancora popolato. Se si rimuove la clausola ??, il compilatore segnala che il metodo potrebbe restituire null pur avendo un tipo di ritorno che non ammette valori null.

Infine, scrivi il report in fondo a 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");
    }
}

Si noti che non è necessario alcun controllo Null per participant, surveyRun.Questionso surveyRun.GetQuestion(i). I tipi dichiarano tali valori come non ammissibili come null, pertanto il compilatore li considera not-null per l'intero ciclo.

Eseguire l'applicazione:

dotnet run

Il risultato è diverso a ogni esecuzione perché gli intervistati vengono generati casualmente, ma ogni riga riporta le risposte di un partecipante oppure indica che ha rifiutato di rispondere.

Conclusione

L'esempio completato si trova nella cartella csharp/NullableIntroduction del repository dotnet/samples . Sperimentare modificando i tipi tra nullable e non nullable. La rimozione di un ? oggetto in cui la progettazione consente valori mancanti genera avvisi del compilatore che puntano a ogni posizione in cui il valore mancante è importante.