¿Las HLists no son más que una forma complicada de escribir tuplas?

144

Estoy realmente interesado en descubrir dónde están las diferencias y, en general, identificar los casos de uso canónico en los que no se pueden usar las listas H (o mejor dicho, no producen ningún beneficio sobre las listas regulares).

(Soy consciente de que hay 22 (creo) TupleNen Scala, mientras que uno solo necesita una sola HList, pero ese no es el tipo de diferencia conceptual que me interesa).

He marcado un par de preguntas en el texto a continuación. Puede que en realidad no sea necesario responderlas, están más destinadas a señalar cosas que no están claras para mí y a guiar la discusión en ciertas direcciones.

Motivación

Recientemente he visto un par de respuestas sobre SO en las que las personas sugirieron usar Listas H (por ejemplo, según lo proporcionado por Shapeless ), incluida una respuesta eliminada a esta pregunta . Dio lugar a esta discusión , que a su vez provocó esta pregunta.

Introducción

Me parece que las listas solo son útiles cuando se conoce el número de elementos y sus tipos precisos estáticamente. El número en realidad no es crucial, pero parece poco probable que alguna vez necesite generar una lista con elementos de tipos diferentes pero conocidos estadísticamente con precisión, pero que no sepa estáticamente su número. Pregunta 1: ¿Podría incluso escribir un ejemplo así, por ejemplo, en un bucle? Mi intuición es que tener una lista h estáticamente precisa con un número estáticamente desconocido de elementos arbitrarios (arbitrario en relación con una determinada jerarquía de clases) simplemente no es compatible.

HListas contra tuplas

Si esto es cierto, es decir, conoce estáticamente el número y el tipo. Pregunta 2: ¿por qué no usar una n-tupla? Claro, puede escribir de forma segura en un mapa y plegar sobre una HList (que también puede, pero no escribir de forma segura, sobre una tupla con la ayuda de productIterator), pero dado que el número y el tipo de elementos son estáticos, probablemente pueda acceder a los elementos de la tupla. directamente y realizar las operaciones.

Por otro lado, si la función fque mapea en una hlist es tan genérica que acepta todos los elementos - Pregunta 3: ¿por qué no usarla productIterator.map? Ok, una diferencia interesante podría venir de la sobrecarga de métodos: si tuviéramos varias sobrecargadas f, tener la información de tipo más fuerte proporcionada por hlist (en contraste con el ProductIterator) podría permitirle al compilador elegir una más específica f. Sin embargo, no estoy seguro de si eso realmente funcionaría en Scala, ya que los métodos y las funciones no son lo mismo.

Listas y entrada del usuario

Partiendo de la misma suposición, a saber, que necesita saber el número y los tipos de los elementos de forma estática. Pregunta 4: ¿ se pueden utilizar las listas en situaciones en las que los elementos dependen de cualquier tipo de interacción del usuario? Por ejemplo, imagine llenar una lista con elementos dentro de un bucle; los elementos se leen desde algún lugar (UI, archivo de configuración, interacción del actor, red) hasta que se cumpla una determinada condición. ¿Cuál sería el tipo de hlist? Similar para una especificación de interfaz getElements: HList [...] que debería funcionar con listas de longitud estáticamente desconocida, y que permite que el componente A en un sistema obtenga dicha lista de elementos arbitrarios del componente B.

Malte Schwerhoff
fuente

Respuestas:

144

Abordar las preguntas de la una a la tres: una de las principales aplicaciones HListses la abstracción sobre la aridad. Arity es típicamente conocido estáticamente en cualquier sitio de uso dado de una abstracción, pero varía de un sitio a otro. Tome esto, de los ejemplos sin 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)

Sin usar HLists(o algo equivalente) para abstraer sobre la aridad de los argumentos de la tupla flatten, sería imposible tener una implementación única que pudiera aceptar argumentos de estas dos formas muy diferentes y transformarlos de una manera segura.

Es probable que la capacidad de abstraer sobre arity sea de interés en cualquier lugar donde estén involucradas arities fijas: así como tuplas, como arriba, que incluyen listas de parámetros de método / función y clases de casos. Vea aquí ejemplos de cómo podríamos abstraer la aridad de las clases de casos arbitrarios para obtener instancias de clases de tipos casi automáticamente,

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

Aquí no hay iteración de tiempo de ejecución , pero hay duplicación , que el uso de HLists(o estructuras equivalentes) puede eliminar. Por supuesto, si su tolerancia para repetitivo repetitivo es alta, puede obtener el mismo resultado escribiendo múltiples implementaciones para cada forma que le interese.

En la pregunta tres, usted pregunta "... si la función que asigna sobre una lista h es tan genérica que acepta todos los elementos ... ¿por qué no usarla a través de productIterator.map?". Si la función que mapea sobre una HList es realmente la forma Any => T, mapear productIteratorle servirá perfectamente bien. Pero las funciones de la forma Any => Tno suelen ser tan interesantes (al menos, no lo son a menos que escriban cast internamente). sin forma proporciona una forma de valor de función polimórfica que permite al compilador seleccionar casos específicos de tipo exactamente de la forma en que tiene dudas. Por ejemplo,

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

Con respecto a su pregunta cuatro, sobre la entrada del usuario, hay dos casos a considerar. El primero es situaciones en las que podemos establecer dinámicamente un contexto que garantiza que se obtenga una condición estática conocida. En este tipo de escenarios, es perfectamente posible aplicar técnicas sin forma, pero claramente con la condición de que si la condición estática no se obtiene en tiempo de ejecución, entonces tenemos que seguir un camino alternativo. Como era de esperar, esto significa que los métodos que son sensibles a las condiciones dinámicas tienen que producir resultados opcionales. Aquí hay un ejemplo 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]

El tipo de lno captura la longitud de la lista o los tipos precisos de sus elementos. Sin embargo, si esperamos que tenga una forma específica (es decir, si debe ajustarse a un esquema fijo conocido), entonces podemos intentar establecer ese hecho y actuar en consecuencia,

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

Hay otras situaciones en las que podría no importarnos la longitud real de una lista dada, además de que es la misma longitud que alguna otra lista. Nuevamente, esto es algo que soporta sin forma, tanto estáticamente como en un contexto estático / dinámico mixto como el anterior. Vea aquí para un ejemplo extendido.

Es cierto, como observa, que todos estos mecanismos requieren que esté disponible información de tipo estático, al menos condicionalmente, y eso parecería excluir que estas técnicas sean utilizables en un entorno completamente dinámico, totalmente impulsado por datos sin tipo proporcionados externamente. Pero con el advenimiento del soporte para la compilación en tiempo de ejecución como un componente de la reflexión de Scala en 2.10, incluso esto ya no es un obstáculo insuperable ... podemos usar la compilación en tiempo de ejecución para proporcionar una forma de puesta en escena ligera y hacer que nuestra tipificación estática se realice en tiempo de ejecución en respuesta a datos dinámicos: extracto de lo anterior a continuación ... siga el enlace para ver el ejemplo 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")

Estoy seguro de que @PLT_Borat tendrá algo que decir al respecto, dados sus sabios comentarios sobre lenguajes de programación mecanografiados de forma dependiente ;-)

Miles Sabin
fuente
2
Estoy un poco desconcertado por la última parte de su respuesta, ¡pero también muy intrigado! Gracias por su gran respuesta y las muchas referencias, parece que tengo mucha lectura que hacer :-)
Malte Schwerhoff
1
Resumir sobre arity es extremadamente útil. ScalaMock, lamentablemente, sufre una considerable duplicación porque los diversos FunctionNrasgos no saben cómo abstraer sobre arity : github.com/paulbutcher/ScalaMock/blob/develop/core/src/main/… github.com/paulbutcher/ScalaMock/blob / desarrollo / core / src / main / ... Lamentablemente no estoy al tanto de cualquier manera que pueda utilizar sin forma de evitar esto, dado que necesito para hacer frente a "real" FunctionNs
Paul Butcher
1
Hice un ejemplo (bastante artificial), ideone.com/sxIw1 , que está en la línea de la pregunta uno. ¿Podría esto beneficiarse de los hlists, quizás en combinación con "tipeo estático realizado en tiempo de ejecución en respuesta a datos dinámicos"? (Todavía no estoy seguro de qué se trata exactamente esto último)
Malte Schwerhoff
18

Para ser claros, una HList no es más que una pila de Tuple2azúcar ligeramente diferente en la parte superior.

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

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

Entonces, su pregunta es esencialmente sobre las diferencias entre el uso de tuplas anidadas frente a las tuplas planas, pero las dos son isomorfas, por lo que al final realmente no hay diferencia, excepto la conveniencia de qué funciones de biblioteca se pueden usar y qué notación se puede usar.

Dan Burton
fuente
Las tuplas se pueden mapear a hlists y viceversa de todos modos, por lo que hay un isomorfismo claro.
Erik Kaplun
10

Hay muchas cosas que no puedes hacer (bueno) con las tuplas:

  • escribir una función genérica de anteponer / agregar
  • escribir una función inversa
  • escribir una función concat
  • ...

Puedes hacer todo eso con tuplas, por supuesto, pero no en el caso general. Entonces, usar HLists hace que su código sea más SECO.

Kim Stebel
fuente
8

Puedo explicar esto en un lenguaje súper simple:

La nomenclatura tupla vs lista no es significativa. Las listas H se pueden nombrar como HTuples. La diferencia es que en Scala + Haskell, puedes hacer esto con una tupla (usando la sintaxis de Scala):

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

para tomar una tupla de entrada de exactamente dos elementos de cualquier tipo, agregar un tercer elemento y devolver una tupla completamente tipada con exactamente tres elementos. Pero si bien esto es completamente genérico sobre los tipos, tiene que especificar explícitamente las longitudes de entrada / salida.

Lo que una lista HList de estilo Haskell le permite hacer es hacer que este genérico se extienda a lo largo, para que pueda agregar cualquier longitud de tupla / lista y recuperar una tupla / lista totalmente estáticamente escrita. Este beneficio también se aplica a las colecciones de tipo homogéneo donde puede agregar un int a una lista de exactamente n ints y recuperar una lista que está estáticamente tipada para tener exactamente (n + 1) ints sin especificar explícitamente n.

arcilla
fuente