Apakah para HLists tidak lebih dari sebuah cara penulisan tuple yang berbelit-belit?

144

Saya benar-benar tertarik untuk mencari tahu di mana perbedaannya, dan lebih umum, untuk mengidentifikasi kasus penggunaan kanonik di mana HList tidak dapat digunakan (atau lebih tepatnya, tidak menghasilkan manfaat apa pun dari daftar biasa).

(Saya sadar bahwa ada 22 (saya percaya) TupleNdi Scala, sedangkan satu hanya membutuhkan HList tunggal, tetapi itu bukan jenis perbedaan konseptual yang saya minati.)

Saya telah menandai beberapa pertanyaan dalam teks di bawah ini. Mungkin sebenarnya tidak perlu untuk menjawab mereka, mereka lebih dimaksudkan untuk menunjukkan hal-hal yang tidak jelas bagi saya, dan untuk membimbing diskusi ke arah tertentu.

Motivasi

Saya baru-baru ini melihat beberapa jawaban di SO di mana orang menyarankan untuk menggunakan HLists (misalnya, seperti yang disediakan oleh Shapeless ), termasuk jawaban yang dihapus untuk pertanyaan ini . Itu memunculkan diskusi ini , yang pada gilirannya memicu pertanyaan ini.

Intro

Sepertinya saya, bahwa daftar hanya berguna ketika Anda tahu jumlah elemen dan tipe tepat mereka secara statis. Jumlahnya sebenarnya tidak penting, tetapi sepertinya tidak mungkin Anda perlu membuat daftar dengan elemen yang bervariasi tetapi jenisnya diketahui secara statis, tetapi Anda tidak tahu jumlahnya secara statis. Pertanyaan 1: Bisakah Anda menulis contoh seperti itu, misalnya, dalam satu lingkaran? Intuisi saya adalah bahwa memiliki daftar statis yang tepat secara statis dengan jumlah elemen arbitrer yang tidak diketahui secara statis (relatif sewenang-wenang terhadap hierarki kelas tertentu) tidak kompatibel.

HList vs. Tuples

Jika ini benar, yaitu, Anda secara statis tahu nomor dan jenis - Pertanyaan 2: mengapa tidak menggunakan n-tuple? Tentu saja, Anda dapat memetakan dan melipat di atas HList dengan mudah (yang juga dapat dilakukan, tetapi tidak dengan agak , dilakukan di atas tuple dengan bantuan productIterator), tetapi karena jumlah dan jenis elemen diketahui secara statis, Anda mungkin bisa mengakses elemen tuple saja langsung dan melakukan operasi.

Di sisi lain, jika fungsi yang fAnda petakan pada daftar sangat umum sehingga menerima semua elemen - Pertanyaan 3: mengapa tidak menggunakannya melalui productIterator.map? Ok, satu perbedaan yang menarik bisa datang dari metode overloading: jika kita memiliki beberapa kelebihan f, memiliki informasi tipe yang lebih kuat yang disediakan oleh hlist (berbeda dengan productIterator) dapat memungkinkan kompiler memilih yang lebih spesifik f. Namun, saya tidak yakin apakah itu benar-benar berfungsi di Scala, karena metode dan fungsi tidak sama.

Masukan dan masukan pengguna

Membangun asumsi yang sama, yaitu, bahwa Anda perlu mengetahui jumlah dan jenis elemen secara statis - Pertanyaan 4: dapatkah hlists digunakan dalam situasi di mana elemen bergantung pada jenis interaksi pengguna? Misalnya, bayangkan mengisi daftar dengan elemen di dalam lingkaran; elemen dibaca dari suatu tempat (UI, file config, interaksi aktor, jaringan) hingga kondisi tertentu berlaku. Seperti apa tipe daftar itu? Mirip untuk spesifikasi antarmuka getElements: HList [...] yang seharusnya bekerja dengan daftar yang panjangnya tidak diketahui secara statis, dan yang memungkinkan komponen A dalam sistem untuk mendapatkan daftar elemen arbitrer dari komponen B.

Malte Schwerhoff
sumber

Jawaban:

144

Mengatasi pertanyaan satu hingga tiga: salah satu aplikasi utama HListsadalah mengabstraksi lebih dari arity. Arity biasanya diketahui secara statis pada situs abstraksi tertentu, tetapi berbeda dari satu situs ke situs lainnya. Ambil ini, dari contoh tak berbentuk ,

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)

Tanpa menggunakan HLists(atau sesuatu yang setara) untuk abstrak di atas argumen argumen tuple untuk flattenitu tidak mungkin untuk memiliki implementasi tunggal yang dapat menerima argumen dari dua bentuk yang sangat berbeda dan mengubahnya dalam jenis cara yang aman.

Kemampuan untuk mengabstraksi lebih dari arity cenderung menarik di mana pun melibatkan arities tetap: serta tuple, seperti di atas, yang mencakup metode / daftar parameter fungsi, dan kelas kasus. Lihat di sini untuk contoh-contoh bagaimana kita dapat mengabstraksi atas aritas kelas kasus arbitrer untuk mendapatkan instance kelas tipe hampir secara otomatis,

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

Tidak ada iterasi runtime di sini, tetapi ada duplikasi , yang penggunaan HLists(atau struktur setara) dapat dihilangkan. Tentu saja, jika toleransi Anda terhadap pelat berulang berulang tinggi, Anda bisa mendapatkan hasil yang sama dengan menulis beberapa implementasi untuk setiap bentuk yang Anda pedulikan.

Dalam pertanyaan ketiga Anda bertanya "... jika fungsi yang Anda petakan pada daftar sangat umum sehingga menerima semua elemen ... mengapa tidak menggunakannya melalui productIterator.map?". Jika fungsi yang Anda petakan pada HList benar-benar dalam bentuk, Any => Tmaka pemetaan atas productIteratorakan sangat membantu Anda. Tetapi fungsi-fungsi bentuk Any => Tbiasanya tidak begitu menarik (setidaknya, mereka tidak kecuali mereka mengetikkan secara internal). tak berbentuk menyediakan bentuk nilai fungsi polimorfik yang memungkinkan kompiler untuk memilih kasus tipe spesifik dengan cara yang persis Anda ragukan. Misalnya,

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

Sehubungan dengan pertanyaan Anda empat, tentang input pengguna, ada dua hal yang perlu dipertimbangkan. Yang pertama adalah situasi di mana kita dapat secara dinamis membangun konteks yang menjamin bahwa kondisi statis diketahui. Dalam skenario semacam ini sangat mungkin untuk menerapkan teknik tak berbentuk, tetapi jelas dengan ketentuan bahwa jika kondisi statis tidak diperoleh pada saat runtime maka kita harus mengikuti jalur alternatif. Tidak mengherankan, ini berarti bahwa metode yang peka terhadap kondisi dinamis harus menghasilkan hasil opsional. Berikut ini contoh menggunakan 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]

Jenis ltidak menangkap panjang daftar, atau jenis elemen yang tepat. Namun, jika kita mengharapkannya memiliki bentuk tertentu (mis. Jika itu harus sesuai dengan beberapa skema yang diketahui dan diperbaiki), maka kita dapat mencoba untuk menetapkan fakta itu dan bertindak sesuai,

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

Ada situasi lain di mana kita mungkin tidak peduli tentang panjang sebenarnya dari daftar yang diberikan, selain itu sama panjangnya dengan beberapa daftar lainnya. Sekali lagi, ini adalah sesuatu yang mendukung tak berbentuk, baik sepenuhnya statis, dan juga dalam konteks statis / dinamis campuran seperti di atas. Lihat di sini untuk contoh lebih lanjut.

Memang benar, seperti yang Anda amati, bahwa semua mekanisme ini memerlukan informasi jenis statis tersedia, setidaknya secara kondisional, dan yang tampaknya mengecualikan teknik ini agar tidak dapat digunakan dalam lingkungan yang benar-benar dinamis, sepenuhnya didorong oleh data yang tidak diketik yang disediakan secara eksternal. Tetapi dengan munculnya dukungan untuk kompilasi runtime sebagai komponen dari refleksi Scala di 2.10, bahkan ini bukan lagi hambatan yang tidak dapat diatasi ... kita dapat menggunakan kompilasi runtime untuk menyediakan bentuk pementasan yang ringan dan pengetikan statis dilakukan pada saat runtime sebagai respons terhadap data dinamis: kutipan dari yang sebelumnya di bawah ini ... ikuti tautan untuk contoh lengkap,

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

Saya yakin @PLT_Borat akan mengatakan sesuatu tentang itu, mengingat komentar bijaknya tentang bahasa pemrograman yang diketik secara dependen ;-)

Miles Sabin
sumber
2
Saya sedikit bingung dengan bagian terakhir dari jawaban Anda - tetapi juga sangat tertarik! Terima kasih atas jawaban Anda yang luar biasa dan banyak referensi, sepertinya saya punya banyak kegiatan untuk dibaca :-)
Malte Schwerhoff
1
Mengabstraksi over arity sangat berguna. ScalaMock, sayangnya, menderita duplikasi yang cukup besar karena berbagai FunctionNsifat tidak tahu bagaimana mengabstraksikan data: github.com/paulbutcher/ScalaMock/blob/develop/core/src/main/… github.com/paulbutcher/ScalaMock/blob / mengembangkan / core / src / main / ... Sayangnya aku tidak menyadari cara apapun yang dapat saya gunakan tak berbentuk untuk menghindari hal ini, mengingat bahwa saya harus berurusan dengan "nyata" FunctionNs
Paul Butcher
1
Saya membuat contoh (sangat tiruan) - ideone.com/sxIw1 -, yang berada di sepanjang baris pertanyaan satu. Bisakah ini mendapat manfaat dari daftar, mungkin dalam kombinasi dengan "pengetikan statis yang dilakukan saat runtime sebagai respons terhadap data dinamis"? (Saya masih tidak yakin apa yang terakhir ini sebenarnya)
Malte Schwerhoff
18

Untuk lebih jelasnya, HList pada dasarnya tidak lebih dari setumpuk Tuple2dengan gula yang sedikit berbeda di atasnya.

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

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

Jadi pertanyaan Anda pada dasarnya adalah tentang perbedaan antara menggunakan nested tuple vs flat tuple, tetapi keduanya isomorfik sehingga pada akhirnya tidak ada perbedaan kecuali kenyamanan di mana fungsi perpustakaan dapat digunakan dan notasi mana yang dapat digunakan.

Dan Burton
sumber
tuple dapat dipetakan ke daftar dan kembali pula, jadi ada isomorfisme yang jelas.
Erik Kaplun
10

Ada banyak hal yang tidak dapat Anda lakukan dengan tupel:

  • tulis fungsi generik / tambahkan append
  • tulis fungsi terbalik
  • menulis fungsi concat
  • ...

Anda dapat melakukan semua itu dengan tupel, tetapi tidak dalam kasus umum. Jadi menggunakan HLists membuat kode Anda lebih KERING.

Kim Stebel
sumber
8

Saya bisa menjelaskan ini dalam bahasa super sederhana:

Penamaan tuple vs daftar tidak signifikan. HLists bisa disebut sebagai HTuples. Perbedaannya adalah bahwa di Scala + Haskell, Anda dapat melakukan ini dengan tuple (menggunakan sintaks Scala):

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

untuk mengambil input tuple dari tepat dua elemen dari tipe apa pun, tambahkan elemen ketiga, dan mengembalikan tuple yang diketik sepenuhnya dengan tepat tiga elemen. Tetapi sementara ini benar-benar generik lebih dari jenis, ia harus secara eksplisit menentukan panjang input / output.

Apa yang dapat dilakukan dengan gaya Haskell HList adalah membuat generik ini lebih panjang, sehingga Anda dapat menambahkan ke setiap panjang tuple / daftar dan mendapatkan kembali tuple / daftar yang diketik sepenuhnya secara statis. Manfaat ini juga berlaku untuk koleksi yang diketik secara homogen di mana Anda dapat menambahkan int ke daftar n int yang tepat dan mendapatkan kembali daftar yang diketik secara statis untuk memiliki int yang tepat (n +1) tanpa menetapkan secara eksplisit n.

tanah liat
sumber