Nullable reference types e sicurezza sui null in C#

Nullable reference types e sicurezza sui null in C#

Apriamo la terza serie, di livello avanzato, affrontando uno dei problemi più diffusi nello sviluppo software: i riferimenti nulli. L'eccezione NullReferenceException è stata a lungo la compagna indesiderata di ogni programmatore C#, tanto che il suo inventore l'ha definita il proprio "errore da un miliardo di dollari". Le versioni recenti del linguaggio introducono i nullable reference types, una funzionalità che sposta il controllo dei nulli in fase di compilazione, aiutando a scrivere codice più robusto. In questo articolo vediamo come funziona e come sfruttarlo.

Il problema dei riferimenti nulli

Come abbiamo visto nella prima serie, i tipi riferimento possono contenere il valore null, che rappresenta l'assenza di un oggetto. Tentare di accedere a un membro di un riferimento nullo provoca un'eccezione a runtime, spesso in un punto lontano da dove il valore è effettivamente diventato nullo, rendendo il problema difficile da individuare.

static int GetLength(string text)
{
    // Se text e null, questa riga solleva NullReferenceException
    return text.Length;
}

GetLength(null); // il compilatore tradizionale non avvisa

Abilitare il contesto nullable

La funzionalità dei nullable reference types va attivata esplicitamente, tipicamente a livello di progetto nel file .csproj. Una volta abilitata, il compilatore assume che tutti i tipi riferimento siano non nulli per impostazione predefinita, segnalando con degli avvisi ogni potenziale violazione.

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

In alternativa si può controllare il contesto a livello di singolo file con le direttive #nullable enable e #nullable disable, utili durante la migrazione graduale di una base di codice esistente.

Tipi non nulli e tipi nullable

Con il contesto abilitato, la distinzione diventa esplicita nella sintassi. Un tipo riferimento scritto normalmente è considerato non nullo, mentre l'aggiunta del punto interrogativo lo dichiara potenzialmente nullo, esattamente come già avveniva per i tipi valore.

// Non nullo: il compilatore si aspetta sempre un valore valido
string name = "Gabriele";

// Nullable: puo essere null, e il compilatore lo sa
string? middleName = null;

// Questa assegnazione genera un avviso: null a un tipo non nullo
string invalid = null; // AVVISO del compilatore

Il flow analysis del compilatore

Il vero valore aggiunto è l'analisi del flusso: il compilatore tiene traccia di quando un riferimento nullable è stato verificato e considera sicuro l'accesso solo dopo un controllo esplicito. All'interno di un blocco in cui il valore è stato accertato non nullo, gli avvisi scompaiono.

static int SafeLength(string? text)
{
    // Prima del controllo, accedere a text.Length darebbe un avviso
    if (text == null)
    {
        return 0;
    }

    // Qui il compilatore sa che text non e nullo: nessun avviso
    return text.Length;
}

Gli operatori per i nulli

Gli operatori visti nella prima serie si integrano perfettamente con questa analisi. L'operatore di accesso condizionale ?. e quello di coalescenza ?? permettono di gestire i valori nullable in modo conciso e sicuro, e il compilatore ne comprende la semantica.

string? input = GetUserInput();

// Accesso sicuro: length e int? (null se input e null)
int? length = input?.Length;

// Valore predefinito se input e nullo
string safeInput = input ?? "valore predefinito";

// Assegnazione condizionata: assegna solo se attualmente nullo
input ??= "default";

L'operatore null-forgiving

In alcune situazioni il programmatore sa con certezza che un valore non è nullo, anche se il compilatore non riesce a dedurlo. L'operatore null-forgiving, un punto esclamativo posposto all'espressione, sopprime l'avviso affermando la propria conoscenza. Va usato con parsimonia, perché aggira il controllo e reintroduce il rischio che si voleva eliminare.

string? maybeNull = GetValue();

// Si afferma al compilatore che il valore non e nullo
int length = maybeNull!.Length;

Annotazioni nelle firme dei metodi

I nullable reference types rendono le firme dei metodi più espressive: diventa chiaro a colpo d'occhio se un parametro può essere nullo e se un metodo può restituire un valore nullo. Questo funge da documentazione verificata dal compilatore, molto più affidabile di un commento.

// La firma comunica: accetta una chiave non nulla, puo restituire null
static string? FindValue(Dictionary<string, string> source, string key)
{
    if (source.TryGetValue(key, out string? value))
    {
        return value;
    }
    return null;
}

Inizializzare i campi non nulli

Una classe con proprietà non nullable deve garantire che vengano inizializzate, altrimenti il compilatore avvisa. Le strategie comuni sono l'inizializzazione nel costruttore, l'uso della parola chiave required che obbliga il chiamante a fornire un valore, oppure l'assegnazione di un valore predefinito.

class User
{
    // required obbliga a valorizzare la proprieta alla costruzione
    public required string Name { get; init; }

    // Inizializzata con un valore predefinito
    public string Role { get; set; } = "guest";

    // Esplicitamente nullable: puo restare non valorizzata
    public string? Bio { get; set; }
}

var user = new User { Name = "Gabriele" };

Un approccio graduale

Adottare i nullable reference types in un progetto esistente non deve essere un'operazione tutto-o-niente. Grazie al controllo per file e alla natura di avviso anziché di errore, è possibile abilitare la funzionalità progressivamente, risolvendo gli avvisi un modulo alla volta. Nei nuovi progetti, invece, conviene attivarla fin dall'inizio per beneficiarne appieno.

Conclusione

Abbiamo visto come i nullable reference types spostino il controllo dei riferimenti nulli dal runtime alla compilazione, riducendo drasticamente il rischio di NullReferenceException. Abbiamo imparato a distinguere tra tipi non nulli e nullable, a sfruttare l'analisi del flusso del compilatore, a usare gli operatori dedicati e l'operatore null-forgiving con cautela, e a rendere le firme dei metodi autoesplicative. Nel prossimo articolo esploreremo i metodi di estensione, il meccanismo che permette di aggiungere funzionalità a tipi esistenti.