Scala: Typy abstrakcyjne a generyczne

250

Czytałem A Tour of Scala: Abstract Types . Kiedy lepiej używać typów abstrakcyjnych?

Na przykład,

abstract class Buffer {
  type T
  val element: T
}

raczej że generyczne, na przykład

abstract class Buffer[T] {
  val element: T
}
thatismatt
źródło

Odpowiedzi:

257

Tutaj masz dobry punkt widzenia na ten temat:

Cel systemu typów Scali
Rozmowa z Martinem Oderskim, część III
, Bill Venners i Frank Sommers (18 maja 2009)

Aktualizacja (październik 2009): to, co następuje poniżej, zostało zilustrowane w tym nowym artykule przez Billa Vennersa:
Członkowie typu abstrakcyjnego kontra parametry typu ogólnego w Scali (patrz podsumowanie na końcu)


(Oto odpowiedni fragment pierwszego wywiadu z maja 2009 r., Moje podkreślenie)

Ogólna zasada

Zawsze istniały dwa pojęcia abstrakcji:

  • parametryzacja i
  • członkowie abstrakcyjni.

W Javie masz również oba, ale zależy to od tego, nad czym abstrahujesz.
W Javie masz metody abstrakcyjne, ale nie możesz przekazać metody jako parametru.
Nie masz pól abstrakcyjnych, ale możesz przekazać wartość jako parametr.
Podobnie nie masz elementów typu abstrakcyjnego, ale możesz określić typ jako parametr.
Tak więc w Javie masz również wszystkie trzy, ale istnieje różnica w tym, jakiej zasady abstrakcji możesz użyć do jakich rzeczy. I można argumentować, że to rozróżnienie jest dość arbitralne.

Droga Scali

Postanowiliśmy mieć takie same zasady konstrukcyjne dla wszystkich trzech rodzajów członków .
Możesz mieć zarówno pola abstrakcyjne, jak i parametry wartości.
Możesz przekazać metody (lub „funkcje”) jako parametry lub możesz nad nimi nadpisać.
Możesz określić typy jako parametry lub możesz nad nimi streścić.
A koncepcyjnie otrzymujemy to, że możemy modelować jedno pod względem drugiego. Przynajmniej w zasadzie możemy wyrazić każdy rodzaj parametryzacji jako formę abstrakcji obiektowej. W pewnym sensie można powiedzieć, że Scala jest bardziej ortogonalnym i kompletnym językiem.

Czemu?

To, co w szczególności kupują ci typy abstrakcyjne, jest dobrym sposobem na rozwiązanie problemów z kowariancją, o których mówiliśmy wcześniej.
Jednym ze standardowych problemów, który istnieje od dłuższego czasu, jest problem zwierząt i żywności.
Łamigłówką było mieć klasę Animalz metodą eat, która zjada trochę jedzenia.
Problem polega na tym, że jeśli podklasujemy Zwierzęta i mamy klasę taką jak Krowa, wtedy zjadają tylko trawę, a nie arbitralne jedzenie. Na przykład krowa nie mogła zjeść ryby.
Chcecie móc powiedzieć, że Krowa ma metodę jedzenia, która zjada tylko Trawę, a nie inne rzeczy.
W rzeczywistości nie można tego zrobić w Javie, ponieważ okazuje się, że można konstruować niepożądane sytuacje, takie jak problem przypisywania Fruit do zmiennej Apple, o której mówiłem wcześniej.

Odpowiedź jest taka, że dodajesz abstrakcyjny typ do klasy Animal .
Mówisz, że moja nowa klasa Zwierząt ma pewien rodzaj SuitableFood, którego nie znam.
To jest typ abstrakcyjny. Nie podajesz implementacji tego typu. Następnie masz eatmetodę, która je tylko SuitableFood.
A potem w Cowklasie powiedziałbym: OK, mam Krowę, która przedłuża klasę Animali na Cow type SuitableFood equals Grass.
Tak więc typy abstrakcyjne zapewniają takie pojęcie typu w nadklasie, której nie znam, którą następnie wypełniam w podklasach czymś, co wiem .

To samo z parametryzacją?

Rzeczywiście możesz. Możesz sparametryzować klasę Zwierzę z rodzajem spożywanego jedzenia.
Ale w praktyce, gdy robisz to z wieloma różnymi rzeczami, prowadzi to do eksplozji parametrów , a co więcej, w granicach parametrów .
Na konferencji ECOOP w 1998 r. Kim Bruce, Phil Wadler i ja mieliśmy artykuł, w którym pokazaliśmy, że wraz ze wzrostem liczby rzeczy, których nie wiesz, typowy program będzie rósł kwadratowo .
Są więc bardzo dobre powody, aby nie robić parametrów, ale mieć tych abstrakcyjnych członków, ponieważ nie dają ci tego kwadratowego wybuchu.


thatismatt pyta w komentarzach:

Czy uważasz, że następujące podsumowanie jest rzetelne:

  • Typy abstrakcyjne są używane w relacjach „has-a” lub „uses-a” (np. A Cow eats Grass)
  • gdzie jako generyczne są zwykle relacje „of” (np. List of Ints)

Nie jestem pewien, czy relacja różni się między używaniem typów abstrakcyjnych lub generycznych. Różnica polega na:

  • jak są one używane, oraz
  • jak zarządza się granicami parametrów.

Aby zrozumieć, o czym mówi Martin, jeśli chodzi o „eksplozję parametrów, a co więcej, w granicach parametrów ”, i jej późniejszy kwadratowy wzrost, gdy typ abstrakcyjny jest modelowany za pomocą ogólnych, możesz rozważyć artykuł „ Skalowalna abstrakcja komponentów ”napisane przez ... Martina Odersky'ego i Matthiasa Zengera dla OOPSLA 2005, o których mowa w publikacjach projektu Palcom (zakończonych w 2007 roku).

Odpowiednie wyciągi

Definicja

Elementy typu abstrakcyjnego zapewniają elastyczny sposób na streszczenie nad konkretnymi typami komponentów.
Typy abstrakcyjne mogą ukrywać informacje o elementach wewnętrznych komponentu, podobnie do ich użycia w podpisach SML . W zorientowanym obiektowo frameworku, w którym klasy mogą być rozszerzane przez dziedziczenie, można je również wykorzystywać jako elastyczny sposób parametryzacji (często nazywany polimorfizmem rodziny, patrz na przykład ten wpis w blogu i artykuł napisany przez Erica Ernsta ).

(Uwaga: polimorfizm rodziny został zaproponowany dla języków obiektowych jako rozwiązanie wspierające klasy rekursywne wielokrotnego użytku, ale bezpieczne dla typu.
Kluczową ideą polimorfizmu rodziny jest pojęcie rodzin, które są używane do grupowania klas rekurencyjnych)

abstrakcja typu ograniczona

abstract class MaxCell extends AbsCell {
type T <: Ordered { type O = T }
def setMax(x: T) = if (get < x) set(x)
}

Tutaj deklaracja typu T jest ograniczona górną granicą typu, która składa się z nazwy klasy Uporządkowana i uściślenia { type O = T }.
Górną granicę ogranicza specjalności T u podklas do tych podtypów uporządkowanych w którym człon typu Oo equals T.
Z powodu tego ograniczenia <gwarantuje się , że metoda klasy Uporządkowana ma zastosowanie do odbiornika i argumentu typu T.
Przykład pokazuje, że ograniczony element typu może sam pojawić się jako część granicy.
(tj. Scala obsługuje polimorfizm związany z F )

(Uwaga: od Petera Canninga, Williama Cooka, Waltera Hilla, Waltera Olthoffa:
Ograniczona kwantyfikacja została wprowadzona przez Cardellego i Wegnera jako sposób pisania funkcji, które działają jednakowo na wszystkich podtypach danego typu.
Zdefiniowali prosty model „obiektowy” i zastosował ograniczoną kwantyfikację do funkcji sprawdzania typu, które mają sens dla wszystkich obiektów posiadających określony zestaw „atrybutów”.
Bardziej realistyczna prezentacja języków zorientowanych obiektowo pozwoliłaby obiektom, które są elementami typów definiowanych rekurencyjnie .
W tym kontekście ograniczona kwantyfikacja nie spełnia już zamierzonego celu. Łatwo jest znaleźć funkcje, które mają sens na wszystkich obiektach posiadających określony zestaw metod, ale których nie można wpisać w systemie Cardelli-Wegner.
Aby zapewnić podstawę dla typowanych funkcji polimorficznych w językach obiektowych, wprowadzamy kwantyfikację F)

Dwie twarze tych samych monet

Istnieją dwie główne formy abstrakcji w językach programowania:

  • parametryzacja i
  • członkowie abstrakcyjni.

Pierwsza forma jest typowa dla języków funkcjonalnych, natomiast druga forma jest zwykle używana w językach obiektowych.

Tradycyjnie Java obsługuje parametryzację wartości i abstrakcję elementu dla operacji. Nowsza wersja Java 5.0 z generics obsługuje parametryzację również dla typów.

Argumenty przemawiające za włączeniem leków generycznych do Scali są dwojakie:

  • Po pierwsze, kodowanie do typów abstrakcyjnych nie jest tak łatwe do zrobienia ręcznie. Oprócz utraty zwięzłości istnieje również problem przypadkowych konfliktów nazw między abstrakcyjnymi nazwami typów, które emulują parametry typu.

  • Po drugie, rodzaje generyczne i typy abstrakcyjne zwykle pełnią różne role w programach Scala.

    • Generyczne są zwykle używane, gdy trzeba tylko utworzyć wystąpienie typu , natomiast
    • typy abstrakcyjne są zwykle używane, gdy trzeba odwoływać się do typu abstrakcyjnego z kodu klienta .
      Ten ostatni powstaje w szczególności w dwóch sytuacjach:
    • Można chcieć ukryć dokładną definicję elementu typu przed kodem klienta, aby uzyskać rodzaj enkapsulacji znanej z systemów modułów typu SML.
    • Lub ktoś może chcieć zastąpić typ kowariantnie w podklasach, aby uzyskać polimorfizm rodziny.

W systemie z ograniczonym polimorfizmem przepisywanie typu abstrakcyjnego na rodzajowy może pociągać za sobą kwadratowe rozszerzenie granic typu .


Aktualizacja z października 2009 r

Członkowie typu abstrakcyjnego a ogólne parametry typu w Scali (Bill Venners)

(moje podkreślenie)

Dotychczasowe spostrzeżenia dotyczące elementów typu abstrakcyjnego są takie, że są one przede wszystkim lepszym wyborem niż ogólne parametry typu, gdy:

  • chcesz pozwolić ludziom mieszać definicje tego typu za pomocą cech .
  • myślisz, że wyraźna wzmianka o nazwie elementu typu podczas jej definiowania pomoże w czytelności kodu .

Przykład:

jeśli chcesz przekazać trzy różne obiekty urządzeń do testów, będziesz mógł to zrobić, ale musisz podać trzy typy, po jednym dla każdego parametru. Tak więc, gdybym przyjął podejście oparte na parametrach typu, Twoje klasy pakietów mogłyby wyglądać następująco:

// Type parameter version
class MySuite extends FixtureSuite3[StringBuilder, ListBuffer, Stack] with MyHandyFixture {
  // ...
}

Natomiast przy podejściu typu członka będzie to wyglądać tak:

// Type member version
class MySuite extends FixtureSuite3 with MyHandyFixture {
  // ...
}

Inną drobną różnicą między elementami typu abstrakcyjnego a parametrami typu ogólnego jest to, że gdy określony jest parametr typu ogólnego, czytniki kodu nie widzą nazwy parametru typu. W ten sposób ktoś zobaczył ten wiersz kodu:

// Type parameter version
class MySuite extends FixtureSuite[StringBuilder] with StringBuilderFixture {
  // ...
}

Nie wiedzieliby, jaka jest nazwa parametru typu określonego jako StringBuilder bez wyszukiwania. Podczas gdy nazwa parametru type znajduje się w kodzie w abstrakcyjnym podejściu członka typu:

// Type member version
class MySuite extends FixtureSuite with StringBuilderFixture {
  type FixtureParam = StringBuilder
  // ...
}

W drugim przypadku czytelnicy kodu mogą zobaczyć, że StringBuilderjest to typ „parametru urządzenia”.
Wciąż będą musieli dowiedzieć się, co oznacza „parametr urządzenia”, ale mogliby przynajmniej uzyskać nazwę typu bez zaglądania do dokumentacji.

VonC
źródło
61
Jak mam zdobyć punkty karmy, odpowiadając na pytania Scali, kiedy przyjdziesz i to zrobisz ??? :-)
Daniel C. Sobral
7
Cześć Daniel: Myślę, że muszą istnieć konkretne przykłady ilustrujące zalety typów abstrakcyjnych w stosunku do parametryzacji. Umieszczenie niektórych w tym wątku byłoby dobrym początkiem;) Wiem, że głosowałbym za tym.
VonC
1
Czy uważasz, że następujące podsumowanie jest rzetelne: Typy abstrakcyjne są używane w relacjach „has-a” lub „uses-a” (np. Krowa zjada trawę), gdzie typowe są zazwyczaj relacje „of” (np. Lista Ints)
thatismatt
Nie jestem pewien, czy relacja różni się między używaniem typów abstrakcyjnych lub generycznych. Różni się tym, jak są używane i jak zarządza się granicami parametrów. Więcej w mojej odpowiedzi za chwilę.
VonC
1
Uwaga do siebie: patrz także post na blogu z maja 2010 r .: daily-scala.blogspot.com/2010/05/…
VonC
37

Miałem to samo pytanie, kiedy czytałem o Scali.

Zaletą używania generycznych jest to, że tworzysz rodzinę typów. Nikt nie będzie musiał podklasy Buffermogą -Oni prostu użyć Buffer[Any], Buffer[String]itp

Jeśli użyjesz typu abstrakcyjnego, ludzie będą zmuszeni utworzyć podklasę. Ludzie będą musieli zajęcia jak AnyBuffer, StringBufferitp

Musisz zdecydować, który jest lepszy dla twojej konkretnej potrzeby.

Daniel Jankowski
źródło
18
mmm cienkie znacznie się poprawiło na tym froncie, możesz po prostu wymagać Buffer { type T <: String }lub w Buffer { type T = String }zależności od swoich potrzeb
Eduardo Pareja Tobes
21

Możesz używać typów abstrakcyjnych w połączeniu z parametrami typów, aby tworzyć niestandardowe szablony.

Załóżmy, że musisz ustanowić wzór z trzema połączonymi cechami:

trait AA[B,C]
trait BB[C,A]
trait CC[A,B]

w sposób, w jaki argumenty wymienione w parametrach typu są z szacunkiem AA, BB, CC

Możesz przyjść z jakimś kodem:

trait AA[B<:BB[C,AA[B,C]],C<:CC[AA[B,C],B]]
trait BB[C<:CC[A,BB[C,A]],A<:AA[BB[C,A],C]]
trait CC[A<:AA[B,CC[A,B]],B<:BB[CC[A,B],A]]

co nie działałoby w ten prosty sposób z powodu wiązań parametrów typu. Aby dziedziczyć poprawnie, musisz ustawić kowariant

trait AA[+B<:BB[C,AA[B,C]],+C<:CC[AA[B,C],B]]
trait BB[+C<:CC[A,BB[C,A]],+A<:AA[BB[C,A],C]]
trait CC[+A<:AA[B,CC[A,B]],+B<:BB[CC[A,B],A]]

Ta jedna próbka zostałaby skompilowana, ale stawia wysokie wymagania dotyczące zasad wariancji i nie można jej użyć w niektórych przypadkach

trait AA[+B<:BB[C,AA[B,C]],+C<:CC[AA[B,C],B]] {
  def forth(x:B):C
  def back(x:C):B
}
trait BB[+C<:CC[A,BB[C,A]],+A<:AA[BB[C,A],C]] {
  def forth(x:C):A
  def back(x:A):C
}
trait CC[+A<:AA[B,CC[A,B]],+B<:BB[CC[A,B],A]] {
  def forth(x:A):B
  def back(x:B):A
}

Kompilator będzie sprzeciwił się błędem sprawdzania wariancji

W takim przypadku możesz zebrać wszystkie wymagania typu dla dodatkowej cechy i sparametryzować inne cechy

//one trait to rule them all
trait OO[O <: OO[O]] { this : O =>
  type A <: AA[O]
  type B <: BB[O]
  type C <: CC[O]
}
trait AA[O <: OO[O]] { this : O#A =>
  type A = O#A
  type B = O#B
  type C = O#C
  def left(l:B):C
  def right(r:C):B = r.left(this)
  def join(l:B, r:C):A
  def double(l:B, r:C):A = this.join( l.join(r,this), r.join(this,l) )
}
trait BB[O <: OO[O]] { this : O#B =>
  type A = O#A
  type B = O#B
  type C = O#C
  def left(l:C):A
  def right(r:A):C = r.left(this)
  def join(l:C, r:A):B
  def double(l:C, r:A):B = this.join( l.join(r,this), r.join(this,l) )
}
trait CC[O <: OO[O]] { this : O#C =>
  type A = O#A
  type B = O#B
  type C = O#C
  def left(l:A):B
  def right(r:B):A = r.left(this)
  def join(l:A, r:B):C
  def double(l:A, r:B):C = this.join( l.join(r,this), r.join(this,l) )
}

Teraz możemy napisać konkretną reprezentację dla opisanego wzoru, zdefiniować metody lewej i połączyć we wszystkich klasach i uzyskać prawo i podwój za darmo

class ReprO extends OO[ReprO] {
  override type A = ReprA
  override type B = ReprB
  override type C = ReprC
}
case class ReprA(data : Int) extends AA[ReprO] {
  override def left(l:B):C = ReprC(data - l.data)
  override def join(l:B, r:C) = ReprA(l.data + r.data)
}
case class ReprB(data : Int) extends BB[ReprO] {
  override def left(l:C):A = ReprA(data - l.data)
  override def join(l:C, r:A):B = ReprB(l.data + r.data)
}
case class ReprC(data : Int) extends CC[ReprO] {
  override def left(l:A):B = ReprB(data - l.data)
  override def join(l:A, r:B):C = ReprC(l.data + r.data)
}

Tak więc do tworzenia abstrakcji używane są zarówno typy abstrakcyjne, jak i parametry typu. Obaj mają słabą i mocną stronę. Typy abstrakcyjne są bardziej szczegółowe i mogą opisywać dowolną strukturę typów, ale są pełne i wymagają wyraźnego określenia. Parametry typu mogą natychmiast tworzyć wiązkę typów, ale dodatkowo martwi Cię dziedziczeniem i ograniczeniami typów.

Zapewniają sobie synergię i mogą być używane łącznie do tworzenia złożonych abstrakcji, których nie da się wyrazić tylko jednym z nich.

ayvango
źródło
0

Myślę, że nie ma tutaj dużej różnicy. Elementy abstrakcyjne typów mogą być postrzegane jako tylko typy egzystencjalne, które są podobne do typów rekordów w niektórych innych językach funkcjonalnych.

Na przykład mamy:

class ListT {
  type T
  ...
}

i

class List[T] {...}

To ListTjest tak samo jak List[_]. Konsekwencją elementów typu jest to, że możemy używać klasy bez wyraźnego konkretnego typu i unikać zbyt wielu parametrów typu.

Comcx
źródło