Functional programming in Java¶
Lambda functions, function references and the concept of a functional interface were added into Java as part of a wider set of features that provide support for so-called 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
Stream
interfaces 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
filter
was 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
Stream
interfaces, 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:
Stream
interfaces 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
arr
can be created asArrays.stream(arr)
.The type of the created stream depends in a natural way on the array’s item type:
int
orInteger
:IntStream
long
orLong
:LongStream
double
orDouble
:doubleStream
Some other type
T
:Stream<T>
A stream reading items of a container
cont
can be created ascont.stream()
. The created stream will always be of typeStream<T>
, whereT
is 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
BufferedReader
objectbr
line 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
BufferedReader
can 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 objectpredicate
returnstrue
. Hence this operation discards those items that do not fulfill some condition defined by the parameterpredicate
.The parameter
predicate
must implement the functional interfacePredicate
whose functiontest
defines the condition.The basic principle is similar to the previous example function
filter
given in the section about interfaces.
map(function)
: produces a new stream where each itemt
of 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
function
must implement the functional interfaceFunction
.
mapToInt(function)
,mapToLong(function)
andmapToDouble(function)
: work otherwise in similar manenr asmap
, but the produced stream is of typeIntStream
,LongStream
orDoubleStream
, respectively. The transformation functionfunction
must 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 itemt
of 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
consumer
must 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()
ofIntStream
returns anint
array.
T reduce(T identity, accumulator)
: produces a sort of cumulative result from the items of this stream.The parameter
accumulator
must implement the functional interfaceBinaryOperator
.The result is initialized as
T result = identity
and then updated asresult = accumulator.apply(result, t)
at each stream itemt
.E.g. the sum of the numbers in a stream
s
of 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
supplier
must implement the functional interfaceSupplier<R>
. It is used for initializing the end result asR result = supplier.get()
.The parameter
accumulator
must implement the functional interfaceBiConsumer<R,? super T>
. The result is updated asaccumulator.accept(result, element)
at each stream itemt
.The parameter
combiner
must 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
s
of some typeStream<T>
could be collected into anArrayList
by 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
new
operation that creates an object of some classclassName
can 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 parametercollector
that simultaneously provides all the three functionalities that the preceding version takes as separate parameters.The parameter
collector
must 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 classCollectors
offers many static member functions that create different types of usefulCollector
objects 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
s
of 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
s
would read items from the array{4, 7, 6, 3, 8}
, the operations.collect(Collectors.counting())
would return 5.This is an example of how
collect
may 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 parametermapper
must 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
s
would 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
s
woudl read the array{4, 7, 6, 3, 8}
, the operations.collect(Collectors.summingInt(i -> i))
would return 28.
Collectors.joining(delimiter)
: returns aString
that consists of all items in the stream converted into strings and separated by the string parameterdelimiter
.E.g. if the stream
s
would 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 itemt
of the stream will get a key computed askey = classifier(t)
, and the itemt
will be inserted into a dictionary into a list under the keykey
.The parameter
classifier
must implement the functional interfaceFunction
.The result will be a dictionary that implements the interface
Map
and whose values are lists that implement the interfaceList
.E.g. if the stream
s
would 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 parameterclassifier
in 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 applyingcollector2
to it.The parameter
collector2
must implement the functional interfaceCollector
. So this function in essence performs two nestedCollector
operations: the outer is thegroupingBy
operation, and the inner is the operation performed bycollector2
(which can in principle be any type of aCollector
operation).E.g. if the stream
s
would 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 areduce
operation to the items of this stream using the functionaccumulator
. The result is wrapped inside anOptional
object 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 theOptional
object 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)