One of the key differences between the object-oriented model in Kotlin and Java is the definition of static methods and classes. Generally speaking, a static class doesn’t have to be instantiated in order to be used. Similarly, to invoke a static method, we just have to know its name, not to instantiate an object of the class where such a method is defined.
In Java, classes may easily declare static fields or methods. However, the definition of a static class is a bit more complex. Kotlin, on the other hand, greatly simplifies the job with native constructs such as package-level functions, object
s, and companion object
s.
In this article, we are first going to look at the legacy Java way of declaring static classes and methods. Secondly, we’ll see how the same thing can be achieved, with much less effort, in Kotlin. Then, we’ll compare the benefits of Kotlin’s way with respect to code reusability.
Table of contents:
In object-oriented programming (OOP), static methods and fields are useful to model common values or operations that do not need to be tied to an instance of a class. For example, the Math
class contains several static fields and methods for common constants (such as pi) and mathematical operations (such as max and min):
public final class Math { public static final double E = 2.7182818284590452354; public static final double PI = 3.14159265358979323846; public static int max(int a, int b) { ... } public static int min(int a, int b) { ... } }
Static classes, on the other hand, are more tricky to define. In Java, only nested classes (that is, classes defined within another class) can be declared static. They do not need a reference to the outer class (the class they’re declared within). Hence, while we may not instantiate non-static nested classes without an instance of the outer class, static classes are independent.
Furthermore, the class loader loads the code of static classes when they are first used and not when the enclosing class gets loaded. This allows us to reduce the memory footprint of our application.
For example, we may want to use static classes to implement a thread-safe singleton class without paying the price of synchronizing the getInstance
method:
public class Singleton { private Singleton(){ } private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return SingletonHolder.INSTANCE; } // … }
In the example above, using a static class to hold a reference to the singleton instance gives us some nice properties:
SingletonHolder
the first time it’s accessed (via Singleton::getInstance()
);SingletonHolder::INSTANCE
gets instantiated immediately before the first usage;SingletonHolder::INSTANCE
can be declared final
even though it’s lazily initialized;Besides performance reasons, static classes are often used to improve the readability and the maintainability of the code, as we can use them to move components closer to where they are used.
As Java, Kotlin allows us to define nested classes in our code. In Kotlin, however, a nested class is static by default. That is, by default nested classes in Kotlin do not hold a reference to the enclosing class:
class Car(val model: String) { class Engine(val fuel: String) } fun main() { val engine = Car.Engine("Gasoline") val car = Car("SomeModel") println(engine.fuel) println(car.model) }
In the example above, we defined a nested class Engine
inside a class Car
. As we can see, we can instantiate Car
and Engine
separately. In particular, we create an instance of Engine
without providing a reference to an object of Car
. The only clue that Engine
is defined inside Car
is its qualified name, Car.Engine
. The example above prints Gasoline
and SomeModel
.
Static classes in Kotlin can access the properties of the companion object of the enclosing class. We’ll see more about companion object
s below.
If we want to define a nonstatic nested class in Kotlin we have to declare it as inner
:
class Car(val model: String) { inner class Engine(val fuel: String) { val forModel = [email protected] } } fun main() { val engine = Car("SomeModel").Engine("Gasoline") println("${engine.forModel} - ${engine.fuel}") }
Now that Engine
is marked as inner
, we have to create an instance of Car
and use it to instantiate Engine
. From within Engine
, we can reference the outer object using this@Car
. The example prints SomeModel - Gasoline
.
Similar to Java, nested classes can be declared also in the scope of a method of the enclosing class. In this case, the new class would be a local type.
The main benefit of Kotlin’s approach is that it limits the risks of memory leaks, by default. In Java, it is easier to overlook the fact that a given nested class holds a reference to the enclosing class. In Kotlin, on the other hand, such a reference does not exist by default.
Whether to use an inner class or a static one largely depends on the way we’re modeling our domain. Surely, static classes allow for greater code reusability, as we do not need to instantiate the enclosing class, while letting us define (possibly) dependent components close to one another.
Kotlin greatly simplifies how we can define static methods or variables. In particular, it does so using (companion) object
s and package-level functions.
Kotlin is not exclusively an object-oriented language because it also supports the functional programming paradigm: this is where package-level functions come from. As the name suggests, they are functions (or members) that do not belong to a given class but are instead defined within a package. Often, they are utility functions that are independent of any other class.
For example, we can use them to implement handy functions to initialize a class. Assume we have a class named Point
to model a point in the Cartesian plane:
package com.logrocket.blog
class Point(val x: Int, val y: Int) { override fun toString(): String = "Point($x, $y)" }
Then, in a different package, we might define the following functions:
// In file factory.kt package com.logrocket.blog.utils import com.logrocket.blog.Point val centerPoint = Point(x = 0, y = 0) fun onXAxis(x: Int) = Point(x, y = 0) fun onYAxis(y: Int) = Point(x = 0, y)
We can then use the functions and values above just by importing them:
package com.logrocket.blog import com.logrocket.blog.utils.centerPoint import com.logrocket.blog.utils.onXAxis fun main() { val point = onXAxis(5) println(centerPoint) println(point) }
The main function above prints the strings Point(0, 0)
and Point(5, 0)
.
Note how we defined two package-level functions in the com.logrocket.blog.utils
package, onXAxis
and onYAxis
. We also defined a package-level value, centerPoint
. Both the functions and the value can be accessed without any references to any enclosing classes, as we’d have done in Java: we just have to import them.
Package-level functions and values are syntactic sugar for static fields and methods in Java. What the Kotlin compiler does is generate a Java class named after the Kotlin file, with static methods and fields in it. For example, the functions in com.logrocket.blog.utils.factory.kt
will be compiled into a class named com.logrocket.blog.utils.FactoryKt
(where the name of the class is built using the name of the file and Kt,
in PascalCase):
package com.logrocket.blog.utils // Generated class class FactoryKt { public static Point centerPoint = new Point(0, 0); public static Point onXAxis(int x) { return new Point(x, 0); } public static Point onYAxis(int y) { return new Point(0, y); } }
If we wanted to change the name of the generated Java class, we could use the @JvmName
annotation. For example, if we place the annotation @file:JvmName("PointFactory")
at the beginning of factory.kt, the generated class will be named PointFactoryKt
instead of FactoryKt
. Such an annotation must appear before the package
directive.
Lastly, if we want more utility functions to be compiled into the same generated Java class, or if we already have a file named pointfactory.kt
, we can use the @JvmMultifileClass
annotation. This way, the compiler will generate a Java façade class with the specified name and all the declarations from all the Kotlin files with the same JvmName
.
By declaring an object
in Kotlin we define a singleton, that is, a class with only one instance. Such an instance is created lazily, the first time it’s used, in a thread-safe manner.
For example, we could define the following object
to group the functions and values we defined above:
object PointFactory { val center = Point(x = 0, y = 0) fun onXAxis(x: Int) = Point(x, y = 0) fun onYAxis(y: Int) = Point(x = 0, y) }
Then, differently than before, we have to specify the name of the object
to access its functions. In other words, an object
defines a scope:
val point = PointFactory.onYAxis(5)
As there’s only one instance of each Kotlin object
, the qualified name of the object
is enough to access its members. This is similar to but slightly different than a Java class consisting of static methods or variables only. In the latter case, we could instantiate that Java class as many times as we wanted (assuming the constructor is not private
). In that case, the static variables would be the same for each different instance of the class. With Kotlin object
s, on the other hand, we only have one instance.
In the example above, PointFactory
is pretty tied to the Point
class, as it contains several methods to instantiate a point. For cases like this, we can make it a companion object
, making this tight coupling more explicit:
class Point(val x: Int, val y: Int) { companion object { val center = Point(x = 0, y = 0) fun onXAxis(x: Int) = Point(x, y = 0) fun onYAxis(y: Int) = Point(x = 0, y) } override fun toString(): String = "Point($x, $y)" }
With companion object
s, we can declare methods and tie them to a given class, rather than to its instances. As for “normal” object
s, companion object
s are singletons. Hence, we can reference the functions by specifying the name of the class:
val point = Point.onYAxis(5)
Kotlin provides us with three different solutions to define static methods or fields.
Package-level functions and values are the most idiomatic way. Often there’s no need to scope utility methods inside a class. In such cases, package-level members are a fine choice allowing for greater reusability of the code. As a matter of fact, most of the standard library is implemented using them.
However, object
s and companion object
s do have some pros. For example, they allow for a better scoping of methods and fields. One of the main cons of package-level members is that they pollute the auto-completion suggestions available in most IDEs, making it more difficult to pick the right function. The scope of an object solves this issue.
Strictly speaking, in a pure object-oriented programming mindset, everything is better defined inside of a class. However, as we saw above, often we need methods that are different to place in an existing class. This can happen, for example, with utility methods that operate on a class but do not represent the behavior of that class.
In languages like Java, the normality is to define Utils
or Helper
classes full of the methods that are different to scope in a certain class. This gets easily out of control and leads to classes with different responsibilities and heterogeneous methods that are very difficult to read, maintain, and re-use.
Kotlin, on the hand, is not just an object-oriented language. It supports other programming paradigms, such as the functional one. Hence, it does not take the object orientation as strictly as Java, allowing us, for example, to define functions that are not tied to any class.
On the one hand, this improves the reusability and the maintainability of the code. Furthermore, we can use the package structure and the visibility keywords to choose which portion of our codebase can use a given function or object
. Better still, with companion object
s, we can define utility code as close as possible to the class it operates on. On the other hand, we ought to pay attention to the freedom and flexibility of Kotlin’s approach. For example, nothing prevents us from defining a mutable package-level variable, essentially a global one, which can be very harmful.
As is common with modern programming languages, we have a number of ways to model the same thing and achieve the same result. Hence, it is always a matter of experience and sensibility to figure out what the right construct is and to use it appropriately.
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.