La scrittura di funzioni aritmetiche per consentire "type coercion" è una buona idea?

2

Diciamo che voglio scrivere una funzione per calcolare fattoriali di numeri interi non negativi. Potrei scrivere qualcosa del genere:

fact :: Num a => Int -> a
fact n = fromInteger(product [1..n])

(Non preoccupiamoci dei valori negativi di n al momento, poiché non è quello di cui si tratta la mia domanda.) Se scrivo la funzione in questo modo, significa che se voglio per girarsi e usarlo in un'espressione come

myExp x :: Double -> Double
myExp x = sum(map (\k -> x^k/fact(k)) [0..9])

direttamente, senza preoccuparsi di convertire il numero intero in un doppio.

C'è qualche ragione non per fare questo? Sto perdendo qualcosa di desiderabile che il sistema di tipi potrebbe fare per me?

Sospetto che l'unica ragione per cui gli operatori aritmetici hanno tipi restrittivi (ad es.

(/) :: Fractional a => a -> a -> a

così che 3.0 / (1 :: Int) è un errore) è che il sistema di tipi non sarebbe in grado di esprimere cose come "usa il tipo di entrambi gli operandi, a seconda di quale è più grande". Sono corretto?

    
posta Daniel McLaury 27.02.2018 - 06:16
fonte

2 risposte

2

Nel tuo esempio, la chiamata di fromInteger è ridondante. Potresti anche aver scritto:

fact :: (Num a, Enum a) => a -> a
fact n = product [1..n]

perché product ha già digitato Num a => [a] -> a (ora è anche polimorfico su Foldable tipi invece di essere specifico per gli elenchi, ma per il momento dobbiamo dimenticarlo) e la sintassi [1..n] incorporata utilizza il Enum typeclass under the hood.

Ciò che la tua invocazione di fromInteger fa è monomorfizzare il risultato del prodotto in Integer , poiché questo è il tipo dell'argomento di fromInteger , che è anche un'istanza Num , quindi funziona. Quindi, fromInteger restituisce nuovamente un% co_de arbitrario. Quindi questo è un viaggio di andata e ritorno ridondante in cui potresti anche aver usato il generico Num valore restituito da Num .

Quindi, nell'altra funzione, tutto funziona perché product è anche un'istanza Double .

Quindi in questo caso non stai perdendo né guadagnando nulla. Qual è il punto della gerarchia delle classi di tipi di tipi numerici, quindi?

Se hai scritto il tuo esempio in una lingua come C, sarebbe stata una promozione automatica , che va bene (in questo caso, non sempre, come sanno i programmatori C). Invece, in Haskell, scrivi codice come se stessi sfruttando le promozioni, senza mai avere promozioni, poiché il codice è monomorfizzato nel sito di chiamata e le operazioni vengono eseguite sul tipo desiderato dall'inizio: la funzione Num (senza la chiamata ridondante fact ) funziona su fromInteger s se è chiamata per restituire un Double , non funziona su Double s solo per promuovere il Integer restituito a Integer .

D'altra parte, le conversioni dall'altra direzione, cioè le coercizioni , devono essere proibite. Ciò accade se hai una funzione che restituisce un Double (ad es. Un Fractional ) e lo passi a una funzione che desidera Double . Si otterrebbe una mancata corrispondenza del tipo e questo evita perdite impreviste di informazioni.

    
risposta data 27.02.2018 - 08:02
fonte
2

Innanzitutto, il codice che hai fornito:

fact :: Num a => Int -> a
fact n = fromInteger (product [1..n])

non digita il controllo. Quindi, questa è una buona ragione per non scrivere la funzione in questo modo. Il problema è che n è di tipo Int , quindi product [1..n] è anche di tipo Int , ma fromInteger prevede un Integer , non un Int .

Potresti sostituire fromInteger con fromIntegral , ma non dovresti. Osservare:

fact1 :: Num a => Int -> a
fact1 n = fromIntegral (product [1..n])

e in GHCi:

> fact1 10
3628800
> fact1 100
0
> fact1 100 :: Double
0.0
> fact1 100 :: Integer
0
>

In confronto, un'attuazione ragionevole di un% di polimorfico% co_de potrebbe essere simile a:

fact2 :: (Integral n, Num a, Enum a) => n -> a
-- OR:  fact2 :: (Num a, Enum a) => Int -> a
fact2 n = product [1..fromIntegral n]

e

> fact2 100 :: Int
0    -- correct value in light of 'Int' overflow
> fact2 100 :: Integer
9332...long number here...000  -- correct for infinite-precision integer
> fact2 100 :: Double
9.33262154439441e157   -- right again, when working with 'Double's
>

Quindi, per rispondere alla tua domanda, , stai perdendo qualcosa di desiderabile che il sistema di tipi fa per te.

In particolare, il sistema dei tipi rende più difficile la conversione senza pensieri tra diversi tipi numerici che possono determinare gli attributi di un tipo numerico (ad esempio fact ) che inaspettatamente si perde negli usi apparenti di altri tipi numerici (ad esempio, Int ). Se lanci Integer e altre conversioni polimorfiche in funzioni matematiche senza considerare attentamente l'implementazione, è probabile che introduca errori di questo tipo.

Quindi, come dovresti scrivere funzioni matematiche in Haskell?

Bene, come approccio generale, le funzioni matematiche di Haskell polimorfiche sono scritte e scritte per funzionare all'interno di uno specifico tipo numerico nella misura del possibile. Pertanto, la firma di fromIntegral è:

(+) :: (Num a) => a -> a -> a

significa che gli argomenti hanno ciascuno lo stesso tipo e che sarà anche il tipo del risultato. Se sto utilizzando un (+) , so che l'overflow potrebbe essere un problema. Se sto usando un Int , so che non lo farà. Se sto usando un Integer , so che aggiungerne uno a un numero enorme potrebbe essere uguale al numero enorme, ma almeno non sarà negativo (come con Double ).

Se combino un sacco di calcoli:

myRoot :: Float -> Float -> Float
myRoot a b c = (-b + sqrt (b*b - 4*a*c)) / (2*a)

So che l'intero calcolo avviene con operazioni su Int s e non coinvolge segretamente Float -to- Float -to- Double conversioni.

Dovresti provare a scrivere le tue stesse funzioni matematiche in questo modo e lasciare le conversioni all'utente della tua funzione, dove saranno chiare ed esplicite, e l'utente avrà (si spera) dimostrato di sapere cosa sta facendo .

Ora, Float è in realtà un po 'un caso speciale. Se avessi seguito il consiglio sopra (ad esempio, scrivere funzioni che funzionano con un singolo tipo numerico), allora potresti preferire una firma più simile a:

fact3 :: (Num a, Enum a) => a -> a
fact3 n = product [1..n]

Questo è ciò che suggerito da @gigabytes. Probabilmente va bene, ma può portare a risultati strani:

> fact3 2.5
6.0
>

Il problema è che fact ha senso solo come una funzione fattoriale quando viene applicata agli interi, e l'applicazione sconsiderata a un non intero probabilmente rappresenta un errore di programmazione. Meglio lasciare che il sistema di tipi dia una mano usando uno dei tipi di firma alternativi sopra:

> fact2 2.5
...type error...
>

Se il programmatore veramente voleva prendere un fattoriale di un non intero, lui o lei dovrebbe rendere esplicito il comportamento desiderato:

> fact2 (floor 2.5)
2
> fact2 (ceiling 2.5)
6
>

Il ragionamento simile conduce a tipi come:

round :: (RealFrac a, Integral b) => a -> b

e gli operatori di alimentazione multipla:

(**) :: Floating a => a -> a -> a
(^) :: (Integral b, Num a) => a -> b -> a
(^^) :: (Fractional a, Integral b) => a -> b -> a

che - eccetto fact - non segue il modello "lavora in un tipo".

Tutto questo parla della tua ultima domanda. Il motivo per cui operazioni come:

(/) :: (Fractional a) => a -> a -> a

avere tipi restrittivi non è che il sistema tipo non possa esprimere "usa qualsiasi tipo sia più grande", è che i programmatori Haskell non VOGLIONO il sistema tipo per esprimere quel concetto, almeno nel contesto di diversi tipi numerici. Valutano il fatto che (**) e (/) sono operatori diversi e che il tipo di div rende chiaro che l'operazione si svolgerà all'interno di un particolare tipo numerico senza implicite coercizioni dietro al retro del programmatore.

Lo svantaggio è che centinaia di nuovi programmatori Haskell sono stati ostacolati da:

mean xs = sum xs / length xs

ma in genere è stato considerato un piccolo prezzo da pagare.

    
risposta data 14.07.2018 - 23:26
fonte

Leggi altre domande sui tag