De Java 8 Stream API-zelfstudie

1. Overzicht

In deze diepgaande zelfstudie bespreken we het praktische gebruik van Java 8 Streams, van creatie tot parallelle uitvoering.

Om dit materiaal te begrijpen, moeten lezers een basiskennis hebben van Java 8 (lambda-uitdrukkingen, Optioneel, method referenties) en van de Stream API. Als u niet bekend bent met deze onderwerpen, bekijk dan onze vorige artikelen - Nieuwe functies in Java 8 en Inleiding tot Java 8-streams.

2. Streamen

Er zijn veel manieren om een ​​streaminstantie van verschillende bronnen te maken. Eenmaal gemaakt, de instance zal de bron niet wijzigen, waardoor het mogelijk is om meerdere instanties vanuit één bron te creëren.

2.1. Lege stroom

De leeg() methode moet worden gebruikt in het geval van het creëren van een lege stream:

Stream streamEmpty = Stream.empty ();

Het is vaak het geval dat de leeg() methode wordt gebruikt bij het maken om terugkeer te voorkomen nul voor streams zonder element:

public Stream streamOf (lijstlijst) retourlijst == null 

2.2. Stroom van Verzameling

Stream kan ook worden gemaakt van elk type Verzameling (Collectie, lijst, set):

Verzameling collectie = Arrays.asList ("a", "b", "c"); Stroom streamOfCollection = collection.stream ();

2.3. Stroom van Array

Array kan ook een bron van een stream zijn:

Stream streamOfArray = Stream.of ("a", "b", "c");

Ze kunnen ook worden gemaakt uit een bestaande array of uit een deel van een array:

String [] arr = nieuwe String [] {"a", "b", "c"}; Stroom streamOfArrayFull = Arrays.stream (arr); Stroom streamOfArrayPart = Arrays.stream (arr, 1, 3);

2.4. Stream.builder ()

Wanneer bouwer wordt gebruikt het gewenste type moet aanvullend worden gespecificeerd in het rechterdeel van de verklaring, anders de bouwen() methode zal een instantie van de Stroom:

Stream streamBuilder = Stream.builder (). Add ("a"). Add ("b"). Add ("c"). Build ();

2.5. Stream.generate ()

De genereren () methode accepteert een Leverancier voor het genereren van elementen. Aangezien de resulterende stroom oneindig is, moet de ontwikkelaar de gewenste grootte of de genereren () methode zal werken totdat het de geheugenlimiet bereikt:

Stream streamGenerated = Stream.generate (() -> "element"). Limit (10);

De bovenstaande code maakt een reeks van tien strings met de waarde - "element".

2.6. Stream.iterate ()

Een andere manier om een ​​oneindige stroom te maken, is door de herhalen() methode:

Stream streamIterated = Stream.iterate (40, n -> n + 2) .limit (20);

Het eerste element van de resulterende stream is een eerste parameter van de herhalen() methode. Voor het maken van elk volgend element wordt de gespecificeerde functie toegepast op het vorige element. In het bovenstaande voorbeeld is het tweede element 42.

2.7. Stroom van primitieven

Java 8 biedt de mogelijkheid om streams te maken uit drie primitieve typen: int, lang en dubbele. Net zo Stroom is een generieke interface en er is geen manier om primitieven te gebruiken als een typeparameter met generieke geneesmiddelen, er zijn drie nieuwe speciale interfaces gemaakt: IntStream, LongStream, DoubleStream.

Het gebruik van de nieuwe interfaces vermindert onnodig automatisch boksen en zorgt voor een hogere productiviteit:

IntStream intStream = IntStream.range (1, 3); LongStream longStream = LongStream.rangeClosed (1, 3);

De bereik (int startInclusive, int endExclusive) methode maakt een geordende stroom van de eerste parameter naar de tweede parameter. Het verhoogt de waarde van volgende elementen met de stap gelijk aan 1. Het resultaat bevat niet de laatste parameter, het is slechts een bovengrens van de reeks.

De rangeClosed (int startInclusive, int endInclusive)methode doet hetzelfde met slechts één verschil - het tweede element is inbegrepen. Deze twee methoden kunnen worden gebruikt om elk van de drie soorten primitievenstromen te genereren.

Sinds Java 8 is het Willekeurig class biedt een breed scala aan methoden voor het genereren van stromen van primitieven. De volgende code maakt bijvoorbeeld een DoubleStream, die drie elementen heeft:

Random random = nieuw Random (); DoubleStream doubleStream = random.doubles (3);

2.8. Stroom van Draad

Draad kan ook worden gebruikt als bron voor het maken van een stream.

Met behulp van de tekens () methode van de Draad klasse. Omdat er geen interface is CharStream in JDK, het IntStream wordt in plaats daarvan gebruikt om een ​​stroom tekens weer te geven.

IntStream streamOfChars = "abc" .chars ();

In het volgende voorbeeld wordt een Draad in subtekenreeksen volgens gespecificeerd RegEx:

Stream streamOfString = Pattern.compile (",") .splitAsStream ("a, b, c");

2.9. Stream van bestand

Java NIO-klasse Bestanden maakt het mogelijk om een Stroom van een tekstbestand via de lijnen () methode. Elke regel van de tekst wordt een onderdeel van de stream:

Padpad = Paths.get ("C: \ file.txt"); Stream streamOfStrings = Files.lines (pad); Stroom streamWithCharset = Files.lines (pad, Charset.forName ("UTF-8"));

De Tekenset kan worden opgegeven als een argument van de lijnen () methode.

3. Verwijzen naar een stream

Het is mogelijk om een ​​stream te instantiëren en er een toegankelijke verwijzing naar te hebben, zolang alleen tussenliggende bewerkingen werden aangeroepen. Het uitvoeren van een terminaloperatie maakt een stream ontoegankelijk.

Om dit aan te tonen, zullen we een tijdje vergeten dat het het beste is om de volgorde van de operatie aan elkaar te koppelen. Naast de onnodige breedsprakigheid is technisch gezien de volgende code geldig:

Stream stream = Stream.of ("a", "b", "c"). Filter (element -> element.contains ("b")); Optioneel anyElement = stream.findAny ();

Maar een poging om dezelfde referentie opnieuw te gebruiken na het aanroepen van de terminalbewerking zal de IllegalStateException:

Optioneel firstElement = stream.findFirst ();

Zoals de IllegalStateException is een RuntimeException, zal een compiler geen melding maken van een probleem. Het is dus erg belangrijk om dat te onthouden Java 8 streams kunnen niet worden hergebruikt.

Dit soort gedrag is logisch omdat streams zijn ontworpen om de mogelijkheid te bieden om een ​​eindige reeks bewerkingen toe te passen op de bron van elementen in een functionele stijl, maar niet om elementen op te slaan.

Dus om de vorige code correct te laten werken, moeten enkele wijzigingen worden aangebracht:

Lijstelementen = Stream.of ("a", "b", "c"). Filter (element -> element.contains ("b")) .collect (Collectors.toList ()); Optioneel anyElement = elements.stream (). FindAny (); Optioneel firstElement = elements.stream (). FindFirst ();

4. Stroompijplijn

Om een ​​reeks bewerkingen uit te voeren op de elementen van de gegevensbron en hun resultaten samen te voegen, zijn drie onderdelen nodig: de bron, tussenliggende operatie (en) en een terminal operatie.

Tussenliggende bewerkingen retourneren een nieuwe gewijzigde stream. Als u bijvoorbeeld een nieuwe stream van de bestaande wilt maken zonder enkele elementen, wordt de overspringen() methode moet worden gebruikt:

Stream onceModifiedStream = Stream.of ("abcd", "bbcd", "cbcd"). Skip (1);

Als er meer dan één wijziging nodig is, kunnen tussenliggende bewerkingen worden geketend. Stel dat we ook elk element van stroom moeten vervangen Stroom met een subtekenreeks van de eerste paar tekens. Dit wordt gedaan door de overspringen() en de kaart() methoden:

Stream twoModifiedStream = stream.skip (1) .map (element -> element.substring (0, 3));

Zoals u kunt zien, is de kaart() methode heeft een lambda-uitdrukking als parameter. Als je meer wilt weten over lambda's, bekijk dan onze tutorial Lambda Expressions and Functional Interfaces: Tips and Best Practices.

Een stream op zichzelf is waardeloos, het echte waar een gebruiker in geïnteresseerd is, is een resultaat van de terminaloperatie, wat een waarde van een bepaald type kan zijn of een actie die op elk element van de stream wordt toegepast. Er kan slechts één terminalbewerking per stream worden gebruikt.

De juiste en handigste manier om streams te gebruiken is door een stroompijplijn, wat een keten is van stroombron, tussenliggende bewerkingen en een terminaloperatie. Bijvoorbeeld:

Lijst lijst = Arrays.asList ("abc1", "abc2", "abc3"); long size = lijst.stream (). skip (1) .map (element -> element.substring (0, 3)). gesorteerd (). count ();

5. Luie aanroep

Tussenliggende operaties zijn lui. Dit betekent dat ze worden alleen aangeroepen als het nodig is voor de uitvoering van de terminaloperatie.

Stel je voor dat we een methode hebben om dit aan te tonen heette(), die een innerlijke teller verhoogt elke keer dat deze werd aangeroepen:

privé lange toonbank; private void wasCalled () {counter ++; }

Laten we de methode noemen wasGebeld() van operatie filter():

Lijst lijst = Arrays.asList ("abc1", "abc2", "abc3"); teller = 0; Stream stream = list.stream (). Filter (element -> {wasCalled (); return element.contains ("2");});

Omdat we een bron van drie elementen hebben, kunnen we die methode aannemen filter() wordt drie keer aangeroepen en de waarde van de teller variabele wordt 3. Maar het uitvoeren van deze code verandert niet teller helemaal is het nog steeds nul, dus de filter() methode werd niet één keer aangeroepen. De reden waarom - ontbreekt aan de terminaloperatie.

Laten we deze code een beetje herschrijven door een kaart() operatie en een terminal operatie - findFirst (). We zullen ook een mogelijkheid toevoegen om een ​​volgorde van methodeaanroepen bij te houden met behulp van logboekregistratie:

Optioneel stream = list.stream (). Filter (element -> {log.info ("filter () was called"); return element.contains ("2");}). Map (element -> {log.info ("map () heette"); return element.toUpperCase ();}). findFirst ();

Het resulterende logboek laat zien dat het filter() methode werd twee keer aangeroepen en de kaart() methode slechts één keer. Het is zo omdat de pijplijn verticaal wordt uitgevoerd. In ons voorbeeld voldeed het eerste element van de stream niet aan het filterpredikaat, en vervolgens de filter() methode werd aangeroepen voor het tweede element, dat het filter passeerde. Zonder de filter() voor het derde element gingen we door de pijpleiding naar de kaart() methode.

De findFirst () bediening voldoet aan slechts één element. Dus in dit specifieke voorbeeld stond de luie aanroep toe om twee methodeaanroepen te vermijden - één voor de filter() en een voor de kaart().

6. Volgorde van uitvoering

Vanuit het oogpunt van prestaties, de juiste volgorde is een van de belangrijkste aspecten van ketenoperaties in de stroompijplijn:

long size = list.stream (). map (element -> {wasCalled (); return element.substring (0, 3);}). skip (2) .count ();

Het uitvoeren van deze code verhoogt de waarde van de teller met drie. Dit betekent dat de kaart() methode van de stream werd drie keer aangeroepen. Maar de waarde van de grootte is een. De resulterende stream heeft dus maar één element en we hebben het dure uitgevoerd kaart() operaties zonder reden twee keer van de drie keer.

Als we de volgorde van het overspringen() en de kaart() methoden, de teller zal slechts met één toenemen. Dus de methode kaart() wordt slechts één keer gebeld:

long size = list.stream (). skip (2) .map (element -> {wasCalled (); return element.substring (0, 3);}). count ();

Dit brengt ons bij de regel: tussenliggende bewerkingen die de omvang van de stroom verkleinen, moeten worden geplaatst vóór bewerkingen die van toepassing zijn op elk element. Bewaar dus methoden als skip (), filter (), distinct () bovenaan je stream-pijplijn.

7. Stroomreductie

De API heeft veel terminalbewerkingen die een stream aggregeren naar een type of naar een primitief, bijvoorbeeld count (), max (), min (), sum (), maar deze bewerkingen werken volgens de vooraf gedefinieerde implementatie. En wat als een ontwikkelaar het reductiemechanisme van een stream moet aanpassen? Er zijn twee methoden die dit mogelijk maken - de verminderen()en de verzamelen() methoden.

7.1. De verminderen() Methode

Er zijn drie varianten van deze methode, die verschillen door hun handtekeningen en terugkerende typen. Ze kunnen de volgende parameters hebben:

identiteit - de initiële waarde voor een accumulator of een standaardwaarde als een stream leeg is en er niets te accumuleren is;

accumulator - een functie die een logica van aggregatie van elementen specificeert. Aangezien de accumulator een nieuwe waarde creëert voor elke stap van het verminderen, is het aantal nieuwe waarden gelijk aan de grootte van de stream en is alleen de laatste waarde nuttig. Dit is niet erg goed voor de prestatie.

combiner - een functie die resultaten van de accumulator aggregeert. Combiner wordt alleen in een parallelle modus aangeroepen om de resultaten van accumulatoren van verschillende threads te verminderen.

Laten we dus eens kijken naar deze drie methoden in actie:

OptioneelInt gereduceerd = IntStream.range (1, 4) .reduce ((a, b) -> a + b);

verminderd = 6 (1 + 2 + 3)

int gereduceerdTwoParams = IntStream.range (1, 4) .reduce (10, (a, b) -> a + b);

gereduceerdTwoParams = 16 (10 + 1 + 2 + 3)

int reductionParams = Stream.of (1, 2, 3) .reduce (10, (a, b) -> a + b, (a, b) -> {log.info ("combiner heette"); retourneer a + b;});

Het resultaat zal hetzelfde zijn als in het vorige voorbeeld (16) en er zal geen login zijn, wat betekent dat die combiner niet werd aangeroepen. Om een ​​combiner te laten werken, moet een stream parallel zijn:

int gereduceerdParallel = Arrays.asList (1, 2, 3) .parallelStream () .reduce (10, (a, b) -> a + b, (a, b) -> {log.info ("combiner werd genoemd" ); retourneer a + b;});

Het resultaat is hier anders (36) en de combiner werd twee keer aangeroepen. Hier werkt de reductie door het volgende algoritme: accumulator liep drie keer door elk element van de stream toe te voegen aan identiteit aan elk element van de stroom. Deze acties worden parallel uitgevoerd. Als resultaat hebben ze (10 + 1 = 11; 10 + 2 = 12; 10 + 3 = 13;). Nu kan combiner deze drie resultaten samenvoegen. Daarvoor zijn twee iteraties nodig (12 + 13 = 25; 25 + 11 = 36).

7.2. De verzamelen() Methode

Reductie van een stream kan ook worden uitgevoerd door een andere terminalbewerking - de verzamelen() methode. Het accepteert een argument van het type Verzamelaar, die het reductiemechanisme specificeert. Er zijn al voorgedefinieerde verzamelprogramma's gemaakt voor de meest voorkomende bewerkingen. Ze zijn toegankelijk met behulp van de Verzamelaars type.

In deze sectie zullen we het volgende gebruiken Lijst als bron voor alle streams:

List productList = Arrays.asList (nieuw product (23, "aardappelen"), nieuw product (14, "sinaasappel"), nieuw product (13, "citroen"), nieuw product (23, "brood"), nieuw product ( 13, "suiker"));

Een stream converteren naar het Verzameling (Collectie, lijst of Set):

Lijst collectorCollection = productList.stream (). Map (Product :: getName) .collect (Collectors.toList ());

Reduceren tot Draad:

String listToString = productList.stream (). Map (Product :: getName) .collect (Collectors.joining (",", "[", "]"));

De schrijnwerker() methode kan één tot drie parameters hebben (scheidingsteken, voorvoegsel, achtervoegsel). Het handigste aan gebruiken schrijnwerker() - ontwikkelaar hoeft niet te controleren of de stream zijn einde bereikt om het achtervoegsel toe te passen en niet om een ​​scheidingsteken toe te passen. Verzamelaar zal daarvoor zorgen.

Verwerking van de gemiddelde waarde van alle numerieke elementen van de stream:

dubbele gemiddeldePrijs = productList.stream () .collect (Collectors.averagingInt (Product :: getPrice));

Verwerking van de som van alle numerieke elementen van de stream:

int summingPrice = productList.stream () .collect (Collectors.summingInt (Product :: getPrice));

Methoden gemiddeldeXX (), somXX () en samenvattenXX () kan werken zoals bij primitieven (int, lang, dubbel) net als bij hun wrapper-klassen (Geheel getal, lang, dubbel). Nog een krachtigere functie van deze methoden is het in kaart brengen. De ontwikkelaar hoeft dus geen extra kaart() operatie voor de verzamelen() methode.

Statistische informatie verzamelen over de elementen van stream:

IntSummaryStatistics statistieken = productList.stream () .collect (Collectors.summarizingInt (Product :: getPrice));

Door de resulterende instantie van type IntSummaryStatistics ontwikkelaar kan een statistisch rapport maken door toe te passen toString () methode. Het resultaat is een Draad gemeenschappelijk voor deze "IntSummaryStatistics {count = 5, sum = 86, min = 13, average = 17.200000, max = 23}".

Het is ook gemakkelijk om uit dit object aparte waarden voor te halen count, som, min, gemiddelde door methoden toe te passen getCount (), getSum (), getMin (), getAverage (), getMax (). Al deze waarden kunnen uit een enkele pijplijn worden gehaald.

Groeperen van streamelementen volgens de gespecificeerde functie:

Kaart collectorMapOfLists = productList.stream () .collect (Collectors.groupingBy (Product :: getPrice));

In het bovenstaande voorbeeld is de stream teruggebracht tot de Kaart die alle producten op prijs groepeert.

De elementen van een stream in groepen verdelen volgens een bepaald predikaat:

Kaart mapPartioned = productList.stream () .collect (Collectors.partitioningBy (element -> element.getPrice ()> 15));

De collector pushen om extra transformatie uit te voeren:

Set unmodifiableSet = productList.stream () .collect (Collectors.collectingAndThen (Collectors.toSet (), Collections :: unmodifiableSet));

In dit specifieke geval heeft de collector een stroom omgezet in een Set en creëerde vervolgens het onveranderlijke Set eruit.

Custom verzamelaar:

Als om de een of andere reden een aangepaste verzamelaar moet worden gemaakt, is de gemakkelijkste en minder uitgebreide manier om dit te doen, de methode te gebruiken van() van het type Verzamelaar.

Verzamelaar toLinkedList = Collector.of (LinkedList :: new, LinkedList :: add, (first, second) -> {first.addAll (second); return first;}); LinkedList linkedListOfPersons = productList.stream (). Collect (toLinkedList);

In dit voorbeeld is een exemplaar van de Verzamelaar werd teruggebracht tot de LinkedList.

Parallelle stromen

Vóór Java 8 was parallellisatie complex. Opkomst van de ExecutorService en de ForkJoin vereenvoudigde het leven van de ontwikkelaar een beetje, maar ze moeten nog steeds in gedachten houden hoe ze een specifieke uitvoerder moeten maken, hoe ze deze moeten uitvoeren, enzovoort.Java 8 introduceerde een manier om parallellisme te bereiken in een functionele stijl.

De API maakt het mogelijk om parallelle streams te maken, die bewerkingen in een parallelle modus uitvoeren. Als de bron van een stream een Verzameling of een array het kan worden bereikt met behulp van de parallelStream () methode:

Stroom streamOfCollection = productList.parallelStream (); boolean isParallel = streamOfCollection.isParallel (); boolean bigPrice = streamOfCollection .map (product -> product.getPrice () * 12) .anyMatch (prijs -> prijs> 200);

Als de bron van de stream iets anders is dan een Verzameling of een array, de parallel() methode moet worden gebruikt:

IntStream intStreamParallel = IntStream.range (1, 150) .parallel (); boolean isParallel = intStreamParallel.isParallel ();

Onder de motorkap gebruikt Stream API automatisch de ForkJoin framework om bewerkingen parallel uit te voeren. Standaard wordt de gemeenschappelijke thread-pool gebruikt en er is geen manier (althans voorlopig) om er een aangepaste thread-pool aan toe te wijzen. Dit kan worden ondervangen door een op maat gemaakte set parallelle collectoren te gebruiken.

Als u streams in de parallelle modus gebruikt, vermijd dan het blokkeren van bewerkingen en gebruik de parallelle modus wanneer taken evenveel tijd nodig hebben om uit te voeren (als de ene taak veel langer duurt dan de andere, kan dit de workflow van de volledige app vertragen).

De stream in parallelle modus kan terug worden geconverteerd naar de sequentiële modus met behulp van de opeenvolgend () methode:

IntStream intStreamSequential = intStreamParallel.sequential (); boolean isParallel = intStreamSequential.isParallel ();

Conclusies

De Stream API is een krachtige maar eenvoudig te begrijpen set tools voor het verwerken van een reeks elementen. Het stelt ons in staat om een ​​enorme hoeveelheid standaardcode te verminderen, beter leesbare programma's te maken en de productiviteit van de app te verbeteren wanneer deze correct wordt gebruikt.

In de meeste codevoorbeelden die in dit artikel worden getoond, werden streams ongebruikt gelaten (we hebben de dichtbij() methode of een terminaloperatie). In een echte app, laat een geïnstantieerde stream niet onbenut, want dat zal leiden tot geheugenlekken.

De volledige codevoorbeelden die bij het artikel horen, zijn beschikbaar op GitHub.


$config[zx-auto] not found$config[zx-overlay] not found