Mastering Kotlin’s Powerful Object-Oriented Programming (OOP) for Seamless Development Success

Table of Contents

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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
class Cupcake(val flavour: String)

This streamlined syntax allows us to create several instances of the Cupcake class with different flavors:

Kotlin
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:

Kotlin
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:

Kotlin
fun main(args: Array<String>) {
    val myBlueberryCupcake = Cupcake("Blueberry")
    println(myBlueberryCupcake.eat())
}

Executing this code will produce the following output:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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().

Kotlin
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.

Kotlin
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.

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:

Kotlin
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.

Kotlin
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

  1. Both are types with an is-a relationship.
  2. Both define behaviors through methods.
  3. Neither abstract classes nor interfaces can be instantiated directly.

Differences

  1. A class can extend just one open or abstract class but can extend many interfaces.
  2. An open/abstract class can have constructors, whereas interfaces cannot.
  3. An open/abstract class can initialize its own values, whereas an interface’s values must be initialized in the classes that implement the interface.
  4. An open class must declare methods that can be overridden as open, while an abstract class can have both open and abstract methods.
  5. 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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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.

Kotlin
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:

Kotlin
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.

Kotlin
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.

Kotlin
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.

Kotlin
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:

Kotlin
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:

Kotlin
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:

Kotlin
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.

Kotlin
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:

Kotlin
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!

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!