[Scala] - wyjaśnienie kwestii wariancji w funkcjach

0

Witam
Jak wiadomo w Scali argumenty funkcji są zdefiniowane w sposób kontrawariantny a wartość zwracana w sposób kowariantny, dlatego coś takiego się wykrzaczy :

    class A[+T]{def show(x: T) = {}}

Z drugiej strony mam taki przykład :

    class A
    class B extends A
    class C extends B
    class D {def show(x: B) = {}}
    
    def main(args: Array[String]) {
        val x = new D
        x.show(new C)
    }

który działa poprawnie mimo iż C jest podtypem B. Mógłby ktoś to wyjaśnić ?

2

C jest podtypem B, więc C możesz bezpiecznie zrzutować na B. Dlaczego kompilacja miałaby się tutaj wysypać?

Przykład z genericsem to inna sprawa. Jeśli masz np class B extends A[List] { ... } to nie możesz jej przypisać do zmiennej val x: A[Seq], bo klasa B obsługuje tylko Listy, a nie wszystkie Seq'y. Sypnie się dla np Vectora. Stąd nie możesz dać plusa przy T.

0
Wibowit napisał(a):

C jest podtypem B, więc C możesz bezpiecznie zrzutować na B. Dlaczego kompilacja miałaby się tutaj wysypać?

Wydawało mi się że pisząc :

    def show(x: B) = {}

taka deklaracja działa tak samo jak w pierwszym przypadku, skoro argument funkcji musi być kontrawariantny. Nie implementuję wtedy :

trait Function1[-T1, +R] extends AnyRef

gdzie T1 = B oraz R = Unit ?

1

Metoda to co innego niż funkcja. Zresztą trait Function1 ma metodę apply. Gdyby metoda apply była typu Function1 to mielibyśmy nieskończoną rekurencję podczas kompilacji i język Scala nie mógłby istnieć :)

Nawet jeśli zamienisz metodę na funkcję to kod i tak się kompiluje:

class A
class B extends A
class C extends B
class D {
  def show_m(x: B): Unit = {}
  val show_f: B => Unit = () => ()
}

object Ast44 {
  def main(args: Array[String]) {
    val x = new D
    x.show_m(new C)
    x.show_f(new C)
  }
}

Nie ma powodu dla którego miałby się nie kompilować. Jeśli funkcja obsłuży argument typu B, to poradzi sobie z argumentem dziedziczącym po B.

Wariancja ma zastosowanie w innych przypadkach, np przy przypisywaniu instancji generycznej klasy do zmiennej o innej parametryzacji:

object Ast44 {
  var function: CharSequence => Number = _ // ten opis typu to cukier składniowy dla: Function1[CharSequence, Number]
  function = (_: CharSequence) => null: Number // OK, parametry generyczne są identyczne
  function = (_: String) => null: Number // źle, oczekujemy że funkcja obsłuży dowolny CharSequence, nie tylko Stringa
  function = (_: Object) => null: Number // OK, funkcja obsługująca każdy Object obsłuży i CharSequence
  function = (_: CharSequence) => null: Integer // OK, oczekiwaliśmy od funkcji że zwróci Number, a Integer jest typu Number
  function = (_: CharSequence) => null: Object // źle, ta funkcja może zwrócić dowolnego Objecta, a my oczekujemy czegoś typu Number
}

null jest tutaj zaślepką. Kod nabierze sensu jeśli zamiast niego wstawi się coś konkretnego pasującego do opisu typu za nim.

0

Hmm...
Dla mnie to jest to wszystko pomieszane jakoś jeśli chodzi o Scalę, może dlatego że nie miałem wcześniej do czynienia z paradygmatem funkcyjnym (i przy okazji przemieszanym z obiektowym).
1.

val show_f: B => Unit = () => ()

Nie powinno być np :

val show_f: B => Unit = (b: B) => ()

Inaczej jednak mi się nie kompiluje ;)

class A[+T]{def show(x: T) = {}}

Czyli metoda zachowuje się w takim przypadku jak funkcja (chodzi o wariancje) ?

  1. Powiedzmy że mamy :
var show_f: B => B = _
show_f = (_: A) => new B // jeśli chodzi o typ to mogę dać _: A lub _: B (kontrawariancja)
show_f(new C) // jeśli chodzi o argument to mogę dać new C lub new B (dziedziczenie)

Dlaczego jako argument nie mogę dać instancji klasy A ?

1
ast44 napisał(a):

Hmm...
Dla mnie to jest to wszystko pomieszane jakoś jeśli chodzi o Scalę, może dlatego że nie miałem wcześniej do czynienia z paradygmatem funkcyjnym (i przy okazji przemieszanym z obiektowym).
1.

val show_f: B => Unit = () => ()

Nie powinno być np :

val show_f: B => Unit = (b: B) => ()

Inaczej jednak mi się nie kompiluje ;)

Tak, masz rację. Walnąłem się :)

class A[+T]{def show(x: T) = {}}

Czyli metoda zachowuje się w takim przypadku jak funkcja (chodzi o wariancje) ?

Metoda, o ile nie podasz wprost, nie ma własnego parametru generycznego, a więc w ogólności zachowuje się inaczej niż funkcja. Jak chcesz konkretną odpowiedź to pokaż konkretny przypadek, tzn zarówno typ bazowy funkcji czy metody oraz metodę czy funkcję implementującą.

  1. Powiedzmy że mamy :
var show_f: B => B = _
show_f = (_: A) => new B // jeśli chodzi o typ to mogę dać _: A lub _: B (kontrawariancja)
show_f(new C) // jeśli chodzi o argument to mogę dać new C lub new B (dziedziczenie)

Dlaczego jako argument nie mogę dać instancji klasy A ?

Funkcja przyjmująca argument, a argument dla funkcji to dwie strony barykady. Funkcja przyjmująca A przyjmie też B, ale jeśli funkcja przyjmuje B to nie możemy wstawić do niej niekompatybilnego A.

Tak jak napisałem wcześniej, w Scali wariancja jest sprawdzana głównie przy przypisaniu instancji F[A] do zmiennej typu F[B], a nie przy wywoływaniu metod na F[B].

Oznaczanie wariancji jest po to, by móc stworzyć poprawny i ogólny kod. Inwariancja pozwala pisać poprawny kod, ale mocno ogranicza możliwości kompozycji. Z drugiej strony niedostatki w sprawdzaniu wariancji powodują błędy niewykrywalne na etapie kompilacji. Sztandarowym przykładem są tablice w Javie:

String[] stringi = new String[] { "ala ma kota" };
Object[] obiekty = stringi;
obiekty[0] = 5; // błąd ArrayStoreException

Z racji tego że są mutowalne, a więc mają de facto settery powinny być inwariantne. Twórcy Javy zdecydowali, że będą kowariantne (niezgodnie z regułami wariancji). Wskutek tego da się wygenerować powyższy błąd rzutowania mimo iż kompilacja przebiegła bez błędów.

Poza tym polecam zapoznać się z tabelką wariancji parametrów i typów zwracanych metod: Covariance and contravariance (computer science)

0
Wibowit napisał(a):

Metoda, o ile nie podasz wprost, nie ma własnego parametru generycznego, a więc w ogólności zachowuje się inaczej niż funkcja.

Co rozumiesz przez "własny parametr generyczny" ? Chodzi o przykład co podałem, o to że funkcje w przeciwieństwie do metod to obiekty implementujące odpowiedniego trait-a czy o coś jeszcze ?

  1. Czyli po prostu pomieszałem chyba dla typu deklarowanego typ przypisany i typ przekazywanego argumentu.
1

Co rozumiesz przez "własny parametr generyczny" ? Chodzi o przykład co podałem, o to że funkcje w przeciwieństwie do metod to obiekty implementujące odpowiedniego trait-a czy o coś jeszcze ?

Chodziło mi o to, że możesz mieć metodę z parametrem generycznym def metoda[T](parametr: T) (wtedy T może się zmieniać z każdym wywołaniem metody) albo i bez def metoda(parametr: String) (i mimo braku parametru generycznego dalej mamy reguły wariancji, ale tylko przy nadpisywaniu metody w podklasach). Względnie możesz mieć typ generyczny, ale z klasy, a nie z metody np class Klasa[T] { def metoda(parametr: T) }, ale wtedy T zmienia się tylko podczas zmiany typu referencji do instancji klasy.

Czyli po prostu pomieszałem chyba dla typu deklarowanego typ przypisany i typ przekazywanego argumentu.

Tak. Zresztą popatrz np do List jak tam jest zrobione kontrawariantne łączenie list (w sensie typ głowy nowej listy jest kontrawariantny względem typu obecnej listy). Masz mniej więcej:

class List[A] {
  def ::[B >: A](new_head: B): List[B] = ::[B](new_head, this)
}

Masz tutaj osobny parametr generyczny w metodzie (czyli dodatkowy parametr metody B oprócz dostępnego w całej klasie A). Trik działa bo lista jest niemutowalna i nie ma przypisania wartości typu B do referencji typu A - jest to sprawdzane na etapie kompilacji zgodnie z regułami wariancji.

Wariancja nie wyłącza reguł dziedziczenia i związanych z nim reguł przypisania. Jeżeli metoda M wymaga parametru typu X, a ty podajesz obiekt klasy Y będącej podtypem X to wszystko będzie się zgadzać i kompilować. Rzutowanie niegenerycznego typu parametru w górę zawsze jest bezpieczne, więc kompilator zawsze może to zrobić. Dopiero z generycznymi jest problem i do gry wchodzą reguły wariancji. Zauważ, że jak rzutujesz Function1[A, B] na Function1[C, D] to typ klasy pozostaje bez zmian, a rzutujesz parametry generyczne. To zupełnie inna sprawa niż przy rzutowaniu z A do C czy z B do D.

0
Wibowit napisał(a):

Chodziło mi o to, że możesz mieć metodę z parametrem generycznym def metoda[T](parametr: T) (wtedy T może się zmieniać z każdym wywołaniem metody) albo i bez def metoda(parametr: String) (i mimo braku parametru generycznego dalej mamy reguły wariancji, ale tylko przy nadpisywaniu metody w podklasach). Względnie możesz mieć typ generyczny, ale z klasy, a nie z metody np class Klasa[T] { def metoda(parametr: T) }, ale wtedy T zmienia się tylko podczas zmiany typu referencji do instancji klasy.

Czyli podsumowując :

  1. w przypadku typu przekazywanego argumentu zarówno metoda jak i funkcja działa tak samo - mogę przekazać zadeklarowany typ lub typ pochodny
  2. dla funkcji typ deklarowany vs typ przypisany => wiązania na wariancje, jak dojdą parametry generyczne to może się posypać
  3. dla metody typ deklarowany vs typ przypisany :
    a) bez generyków - przy nadpisywaniu (dla argumentów nie ma to chyba znaczenia bo musi być ich kolejność zgodna podobnie jak typy bo inaczej nie miałby co kompilator nadpisywać, typ zwracany kowariantnie ?)
    b) z generykiem bezpośrednio dla metody - można stosować tylko typ invariantny więc nie ma problemów ?
    c) z generykiem dla klasy - sprawdzane normalnie typy (argumenty kontrawariantne, a typ zwracany kowariantny)
Wibowit napisał(a):

Zresztą popatrz np do List

Za pierwszym razem przeczytałem Lisp xD

Wibowit napisał(a):
class List[A] {
  def ::[B >: A](new_head: B): List[B] = ::[B](new_head, this)
}

Hmm, a to nie oznacza tylko że A musi być albo B albo podtypem B ? Nie powinno być jakiegoś + czy - przy deklaracji typu A w klasie ? (i chyba liczba argumentów się nie zgadza, chyba że this jest jakoś z automatu przekazywane).

1

w przypadku typu przekazywanego argumentu zarówno metoda jak i funkcja działa tak samo - mogę przekazać zadeklarowany typ lub typ pochodny

Tak. W ogóle dla typu przekazywanego argumentu do metody (czyli w miejscu użycia tej metody) nie ma zastosowania wariancja określona na parametrach generycznych klasy.

dla funkcji typ deklarowany vs typ przypisany => wiązania na wariancje, jak dojdą parametry generyczne to może się posypać

Function1[A, B] jest typem generycznym, więc przypisując instancję Function1[A, B] do zmiennej typu Function1[C, D] jak najbardziej reguły wariancji muszą być uwzględnione.

dla metody typ deklarowany vs typ przypisany :
a) bez generyków - przy nadpisywaniu (dla argumentów nie ma to chyba znaczenia bo musi być ich kolejność zgodna podobnie jak typy bo inaczej nie miałby co kompilator nadpisywać, typ zwracany kowariantnie ?)

Typ zwracany jest kowariantny. Np:

trait MyTrait {
  def method: Number
}

class MyClass extends MyTrait {
  override def method: Integer = 5
}

Typy argumentów są inwariantne, zgodnie z tabelką którą już podałem.

b) z generykiem bezpośrednio dla metody - można stosować tylko typ invariantny więc nie ma problemów ?

Co miałaby oznaczać wariancja przy parametrze generycznym metody?

c) z generykiem dla klasy - sprawdzane normalnie typy (argumenty kontrawariantne, a typ zwracany kowariantny)

Tak

Hmm, a to nie oznacza tylko że A musi być albo B albo podtypem B ? Nie powinno być jakiegoś + czy - przy deklaracji typu A w klasie ? (i chyba liczba argumentów się nie zgadza, chyba że this jest jakoś z automatu przekazywane).

Zapomniałem plusa przy List. Powinno być:

class List[+A] {
  def ::[B >: A](new_head: B): List[B] = ::[B](new_head, this)
}

this jest tym samym co this w Javie, C# czy C++ i też się go jawnie nie przekazuje. Zapis B >: A oznacza że B jest nadtypem A.

1 użytkowników online, w tym zalogowanych: 0, gości: 1