Wo kommen die Daten her, wo gehen sie hin?

JDK 8: Streams first touch

Sven Ruppert
©Shutterstock.com/ilolab

Im letzten Artikel „Streams: wie alles beginnen kann“ wurde ein Beispiel unter Verwendung von Streams gezeigt. Aber wie beginnt man praktisch mit der Arbeit? In diesem Artikel geht es um die Frage wo Daten herkommen und wo sie hingehen.

Jeder ist in Java schon mal mit irgendeiner Form von Streams konfrontiert worden. Aber was genau macht einen Stream nun im JDK8 aus?

Streams sind keine Datenstruktur, was so viel bedeutet, dass sie keinen Storage für Daten darstellen. Es handelt sich eher um eine Pipeline für Datenströme. In dieser Pipeline werden verschiedene Transformationen auf die Daten angewendet. In diesem speziellen Fall werden die Transformationen nicht auf den Daten der Quellstruktur selbst durchgeführt. Die zugrundeliegenden Datenstrukturen wie Arrays oder Listen werden also nicht verändert. Ein Stream wrapped also die Datenstruktur, entnimmt dieser die Quelldaten und arbeitet auf Kopien.

Streams sind für den Einsatz von Lambdas konzipiert worden. Es gibt also keine Streams ohne Lambdas, was kein Problem darstellt da Streams und Lambdas zusammen im JDK8 enthalten sind.

Streams bieten keinen wahlfreien Zugriff per Index oder ähnliches auf die Quelldaten. Der Zugriff auf das erste Element ist möglich, nicht jedoch auf beliebige nachfolgende Elemente.

Streams bieten guten Support um die Ergebnisse selbst wieder als z.B. Array oder List zur Verfügung zu stellen.

Streams sind lazy organisiert. Das bedeutet, dass die Elemente erst geholt werden wenn die Operation auf ihnen angewendet werden soll. Besteht die Datenquelle aus 1000 Elementen, dann dauert der erste Zugriff nicht 1000 Zeiteinheiten, sondern eine Zeiteinheit (vorausgesetzt der Zugriff auf ein Element ist linear im Zeitverbrauch).

Streams sind parallel, wenn gewünscht. Die Streams kann man prinzipiell in zwei Hauptgruppen unterteilen: Die seriellen und die parallelen Implementierungen. Wenn also Operationen atomar und ohne Invarianten durchgeführt werden können, wird kein typischer multithreaded Code notwendig um die im System schlafenden Cores zu verwenden.

Streams sind ungebunden, da sie nicht wie Collections initial befüllt werden. Damit sind Streams auch unendlich. Es können Generatorfunktionen angegeben werden, die für die permanente Lieferung von Quelldaten sorgen, die erzeugt werden wenn der Client Elemente des Streams konsumiert.

Wo kommen all die Quell-Daten her?

Wenn man daran denkt, dass Streams nicht wie Collections ihre eigenen Daten halten, dann stellt sich die Frage wo diese dann herkommen? Die gebräuchlichste Art um Streams zu erzeugen ist die Verwendung der Methoden, um aus einer festen Anzahl von Elementen einen Stream zu erzeugen. Das sind dementsprechend die Methoden Stream.of(val1, val2,val3…) , Stream.of(array) und list.stream(). Zu den Methoden aus dem Bereich der Erzeugung aus einem festen Wertevorrat, gehört auch die Methode, die aus einem String einen Stream erzeugt. Ein String ist nichts anderen als eine endliche Kette von chars. Diese kann als Wertevorrat übergeben werden: final Stream<String> splitOf = Stream.of(„A,B,C“.split(„,“));

Genauso können Streams aus Streams erzeugt werden, was in dem nächsten Artikel genauer dargestellt werden wird.

Nun fehlen noch zwei Möglichkeiten Streams zu erzeugen. Es kann mit einem Builder ein Stream programmatisch erzeugt werden: final Stream<Pair> stream = Stream.<Pair>builder().add(new Pair()).build();

Die letzte der Möglichkeiten einen Stream zu erzeugen besteht darin, einen Generator zu verwenden. Das erfolgt über die Methode Streams.generate(..) (Listing 1), in dessen Argument die Methode eine Instanz der Klasse Supplier<T> bekommt.

    Stream
        .generate(() -> {
            final Pair p = new Pair();
            p.id = random.nextInt(100);
            p.value = "Value + " + p.id;
            return p;
        })


Wo gehen all die Daten hin?

Nachdem wir also wissen wo die Daten herkommen, nun die Frage wie die Daten aus dem Stream wieder zu bekommen sind. Immerhin ist es ja meist angedacht, damit weiter zu arbeiten.

Die einfachste Möglichkeit besteht darin, aus einem Stream ein Array mittels der Methode stream.toArray() oder eine Liste mittels stream.collect(Collectors.toList()) zu erzeugen.

Damit sind fast 90 Prozent der Einsatzgebiete beschrieben, dennoch können auch Sets und Maps erzeugt werden. Sets mittels der Methode stream.collect(Collectors.toSet()), Maps hingegen mit stream.collect(Collectors.groupingBy(..)). Das Argument von groupingBy() sieht mindestens eine Function vor, mit der eine Gruppierung vorgenommen werden kann. Die Gruppierung stellt den Schlüssel in der Map dar, das Value ist dann eine List vom Typ der Elemente des Streams.

Eine für manchen Entwickler etwas ungewohnte Möglichkeit besteht darin, den Stream in einem String auszugeben. Um das zu erreichen wird in der Methode collect ein toStringJoiner verwendet, dessen Übergabeparameter ein Delimiter ist. Das Ergebnis ist dann eine Liste der durch diesen Delimiter konkatenierten toString()-Repräsentationen aller Elemente.

    public static void main(String[] args) {
        final List<Pair> generateDemoValues = generateDemoValues();

        //Stream from Values
        final Stream<Pair> fromValues 
            = Stream.of(new Pair(), new Pair());

        //Stream from Array
        final Pair[] pairs = {new Pair(), new Pair()};
        final Stream<Pair> fromArray = Stream.of(pairs);

        //Stream from List
        final Stream<Pair> fromList = generateDemoValues.stream();

        //Stream from String
        final Stream<String> abc = Stream.of("ABC");
        final Stream<IntStream> of = Stream.of("ABC".chars());
        final Stream<String> splitOf = Stream.of("A,B,C".split(","));

        //Stream from builder
        final Stream<Pair> builderPairStream =
            Stream.<Pair>builder().add(new Pair()).build();


        //Stream to Array
        final Pair[] toArray =
            generateDemoValues.stream().toArray(Pair[]::new);
        //Stream to List
        final List<Pair> toList =
            generateDemoValues.stream()
                .collect(Collectors.toList());

        //Stream to Set
        final Set<Pair> toSet =
            generateDemoValues.stream()
                .collect(Collectors.toSet());

        //Stream to Map
        final Map<Integer,List<Pair>> collectedToMap =
            generateDemoValues.stream()
                .collect(Collectors.groupingBy(Pair::getId));
        System.out.println("collectedToMap.size() = " 
            + collectedToMap.size());

        for (final Map.Entry<Integer, List<Pair>> entry : 
            collectedToMap.entrySet()) {
                System.out.println("entry = " + entry);
        }

    }


Fazit

Sehr schön zu sehen ist, dass sich die Streams einfach in bestehenden JavaCode einbinden lassen. Keine unnötigen Wrapper oder ähnliches sind zu schreiben. Die Integration ist damit mühelos in Altprojekten genauso möglich wie in neuen Projekten. Hat man sich an das API ein wenig gewöhnt, fallen einem sehr viele Stellen auf, in denen eine starke Codereduktion durch den Einsatz von Streams erreicht werden kann.

In den nachfolgenden Artikeln werden wir uns mit der Funktionsvielfalt der Streams beschäftigen und anhand von praktischen Beispielen erläutern. Ein genauerer Blick wird sich lohnen.

Die Quelltexte zu diesem Text sind unter [1] zu finden. Wer umfangreichere Beispiele zu diesem Thema sehen möchte, dem empfehle einen Blick auf [2].

Aufmacherbild: Streams of light abstract Cool waves background von Shutterstock / Urheberrecht: ilolab

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.