0. Introduction
Many older Scala programmers—this author included—came to Scala after clocking years of coding in Java. The existing knowledge of the Java VM and its standard library, among other concepts, were immediately transferrable. Yet, as colleges abandon Java as the primary teaching language, and companies slow down their investment in new lines of Java code, younger Scala programmers are increasingly less likely to have encountered Java in their professional careers. Still, if measured by the lines of production code, Java will remain the most ubiquitous programming language for many years to come, and new generations of programmers will have to learn it. Scala programmers are in an advantageous position to learn Java quickly. This article will help experienced Scala programmers to grasp Java’s key concepts by introducing them by analogy with the familiar Scala idioms.
Java is a single-paradigm impure object-oriented language whose type system deviates from that of antecedent pure OO languages, like Smalltalk, or, in fact, from Scala’s own far more consistent type system. These deviations were the consequence of certain performance tradeoffs that were reasonable for the mid-1990s, when Java was first created at Sun Microsystems. Since then, the language has had a number of updates, like parametric types and lambda expressions, but Java’s general commitment to backward compatibility increasingly comes at the cost of constrained ability to keep up with modern language design ideas and advances in hardware on which Java programs now run.
1. Type System
1.1. Primitive Types
In, perhaps, the most consequential departure from the object-oriented design, Java supports eight primitive value types: boolean
, byte
, char
, short
, int
, long
, float
, and double
. These low-level higher performing types map directly to the underlying hardware and exist entirely beside Java’s type system: they are not subject to type inheritance, cannot serve as type parameters, and can only be operated on by operators built into the language; they can only be used to declare the type of a variable or a method parameter or the return type of a method.
To compensate for some of these shorcomings, the standard library offers an object wrapper for each primitive type, e.g. Integer
and Double
. These have convenient supertypes, can serve as type parameters, and come with many useful methods, like valueOf()
for parsing the value from a string. The compiler offers an autoboxing facility, seamlessly converting between primitives and the corresponding object wrappers:
var i = Integer.valueOf(4); // Type Integer Integer j = 5; // Autoboxed to type Integer println(i + j); // Auto-unboxed to int
Unlike Scala, Java’s +
is not a method on a numeric value type, but an operator applicable to certain types, like primitive numeric types, but not to their object wrappers. For the last line to work, the compiler has to auto-unbox the two instances of Integer
to their primitive counterparts.
1.2. void
Java has a special keyword void
used in place of the return type in method signatures which return no value. Which is to say whose bodies have no return
statement; Java requires an explicit return
from a method returning anything other than void
. For example, here’s Java’s main
method’s signature :
static public void main(String[] args) { ... }
A Scala programmer’s intuition may be to relate void
to Unit
, but the analogy doesn’t quite work: void
is not a type, but a keyword in the language. It cannot be used to declare a variable or as a type parameter. The problem is partially alleviated by the pseudotype Void
, which behaves more like a type in that it can be used as a type parameter. It is even possible to define a method with the return type Void
, whose only usable instance that can be returned is null
.
1.3. Type Parameters (Generics)
In Java, parametric types are called generic types, or, simply, generics. For example, the Java standard library defines the type Function
taking one parameter of type T
and returning a value of type R
interface Function<T,R> { ... }
Java does not support higher-kinded types; only unparameterized types can be type parameters.
interface Functor<F<?>> {} // Syntax error
A type parameter can have an upper bound:
class DelayQueue<D extends Delayed> { ... } // D must be a subtype of Delayed
which is identical to Scala’s
class DelayQueue[D <: Delayed] { ... } // D must be a subtype of Delayed
Unlike Scala, Java does not implement lower-bounds for type parameters, (reasonably) citing limited utility.
1.4 Type Variance
In Scala, a type’s variance can either be declared with the type, or specified at the point of use. For example, Scala’s unary function type Function1[-T1, +R]
is always contravariant in its parameter type and covariant in its return type. Which is to say that wherever a unary function f(T) => R
is expected, the Scala compiler will accept an implementation taking a supertype of T
or returning a subtype of R
.
Java only supports use-site variance specification. For example, Java’s own unary function type Function<T,R>
is invariant, requiring its clients to specify variance at use-site, e.g. the method java.util.Stream.map()
from Java’s standard library:
<R> Stream<T> map(Function<? super T, ? extends R> mapper);
This is exactly analogous to Scala’s similar syntax for use-site variance:
def map[R] (mapper: Function[_ <: T, _ >: R]): Stream[R] = ??? // Not actual method.
Declaration-site variance would be clearly preferable in this case, because it is not conceivable that any client of Function
would want it any other way.
1.5 Type Inference
Modern Java has limited type inference. In particular, in most local variable declarations their type can be inferred:
var films = new LinkedList<String>();
is equivalent to
LinkedList<String> films = new LinkedList<String>;
The keyword var
has the same meaning as in Scala. There is no val
keyword in Java, but the qualifier final
can be used to declare an immutable variable. As well, just like in Scala, the type parameter list on the right of the assignment may be dropped, if the variable type is explicitly declared on the left:
List<String> list = new LinkedList();
In fact, the type parameter can be omitted from the type declaration too, replicating the ancient Java grammar and semantics predating parametric types:
List list = new ArrayList(); list.add(new Object()); // OK list.add(1); // OK
Without an explicit type parameter, the compiler will choose Object
— the ultimate supertype of Java’s type hierarchy. Such a collection will accept a value of any type. Conversely, Scala’s compiler in this case will substitute Nothing
—the ultimate subtype of the type hierarchy—rendering the collection unusable because Nothing
is final and vacant:
val map = new mutable.HashMap() map.put(1, "one") // Compilation error Found: (1 : Int) Required: Nothing
Types of concrete class members, both fields and methods, cannot be inferred. Types of lambda parameters are always inferred and cannot be given explicitly.
List.of("The Stranger", "Citizen Kane", "Touch of Evil") .forEach(name -> System.out.println("Film Title: " + name)); // Type of name is inferred
2. Classes and Inheritance
Like Scala, Java implements the single inheritance model, whereby a class can inherit from at most one class and, optionally, implement any number of interfaces:
class Foo extends Bar implements Baz, Qux { ... }
Interfaces are partial equivalents of Scala traits with the following limitations:
- Java interfaces are not stackable: they are always selfless and their order in a type declaration is not significant.
- Concrete methods in interfaces are known as default implementations and must be annotatged with the
default
qualifier.
If more than one interface in a class declaration contains the same method (abstract or concrete), the implementing class must either be declared abstract or override it:
interface Baz { String method(); } interface Bar { default String method() { return "Bar"; } } class Foo implements Bar, Baz { } // Compilation error class Foo implements Bar, Baz { @Override String method() { return "Foo"; } // Ok }
Only methods can be abstract or overridden. A field defined in an interface or a class must be concrete. If a field defined in a subclass clashes with one defined in its superclass, the latter is not overridden, but hidden as a matter of syntactic scope, and cannot be accessed via super
. There are no lazy fields in Java; all fields must be initializable at class creation time either with a static expression or by a constructor.
Both fields and concrete methods can be declared final
, though with different semantics:
final int height = 186; // Cannot be mutated final void printHeight() { ... } // Cannot be overridden
Java does not have objects in Scala’s sense of the word*. Rather, static members are declared alongside with instance members inside the class body and are annotated with the keyword static
. While a Scala object can extend a class or a trait, static methods in Java are not subject to inheritance. When the name of a static member clashes with that of a static member in a superclass, the latter is hidden as a matter of syntactic context. A static member cannot be abstract or annotated with @Override
.
* In Java, object refers to the same concept as instance in Scala: an instantiation of a concrete class.
3. Arrays
An array of elements of some type T
has the type T[]
. T
can be either an object or a primitive type. Arrays can be allocated (and, optionally, initialized) at declaration or, dynamically, at run time.
int[] ints; // Declared only. LocalDate[] dates = new LocalDate[3]; // Declared and allocated. BigDecimal[] fines = {BigDecimal.valueOf(20)}; // Declared, allocated and initialized. ints = new int[2]; // Allocated dynamically at runtime. ints = new int[] {1,2}; // Allocated and initialized dynamically at runtime.
Although created with special syntax, Java arrays possess many properties of a regular object:
- Just like any Scala array is a subtype of
Any
, any Java array is a subtype ofObject
. In addition to the public members inherited fromObject
, liketoString()
orclone()
, Java arrays also expose the final fieldlength
. - Just like in Scala, Java arrays cannot be extended. In Scala, the
Array
class is final and explicitly implementsClonable
andSerializable
. Java arrays cannot be extended because they are created with special syntax which doesn’t allow the regular class inheritance constructs. Java arrays also implementClonable
andSerializable
, but implicitly. - While a Scala array cannot have a subtype, a Java array can, because arrays in Java are implicitly covariant.
Fish[]
is automatically a subclass ofPet[]
if and only ifFish
is a subclass ofPet
. Variance violations are checked at run time whenever an element is updated:
class Pet {} class Fish extends Pet {} class Snake extends Pet {} Pet[] pets = new Fish[10]; // Succeeds due to Java's implicit array covariance pets[0] = new Snake(); // Runtime ArrayStoreException
In contrast, Scala’s compiler would not have allowed assignment on line 4 because Scala arrays are nonvariant.
Java’s arrays predate generic types, and the two concepts have limited interoperability. It is possible to allocate a generic array only if the element type can be constructed at compilation time:
Optional<Integer>[] maybies = new Optional[10];
Note the lack of the type parameter to Optional
on the right: due to type erasure on the JVM at run time, language designers opted for this way of signaling that the compiler will not be capable of enforcing safety of the type parameter. In fact, it is downright impossible to allocate a generic array at runtime without sacrificing compile-time type safety. There is no way to implement the following method in Java without resorting to runtime reflection which breaks type safety*:
/** Allocate a generic array of given size -- not doable in Java */ <T> T[] alloc(int size) { ... }
Scala solved this and many other problems related to type erasure with type tags and type classes, so allocating a parametric array at runtime is imminently doable in Scala:
/** Allocate a generic array of given size -- Scala */ def allocate[T: ClassTag](size: Int): Array[T] = new Array[T](len)
Java arrays can be passed to type constructors or used as type bounds. Here’s one of several Arrays.copyOf()
static methods implementing fast shallow copy of an array:
<T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType)
The new array may have a different (typically larger) size and a type that is is upper-bounded by some type new type unrelated to the type of the original array.
4. Functions
4.1. Functional Interfaces as Function Types
Java does not support functions as first-class values: there’s no function type that can be instantiated, assigned, or passed to a method or another function as a parameter. Nevertheless, Java has made significant strides toward enabling function-like syntax known as lambda expressions.
List.of("The Stranger", "Citizen Kane", "Touch of Evil") .forEach(name -> System.out.println("Film Title: " + name));
Here, name -> System.out.println("Film Title: " + name)
has all the syntactic trappings of a function literal. It can be even assigned to a variable:
Consumer<List> func = name -> System.out.println("Film Title: " + name); // The type of func must be declared explicitly
Lambda expressions provide concise syntactic sugar for fully-fledged class literals, which is what goes on behind the scenes, as is evident from func
‘s type Consumer<List>
. Now, the previous expression can be rewritten as
List.of("The Stranger", "Citizen Kane", "Touch of Evil").forEach(func);
This works because the method forEach()
has the suitable signature:
interface Iterable<T> { void forEach(Consumer<? super T> action); }
The type Consumer
is a functional interface:
@FunctionalInterface public interface Consumer<T> { void accept(T t); default Consumer<T> andThen(Consumer<? super T> after) { Objects.requireNonNull(after); return (T t) -> { accept(t); after.accept(t); }; } }
The actual value of func
, therefore, just like in Scala, is an object instantiated for us by the compiler:
Consumer<String> f = new Consumer<>() { @Override public void accept(String name) { System.out.println("Film Title: " + name); } };
The best way to think of lambda expressions is as an alternative syntax for class literals of a certain type. Java compiler will accept a lambda expression in place of the traditional class literal, provided that it is structurally compatible with the target type, which must be explicitly declared.
- The target type must be an interface with exactly one abstract method. Such interfaces are referred to as functional. The method’s name is not significant.
- Parameter list in the lambda expression must have the same arity as that of the abstract method in the target interface.
- The return type of the abstract method must be a supertype of that returned by the lambda expression.
The Consumer
interface above is an example of a functional interface. Note that a functional interface need not be annotated with @FunctionalInterface
. However, it is a good idea to annotate custom functional interfaces in order to signal the intent, and to prevent accidental updates, introducing new abstract methods. If such an update were to be made, the annotation would trigger a compilation error on the interface, in addition to the corresponding lambda expressions, which may be located in different compilation units.
4.2 Standard Functional Interfaces
The package java.util.function
contains an assortment of reusable functional interfaces that fit many common use cases. It is a good idea to reuse these functional interfaces, rather than defining custom ones when one of these would do:
Interface | Corresponding Lambda Expression |
*Consumer | Functions returning void |
*Supplier | Nullary functions to some return type. |
*Function | Non-nullary functions to some return type. |
*Predicate | Non-nullary function returning boolean. |
The reason for the sheer number of these interfaces the need to accommodate the primitive types, which cannot be passed to a type constructor.
4.3 Streams and Higher Order Methods
In Java, higher order transformations like map
or filter
are not available directly on the collection types. Rather, they are provided by the java.util.stream.Stream
interface, a concrete instance of which is obtained by calling the stream()
method, available on all concrete collection types. For example, to transform a list:
var films = List.of("Citizen Kane", "Touch of Evil") .stream() .filter(f -> f.contains("Evil")) .map(f -> "Title: " + f); System.out.println(films); // java.util.stream.ReferencePipeline$2@372f7a8d
The transformer methods available on a Stream
instance return another Stream
instance, enabling composing of transformations in the style of functional programming. However, in order to terminate a stream an additional call to collect()
is required. There is a variety of available collector types that enable capturing a stream in a fully materialized collection or folding it into a single value. These collectors are obtainable from the static factory methods java.util.stream.Collectors.*
. For example, to collect the above stream back to a list:
var films = List.of("Citizen Kane", "Touch of Evil") .stream() .filter(f -> f.contains("Evil")) .map(f -> "Title: " + f) .collect(Collectors.toUnmodifiableList()); System.out.println(films); // [Title: Touch of Evil]
Note, that the call to collect(Collectors.toUnmodifiableList())
can be replaced with the shortcut toList()
—the familiar way to convert a collection to an immutable list in Scala.
5. Case Classes
Java’s case classes are called records:
record Ship(String name, LocalDate launched) {}; // Still cannot omit an empty body
Just like case classes in Scala, Java’s records come with automatically generated convenience methods:
- Public final fields for all parameters of the primary constructor;
- A deep implementation of
equals()
which compares values of all parameters of the primary constructor; - An implementation of
hashCode()
that is consistent withequals()
- A specialized implementation of
toString()
similar to Scala’s.
There’s no copy()
method.
A record’s body need not be empty. It can contain auxiliary constructors and additional methods. Records cannot define additional fields, except for static fields which are ignored by the default equals()
method.
A Java record implicitly extends the java.lang.Record
class and is implicitly final. Consequently, a record cannot extend another class, nor can be extended. In fact, records don’t even support the extends
clause. However, records can implement any number of interfaces. The finality restriction is in keeping with Scala’s case classes, but the impossibility of a record’s extending a superclass is a serious limitation. Consider, for instance, this implementation of a binary tree:
interface Tree<T> { T value(); @Override String toString() { ... } // Compilation error } record Leaf<T>(T value) implements Tree<T> {} record Node<T>(T value, Tree<T> left, Tree<T> right) implements Tree<T> {}
It’s likely we would want to override the tree’s toString()
method and the right place to do so would be in the interface. But that’s not possible because concrete methods on interfaces are disallowed to override methods inherited from Object
.
6. Exception Handling
Java supports exceptions with syntax similar to Scala’s. They are thrown with the throw
keyword, and can be caught with the try
block:
try { var i = Integer.decode(someString); } catch (NumberFormatException ex) { ... } catch (RuntimeException ex) { ... } finally { ... }
The semantics are similar to Scala’s, with one crucial difference that while exceptions in Scala are unchecked, Java exceptions can be also checked. Java’s unchecked exceptions are those descending from RuntimeException
, and they behave just like Scala exceptions. Checked exceptions, on the other hand, have no analogs in Scala. They must be declared in the signature of the method that either throws them or calls another method that declares them in its throws
clause:
String foo() throws Exception { ... throw new Exception("I am a checked exception"); ... }
Checked exception may cause a lot of unnecessary boilerplate code and are generally avoided by modern style guides. Nonetheless, many standard library classes expose checked exceptions, necessitating handling by the client code.
7. Java Standard Library
7.1. Collections
7.1.1. Collections Architecture
Java’s collections library is not nearly as consistent as that of Scala. Although both mutable and immutable collections are now supported, the class hierarchy hasn’t fundamentally changed since the early releases when only mutable collections were available. This has lead to a number of anomalies, counterintuitive to a Scala programmer. The principal members of Java’s Collections type hierarchy are illustrated below.
Iterable
declares the abstract method iterator()
and, given its implementation by a concrete class, provides a concrete implementation of forEach()
—the only higher order method available directly on collection classes, thanks to its void
return type. Other hither order methods are accessible via a Stream
, obtainable with the stream()
method declared in the Collection
interface and thus available on all concrete collection classes. See section 4.3. for more details.
Collection
declares three toArray()
methods for converting any collection to an array:
Object[] Collection.toArray()
is the most straightforward. Due to type erasure, the JVM does not know the collection’s declared element type at run time. But thanks to arrays’ implicit covariance,Object[]
is a supertype of any array, so it’s safe to return it, leaving it up to the programmer to downcast if required.<T> T[] Collection.toArray(T[] arr)
goes a small step further, allowing the programmer to allocate an array of a known type.<T> T[] toArray(IntFunction<T[]> generator)
is a minor revision of the previous method, that may be more suitable for streams.
7.1.2 Mutable Collections
Instantiation of mutable collections is accomplished via constructors. It is the responsibility of the programmer to pick the concrete collection class, and thus a particular implementation. The nullary constructor creates an empty modifiable collection of the requested type:
var films = new LinkedList<String>(); films.add("Citizen Kane");
It is possible to be marginally less verbose with Java’s instance initializers:
var balances = new HashMap<String, BigDecimal>() {{ put("Checking", new BigDecimal(23.45)); put("Savings", new BigDecimal(67.89)); }};
There’s also the non-nullary constructor, taking another collection (mutable or immutable) of a comparable type, which will be deep-copied into the new mutable collection:
var myBalances = new HashMap(balances);
All collection classes in package java.util
are not thread safe. (The only exceptions are Hashtable
and Vector
, both of which are obsolete and should not be used.) In use cases involving concurrent updates (with other updates or reads), these two options are available:
- The class
Collections
provides several static methods that can be used to wrap a regular unsafe collection in a synchronized view:
var map = Collections.synchronizedMap(new HashMap<String,String>());
Note that iterator traversals of synchronized collections are consistent with the underlying collection, but must be explicitly synchronized on the collection object in the client code. This is a good choice for use cases involving many fewer updates than reads.
- The collections in the package
java.util.concurrent
are intrinsically thread safe, providing a newer alternative to synchronized wrappers. In particular, iterator traversals are consistent without synchronization, but provide a snapshot view of the underlying collection which may be stale.
7.1.3 Immutable Collections
Immutable collections are instantiated with the static factory methods of()
and copyOf()
, available on super-interfaces. The method of()
takes 0 or more individual list elements, while copyOf()
takes a compatible collection:
var films = List.of("Citizen Kane", "Touch of Evil"); films = List.copyOf(films);
Java does not provide a distinct type hierarchy for immutable collections: the type returned by List.of()
or List.copyOf()
implements the same superinterface List
as its any mutable list class like LinkedList
. Consequently, immutable collection types expose the same mutator methods and calling these methods does not cause a compilation error.
films.add("The Trial") // Compiles, but throws exception at runtime.
This may be a source of confounding bugs, because the UnsupportedOperationException
exception thrown in this case, may not be thrown by all execution histories.
In another departure from consistency, immutable collections do not allow null
elements even though mutable collections do.
7.1.4. List
The following are the most frequently used concrete (mutable) List
implementations:
Concrete List Class | Description |
---|---|
java.util.ArrayList | List backed by an array. |
java.util.LinkedList | Doubly linked list. |
java.util.Stack | A LIFO queue. |
java.util.Vector | An early thread safe implementation of a list backed by an array. Obsolete. |
java.util.concurrent.CopyOnWriteArrayList | A tread safe list that is preferable to a synchronized ArrayList , particularly when updates are much less frequent than traversals. |
See section 7.1.2 for more on regarding thread safety.
7.1.5. Map
The following are the most frequently used concrete (mutable) Map
implementations:
java.util.HashMap | Map backed by a hash table. |
java.util.LinkedHashMap | Map backed by a hash table and a doubly-linked list, yielding predictable traversal in insertion order. |
java.util.TreeMap | Navigable map backed by a Red-Black tree. |
java.util.WeakHashMap | Map backed by hash table with weak keys: a key that is not referenced outside of the map will be removed by the GC. |
java.util.Hashtable | An early thread safe implementation of a list backed by a hash table. Obsolete. If thread safety is a concern use … |
java.util.concurrent.ConcurrentHashMap | Thread safe map backed by a hash table. |
java.util.concurrent.ConcurrentSkipListMap | Thread safe navigable map backed by skiplist. |
In additional to the traditional low-level map methods, Java maps support these higher order mutators (assuming the map is parameterized as Map<K,V>
):
V compute(K key, BiFunction<? super K,? super V, ?extends V> remappingFunction)
(Re)computes a mapping for the given key and its current mapped value. If the given key is absent, remappingFunction
will be passed null
, making it impossible to tell apart the case of a missing mapping and that of a present key mapped to null
. If such a distinction must be made, use computeIfPresent
or computeIfAbsent
variants.
void replaceAll(BiFunction<? super K,? super V,? extends V> function)
Same as compute
but is applied to all existing mapping.
7.1.6. Set
The following are the most frequently used concrete (mutable) Map
implementations:
java.util.HashSet | Set backed by an instance of HashTable . |
java.util.LinkedHashSet | Set backed by an instance of LinkedHashTable providing predictable traversal in insertion order. |
java.util.TreeSet | Navigable set backed by TreeMap . |
java.util.concurrent.ConcurrentSkipListSet | Navigable thread safe set backed by ConcurrentSkipListMap . |
java.util.concurrent.CopyOnWriteArraySet | Thread safe set backed by CopyOnWriteArrayList . |