Perché ci sono “dati” e “newtype” in Haskell?

Sembra che una definizione di newtype sia solo una definizione di data che obbedisce ad alcune restrizioni (ad esempio, un solo costruttore), e che a causa di queste restrizioni il sistema di runtime può gestire newtype più efficiente. E la gestione della corrispondenza dei modelli per i valori indefiniti è leggermente diversa.

Ma supponiamo che Haskell conoscesse solo data definizioni dei data , nessun nuovo tipo: il compilatore non potrebbe scoprire da solo se una determinata definizione di dati obbedisce a queste restrizioni e la tratta automaticamente in modo più efficiente?

Sono sicuro che mi sto perdendo qualcosa, ci deve essere una ragione più profonda per questo.

Entrambi i dati newtype e single-constructor introducono un costruttore di valore singolo, ma il costruttore di valori introdotto da newtype è rigido e il costruttore di valori introdotto dai data è pigro. Quindi se lo hai

 data D = D Int newtype N = N Int 

Quindi N undefined è equivalente a undefined e causa un errore quando viene valutato. Ma D undefined non è equivalente a undefined , e può essere valutato fino a quando non cerchi di sbirciare dentro.

Il compilatore non può gestirlo da solo.

No, non proprio-questo è un caso in cui come programmatore si arriva a decidere se il costruttore è severo o pigro. Per capire quando e come rendere i costruttori severi o pigri, devi avere una comprensione molto migliore della valutazione pigra di me. Mi attengo all’idea nel Report, ovvero che newtype è lì per rinominare un tipo esistente, come avere diversi tipi di misure incompatibili:

 newtype Feet = Feet Double newtype Cm = Cm Double 

entrambi si comportano esattamente come Double in fase di esecuzione, ma il compilatore promette di non permetterti di confonderli.

Secondo Learn You a Haskell :

Invece della parola chiave data, viene utilizzata la parola chiave newtype. Ora, perché è così? Bene per uno, newtype è più veloce. Se si utilizza la parola chiave data per racchiudere un tipo, c’è un sovraccarico in tutto ciò che avvolge e scartina quando il programma è in esecuzione. Ma se usi newtype, Haskell sa che lo stai solo usando per racchiudere un tipo esistente in un nuovo tipo (da cui il nome), perché vuoi che sia lo stesso internamente ma che abbia un tipo diverso. Con questo in mente, Haskell può liberarsi del wrapping e del unwrapping una volta risolto il valore di quale tipo.

Allora perché non usare sempre newtype invece dei dati allora? Bene, quando si crea un nuovo tipo da un tipo esistente usando la parola chiave newtype, si può avere un solo costruttore di valori e tale costruttore di valori può avere solo un campo. Ma con i dati, puoi creare tipi di dati con diversi costruttori di valori e ogni costruttore può avere zero o più campi:

 data Profession = Fighter | Archer | Accountant data Race = Human | Elf | Orc | Goblin data PlayerCharacter = PlayerCharacter Race Profession 

Quando usi newtype, sei limitato a un solo costruttore con un campo.

Considerare ora il seguente tipo:

 data CoolBool = CoolBool { getCoolBool :: Bool } 

È il tipo di dati algebrici run-of-the-mill che è stato definito con la parola chiave data. Ha un costruttore di valori, che ha un campo il cui tipo è Bool. Facciamo una funzione con cui il pattern corrisponde a un CoolBool e restituisce il valore “ciao” indipendentemente dal fatto che il Bool all’interno di CoolBool fosse True o False:

 helloMe :: CoolBool -> String helloMe (CoolBool _) = "hello" 

Invece di applicare questa funzione a un normale CoolBool, gettiamo una curva e applicala a undefined!

 ghci> helloMe undefined "*** Exception: Prelude.undefined 

Yikes! Un’eccezione! Ora, perché è avvenuta questa eccezione? I tipi definiti con la parola chiave data possono avere più costruttori di valori (anche se solo CoolBool ne ha uno). Quindi, per vedere se il valore dato alla nostra funzione è conforms al modello (CoolBool _), Haskell deve valutare il valore quel tanto che basta per vedere quale costruttore di valori è stato usato quando abbiamo fatto il valore. E quando proviamo a valutare un valore indefinito, anche un po ‘, viene lanciata un’eccezione.

Invece di usare la parola chiave data per CoolBool, proviamo ad usare newtype:

 newtype CoolBool = CoolBool { getCoolBool :: Bool } 

Non è necessario modificare la funzione helloMe, poiché la syntax di corrispondenza del modello è la stessa se si utilizza newtype o dati per definire il proprio tipo. Facciamo la stessa cosa qui e applichiamo Hello a un valore indefinito:

 ghci> helloMe undefined "hello" 

Ha funzionato! Hmmm, perché è quello? Bene, come abbiamo detto, quando usiamo newtype, Haskell può rappresentare internamente i valori del nuovo tipo allo stesso modo dei valori originali. Non ha bisogno di aggiungere un altro riquadro intorno a loro, deve solo essere consapevole dei valori di diversi tipi. E poiché Haskell sa che i tipi creati con la parola chiave newtype possono avere solo un costruttore, non deve valutare il valore passato alla funzione per assicurarsi che sia conforms al modello (CoolBool _) perché i tipi newtype possono avere solo uno costruttore di valori possibili e un campo!

Questa differenza di comportamento può sembrare banale, ma in realtà è piuttosto importante perché ci aiuta a capire che anche se i tipi definiti con data e newtype si comportano in modo simile dal punto di vista del programmatore perché entrambi hanno costruttori e campi di valore, in realtà sono due meccanismi diversi . Mentre i dati possono essere utilizzati per creare i propri tipi da zero, newtype è per creare un tipo completamente nuovo da un tipo esistente. La corrispondenza dei modelli sui valori newtype non è come prendere qualcosa da una scatola (come con i dati), si tratta piuttosto di effettuare una conversione diretta da un tipo all’altro.

Ecco un’altra fonte. Secondo questo articolo di Newtype :

Una dichiarazione newtype crea un nuovo tipo più o meno allo stesso modo dei dati. La syntax e l’uso di newtype sono praticamente identici a quelli delle dichiarazioni di dati: infatti, è ansible sostituire la parola chiave newtype con i dati e verrà comunque compilata, infatti ci sono anche buone probabilità che il programma funzioni ancora. Il contrario non è vero, tuttavia – i dati possono essere sostituiti con newtype solo se il tipo ha esattamente un costruttore con esattamente un campo al suo interno.

Qualche esempio:

 newtype Fd = Fd CInt -- data Fd = Fd CInt would also be valid -- newtypes can have deriving clauses just like normal types newtype Identity a = Identity a deriving (Eq, Ord, Read, Show) -- record syntax is still allowed, but only for one field newtype State sa = State { runState :: s -> (s, a) } -- this is *not* allowed: -- newtype Pair ab = Pair { pairFst :: a, pairSnd :: b } -- but this is: data Pair ab = Pair { pairFst :: a, pairSnd :: b } -- and so is this: newtype Pair' ab = Pair' (a, b) 

Sembra abbastanza limitato! Quindi, perché qualcuno usa newtype?

La versione breve La restrizione a un costruttore con un campo indica che il nuovo tipo e il tipo del campo sono in corrispondenza diretta:

 State :: (s -> (a, s)) -> State sa runState :: State sa -> (s -> (a, s)) 

o in termini matematici sono isomorfi. Ciò significa che dopo che il tipo è stato controllato in fase di compilazione, in fase di esecuzione i due tipi possono essere trattati essenzialmente allo stesso modo, senza il sovraccarico o l’indiretto normalmente associati a un costruttore di dati. Quindi, se vuoi dichiarare diverse istanze di classi di tipi per un particolare tipo, o vuoi fare un abstract di tipo, puoi racchiuderlo in un nuovo tipo e sarà considerato distinto per il type-checker, ma identico in fase di runtime. È quindi ansible utilizzare tutti i tipi di trucchi profondi come i tipi fantasma o ricorsivi senza preoccuparsi dei buffer di byte di shuffling GHC senza alcun motivo.

Vedi l’articolo per i bit disordinati …

Versione semplice per persone ossessionate da elenchi di elenchi puntati (non è riuscito a trovarne uno, quindi è necessario scriverlo da solo):

data – crea un nuovo tipo algebrico con costruttori di valori

  • Può avere diversi costruttori di valori
  • I costruttori di valori sono pigri
  • I valori possono avere diversi campi
  • Colpisce sia la compilazione che il runtime, ha un sovraccarico di runtime
  • Il tipo creato è un nuovo tipo distinto
  • Può avere le sue istanze di class del tipo
  • Quando la corrispondenza del modello con i costruttori di valore, sarà valutata almeno in forma normale debole (WHNF) *
  • Utilizzato per creare un nuovo tipo di dati (esempio: Indirizzo {zip :: String, street :: String})

newtype : crea un nuovo tipo di “decorazione” con un costruttore di valori

  • Può avere solo un costruttore di valori
  • Il costruttore del valore è rigoroso
  • Il valore può avere solo un campo
  • Interessa solo la compilazione, nessun sovraccarico di runtime
  • Il tipo creato è un nuovo tipo distinto
  • Può avere le sue istanze di class del tipo
  • Quando la corrispondenza del modello con il costruttore del valore, CAN non può essere valutata affatto *
  • Utilizzato per creare un concetto di livello superiore basato sul tipo esistente con serie distinte di operazioni supportate o che non è intercambiabile con il tipo originale (esempio: Meter, Cm, Feet is Double)

type – crea un nome alternativo (sinonimo) per un tipo (come typedef in C)

  • Nessun valore costruttori
  • Nessun campo
  • Interessa solo la compilazione, nessun sovraccarico di runtime
  • Non viene creato alcun nuovo tipo (solo un nuovo nome per il tipo esistente)
  • NON può avere le sue istanze di class del tipo
  • Quando la corrispondenza del modello con il costruttore di dati, si comporta come il tipo originale
  • Utilizzato per creare un concetto di livello superiore basato sul tipo esistente con lo stesso insieme di operazioni supportate (esempio: String is [Char])

[*] In tema di pigrizia abbinata:

 data DataBox a = DataBox Int newtype NewtypeBox a = NewtypeBox Int dataMatcher :: DataBox -> String dataMatcher (DataBox _) = "data" newtypeMatcher :: NewtypeBox -> String newtypeMatcher (NewtypeBox _) = "newtype" ghci> dataMatcher undefined "*** Exception: Prelude.undefined ghci> newtypeMatcher undefined “newtype" 

Fuori dalla mia testa; le dichiarazioni di dati usano la valutazione pigra dell’accesso e dell’archiviazione dei loro “membri”, mentre newtype no. Newtype rimuove anche tutte le istanze di tipi precedenti dai suoi componenti, nascondendo efficacemente la sua implementazione; mentre i dati lasciano aperta l’attuazione.

Tendo ad usare newtype quando evito il codice boilerplate in tipi di dati complessi in cui non ho necessariamente bisogno di accedere agli interni quando li utilizzo. Ciò accelera sia la compilazione che l’esecuzione e riduce la complessità del codice in cui viene utilizzato il nuovo tipo.

Quando ho letto per la prima volta su questo argomento ho trovato piuttosto intuitivo questo capitolo di Gentle Introduction to Haskell.