Kotlin Abstract Classes

Abstract classes provide developers a place to provide an abstraction point while still being able to include common functionality. In a manner of speaking, an abstract class can be thought of as an unfinished class. It depends on subclasses to finish the class by defining the final behavior of the class. Since abstract classes serve as base classes for concrete classes, we can use them for polymorphism purposes.

Let’s consider a problem that uses abstract classes. We have two cooks. One cook is Bob and he makes burgers. The other cook is Jimmy and he makes pizza. Both cooks have names and own their own restaurants. They also cook food. So in this sense, they both have a lot in common. Although both cooks have the same behavior, cook(), they both cook different things. Bob cooks burgers while Jimmy cooks pizza.

We don’t want to define two classes that are almost completely the same. That would be a really bad practice because then a change in both classes would need to get updated in both places. We also would not be able to define methods that could use both classes without function overloading. In other words, our code would become highly coupled to the implementation of each cook instead of being able to use both cooks generally.

Abstract Class

Here is our Cook class that defines properties and behaviors for both Bob and Jimmy.

abstract class Cook(val name : String, val resturant : String){

    /**
     * This method doesn't have a body.
     * Child classes must define the cook() method
     * or they too must be abstract
     */
    abstract fun cook() : String

    override fun toString(): String {
        return this.name
    }
}

Classes are made abstract in Kotlin by adding the abstract keyword in front of the class keyword. We will never be able to create an instance of the Cook class due to it being marked abstract. Since our cooks cook different meals, we leave the cook method undefined by marking it as abstract as well. Abstract methods are open by default in Kotlin since they have to be overridden by definition. Since we have an abstract cook() method in Cook, we are forcing all subclasses to define this behavior.

Bob

Bob is a Cook so it makes sense that he subclasses Cook. By extending Cook, we are saying that Bob cooks. However, the Cook class doesn’t say what he cooks or how he cooks. We need Bob to extend Cook and define the cook() method.

class Bob : Cook("Bob Belcher", "Bob's Burgers") {

    override fun cook(): String {
        return "Chorizo Your Own Adventure Burger"
    }
}

Since Bob is a cook, he has a name and a restaurant. He not only cooks, but we now know how Bob cooks. Since Bob is a child class of Cook, he can be assigned to any Cook variable.

Jimmy

Jimmy is similar to Bob but he cooks pizzas.

class Jimmy : Cook("Jimmy Pesto", "Petso's Pizzeria"){
    override fun cook(): String {
        return "Boring Pizza"
    }
}

Using Abstract Classes

Now that we have Cook, Bob, and Jimmy defined, we are free to use them in our program. Let’s write a function that takes a Cook, says hello, and tells us what they cook.

fun sayHello(c : Cook){
    println("Hello! My name is ${c.name} and I cook ${c.cook()}")
}

Since Cook has a name property and a cook() method, the compiler knows that its safe to call cook() on any cook. Which version of the cook() method gets called depends on the runtime type of Cook. Regardless of who is the cook, the program will still work properly.

The sayHello function is said to be loosely coupled to the Cook class. Although we can only actually make Bob or Jimmy objects, the Cook class provides a degree of indirection for developer purposes. If we add other cooks, later on, the sayHello function can utilize those objects as well because they are all members of a common supertype.

Putting it Together

Use abstract classes when

  • You need an abstraction point
  • You have one or more child classes that have common behavior and properties
  • You need to declare behavior but need to define it in child classes

Here is an example program that ties everything together

package ch4.abstractclasses

abstract class Cook(val name : String, val resturant : String){

    /**
     * This method doesn't have a body.
     * Child classes must define the cook() method
     * or they too must be abstract
     */
    abstract fun cook() : String

    override fun toString(): String {
        return this.name
    }
}

class Bob : Cook("Bob Belcher", "Bob's Burgers") {

    override fun cook(): String {
        return "Chorizo Your Own Adventure Burger"
    }
}

class Jimmy : Cook("Jimmy Pesto", "Petso's Pizzeria"){
    override fun cook(): String {
        return "Boring Pizza"
    }
}

fun sayHello(c : Cook){
    println("Hello! My name is ${c.name} and I cook ${c.cook()}")
}

fun main(args : Array<String>){
    val bob = Bob()
    val jimmy = Jimmy()

    sayHello(bob)
    sayHello(jimmy)
}

We get this output when run.

Hello! My name is Bob Belcher and I cook Chorizo Your Own Adventure Burger
Hello! My name is Jimmy Pesto and I cook Boring Pizza
Advertisement

OOP Abstraction

Abstraction is one of the major components of OOP. When we abstract, we are hiding the internal working details of something from its user. The user only cares about the controls that operate an object, but how the object acts on the controls are of no concern to the user.

A common everyday abstraction that people use daily can be found in a smart phone’s operating system. When a user wishes to make a phone call, they do not worry about how the phone makes a call. All the user cares about is using the keypad to dail a phone number and then pressing the call button. The details of connecting to the cell phone tower and then routing the phone call through the phone network are of no concern to the user. Those details have been abstracted.

Kotlin provides a variety of ways to provide abstraction. In the example below, I used the interface feature to model a Vehicle

interface Vehicle {
    fun park()

    fun drive()

    fun reverse()

    fun start()

    fun shutDown()
}

This code defines an abstraction point for all Vehicles. It guarantees that all classes that implement Vehicle have the following behaviors: park, drive, reverse, start, and shutDown. However, what we do not have is details as to how the Vehicle drives, parks, etc. As a matter of fact, the function bodies of all of the methods inside of vehicle are left empty (they are called abstract methods).

We may wish to take our vehicle for a drive. When we drive our vehicle, we are only really concerned with what the vehicle can do. We don’t care how it parks or goes in reverse. Let’s see this example in terms of code.

fun takeForDrive(v : Vehicle){
    with(v){
        //How we start is abstracted. We only care that the vehicle starts, but
        //we don't care about how it starts.
        start()

        //Likewise, we only care that it goes in reverse(). How it goes in reverse
        //is irrelevant here.
        reverse()

        //And so on...
        drive()
        park()
        shutDown()
    }
}

Notice how the takeForDrive function calls all five of our behaviors on the supplied Vehicle object. It doesn’t even know what kind of a vehicle it is driving. The Vehicle could be a car, Truck, airplane, boat, etc. None of that matters to the takeForDrive function. The details are hidden behind the Vehicle interface (in other words, abstracted).

One of the reasons abstraction is so important is that it promotes code reusability and maintainability. For example, now that we have this takeForDrive function, we can use any object that implements Vehicle. So for example, we can create a Truck class that implements Vehicle.

class Truck : Vehicle {
    override fun park() = println("Truck is parking")

    override fun drive() = println("Truck is driving")

    override fun reverse() = println("Truck is in reverse")

    override fun start() = println("Truck is starting")

    override fun shutDown() = println("Truck is shutting down")
}

and now we can take the Truck for a drive.

val truck = Truck()
takeForDrive(truck)

The price of gas may spike later one and we may choose to drive something that is more efficient. As long as our new mode of transportation implements the Vehicle interface, we can take it for a drive. Here is a car class that impelements Vehicle.

class Car : Vehicle{
    override fun park() = println("Car is parking")

    override fun drive() = println("Car is driving")

    override fun reverse() = println("Car is in reverse")

    override fun start() = println("Car is starting")

    override fun shutDown() = println("Car is shutting down")
}

Just like with truck, we can drive the car.

val car = Car()
takeForDrive(car)

Since Vehicle provides an abstraction point, any code that accepts Vehicle as a parameter can use Truck or Car. The function takeForDrive can be said to be loosely coupled to Truck and Car because it indirectly accepts Trucks or Cars using the Vehicle interface. This makes the takeForDrive function highly reusable to other components that may need to get developed in the future.

Example Program

Here is a fully working Kotlin program that ties everything together.

package ch1

//This defines our public interface for all vehicles
interface Vehicle {
    fun park()

    fun drive()

    fun reverse()

    fun start()

    fun shutDown()
}

//Our Truck class provides an implementation of Vehicle
class Truck : Vehicle {
    override fun park() = println("Truck is parking")

    override fun drive() = println("Truck is driving")

    override fun reverse() = println("Truck is in reverse")

    override fun start() = println("Truck is starting")

    override fun shutDown() = println("Truck is shutting down")
}

//Car provides an alternative implementation of Vehicle
class Car : Vehicle{
    override fun park() = println("Car is parking")

    override fun drive() = println("Car is driving")

    override fun reverse() = println("Car is in reverse")

    override fun start() = println("Car is starting")

    override fun shutDown() = println("Car is shutting down")
}

/**
 * This function demonstrates Abstraction. Notice how it accepts a Vehicle object but
 * makes no distinction if it's a Truck or a Car. The details of how the vehicle parks,
 * drives, reverses, starts, or shuts down are abstracted from this function. In the end, we are
 * only concerned with what the Vehicle object does, not how it does it.
 */
fun takeForDrive(v : Vehicle){
    with(v){
        //How we start is abstracted. We only care that the vehicle starts, but
        //we don't care about how it starts.
        start()

        //Likewise, we only care that it goes in reverse(). How it goes in reverse
        //is irrelevant here.
        reverse()

        //And so on...
        drive()
        park()
        shutDown()
    }
}

fun main(args : Array<String>){
    //Create a new Truck and take it for a drive. It works because Truck
    //implements the Vehicle Interface which abstracts the truck's details from
    //the takeForDrive function
    takeForDrive(Truck())

    //Likewise, we can also take a car for a drive. The car class also implements
    //Vehicle so takeForDrive can also use cars.
    takeForDrive(Car())
}

When run, the program prints

Truck is starting
Truck is in reverse
Truck is driving
Truck is parking
Truck is shutting down
Car is starting
Car is in reverse
Car is driving
Car is parking
Car is shutting down
%d bloggers like this: