Navigation
Search
|
Functional programming with Java collections
Wednesday August 14, 2024. 11:00 AM , from InfoWorld
Java’s collections like arrays and lists are foundational building blocks. Functional programming techniques are at times the ideal way to work with these collections. This article gives a tour of some of the most essential functional programming operations in Java collections, using the built-in lambda features of Java 8 and later.
Imperative vs. functional collections There are many ways to do anything in Java, and there is no conclusive right way. Often, the right way is simply the way you know best. Imperative loops like the for loop are the most basic way to do something with the elements of a collection, and they have in their favor a utilitarian handiness. However, functional operations often can do the same thing with a cleaner and more obvious syntax. There’s never any shame in using an imperative loop—sometimes it’s the only way!—but there’s always delight in arriving at a bit-sized functional expression that does just the thing you need. The developers who read and maintain the code will appreciate the improved ability to look at and divine the purpose of the code within the larger context. Streams and lambdas in Java One of the great evolutions in Java was the introduction of streams and lambdas in Java 8. These are the features that allow Java programmers to apply functional style to collections: Streams: Turns a collection into a flow of elements, to which can be applied functions. Lambdas: Allows for defining first-class functions (outside of a class) that can consume the elements provided by a stream. Virtual threads For another great leap in Java, have a look at Virtual Threads. Sort One of the most common needs with collections is sorting. Java has long had a built-in Arrays.sort method, and Collections.sort allows for sorting by natural order or using a custom comparator. However, these approaches are destructive, meaning they modify the original collection. Using a functional approach lets us sort into a new collection, which honors immutability and lets us chain together operations. We could sort a list of names like so: List names = new ArrayList(); names.add('Lao Tzu'); names.add('Paramahansa Yogananda'); names.add('Rudolph Steiner'); System.out.println('nOriginal List: ' + names); names.stream().sorted((a, b) -> b.compareTo(a)) // creates a sorted stream We use the standard compareTo() method (which in the case of String does a simple alphanumeric comparison) to sort the elements. The statement (a, b) -> b.compareTo(a) creates a function that accepts two arguments, and then compares them, returning true or false based on that comparison. The sorted() method on the stream uses that return value to move through the stream, ordering the elements. Notice that this creates a sorted stream of the elements in the list—an event-based channel of the events, not a collection. This makes for a very efficient, composable pipeline that we can do almost anything with. For example, if our goal is to just output the sorted list to the console, we could chain the stream from sorted() to forEach() and print it like so: List sortedNames = names.stream().sorted((a, b) -> b.compareTo(a)).forEach(System.out::println); forEach The forEach() function does just what its name says, letting you pass in a function to do something with each element. It’s important to note that forEach is a “terminal operation.” That is because it doesn’t return the stream for further chaining. Rather, it performs its operation (in our case, printing the element to the console) and then it’s finished. This contrasts with the map function you’ll see in a moment, which does something to each element but also returns the stream. We use the method reference operator (the double colon) to refer to the System.out::println() function. This lets us supply that operation to forEach, resulting in each element being printed to the console. Turn a stream back into a collection with a Collector Now, if what we want is to get a new List back out, we can use a Collector: List sortedNames = names.stream().sorted((a, b) -> b.compareTo(a)).collect(Collectors.toList()); Like the forEach example, this is another simple example of functional composition, or chaining. The.sorted() method is chained to.collect(), creating a larger and more complex operation out of atomic functions. (Like forEach, collect is a terminal operation.) Now, let’s say we wanted to sort the name by last name. We could implement this with a more customized method for collect: // a fancier sort operation List lastNames = names.stream().sorted((name1, name2) -> { String[] parts1 = name1.split(' '); String lastName1 = parts1[parts1.length - 1]; String[] parts2 = name2.split(' '); String lastName2 = parts2[parts2.length - 1]; return lastName2.compareTo(lastName1); }) Map How about turning this into a stream that holds only the last names: names.stream().sorted((name1, name2) -> { String[] parts1 = name1.split(' '); String lastName1 = parts1[parts1.length - 1]; String[] parts2 = name2.split(' '); String lastName2 = parts2[parts2.length - 1]; return lastName2.compareTo(lastName1); }).map(name -> { // keep only last names String[] parts = name.split(' '); return parts[parts.length - 1]; }).forEach(System.out::println); Here we output the sorted last names. The map() method is used to apply a transformation to the elements. Each element is accepted as the argument name, then the return value of the string (modified to hold only the last name) is returned. The map will assign that return value to each element. map lets you “map” each element from one thing (the argument) to another (the return value). You’ll remember that forEach and collect are terminal operations. The map function is not. Like sort, it is an “intermediate operation.” Filter Let’s continue our manipulation of the stream by keeping only the last names with five or more characters: names.stream().sorted((name1, name2) -> { String[] parts1 = name1.split(' '); String lastName1 = parts1[parts1.length - 1]; String[] parts2 = name2.split(' '); String lastName2 = parts2[parts2.length - 1]; return lastName2.compareTo(lastName1); }).map(name -> { String[] parts = name.split(' '); return parts[parts.length - 1]; }).filter(lastName -> lastName.length() >= 5).forEach(System.out::println); Here we have added the filter() method, another intermediate operation, to our chain. This example uses a simple lambda expression that accepts a single argument and uses it to return true if the length of the string is greater than five. The result is the steam now contains just the two last names with more than five characters. Reduce Now let’s consider another terminal operation, reduce(). This lets us take the entire stream and output a scalar (single) value. For this example, let’s reduce the stream to a single string with the names joined by commas, which we could handle like so: String output = names.stream().sorted((name1, name2) -> { String[] parts1 = name1.split(' '); String lastName1 = parts1[parts1.length - 1]; String[] parts2 = name2.split(' '); String lastName2 = parts2[parts2.length - 1]; return lastName2.compareTo(lastName1); }).map(name -> { String[] parts = name.split(' '); return parts[parts.length - 1]; }).filter(lastName -> lastName.length() >= 5).reduce('', (accumulator, element) -> accumulator + element + ', '); System.out.println('result: ' + output); This will concatenate all the strings together, joined by a comma. (We’ll have a trailing comma that we could drop off the end.) reduce gives us an interesting look at a slightly more advanced area of streams. Consider if you wanted to count the string characters and return an integer value. How could you do that? The return value of reduce would want to be an integer, but the accumulator and element args are strings. I’ll leave that for an exercise, with this Stack Overflow question as one lead, and my introduction to Java stream gatherers as another. Reusing operations We’ve had a pretty good look at the way it feels to use and compose some of the most important Java functional operations. Another important facet is code reuse. Say you needed to use that fancy string sorter in several places. In Java, we could create a functional interface to share that operation: static class LastNameComparator implements java.util.Comparator { @Override public int compare(String name1, String name2) { String[] parts1 = name1.split(' '); String lastName1 = parts1[parts1.length - 1]; String[] parts2 = name2.split(' '); String lastName2 = parts2[parts2.length - 1]; return lastName2.compareTo(lastName1); } } names.stream().sorted(new LastNameComparator()).forEach(System.out::println); Now we can use the LastNameComparator in whatever streams we need. Similar abstraction can be used for other functional operations. Other collections We’ve been working with ArrayList so far. An array can be converted into a stream, just like ArrayList, like so: Arrays.stream(array) And then the same operations can be used on the stream just like we’ve seen before. Other collections like sets and maps can also be readily turned into streams with the stream() method. Streams are good ways to transform from one collection to another. Sometimes the underlying implementation has implications for stream operators, however. For example, HashSet and HashMap do not maintain order and therefore using sort() on them doesn’t make sense. Streams and lambdas are an incredibly useful way to work with Java’s collections. Functional programming is in general a great addition to the overall range of tools available to us in building applications of all kinds.
https://www.infoworld.com/article/3481596/functional-programming-with-java-collections.html
Related News |
25 sources
Current Date
Dec, Mon 23 - 02:32 CET
|