- COMP.CS.140
- 8. Generics
- 8.4 Functional programming in Java
Functional programming in Java¶
Lambda functions, function references and the concept of a functional interface were added into Java in version 8 as part of a wider set of features that provide support for the functional programming paradigm. This course will discuss functional programming only in a very limited manner. Interested students are recommended to take a separate functional programming course offered at our university.
This brief discussion will at least in some way touch upon the following features of the functional programming paradigm:
A data set is processed with functions instead of explicit iteration (such as loops).
In Java: process items using so-called
Streaminterfaces instead of containers and iteration.
It is common to pass functions as parameters.
Data is processed with general functions that take functions, which define some details of how how the processing should be done, as parameters.
The preceding example function
filterwas an example of this principle, as are also the sorting functions, which we have already used extensively, that take a comparison function as a parameter.
In Java: functions can receive function objects as parameters, and function objects that implement functional interfaces can be created easily using lambda functions or function references.
Data is immutable and functiond do not cause side effects.
Functions do not mutate existing data: they create new data (e.g. from the existing data).
In purely functional programming a sorting function would never sort a list directly; it would produce a new list that contains the items of the original list in sorted order.
In Java: it is encouraged to follow this principle when using
Streaminterfaces, but it is not mandatory.
Operationg that manipulate data are performed in a “lazy” manner (so-called lazy evaluation).
Actual data processing begins only once the result is explicitly requested. This is clarified below.
In Java:
Streaminterfaces follow this principle.
From now on we will concentrate on Java’s Stream interfaces as they are the most central tool
for functional programming style data processing in Java.
Java’s Stream interfaces¶
In order to simplify the exposition, we will from now on use the term “stream” to mean a Java
Stream interface or (and perhaps most often) a concrete instance of such.
Java’s streams are types that offer a fairly diverse set of operations for processing data that
a stream reads. Java class library contains four different stream types: Stream<T>,
IntStream, LongStream and DoubleStream. The main difference between these is the type
of the processed items. The generic stream Stream<T> can be used for processing generally any
kind of reference type data, and the latter three are specialized for processing the number types
corresponding to their names. These numeric streams offer operations that are feasible only for
numbers, such as computing their sum.
A stream does not store any items itself (a stream is not a container): it reads data from some source that is defined when the stream is first created. If we talk about items or data in/of a stream, we mean the data/items read by the stream. Java class library offers e.g. the following ways to create a stream:
A stream that reads items from an array
arrcan be created asArrays.stream(arr).The type of the created stream depends in a natural way on the array’s item type:
int:IntStreamlong:LongStreamdouble:doubleStreamSome other type
T:Stream<T>
A stream reading items of a container
contcan be created ascont.stream(). The created stream will always be of typeStream<T>, whereTis the container’s item type.For example if the container item type is
Integer, the stream will still beStream<Integer>and notIntStream.
A stream that reads a
BufferedReaderobjectbrline by line can be created asbr.lines(). The stream will be of typeStream<String>since the read lines are strings.This allows to read data from a multitude of sources as a
BufferedReadercan be initialized to read data from practically any kind of input source, such as a file or a string.
Once a stream has been created, we may perform two types of stream operations on it (that is, on the items read by the stream): so-called intermediate operations or so-called terminal operations. An intermediate operations means an operation whose result is also a stream. This enables us to chain stream operations: the result of one intermediate operation can immediately be the target of a new stream operation (without e.g. needing to create a new stream). A terminal operation produces a concrete result (e.g a value or a container that contains items) and the underlying chain of stream operations will end. If we wish to perform further stream operations on the result, we need to create a new stream that reads the previously returned result.
Stream operations are implemented as member functions of the stream types. E.g. if we have already
created a stream object s, then e.g. the first operation listed below could be performed as
s.distinct(). Stream opOperations can be chained by chaining member function calls: e.g.
s.distinct().sorted() would first perform a distinct operation on s and then a
sorted operation on the result of the first operation (which also was a stream).
Some intermediate operations offered by the generic stream type Stream<T> are listed below. We
have omitted the explicit functional interface parameter types to keep the presentation more
simple, but such parameters are described separately.
distinct(): produces a new stream that contains those the unique items of this stream (that is, this operation removes duplicate items).filter(predicate): produces a new stream that contains only those items of this stream for which the function objectpredicatereturnstrue. Hence this operation discards those items that do not fulfill some condition defined by the parameterpredicate.The parameter
predicatemust implement the functional interfacePredicatewhose functiontestdefines the condition.The basic principle is similar to the previous example function
filtergiven in the section about interfaces.
map(function): produces a new stream where each itemtof this stream has been replaced by the resukt of the function callfunction.apply(t). That is, this operation transforms all items of the stream by the provided transformation function.The parameter
functionmust implement the functional interfaceFunction.
mapToInt(function),mapToLong(function)andmapToDouble(function): work otherwise in similar manenr asmap, but the produced stream is of typeIntStream,LongStreamorDoubleStream, respectively. The transformation functionfunctionmust return a value that is compatible with the created stream type.The role of these is to allow changing the stream type from a generic stream into a numeric stream. This would be necessary mainly when we want to use the numeric operations offerd by numeric streams.
sorted()andsorted(comparator): produce a new stream that lists the items of this stream in sorted order. The first form uses natural item order and the second takes a separate comparison object that must implement the functional interfaceComparator.
As stated before, streams process data in a lazy manner. Stream operations are started only when a
terminal operation is performed (a terminal operation will produce a result, which might be void).
Therefore the last step of processing data with streams must always be a terminal operation (there
may be zero or more intermediate operations; it might e.g. be the case that only a single terminal
operation suffices to produce the desired result). Some terminal operations of Stream<T> are
listed below:
void forEach(consumer): Performs the function callconsumer.accept(t)for each itemtof this stream. Note that this operation does not produce an explicit result: its effect depends only on the side effects produced by the performed function calls.The parameter
consumermust implement the functional interfaceConsumer.
Object[] toArray(): Returns an array that contains the items of this stream.The corresponding numeric stream operations return numeric arrays. E.g.
toArray()ofIntStreamreturns anintarray.
T reduce(T identity, accumulator): produces a sort of cumulative result from the items of this stream.The parameter
accumulatormust implement the functional interfaceBinaryOperator.The result is initialized as
T result = identityand then updated asresult = accumulator.apply(result, t)at each stream itemt.E.g. the sum of the numbers in a stream
sof typeStream<Integer>could be computed by the operations.reduce(0, (a, b) -> a + b)or alternativelu, using function reference, by the operations.reduce(0, Integer::sum).E.g. the sum of 7, 2, 6 would be computed by first initializing
result= 0 and then updatingresult= 0 + 7 = 7,result= 7 + 2 = 9 and finallyresult= 9 + 6 = 15.
R collect(supplier, accumulator, combiner): collects the items of this stream (usually either literally collects them into a container or computes and returns some other type of a result).The parameter
suppliermust implement the functional interfaceSupplier<R>. It is used for initializing the end result asR result = supplier.get().The parameter
accumulatormust implement the functional interfaceBiConsumer<R,? super T>. The result is updated asaccumulator.accept(result, element)at each stream itemt.The parameter
combinermust implement the functional interfaceBiConsumer<R, R>. The stream may use this, if necessary, to combine two partial results into a single result (this might be necessary e.g. if the stream is processed in parallel manner).For example the items of a stream
sof some typeStream<T>could be collected into anArrayListby the operations.collect(() -> new ArrayList<>(), (r, t) -> r.add(t), (r1, r2) -> r1.addAll(r2))or alternatively, using function references, the operations.collect(ArrayList::new, ArrayList::add, ArrayList::addAll).Note how a
newoperation that creates an object of some classclassNamecan be referred to asclassName::new.
R collect(collector): collects the items of this stream. Otherwise similar to the precedingcollect, but now there is only one parametercollectorthat simultaneously provides all the three functionalities that the preceding version takes as separate parameters.The parameter
collectormust implement the functional interfaceCollector. We have not introduced it before, and will not introduce it in detail here either. It should suffice for now that the Java class library classCollectorsoffers many static member functions that create different types of usefulCollectorobjects that may be used withcollect. We describe some of them below:Collectors.toList(): collects the items into a list.E.g. the items of a stream
sof some typeStream<T>could be collected into aList<T>by the operations.collect(Collectors.toList()). The returned list is of some type that implements the interfaceList<T>.
Collectors.counting(): returns the number of items in the stream.E.g. if the stream
swould read items from the array{4, 7, 6, 3, 8}, the operations.collect(Collectors.counting())would return 5.This is an example of how
collectmay produce some kind of a result instead of literally “collecting” the items.
Collectors.averagingInt(mapper),Collectors.averagingLong(mapper)andCollectors.averagingDouble(mapper): return the average vaue of the stream items as aDouble. The parametermappermust offer a function that transforms an item into the corresponding numeric type (described by the function name). If the items already are of a correct type, the transformation may keep the items as such (but the transformation function must still be defined).E.g. if the stream
swould read the array{4, 7, 6, 3, 8}, the operations.collect(Collectors.averagingInt(i -> i))would return 5.6.
Collectors.summingInt(mapper),Collectors.summingLong(mapper)jaCollectors.SummingDouble(mapper): similar to the preceding average functions but return the sum of the stream items and the result type corresponds to the numeric type (described by the function name)E.g. if the stream
swoudl read the array{4, 7, 6, 3, 8}, the operations.collect(Collectors.summingInt(i -> i))would return 28.
Collectors.joining(delimiter): returns aStringthat consists of all items in the stream converted into strings and separated by the string parameterdelimiter.E.g. if the stream
swould read the array{"one", "two", "three"}, the operations.collect(Collectors.joining("-"))would return the string “one-two-three”.
Collectors.groupingBy(classifier): groups the stream items by storing them into a dictionary container under keys defined by the parameterclassifier. Each itemtof the stream will get a key computed askey = classifier(t), and the itemtwill be inserted into a dictionary into a list under the keykey.The parameter
classifiermust implement the functional interfaceFunction.The result will be a dictionary that implements the interface
Mapand whose values are lists that implement the interfaceList.E.g. if the stream
swould read the array{"one", "two", "three", "four"}, the operations.collect(Collectors.groupingBy(String::length))would griups the strings based on their lengths: the result would be a dictionary that holds the strings “one” and “two” in a list under the key 3, the string “four” in a list under the key 4, and the string “three” in a list under the key 5.Again note the function reference used above. The same result could be obtained by using a lambda function
s -> s.length().
Collectors.groupingBy(classifier, collector2): groups the stream items based on the parameterclassifierin otherwise similar manner as above, but now the dictionary will not store item lists as such: each list weill be raplaced by the result of applyingcollector2to it.The parameter
collector2must implement the functional interfaceCollector. So this function in essence performs two nestedCollectoroperations: the outer is thegroupingByoperation, and the inner is the operation performed bycollector2(which can in principle be any type of aCollectoroperation).E.g. if the stream
swould read the array{"one", "two", "three", "four"}, the operations.collect(Collectors.groupingBy(String::length, Collectors.counting()))would return a dictionary where the value corresponding to a key (string length) tells the number of strings that have that length. So here the key 3 would have value 2, the key 4 value 1 and the key 5 value 1.
Collectors.reducing(accumulator): performs areduceoperation to the items of this stream using the functionaccumulator. The result is wrapped inside anOptionalobject that is empty if and only if the stream is empty.Optional<T>is a generic class of Java class library that is meant for representing values that do not necessarily exist. The class has e.g. the member functionsboolean isEmpty()andboolean isPresent()for inspecting whether theOptionalobject is empty or holds a value, and the member functionT get()for reading the value (if it exists).
Numeric streams have more or less similar operations as listed above, but in addition e.g. the
numeric operations min(), max() and sum(), that compute the minimum, maximum and sum of
the numbers in the stream, and summaryStatistics(), that return at once the mimimum, maximum,
sum and average of the numbers in the stream.
We have introduced only a small part of the Java class library’s stream operations and related
tools. If you wish to explore further, it is a good idea to browse through the Java library
documentation of the package java.util.stream.
Below is as a final example a program that processes a file using streams. The program assumes that
the input file transactions.csv contains rows of form “bankAccount;transferAmount”, where
bankAccount is a string thet describes an account number and transferAmount is a number
that decribed a transaction; a negative value means an outgoing and a positive value an incoming
payment (concerning the specified account). The program reads the data and creates a Map
container whose keys are account numbers and values are corresponding Optional<Account>
objects. Each Account object (here wrapped inside an Optional object) stores an account
number and the overal balance (sum of the transactions concerning that account). The classes
should again be in separate files.
public class Account {
private String number;
private double balance;
public Account(String number, double balance) {
this.number = number;
this.balance = balance;
}
public String getNumber() {
return number;
}
public double getBalance() {
return balance;
}
public void addAmount(double amount) {
this.balance += amount;
}
@Override
public String toString() {
return String.format("%s: %.1f", number, balance);
}
}
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
public class ReadAccounts {
public static void main(String[] args)
throws IOException {
try(var br = new BufferedReader(new FileReader("transactions.csv"))) {
Map<String, Optional<Account>> accs = br.lines() // Read file lines with a stream.
.map(line -> line.split(";")) // Split the line into parts.
.map(acc -> new Account(acc[0], Double.parseDouble(acc[1]))) // Parts -> Account object.
.collect(Collectors.groupingBy(Account::getNumber, // Group by account number.
Collectors.reducing((a, b) -> {
a.addAmount(b.getBalance()); // Sum transactions.
return a;
})
)
);
System.out.println(accs);
}
}
If the contents of the input file transactions.csv were
46262;7200
26736;2500
78291;3900
46262;-1825.4
26736;-50.9
26736;-220.5
78291;-31.9
46262;-125
78291;-180.3
46262;-449.1
26736;115
78291;-1390
46262;-899
78291;-49.9
46262;25
then the preceding program would output more or less the following:
{26736=Optional[26736: 2343.6], 46262=Optional[46262: 3926.5], 78291=Optional[78291: 2247.9]}
Programming demo (duration 1:57:51)