Press "Enter" to skip to content

A Tour de Force of Modern Java for Scala Programmers
Covers Java 17

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 of Object. In addition to the public members inherited from Object, like toString() or clone(), Java arrays also expose the final field length.
  • Just like in Scala, Java arrays cannot be extended. In Scala, the Array class is final and explicitly implements Clonable and Serializable. Java arrays cannot be extended because they are created with special syntax which doesn’t allow the regular class inheritance constructs. Java arrays also implement Clonable and Serializable, 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 of Pet[] if and only if Fish is a subclass of Pet. 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:

InterfaceCorresponding Lambda Expression
*ConsumerFunctions returning void
*SupplierNullary functions to some return type.
*FunctionNon-nullary functions to some return type.
*PredicateNon-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 with equals()
  • 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.

Java Collections Type Hierarchy

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 ClassDescription
java.util.ArrayListList backed by an array.
java.util.LinkedListDoubly linked list.
java.util.StackA LIFO queue.
java.util.VectorAn early thread safe implementation of a list backed by an array. Obsolete.
java.util.concurrent.CopyOnWriteArrayListA 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.HashMapMap backed by a hash table.
java.util.LinkedHashMapMap backed by a hash table and a doubly-linked list, yielding predictable traversal in insertion order.
java.util.TreeMapNavigable map backed by a Red-Black tree.
java.util.WeakHashMapMap 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.HashtableAn early thread safe implementation of a list backed by a hash table. Obsolete. If thread safety is a concern use …
java.util.concurrent.ConcurrentHashMapThread safe map backed by a hash table.
java.util.concurrent.ConcurrentSkipListMapThread 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.HashSetSet backed by an instance of HashTable.
java.util.LinkedHashSetSet backed by an instance of LinkedHashTable providing predictable traversal in insertion order.
java.util.TreeSetNavigable set backed by TreeMap.
java.util.concurrent.ConcurrentSkipListSetNavigable thread safe set backed by ConcurrentSkipListMap.
java.util.concurrent.CopyOnWriteArraySetThread safe set backed by CopyOnWriteArrayList.