Linee guida per il ritorno di collezioni da funzioni pubbliche

6

Il problema

Immagina di avere una funzione in un'API pubblica che deve restituire una specie di raccolta. Il suo tipo è qualcosa come

foo :: a -> b

dove b è una sorta di collezione.

Per quanto posso vedere, ci sono tre principali tipi di collezioni - almeno quelle utilizzate più spesso:

  1. Sequenze (l'ordine conta, potrebbero esserci duplicati)
  2. Set (l'ordine non ha importanza, nessun duplicato)
  3. Mappe (gli elementi sono coppie chiave-valore, l'ordine non ha importanza, nessuna chiave duplicata)

Che cosa dovresti utilizzare per b in questi tre casi ?. Ho preso in considerazione tre alternative, ma potrebbero essercene di più.

A. Basta restituire una lista

In questa alternativa, si restituiscono solo gli elenchi per tutto:

  1. foo :: a -> [c]
  2. foo :: a -> [c] (e documenta che non ci sono duplicati)
  3. foo :: a -> [(k, c)] (e documenta che non ci sono chiavi duplicate)

Il vantaggio di questo è che il client può costruire qualsiasi raccolta desiderata dall'elenco restituito.

Lo svantaggio per 2 e 3 è che

  • il tipo non ha la garanzia "no duplicati" e il client deve fare più lavoro per ottenere un set / mappa da esso
  • la costruzione della lista in 2 e 3 potrebbe essere meno efficiente, perché qualcosa come un union per insiemi è ovviamente più veloce di quella per le liste
  • potresti costruire una lista intermedia (dipende dal problema in questione).

B. Restituisce un tipo concreto che corrisponde alle garanzie

In questa alternativa, si restituisce un tipo che corrisponde esattamente alle garanzie che si stanno dando:

  1. foo :: a -> [c] (o forse foo :: a -> Data.Sequence.Seq c )
  2. foo :: a -> Data.Set.Set c (pigro o severo?)
  3. foo :: a -> Data.Map.Map k c (pigro o severo?)

Il vantaggio è che il tipo descrive accuratamente le garanzie che vuoi esprimere e che il cliente non deve fare alcun lavoro extra.

Lo svantaggio è che sei bloccato in una particolare implementazione. Se si desidera passare a un'implementazione di un set diverso, si interrompe l'API pubblica. Potrebbe essere una buona cosa in caso di passaggio tra pigro e severo, perché questi possono avere importanza per la correttezza del codice chiamante. Ma in generale vuoi essere in grado di scambiare l'implementazione senza che i tuoi clienti se ne accorgano.

C. Lascia che il cliente decida

In questa alternativa, devi solo limitare il tipo restituito con una classe di tipo e lasciare che il client decida sul tipo concreto:

1. 'foo :: Sequence b c => a -> b'
2. 'foo :: Set b c => a -> b'
3. 'foo :: Map b c k => a -> b'

(I nomi che ho usato provengono da collections-api , ma non importa.)

Il vantaggio di questo è che il client può scegliere il tipo di raccolta da utilizzare.

Lo svantaggio è che non hai più il controllo sulla collezione che costruisci più. Ciò significa, ad esempio, che non puoi assumere la pigrizia o la complessità degli inserti. Un altro problema è che il client può ottenere tipi ambigui, ad es. Quando piegano immediatamente la raccolta restituita:

Data.Foldable.fold (foo whatever)

Ora b è ambiguo.

Ci sono altre alternative? Cosa consiglieresti?

I non vuole foo per poter restituire un tipo diverso di raccolta per ogni chiamata. I tre casi considerati sono completamente separati.

In ogni caso la domanda è "se ho una funzione che deve restituire una X, che tipo dovrebbe avere?" dove X può essere una sequenza, un set o una mappa (o qualsiasi altro tipo di collezione a cui puoi pensare)

    
posta Tobias Brandt 03.05.2014 - 14:02
fonte

2 risposte

5

Il mio suggerimento sarebbe di dichiarare un opaco newtype che nasconde l'effettiva implementazione e implementa classi di tipi che potrebbero essere utili ai tuoi clienti e che puoi garantire di essere stabile in futuro. Ed è anche possibile esportare funzioni che consentono la conversione del proprio tipo di dati su alcuni standard.

Ciò impone ai client di utilizzare solo funzioni o istanze fornite e documentate e consente inoltre ai client di utilizzare API standard come Foldable :

module MyMod
    ( MyResult()
    , resultToSet   -- by hiding the internals and exporting the accessor
                    -- explicitly, you can always change the internal
                    -- representation and replace the accessor a function
                    -- with the same signature
    ) where

import Data.Monoid
import qualified Data.Foldable as F
import qualified Data.Set as S

newtype MyResult a = MyResult { resultToSet :: S.Set a }

-- For example, your clients could use 'Foldable' and 'Monoid' instances:

instance F.Foldable MyResult where
    foldMap f = F.foldMap f . resultToSet

instance (Ord a) => Monoid (MyResult a) where
    mempty = MyResult mempty
    mappend (MyResult x) (MyResult y) = MyResult (x 'mappend' y)

Aggiornamento: se in seguito deciderai di modificare la rappresentazione interna in HashSet , il modulo sarà simile a questo:

module MyMod
    ( MyResult()
    , resultToSet   -- by hiding the internals and exporting the accessor
                    -- explicitly, you can always change the internal
                    -- representation and replace the accessor a function
                    -- with the same signature
    ) where

import Data.Monoid
import qualified Data.Foldable as F
import Data.Hashable
import qualified Data.HashSet as HS
import qualified Data.Set as S

newtype MyResult a = MyResult { resultToHashSet :: HS.HashSet a }

-- For example, your clients could use 'Foldable' and 'Monoid' instances:

instance F.Foldable MyResult where
    foldMap f = F.foldMap f . resultToHashSet

instance (Eq a, Hashable a) => Monoid (MyResult a) where
    mempty = MyResult mempty
    mappend (MyResult x) (MyResult y) = MyResult (x 'mappend' y)

-- Reimplementation of functions from the previous version

resultToSet :: (Ord a) => MyResult a -> S.Set a
resultToSet = S.fromList . HS.toList . resultToHashSet

L'interfaccia esterna è quasi la stessa (l'unica modifica è che l'istanza Monoid ora ha vincoli leggermente diversi).

Un altro modo per dare ai clienti molta flessibilità è lasciare che la collezione sia completamente astratta. Questo può essere ben espresso usando monoids . L'output della tua funzione sarà un monoide polimorfico, abbiamo solo bisogno di un modo per creare singleton. Ad esempio, se vogliamo generalizzare enumFromTo , potremmo scrivere

enumCol :: (Enum a, Monoid c) => a -> a -> ((a -> c) -> c)
enumCol f t singleton = mconcat . map singleton $ enumFromTo f t

È quindi responsabilità del cliente utilizzare un monoide con un'effettiva operazione mappend .

Con RankNTypes il tipo di ritorno potrebbe anche essere incapsulato usando type come

type OutCol e c = (Monoid c) => (e -> c) -> c

Il vantaggio ulteriore è che nei casi alcuni questo può essere molto efficace grazie alla pigrizia. Ad esempio, il seguente ritorna immediatamente:

enumCol 0 (10^20) (First . Just)
    
risposta data 03.05.2014 - 20:17
fonte
0

Perché non ci sono solo tre (o N) funzioni separate, una per ciascuno dei tre (o N) tipi separati di collezioni?

    
risposta data 03.05.2014 - 19:55
fonte

Leggi altre domande sui tag