Le liste H sono nient’altro che un complicato modo di scrivere tuple?

Sono davvero interessato a scoprire dove sono le differenze e, più in generale, a identificare i casi d’uso canonico in cui le liste H non possono essere utilizzate (o, piuttosto, non offrono alcun vantaggio rispetto alle liste regolari).

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

Ho segnato un paio di domande nel testo qui sotto. Potrebbe non essere effettivamente necessario rispondere, sono più rivolti a sottolineare cose che non sono chiare a me, e per guidare la discussione in certe direzioni.

Motivazione

Di recente ho visto un paio di risposte su SO in cui le persone suggerivano di utilizzare le liste HL (ad esempio, come fornito da Shapeless ), inclusa una risposta cancellata 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 il numero di elementi e i loro tipi precisi in modo statico. Il numero in realtà non è cruciale, ma sembra improbabile che tu abbia mai bisogno di generare un elenco con elementi di tipi noti diversi ma staticamente precisi, ma che non conosci staticamente il loro numero. Domanda 1: potresti scrivere un esempio simile, ad esempio, in un ciclo? La mia intuizione è che avere una hlist staticamente precisa 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 il numero e il tipo – Domanda 2: perché non usare solo una n-tupla? Certo, puoi tipicamente mappare e piegare una HList (che puoi anche, ma non tipicamente, fare su una tupla con l’aiuto di productIterator ), ma dal momento che il numero e il tipo degli elementi sono staticamente noti probabilmente potresti semplicemente accedere alla tupla elementi direttamente ed eseguire le operazioni.

D’altra parte, se la funzione di mappare su una hlist è così generica da accettare tutti gli elementi – Domanda 3: perché non usarla tramite productIterator.map ? Ok, una differenza interessante potrebbe derivare dall’overloading del metodo: se avessimo più f overload, avere le informazioni di tipo più forte fornite dalla hlist (al contrario di productIterator) potrebbe consentire al compilatore di scegliere un f più specifico. Tuttavia, non sono sicuro che ciò funzionerebbe effettivamente in Scala, poiché i metodi e le funzioni non sono gli stessi.

HListe e input dell’utente

Basandosi sullo stesso presupposto, ovvero che è necessario conoscere staticamente il numero e il tipo degli elementi – Domanda 4: le hlists possono essere utilizzate in situazioni in cui gli elementi dipendono da qualsiasi tipo di interazione dell’utente? Ad esempio, immagina di compilare una lista con elementi all’interno di un ciclo; gli elementi vengono letti da qualche parte (interfaccia utente, file di configurazione, interazione con l’attore, rete) finché non viene mantenuta una determinata condizione. Quale sarebbe il tipo di hlist? Simile per una specifica dell’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.

Affrontare le domande da 1 a 3: una delle principali applicazioni di HLists è l’astrazione sull’arity. In genere, Arity è conosciuto staticamente in un determinato sito di utilizzo di un’astrazione, ma varia da sito a sito. Prendi questo, dagli esempi senza forma,

 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 l'aritmetica degli argomenti della tupla per flatten sarebbe imansible avere una singola implementazione che possa accettare argomenti di queste due forms molto diverse e trasformarle in un modo sicuro.

È probabile che la capacità di astrarre l'arità sia di interesse ovunque siano coinvolte le entity framework 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 l'arità di case classs arbitrarie per ottenere istanze di classi di tipi quasi automaticamente,

 // A pair of arbitrary case classs 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'è nessuna iterazione di runtime qui, ma c'è una duplicazione , che l'uso di HLists (o strutture equivalenti) può eliminare. Naturalmente, se la tua tolleranza per la piastra ripetitiva è elevata, puoi ottenere lo stesso risultato scrivendo più implementazioni per ogni forma che ti interessa.

Nella terza domanda chiedi "... se la funzione di mappare su una hlist è così generica da accettare tutti gli elementi ... perché non utilizzarla tramite productIterator.map?". Se la funzione che mappi su una lista HL è in realtà della forma Any => T mapping su productIterator ti servirà perfettamente. Ma le funzioni della forma Any => T non sono in genere così interessanti (almeno non lo sono a meno che non digitino il 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 si è 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 domanda quattro, sull'input dell'utente, ci sono due casi da considerare. La prima è rappresentata da situazioni in cui è ansible stabilire dynamicmente un contesto che garantisce l'ottenimento di una condizione statica nota. In questi tipi di scenari è perfettamente ansible applicare tecniche informi, ma chiaramente a condizione che, se la condizione statica non si ottiene in fase di esecuzione, dobbiamo seguire un percorso alternativo. Non sorprende, questo significa che i metodi che sono sensibili alle condizioni dinamiche devono produrre risultati opzionali. Ecco un esempio usando HList s,

 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 l non cattura la lunghezza dell'elenco o i tipi precisi dei suoi elementi. Tuttavia, se ci aspettiamo che abbia un modulo specifico (cioè, se dovrebbe conformarsi a uno schema noto e definito), allora possiamo tentare di stabilire quel 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()) 

Ci sono altre situazioni in cui potremmo non interessarci della lunghezza effettiva di una data lista, a parte il fatto che è della stessa lunghezza di qualche altra lista. Di nuovo, questo è qualcosa che supporta senza forma, sia completamente staticamente, sia anche in un contesto statico / dinamico misto come sopra. Vedi qui per un esempio esteso.

È vero, come si osserva, che tutti questi meccanismi richiedono che le informazioni di tipo statico siano disponibili, almeno in modo condizionale, e che sembrerebbe escludere queste tecniche dall'essere utilizzabili in un ambiente completamente dinamico, completamente guidato da dati non tipizzati forniti esternamente. Ma con l'avvento del supporto per la compilazione runtime come componente di Scala reflection in 2.10, anche questo non è più un ostacolo insormontabile ... possiamo usare la compilazione runtime per fornire una forma di staging leggero e avere la nostra tipizzazione statica eseguita in runtime in risposta a dati dinamici: estratto dal precedente sotto ... 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, visti i suoi saggi commenti sui linguaggi di programmazione tipicamente dipendenti 😉

Giusto per essere chiari, un HList è essenzialmente nient’altro che una pila di Tuple2 con uno zucchero 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’utilizzo di tuple annidate e tuple piatte, ma i due sono isomorfi, quindi alla fine non c’è alcuna differenza tranne la convenienza in cui le funzioni della libreria possono essere utilizzate e quale notazione può essere usata.

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

  • scrivere una funzione di antefatto / aggiunta generica
  • scrivere una funzione inversa
  • scrivere una funzione concat

Puoi fare tutto questo con le tuple, naturalmente, ma non nel caso generale. Quindi usare HLists rende il tuo codice più ASCIUTTO.

Posso spiegarlo in un linguaggio super semplice:

La denominazione di tuple vs list non è significativa. HLists potrebbe essere chiamato HTuples. La differenza è che in Scala + Haskell, puoi farlo con una tupla (usando la syntax di 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 di esattamente due elementi di qualsiasi tipo, aggiungere un terzo elemento e restituire una tupla completamente tipizzata con esattamente tre elementi. Ma mentre questo è completamente generico sui tipi, deve specificare esplicitamente le lunghezze di input / output.

Lo stile HL di Haskell che ti permette di fare questo generico su tutta la lunghezza, in modo da poter aggiungere a qualsiasi lunghezza di tuple / lista e ottenere una tupla / lista completamente tipizzata in modo statico. Questo vantaggio si applica anche alle raccolte tipizzate in modo omogeneo in cui è ansible aggiungere un int a un elenco di n esattamente e recuperare un elenco che è tipizzato staticamente per avere esattamente (n + 1) valori senza specificare esplicitamente n.