Chapter 4. Libraries
Iâve talked about how to write lambda expressions but so far havenât covered the other side of the fence: how to use them. This lesson is important even if youâre not writing a heavily functional library like streams. Even the simplest application is still likely to have application code that could benefit from code as data.
Another Java 8 change that has altered the way that we need to think about
libraries is the introduction of default
methods and static methods on
interfaces. This change means that methods on interfaces can now have bodies
and contain code.
Iâll also fill in some gaps in this chapter, covering topics such as what happens when you overload methods with lambda expressions and how to use primitives. These are important things to be aware of when youâre writing lambda-enabled code.
Using Lambda Expressions in Code
In Chapter 2, I described how a lambda expression is given the type of a functional interface and how this type is inferred. From the point of view of code calling the lambda expression, you can treat it identically to calling a method on an interface.
Letâs look at a concrete example framed in terms of logging frameworks. Several
commonly used Java logging frameworks, including slf4j
and log4j
, have methods
that log output only when their logging level is set to a certain level or higher. So, they
will have a method like void debug(String message)
that will log message
if the level is at debug
.
Unfortunately, calculating the message
to log frequently has a performance cost associated with it. Consequently, you end up with a situation in which people start explicitly calling the Boolean isDebugEnabled
method in order to optimize this performance cost. A code sample is shown in Example 4-1. Even though a direct call to debug
would have avoided logging the text, it still would had to call the expensiveOperation
method and also concatenate its output to the message String
, so the explicit if
check still ends up being faster.
Logger
logger
=
new
Logger
();
if
(
logger
.
isDebugEnabled
())
{
logger
.
debug
(
"Look at this: "
+
expensiveOperation
());
}
What we actually want to be able to do is pass in a lambda expression that
generates a String
to be used as the message. This expression would be called only if
the Logger
was actually at debug level or above. This approach would allow us to
rewrite the previous code example to look like the code in Example 4-2.
Logger
logger
=
new
Logger
();
logger
.
debug
(()
->
"Look at this: "
+
expensiveOperation
());
So how do we implement this method from within our Logger
class? From the
library point of view, we can just use the builtin Supplier
functional
interface, which has a single get
method. We can then call
isDebugEnabled
in order to find out whether to call this method and pass the result
into our debug
method if it is enabled. The resulting code is shown in
Example 4-3.
public
void
debug
(
Supplier
<
String
>
message
)
{
if
(
isDebugEnabled
())
{
debug
(
message
.
get
());
}
}
Calling the get()
method in this example corresponds to calling the
lambda expression that was passed into the method to be called. This approach
also conveniently works with anonymous inner classes, which allows you maintain
a backward-compatible API if you have consumers of your code who canât upgrade
to Java 8 yet.
Itâs important to remember that each of the different functional interfaces
can have a different name for its actual method. So, if we were using
a Predicate
, we would have to call test
, or if we were using Function
,
we would have to call apply
.
Primitives
You might have noticed in the previous section that we skimmed over
the use of primitive types. In Java we have a set of
parallel typesâfor example, int
and Integer
âwhere one is a primitive
type and the other a boxed type. Primitive types are built into the language
and runtime environment as fundamental building blocks; boxed types
are just normal Java classes that wrap up the primitives.
Because Java generics are based around erasing a generic parameterâin
other words, pretending itâs an instance of Object
âonly the boxed types
can be used as generic arguments. This is why if you want a list of integer
values in Java it will always be List<Integer>
and not List<int>
.
Unfortunately, because boxed types are objects, there is a memory overhead to
them. For example, although an int
takes 4 bytes of memory, an Integer
takes
16 bytes. This gets even worse when you start to look at arrays of numbers,
as each element of a primitive array is just the size of the primitive,
while each element of a boxed array is actually an in-memory pointer to another
object on the Java heap. In the worst case, this might make an Integer[]
take
up nearly six times more memory than an int[]
of the same size.
There is also a computational overhead when converting from a primitive type to a boxed type, called boxing, and vice versa, called unboxing. For algorithms that perform lots of numerical operations, the cost of boxing and unboxing combined with the additional memory bandwidth used by allocated boxed objects can make the code significantly slower.
As a consequence of these performance overheads, the streams library
differentiates between the primitive and boxed versions of some library
functions. The mapToLong
higher-order function and ToLongFunction
, shown in
Figure 4-1, are examples of this effort.
Only the int
, long
, and double
types have been chosen as the focus of the primitive specialization implementation in Java 8 because the impact is most noticeable in
numerical algorithms.
The primitive specializations have a very clear-cut naming convention. If the
return type is a primitive, the interface is prefixed with To
and the
primitive type, as in ToLongFunction
(shown in Figure 4-1).
If the argument type is a primitive type, the name prefix is just the type
name, as in LongFunction
(Figure 4-2). If the higher-order function uses a
primitive type, it is suffixed with To
and the primitive type, as in mapToLong
.
There are also specialized versions of Stream
for these primitive types that
prefix the type name, such as LongStream
. In fact, methods like
mapToLong
donât return a Stream
; they return these specialized streams. On
the specialized streams, the map
implementation is also specialized: it takes a
function called LongUnaryOperator
, visible in Figure 4-3,
which maps a long
to a long
. Itâs also possible to get back from a
primitive stream to a boxed stream through higher-order function variations such
as mapToObj
and the boxed
method, which returns a stream of boxed objects
such as Stream<Long>
.
Itâs a good idea to use the primitive specialized functions wherever possible because of the performance benefits. You also get additional functionality available on the specialized streams. This allows you to avoid having to implement common functionality and to use code that better conveys the intent of numerical operations. You can see an example of how to use this functionality in Example 4-4.
public
static
void
printTrackLengthStatistics
(
Album
album
)
{
IntSummaryStatistics
trackLengthStats
=
album
.
getTracks
()
.
mapToInt
(
track
->
track
.
getLength
())
.
summaryStatistics
();
System
.
out
.
printf
(
"Max: %d, Min: %d, Ave: %f, Sum: %d"
,
trackLengthStats
.
getMax
(),
trackLengthStats
.
getMin
(),
trackLengthStats
.
getAverage
(),
trackLengthStats
.
getSum
());
}
Example 4-4 prints out a summary of track length information
to the console. Instead of calculating that information ourselves, we map each
track to its length, using the primitive specialized mapToInt
method. Because this method returns an IntStream
, we can call summaryStatistics
, which calculates statistics such as the minimum, maximum, average, and sum values on the IntStream
.
These values are available on all the specialized streams, such as
DoubleStream
and LongStream
. Itâs also possible to calculate the individual
summary statistics if you donât need all of them through the min
, max
,
average
, and sum
methods, which are all also available on all three
primitive specialized Stream
variants.
Overload Resolution
Itâs possible in Java to overload methods, so you have multiple methods with
the same name but different signatures. This approach poses a problem for parameter-type inference because it means that there are several types that could be
inferred. In these situations javac
will pick the most specific type for
you. For example, the method call in Example 4-5, when
choosing between the two methods in Example 4-6, prints out
String
, not Object
.
private
void
overloadedMethod
(
Object
o
)
{
System
.
out
.
(
"Object"
);
}
private
void
overloadedMethod
(
String
s
)
{
System
.
out
.
(
"String"
);
}
A BinaryOperator
is a special type of BiFunction
for which the arguments
and the return type are all the same. For example, adding two integers would
be a BinaryOperator
.
Because lambda expressions have the types of their functional interfaces, the same
rules apply when passing them as arguments. We can overload a method with the
BinaryOperator
and an interface that extends it. When calling these methods, Java will infer the
type of your lambda to be the most specific functional interface. For example,
the code in Example 4-7 prints out IntegerBinaryOperator
when
choosing between the two methods in Example 4-8.
private
interface
IntegerBiFunction
extends
BinaryOperator
<
Integer
>
{
}
private
void
overloadedMethod
(
BinaryOperator
<
Integer
>
lambda
)
{
System
.
out
.
(
"BinaryOperator"
);
}
private
void
overloadedMethod
(
IntegerBiFunction
lambda
)
{
System
.
out
.
(
"IntegerBinaryOperator"
);
}
Of course, when there are multiple method overloads, there isnât always a clear âmost specific type.â Take a look at Example 4-9.
overloadedMethod
((
x
)
->
true
);
private
interface
IntPredicate
{
public
boolean
test
(
int
value
);
}
private
void
overloadedMethod
(
Predicate
<
Integer
>
predicate
)
{
System
.
out
.
(
"Predicate"
);
}
private
void
overloadedMethod
(
IntPredicate
predicate
)
{
System
.
out
.
(
"IntPredicate"
);
}
The lambda expression passed into overloadedMethod
is compatible with both a normal Predicate
and the IntPredicate
. There are method overloads for each of these options defined within this code block. In this case, javac
will fail to compile the example, complaining that the lambda expression is an ambiguous method call: IntPredicate
doesnât extend any Predicate
, so the compiler isnât able to infer that itâs more specific.
The way to fix these situations is to cast the lambda expression to either
IntPredicate
or Predicate<Integer>
, depending upon which behavior you want
to call. Of course, if youâve designed the library yourself, you might conclude that
this is a code smell and you should start renaming your overloaded
methods.
In summary, the parameter types of a lambda are inferred from the target type, and the inference follows these rules:
- If there is a single possible target type, the lambda expression infers the type from the corresponding parameter on the functional interface.
- If there are several possible target types, the most specific type is inferred.
- If there are several possible target types and there is no most specific type, you must manually provide a type.
@FunctionalInterface
Although I talked about the criteria for what a functional interface actually is
back in Chapter 2, I havenât yet mentioned the
@FunctionalInterface
annotation. This is an annotation that should be
applied to any interface that is intended to be used as a functional interface.
What does that really mean? Well, there are some interfaces in Java that have only a single method but arenât normally meant to be implemented by lambda expressions. For example, they might assume that the object has internal state and be interfaces with a single method only coincidentally. A couple of good examples are java.lang.Comparable
and java.io.Closeable
.
If a class is Comparable
, it means there is a defined order between
instances, such as alphabetical order for strings. You donât normally
think about functions themselves as being comparable objects because they lack
fields and state, and if there are no fields and no state, what is there to
sensibly compare?
For an object to be Closeable
it must hold an open
resource, such as a file handle that needs to be closed at some point in
time. Again, the interface being called cannot be a pure function because
closing a resource is really another example of mutating state.
In contrast to Closeable
and Comparable
, all the new interfaces introduced
in order to provide Stream
interoperability are expected to be implemented by
lambda expressions. They are really there to bundle up blocks of code as data.
Consequently, they have the @FunctionalInterface
annotation applied.
Using the annotation compels javac
to actually check whether the interface meets
the criteria for being a functional interface. If the annotation is applied to
an enum
, class
, or annotation
, or if the type is an interface with more
than one single abstract method, then javac
will generate an error message.
This is quite helpful for being able to catch errors easily when refactoring
your code.
Binary Interface Compatibility
As you saw in Chapter 3, one of the biggest API changes in Java 8 is to the collections library. As Java has evolved, it has maintained backward binary compatibility. In practical terms, this means that if you compiled a library or application with Java 1 through 7, itâll run out of the box in Java 8.
Of course, there are still bugs from time to time, but compared to many other
programming platforms, binary compatibility has been viewed as a key Java
strength. Barring the introduction of a new keyword, such as enum
, there has
also been an effort to maintain backward source compatibility. Here
the guarantee is that if youâve got source code in Java 1-7, itâll compile in Java 8.
These guarantees are really hard to maintain when youâre changing such a core
library component as the collections library. As a thought exercise,
consider a concrete example. The stream
method was added to the
Collection
interface in Java 8, which means that any class that implements
Collection
must also have this method on it. For core
library classes, this problem can easily be solved by implementing that method (e.g., adding a stream
method to
ArrayList
).
Unfortunately, this change still breaks binary compatibility because it means
that any class outside of the JDK that implements Collection
âsay,
MyCustomList
âmust also have implemented the stream
method. In Java 8
MyCustomList
would no longer compile, and even if you had a compiled version
when you tried to load MyCustomList
into a JVM, it would result in an
exception being thrown by your ClassLoader
.
This nightmare scenario of all third-party collections libraries being broken has been averted, but it did require the introduction of a new language concept: default methods.
Default Methods
So youâve got your new stream
method on Collection
; how do you allow
MyCustomList
to compile without ever having to know about its existence?
The Java 8 approach to solving the problem is to allow Collection
to say, âIf
any of my children donât have a stream
method, they can use this one.â These
methods on an interface are called default methods. They can be
used on any interface, functional or not.
Another default method that has been added is the forEach
method on Iterable
,
which provides similar functionality to the for
loop but lets you use a lambda
expression as the body of the loop. Example 4-10 shows how this could be
implemented in the JDK.
default
void
forEach
(
Consumer
<?
super
T
>
action
)
{
for
(
T
t
:
this
)
{
action
.
accept
(
t
);
}
}
Now that youâre familiar with the idea that you can use lambda expressions by just calling methods on interfaces, this example should look pretty simple. It uses a regular for
loop to iterate over the underlying Iterable
, calling the accept
method with each value.
If itâs so simple, why mention it? The important thing is that new default
keyword right at the beginning of the code snippet. That tells javac
that
you really want to add a method to an interface. Other than the addition of
a new keyword, default
methods also have slightly different inheritance rules
to regular methods.
The other big difference is that, unlike classes, interfaces donât have
instance fields, so default
methods can modify their child classes only by
calling methods on them. This helps you avoid making assumptions about the
implementation of their children.
Default Methods and Subclassing
There are some subtleties about the way that default
methods override and
can be overridden by other methods. Letâs look the simplest case to begin with:
no overriding. In Example 4-11, our Parent
interface defines a
welcome
method that sends a message when called. The ParentImpl
class
doesnât provide an implementation of welcome
, so it inherits the default
method.
public
interface
Parent
{
public
void
message
(
String
body
);
public
default
void
welcome
()
{
message
(
"Parent: Hi!"
);
}
public
String
getLastMessage
();
}
When we come to call this code, in Example 4-12, the default
method
is called and our assertion passes.
@Test
public
void
parentDefaultUsed
()
{
Parent
parent
=
new
ParentImpl
();
parent
.
welcome
();
assertEquals
(
"Parent: Hi!"
,
parent
.
getLastMessage
());
}
Now we can extend Parent
with a Child
interface, whose code is listed in
Example 4-13. Child
implements its own default welcome
method.
As you would intuitively expect, the default
method on Child
overrides the
default
method on Parent
. In this example, again, the ChildImpl
class
doesnât provide an implementation of welcome
, so it inherits the default
method.
public
interface
Child
extends
Parent
{
@Override
public
default
void
welcome
()
{
message
(
"Child: Hi!"
);
}
}
You can see the class hierarchy at this point in Figure 4-4.
Example 4-14 calls this interface and consequently ends up
sending the string "Child: Hi!"
.
@Test
public
void
childOverrideDefault
()
{
Child
child
=
new
ChildImpl
();
child
.
welcome
();
assertEquals
(
"Child: Hi!"
,
child
.
getLastMessage
());
}
Now the default
method is a virtual methodâthat is, the opposite of a static
method. What this means is that whenever it comes up against competition from
a class method, the logic for determining which override to pick always chooses
the class. A simple example of this is shown in Examples 4-15 and 4-16, where the welcome
method of OverridingParent
is chosen over that of Parent
.
public
class
OverridingParent
extends
ParentImpl
{
@Override
public
void
welcome
()
{
message
(
"Class Parent: Hi!"
);
}
}
@Test
public
void
concreteBeatsDefault
()
{
Parent
parent
=
new
OverridingParent
();
parent
.
welcome
();
assertEquals
(
"Class Parent: Hi!"
,
parent
.
getLastMessage
());
}
Hereâs a situation, presented in Example 4-18, in which
you might not expect the concrete class to override the default
method.
OverridingChild
inherits both the welcome
method from Child
and the welcome
method from OverridingParent
and doesnât do anything itself.
OverridingParent
is chosen despite OverridingChild
(the code in
Example 4-17), being a more specific type because itâs a concrete method
from a class rather than a default
method (see Figure 4-5).
public
class
OverridingChild
extends
OverridingParent
implements
Child
{
}
@Test
public
void
concreteBeatsCloserDefault
()
{
Child
child
=
new
OverridingChild
();
child
.
welcome
();
assertEquals
(
"Class Parent: Hi!"
,
child
.
getLastMessage
());
}
Put simply: class wins. The motivation for this decision is that default
methods are designed primarily to allow binary compatible API evolution.
Allowing classes to win over any default
methods simplifies a lot of
inheritance scenarios.
Suppose we had a custom list implementation called
MyCustomList
and had implemented a custom addAll
method, and the new
List
interface provided a default addAll
that delegated to the add
method. If the default
method wasnât guaranteed to be overridden by this
addAll
method, we could break the existing implementation.
Multiple Inheritance
Because interfaces are subject to multiple inheritance, itâs possible to get
into situations where two interfaces both provide default
methods with
the same signature. Hereâs an example in which both a Carriage
and a
Jukebox
provide a method to rock
âin each case, for different
purposes. We also have a MusicalCarriage
, which is both a Jukebox
(Example 4-19)
and a Carriage
(Example 4-20) and tries to inherit the rock
method.
public
interface
Jukebox
{
public
default
String
rock
()
{
return
"... all over the world!"
;
}
}
public
interface
Carriage
{
public
default
String
rock
()
{
return
"... from side to side"
;
}
}
public
class
MusicalCarriage
implements
Carriage
,
Jukebox
{
}
Because itâs not clear to javac
which method it should inherit, this will just result in the compile error class MusicalCarriage inherits unrelated defaults for rock() from types Carriage and Jukebox
. Of course, itâs possible to resolve this by implementing the rock
method, shown in Example 4-21.
public
class
MusicalCarriage
implements
Carriage
,
Jukebox
{
@Override
public
String
rock
()
{
return
Carriage
.
super
.
rock
();
}
}
This example uses the enhanced super
syntax in order to pick Carriage
as its preferred rock
implementation. Previously, super
acted as a reference to the parent class, but by using the InterfaceName.super
variant itâs possible to specify a method from an inherited interface.
The Three Rules
If youâre ever unsure of what will happen with default
methods or with multiple inheritance of behavior, there are three simple rules for handling conflicts:
- Any class wins over any interface. So if thereâs a method with a body, or an abstract declaration, in the superclass chain, we can ignore the interfaces completely.
-
Subtype wins over supertype. If we have a situation in which two interfaces
are competing to provide a
default
method and one interface extends the other, the subclass wins. -
No rule 3. If the previous two rules donât give us the answer, the
subclass must either implement the method or declare it
abstract
.
Rule 1 is what brings us compatibility with old code.
Tradeoffs
These changes raise a bunch of issues regarding what an interface really is in Java 8, as you can define methods with code bodies on them. This means that interfaces now provide a form of multiple inheritance that has previously been frowned upon and whose removal has been considered a usability advantage of Java over C++.
No language feature is always good or always bad. Many would argue that the
real issue is multiple inheritance of state rather than just blocks of code,
and as default
methods avoid multiple inheritance of state, they avoid the
worst pitfalls of multiple inheritance in C++.
It can also be very tempting to try and work around these limitations. Blog posts have already cropped up trying to implement full-on traits with multiple inheritance of state as well as default
methods. Trying to hack around the deliberate restrictions of Java 8 puts us back into the old pitfalls of C++.
Itâs also pretty clear that thereâs still a distinction between interfaces and abstract classes. Interfaces give you multiple inheritance but no fields, while abstract classes let you inherit fields but you donât get multiple inheritance. When modeling your problem domain, you need to think about this tradeoff, which wasnât necessary in previous versions of Java.
Static Methods on Interfaces
Weâve seen a lot of calling of Stream.of
but havenât gotten into its details yet. You may recall that Stream
is an interface, but this is a static method on an interface. This is another new language change that has made its way into Java 8, primarily in order to help library developers, but with benefits for day-to-day application developers as well.
An idiom that has accidentally developed over time is ending up with classes
full of static methods. Sometimes a class can be an appropriate location for
utility code, such as the Objects
class introduced in Java 7 that
contained functionality that wasnât specific to any particular class.
Of course, when thereâs a good semantic reason for a method to relate to a concept, it should always be put in the same class or interface rather than hidden in a utility class to the side. This helps structure your code in a way thatâs easier for someone reading it to find the relevant method.
For example, if you want to create a simple Stream
of values, you would expect
the method to be located on Stream
. Previously, this was impossible, and the
addition of a very interface-heavy API in terms of Stream
finally motivated
the addition of static methods on interfaces.
Note
There are other methods on Stream
and its primitive specialized variants. Specifically, range
and iterate
give us other ways of generating our own streams.
Optional
Something Iâve glossed over so far is that reduce
can come in a couple of forms:
the one weâve seen, which takes an initial value, and another variant, which
doesnât. When the initial value is left out, the first call to the reducer uses
the first two elements of the Stream
. This is useful if thereâs no sensible
initial value for a reduce
operation and will return an instance of Optional
.
Optional
is a new core library data type that is designed to provide a better
alternative to null
. Thereâs quite a lot of hatred for the old null
value.
Even the man who invented the concept, Tony Hoare, described it as âmy
billion-dollar mistake.â Thatâs the trouble with being an influential computer
scientistâyou can make a billion-dollar mistake without even seeing the
billion dollars yourself!
null
is often used to represent the absence of a value, and this is the use case that Optional
is replacing. The problem with using null
in order to represent absence is the dreaded NullPointerException
. If you refer to a variable that is null
, your code blows up. The goal of Optional
is twofold. First, it encourages the coder to make appropriate checks as to whether a variable is null
in order to avoid bugs. Second, it documents values that are expected to be absent in a classâs API. This makes it easier to see where the bodies are buried.
Letâs take a look at the API for Optional
in order to get a feel for how to use it. If you want to create an Optional
instance from a value, there is a factory method called of
. The Optional
is now a container for this value, which can be pulled out with get
, as shown in Example 4-22.
Optional
<
String
>
a
=
Optional
.
of
(
"a"
);
assertEquals
(
"a"
,
a
.
get
());
Because an Optional
may also represent an absent value, thereâs also a factory method called empty
, and you can convert a nullable value into an Optional
using the ofNullable
method. Both of these are shown in Example 4-23, along with the use of the isPresent
method (which indicates whether the Optional
is holding a value).
Optional
emptyOptional
=
Optional
.
empty
();
Optional
alsoEmpty
=
Optional
.
ofNullable
(
null
);
assertFalse
(
emptyOptional
.
isPresent
());
// a is defined above
assertTrue
(
a
.
isPresent
());
One approach to using Optional
is to guard any call to get()
by checking
isPresent()
. A neater approach is to call the orElse
method, which
provides an alternative value in case the Optional
is empty. If creating an
alternative value is computationally expensive, the orElseGet
method
should be used. This allows you to pass in a Supplier
that is called only if
the Optional
is genuinely empty. Both of these methods are demonstrated in
Example 4-24.
assertEquals
(
"b"
,
emptyOptional
.
orElse
(
"b"
));
assertEquals
(
"c"
,
emptyOptional
.
orElseGet
(()
->
"c"
));
Not only is Optional
used in new Java 8 APIs, but itâs also just a regular class
that you can use yourself when writing domain classes. This is definitely
something to think about when trying to avoid nullness-related bugs such
as uncaught exceptions.
Key Points
-
A significant performance advantage can be had by using primitive specialized
lambda expressions and streams such as
IntStream
. -
Default methods are methods with bodies on interfaces prefixed
with the keyword
default
. -
The
Optional
class lets you avoid usingnull
by modeling situations where a value may not be present.
Exercises
Given the
Performance
interface in Example 4-25, add a method calledgetAllMusicians
that returns aStream
of the artists performing and, in the case of groups, any musicians who are members of those groups. For example, ifgetMusicians
returns The Beatles, then you should return The Beatles along with Lennon, McCartney, and so on.-
Based on the resolution rules described earlier, can you ever override
equals
orhashCode
in adefault
method? -
Take a look at the
Artists
domain class in Example 4-26, which represents a group of artists. Your assignment is to refactor thegetArtist
method in order to return anOptional<Artist>
. It contains an element if the index is within range and is an emptyOptional
otherwise. Remember that you also need to refactor thegetArtistName
method, and it should retain the same behavior.
public
class
Artists
{
private
List
<
Artist
>
artists
;
public
Artists
(
List
<
Artist
>
artists
)
{
this
.
artists
=
artists
;
}
public
Artist
getArtist
(
int
index
)
{
if
(
index
<
0
||
index
>=
artists
.
size
())
{
indexException
(
index
);
}
return
artists
.
get
(
index
);
}
private
void
indexException
(
int
index
)
{
throw
new
IllegalArgumentException
(
index
+
" doesn't correspond to an Artist"
);
}
public
String
getArtistName
(
int
index
)
{
try
{
Artist
artist
=
getArtist
(
index
);
return
artist
.
getName
();
}
catch
(
IllegalArgumentException
e
)
{
return
"unknown"
;
}
}
}
Open Exercises
- Look through your work code base or an open source project youâre familiar with and try to identify classes that have just static methods that could be moved to static methods on interfaces. It might be worth discussing with your colleagues whether they agree or disagree with you.
Get Java 8 Lambdas now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.