1.4-Software-Development-Principles

Streams deel 1

Leerdoelen

Vereiesten: Om dit concept te begrijpen dien je comfortabel te zijn met interfaces in Java. Verder verwachten we ook een basis begrip van lambda expressies

Voor de voorbeelden in dit theorie gedeelte gebruiken we de code van de IndianDishQuiz opdracht. Specifiek een IndianDish.

De data kan gevonden worden in de indian_food.csv. Let op dat in deze versie de ingredienten ook gesplitst zijn in een lijst van strings. (in plaats van alleen een komma gescheiden string)

Een voorbeeld

Na het lezen van de theorie zou je in staat moeten zijn om het volgende code fragment te begrijpen:

// Using a list of integers.
List<Integer> list = Arrays.asList(8, 4, 5, 7, 2);

int totalTimes2 =
    // Take the list.
    list
        // Use it as a stream.
        .stream()
        // Convert (map) each integer using this calculation (lambda expression)
        .map(x -> x * 2)
        // Add the resulting integers together (another lambda expression)
        .reduce(Integer::sum);

// Answer should be (8*2 + 4*2 + 5*2 + 7*2 + 2*2 =) 52
System.out.println(totalTimes2);

Wat zijn streams?

Na bijna een jaar programmeren is het je waarschijnlijk opgevallen dat veel software vooral vertrouwd op het opslaan en verwerken van collecties van data. Alles wat we gaan laten zien kan gedaan worden met kennis van Java die je al hebt.

Er is echter een probleem: Hoewel JIJ weet hoe je dit kunt doen in Java, het uitdrukken van overeenkomende ideeen achter je code kan lastig zijn. Andere collega’s kunnen makkelijker communiceren met database termen.

Bijvoorbeeld: “Van de lijst van dishes wil alleen degenen zien die vegetarisch zijn.” SELECT * FROM dishes WHERE diet='vegetarian' De eenvoudige concept kost, met alleen collections, meer stappen om voor elkaar te krijgen:

ArrayList<Dish> result = new ArrayList<Dish>();
for (Dish dish:dishes) {
  if (dish.getDiet().equals("vegetarian")) {
    result.add(dish);
  }
}
return result;

Streams zijn een update in Java waarmee lijsten en collecties worden aangeboden als een reeks van elementen. Deze streams voorzien ook in een set van standaard operaties om deze elementen te filteren, sorteren of updaten. Voorheen moest iedere ontwikkelaar hun eigen code schrijven om dat te doen.

Let op: Zoals eerder aangegeven, dit kan nog steeds. Maar wanneer je streams begrijpt, zul je doorhebben dat je code meer op een query lijkt welke eenvoudig aangepast kan worden bij een update.

Nog een voorbeeld

Met streams willen we de namen laten zien van dishes met minder dan 400 calorieen gesorteerd op calorieen:

// We're using a list of menu items.
List<MenuItem> menu = readMenu();
List<String> lowCaloricDishesName =
    // Using the menu collection.
    menu
        // Start by providing the stream source.
        .stream()
        // Only select dishes WHERE calories < 400
        .filter(d -> d.getCalories() < 400)
        // Order using those calories
        .sorted(comparing(Dish::getCalories))
        // Select the name of the dish.
        .map(Dish::getName)
        // Add the result to a list again.
        .collect(toList());               

In this code .filter, .sort, .map and .collect are standard operations on a stream that allow you to define the steps that manipulate the original content and result in the answer you want.

The code contains three parts:

The rest of the theory will show you the most common operations on a stream.

One side node: In this code the intermediate opreations are chained together: stream.doSomething().doAnotherThing().etc() This is called creating a pipeline of operations. Each operation gives you another stream that contains slightly different data along the way.

This may cause very long lines of code, so we split them on the dots.

Common stream operations

Creating a stream (source)

Er zijn verschillende manieren om een stream op te zetten, de eerste twee worden het meest gebruikt.

Stream<Integer> stream = Stream.of(1,2,3,4,5);
Stream<Integer> stream = Stream.iterate(0, n->n+1).limit(10);

(Take your time to wrap your head around that last one: Create a sequence starting at 0. Increase each next value. (In this case by 1) Please stop after 10 elements.)

Intermediate operations

Na het aanroepen van deze methoden wordt je een nieuwe (weergave van) de stream aangeboden die je verder kunt manipuleren.

.filter(x -> x > 5)

.filter(x -> x.equals(">")

.map(x -> x - 1)`

.map(dish -> dish->getCalories())`

.limit(10)

.sorted(comparing(Dish::getCalories))

Terminal operations

These methods take a stream and convert to a single result. (This result is not a stream otherwise it would have been an intermediate method.)

.count()

.reduce(Integer::sum)

.distince()

.collect(Collectors.toList())

Primitive Streams

Streams as defined so far seem to require reference types as their elements. For instance Stream<String> or Stream<Integer>.

To use with primitive types there are specialised implementations:

To convert any stream to a specific primitive stream a special conversion is required:

Aggregation functions:

This allows dedicated terminal operation:

These are the basic aggregation function we know (and love) from databases except for .count() because that one doesn’t require a primitive type stream.

Collection<Dish> yourOrder = getOrder();

int totalCalories = yourOrder
    .stream()
    .mapToInt(dish -> dish.getCalories())
    .sum();
    
System.out.println("Total calories in your order : "+totalCalories);

Advanced uses of Streams

As stated before the Dish class itself contains a list of ingredients used in that dish. Let’s say that we have an order for a certain set of dishes. How would you get a list of all the required ingredients.

Effectively this is an array within an array: (Please forgive the JSON notation.)

[
  {
    "name" : "Dish 1",
    "ingredients" : [ "apples", "cumin", "rice" ]
  },
  {
    "name" : "Dish 2",
    "ingredients" : [ "rice", "sugar", "ghee", "cinnamon" ]
  },
  {
    // etc.
  }
]

I would like to get a list that contains only:

["apples", "cumin", "rice", "sugar", "ghee", "cinnamon"]

You can solve this problem using the .flatMap intermediate operation:

List<Dish> order = getOrderList();
List<String> uniqueIngredients =
    order
        .stream()
        // Temporarily create a stream that only contains arraylists.
        .map(d -> d.getIngredients())
        // Merge those separate arraylists into a single stream.
        .flatMap(ArrayList::stream)
        // Filter out duplicates.
        .distinct()
        .collect(Collectors.toList());