puntatori null vs. Null Object Pattern


27

Attribuzione: nasce da una domanda P.SE correlata

Il mio background è in C / C ++, ma ho lavorato parecchio in Java e attualmente sto codificando C #. A causa del mio background C, il controllo dei puntatori passati e restituiti è di seconda mano, ma riconosco che distorce il mio punto di vista.

Di recente ho visto la menzione del Null Object Pattern in cui l'idea è che un oggetto venga sempre restituito. Il caso normale restituisce l'oggetto popolato previsto e il caso di errore restituisce l'oggetto vuoto anziché un puntatore nullo. La premessa è che la funzione chiamante avrà sempre una sorta di oggetto per accedere e quindi evitare violazioni della memoria ad accesso nullo.

  • Quindi quali sono i pro / contro di un controllo null rispetto all'utilizzo del Null Object Pattern?

Riesco a vedere un codice di chiamata più pulito con il NOP, ma posso anche vedere dove creerebbe errori nascosti che altrimenti non vengono generati. Preferirei che la mia applicazione fallisse (alias un'eccezione) mentre la sviluppavo piuttosto che un silenzioso errore scappare in libertà.

  • Il modello a oggetti nulli non può avere problemi simili a non eseguire un controllo null?

Molti degli oggetti con cui ho lavorato tengono oggetti o contenitori propri. Sembra che dovrei avere un caso speciale per garantire che tutti i contenitori dell'oggetto principale abbiano oggetti vuoti propri. Sembra che questo potrebbe diventare brutto con più livelli di nidificazione.


Non il "caso di errore" ma "in tutti i casi un oggetto valido".

@ ThorbjørnRavnAndersen - buon punto, e ho modificato la domanda per riflettere questo. Per favore fatemi sapere se sto ancora fraintendendo la premessa della NOP.

6
La risposta breve è che "modello di oggetti null" è un termine improprio. È più precisamente chiamato "null oggetto anti-pattern". Di solito stai meglio con un "oggetto errore" - un oggetto valido che implementa l'intera interfaccia della classe, ma ogni suo utilizzo provoca una morte improvvisa e forte.
Jerry Coffin,

1
@JerryCoffin quel caso dovrebbe essere indicato da un'eccezione. L'idea è che non è mai possibile ottenere un valore null e quindi non è necessario verificarne la presenza.

1
Non mi piace questo "schema". Ma probabilmente è radicato in database relazionali vincolati eccessivamente, dove è più semplice fare riferimento a "righe null" speciali in chiavi esterne durante la gestione temporanea di dati modificabili, prima dell'aggiornamento finale quando tutte le chiavi esterne non sono perfettamente nulle.

Risposte:


24

Non utilizzare un modello di oggetti nulli in luoghi in cui viene restituito null (o oggetto null) a causa di un errore catastrofico. In quei posti continuerei a restituire null. In alcuni casi, se non viene ripristinato, è possibile che si verifichi un arresto anomalo perché almeno il dump di arresto indica esattamente dove si è verificato il problema. In questi casi, quando si aggiunge la propria gestione degli errori, si continuerà comunque a terminare il processo (ancora una volta, ho detto per i casi in cui non esiste alcun recupero) ma la gestione degli errori maschererà informazioni molto importanti che un dump di crash avrebbe fornito.

Null Object Pattern è più indicato per i luoghi in cui esiste un comportamento predefinito che potrebbe essere assunto nel caso in cui l'oggetto non venga trovato. Ad esempio, considerare quanto segue:

User* pUser = GetUser( "Bob" );

if( pUser )
{
    pUser->SetAddress( "123 Fake St." );
}

Se usi NOP, dovresti scrivere:

GetUser( "Bob" )->SetAddress( "123 Fake St." );

Si noti che il comportamento di questo codice è "se Bob esiste, voglio aggiornare il suo indirizzo". Ovviamente se la tua applicazione richiede che Bob sia presente, non vuoi avere successo in silenzio. Ma ci sono casi in cui questo tipo di comportamento sarebbe appropriato. E in questi casi, NOP non produce un codice molto più pulito e conciso?

Nei luoghi in cui non puoi davvero vivere senza Bob, vorrei che GetUser () lanciasse un'eccezione dell'applicazione (cioè non violazioni dell'accesso o cose del genere) che sarebbe gestita a un livello superiore e segnalerebbe un errore generale di funzionamento. In questo caso, non è necessario NOP ma non è necessario controllare esplicitamente NULL. IMO, quei controlli per NULL, rendono solo il codice più grande e togliono la leggibilità. Verificare che NULL sia ancora la scelta di progettazione giusta per alcune interfacce, ma non così tante come alcune persone tendono a pensare.


4
Concordare con il caso d'uso per i modelli di oggetti null. Ma perché restituire null quando si verificano guasti catastrofici? Perché non generare eccezioni?
Apoorv Khurasia,

2
@MonsterTruck: invertire quello. Quando scrivo il codice, mi assicuro di convalidare la maggior parte degli input ricevuti da componenti esterni. Tuttavia, tra classi interne se scrivo codice in modo tale che una funzione di una classe non restituirà mai NULL, dal lato chiamante non aggiungerò un controllo null solo per "essere più sicuro". L'unico modo in cui quel valore potrebbe essere NULL è se si è verificato un errore logico catastrofico nella mia applicazione. In tal caso, voglio che il mio programma si blocchi esattamente in quel punto perché poi ottengo un bel file di dump di arresto anomalo e riavvolgo lo stack e lo stato dell'oggetto per identificare l'errore logico.
DXM,

2
@MonsterTruck: intendevo "invertire quello", non restituire esplicitamente NULL quando si identifica un errore catastrofico, ma aspettarsi che NULL si verifichi solo a causa di un errore catastrofico imprevisto.
DXM,

Ora capisco cosa intendevi per errore catastrofico, qualcosa che fa fallire l'allocazione dell'oggetto stesso. Destra? Modifica: impossibile fare @ DXM perché il tuo nickname è lungo solo 3 caratteri.
Apoorv Khurasia,

@MonsterTruck: strano riguardo alla cosa "@". So che altre persone l'hanno già fatto. Mi chiedo se hanno modificato di recente il sito web. Non deve essere allocazione. Se scrivo una classe e l'intenzione dell'API è di restituire sempre un oggetto valido, allora il chiamante non farebbe un controllo nullo. Ma l'errore catastrofico potrebbe essere qualcosa di simile all'errore del programmatore (un errore di battitura che ha causato la restituzione di NULL), o forse una condizione di competizione che ha cancellato un oggetto prima del suo tempo, o forse un po 'di corruzione dello stack. In sostanza, qualsiasi percorso di codice imprevisto che ha portato alla restituzione di NULL. Quando ciò accade, vuoi ...
DXM,

7

Quindi quali sono i pro / contro di un controllo null rispetto all'utilizzo del Null Object Pattern?

Professionisti

  • Un controllo null è migliore poiché risolve più casi. Non tutti gli oggetti hanno un comportamento normale o no-op.
  • Un controllo null è più solido. Anche gli oggetti con valori predefiniti sani vengono utilizzati in luoghi in cui il valore predefinito sano non è valido. Il codice dovrebbe fallire vicino alla causa principale se fallisce. Il codice dovrebbe fallire ovviamente se fallirà.

Contro

  • I valori predefiniti sani generalmente generano un codice più pulito.
  • Le impostazioni predefinite corrette in genere comportano errori meno catastrofici se riescono a entrare in libertà.

Quest'ultimo "pro" è il principale fattore di differenziazione (secondo la mia esperienza) su quando ciascuno dovrebbe essere applicato. "L'errore dovrebbe essere rumoroso?". In alcuni casi, vuoi che un fallimento sia duro e immediato; se qualche scenario che non dovrebbe mai accadere lo fa in qualche modo. Se non è stata trovata una risorsa vitale ... ecc. In alcuni casi, stai bene con un valore di default sano dato che non è proprio un errore : ottenere un valore da un dizionario, ma la chiave manca ad esempio.

Come ogni altra decisione di progettazione, ci sono aspetti positivi e negativi a seconda delle esigenze.


1
Nella sezione Pro: concordare con il primo punto. Il secondo punto è colpa dei progettisti perché anche null può essere usato dove non dovrebbe essere. Non dice nulla di negativo sul modello si riflette solo sullo sviluppatore che ha usato il modello in modo errato.
Apoorv Khurasia,

Che cosa è più fallimento catastrofico, non essere in grado di inviare denaro o inviare (e quindi non avere più) denaro senza che il destinatario li riceva e non lo conosca prima del controllo esplicito?
user470365

Contro: i valori predefiniti sani possono essere usati per costruire nuovi oggetti. Se viene restituito un oggetto non nullo, alcuni dei suoi attributi possono essere modificati durante la creazione di un altro oggetto. Se viene restituito un oggetto null, può essere utilizzato per creare un nuovo oggetto. In entrambi i casi, funziona lo stesso codice. Non è necessario un codice separato per copia-modifica e nuovo. Questo fa parte della filosofia secondo cui ogni riga di codice è un altro posto per i bug; ridurre le linee riduce i bug.
Shawnhcorey,

@shawnhcorey - che cosa?
Telastyn,

Un oggetto null dovrebbe essere utilizzabile come se fosse un oggetto reale. Ad esempio, considera la NaN dell'IEEE (non un numero). Se un'equazione va storta, viene restituita. E può essere utilizzato per ulteriori calcoli poiché qualsiasi operazione su di esso restituirà NaN. Semplifica il codice poiché non è necessario verificare la presenza di NaN dopo ogni operazione aritmetica.
Shawnhcorey,

6

Non sono una buona fonte di informazioni obiettive, ma soggettivamente:

  • Non voglio che il mio sistema muoia, mai; per me è un segno di un sistema mal progettato o incompleto; il sistema completo dovrebbe gestire tutti i casi possibili e non entrare mai in uno stato imprevisto; per raggiungere questo obiettivo devo essere esplicito nel modellare tutti i flussi e le situazioni; l'utilizzo di null non è comunque esplicito e raggruppa molti casi diversi in un'unica umrella; Preferisco l'oggetto NonExistingUser a null, preferisco NaN a null, ... tale approccio mi permette di essere esplicito su quelle situazioni e la loro gestione in tutti i dettagli che voglio, mentre null mi lascia solo con l'opzione null; in un ambiente come Java qualsiasi oggetto può essere nullo, quindi perché preferisci nascondere in quel pool generico di casi anche il tuo caso specifico?
  • le implementazioni null hanno un comportamento drasticamente diverso rispetto all'oggetto e sono per lo più incollate all'insieme di tutti gli oggetti (perché? perché? perché?); per me tale differenza drastica sembra che il risultato della progettazione di valori null sia un'indicazione di errore, nel qual caso il sistema molto probabilmente morirà (sono convinto che così tante persone preferiscono effettivamente che il loro sistema muoia nei messaggi precedenti) a meno che non sia esplicitamente gestito; per me questo è un approccio imperfetto nella sua radice - permette al sistema di morire di default a meno che tu non ti sia preso esplicitamente cura di esso, non è molto sicuro, giusto? ma in ogni caso è impossibile scrivere un codice che tratta il valore null e object nello stesso modo - funzioneranno solo pochi operatori di base (assegnazione, uguale, param, ecc.), tutto il resto del codice fallirà e sarà necessario due percorsi completamente diversi;
  • usando meglio le scale Null Object - puoi inserire tutte le informazioni e la struttura che desideri; NonExistingUser è un ottimo esempio: può contenere un'e-mail dell'utente che hai provato a ricevere e suggerire di creare un nuovo utente in base a tali informazioni, mentre con la soluzione nulla avrei bisogno di pensare a come mantenere l'e-mail a cui la gente ha tentato di accedere vicino alla gestione del risultato null;

In un ambiente come Java o C # non ti darà il vantaggio principale dell'esplicitazione: la sicurezza della soluzione è completa, perché puoi comunque ricevere null al posto di qualsiasi oggetto nel sistema e Java non ha mezzi (tranne le annotazioni personalizzate per Beans , ecc.) per proteggerti da questo, tranne se espliciti. Ma nel sistema privo di implementazioni nulle, avvicinarsi alla soluzione del problema in modo tale (con oggetti che rappresentano casi eccezionali) offre tutti i vantaggi menzionati. Quindi prova a leggere qualcosa di diverso dalle lingue tradizionali e ti ritroverai a cambiare il tuo modo di scrivere codice.

In generale oggetti Null / Error / tipi di ritorno sono considerati una strategia di gestione degli errori molto solida e abbastanza matematicamente valida, mentre le eccezioni della strategia di gestione di Java o C vanno come un solo passo sopra la strategia "die ASAP" - quindi sostanzialmente lasciano tutto il peso di instabilità e imprevedibilità del sistema su sviluppatore o manutentore.

Ci sono anche monadi che possono essere considerate superiori a questa strategia (inizialmente anche superiori nella complessità della comprensione), ma quelle riguardano maggiormente i casi in cui è necessario modellare aspetti esterni del tipo, mentre Null Object modella gli aspetti interni. Quindi NonExistingUser è probabilmente meglio modellato come monade maybe_existing.

E infine non penso che ci sia alcuna connessione tra il modello Null Object e la gestione silenziosa delle situazioni di errore. Dovrebbe essere al contrario - dovrebbe costringerti a modellare esplicitamente ogni singolo caso di errore e gestirlo di conseguenza. Ora, ovviamente, potresti decidere di essere sciatto (per qualsiasi motivo) e saltare la gestione del caso di errore in modo esplicito, ma gestirlo in modo generico - beh, questa è stata la tua scelta esplicita.


9
Il motivo per cui mi piace che la mia applicazione fallisca quando succede qualcosa di inaspettato è che è successo qualcosa di inaspettato. Come è successo? Perché? Ricevo un dump di arresto anomalo e posso indagare e risolvere un problema potenzialmente pericoloso, piuttosto che lasciarlo nascosto nel codice. In C #, ovviamente, posso cogliere tutte le eccezioni impreviste per provare a ripristinare l'applicazione, ma anche allora voglio registrare il luogo in cui si è verificato il problema e scoprire se si tratta di qualcosa che necessita di una gestione separata (anche se è "non farlo registra questo errore, è effettivamente previsto e recuperabile ").
Luaan,

3

Penso che sia interessante notare che la fattibilità di un oggetto null sembra essere leggermente diversa a seconda che la tua lingua sia o meno tipizzata staticamente. Ruby è un linguaggio che implementa un oggetto per Null (o Nilnella terminologia del linguaggio).

Invece di restituire un puntatore alla memoria non inizializzata, la lingua restituirà l'oggetto Nil. Ciò è facilitato dal fatto che la lingua è tipizzata in modo dinamico. Una funzione non è garantita per restituire sempre un int. Può tornare Nilse è quello che deve fare. Diventa più complicato e difficile farlo in un linguaggio tipicamente statico, perché naturalmente ti aspetti che null sia applicabile a qualsiasi oggetto che può essere chiamato come riferimento. Dovresti iniziare a implementare versioni nulle di qualsiasi oggetto che potrebbe essere nullo (oppure seguire il percorso Scala / Haskell e avere una sorta di wrapper "Forse").

MrLister ha sottolineato il fatto che in C ++ non è garantito avere un riferimento / puntatore di inizializzazione quando lo si crea per la prima volta. Avendo un oggetto null dedicato invece di un puntatore null grezzo, è possibile ottenere alcune belle funzionalità aggiuntive. Ruby Nilinclude una funzione to_s(toString) che genera testo vuoto. Questo è ottimo se non ti dispiace ottenere un ritorno nullo su una stringa e vuoi saltare la segnalazione senza crash. Le classi aperte consentono inoltre di sovrascrivere manualmente l'output di, Nilse necessario.

Concesso questo può essere preso con un granello di sale. Credo che in un linguaggio dinamico, l'oggetto null possa essere di grande convenienza se usato correttamente. Sfortunatamente, ottenere il massimo da esso potrebbe richiedere di modificarne le funzionalità di base, il che potrebbe rendere il programma difficile da capire e mantenere per le persone abituate a lavorare con i suoi moduli più standard.


1

Per qualche punto di vista aggiuntivo, dai un'occhiata a Objective-C, dove invece di avere un oggetto Null il valore nil tipo di funziona come un oggetto. Qualsiasi messaggio inviato a un oggetto nullo non ha alcun effetto e restituisce un valore di 0 / 0.0 / NO (false) / NULL / nil, qualunque sia il tipo di valore restituito del metodo. Questo è usato come modello molto pervasivo nella programmazione di MacOS X e iOS, e di solito i metodi saranno progettati in modo che un oggetto nullo che ritorni 0 sia un risultato ragionevole.

Ad esempio, se si desidera verificare se una stringa ha uno o più caratteri, è possibile scrivere

aString.length > 0

e ottenere un risultato ragionevole se aString == zero, poiché una stringa inesistente non contiene caratteri. Le persone tendono ad impiegare un po 'di tempo per abituarsi, ma probabilmente mi ci vorrebbe un po' per abituarmi a cercare valori nulli ovunque in altre lingue.

(C'è anche un oggetto singleton della classe NSNull che si comporta in modo abbastanza diverso e non è molto usato nella pratica).


Objective-C ha reso la cosa Null Object / Null Pointer davvero sfocata. nilarriva #definea NULL e si comporta come un oggetto null. Questa è una funzionalità di runtime mentre in C # / Java i puntatori null generano errori.
Maxthon Chan,

0

Non usare neanche se puoi evitarlo. Utilizzare una funzione di fabbrica che restituisce un tipo di opzione, una tupla o un tipo di somma dedicato. In altre parole, rappresentano un valore potenzialmente predefinito con un tipo diverso da un valore garantito da non default.

Innanzitutto, elenchiamo i desiderati, quindi pensiamo a come possiamo esprimerlo in alcune lingue (C ++, OCaml, Python)

  1. rileva l'uso indesiderato di un oggetto predefinito al momento della compilazione.
  2. chiarire se un determinato valore è potenzialmente predefinito o meno durante la lettura del codice.
  3. scegli il nostro valore predefinito ragionevole, se applicabile, una volta per tipo . Sicuramente non scegliere un valore predefinito potenzialmente diverso in ogni sito di chiamata .
  4. semplificare la grepricerca di potenziali errori da parte di strumenti di analisi statica o di un essere umano .
  5. Per alcune applicazioni , il programma dovrebbe continuare normalmente se inaspettatamente viene assegnato un valore predefinito. Per altre applicazioni , il programma dovrebbe arrestarsi immediatamente se viene assegnato un valore predefinito, idealmente in modo informativo.

Penso che la tensione tra il Null Object Pattern e i puntatori null provenga da (5). Se riusciamo a rilevare gli errori abbastanza presto, però, (5) diventa discutibile.

Consideriamo questa lingua per lingua:

C ++

A mio avviso, una classe C ++ dovrebbe essere generalmente costruibile in modo predefinito poiché facilita le interazioni con le librerie e semplifica l'utilizzo della classe in contenitori. Semplifica anche l'eredità poiché non è necessario pensare a quale costruttore di superclasse chiamare.

Tuttavia, ciò significa che non si può sapere con certezza se un valore di tipo MyClassè nello "stato predefinito" oppure no. Oltre a mettere in un bool nonemptycampo o simile per emergere default in fase di esecuzione, il meglio che puoi fare è produrre nuove istanze MyClassin un modo che costringa l'utente a verificarlo .

Consiglierei di utilizzare una funzione di fabbrica che restituisce a std::optional<MyClass>, std::pair<bool, MyClass>o un riferimento di valore r a a std::unique_ptr<MyClass>se possibile.

Se si desidera che la funzione di fabbrica restituisca un tipo di stato "segnaposto" diverso da uno predefinito MyClass, utilizzare a std::paire assicurarsi di documentare che la funzione funzioni.

Se la funzione di fabbrica ha un nome univoco, è facile grepper il nome e cercare la sciattezza. Tuttavia, è difficile grepper i casi in cui il programmatore avrebbe dovuto usare la funzione di fabbrica ma non l'ha fatto .

OCaml

Se stai usando un linguaggio come OCaml, puoi semplicemente usare un optiontipo (nella libreria standard OCaml) o un eithertipo (non nella libreria standard, ma facile da usare). O un defaultabletipo (sto inventando il termine defaultable).

type 'a option =
    | None
    | Some of 'a

type ('a, 'b) either =
    | Left of 'a
    | Right of 'b

type 'a defaultable =
    | Default of 'a
    | Real of 'a

A defaultablecome mostrato sopra è meglio di una coppia perché l'utente deve eseguire il pattern match per estrarre il 'ae non può semplicemente ignorare il primo elemento della coppia.

L'equivalente del defaultabletipo come mostrato sopra può essere usato in C ++ usando a std::variantcon due istanze dello stesso tipo, ma parti std::variantdell'API sono inutilizzabili se entrambi i tipi sono uguali. Inoltre è un uso strano std::variantdal momento che i suoi costruttori di tipo sono senza nome.

Pitone

Non si ottiene comunque alcun controllo in fase di compilazione per Python. Ma, essendo tipizzato dinamicamente, in genere non ci sono circostanze in cui è necessaria un'istanza segnaposto di qualche tipo per placare il compilatore.

Consiglierei di lanciare un'eccezione quando si sarebbe costretti a creare un'istanza predefinita.

Se ciò non è accettabile, consiglierei di creare un DefaultedMyClasselemento che eredita MyClasse di restituirlo dalla funzione di fabbrica. Ciò ti offre flessibilità in termini di funzionalità di "estrazione" in casi predefiniti, se necessario.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.