"Cannot read property 'propertyName' of undefined/null."
JavaScript
"Object reference not set to an instance of an object."
C#
"Cannot invoke 'object.method()' because object is null"
Java
Questi sono solo alcuni messaggi di errore di null deferencing (cioè di accesso al contenuto di un puntatore nullo), presi da tre popolari linguaggi di programmazione.
Ma che cos'è il non-valore null?
Cosa cambia tra null e undefined?
Perché esistono questi non-valori?
Ma soprattutto: non se ne poteva proprio fare a meno?
Vediamo insieme come padroneggiare questa spinosa tematica senza pregiudizi e soprattutto, senza farci male.
L'errore da un miliardo di dollari
Anzitutto, un cenno storico: nel 2009, parlando a una conferenza, un autorevole informatico di classe 1934 di nome Tony Hoare, si auto-accusò di aver inventato le null references:
Lo chiamo il mio errore da un miliardo di dollari. Fu l'invenzione della null reference nel 1965. All'epoca stavo progettando il primo type system completo per i puntatori in un linguaggio a oggetti (ALGOL W). Il mio obiettivo era garantire che l'uso dei puntatori fosse sempre assolutamente sicuro, con tutti i controlli fatti automaticamente dal compilatore. Ma non riuscivo a resistere alla tentazione di includere una null reference, semplicemente perché era troppo facile da implementare. Questo ha portato a innumerevoli errori, vulnerabilità e crash di sistema, che hanno probabilmente causato un miliardo di dollari di dolori e danni negli ultimi quarant'anni.
Nel tempo, i programmatori più esperti hanno affinato tecniche per non incorrere in questa categoria molto diffusa di errore; alcuni linguaggi o architetture forniscono soluzioni alternative ai puntatori nulli per indicare che il risultato di un'operazione potrebbe o meno avere un valore sensato.
Spesso gli stessi linguaggi che contemplano l'esistenza del non-valore null, dispongono di strumenti sintattici che ne limitano i danni e, ultimamente, sempre più linguaggi propongono opzioni di compilazione che rendano i controlli più severi riguardo alle variabili che potrebbero assumere valori nulli.
Il senso ossimorico di un valore nullo
In programmazione, non tutto si può fare. Alcune operazioni non hanno semplicemente senso, altre sono intrinsecamente incerte, perché dipendono da un contesto che non può essere controllato. Se per esempio tento di dividere un numero per zero, oppure vado a leggere una proprietà non esistente su un oggetto, o una posizione che supera la lunghezza di un array, diversi linguaggi reagiranno in diversi modi per gestire queste casistiche.
Principalmente, le strategie sono tre:
- L'operazione prevede la restituzione di un risultato speciale
- L'operazione restituisce un valore null o undefined
- L'operazione scatena un'eccezione
Possiamo saggiare tutte queste strategie in JavaScript:
const a = 1 / 0 // Infinity -> Special "number" value
const b = Number('abc') // NaN – Not A Number -> Special "number" value
const c = {}.someProperty // undefined -> a non-value
const e = document.getElementById('non-existing-element') // null -> a non-value
const d = {}.someProperty.someOtherProperty // Uncaught TypeError: Cannot read property 'someOtherProperty' of undefined -> Code broke 🙁
Innanzitutto bisogna notare che, per quanto controintuitivo, NotANumber è a tutti gli effetti un valore del tipo number, al pari di 1, 2, 3, ecc.
Questo significa che in JavaScript il tipo number è sostanzialmente fail-safe, cioè è resiliente rispetto a tutte le operazioni, anche quelle non propriamente valide; NaN e Infinity possono essere usati come numeri in tutte le operazioni, semplicemente influenzandone il risultato di conseguenza; ma non importa cosa chiediamo a JavaScript di calcolare, il risultato sarà, propriamente o impropriamente, un number.
NaN + Infinity + 1 // -> NaN
Infinity + 1 // -> Infinity
-Infinity + 1 // -> -Infinity
-Infinity + Infinity // -> NaN
Riguardo alle altre operazioni, è evidente che sia una scelta del linguaggio, o di chi implementa una funzione, quale strategia adottare per gestire i casi limite. Nulla vieterebbe (in altri linguaggi avviene) che tentando di leggere il quinto elemento di un array lungo 4, si ottenga un errore.
Quindi, una volta appurato che possiamo aspettarci di tutto, vediamo come i veri pro gestiscono questi casi limite.
Regola numero 0 dei casi limite
La regola numero 0 per gestire i casi limite è: Gestisci i casi limite!
Nella maggior parte dei casi, il problema non sta tanto in un'errata gestione di queste situazioni, ma semplicemente nella mancata gestione delle stesse. Quindi il consiglio padre di tutti i consigli è: immediatamente dopo qualsiasi operazione che potrebbe ritornare valori nulli, bisogna eseguire un null-check, cioè assicurarsi che l'operazione abbia avuto un esito sensato prima di utilizzare quell'esito nel resto del codice.
const element = document.getElementById('i-wish-this-exists')
if (!element) {
// let's handle this before someone gets hurt!
}
È chiaro che vale lo stesso anche per i valori speciali o le eccezioni; in questi casi però potremmo stabilire che non ci interessa gestire la cosa, perché "ci fidiamo" della strategia adottata dall'operazione stessa.
Se ad esempio un'operazione può scatenare un'eccezione, potremmo decidere di non intercettarla, perché lo scoppio del programma è esattamente ciò che ci aspettiamo in quel caso; analogamente potremmo considerare ok tutti i valori speciali del tipo number.
L'unica cosa imprescindibile è che ci si ponga la questione; dopodiché non è detto che una strategia fail-safe sia sempre la migliore.
Il motivo per cui invece i null-checks vanno sempre fatti, è che gli errori di null deferencing, a differenza di un'eccezione o di un valore speciale, non sono parlanti, cioè non sono fatti in modo tale da aiutarci a identificarli e risolverli, né sono sicuri, poiché in ogni caso interrompono l'esecuzione in malo modo.
Una buona prassi, in assenza di un valore di ripiego, sarebbe:
const element = document.getElementById('i-wish-this-exists')
if (!element) throw new Error('Expected element "i-wish-this-exists" to exist. Check your HTML template.')
L'avvocato del diavolo
Ma se questi non-valori sono così pericolosi, perché ce li teniamo?
Due verità:
- Invero, spesso non ce li teniamo. Molti linguaggi ne fanno a meno. Molte librerie aiutano a farne a meno, anche nei linguaggi in cui sono previsti
- Dove invece ce li teniamo, dobbiamo ammettere che, con un po' di attenzione, possono essere estremamente versatili
L'idea che una variabile nasca con un default value di undefined che è uguale per tutte le variabili, è in qualche modo una garanzia di uniformità. In JavaScript, esistono addirittura ben due non valori: undefined e null; la convenzione vuole che il primo rappresenti il contenuto di una variabile a cui non è mai stato assegnato un valore, mentre il secondo rappresenti un non-valore intenzionalmente assegnato dal programmatore. Nella realtà dei fatti, undefined e null sono sostanzialmente intercambiabili, e non c'è assolutamente niente all'interno del linguaggio che forzi o indirizzi verso l'una o l'altra soluzione.
Il che ci porta ad una questione importante: i contratti e le aspettative all'interno di un programma. Il grande problema di null sta nel fatto pratico che questo non-valore si comporta in maniera completamente diversa da qualsiasi altro valore.
Ma come possiamo assicurarci di sapere in anticipo (e quindi di poter validare prima dell'esecuzione) se dobbiamo preoccuparci di possibili valori nulli in uscita da un'espressione?
Una parziale soluzione
I linguaggi che non prevedono una gestione forte dei valori nulli, generalmente forniscono delle soluzioni resilienti per gestire variabili che potrebbero avere un valore oppure no.
Null-coalescing operator
Molti linguaggi, tra cui JavaScript, mettono a disposizione un semplice operatore che ci consente di adottare un fallback value:
const element = document.getElementById('i-wish-this-exists') ?? document.body
// is same as
const element = document.getElementById('i-wish-this-exists') != null ? document.getElementById('i-wish-this-exists') : document.body
Prima della comparsa di ??, in JavaScript si era soliti utilizzare in maniera impropria l'operatore ||
const element = document.getElementById('i-wish-this-exists') || document.body
// is same as
const element = document.getElementById('i-wish-this-exists') ? document.body : document.getElementById('i-wish-this-exists')
La sfumatura è lieve e si fonda sul fatto che, in JavaScript, ogni valore può essere considerato come un booleano se necessario, quindi un valore non-nullo è considerato veritiero.
Nella maggior parte dei casi, ?? e || fanno esattamente la stessa cosa; ?? fornisce un elemento di precisione in più, distinguendo tra un valore genericamente falsy e un valore effettivamente nullo.
// 0 is falsy
const n = 0 ?? 1 // -> 0
const n = 0 || 1 // -> 1
Safe navigation operator
Un altro operatore molto utile presente in JavaScript è il cosiddetto operatore di navigazione sicura ?., che punta specificamente a risolvere il problema del null deferencing in una logica fail-safe:
const a = null
const b = a.someProperty // -> Error
const c = b?.someProperty // -> undefined
Questo operatore è molto comodo in tutte quelle situazioni in cui dobbiamo leggere dei valori da un oggetto che potrebbe esserci o meno, ma vogliamo limitarci a leggere i valori solo se presenti.
Prima dell'introduzione di questo operatore, si adottavano soluzioni di questo tipo:
const a = null
const b = a && a.someProperty // -> null
const c = (a || {}).someProperty // -> undefined
I limiti di questo tipo di soluzioni sono essenzialmente due:
- Sono chiaramente un accrocchio, e gli accrocchi sono brutti perché mescolano il comportamento e l'implementazione
- Sono scomodi da concatenare; per esempio, se dovessi andare a leggere il valore che c'è in a?.b?.c?.d senza usare ?., dovrei scrivere qualcosa di molto molto illeggibile
Alternative
Secondo la regola 0 dei casi limite, dobbiamo sempre assicurarci di gestire i casi limite. Ma non c'è niente che ci obblighi a farlo. Perciò, non si potrebbe rendere la gestione dei valori nulli un pochino più strutturale all'interno dei linguaggi di programmazione?
Assolutamente sì.
Può senz'altro esserci utile dare un'occhiata ai vari linguaggi di programmazione che hanno preso più di petto questa problematica.
Tipi opzionali
Nei linguaggi orientati alla programmazione funzionale, abbiamo tipicamente dei type systems molto rigorosi. Quando non è possibile stabilire se il risultato di un'espressione avrà un valore o meno, si usano gli algebraic data types per normare a livello di compilazione quest'incertezza.
Algeché? I tipi algebraici sono tipi che vengono definiti a partire da operazioni simil-insiemistiche.
Possono essere principalmente di due tipi, prendendo in prestito la definizione di TypeScript:
- Tipo intersezione: un tipo che possiede tutte le qualità di più tipi
- Tipo unione: un tipo che possiede le qualità di uno fra più tipi
In Haskell, il tipo Maybe può essere costruito come Just, e in quel caso contenere un valore, oppure come Nothing.
data Maybe a = Just a | Nothing
In TypeScript è possibile realizzare qualcosa di estremamente simile:
type Option<T> = Some<T> | None
In Rust gli union types sono chiamati enum, potenziando il concetto di tipo enumerativo presente in molti altri linguaggi in forma ben più semplificata.
enum Option<T> {
None,
Some(T)
}
Potremmo ricreare un comportamento simile in un qualsiasi linguaggio orientato agli oggetti, usando il polimorfismo:
class Option {
static from(value) {
return value == null ? new None() : new Some(value)
}
isSome() { return this instanceof Some }
isNone() { return this instanceof None }
}
class None extends Option {
extract() { throw new Error('nothing to extract') }
}
class Some extends Option {
constructor(payload) {
super()
this.payload = payload
}
extract() { return this.payload }
}
const a = Option.from(4)
const b = Option.from(null)
console.log(a.isNone()) // -> false
console.log(a.isSome()) // -> true
console.log(b.isNone()) // -> true
console.log(b.isSome()) // -> false
console.log(a.extract()) // -> 4
console.log(b.extract()) // -> Error: nothing to extract
- Possiamo mettere il risultato della nostra espressione in un tipo Option usando Option.from
- Possiamo sapere se un oggetto di tipo Option è Some o None
- Poiché le due classi che estendono Option hanno entrambe il metodo extract(), possiamo chiamare lo stesso metodo per estrarre il payload
- Se chiamiamo Option.extract() senza assicurarci che sia Some, il tipo Option farà il null-checking per noi, scatenando un errore nel caso in cui non ci sia nulla da estrarre
Utile? Forse sì, forse no. In ogni caso, in un linguaggio non compilato saremo destinati a scoprire se abbiamo sbagliato qualcosa solo durante l'esecuzione del programma. Questo approccio dà certamente il meglio di sé quando gran parte di questi controlli possono essere fatti a compile time. L'idea di un type system molto forte è che se il codice compila, il compilato funzionerà.
Regola finale
Per riassumere, cosa distingue i veri buoni a NULL dai più inesperti?
Tanto per cominciare, la consapevolezza che, per la legge di Murphy, qualche caso limite è destinato a verificarsi, e la saggezza che affrontare in modo strutturale i casi limite può risparmiare molte ore di troubleshooting. Chiaramente, questo non è a costo zero, e in alcuni casi essere flessibili e accettare la presenza di valori null e undefined (e simili) in un linguaggio di programmazione può accelerare di molto lo sviluppo di una prima versione funzionante del software. Un altro vantaggio di un approccio meno rigoroso è che, con un po' di esperienza, è possibile dosare la quantità di rigore da adottare in funzione della probabilità di incorrere in casi limite particolarmente problematici.
Nel concreto, in un sacco di casi ce la caveremo con gli operatori ?? e ?.. Ma per gestire le situazioni più complicate, solo la padronanza del problema, e quindi una gestione più strutturata di queste casistiche, farà la differenza.
È sempre importante, ma è anche difficile, non scadere in facili tifoserie