niedziela, 29 stycznia 2017

Kowariantne Konsekwencje

Mroźnym popołudniem standardowego dnia pracy wiedzeni drzemiącą w nas naturą podróżnika możemy wylądować gdzieś w pośród deklaracji metod bibliotecznych, które w javie8 wielce prawdopodobne będa przyjmować funkcje. I wtedy to naszym oczom może ukazać się coś takiego :

Function<? super V, ? extends T> declarationName

I tak w zasadzie to podobne rzeczy będą się ukazywać nam co chwilę... coraz częściej... w zasadzie w przypadku funkcji inna deklaracja(bez tego super i extends) nie ma sensu. Omówimy sobie skąd się to wzięło, dlaczego tak zostało, czemu inne deklaracje nie mają sensu i dlaczego pomimo, że to jedyna deklaracja z sensem to trzeba z palca zawsze to extends i super dodawać oraz, że istnieje alternatywa którą (znowu) ma w zasadzie wiele języków a nie ma java.

Problemy z typem typu

Dawno dawno temu wszystko w Javie było faktycznie obiektem. Nawet jak nie chciałeś. Jeśli mieliśmy Listę to mieliśmy listę obiektów. Były to czasy gdy monitory CRT wypalały oczy a aplikacja startowała 15 minut po to by zaraz po starcie rzucić wyjątek "class case exception" - no bo trzeba było zgadywać co za typ kryje się za obiektem. Nie było generyków.

Gdy generyki w końcu zawitały do Javy5 te problemy stały się znikome ale fani zbyt uproszczonych rozwiązań dostali nowe tematy do hejtowania bo oto pojawiły się pewne zachowania z punktu widzenia bazującego na "wszystko jest obiektem" - trochę nieintuicyjne.

No bo mamy takie coś, że -> String jest Obiektem -> mamy listę stringów -> List -> lista stringów jest obiektem ale czy stringi w liście są na tyle obiektami, że to także lista obiektów? Okazuje się, że w tym miejscu model się załamuje - bo nie jest!

Gdyby była to dałoby się zrobić coś takiego
List<String> stringi=new LinkedList<>();
List<Object> objjjekty = stringi; 
objjjekty.add(1); //kupa

A ponieważ doprowadziłoby to do eksplozji to takie niecne programowanie jest zabronione. No ale jeśli dobrze nam z oczu patrzy i obiecamy, że nie będziemy tam nic wkładać to kompilator może nam pójść na pewne ustępstwo :

List<? extends Object> probaDruga=stringi;
I jest teraz zabezpieczenie w postaci dziwnego typu co wystarczy by nas powstrzymać przed wrzucaniem tam śmieci

Czy można było inaczej to zrobić? Można było, co zobaczymy później ale patrząc na ten kawałek kodu z listą wydaje się, że mechanizm jest w porządku, nieprzekombnowany i ratuje nas przed ClassCastEx. To był rok 2004. Mija kolejnych 10 lat. Cała dekada. Wychodzi Java8 a w niej Funkcje . I to właśnie ten nowy mechanizm pokaże słabe strony wyborów z przeszłości...

Use Site Variance

Jeśli wejdziesz w definicję java.util.function.Function to zobaczysz do czego te zabawy z generykami doprowadziły

  default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

Pytania czy te "extends" i "super" musza tam być? To co teraz zrobię być może ma jakąś nazwę w nauce o wnioskowaniu (a może nie) ale generalnie one tam być muszą bo... nie ma żadnych argumentów za tym aby w przypadku funkcji ich tam nie było! (ewentualnie ktoś może mnie tutaj do-edukować).

Extends

Jaka jest różnica pomiędzy dwoma poniższymi funkcjami bibliotecznymi? (taka funkcja "map" dla ubogich)

  public static <A,B> Collection<B> libraryMethod(Collection<A> c, Function<A,B> f){
        List<B> l =new ArrayList<>();
        for(A a: c){
            l.add(f.apply(a));
        }

        return l;
    }


    public static <A,B> Collection<B> libraryMethod2(Collection<A> c, Function<? super A,? extends B> f){
        List<B> l =new ArrayList<>();
        for(A a: c){
            l.add(f.apply(a));
        }

        return l;
    }

Różnica pojawi się w wywołaniu bo mając klasę

class User{
    private String name;
    private Integer age;

    public User(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public Integer getAge() {
        return age;
    }
}

Chciałbym teraz wykonać poniższe przekształcenie:

Collection<User>  users=new LinkedList<>();
Function<User,String> display=o->o.toString();
Collection<Object> res1=libraryMethod(users,display); // error
Collection<Object> res2=libraryMethod2(users,display);

Ale nie można go wykonać :( Wiem, że w tych akapitach zbytnio się nie rozpisałem ale próbki kodu chyba lepiej pokazują o co chodzi. Bez dodatkowego wysiłku i dodatkowych deklaracji wprowadzamy niczym nie uzasadnione ograniczenie. Nie ma naprawdę żadnego argumentu za ograniczeniem przyjmowanych funkcja do stricte typów biorących udział w równaniu gdyż nic przez to nie zyskujemy a tracimy wszystkie przypadki wywołania gdy typy nie zgadzają się "jeden do jednego". I w zasadzie tak będzie wszędzie - w sensie każdej funkcji bibliotecznej. Nie potrafię znaleźć kontr-przykładu.

Super

Z tym super na początku sprawa jest trudniejsza bo to szalenie nieintuicyjne co tu się dzieje. Starałem się to kiedyś wytłumaczyć tutaj -> http://pawelwlodarski.blogspot.com/2013/08/covariance-i-contravariance-dla.html. Tutaj spróbujmy taki prosty przykład. Niech User ma podklasę jakaś taką specjalną :

class SpecialUser extends User{

    private String somethingSpecial;

    public SpecialUser(String name, Integer age, String somethingSpecial) {
        super(name, age);
        this.somethingSpecial = somethingSpecial;
    }
}

Mamy znowu inna funkcję biblioteczną, która tym razem filtruje nasze obiekty :

 public static <A> Collection<A> filter1(Collection<A> c, Function<A,Boolean> f){
        List<A> l =new ArrayList<>();
        for(A a: c){
            if(f.apply(a)) l.add(a);
        }

        return l;
    }


    public static <A> Collection<A> filter2(Collection<A> c, Function<? super A,Boolean> f){
        List<A> l =new ArrayList<>();
        for(A a: c){
            if(f.apply(a)) l.add(a);
        }

        return l;
    }

I znowu nie ma żadnego powodu by nie pozwolić tej metodzie przyjmować funkcje, które działają na podtypach bo przecież jest to całe is-A zapewnione - w sensie, że w podtypie na pewno znajdą się te metody których wymaga metoda działająca na rodzicu i całość się skompiluje.

Function<User,Boolean> isAdult=user->user.getAge()>= 18;

Collection<SpecialUser>  specialUsers=new LinkedList<>();
// filter1(specialUsers,isAdult); //error
filter2(specialUsers,isAdult);

No i oczywiście jak zajrzysz w implementacje java.util.stream.Stream to i tam też map oraz filter również mają te super i extends -> bo zwyczajnie inna deklaracja nie ma sensu! Ale skoro nie ma sensu to czy dałoby się zrobić to jakoś inaczej by tyle tego nie pisać i by uprościć typy? Oczywiście, gdyż to już jest, to już jest, jest już to
zdrowo i wesoło
we wszystkich językach w koło

Declaration-Site Variance

Otóż w językach innych możemy zamiast pisać po tysiąc razy to "extends" zwyczajnie napisać jeden raz przy deklaracji, domyślnie zawsze będzie "extends", że to jest po prostu natura danej konstrukcji.

C#
interface IProducer<out T> // Covariant
{
    T produce();
}
 
interface IConsumer<in T> // Contravariant
{
    void consume(T t);
}
Kotlin
abstract class Source<out T> {
    abstract fun nextT(): T
}

Scala
trait Function1[-T1,+R] extends AnyRef 

To dlaczego nie tak?

Dlaczego w javie jest to zrobione ala "use-site variance?" . Najprawdopodobniej gdy ten mechanizm powstawał wydawał się doskonałym pomysłem bo java miała jedynie mutowalne kolekcje do których ta maszynka wystarczała. Przypomnijmy - były to lata 2002-2005 -> IE miało 90% udziałów w rynku, ratunku dla branży widziano w tonach xmli a o funkcjach nikt nie myślał. No i w Javie musi być kompatybilność wstecz to jak już zrobili to tak zostało... Jeśli ktoś szuka przykładu długu technicznego to wydaje mi się, że tutaj właśnie go znaleźliśmy.

O nielicznych zaletach i licznej krytyce "Use-Site variance" przeczytacie tutaj -> https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)#Comparing_declaration-site_and_use-site_annotations. Generalnie jeśli chodzi o funkcje to przewaga "declaration-site variance" wydaje mi się bezdyskusyjna. Bardziej problematyczne są kolekcje. O ile chyba Kotlin ma obydwa typy podejść o tyle np. (znowu z tego co mi wiadomo) scala zrezygnowała z "use-site" na jakimś tam początkowym etapie rozwoju gdyż niewiele to dawało a utrudniało mocno koncepcję auto wykrywania typów dzięki któremu programy w scali mają tone mniej tekstu niż to co reprezentuje sobą java. Zaś co do samych kolekcji należałoby przenieść dyskusje na zupełnie inną płaszczyznę zatytułowaną "algorytmy z wykorzystaniem kolekcji niezmiennych" a tu już jesteśmy o krok od FP vs OOP co może nas doprowadzić do ogólnego wniosku, że problemy z generykami w Javie są pochodną wybranego paradygmatu programowania.

Pocieszenie

Jeśli pracujesz w dobrym ogromnym korpo gdzie kastą rządząca są ludzie biegający z plikiem exceli pod pachą (tzw. "jego EXCELencja") to nie masz się czym martwić bo i tak javy8 pewnie nie zobaczysz przez następne 10 lat. Także głowa do góry!

linki


1 komentarz:

  1. Tyle pytań rekrutacyjnych o PECS... I wszystko jak krew w piach ;)

    OdpowiedzUsuń