Kotlin, the JVM’s rising star, isn’t just known for its conciseness and elegance. It’s also a powerful object-oriented language, packing a punch with its intuitive and modern take on OOP concepts. Whether you’re a seasoned Java veteran or a curious newbie, navigating Kotlin’s object-oriented playground can be both exciting and, well, a bit daunting.
But fear not, fellow programmer! This blog takes you on a guided tour of Kotlin’s OOP constructs, breaking down each element with practical examples and clear explanations. Buckle up, and let’s dive into the heart of Kotlin’s object-oriented magic!
BTW, What is Contruct?
The term “construct” is defined as a fancy way to refer to allowed syntax within a programming language. It implies that when creating objects, defining categories, specifying relationships, and other similar tasks in the context of programming, one utilizes the permissible syntax provided by the programming language. In essence, “language constructs” are the syntactic elements or features within the language that enable developers to express various aspects of their code, such as the creation of objects, organization into categories, establishment of relationships, and more.
In simple words, Language constructs are the specific rules and structures that are permitted within a programming language to create different elements of a program. They are essentially the building blocks that programmers use to express concepts and logic in a way that the computer can understand.
Kotlin Construct
Kotlin provides a rich set of language constructs that empower developers to articulate their programs effectively. In this section, we’ll explore several of these constructs, including but not limited to: Class Definitions, Inheritance Mechanisms, Abstract Classes, Interface Implementations, Object Declarations, and Companion Objects.
Classes
Classes serve as the fundamental building blocks in Kotlin, offering a template that encapsulates state, behavior, and a specific type for instances (more details on this will be discussed later). Defining a class in Kotlin requires only a name. For instance:
class VeryBasic
While VeryBasic may not be particularly useful, it remains a valid Kotlin syntax. Despite lacking state or behavior, instances of the VeryBasic type can still be declared, as demonstrated below:
fun main(args: Array<String>) {
val basic: VeryBasic = VeryBasic()
}
In this example, the basic
value is of type VeryBasic, indicating that it is an instance of the VeryBasic class. Kotlin’s type inference capability allows for a more concise declaration:
fun main(args: Array<String>) {
val basic = VeryBasic()
}
In this revised version, Kotlin infers the type of the basic
variable. As a VeryBasic instance, basic
inherits the state and behavior associated with the VeryBasic type, which, in this case, is none—making it a somewhat melancholic example.
Properties
As mentioned earlier, classes in Kotlin can encapsulate a state, with the class’s state being represented by properties. Let’s delve into the example of a BlueberryCupcake class:
class BlueberryCupcake {
var flavour = "Blueberry"
}
Here, the BlueberryCupcake class possesses a property named flavour
of type String. Instances of this class can be created and manipulated, as demonstrated in the following code snippet:
fun main(args: Array<String>) {
val myCupcake = BlueberryCupcake()
println("My cupcake has ${myCupcake.flavour}")
}
Given that the flavour
property is declared as a variable, its value can be altered dynamically during runtime:
fun main(args: Array<String>) {
val myCupcake = BlueberryCupcake()
myCupcake.flavour = "Almond"
println("My cupcake has ${myCupcake.flavour}")
}
In reality, cupcakes do not change their flavor, unless they become stale. To mirror this in code, we can declare the flavour
property as a value, rendering it immutable:
class BlueberryCupcake {
val flavour = "Blueberry"
}
Attempting to reassign a value to a property declared as a val
results in a compilation error, as demonstrated below:
fun main(args: Array<String>) {
val myCupcake = BlueberryCupcake()
myCupcake.flavour = "Almond" // Compilation error: Val cannot be reassigned
println("My cupcake has ${myCupcake.flavour}")
}
Now, let’s introduce a new class for almond cupcakes, the AlmondCupcake class:
class AlmondCupcake {
val flavour = "Almond"
}
Interestingly, both BlueberryCupcake and AlmondCupcake share identical structures; only the internal value changes. In reality, you don’t need different baking tins for distinct cupcake flavors. Similarly, a well-designed Cupcake class can be employed for various instances:
class Cupcake(val flavour: String)
The Cupcake class features a constructor with a flavour
parameter, which is assigned to the flavour
property. In Kotlin, to enhance readability, you can use syntactic sugar to define it more succinctly:
class Cupcake(val flavour: String)
This streamlined syntax allows us to create several instances of the Cupcake class with different flavors:
fun main(args: Array<String>) {
val myBlueberryCupcake = Cupcake("Blueberry")
val myAlmondCupcake = Cupcake("Almond")
val myCheeseCupcake = Cupcake("Cheese")
val myCaramelCupcake = Cupcake("Caramel")
}
In essence, this example showcases how Kotlin’s concise syntax and flexibility in property declaration enable the creation of classes representing real-world entities with ease.
Methods
In Kotlin, a class’s behavior is defined through methods, which are technically member functions. Let’s explore an example using the Cupcake class:
class Cupcake(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour cupcake"
}
}
In this example, the eat()
method is defined within the Cupcake class, and it returns a String value. To demonstrate, let’s call the eat()
method:
fun main(args: Array<String>) {
val myBlueberryCupcake = Cupcake("Blueberry")
println(myBlueberryCupcake.eat())
}
Executing this code will produce the following output:
nom, nom, nom... delicious Blueberry cupcake
While this example may not be mind-blowing, it serves as an introduction to methods. As we progress, we’ll explore more intricate and interesting aspects of defining and utilizing methods in Kotlin.
Inheritance
Inheritance is a fundamental concept that involves organizing entities into groups and subgroups and also establishing relationships between them. In an inheritance hierarchy, moving up reveals more general features and behaviors, while descending highlights more specific ones. For instance, a burrito and a microprocessor are both objects, yet their purposes and uses differ significantly.
Let’s introduce a new class, Biscuit:
class Biscuit(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour biscuit"
}
}
Remarkably, this class closely resembles the Cupcake class. To address code duplication, we can refactor these classes by introducing a common superclass, BakeryGood:
open class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour bakery good"
}
}
class Cupcake(flavour: String): BakeryGood(flavour)
class Biscuit(flavour: String): BakeryGood(flavour)
Here, both Cupcake and Biscuit extend BakeryGood, sharing its behavior and state. This establishes an is-a relationship, where Cupcake (and Biscuit) is a BakeryGood, and BakeryGood is the superclass.
Note the use of the open
keyword to indicate that BakeryGood is designed to be extended. In Kotlin, a class must be marked as open to enable inheritance.
The process of consolidating common behaviors and states in a parent class is termed generalization. However, our initial attempt encounters unexpected results when calling the eat() method with a reference to BakeryGood:
fun main(args: Array<String>) {
val myBlueberryCupcake: BakeryGood = Cupcake("Blueberry")
println(myBlueberryCupcake.eat())
}
To refine this behavior, we modify the BakeryGood class to include a name() method:
open class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
open fun name(): String {
return "bakery good"
}
}
class Cupcake(flavour: String): BakeryGood(flavour) {
override fun name(): String {
return "cupcake"
}
}
class Biscuit(flavour: String): BakeryGood(flavour) {
override fun name(): String {
return "biscuit"
}
}
Now, calling the eat() method produces the expected output:
nom, nom, nom... delicious Blueberry cupcake
Here, the process of extending classes and overriding behavior in a hierarchy is called specialization. A key guideline is to place general states and behaviors at the top of the hierarchy (generalization) and specific states and behaviors in subclasses (specialization).
We can further extend subclasses, such as introducing a new Roll class:
open class Roll(flavour: String): BakeryGood(flavour) {
override fun name(): String {
return "roll"
}
}
class CinnamonRoll: Roll("Cinnamon")
Subclasses, like CinnamonRoll, can be extended as well, marked as open. We can also create classes with additional properties and methods, exemplified by the Donut class:
open class Donut(flavour: String, val topping: String) : BakeryGood(flavour) {
override fun name(): String {
return "donut with $topping topping"
}
}
fun main(args: Array<String>) {
val myDonut = Donut("Custard", "Powdered sugar")
println(myDonut.eat())
}
This flexibility in inheritance and specialization allows for a versatile and hierarchical organization of classes in Kotlin.
Abstract classes
Up to this point, our bakery model has been progressing smoothly. However, a potential issue arises when we realize we can instantiate the BakeryGood
class directly, making it too generic. To address this, we can mark BakeryGood
as abstract:
abstract class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
abstract fun name(): String
}
By marking it as abstract, we ensure that BakeryGood
can’t be instantiated directly, resolving our concern. The abstract
keyword denotes that the class is intended solely for extension, and it cannot be instantiated on its own.
The distinction between abstract
and open
lies in their instantiation capabilities. While both modifiers allow for class extension, open
permits instantiation, whereas abstract
does not.
Now, given that we can’t instantiate BakeryGood
directly, the name()
method in the class becomes less useful. Most subclasses, except for CinnamonRoll
, override it. Therefore, we redefine the BakeryGood
class:
abstract class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
abstract fun name(): String
}
Here, the name()
method is marked as abstract, lacking a body, only declaring its signature. Any class directly extending BakeryGood
must implement (override) the name()
method.
Let’s introduce a new class, Customer
, representing a bakery customer:
class Customer(val name: String) {
fun eats(food: BakeryGood) {
println("$name is eating... ${food.eat()}")
}
}
The eats(food: BakeryGood)
method accepts a BakeryGood
parameter, allowing any instance of a class that extends BakeryGood
, regardless of hierarchy levels. It’s important to note that we can’t instantiate BakeryGood
directly.
Consider the scenario where we want a simple BakeryGood
instance, like for testing purposes. An alternative approach is using an anonymous subclass:
fun main(args: Array<String>) {
val mario = Customer("Mario")
mario.eats(object : BakeryGood("TEST_1") {
override fun name(): String {
return "TEST_2"
}
})
}
Here, the object
keyword introduces an object expression, defining an instance of an anonymous class that extends a type. The anonymous class must override the name()
method and pass a value for the BakeryGood
constructor, similar to how a standard class would.
Additionally, an object expression can be used to declare values:
val food: BakeryGood = object : BakeryGood("TEST_1") {
override fun name(): String {
return "TEST_2"
}
}
mario.eats(food)
This demonstrates how Kotlin’s flexibility with abstract classes, inheritance, and anonymous subclasses allows for a versatile and hierarchical organization of classes in a bakery scenario.
Interfaces
Creating hierarchies is effectively facilitated by open and abstract classes, yet their utility has limitations. In certain cases, subsets may bridge seemingly unrelated hierarchies. Take, for instance, the bipedal nature shared by birds and great apes; both belong to the categories of animals and vertebrates, despite lacking a direct relationship. To address such scenarios, Kotlin introduces interfaces as a distinct construct, recognizing that other programming languages may handle this issue differently.
While our bakery goods are commendable, their preparation involves an essential step: cooking. The existing code employs an abstract class named BakeryGood
to define various baked products, accompanied by methods like eat()
and bake()
.
abstract class BakeryGood(val flavour: String) {
fun eat(): String {
return "nom, nom, nom... delicious $flavour ${name()}"
}
fun bake(): String {
return "is hot here, isn't??"
}
abstract fun name(): String
}
However, a complication arises when considering items like donuts, which are not baked but fried. One potential solution is to move the bake()
method to a separate abstract class named Bakeable
.
abstract class Bakeable {
fun bake(): String {
return "is hot here, isn't??"
}
}
By doing so, the code attempts to address the issue and introduces a class called Cupcake
that extends both BakeryGood
and Bakeable
. Unfortunately, Kotlin imposes a restriction, allowing a class to extend only one other class at a time. This limitation prompts the need for an alternative approach.
The subsequent code explores a different strategy to resolve this limitation, emphasizing the intricate nature of class extension in Kotlin.
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable() { // Compilation error: Only one class // may appear in a supertype list
override fun name(): String {
return "cupcake"
}
}
The above code snippets illustrate the attempt to reconcile the challenge of combining the BakeryGood
and Bakeable
functionalities in a single class, highlighting the restrictions imposed by Kotlin’s class extension mechanism.
Kotlin doesn’t allow a class to extend multiple classes simultaneously. Instead, we can make Cupcake
extend BakeryGood
and implement the Bakeable
interface:
interface Bakeable {
fun bake(): String {
return "It's hot here, isn't it??"
}
}
An interface named Bakeable
is defined with a method bake()
that returns a string. Interfaces in Kotlin define a type that specifies behavior, such as the bake()
method in the Bakeable
interface.
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable {
override fun name(): String {
return "cupcake"
}
}
A class named Cupcake is created, which extends both BakeryGood and implements the Bakeable interface. It has a method name() that returns “cupcake.”
Now, let’s highlight the similarities and differences between open/abstract classes and interfaces:
Similarities
- Both are types with an is-a relationship.
- Both define behaviors through methods.
- Neither abstract classes nor interfaces can be instantiated directly.
Differences
- A class can extend just one open or abstract class but can extend many interfaces.
- An open/abstract class can have constructors, whereas interfaces cannot.
- An open/abstract class can initialize its own values, whereas an interface’s values must be initialized in the classes that implement the interface.
- An open class must declare methods that can be overridden as open, while an abstract class can have both open and abstract methods.
- In an interface, all methods are open, and a method with no implementation doesn’t need an abstract modifier.
Here’s an example demonstrating the use of an interface and an open class:
interface Fried {
fun fry(): String
}
open class Donut(flavour: String, val topping: String) : BakeryGood(flavour), Fried {
override fun fry(): String {
return "*swimming in oil*"
}
override fun name(): String {
return "donut with $topping topping"
}
}
When choosing between an open class, an abstract class, or an interface, consider the following guidelines:
- Use an open class when the class should be both extended and instantiated.
- Use an abstract class when the class can’t be instantiated, a constructor is needed, or there is initialization logic (using init blocks).
- Use an interface when multiple inheritances must be applied, and no initialization logic is needed.
It’s recommended to start with an interface for a more straightforward and modular design. Move to abstract or open classes when data initialization or constructors are required.
Finally, object expressions can also be used with interfaces:
val somethingFried = object : Fried {
override fun fry(): String {
return "TEST_3"
}
}
This showcases the flexibility of Kotlin’s object expressions in conjunction with interfaces.
Objects
Objects in Kotlin serve as natural singletons, meaning they naturally come as language features and not just as implementations of behavioral patterns seen in other languages. In Kotlin, every object is a singleton, presenting interesting patterns and practices, but they can also be risky if misused to maintain global state.
Object expressions are a way to create singletons, and they don’t need to extend any type. Here’s an example:
fun main(args: Array<String>) {
val expression = object {
val property = ""
fun method(): Int {
println("from an object expression")
return 42
}
}
val i = "${expression.method()} ${expression.property}"
println(i)
}
In this example, the expression
value is an object that doesn’t have any specific type. Its properties and functions can be accessed as needed.
However, there is a restriction: object expressions without a type can only be used locally, inside a method, or privately, inside a class. Here’s an example demonstrating this limitation:
class Outer {
val internal = object {
val property = ""
}
}
fun main(args: Array<String>) {
val outer = Outer()
println(outer.internal.property) // Compilation error: Unresolved reference: property
}
In this case, trying to access the property
value outside the Outer
class results in a compilation error.
It’s important to note that while object expressions provide a convenient way to create singletons, their use should be considered carefully. They are especially useful for coordinating actions across the system, but if misused to maintain global state, they can lead to potential issues. Careful consideration of the design and scope of objects in Kotlin is crucial to avoid unintended consequences.
Object Declaration
An object declaration is a way to create a named singleton:
object Oven {
fun process(product: Bakeable) {
println(product.bake())
}
}
In this example, Oven
is a named singleton. It’s a singleton because there’s only one instance of Oven
, and it’s named as an object declaration. You don’t need to instantiate Oven
to use it.
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake("Almond")
Oven.process(myAlmondCupcake)
}
Here, an instance of the Cupcake
class is created, and the Oven.process
method is called to process the myAlmondCupcake
. Objects, being singletons, allow you to access their methods directly without instantiation.
Objects Extending Other Types
Objects can also extend other types, such as interfaces:
interface Oven {
fun process(product: Bakeable)
}
object ElectricOven : Oven {
override fun process(product: Bakeable) {
println(product.bake())
}
}
In this case, ElectricOven
is an object that extends the Oven
interface. It provides an implementation for the process
method defined in the Oven
interface.
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake("Almond")
ElectricOven.process(myAlmondCupcake)
}
Here, an instance of Cupcake
is created, and the ElectricOven.process
method is called to process the myAlmondCupcake
.
In short, object declarations are a powerful feature in Kotlin, allowing the creation of singletons with or without names. They provide a clean and concise way to encapsulate functionality and state, making code more modular and maintainable.
Companion objects
Objects declared inside a class/interface and marked as companion object
are called companion objects. They are associated with the class/interface and can be used to define methods or properties that are related to the class as a whole.
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable {
override fun name(): String {
return "cupcake"
}
companion object {
fun almond(): Cupcake {
return Cupcake("almond")
}
fun cheese(): Cupcake {
return Cupcake("cheese")
}
}
}
In this example, the Cupcake
class has a companion object with two methods: almond()
and cheese()
. These methods can be called directly using the class name without instantiating the class.
fun main(args: Array<String>) {
val myBlueberryCupcake: BakeryGood = Cupcake("Blueberry")
val myAlmondCupcake = Cupcake.almond()
val myCheeseCupcake = Cupcake.cheese()
val myCaramelCupcake = Cupcake("Caramel")
}
Here, various instances of Cupcake
are created using the companion object’s methods. Note that Cupcake.almond()
and Cupcake.cheese()
can be called without creating an instance of the Cupcake
class.
Limitation on Usage from Instances
Companion object’s methods can’t be used from instances:
fun main(args: Array<String>) {
val myAlmondCupcake = Cupcake.almond()
val myCheeseCupcake = myAlmondCupcake.cheese() // Compilation error: Unresolved reference: cheese
}
In this example, attempting to call cheese()
on an instance of Cupcake
results in a compilation error. Companion object’s methods are meant to be called directly on the class, not on instances.
Using Companion Objects Outside the Class
Companion objects can be used outside the class as values with the name Companion
:
fun main(args: Array<String>) {
val factory: Cupcake.Companion = Cupcake.Companion
}
Here, Cupcake.Companion
is used as a value. It’s a way to reference the companion object outside the class.
Named Companion Objects
A companion object can also have a name:
class Cupcake(flavour: String) : BakeryGood(flavour), Bakeable {
override fun name(): String {
return "cupcake"
}
companion object Factory {
fun almond(): Cupcake {
return Cupcake("almond")
}
fun cheese(): Cupcake {
return Cupcake("cheese")
}
}
}
Now, the companion object has a name, Factory
. This allows for a more structured and readable organization of companion objects.
fun main(args: Array<String>) {
val factory: Cupcake.Factory = Cupcake.Factory
}
Here, Cupcake.Factory
is used as a value, referencing the named companion object.
Usage Without a Name
Companion objects can also be used without a name:
fun main(args: Array<String>) {
val factory: Cupcake.Factory = Cupcake
}
In this example, Cupcake
without parentheses refers to the companion object itself. This usage is equivalent to Cupcake.Factory
and can be seen as a shorthand syntax.
Don’t be confused by this syntax. The Cupcake value without parenthesis is the companion object; Cupcake() is an instance.
Conclusion
Kotlin’s support for object-oriented programming constructs empowers developers to build robust, modular, and maintainable code. With features like concise syntax, interoperability with Java, and modern language features, Kotlin continues to be a top choice for developers working on a wide range of projects, from mobile development to backend services. As we’ve explored in this guide, Kotlin’s OOP constructs provide a solid foundation for creating efficient and scalable applications.Kotlin’s language constructs are more than just features; they’re a philosophy. They encourage conciseness, expressiveness, and safety, making your code a joy to write. So, take your first step into the Kotlin world, and prepare to be amazed by its magic!