Module 2 - Streams

Module Overview

Explore the Java Streams API and learn how to perform functional operations on collections of objects with improved readability and performance.

Learning Objectives

  • Recall that streams are only evaluated when a terminal operation is invoked
  • Use streams to perform an operation on every object in a Collection
  • Use streams to find all elements of a Collection that satisfy a provided condition
  • Use streams to apply a Function to each element of a Collection
  • Implement code to collect the elements of a Stream into a specified Collection
  • Design and implement functionality that combines stream operations to satisfy a given use case
  • Recall that terminal operations are Stream methods that produce a non-Stream result
  • Compare and contrast stateful and stateless stream operations
  • Recall that a short-circuiting terminal operation ends the stream as soon as it is satisfied

Introduction to Streams

Overview

In this lesson, we will be covering the Java Stream API. Streams are a feature of Java that allows us to perform operations on streams of elements, removing the need for iterative code like for loops. We will learn how they function, what we can do with them, and how to best utilize them in our programs.

In this reading we will dive into how Streams operates and how to create and use them. We'll explain the two types of operations, intermediate and terminal along with examples of each of these types of operations.

In the next reading, we will show how to use the Stream API in our programs. We will be covering how Streams can simplify complex operations, and the best practices to use with them.

Intro to Streams

In this lesson we are digging into how the Stream API can be used to write operations on streams of elements in place of using our familiar for loops and if statements. What does it mean to have a stream of elements? It just means that we perform some operations on one element (one Java object) after another until there are no elements left in our data source. A stream is almost like a conveyor belt where the elements are the boxes on the conveyor belt being transported one after another. While streams can wrap any data source, we will focus on how streams can be used with Collections, such as an List or Map. The Stream API can perform operations on every element in a collection using only one line of code per operation, simplifying the process and removing the need to have any iteration logic.

A Stream is not a new collection type and it does not hold any actual data. A stream can be created from an existing collection. Once created, the Stream processes the elements of the collection according to our instructions. Each Stream operation returns either another Stream allowing us to chain more operations together or terminates the Stream in some way. The terminating operation could return a new collection of elements, a single element, or some new computed value. It's important to emphasize that when a collection is returned at end of the execution of a Stream, it is a new collection. The original collection remains unaltered.

The important thing to keep in mind with the Stream API is that most of its functionality is just a new way of doing things we have already been implementing! The purpose of it is to simplify the code we are writing. Much of its structure just replaces the syntax of code we have been writing. Similar to Java lambda functions, at first this may seem less readable than the iteration you're used to, but with time and exposure this will begin to be more readable. It allows you to focus on just the operations specific to your business logic and not be distracted by reading a for loop for the 100th time.

Initializing Streams

The first thing we need to know is how to create a Stream. There are many ways to create a Stream, so we'll only dive into a few of the most common. The first way to create a Stream that we'll dive into is to create a Stream of specified elements using the static method of the Stream class. Below is a stream of Amazonian aliases:

Stream<String> amazonianStream = Stream.of("jeff", "ajassy", "wilke", "galettib");

This may look similar to the Arrays.asList method we've used previously. You can think about it in a similar way. A few things to notice:

  • We need to declare the type of the elements that the Stream will be processing. In this case we declared the Stream as processing Strings. Like other generic types we must include the type on the left side of the equals.
  • Stream.of accepts a variable-length argument of the type defined in the generics. For this case that means we can provide zero or more arguments of type String.

Now that we've created our Stream we could perform some set of operations on these aliases. Perhaps we convert from an alias to an employee object, find everyone who has been at Amazon at least 5 years, and then return that result as a new Collection.

However, it is not necessary to create a Stream this way. The most common way to create a Stream is from an existing Java Collection. The way to do this is to use the stream() method that is part of all collections.

List<String> amazonians = Arrays.asList("jeff", "ajassy", "wilke", "galettib");
Stream<String> amazonianStream = amazonians.stream();

All collections in the Collection interface have this method, so this method is not limited to just List. This example may look silly since we're using Arrays.asList to initialize the List, but this List could come from anywhere -- the results of calling another service, from our database, etc. Note that the generic type of the Stream will be the same as the generic type of the Collection. Our amazonianStream will allow us to perform some set of operations on every element that was in our List. This could be just the 4 elements listed here, but it could also be hundreds of elements.

Types of Stream Operations

When it comes to operating on a collection, a Stream has two different types of operations that can be called. These are intermediate and terminal operations.

Intermediate operations return a new Stream, which we can add additional operations to. Intermediate operations are intended for when you want to perform multiple steps on a collection or element.

Terminal operations return a result, such as a collection or single element, or produce some kind of side effect, such as performing an action on each element in the Stream. Terminal operations end the processing of the Stream. After a terminal operation the Stream is considered consumed and can no longer be used. If you needed to perform additional operations you would have to create a brand new stream from the same data source.

A Stream that is set up with intermediate operations and ending with a terminal operation is called a Stream pipeline.

A pipeline consists of:

  • A source -- what we initialize the stream from.
  • Zero or more intermediate operations.
  • A single terminal operation.

Intermediate Operations

Let's say we are trying to find all of the Jane's in a class. We need to alphabetize their names and capitalize them in order to prepare this new list of names for something we want to do later. We'll look at a stream that does exactly this and use it to explore the intermediate operations filter, map, and sorted. Take a look at the stream:

List<String> names = new ArrayList<>();
names.add("Jane Roe");
names.add("Jane Doe");
names.add("Saanvi Sarkar");
names.add("Zhang Wei");
names.add("Shirley Rodriguez");
names.add("Carlos Salazar");

List<String> processedNames = names.stream() .filter(name -> name.contains("Jane")) .map(String::toUpperCase) .sorted() .collect(Collectors.toList());

We create a new List<String> processedNames to contain the results of the Stream with three intermediate operations: stream.filter(), stream.map(), and stream.sorted() and close it out with a terminal operation stream.collect(). Notice here that we've chained all the Stream operations together into one procedure. This is the most common way to utilize Streams and it helps make the order of execution clear. It is also a common practice for us to put one operation on each line as you see here.

filter

In the first method filter(), we pass in a predicate lambda that selects elements to be filtered into a new Stream. Any element that the predicate returns true for will be in this new Stream. The Stream returned from filter must be the same type as the Stream filter must be called on.

Stream<T> filter(Predicate<T> predicate)

In this example we pass in a predicate lambda that returns true when the element contains the string "Jane". Filter is called on a Stream<String>, which means it must use a Predicate<String>. This allows us access to Strings's contains method. Our new Stream returned from filter contains only two elements -- "Jane Roe" and "Jane Doe."

Stream<String> janes = names.stream().filter(name -> name.contains("Jane"));

map

The second method map() takes in a Function that is applied to all elements in the collection. Since Function takes in an object of one type and returns an object of another type, it is often used to convert or map the Stream to another type. So if map is called on a Stream of type T, it returns a brand new Stream that is now of type R.

Stream<R> map(Function<T, R> mapper)

In this example we use a static method reference from the String class to make all the elements uppercase. Here our function is operating on a Stream of type String and returning a Stream of type String, but it is taking our existing String and mapping it or converting it to another String. For us that means returning a new String that is capitalized. Since our Streams use functional interfaces as inputs, the operations can take method references or lambdas.

The only elements that make it to our map operation are "Jane Roe" and "Jane Doe." After our map operation the elements in our new Stream would be "JANE ROE" and "JANE DOE."

Stream<String> capitalized = janes.map(String::toUpperCase);

sorted

The third method, sorted(), sorts the elements in the list. There are two versions of this method, one with no inputs that uses the natural ordering and another that accepts a Comparator to specify how to sort the elements.

Stream<T> sorted()
Stream<T> sorted(Comparator<T> comparator)

Since the elements of our Stream that sorted is called on are of type String, they have a natural ordering. Strings use a lexicographical ordering. That means that after our sorted step, the new Stream would have the elements in the order "JANE DOE" and then "JANE ROE."

Stream<String> sortedNames = capitalized.sorted();

Closing out our example

Finally, the elements processed by the Stream are collected with the collect terminal operation into a new List. We'll talk more about how this works later in this reading.

For comparison, let's see how we could perform the same operations on the elements of a List without using a Stream.

List<String> results = new ArrayList<>();
for (String name : names) {
    if (name.contains("Jane")) {
        String capitalizedName = name.toUpperCase();
        results.add(capitalizedName);
    }
}

Collections.sort(results);

This code is only slightly longer than our Stream code. The biggest difference is how the Stream code allows us to focus on what we want to do to each element of the list and not on the mechanics of iterating and storing the results.

As mentioned in the pipeline section our stream can have anywhere from zero to infinite intermediate operations and each intermediate operation can be used more than once. Take a look at this example where we go from an alias to an employee object that has tenure information. We use this stream to find all employees that have been at Amazon at least 10 years.

List<Employee> redBadges = Stream.of("jeff", "ajassy", "wilke", "galettib")
    // The EmployeeDAO returns null if there is not a matching Employee for a given alias, otherwise it returns an
    // Employee object
    .map(alias -> employeeDao.find(alias))
    .filter(employee -> employee != null)
    .filter(employee -> employee.tenureInYears() > 10)
    .collect(Collectors.toList());

Terminal Operations

In order to invoke the actions of a Stream, we need to close out the intermediate operations with a terminal one. Terminal operations are the endpoint of a Stream. They can either return a result, such as a collection, or perform a side-effect, such as an external operation. In the code below, we expand on the last example to show a few terminal operations.

private static Stream<String> createNameStream(List<String> names) {
    return names.stream()
        .filter(name -> name.contains("Jane"))
        .map(String::toUpperCase)
        .sorted();
}

public static void main(String[] args) {
    List<String> names = new ArrayList<>();
    names.add("Jane Roe");
    names.add("Jane Doe");
    names.add("Saanvi Sarkar");
    names.add("Zhang Wei");
    names.add("Shirley Rodriguez");
    names.add("Carlos Salazar");

    //Terminal operations.
    Optional<String> first = createNameStream(names).findFirst();
    createNameStream(names).forEach(System.out::println);
    Set<String> results = createNameStream(names).collect(Collectors.toSet());
}

A thing to take note of in this example is that we are creating a brand new Stream from the same data source for each of our terminal operations. A stream can only be used once. After a terminal operation a stream pipeline is considered to be consumed and can no longer be used. To perform another streaming operation we must create a new stream. We can create as many Streams from a single data source as we desire.

findFirst

The first terminal method is stream.findFirst(). This returns an Optional containing the first element in the Stream, or an empty Optional if the Stream is empty.

Optional<T> findFirst()

For our example stream, findFirst() would return an Optional wrapping the value "JANE DOE." If we changed our filter to look for names containing "John," we would have an empty Stream after this stem and our findFirst() would return an empty Optional.

forEach

The second method stream.forEach() is like its namesake, it iterates through each element in the stream and performs an action external to the Stream.

void forEach(Consumer<T> action)

Here we used a method reference to print each element to console, but it also can take a lambda, or a method call as well. Note that forEach() does not actually return anything, it just iterates over all elements in the Stream.

collect() and the Collectors class

The final terminal method is stream.collect(), this method returns all the elements of a Stream as a new collection. While the datatype of the elements must be matching, collect() can create a collection of any type using the Collectors class. Collectors is a utility class with static methods for the most common ways to collect a Stream.

R collect(Collector<T, A, R> collector)

In the above snippet, while the source collection was an ArrayList, we outputted the result as a Set using Collectors.toSet(). The Collectors class has a few native methods such as toList(), toSet(), and toMap() that can be used to create the collections. toCollection() also exists, which can take in a list or set type as a parameter to create the collection. toList() and toSet() are straightforward in that they will just create a collection of those respective types. The toCollection() method creates a list of the specified collection type, as we show this in the example below where we create a LinkedList using a Stream. toCollection should be used when you need to guarantee the implementation of the List, Set, or Map interface being used.

LinkedList results = sortedStream.collect(Collectors.toCollection(LinkedList::new));

The toMap() method gets a bit complicated as you must generate keys along with the values for the map. It has two parameters, keyMapper which is a Function to provide keys, and valueMapper which is a Function to provide values. Let's take another look at our employee example and how we might use Collectors.toMap there.

Map<String, Integer> redBadgeTenures = Stream.of("jeff", "ajassy", "wilke", "galettib")
    // The EmployeeDAO returns null if there is not a matching Employee for a given alias, otherwise it returns an Employee object
    .map(alias -> employeeDao.find(alias))
    .filter(employee -> employee != null)
    .filter(employee -> employee.tenureInYears() > 10)
    .collect(Collectors.toMap(employee -> employee.getAlias(), employee.tenureInYears()));

This stream returns a Map where the employee's alias is the key, and their tenure in years is the value for any employee with at least 10 years of experience.

If we need to create a Map with a List as it's value we can use the convenient Collectors.groupingBy method. This allows us to specify a function to determine keys. Then any element with that same key is added to a List to be used as the value of that key. If instead of creating a Map of the tenure of each employee, we could create a Map where tenure is the key and the value is a list of all aliases who have been at Amazon for that number of years.

Map<Integer, List<Employee>> redBadgeTenures = Stream.of("jeff", "ajassy", "wilke", "galettib")
    // The EmployeeDAO returns null if there is not a matching Employee for a given alias, otherwise it returns an Employee object
    .map(alias -> employeeDao.find(alias))
    .filter(employee -> employee != null)
    .filter(employee -> employee.tenureInYears() > 10)
    .collect(Collectors.groupingBy(Employee::tenureInYears));

Now we're starting to see some of the real power of Streams!

Summary

In this reading, we learned about the Stream API. We learned about how it's an interface designed to process the elements of a collection through a series of operations. We learned how to create a Stream from an existing collection and apply several operations to get a resulting collection or alter its elements. We learned the difference between intermediate and terminal operations, and their associated methods. With all this we can create streamlined operations on collections and lists, without having to use iterative logic to make it happen. There are many other Stream operations. The Stream JavaDoc is a great way to explore the other operations.

Up next

In the next lesson, we will be learning about the applications of the Stream API. We will also be covering the best practices and common mistakes around using a Stream.

Using Streams

Overview

In this lesson, we will be covering the Java Stream API. Streams are a feature of Java that allow us to perform operations on streams of elements, removing the need for iterative code like for loops. We will learn how they function, what we can do with them, and how to best utilize them in our programs.

In the previous reading we dug into how Streams operate and how to create and use them. We explained the two types of operations, intermediate and terminal along with examples of each of these types of operations.

In this reading, we will show how to use the Stream API in our programs. We will be covering how they can simplify complex operations down to a single line, and the best practices to use with them.

What Streams do for us

In the last reading, we covered the syntax of how to use the Stream API, but not much on the purpose of using them. They don't add functionality, most of what they do can be done using for loops, or other iterative logic. But what a Stream can do is reduce our code down to what we want executed, and not bother with the boilerplate iterative logic we normally would use.

Let's demonstrate this by making a comparison between using a Stream and using iterative code. In the code below, we have a list of names of our clients that holds some bad data. We want to run through the list and remove any elements that contain bad data, and then make the valid elements consistent by capitalizing the first letter of the names.

public static List<String> validateClientList() {
    List<String> clients = new ArrayList<>();
    clients.add("");
    clients.add("");
    clients.add("any_CORP");
    clients.add("");
    clients.add("carlos ####");
    clients.add("##### Salazar");
    clients.add("Jane_InduSTries");
    clients.add("JOHN ###");
    clients.add("### ####");
    clients.add("carlos_INC");
    List<String> results = new ArrayList<>();

    for (String name : clients) {
        name.replace('#', '');
        if (!name.isEmpty()) {
            name = WordUtils.capitalize(name);
            results.add(name);
        }
    }

    for (String name : results) {
        System.out.println(name);
    }

    return results;
}

Output:

Any_corp
Carlos
Salazar
Jane_industries
John
Carlos_inc

None of these tasks individually are very complex. We simply are iterating through the list, filtering out the bad data entries, and applying consistent changes to all valid elements. Then reiterating through them to print the new list. But it still requires a good amount of code. However, we can reduce things down using a Stream, as we'll see in the next example.

List<String> results = clients.stream()
    .map(name -> name.replace('#', ''));
    .filter(name -> !name.isEmpty())
    .map(WordUtils::capitalize)
    .collect(Collectors.toList());

results.stream().forEach(System.out::println);

Comparing these two examples, the operations and logic we're performing on the elements remain almost the same, but we removed the iterative code. The Stream version of this operation is much more straightforward to read. It lets us focus on our business logic, what are the changes that are specific to this use case. The other difference between these two code snippets is that the first is mutating our underlying data source, while the second is leaving that as is in case we may need it again and creating a new data structure. If we were to make the first code snippet non-mutating it would make it even longer and more complex. In practice, it may not be necessary to replace every instance of iterative code with a Stream. It may only make sense to do so in situations where the iteration or comparison is getting more complex than the actual code to perform the operations.

Lazy Execution

While a Stream can reduce iterative logic, its usage still has a lot of subtle complexity to understand. Case in point, intermediate operations are lazy. What does that mean? Intermediate operations will not execute unless a terminal operation is present on the Stream. Let's look at our Jane name example from the last reading. A Stream is created and has several intermediate operations:

Stream<String> nameStream = names.stream()
    .filter(name -> name.contains("Jane"))
    .map(String::toUpperCase)
    .sorted();

However, while this statement will compile, it won't actually perform the operations shown. Without the terminal operation the Stream will do nothing at all. Let's look at another example to prove it. Here is another Stream made up of entirely intermediate operations. What would you expect to get printed to the console?

Stream<String> nameStream = names.stream()
    .filter(name -> {
        System.out.println("Filter " + name);
        return name.contains("Jane");
        })
    .map(name -> {
        System.out.println("Map " + name);
        return name.toUpperCase()
    })
    .sorted();

Nothing would get printed to the console! If we added a terminal operation to this like .collect(Collectors.toSet()) we would see the filter and map Strings printed to the console many times.

How Streams Evaluate

The order that the intermediate operations are specified in matters as well. In this example, we have the exact same Stream as the last example. Is one more efficient than the other? Why?

Optional<String> firstJane = names.stream()
    .sorted()
    .filter(name -> name.contains("Jane"))
    .map(String::toUpperCase)
    .findFirst();

The only difference here is that the sorted() operation is called before the Stream has been filtered. Let's imagine there are thousands of names in our names List, but only two with the name Jane. If we sort or even map before filtering we are performing these operations on thousands of names and while the map operation is O(n) just like filter, our sorted operation has a runtime of O(n log n). If we filter first the n for our sort is much smaller.

It's important to understand that a Stream can evaluate a collection in multiple ways. One element can be evaluated all the way through a Stream, or all elements can be evaluated one step at a time. Let's look at the same examples but add print statements to each of the steps.

List<String> names = new ArrayList<>();
names.add("Jane Roe");
names.add("Jane Doe");
names.add("Saanvi Sarkar");
names.add("Zhang Wei");
names.add("Shirley Rodriguez");
names.add("Carlos Salazar");

Optional<String> firstJane = names.stream()
    .filter(name -> {
        System.out.println("filter " + name);
        return name.contains("Jane");
    }).map(name -> {
        System.out.println("map " + name);
        return name.toUpperCase();
    }).sorted((name1, name2) -> {
        System.out.println(String.format("sorted %s %s", name1, name2));
        return name1.compareTo(name2);
    }).findFirst();

What do you think gets printed here? It's very natural to think that every element goes through each operation one at a time -- filter printed six times followed by map printed twice. But that's not how this would work! One element actually goes as far as it can through a pipeline before the next element starts. The output of this code would be:

filter Jane Roe
map Jane Roe
filter Jane Doe
map Jane Doe
filter Saanvi Sarkar
filter Zhang Wei
filter Shirley Rodriguez
filter Carlos Salazar
sorted JANE DOE JANE ROE

Let's do an experiment and switch the order of the operations. Let's again move sorted to be our first operation.

Optional<String> firstJane = names.stream()
    .sorted((name1, name2) -> {
        System.out.println(String.format("sorted %s %s", name1, name2));
        return name1.compareTo(name2);
    }).filter(name -> {
        System.out.println("filter " + name);
        return name.contains("Jane");
    }).map(name -> {
        System.out.println("map " + name);
        return name.toUpperCase();
    }).findFirst();

What do you expect would now be printed? Your first thought may be something like: sorted Jane Roe Jane Doe, filter Jane Roe, map Jane Roe, filter Jane Doe, map Jane Doe, etc.

But that would be wrong! Let's take a look at the actual output:

sorted Jane Doe Jane Roe
sorted Saanvi Sarkar Jane Doe
sorted Saanvi Sarkar Jane Roe
sorted Zhang Wei Jane Roe
sorted Zhang Wei Saanvi Sarkar
sorted Shirley Rodriguez Saanvi Sarkar
sorted Shirley Rodriguez Zhang Wei
sorted Carlos Salazar Saanvi Sarkar
sorted Carlos Salazar Jane Roe
sorted Carlos Salazar Jane Doe
filter Carlos Salazar
filter Jane Doe
map Jane Doe

But didn't we just learn that an element goes as far as it can through the pipeline before the next element starts? That's still true, but in this case, the elements initially cannot go any farther than the sorted operation because sorted is a stateful operation.

There are two types of intermediate operations: stateful and stateless. Stateful operations require knowledge of all the elements, so elements will go as far as they can until they hit either a terminal operation or stateful operation. sorted is a stateful operation because it needs all of the elements available to create a sorted order. When sorted is our last intermediate operation, it waits for all of the elements to complete filtering and mapping before sorting. But when it's our first operation, it performs the sort on all of the elements before restarting the pattern of each element individually going as far through the pipeline as it can. Both filter and map are stateless so they will execute on elements one at a time.

The other strange thing you may notice in our new output is that only two of the elements are filtered and only Jane Doe is mapped. Why is that? Some terminal operations are short-circuiting terminal operations. findFirst() is a short-circuiting terminal operation, but collect and forEach are not. Since findFirst is a short-circuiting terminal operation, once it has found what it needs it will go ahead and terminate the stream and prevent any unneeded operations from occurring. The result of mapping Jane Doe is passed directly to findFirst. A first element is found, so the Stream can terminate, there's no need to try to filter or map any other elements. This is exactly why elements try to go as far down a pipeline as they can. It may prevent future work!

The Stream JavaDoc tells you for each method if it is a stateful intermediate operation, an intermediate operation (this means it's stateless), a short-circuiting terminal operation, or a terminal operation, so you don't have to guess.

Conclusion

In this lesson, we covered the Stream API. We learned how they can take an inputted collection and perform a wide variety of operations on it. We learned about the unusual way they operate; evaluating with a terminal operation and collecting only the elements we want. While they can seem complex, and perhaps redundant, they offer a great deal of flexibility to our programs. They can greatly improve how we iterate and process collections. They might not be the replacement of for loops or if statements, but they are a powerful functionality to learn.

Setup Your Sprint 25 Challenge Repo

This Sprint culminates in a Sprint Challenge project. You should begin by forking and cloning the Sprint Challenge starter repo:

This will be your project repo for Sprints 25 - 27.

This resource is also visible under the Sprint Challenge section of the course page. After each module, you will be assigned a mastery task with instructions on adding to or modifying the starter code for the challenge. Upon completion of all mastery tasks, the Sprint Challenge project will be complete and ready for you to submit to CodeGrade. The CodeGrade submission page is available under the Sprint Challenge section on the modules page.

Mastery Task 1: Filter out the noise

Milestone 1: Update TargetingEvaluator and AddTargetingGroupActivity

While looking over the current service code, you see some instances where we're using a for loop to iterate and process elements in a collection. To help reduce the boilerplate iterative code and make the logic easier to follow, you decide to update the logic to use streams instead!

For this task, you'll be updating logic in two classes to use a stream instead of a for loop to iterate and process elements.

TargetingEvaluator's evaluate method determines if all the TargetingPredicates in a given TargetingGroup are true for the given RequestContext.

  • Update TargetingEvaluator's evaluate method to use a stream instead of a for loop to evaluate the TargetingPredicateResult.

AddTargetingGroupActivity is the activity class for the operation that adds a new targeting group to an existing piece of advertising content.

  • Update AddTargetingGroupActivity's addTargetingGroup method to use a stream when converting the TargetingPredicates from the Coral model to its internal model.

We encourage you to take a look at the Stream Javadoc to see what available methods can help implement the logic.

Milestone 2: Update AdvertisementSelectionLogic

If you look at our service's AdvertisementSelectionLogic, you'll see that we're currently retrieving all advertisements for a marketplace and randomly showing one of those advertisements to customers. To help increase our ad click through rate, our marketing team has created targeting rules that they think will help show ads to customers who are most likely to click on it. They've created ads specifically for each of these targeting groups. So now we want to only show ads that customers are eligible for, based on the customer being a part of an ad's targeting group.

Update AdvertisementSelectionLogic's selectAdvertisement method so that it randomly selects only from ads that the customer is eligible for based on the ad content's TargetingGroup. Your solution should use streams to evaluate the targeting groups (again, take a look at the streams javadoc if you need some guidance!). Use TargetingEvaluator to help filter out the ads that a customer is not eligible for. Then randomly return one of the ads that the customer is eligible for (if any).

If there are no eligible ads for the customer, then return an EmptyGeneratedAdvertisement. Since these ads are being rendered on the retail website, we don't want to return null or throw exceptions if there are no eligible ads for the customer. Instead we return an empty string so that the front-end can handle rendering gracefully.

Exit Checklist:

  • You've updated AdvertisementSelectionLogic's selectAdvertisement method so that it randomly selects an ad that the customer is eligible for
  • You've updated TargetingEvaluator's evaluate method to use a stream instead of a for loop to evaluate the TargetingPredicateResult.
  • You've set both AdvertisementSelectionLogic and TargetingEvaluator's IMPLEMENTED_STREAM boolean flag to true.
  • You've updated AddTargetingGroupActivity's addTargetingGroup method to use a stream when building the Map of spend categories to return.
  • Running the gradle command ./gradlew -q clean :test --tests com.tct.mastery.task1.MasteryTaskOneLogicTests passes.
  • Running the gradle command ./gradlew -q clean :test --tests com.tct.introspection.MT1IntrospectionTests passes.

Resources