Les HLists ne sont-ils rien de plus qu'une manière compliquée d'écrire des tuples?

144

Je suis vraiment intéressé à découvrir où se trouvent les différences, et plus généralement, à identifier les cas d'utilisation canoniques où les HLists ne peuvent pas être utilisées (ou plutôt, ne donnent aucun avantage par rapport aux listes régulières).

(Je suis conscient qu'il y en a 22 (je crois) TupleNdans Scala, alors qu'on n'a besoin que d'une seule HList, mais ce n'est pas le genre de différence conceptuelle qui m'intéresse.)

J'ai marqué quelques questions dans le texte ci-dessous. Il n'est peut-être pas nécessaire d'y répondre, ils visent davantage à signaler des choses qui ne me sont pas claires et à orienter la discussion dans certaines directions.

Motivation

J'ai récemment vu quelques réponses sur SO où les gens ont suggéré d'utiliser les HLists (par exemple, comme fourni par Shapeless ), y compris une réponse supprimée à cette question . Elle a donné lieu à cette discussion , qui à son tour a suscité cette question.

Intro

Il me semble que les hlists ne sont utiles que lorsque vous connaissez le nombre d'éléments et leurs types précis de manière statique. Le nombre n'est en fait pas crucial, mais il semble peu probable que vous ayez jamais besoin de générer une liste avec des éléments de types différents mais statiquement connus avec précision, mais que vous ne connaissiez pas statiquement leur nombre. Question 1: Pourriez-vous même écrire un tel exemple, par exemple, en boucle? Mon intuition est qu'avoir une hlist statiquement précise avec un nombre statiquement inconnu d'éléments arbitraires (arbitraires par rapport à une hiérarchie de classes donnée) n'est tout simplement pas compatible.

HLists et tuples

Si cela est vrai, c'est-à-dire que vous connaissez statiquement le nombre et le type - Question 2: pourquoi ne pas simplement utiliser un n-uplet? Bien sûr, vous pouvez taper et replier en toute sécurité une HList (ce que vous pouvez également, mais pas en toute sécurité, faire sur un tuple à l'aide de productIterator), mais comme le nombre et le type des éléments sont statiquement connus, vous pouvez probablement accéder aux éléments du tuple. directement et effectuez les opérations.

En revanche, si la fonction que fvous mappez sur une hlist est si générique qu'elle accepte tous les éléments - Question 3: pourquoi ne pas l'utiliser via productIterator.map? Ok, une différence intéressante pourrait venir de la surcharge de méthode: si nous avions plusieurs surchargés f, avoir les informations de type les plus fortes fournies par la hlist (contrairement à productIterator) pourrait permettre au compilateur de choisir un fichier plus spécifique f. Cependant, je ne sais pas si cela fonctionnerait réellement dans Scala, car les méthodes et les fonctions ne sont pas les mêmes.

HLists et entrée utilisateur

En partant de la même hypothèse, à savoir que vous devez connaître statiquement le nombre et les types d'éléments - Question 4: les hlists peuvent-elles être utilisées dans des situations où les éléments dépendent de tout type d'interaction utilisateur? Par exemple, imaginez remplir une hlist avec des éléments à l'intérieur d'une boucle; les éléments sont lus à partir de quelque part (interface utilisateur, fichier de configuration, interaction des acteurs, réseau) jusqu'à ce qu'une certaine condition se vérifie. Quel serait le type de la hlist? Similaire pour une spécification d'interface getElements: HList [...] qui devrait fonctionner avec des listes de longueur statiquement inconnue, et qui permet au composant A dans un système d'obtenir une telle liste d'éléments arbitraires du composant B.

Malte Schwerhoff
la source

Réponses:

144

Répondre aux questions un à trois: l'une des principales applications de l' HListsabstraction sur l'arité. Arity est généralement statiquement connue sur tout site d'utilisation donné d'une abstraction, mais varie d'un site à l'autre. Prenez ceci, des exemples d'informe ,

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)

Sans utiliser HLists(ou quelque chose d'équivalent) pour faire abstraction de l'arité des arguments de tuple, flattenil serait impossible d'avoir une seule implémentation qui pourrait accepter des arguments de ces deux formes très différentes et les transformer d'une manière sûre de type.

La capacité d'abstraction de l'arité est susceptible d'être intéressante partout où des arités fixes sont impliquées: ainsi que des tuples, comme ci-dessus, qui incluent des listes de paramètres de méthode / fonction et des classes de cas. Voir ici des exemples de la façon dont nous pourrions faire abstraction de l'arité des classes de cas arbitraires pour obtenir des instances de classe de type presque automatiquement,

// 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))

Il n'y a pas d' itération d' exécution ici, mais il y a duplication , que l'utilisation de HLists(ou des structures équivalentes) peut éliminer. Bien sûr, si votre tolérance pour le passe-partout répétitif est élevée, vous pouvez obtenir le même résultat en écrivant plusieurs implémentations pour chaque forme qui vous tient à cœur.

Dans la troisième question, vous demandez "... si la fonction f que vous mappez sur une hlist est si générique qu'elle accepte tous les éléments ... pourquoi ne pas l'utiliser via productIterator.map?". Si la fonction que vous mappez sur une HList est vraiment de la forme, Any => Tle mappage productIteratorvous sera parfaitement utile. Mais les fonctions du formulaire Any => Tne sont généralement pas très intéressantes (du moins, elles ne le sont pas à moins qu'elles ne tapent en interne). shapeless fournit une forme de valeur de fonction polymorphe qui permet au compilateur de sélectionner des cas spécifiques au type exactement de la manière dont vous doutez. Par exemple,

// 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)

En ce qui concerne votre quatrième question, sur les commentaires des utilisateurs, il y a deux cas à considérer. La première concerne les situations dans lesquelles nous pouvons établir dynamiquement un contexte qui garantit qu'une condition statique connue est obtenue. Dans ces types de scénarios, il est parfaitement possible d'appliquer des techniques informes, mais clairement à la condition que si la condition statique ne se produit pas à l'exécution, nous devons suivre un chemin alternatif. Sans surprise, cela signifie que les méthodes sensibles aux conditions dynamiques doivent donner des résultats optionnels. Voici un exemple utilisant 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]

Le type de lne capture pas la longueur de la liste, ni les types précis de ses éléments. Cependant, si nous nous attendons à ce qu'il ait une forme spécifique (c'est-à-dire s'il doit se conformer à un schéma connu et fixe), alors nous pouvons essayer d'établir ce fait et agir en conséquence,

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())

Il y a d'autres situations où nous pouvons ne pas nous soucier de la longueur réelle d'une liste donnée, à part qu'elle est de la même longueur qu'une autre liste. Encore une fois, c'est quelque chose qui prend en charge l'informe, à la fois de manière totalement statique, et également dans un contexte mixte statique / dynamique comme ci-dessus. Voir ici pour un exemple détaillé.

Il est vrai, comme vous l'observez, que tous ces mécanismes nécessitent que des informations de type statique soient disponibles, au moins sous condition, et cela semblerait exclure ces techniques d'être utilisables dans un environnement complètement dynamique, entièrement piloté par des données non typées fournies en externe. Mais avec l'avènement de la prise en charge de la compilation d'exécution en tant que composant de la réflexion Scala dans la version 2.10, même ce n'est plus un obstacle insurmontable ... nous pouvons utiliser la compilation d'exécution pour fournir une forme de mise en scène légère et faire effectuer notre typage statique au moment de l'exécution en réponse aux données dynamiques: extrait de ce qui précède ci-dessous ... suivez le lien pour l'exemple complet,

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")

Je suis sûr que @PLT_Borat aura quelque chose à dire à ce sujet, compte tenu de ses sages commentaires sur les langages de programmation typés de manière dépendante ;-)

Miles Sabin
la source
2
Je suis un peu intrigué par la dernière partie de votre réponse - mais aussi très intrigué! Merci pour votre excellente réponse et les nombreuses références, on dirait que j'ai beaucoup de lecture à faire :-)
Malte Schwerhoff
1
Abstraction sur l'arité est extrêmement utile. ScalaMock, malheureusement, souffre d'une duplication considérable car les différents FunctionNtraits ne savent pas comment faire l'abstraction sur l'arité: github.com/paulbutcher/ScalaMock/blob/develop/core/src/main / ... github.com/paulbutcher/ScalaMock/blob / develop / core / src / main /… Malheureusement, je ne connais aucun moyen d'utiliser Shapeless pour éviter cela, étant donné que je dois faire face à de «vrais» FunctionNs
Paul Butcher
1
J'ai inventé un exemple (assez artificiel) - ideone.com/sxIw1 -, qui va dans le sens de la première question. Cela pourrait-il bénéficier des hlists, peut-être en combinaison avec "un typage statique effectué au moment de l'exécution en réponse à des données dynamiques"? (Je ne sais toujours pas de quoi il s'agit exactement)
Malte Schwerhoff
18

Pour être clair, une HList n'est essentiellement rien de plus qu'une pile de Tuple2sucre légèrement différent sur le dessus.

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

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

Donc, votre question porte essentiellement sur les différences entre l'utilisation de tuples imbriqués et de tuples plats, mais les deux sont isomorphes donc à la fin il n'y a vraiment aucune différence, sauf la commodité dans laquelle les fonctions de bibliothèque peuvent être utilisées et la notation qui peut être utilisée.

Dan Burton
la source
Les tuples peuvent être mappés sur des hlists et inversement de toute façon, il y a donc un isomorphisme clair.
Erik Kaplun
10

Il y a beaucoup de choses que vous ne pouvez pas (bien) faire avec les tuples:

  • écrire une fonction générique de préfixe / ajout
  • écrire une fonction inverse
  • écrire une fonction concat
  • ...

Vous pouvez faire tout cela avec des tuples bien sûr, mais pas dans le cas général. Ainsi, l'utilisation de HLists rend votre code plus SEC.

Kim Stebel
la source
8

Je peux expliquer cela dans un langage très simple:

La dénomination tuple vs liste n'est pas significative. Les HLists peuvent être nommées HTuples. La différence est que dans Scala + Haskell, vous pouvez le faire avec un tuple (en utilisant la syntaxe Scala):

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

pour prendre un tuple d'entrée d'exactement deux éléments de n'importe quel type, ajouter un troisième élément et renvoyer un tuple entièrement typé avec exactement trois éléments. Mais bien que ce soit complètement générique sur les types, il doit spécifier explicitement les longueurs d'entrée / sortie.

Ce qu'une HList de style Haskell vous permet de faire est de rendre ce générique sur toute la longueur, de sorte que vous pouvez ajouter à n'importe quelle longueur de tuple / liste et récupérer un tuple / liste entièrement statiquement typé. Cet avantage s'applique également aux collections de typage homogène où vous pouvez ajouter un int à une liste d'exactement n entiers et récupérer une liste typée statiquement pour avoir exactement (n + 1) entiers sans spécifier explicitement n.

argile
la source