HLists non è altro che un modo contorto di scrivere le tuple?


144

Sono davvero interessato a scoprire dove sono le differenze e, più in generale, per identificare i casi d'uso canonici in cui gli elenchi HL non possono essere utilizzati (o meglio, non producono alcun vantaggio rispetto agli elenchi regolari).

(Sono consapevole che ci sono 22 (credo) TupleNin Scala, mentre uno ha bisogno solo di una singola HList, ma non è questo il tipo di differenza concettuale a cui sono interessato.)

Ho segnato un paio di domande nel testo qui sotto. In realtà potrebbe non essere necessario rispondere loro, sono più pensati per sottolineare cose che non sono chiare per me e per guidare la discussione in determinate direzioni.

Motivazione

Di recente ho visto un paio di risposte su SO in cui le persone hanno suggerito di utilizzare HList (ad esempio, come fornito da Shapeless ), inclusa una risposta eliminata a questa domanda . Ha dato origine a questa discussione , che a sua volta ha suscitato questa domanda.

Intro

Mi sembra che le liste siano utili solo quando si conosce staticamente il numero di elementi e i loro tipi precisi. Il numero in realtà non è cruciale, ma sembra improbabile che tu abbia mai bisogno di generare un elenco con elementi di tipi diversi ma staticamente noti con precisione, ma che non conosci staticamente il loro numero. Domanda 1: potresti persino scrivere un esempio del genere, ad esempio in un ciclo? La mia intuizione è che avere un hlist staticamente preciso con un numero staticamente sconosciuto di elementi arbitrari (arbitrario rispetto a una determinata gerarchia di classi) non è compatibile.

HLists vs. Tuples

Se questo è vero, cioè conosci staticamente numero e tipo - Domanda 2: perché non usare semplicemente una n-tupla? Certo, puoi tipicamente mappare e piegare su una HList (che puoi anche, ma non tipicamente, fare su una tupla con l'aiuto di productIterator), ma poiché il numero e il tipo degli elementi sono staticamente noti, probabilmente potresti semplicemente accedere agli elementi tupla direttamente ed eseguire le operazioni.

D'altra parte, se la funzione fmappata su una hlist è così generica da accettare tutti gli elementi - Domanda 3: perché non utilizzarla tramite productIterator.map? Ok, una differenza interessante potrebbe derivare dal sovraccarico del metodo: se avessimo diversi sovraccarichi f, avere le informazioni di tipo più forte fornite dall'hlist (contrariamente al productIterator) potrebbe consentire al compilatore di scegliere uno più specifico f. Tuttavia, non sono sicuro che funzionerebbe effettivamente in Scala, poiché i metodi e le funzioni non sono gli stessi.

HList e input dell'utente

Basandosi sullo stesso presupposto, vale a dire, è necessario conoscere staticamente il numero e i tipi di elementi - Domanda 4: è possibile utilizzare le liste in situazioni in cui gli elementi dipendono da qualsiasi tipo di interazione dell'utente? Ad esempio, immagina di popolare una lista con elementi all'interno di un ciclo; gli elementi vengono letti da qualche parte (UI, file di configurazione, interazione attore, rete) fino a quando una determinata condizione rimane valida. Quale sarebbe il tipo di hlist? Simile per una specifica di interfaccia getElements: HList che [...] dovrebbe funzionare con elenchi di lunghezza staticamente sconosciuta e che consente al componente A in un sistema di ottenere tale elenco di elementi arbitrari dal componente B.

Risposte:


144

Rispondere alle domande da una a tre: una delle principali applicazioni HListsè l'astrazione sull'arità. L'arità è tipicamente conosciuta staticamente in un dato sito di utilizzo di un'astrazione, ma varia da sito a sito. Prendi questo, dagli esempi informi ,

def flatten[T <: Product, L <: HList](t : T)
  (implicit hl : HListerAux[T, L], flatten : Flatten[L]) : flatten.Out =
    flatten(hl(t))

val t1 = (1, ((2, 3), 4))
val f1 = flatten(t1)     // Inferred type is Int :: Int :: Int :: Int :: HNil
val l1 = f1.toList       // Inferred type is List[Int]

val t2 = (23, ((true, 2.0, "foo"), "bar"), (13, false))
val f2 = flatten(t2)
val t2b = f2.tupled
// Inferred type of t2b is (Int, Boolean, Double, String, String, Int, Boolean)

Senza usare HLists(o qualcosa di equivalente) per astrarre sull'arità degli argomenti di tupla flattensarebbe impossibile avere un'unica implementazione che possa accettare argomenti di queste due forme molto diverse e trasformarle in un modo sicuro.

È probabile che la capacità di astrarre sull'arità sia interessante ovunque siano coinvolte le arità fisse: così come le tuple, come sopra, che includono elenchi di parametri metodo / funzione e classi di casi. Vedi qui per esempi di come possiamo astrarre sull'arità di classi di casi arbitrarie per ottenere istanze di classe di tipo quasi automaticamente,

// A pair of arbitrary case classes
case class Foo(i : Int, s : String)
case class Bar(b : Boolean, s : String, d : Double)

// Publish their `HListIso`'s
implicit def fooIso = Iso.hlist(Foo.apply _, Foo.unapply _)
implicit def barIso = Iso.hlist(Bar.apply _, Bar.unapply _)

// And now they're monoids ...

implicitly[Monoid[Foo]]
val f = Foo(13, "foo") |+| Foo(23, "bar")
assert(f == Foo(36, "foobar"))

implicitly[Monoid[Bar]]
val b = Bar(true, "foo", 1.0) |+| Bar(false, "bar", 3.0)
assert(b == Bar(true, "foobar", 4.0))

Non c'è iterazione di runtime qui, ma c'è la duplicazione , che l'uso di HLists(o strutture equivalenti) può eliminare. Naturalmente, se la tolleranza per la piastra di cottura ripetitiva è elevata, puoi ottenere lo stesso risultato scrivendo più implementazioni per ogni forma che ti interessa.

Nella terza domanda ti chiedi "... se la funzione f che mappi su una hlist è così generica da accettare tutti gli elementi ... perché non utilizzarla tramite productIterator.map?". Se la funzione che mappi su una HList è davvero della forma, Any => Tallora la mappatura productIteratorti servirà perfettamente. Ma le funzioni del modulo Any => Tnon sono in genere così interessanti (almeno, a meno che non scrivano cast internamente). shapeless fornisce una forma di valore della funzione polimorfica che consente al compilatore di selezionare casi specifici del tipo esattamente nel modo in cui sei in dubbio. Per esempio,

// size is a function from values of arbitrary type to a 'size' which is
// defined via type specific cases
object size extends Poly1 {
  implicit def default[T] = at[T](t => 1)
  implicit def caseString = at[String](_.length)
  implicit def caseList[T] = at[List[T]](_.length)
}

scala> val l = 23 :: "foo" :: List('a', 'b') :: true :: HNil
l: Int :: String :: List[Char] :: Boolean :: HNil =
  23 :: foo :: List(a, b) :: true :: HNil

scala> (l map size).toList
res1: List[Int] = List(1, 3, 2, 1)

Per quanto riguarda la tua domanda quattro, sull'input dell'utente, ci sono due casi da considerare. La prima è la situazione in cui possiamo stabilire dinamicamente un contesto che garantisce l'ottenimento di una condizione statica nota. In questo tipo di scenari è perfettamente possibile applicare tecniche informe, ma chiaramente a condizione che se la condizione statica non si ottiene in fase di esecuzione, dobbiamo seguire un percorso alternativo. Non sorprende che ciò significhi che i metodi sensibili alle condizioni dinamiche devono produrre risultati opzionali. Ecco un esempio usando HLists,

trait Fruit
case class Apple() extends Fruit
case class Pear() extends Fruit

type FFFF = Fruit :: Fruit :: Fruit :: Fruit :: HNil
type APAP = Apple :: Pear :: Apple :: Pear :: HNil

val a : Apple = Apple()
val p : Pear = Pear()

val l = List(a, p, a, p) // Inferred type is List[Fruit]

Il tipo di lnon acquisisce la lunghezza dell'elenco o i tipi precisi dei suoi elementi. Tuttavia, se ci aspettiamo che abbia una forma specifica (ad es. Se deve conformarsi a qualche schema noto noto), allora possiamo tentare di stabilire tale fatto e agire di conseguenza,

scala> import Traversables._
import Traversables._

scala> val apap = l.toHList[Apple :: Pear :: Apple :: Pear :: HNil]
res0: Option[Apple :: Pear :: Apple :: Pear :: HNil] =
  Some(Apple() :: Pear() :: Apple() :: Pear() :: HNil)

scala> apap.map(_.tail.head)
res1: Option[Pear] = Some(Pear())

Vi sono altre situazioni in cui potremmo non preoccuparci della lunghezza effettiva di un determinato elenco, a parte il fatto che ha la stessa lunghezza di un altro elenco. Ancora una volta, questo è qualcosa che supporta informe, sia completamente staticamente, sia in un contesto statico / dinamico misto come sopra. Vedi qui per un esempio esteso.

È vero, come si osserva, che tutti questi meccanismi richiedono che siano disponibili informazioni di tipo statico, almeno in modo condizionale, e che sembrerebbe escludere queste tecniche dall'essere utilizzabili in un ambiente completamente dinamico, guidato da dati non tipizzati forniti esternamente. Ma con l'avvento del supporto per la compilazione di runtime come componente della riflessione di Scala nella 2.10, anche questo non è più un ostacolo insuperabile ... possiamo usare la compilazione di runtime per fornire una forma di stadiazione leggera e far eseguire la nostra tipizzazione statica in fase di runtime in risposta a dati dinamici: estratto dal precedente precedente ... segui il link per l'esempio completo,

val t1 : (Any, Any) = (23, "foo") // Specific element types erased
val t2 : (Any, Any) = (true, 2.0) // Specific element types erased

// Type class instances selected on static type at runtime!
val c1 = stagedConsumeTuple(t1) // Uses intString instance
assert(c1 == "23foo")

val c2 = stagedConsumeTuple(t2) // Uses booleanDouble instance
assert(c2 == "+2.0")

Sono sicuro che @PLT_Borat avrà qualcosa da dire al riguardo, dati i suoi saggi commenti sui linguaggi di programmazione tipicamente dipendenti ;-)


2
Sono un po 'perplesso dall'ultima parte della tua risposta - ma anche molto incuriosito! Grazie per l'ottima risposta e per i numerosi riferimenti, sembra che avrò molte letture da fare :-)
Malte Schwerhoff,

1
Astrarre sull'arità è estremamente utile. ScalaMock, purtroppo, soffre di notevoli duplicazioni perché i vari FunctionNtratti non sanno astrarre sull'arità: github.com/paulbutcher/ScalaMock/blob/develop/core/src/main/… github.com/paulbutcher/ScalaMock/blob / sviluppo / core / src / main / ... Purtroppo io non sono a conoscenza di alcun modo che posso usare Shapeless per evitare questo, dato che ho bisogno di trattare con "reale" FunctionNs
Paul Butcher

1
Ho fatto un esempio (piuttosto artificiale) - ideone.com/sxIw1 -, che è sulla falsariga della domanda uno. Questo potrebbe trarre vantaggio dagli elenchi, forse in combinazione con "la tipizzazione statica eseguita in fase di esecuzione in risposta a dati dinamici"? (Non sono ancora sicuro di cosa si tratti esattamente di quest'ultimo)
Malte Schwerhoff il

18

Per essere chiari, una HList non è altro che una pila di Tuple2zucchero leggermente diverso in cima.

def hcons[A,B](head : A, tail : B) = (a,b)
def hnil = Unit

hcons("foo", hcons(3, hnil)) : (String, (Int, Unit))

Quindi la tua domanda riguarda essenzialmente le differenze tra l'uso di tuple nidificate rispetto a tuple piatte, ma i due sono isomorfi, quindi alla fine non c'è davvero alcuna differenza se non la praticità in cui le funzioni di libreria possono essere usate e quale notazione può essere usata.


le tuple possono comunque essere mappate su liste e ritorno, quindi c'è un chiaro isomorfismo.
Erik Kaplun,

10

Ci sono molte cose che non puoi fare (bene) con le tuple:

  • scrivere una funzione di prepend / append generica
  • scrivere una funzione inversa
  • scrivere una funzione concat
  • ...

Puoi fare tutto questo con le tuple ovviamente, ma non nel caso generale. Quindi l'utilizzo di HLists rende il codice più ASCIUTTO.


8

Posso spiegarlo in un linguaggio super semplice:

La denominazione tupla vs lista non è significativa. Gli elenchi H potrebbero essere denominati HTuple. La differenza è che in Scala + Haskell, puoi farlo con una tupla (usando la sintassi Scala):

def append2[A,B,C](in: (A,B), v: C) : (A,B,C) = (in._1, in._2, v)

per prendere una tupla di input esattamente di due elementi di qualsiasi tipo, aggiungere un terzo elemento e restituire una tupla completamente digitata con esattamente tre elementi. Ma mentre questo è completamente generico rispetto ai tipi, deve specificare esplicitamente le lunghezze di input / output.

Ciò che una HList in stile Haskell ti consente di fare è rendere questa lunghezza eccessiva generica, quindi puoi aggiungere a qualsiasi lunghezza di tupla / elenco e ottenere una tupla / elenco completamente staticamente digitata. Questo vantaggio si applica anche a raccolte tipizzate in modo omogeneo in cui è possibile aggiungere un int a un elenco di esattamente n ints e recuperare un elenco tipicamente statico per avere esattamente (n + 1) ints senza specificare esplicitamente n.

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.