Czy powinno się zwracać Optional?

4
  1. Luj ogłuszacz gościowi który dał ci taki komentarz na review
  2. Zwracanie Option/Optional/Either etc jest jak najbardziej ok
  3. Za samo pomyślenie o zrobieniu return null powinien być zakaz dotykania klawiatury. Jedyna sytuacja kiedy wolno coś takiego zrobić, to jak integrujesz się z jakimś API które inaczej nie umie i oczekuje nulla.
5

Jedyna sytuacja kiedy wolno coś takiego zrobić, to jak integrujesz się z jakimś API które inaczej nie umie i oczekuje nulla.

Nie no, jest jeszcze druga możliwość - zależy Ci na tym, aby Twój kod w Javie nie był powolniejszy 10x od ekwiwalentu w Rust/C/C++, a jedynie o 50%.
Optional powoduje alokację na stercie, która jeśli nie zostanie w szczególnym przypadku wyeliminowana przez JVM, który to przypadek zwykle zdarza się w mikrobenchmarkach a nie chce się zdarzać na produkcji, ma naprawdę spory narzut względem zwykłego przekazania referencji / nulla przez rejestr. I to narzut zarówno pamięciowy jak i CPU. Bodajże 16 B nagłówka obiektu, 8 B na właściwy wskaźnik lub null, 8 B wyrównania. Razem 32 bajty narzutu. Potem GC musi częściej sprzątać i płacisz jeszcze cyklami CPU.

W przypadku jak zwracasz wartość typu prostego a nie referencję jest jeszcze gorzej, bo Optional wymusza opakowanie wartości aby móc się odwołać przez referencję, czyli zamiast int będziesz mieć Integer. I cyk, kolejne 32 bajty na stercie robią papa. Razem 64 bajty aby zwrócić jakiegoś g***anego integera zajmującego 8 bajtów. Tylko dlatego, że chciałeś aby był opcjonalny i aby API było ładne. ;)

Dla porównania - w Rust Option na referencji kosztuje 0 bajtów, na typie prostym 8 bajtów (zakładając arch. 64 bitową), ale w pewnych sytuacjach nawet na typach prostych (np. typy NonZero***) 0 bajtów. I nigdy nie powoduje alokacji na stercie, w najgorszym przypadku idzie przez stos.

W Kotlinie zdaje się że też Option na referencji jest kompilowany pod spodem do ekwiwalentu na nullach, ale tego nie jestem pewien.

1

Chciałem zwrocic uwagę ze w Javie wcale nie trzeba robic softu dla rakiet balistycznych czy TGV zeby odczuc różnice w wydajności miedzy obydwoma rozwiązaniami (i ogólnie zobaczyć efekt jakichś mikrooptymalizacji).

Wystarczy, ze wczytujesz kilka milionow wierszy do raportu a w kazdym masz po kilkadziesiąt wywołań danej funkcji.

5

Co do powolności lub nie Optionala i jaki narzut daje.
Puściłem dla was benchmark - https://github.com/jarekrataj[...]etblack/optionalTest/Opt.java ( można cały projekt pobrać i opdalić przez `./gradlew jmh).

Kod jest obrzydliwie korzysta z Optional.get i Optional.isPresent, ale chodziło, aby przetestować jako "najbardziej wprost" alternatywę do null (jak najbliżej kodu z null).

    private Long randomNumber(Random rnd) {
        int i = rnd.nextInt(100);
        if (i > 10) {
            return  null;
        } else {
            return (long)i;
        }
    }

    private Optional<Long> randomNumberOpt(Random rnd) {
        int i = rnd.nextInt(100);
        if (i > 10) {
            return  Optional.empty();
        } else {
            return Optional.of((long)i);
        }
    }

//kod na nullach
    @Benchmark
    public long sumClassic() {
        final Random rnd = new Random(42);
        long sum = 0;
        for (int i=0; i<1_000_000;++i) {
            Long n = randomNumber(rnd);
            if (n != null) {
                sum+= n;
            }
        }
        assert sum == 549645;
        return sum;
    }
//kod na Optionallu
    @Benchmark
    public long sumOptional() {
        final Random rnd = new Random(42);
        long sum = 0;
        for (int i=0; i<1_000_000;++i) {
            Optional<Long> n = randomNumberOpt(rnd);
            if (n.isPresent()) {
                sum+= n.get();
            }
        }
        assert sum == 549645;
        return sum;
    }

Najpierw wyniki:

Benchmark                      Mode  Cnt    Score    Error  Units
optionalTest.Opt.sumClassic   thrpt    6  418.330 ±  7.261  ops/s
optionalTest.Opt.sumOptional  thrpt    6  413.619 ± 11.100  ops/s

W granicach błędu - takie same. Zaszło to o czy pisze @Krolik - w kodzie benchmarkowym jvm ładnie "eskejpuje" alokację na stercie (i używa stosu) - w efekcie nie ma różnicy. Obie implementacje generuja prawie ten sam kod (nie chciało mi się dumpować, ale strzelam, że różnią się instrukcją mov głównie i adresowaniem pośrednim).
Haczyk: taki ładny rezultat to mam tylko na:
tadam

# VM version: JDK 16.0.2, Java HotSpot(TM) 64-Bit Server VM, 16.0.2+7-jvmci-21.2-b08
# VM invoker: /home/jarek/programs/graalvm-ee-java16-21.2.0.1/bin/java
# VM options: -Dgraal.CompilerConfiguration=enterprise

Czyli trzeba graalvm i do tego jeszcze w wersji enterprise (olaboga...).

Użyjmy normalnego graala (community).


# JMH version: 1.29
# VM version: JDK 16.0.2, OpenJDK 64-Bit Server VM, 16.0.2+7-jvmci-21.2-b08
# VM invoker: /home/jarek/.sdkman/candidates/java/21.2.0.r16-grl/bin/java
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCIProduct -XX:-UnlockExperimentalVMOptions -XX:ThreadPriorityPolicy=1 -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/home/jarek/dev/wisnie/benchmarks/javowe/app/build/tmp/jmh -Duser.country=US -Duser.language=en -Duser.variant

optionalTest.Opt.sumClassic   thrpt    6  397.427 ± 13.058  ops/s
optionalTest.Opt.sumOptional  thrpt    6  382.808 ±  9.249  ops/s

Tutaj też jest nieźle, ale optional wyszedł troszkę gorzej (nadal w granicach błędu takie same). Co ciekawe (ku mojemu zaskoczeniu) - escape analysis nie zadziałało i była alokacja na stercie. Czyli nawet w tak trywialnym benchmarkowym kodzie graal nie poradził sobie z eliminacją alokacji na stercie.sad frog
(czy są alokacje, czy nie - to ładnie widać jak się podłączymy do jvm benchmarku przez np. jvisualvm).

Tylko, że co z tego, skoro GC opiernicza te krótko żyjące optionale w pierdyliardach na sekunde. Może przesadziłem z pierdyliardami, ale widać, że dramatu nie ma.

To zobaczmy jeszcze - hotspota openjdk

# JMH version: 1.29
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724
# VM invoker: /home/jarek/.sdkman/candidates/java/17-open/bin/java
# VM options: -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/home/jarek/dev/wisnie/benchmarks/javowe/app/build/tmp/jmh -Duser.country=US -Duser.language=en -Duser.variant

optionalTest.Opt.sumClassic   thrpt    6  158.343 ± 5.865  ops/s
optionalTest.Opt.sumOptional  thrpt    6  155.063 ± 5.330  ops/s

Widać dużo gorsze (oba!!!) wyniki. W optionalu była alokacja (!!!), ale znowu: dramatu to raczej nie ma, bo w granicach błędu to samo.

Na koniec - bencharki to oczywiście tylko benchmarki. Oracle (wcześniej Sun) już kilkanaście lat temu odrobili lekcję i robią tak JVM, żeby dobrze wychodził w benchmarkach:
troll

W praktyce może być różnie, aczkolwiek stawiam hipotezę, że w typowych programach jakie robimy i które są bardziej io bound, ( nie męczą CPU), to ten optional naprawdę nie ma znaczenia.
Oczywiście może mieć znaczenie jak masz tablicę milionów Optionali lub coś podobnego.
Ciekawe byłoby przetestowanie jeszcze na różnych GC (np. nie generacyjnym) - ale nie mam aż tyle czasu w życiu na pierdoły.

Oczywiście wszystkie rezultaty były robione w pełni nieprofesjonalnie, nie dość, że na komputerze gdzie gra muzyka, 100 zakładek w chrome, to jeszcze na vmware itd. Olaboga. Dlatego daje źródła, żeby samemu sobie odpalić: https://github.com/jarekratajski/-benchmarekPoczwarekOpt
(ale ponieważ pi razy oko wiem co tam się dzieje to raczej względne wyniki wyjdą Ci podobne).

1

Disclaimer: nie chcę tutaj udowadniać, że nie warto używać Optionala, po prostu myślę że można by ten test poprawić.
Trochę zmieniłem testy i odpaliłem na JVM 11 bez dodatkowych opcji.

Środowisko:


# JMH version: 1.19
# VM version: JDK 11.0.11, VM 11.0.11+9-Ubuntu-0ubuntu2.20.04
# VM invoker: /usr/lib/jvm/java-11-openjdk-amd64/bin/java
# VM options: <none>

Wyniki:

Benchmark                  Mode  Cnt    Score   Error  Units
BenchTests.sumClassicJR   thrpt  200   90.690 ± 0.187  ops/s
BenchTests.sumClassicPL   thrpt  200  195.298 ± 6.729  ops/s
BenchTests.sumOptionalJR  thrpt  200   87.573 ± 0.322  ops/s
BenchTests.sumOptionalPL  thrpt  200  145.631 ± 0.600  ops/s

Co zmieniłem (suffiks: PL) w stosunku do oryginalnych wersji (suffiks: JR):

  • zmniejszyłem użycie Random.nextInt z testów (z 1 mln do 100 wywołań)
  • null/empty jest zwracany potencjalnie dla większej liczby przypadków
  • zamiast asercji użyłem Blackhole

Podsumowanie wyników:
Wg mnie oznaczają to że Random.nextInt dominuje czas wykonania a różnica m. Long a Optional<long> jest dopiero widoczna gdy wszystko inne w teście jest zaniedbywalne.
Liczba wywołań nie zmieniła się, ale w mojej wersji czas wykonania jest znacząco (2x) mniejszy. Zakładam, że powoduje to właśnie Random.
W mojej wersji eliminacja Optional spowodowała skok wydajności jeśli dobrze liczę na poziomie 30%.
JMH znam słabo, opcje Javy żeby to zoptymalizować jeszcze mniej.

Mój kod:

plik 1:

// com.company.BenchTests

//kod na nullach
    @Benchmark
    public long sumClassicPL(Blackhole blackhole) {
        int size = 100;
        final SubjectClass sc = new SubjectClass(size);
        long sum = 0;
        for (int i=0; i<size;++i) {
          for (int j=0; j<size;++j) {
            for (int k=0; k<size;++k) {
                Long v = sc.getValue(i, j, k);
                if (v != null) {
                    sum += v;
                }
            }
          } 
        }
        blackhole.consume(sum);
        return sum;
    }
//kod na Optionallu
    @Benchmark
    public long sumOptionalPL(Blackhole blackhole) {
        int size = 100;
        final SubjectClass sc = new SubjectClass(size);
        long sum = 0;
        for (int i=0; i<size;++i) {
          for (int j=0; j<size;++j) {
            for (int k=0; k<size;++k) {
                Optional<Long> v = sc.getValueOpt(i, j, k);
                if (v.isPresent()) {
                    sum += v.get();
                }
            }
          } 
        }
        blackhole.consume(sum);
        return sum;
    }

plik 2:

// com.company.SubjectClass

public class SubjectClass {

    private final int[] testData;
    private final int sampleSize;

    public SubjectClass(int sampleSize) {
       this.sampleSize = sampleSize;
       this.testData = new int[sampleSize];
       final Random rnd = new Random(42);
       for(int i=0; i < testData.length; i++) {
         testData[i] = rnd.nextInt(sampleSize);
       }
    }

    public int getSampleSize() {
       return sampleSize;
    }

    public Long getValue(int x, int y, int z) {
        long result = doGetValue(x, y, z);
        if (result > 10) {
          return null;
        } else {
          return result;
        }  
    }

    public Optional<Long> getValueOpt(int x, int y, int z) {
        long result = doGetValue(x, y, z);
        if (result > 10) {
          return Optional.empty();
        } else {
          return Optional.of(result);
        }  
    }

    private long doGetValue(int x, int y, int z) {
        int a = testData[x];
        int b = testData[(a + y) % sampleSize];
        long c = testData[(b + z) % sampleSize];
        return c ^ b ^ a;
    }

}
1

@jarekr000000: @vpiotr: a sprawdzaliscie flame graph i co zajmuje najwiecej czasu? Bez tego nie wiadomo co wlaściwie mierzymy.
Moze w przypadku @jarekr000000 narzut optional to <1% czasu wywolania, wiec nawet jak jest 2x wolniejszy to praktycznie to nie wyjdzie w tym benchmarku.

Shameless plug, kiedys próbowalem zrobic podobne benchmarki dla różnych formatów i okazało się to całkiem tricky dla mnie z masą kwiatków - typu generowanie danych czy obiektów zajmuje 70% runtime.

4

@Fedaykin:
Po prostu mierzę konkretny scenariusz, w którym Optional jest, ale nawet niekoniecznie zajmuje najwięcej czasu. Ty niemniej sztucznie istotnie więcej niż w typowym programie, gdzie oprócz obliczania czegoś z optional (sumowanie to nie jest droga oparacja), robisz masę innych rzeczy. (u mnie Random miał symulować "inne rzeczy".) W sumie o to chodziło - nawet jak jest 2x wolniejszy to nie bardzo wyjdzie.

Tym niemniej zmieńmy ten kod, tak żeby już prawie nic poza Optionalem (i to "pełnym") nie było (tu nawet nie będzie czego zobaczyć we flame grafie).
W zasadzie to jest ten scenariusz typu tablica miliona optionali.

 private Long randomNumber(int n) {
        int i = n % 100;
        if (i > 98) {
            return null;
        } else {
            return (long) i;
        }
    }

    private Optional<Long> randomNumberOpt(int n) {
        int i = n % 100;
        if (i > 98) {
            return Optional.empty();
        } else {
            return Optional.of((long) i);
        }
    }

    @Benchmark
    public long sumClassic() {
        int k = 0;
        long sum = 0;
        for (int i = 0; i < 1_000_000; ++i) {
            Long n = randomNumber(k++);
            if (n != null) {
                sum += n;
            }
        }
        return sum;
    }

    @Benchmark
    public long sumOptional() {
        int k = 0;
        long sum = 0;
        for (int i = 0; i < 1_000_000; ++i) {
            Optional<Long> n = randomNumberOpt(k++);
            if (n.isPresent()) {
                sum += n.get();
            }
        }
        return sum;
    }

graal enterprise:

Benchmark                      Mode  Cnt     Score    Error  Units
optionalTest.Opt.sumClassic   thrpt    6  1216.536 ±  7.501  ops/s
optionalTest.Opt.sumOptional  thrpt    6  1215.374 ± 11.645  ops/s

graal community:

optionalTest.Opt.sumClassic   thrpt    6  748.317 ± 11.566  ops/s
optionalTest.Opt.sumOptional  thrpt    6  376.698 ±  5.545  ops/s

Jak widać - idealnie jak napisałeś 2x wolniejsze (bez enterprise). (Chociaż, jak się czepić to nadal to nie był sam Optional).

EDIT:
Dla lepszego obrazu dodaje jeszcze hotspoty...

# VM version: JDK 11.0.11, OpenJDK 64-Bit Server VM, 11.0.11+9
# VM invoker: /home/jarek/.sdkman/candidates/java/11.0.11.hs-adpt/bin/java
# VM options: -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/home/jarek/dev/wisnie/benchmarks/javowe/app/build/tmp/jmh -Duser.country=US -Duser.language=en -Duser.variant
optionalTest.Opt.sumClassic   thrpt    6  623.655 ±  1.869  ops/s
optionalTest.Opt.sumOptional  thrpt    6  247.115 ± 24.290  ops/s
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724
# VM invoker: /home/jarek/.sdkman/candidates/java/17-open/bin/java
# VM options: -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/home/jarek/dev/wisnie/benchmarks/javowe/app/build/tmp/jmh -Duser.country=US -Duser.language=en -Duser.variant

optionalTest.Opt.sumClassic   thrpt    6  873.666 ± 5.492  ops/s
optionalTest.Opt.sumOptional  thrpt    6  352.608 ± 2.380  ops/s

Widać różnicę, ale widać też ładny progress na kolejnych wersjach jvm.

5

Fajnie, ze są benchmarki tylko z mojego punktu widzenia, to albo jest o co się bić z wydajnością i wtedy użycie Optional blednie na tle całych wiader ustępstw w clean code, dzieleniu na warstwy i zastąpieniu kolekcji tablicami, bo faktycznie trzeba się bić o każdy cykl procesora z jakiegoś tam powodu, albo mamy standardową aplikację działającą sobie na serwerze i wydajność w zupełnie nieistotnej części zależy od tego czy użyliśmy tej klasy, czy innej, bo narzut całego bałaganu z użyciem refleksji, komunikacją przez HTTP, odczyt i zapis danych do DB, czy od obsługa wyrażeń regularnych sprawia, że narzut iluś tam nanosekund nie ma żadnego realnego znaczenia. Gdyby wydajność była jedynym czynnikiem o który warto walczyć, to wszyscy siedzielibyśmy w asm'ie. Nawet jeżeli jest sens walczyć o wydajność, to zwykle ten miliard wywołań na sekundę można opakować w coś (klasa, moduł, metoda) z napisem "DRAGONS AHEAD, DO NOT ENTER` a efekt tego miliona wywołań zapakować w optionala jeżeli ma to sens. Wtedy mamy prawie maksymalną wydajność tam gdzie trzeba i "ładnie" tam gdzie na tej wydajności nam nie zależy tak bardzo.

Zupełnie osobna sprawa, to pytanie czy jeżeli mamy zadanie w którym wydajność jest naprawdę ważne powinniśmy używać do jego obsługi języka, który od 25 lat twierdzi, że już za chwilę będzie szybszy od C++ i jak to kolejne wynalazki pozwolą uzyskać nie wiadomo jak wielkie przyśpieszenie, o którym później mówi się już jakoś mniej.

5

@piotrpo: dokładnie.
Najgorsze, że raz na jakiś czas ktoś w internecie palnie o wydajności coś co nawet w jakimś kontekście (milion na sekunde) jest prawdą i potem przez lata trzeba walczyć, bo przyłażą uberarchitekci i Ci mówią, że niewydajne (czytali), albo nowi programiści i tak samo trzeba tłuc od razu koszmarne nawyki. (tylko midom zwykle zwisa).

Najgorsze, jak wskutek postępu technicznego taka sprawa sprzed dziesieciu lat jest już nieaktualna, ale duchy wydajności nadal straszą. Swego czasu mój zespół przepisywał całe kawały kodu pod dyktando germańskich architektów, co gdzieś coś wyczytali - mimo, że wiedzieliśmy i umieliśmy pokazać, że bezedura....Benchmarki, benchmarkami, ale 2 wpisy blogowe javowych góru sprzed pięciu lat nie mogą być błędne.

Dlatego jednak te jmh publikuję, żeby chociaż te rzeczy które już stały się bzdurami odsiać - Optional ma niezłą szansę być zupełnie odsiany za jakiś czas nawet w tych miliardach na sekundę.

Zabawne będzie jak ktoś kiedyś spojrzy na te moje wypociny i powie: o pacz - typy pisze jaki Optional wolny ( a moja teza była inna).
Żeby było śmieszniej to pracuje teraz przy wydajności na cały etat, operniczam taki funkcyjny kod, że Optional to przy tym pikuś. I jakoś działa.
(ale its complicated - nie chce tutaj pisać i kłamać, że funkcyjny kod jest szybszy, po prostu nawet tam, gdzie wydajność się liczy i opitalamy jakieś 1000 req na sekunde, nadal jest miejsce na "pure code").

0

@jarekr000000: O, to jest właśnie bardzo interesujące. Wygląda na to, że Optional vs null może być co najmniej 2x wolniejszy (a łatwo sobie wyobrazić przypadki gdzie może wymuszać nawet większe spowolnienie całości - choćby ze względu na wymóg pełnych obiektów w środku). Dzięki za porównanie z JDK 11, zaskakująca różnica pomiędzy wersjami.

Generalnie zgadzam się, że ilość aplikacji dla których to ma znaczenie jest relatywnie mała - i co więcej, cały czas się zmniejsza (nowoczesne komputery i sama Java są bardzo szybkie) - to jednak istnieją i będą istnieć dziedziny gdzie podobne optymalizacje mają czasami sens - procesowanie dużych wolumenów danych, bazy danych, procesowanie message'y, systemy rozproszone, image processing, AI, HPC etc. generalnie wszystkie dziedziny gdzie poza programowaniem znaczenie ma jeszcze informatyka albo skala.

Zupełnie osobna sprawa, to pytanie czy jeżeli mamy zadanie w którym wydajność jest naprawdę ważne powinniśmy używać do jego obsługi języka, który od 25 lat twierdzi, że już za chwilę będzie szybszy od C++ i jak to kolejne wynalazki pozwolą uzyskać nie wiadomo jak wielkie przyśpieszenie, o którym później mówi się już jakoś mniej.

Tylko, że Java faktycznie może być całkiem szybka - inna sprawa, że trzeba odpowiednio się dostosować pisząc kod - vide narzekanie @Krolik że niby Java a i tak trzeba żonglować bajtami z lewej na prawą i ręcznie zwalniać pamięć.

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