Lecture05 - nus-cs2030/2324-s2 GitHub Wiki
This lecture serves as a prelude to the topics in the second half of
the semester.
We focus on the avoidance of null
and its associated side-effects
by replacing it with Java's Optional
.
We demonstrate how function objects (which are implementations of different functional interfaces) can be passed to Optional
via higher-order methods.
Below is an extract of the createUnitCircle
method from
the maximum disc coverage exercise. Specifically, the method should
return a circle only if points p
and q
are not at the same location and within a distance of 2.0.
Circle createUnitCircle(Point p, Point q) {
double d = p.distanceTo(q);
if (d < EPSILON || d > 2.0 + EPSILON) {
return null;
}
...
return new Circle(...);
}
What should we return from the method when the condition is not satisfied?
In the above, null
is returned as there is no better value.
But what exactly is null
?
null
is not an object, nor does it have its own type;
but null
can be assigned to a variable of any reference (i.e. class or interface) type!
One could implement null
as a possible value of say, the Null
type,
and have the Null
type be a sub-type of every reference type (i.e. a bottom-type of reference types).
But alas, unlike Scala, this is not how Java implements it.
null
is a special literal in Java that is designed to be a possible value of any
reference typed variable, and here is where the problem lies.
Upon calling createUnitCircle
, we are not assured that
createUnitCircle(..).contains(..)
will always work.
If createUnitCircle
returns a null
, we get a
NullPointerException
and the program breaks!
As an aside, if we do have a Null
type, then we would also need to
consider the bottom-type of primitives. This could take the form of
say, a Nothing
type with no value. How is this useful?
As an example, a method can return Nothing
if it never returns.
Suppose we have a method that throws an exception.
... foo() {
returns new IllegalStateException();
}
In Java and the absence of a Nothing
type, you can set the return type to any type! Another example is when your method keeps looping (or recursing),
... foo() {
return foo();
}
Anyway, we digressed...
Looking at the behaviour of the createUnitCircle
method, we expect
that the method may return a circle, or return nothing.
We can also say that createUnitCircle
always returns a
box, but inside this box it may either contain a circle or nothing
(or an empty box).
Java Optional
class is exactly the box we need.
Optional
is no different from the List
or ArrayList
.
They are just containers of some fixed type.
While List<T>
and ArrayList<T>
are generic containers that hold zero or more
elements of type T
, Optional<T>
is a generic container of zero or one element of type T
.
In particular, the Optional
class has two main factory methods: of(..)
and empty()
.
The former allows you to put something into the box (or wrap
something in an Optional
) while the latter creates an empty
Optional
. There is another factory method ofNullable(..)
that
allows null
to be taken in and produces an empty
box.
On the other hand, of(null)
throws an exception.
We can now redefine the createUnitCircle
method to have a return
type of Optional<Circle>
.
Optional<Circle> createUnitCircle(Point p, Point q) {
double d = p.distanceTo(q);
if (d < EPSILON || d > 2.0 + EPSILON) {
return Optional.<Circle>empty();
}
...
return Optional.<Circle>of(new Circle(...));
}
The Optional
class provides low-level methods for low-level
programming or first-order programming.
First order programming is where we have data (values or objects) passed and
return from methods. As an example, consider the following method to find the
coverage given an Optional<Circle>
object and a list of points.
Specifically, if circle
is Optional.empty
, it will return a coverage of
zero.
int findCoverage(Optional<Circle> circle, ImList<Point> points) {
int numOfPoints = 0;
if (circle.isEmpty()) { // or if(!circle.isPresent())
return 0;
} else {
Circle c = circle.get();
for (Point point : points) {
if (c.contains(point)) {
numOfPoints = numOfPoints + 1;
}
}
return numOfPoints;
}
}
Notice the use of the isEmpty()
(or isPresent()
) and get()
methods of the Optional
class.
This is a common misuse of the Optional
class by beginning students.
They see the use of an Optional
as another hassle to wrap and unwrap the value contained within, when they could have just coded the if..else
statement using null
.
if (circle == null) {
return 0;
} else {
for (Point point : points) {
if (circle.contains(point)) {
numOfPoints = numOfPoints + 1;
}
}
return numOfPoints;
}
This is not the correct way to use Optional
as findCoverage
method still needs to be mindful of whether the Optional
is empty or not.
Indeed, in the implementation using Optional
, if one were to skip the conditional check and invoke circle.get()
straightaway, an exception will still surface if circle
is Optional.empty()
.
Java's Optional
is more than just a data container.
In fact, Optional
is a kind of safe-box that encapsulates the handling of exceptions within it.
In this way, the client that uses Optional
correctly does not need to be aware of exceptions!
The idea is to let the client of Optional
be able to pass the functionality of finding containment of points in a circle to Optional
instead.
Higher-level (or higher-order) methods are provided by the Optional
to do just that.
But first, functions must behave like data, i.e. a function can be assigned to a variable, passed into a method, and also returned from a method.
We say that functions are first class citizens.
And Java makes use of functional interfaces to allow you to build objects, each containing a function (we sometimes call them function objects).
We shall demonstrate with two examples of higher order methods: filter
and map
.
The filter
method in Optional
class takes in a Predicate
which is a generic functional interface with a single abstract method test
(sometimes called a SAM interface).
The method specified in Predicate<T>
is public boolean test(T t)
.
As an example, to test whether a circle contains a fixed point (0.5, 0.5)
, we need to define an implementation of Predicate<Circle>
.
class Containment implements Predicate<Circle> {
public boolean test(Circle c) {
return c.contains(new Point(0.5, 0.5));
}
We can create an instance of Containment
and call its test
method by passing in some Circle
object.
jshell> Predicate<Circle> pred = new Containment()
pred ==> Containment@...
jshell> pred.test(new Circle(new Point(0.0, 0.0), 1.0))
$.. ==> true
The above tests if the circle located at the origin with radius 1.0
contains the point (0.5, 0.5)
.
More importantly, we can now pass an instance of Containment
into the filter
method of Optional
:
createUnitCircle(..).filter(pred)
Depending on the outcome of createUnitCircle
, if it returns an Optional
containing a valid circle, then filter
will check whether the point (0.5, 0.5)
is contained within. If it does, then the same Optional
is returned from filter
; otherwise Optional.empty()
is returned.
On the other hand, if Optional.empty()
is returned from createUnitCircle
, then filter
just returns it.
Here are some more examples.
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> filter(pred)
$.. ==> Optional[circle of radius 1.0 centred at point (0.000, 0.000)]
jshell> createUnitCircle(new Point(-1.0, 10.0), new Point(1.0, 10.0)).
...> filter(pred)
$.. ==> Optional.empty
jshell> createUnitCircle(new Point(1.0, 0.0), new Point(1.0, 0.0)).
...> filter(pred)
$.. ==> Optional.empty
Notice that onus on checking the presence or absence of a circle no longer lies with the client, but on Optional
instead. In other words, Optional
handles missing values for the client.
You may also realize that this is not the first time we are defining an implementation of a functional interface and passing to a higher order method.
Recall the Comparator
interface and how it was passed to the sort
method of a list.
We can certainly pass different test functionalities to the filter
method. But do we need to create different classes every time?
One way of writing the implementation of a functional interface is to use an anonymous inner class (or class with no name).
jshell> Predicate<Circle> pred = new Predicate<Circle>() {
...> public boolean test(Circle c) {
...> return c.contains(new Point(0.5, 0.5));
...> }
...> }
pred ==> ..@..
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> filter(pred)
$.. ==> Optional[circle of radius 1.0 centred at point (0.000, 0.000)]
Yet another simpler way is to write a lambda expression.
jshell> Predicate<Circle> pred = c -> c.contains(new Point(0.5, 0.5))
pred ==> $Lambda$...
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> filter(pred)
$.. ==> Optional[circle of radius 1.0 centred at point (0.000, 0.000)]
Here we do not even need to provide the header of the test
method; just the body will suffice.
This is possible because a functional interface has only one abstract method to be defined.
Notice that the test
method in Predicate
needs to read data from the Circle
. That is to say data flows into Predicate
.
From the discussion of data flow in the previous lecture, we see that the filter
method should be declared with the contravariant parameter Predicate<? super T>
instead so as to support more types of predicates.
filter :: Optional<T> -> Predicate<? super T> -> Optional<T>
Using the Circle
example, the predicate passed to filter
need not only read the Circle
as a Circle
, but can also read it as an Object
!
jshell> Predicate<Object> pred = x -> x.equals(new Circle(new Point(0.0, 0.0), 1.0))
pred ==> $Lambda$...
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> filter(pred)
$.. ==> Optional[circle of radius 1.0 centred at point (0.000, 0.000)]
jshell> pred = x -> x.equals(new Point(0.0, 0.0))
pred ==> $Lambda$...
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> filter(pred)
$.. ==> Optional.empty
The above first tests if the Optional<Circle>
returned from createUnitCircle
is equals to a circle located at the origin with radius 1.0
, and then tests if it is equals to a point located at the origin.
The map
method in Optional
takes in an instance of some implementation of the Function
functional interface with the specification of the single abstract method apply
. The apply method is associated with an input type T
and an output type R
.
As an example consider mapping a circle to its radius (assuming the presence of a getRadius()
method). We demonstrate the creation of the function by defining an anonymous inner class, as well as a lambda expression.
jshell> Function<Circle, Double> f = new Function<Circle, Double>() {
...> public Double apply(Circle c) {
...> return c.getRadius();
...> }
...> }
f ==> ..@..
jshell> f.apply(new Circle(new Point(0.0, 0.0), 1.0))
$.. ==> 1.0
jshell> f = c -> c.getRadius()
f ==> $Lambda$..
jshell> f.apply(new Circle(new Point(0.0, 0.0), 1.0))
$.. ==> 1.0
Likewise we can pass a Function<Circle,Double>
function object into Optional<Circle>
.
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> map(c -> c.getRadius())
$.. ==> Optional[1.0]
jshell> createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> map(c -> c.contains(new Point(0.5, 0.5)))
$.. ==> Optional[true]
jshell> createUnitCircle(new Point(1.0, 0.0), new Point(1.0, 0.0)).
...> map(c -> c.contains(new Point(0.5, 0.5)))
$.. ==> Optional.empty
Notice that the map
method of Optional
applies the Function
to the object inside the box, while retaining the box.
Observe that calling map
results in another Optional
, containing the mapped value, possibly of a different type.
What happens if we have a Function
defined as follows:
jshell> Function<Object,Integer> f = x -> x.toString().length()
f ==> $Lambda$..
jshell> Optional<Number> on = createUnitCircle(new Point(-1.0, 0.0), new Point(1.0, 0.0)).
...> map(f)
on ==> Optional[62]
jshell> Optional<Number> on = createUnitCircle(new Point(1.0, 0.0), new Point(1.0, 0.0)).
...> map(f)
on ==> Optional.empty
Notice that createUnitCircle
returns Optional<Circle>
from which the map
method was invoked that takes in Function<Object,Integer>
, with the return value assigned to a variable of type Optional<Number>
.
There are four different type parameterizations here!
The reason why this works is because the map
method is declared with a Function
parameter where function inputs T
are contravariant and function outputs R
are covariant.
map :: Optional<T> -> Function<? super T, ? extends R> -> Optional<R>
In our example, T
is bound to Circle
which is the outcome of createUnitCircle
. R
is bound to Number
based on the assignment to the on
variable.
The Function
passed to map is expected to read in data from the circle, and give out data in the form of a Number
.
Since data is flowing into Function
with type T
, it can also be read as ? super T
.
Likewise, if Function
is expected to have data of type R
flowing out, it can also give out data as ? extends R
.
Take some time to digest this.
Now we have an alternative way to define the findCoverage
method.
int findCoverage(Optional<Circle> circle, ImList<Point> points) {
int numOfPoints = 0;
for (Point point : points) {
numOfPoints = numOfPoints + circle
.filter(c -> c.contains(point)) // Optional<Circle>
.map(x -> 1) // Optional<Integer>
.orElse(0); // Integer
}
return numOfPoints;
}
In the above, finding coverage of a particular point within the circle requires passing the functionality of containment testing via the filter
method, and then passing the functionality of mapping each circle to the value 1
via the map
method.
The orElse
method if necessary as it will return the value 1
within the non-empty Optional
, or a default value of 0
if map
returns Optional.empty
.
In this way, summing up the 1
s and 0
s will give the coverage over all points.
It is important to note that the existence of an object in an Optional
container is encapsulated within the Optional
and never exposed to the client, at least not until the final integer value is needed for the purpose of summing as part of iterating over the points.
As one can envision, map
, filter
and other higher order methods are useful design patterns that one can employ to solve computation problems.
Many problems will require us to deal with missing values and we can now rely on the creation of Optional
objects to help us manage them. The only difference would be the functionalities that we pass to the higher order methods.
This is in line with the abstraction principle [Benjamin C. Pierce]:
Each significant piece of functionality in a program should be implemented in just one place in the source code. Where similar functions are carried out by distinct pieces of code, it is generally beneficial to combine them into one by abstracting out the varying parts.
With respect to say the map
method in Optional
, mapping only when a value is present is the "similar function" that is common throughout all problems, and hence implemented in the map
method.
On the other hand, the "varying parts" are the specific mapping functions that are abstracted out, which will be different depending on the specifics of the problem.
As we mentioned, this is just a prelude to the topics that we are going to cover in the later half of the semester.
And just to whet your appetite, you will see in due time that we can further refactor the implementation of findCoverage
by encapsulating the iteration of points.
In other words, we no longer need to write loops!
int findCoverage(Optional<Circle> circle, ImList<Point> points) {
return circle
.map(x -> points.stream()
.filter(y -> x.contains(y))
.map(y -> 1)
.reduce(0, (a,b) -> a + b))
.orElse(0);
}
Comparing this new implementation with the original one below
int findCoverage(Optional<Circle> circle, ImList<Point> points) {
int numOfPoints = 0;
if (circle.isEmpty()) { // or if(!circle.isPresent())
return 0;
} else {
Circle c = circle.get();
for (Point point : points) {
if (c.contains(point)) {
numOfPoints = numOfPoints + 1;
}
}
return numOfPoints;
}
}
you will see two distinctly different ways of programming.
The original implementation focuses on state changes while the program runs.
In particular, the state of the loop variable point
, as well as the state of numOfPoints
change at every loop.
This is the usual imperative programming paradigm that students have already been acquainted with since programming 101, where one needs to express how to do something and be always mindful of the changes in state of the program as it runs.
In contrast, our new implementation is coded using a declarative programming paradigm.
One need only tell Optional
and also Stream
what to do via the higher order methods.
There are no state changes; indeed there is no assignment at all!