Czy HLists to nic innego jak zawiły sposób pisania krotek?

144

Naprawdę jestem zainteresowany ustaleniem, gdzie są różnice, a bardziej ogólnie, zidentyfikowaniem kanonicznych przypadków użycia, w których HLists nie mogą być używane (a raczej nie dają żadnych korzyści w porównaniu do zwykłych list).

(Zdaję sobie sprawę, że TupleNw Scali jest 22 (wierzę) , podczas gdy potrzebna jest tylko jedna lista HList, ale nie jest to rodzaj różnicy pojęciowej, która mnie interesuje.)

W poniższym tekście zaznaczyłem kilka pytań. Właściwie może nie być konieczne odpowiadanie na nie, mają one raczej na celu wskazanie rzeczy, które są dla mnie niejasne, i ukierunkowanie dyskusji w określonych kierunkach.

Motywacja

Niedawno widziałem kilka odpowiedzi na SO, w których ludzie sugerowali użycie HLists (na przykład dostarczonych przez Shapeless ), w tym usuniętą odpowiedź na to pytanie . To dało początek tej dyskusji , która z kolei wywołała to pytanie.

Intro

Wydaje mi się, że hlisty są przydatne tylko wtedy, gdy znasz statycznie liczbę elementów i ich dokładne typy. Liczba w rzeczywistości nie jest kluczowa, ale wydaje się mało prawdopodobne, że kiedykolwiek będziesz musiał wygenerować listę z elementami różnych, ale statystycznie precyzyjnie znanych typów, ale nie znasz ich liczby. Pytanie 1: Czy mógłbyś w ogóle napisać taki przykład, np. W pętli? Moja intuicja jest taka, że ​​posiadanie statycznie precyzyjnej listy hlist ze statycznie nieznaną liczbą dowolnych elementów (dowolnych względem danej hierarchii klas) po prostu nie jest kompatybilne.

HLists vs. krotki

Jeśli to prawda, tzn. Statycznie znasz liczbę i typ - Pytanie 2: dlaczego po prostu nie użyć n-krotki? Jasne, możesz bezpiecznie mapować i zawijać HList (co możesz również, ale nie typowo, zrobić w krotce za pomocą productIterator), ale ponieważ liczba i typ elementów są znane statycznie, prawdopodobnie możesz po prostu uzyskać dostęp do elementów krotki bezpośrednio i wykonać operacje.

Z drugiej strony, jeśli funkcja, fktórą mapujesz na hlistę, jest tak ogólna, że ​​akceptuje wszystkie elementy - Pytanie 3: dlaczego nie użyć jej za pośrednictwem productIterator.map? Ok, jedna interesująca różnica może wynikać z przeciążenia metody: gdybyśmy mieli kilka przeciążonych f, posiadanie silniejszej informacji o typie dostarczonej przez hlist (w przeciwieństwie do productIterator) mogłoby pozwolić kompilatorowi na wybranie bardziej szczegółowego f. Nie jestem jednak pewien, czy to faktycznie zadziała w Scali, ponieważ metody i funkcje nie są takie same.

HLists i dane wejściowe użytkownika

Opierając się na tym samym założeniu, a mianowicie, że musisz znać liczbę i typy elementów statycznie - Pytanie 4: czy hlisty mogą być używane w sytuacjach, gdy elementy zależą od dowolnego rodzaju interakcji użytkownika? Np. Wyobraź sobie wypełnianie hlist elementów wewnątrz pętli; elementy są odczytywane skądś (interfejs użytkownika, plik konfiguracyjny, interakcja aktora, sieć), dopóki nie zostanie spełniony określony warunek. Jaki byłby typ listy hlist? Podobnie jest w przypadku specyfikacji interfejsu getElements: HList [...], która powinna działać z listami o statycznie nieznanej długości i która umożliwia komponentowi A w systemie uzyskanie takiej listy dowolnych elementów z komponentu B.

Malte Schwerhoff
źródło

Odpowiedzi:

144

Odpowiadając na pytania od pierwszego do trzeciego: jednym z głównych zastosowań HListsjest ujęcie abstrakcji. Arity jest zwykle statycznie znane w dowolnym miejscu użycia abstrakcji, ale różni się w zależności od witryny. Weź to, z bezkształtnej w przykładach ,

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)

Bez użycia HLists(lub czegoś równoważnego) abstrakcji nad liczbą argumentów krotki flattenbyłoby niemożliwe uzyskanie pojedynczej implementacji, która mogłaby zaakceptować argumenty o tych dwóch bardzo różnych kształtach i przekształcić je w bezpieczny sposób.

Zdolność do abstrahowania od wartości liczbowych może być interesująca wszędzie tam, gdzie występują ustalone wartości: jak również krotki, jak powyżej, obejmujące listy parametrów metod / funkcji i klasy przypadków. Zobacz tutaj, aby zobaczyć przykłady, w jaki sposób możemy abstrahować od liczby dowolnych klas przypadków, aby uzyskać instancje klas typów prawie automatycznie,

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

Nie ma tu żadnej iteracji w czasie wykonywania , ale występuje duplikacja , którą HListsmożna wyeliminować za pomocą (lub równoważnych struktur). Oczywiście, jeśli Twoja tolerancja na powtarzalne schematy jest wysoka, możesz uzyskać ten sam wynik, pisząc wiele implementacji dla każdego kształtu, na którym Ci zależy.

W pytaniu trzecim pytasz "... jeśli funkcja, którą odwzorowujesz na hlistę jest tak ogólna, że ​​akceptuje wszystkie elementy ... dlaczego nie użyć jej przez productIterator.map?". Jeśli funkcja, którą mapujesz na HList, naprawdę ma postać, Any => Tmapowanie productIteratorbędzie ci służyć doskonale. Ale funkcje formularza Any => Tnie są zazwyczaj tak interesujące (przynajmniej nie są, chyba że wpisują rzutowanie wewnętrznie). shapeless zapewnia postać polimorficznej wartości funkcji, która pozwala kompilatorowi wybierać przypadki specyficzne dla typu w dokładnie taki sposób, w jaki masz wątpliwości. Na przykład,

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

W odniesieniu do pytania czwartego, dotyczącego danych wejściowych użytkownika, należy rozważyć dwa przypadki. Pierwsza to sytuacje, w których możemy dynamicznie ustalić kontekst, który gwarantuje uzyskanie znanego warunku statycznego. W tego rodzaju scenariuszach jest całkowicie możliwe zastosowanie technik bezkształtnych, ale oczywiście z zastrzeżeniem, że jeśli stan statyczny nie występuje w czasie wykonywania, musimy podążać alternatywną ścieżką. Nic dziwnego, że oznacza to, że metody wrażliwe na warunki dynamiczne muszą dawać opcjonalne wyniki. Oto przykład z użyciem 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]

Typ lnie obejmuje długości listy ani dokładnych typów jej elementów. Jeśli jednak spodziewamy się, że będzie miał określoną postać (tj. Jeśli ma odpowiadać jakimś znanym, ustalonym schematom), to możemy pokusić się o ustalenie tego faktu i odpowiednio postępować,

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

Są inne sytuacje, w których możemy nie przejmować się faktyczną długością danej listy, poza tym, że jest ona takiej samej długości, jak inna lista. Ponownie, jest to coś, co bezkształtne obsługuje, zarówno w pełni statycznie, jak i w mieszanym kontekście statycznym / dynamicznym, jak powyżej. Zobacz tutaj rozszerzony przykład.

Prawdą jest, jak zauważyłeś, że wszystkie te mechanizmy wymagają, aby informacje o typie statycznym były dostępne, przynajmniej warunkowo, i wydaje się, że wyklucza to możliwość użycia tych technik w całkowicie dynamicznym środowisku, w pełni sterowanym przez zewnętrzne dane bez typu. Ale wraz z pojawieniem się obsługi kompilacji środowiska uruchomieniowego jako składnika odbicia Scala w 2.10, nawet to nie jest już przeszkodą nie do przezwyciężenia ... możemy użyć kompilacji środowiska uruchomieniowego, aby zapewnić lekką formę przejściową i wykonać nasze statyczne typowanie w czasie wykonywania w odpowiedzi na dane dynamiczne: fragment powyższego poniżej ... kliknij łącze do pełnego przykładu,

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

Jestem pewien, że @PLT_Borat będzie miał coś do powiedzenia na ten temat, biorąc pod uwagę jego mądre komentarze na temat języków programowania zależnie typowanych ;-)

Miles Sabin
źródło
2
Jestem lekko zdziwiona ostatnią częścią Twojej odpowiedzi - ale też bardzo zaintrygowana! Dziękuję za świetną odpowiedź i wiele referencji, wygląda na to, że mam dużo do zrobienia :-)
Malte Schwerhoff
1
Abstrahowanie od aryczności jest niezwykle przydatne. Niestety ScalaMock cierpi na znaczne powielanie, ponieważ różne FunctionNcechy nie potrafią wyodrębnić z arity: github.com/paulbutcher/ScalaMock/blob/develop/core/src/main/ ... github.com/paulbutcher/ScalaMock/blob / opracowania / core / src / main / ... Niestety nie jestem świadomy jakikolwiek sposób, że mogę korzystać z bezkształtnej, aby tego uniknąć, zważywszy, że muszę radzić sobie z „prawdziwym” FunctionNs
Paul Butcher
1
Wymyśliłem (całkiem sztuczny) przykład - ideone.com/sxIw1 - który jest podobny do pytania pierwszego. Czy skorzystałoby na tym hlists, być może w połączeniu z „statycznym typowaniem wykonywanym w czasie wykonywania w odpowiedzi na dynamiczne dane”? (Nadal nie jestem pewien, o co dokładnie chodzi w tym drugim)
Malte Schwerhoff,
17

Dla jasności, HList to w zasadzie nic innego jak stos Tuple2z nieco innym cukrem na wierzchu.

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

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

Więc twoje pytanie dotyczy zasadniczo różnic między używaniem krotek zagnieżdżonych a krotek płaskich, ale te dwie są izomorficzne, więc ostatecznie nie ma żadnej różnicy poza wygodą, w której można używać funkcji biblioteki i której notacji można używać.

Dan Burton
źródło
krotki mogą być mapowane na hlists iz powrotem, więc istnieje wyraźny izomorfizm.
Erik Kaplun
10

Jest wiele rzeczy, których nie można (dobrze) zrobić z krotkami:

  • napisz ogólną funkcję poprzedzającą / dołączaną
  • napisz funkcję odwrotną
  • napisz funkcję concat
  • ...

Oczywiście możesz to wszystko zrobić za pomocą krotek, ale nie w ogólnym przypadku. Więc użycie HLists sprawia, że ​​twój kod jest bardziej SUCHY.

Kim Stebel
źródło
8

Mogę to wyjaśnić w bardzo prostym języku:

Nazewnictwo krotki i listy nie jest istotne. HLists można nazwać jako HTuples. Różnica polega na tym, że w Scala + Haskell możesz to zrobić za pomocą krotki (używając składni Scala):

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

aby pobrać krotkę wejściową składającą się z dokładnie dwóch elementów dowolnego typu, dołączyć trzeci element i zwrócić w pełni wpisaną krotkę z dokładnie trzema elementami. Ale chociaż jest to całkowicie ogólne w stosunku do typów, musi jawnie określić długości wejścia / wyjścia.

To, co pozwala HList w stylu Haskell, to nadanie tej ogólnej długości, więc możesz dołączyć do dowolnej długości krotki / listy i odzyskać w pełni statycznie wpisaną krotkę / listę. Ta korzyść dotyczy również kolekcji o jednorodnym typie, w których można dołączyć liczbę int do listy dokładnie n wartości int i otrzymać listę, która jest zapisana statycznie tak, aby zawierała dokładnie (n + 1) liczb int bez jawnego określania n.

glina
źródło