For most projects related to server-side software development, Cloudflight prefers to use Kotlin over Java. This has a multitude of reasons, some of which I'll describe in this blog post.
A plain old Java object according to Kotlin
When transferring data from one place to another with Java, it's common to use a plain old java object (POJO). Usually, this is an object with some properties and accessor methods.
Kotlin provides means to reduce the amount of code used in these objects. In this example, we'll take a POJO, and convert it into a Kotlin class.
Plain old Java objects
Let's start with a UserDto
, for now, it will only contain a few fields, and
accessor methods for those fields. It could look as follows:
public class UserDto {
private Integer id;
private String name;
private String email;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
If you are using IntelliJ IDEA and would copy this code into a Kotlin file, we'll get a notification asking if the Java code should be converted to Kotlin code. After clicking yes, we get the following code.
class UserDto {
var id: Int? = null
var name: String? = null
var email: String? = null
}
The piece of Kotlin code is 25 lines shorter compared to the Java code. All the accessor methods can be omitted by the syntax of Kotlin.
Constructors
To be able to instantiate a fully initialized copy of the earlier defined Java object we would need to add a constructor. If we would make one for all our arguments, it would look as follows:
public class UserDto {
public UserDto(
id: Integer,
name: String,
email: String
) {
this.id = id;
this.name = name;
this.email = email;
}
...
}
Adding the constructor in Java would add more code. if we add more properties to the class, we would have to update the constructor as well.
Meanwhile, on the Kotlin side, we can update our class as follows to add a constructor.
class UserDto(
var id: Int? = null,
var name: String? = null,
var email: String? = null
)
By replacing the braces with parentheses, we added a constructor to the class, without adding a line of code. Both constructors are called the same way in both Java and Kotlin code.
Equals, Hashcode, ToString
Commonly, Java classes use some boilerplate methods as well. This would add
more code to our UserDto
class.
public class UserDto {
...
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
UserDto userDto = (UserDto) o;
return id.equals(userDto.id) && name.equals(userDto.name) && email.equals(userDto.email);
}
@Override
public int hashCode() {
return Objects.hash(id, name, email);
}
@Override
public String toString() {
return "UserDto{" +
"id=" + id +
", name='" + name + '\'' +
", email='" + email + '\'' +
'}';
}
}
Similar to the constructor, when updating our class, we would need to update these methods as well.
Kotlin again provides a solution for this with only one line change. Kotlin
introduces data classes. Data classes define a handful of extra methods for us
(equals
, hashCode
, toString
and copy
). If we would use a data class to
give us these methods, our class will look as follows.
data class UserDto(
var id: Int? = null,
var name: String? = null,
var email: String? = null
)
But all of this code can be generated by any modern IDE. Why would I use Kotlin for this?
The resulting Java version has a whopping 63 lines of code, and 63 lines of maintenance. Meanwhile, the Kotlin version has only 5 lines. Leading to a codebase that's easier to navigate, has fewer surprises and is easier to maintain.
I took a quick look at a relatively small project and found 35 similar classes to the freshly refactored class. I'd estimate that using Kotlin in this project would save 4000 lines of code and just POJO's.
Safer and more expressive code
The following section will refactor a function from a Java-looking function, into a Kotlin function, using features Kotlin provides us to write safe and more expressive code.
First, we define a basic tree. A tree is either a branch with multiple Tree
items or a leaf with one value in it. Here is the Java version:
interface Tree<T> {
}
class Branch<T> implements Tree<T> {
public Branch(List<Tree<T>> nodes) {
this.nodes = nodes;
}
List<Tree<T>> nodes;
public List<Tree<T>> getNodes() {
return nodes;
}
}
class Leaf<T> implements Tree<T> {
public Leaf(T value) {
this.value = value;
}
T value;
public T getValue() {
return value;
}
}
In Kotlin it would look as follows:
interface Tree<T>
class Branch<T>(val nodes: List<Tree<T>>) : Tree<T>
class Leaf<T>(val value: T) : Tree<T>
Next, we write a function to refactor into Kotlin. For this example, I made a function to sum all numbers in the tree. In Java you could make it like this:
class TreeUtil {
static Integer sumTree(Tree<Integer> tree) {
Integer count;
if (tree instanceof Branch) {
var branch = (Branch<Integer>) tree;
count = 0;
for (var node : branch.getNodes()) {
count += sumTree(node);
}
} else if (tree instanceof Leaf) {
var leaf = (Leaf<Integer>) tree;
count = leaf.getValue();
} else {
throw new IllegalArgumentException("Unknown variant");
}
return count;
}
}
Directly translating this to Kotlin gives me the following:
fun sumTree(tree: Tree<Int>): Int {
var count: Int
if (tree is Branch) {
val branch = tree as Branch
count = 0
for (node in branch.nodes) {
count += sumTree(node)
}
} else if (tree is Leaf) {
val leaf = tree as Leaf
count = leaf.value
} else {
throw IllegalArgumentException("Unknown variant")
}
return count
}
Now let's apply some of Kotlin's features to make this function more readable and expressive.
Expressions
A common pattern in Java is to define a variable set to null and update it during an if statement. In Kotlin, if statements are expressions, this allows us to return values from the if statement instead of mutating it during the if statement. Allowing us to refactor the method as follows.
fun sumTree(tree: Tree<Int>): Int = if (tree is Branch) {
val branch = tree as Branch
var count = 0
for (node in branch.nodes) {
count += sumTree(node)
}
count
} else if (tree is Leaf) {
val leaf = tree as Leaf
leaf.value
} else {
throw IllegalArgumentException("Unknown variant")
}
Instead of returning a value, we can return the if statement as a whole, making for less noise code.
Smart casting
Type casting an object in Java requires two steps. First, a check needs to be made if the type matches the expected one, then the value needs to be cast into another type. Kotlin introduces smart casting here. After checking the type, the compiler already knows the actual type, so here we could omit the cast. When using smart casting, our code would look like this.
fun sumTree(tree: Tree<Int>): Int = if (tree is Branch) {
var count = 0
for (node in tree.nodes) {
count += sumTree(node)
}
count
} else if (tree is Leaf) {
tree.value
} else {
throw IllegalArgumentException("Unknown variant")
}
After calling the type check, tree
becomes either a Branch
or a Leaf
implicitly, allowing us to remove the unsafe typecast and directly access the
properties from the tree
variable.
Pattern matching
Kotlin allows us to pattern match for a type, then we could check the type without adding cases to an if statement, refactoring our code to the following.
fun sumTree(tree: Tree<Int>): Int = when (tree) {
is Branch -> {
var count = 0
for (node in tree.nodes) {
count += sumTree(node)
}
count
}
is Leaf -> tree.value
else -> throw IllegalArgumentException("Unknown variant")
}
The when
statement in Kotlin is similar to the switch
in Java, but it
provides some superpowers compared to switch
, primarily that we can define a
condition for each branch instead of only checking equality.
Sealed classes
Kotlin introduces sealed classes, all implementors of a sealed class must be in the same module as the sealed class itself. We can make our tree a sealed class in the following manner:
sealed interface Tree<T>
Now the compiler knows all possible types a tree could be, allowing us to safely remove the else clause from the when statement.
fun sumTree(tree: Tree<Int>): Int = when (tree) {
is Branch -> {
var count = 0
for (node in tree.nodes) {
count += sumTree(node)
}
count
}
is Leaf -> tree.value
}
If we would remove the sealed modifier, the program will not compile because
the pattern match is not exhaustive. Because only two versions of Tree
are
possible, we only need to check for those types.
Functional abstraction
To clean up the last part of the Java code, we could rewrite the iteration of all nodes in the branch into something more readable.
fun sumTree(tree: Tree<Int>): Int = when (tree) {
is Branch -> tree.nodes.sumOf { node -> sumTree(node) }
is Leaf -> tree.value
}
The sumOf
method is a standard library method turning iterable into an int.
If we would write it ourselves it could look like this:
fun <T> Iterable<T>.sumOf(operation: (T) -> Int): Int {
var sum = 0
for (item in this) {
sum += operation(item)
}
return sum
}
A few things are happening here:
- We are defining an extension method, even though
Iterable
is not in our class, we can write functions for it. This is not possible in Java at all. - The parameter
operation
is a function that expects typeT
and returns aInt
.
Using the tools that Kotlin provides, the Java-like function is rewritten into 4 lines of expressive Kotlin code.
Other features
Next, some other features of Kotlin.
Compile-time null safety
One of the primary selling points for Kotlin is compile-time null safety. For a variable to be null, it needs to be defined as nullable. Nullable variables need to be handled or checked. This is one of the primary defects in Java systems.
In Kotlin, a parameter can be assigned as nullable as follows:
fun sumTwoNumbers(n1: Int?, n2: Int?): Int {
return n1 + n2 // Will not compile because n1 or n2 could be null
}
This example will not compile. Both n1
and n2
could be null. To make it
compile, we need to add some checks or have different behavior for null values.
Using smart-casting, we can safely add the values.
fun sumTwoNumbers(n1: Int?, n2: Int?): Int {
if (n1 == null || n2 == null) throw Exception("Invalid value passed")
return n1 + n2
}
Interoperability with Java
Kotlin was designed to be fully cross-compatible with JVM Java. This allows us
to define logic or structures in either Kotlin or Java code, and call them from
both Kotlin and Java code. I'll take the earlier created UserDto
as an
example.
data class UserDto(var id: Int, var name: String, var email: String)
With Gradle or Maven properly configured, we can call this code the Java side in an idiomatically correct manner. It would look at follows:
void main() {
var user = new UserDto(1, "Cloudflight", "info@cloudflight.io");
System.out.println(user.toString()); // UserDto(id=1, name=Cloudflight, email=info@cloudflight.io)
System.out.println(user.getId()); // 1
}
Preferred immutability
Java includes a final
keyword, which disallows a variable from being
reassigned. A small example is shown below.
void main() {
var someMutableValue = "The first value";
someMutableValue = "The value is updated"
final var someImmutableValue = "The first value"
someImmutableValue = "Will not compile now"
}
The final
keyword does add noise to the code. Kotlin has a much more subtile
approach to this.
fun main() {
var someMutableValue = "The first value";
someMutableValue = "The value is updated"
val someImmutableValue = "The first value"
someImmutableValue = "Will not compile now"
}
Kotlin has two keywords for defining variables, val
and var
. val
is used
for immutable variables, while var
is for mutable variables.
This pattern can also be found in the Kotlin standard library.
Due to historical reasons, most collections in Java have a method named add
,
which adds an item to the collection. This includes immutable lists. These
usually throw an UnsupportedOperationException
at runtime when called.
void main() {
var list = new ArrayList();
list.add(1);
list.add(2);
var immutableList = List.of();
immutableList.add(1); // Throws an UnsupportedOperationException at runtime
}
In Kotlin, collections are immutable by default. To create a mutable list
mutableListOf
should be called, and to create an immutable list, listOf
is
called. An immutable list has no to add items to a collection, this could
prevent runtime defects.
fun main() {
var list = mutableListOf()
list.add(1)
list.add(2)
var immutableList = listOf()
immutableList.add(1) // Will not compile here
}
Internal visibility
Kotlin introduces a new visibility keyword, internal
. This allows code to be
visible in the following cases:
- The internal code can be called within the same Maven project.
- The internal code can be called within the same Gradle source set.
- The internal code can be called by code compiled with the same
kotlinc
invocation.
This helps with modularizing codebases when working with separate modules.
Clarifications of some risks
There are a few points commonly made on why switching to Kotlin is a bad idea. This section explains why these points are not as strong as commonly thought of.
Is it possible to find Kotlin developers?
According to the StackOverflow developer survey 2022, 9.2% of developers work with Kotlin compared to 33.27% who work with Java. The survey also finds that at least 10% of these developers want to work with Kotlin instead of Java.
Turning a Java developer into a Kotlin developer is easy. Writing Kotlin compared to writing Java is very similar. Both are usually run in the same environment as well. Both for development and execution. Together with the better code safety of Kotlin, a Java developer can quickly write good Kotlin code.
Is Kotlin future-proof?
At I/O 2019, Google announced that Kotlin is going to be the preferred programming language for all Android apps. This shows that Google is certain Kotlin will stay and Kotlin stays for a long time to come.
According to Google, 80% of the top 1000 android apps use Kotlin as a programming language.
Conclusion
So, a few of the reasons why Cloudflight (and other companies) prefer to use Kotlin over Java for server-side app development.