Object-Oriented Programming Best Practices with Kotlin

Learn how to write better code following Object Oriented Programming Best Practices with Kotlin and SOLID principles by developing a Terminal Android app. By Ivan Kušt.

3 (2) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 3 of this article. Click here to view the first page.

Understanding the Liskov Substitution Principle

This principle states that if you replace a subclass of a class with a different one, the app shouldn’t break.

For example, if you’re using a List, the actual implementation doesn’t matter. Your app would still work, even though the times to access the list elements would vary.

To test this out, create a new class named DebugShellCommandProcessor in processor/shell package.
Paste the following code into it:

package com.kodeco.android.kodecoshell.processor.shell

import com.kodeco.android.kodecoshell.processor.TerminalCommandProcessor
import com.kodeco.android.kodecoshell.processor.model.TerminalCommandErrorOutput
import com.kodeco.android.kodecoshell.processor.model.TerminalCommandInput
import com.kodeco.android.kodecoshell.processor.model.TerminalItem
import java.util.concurrent.TimeUnit

class DebugShellCommandProcessor(
    override var outputCallback: (TerminalItem) -> Unit = {}
) : TerminalCommandProcessor {

  private val shell = Shell(
      outputCallback = {
        val elapsedTimeMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - commandStartNs)
        outputCallback(TerminalItem(it))
        outputCallback(TerminalItem("Command success, time: ${elapsedTimeMs}ms"))
      },
      errorCallback = {
        val elapsedTimeMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - commandStartNs)
        outputCallback(TerminalCommandErrorOutput(it))
        outputCallback(TerminalItem("Command error, time: ${elapsedTimeMs}ms"))
      }
  )

  private var commandStartNs = 0L

  override fun init() {
    outputCallback(TerminalItem("Welcome to Kodeco shell (Debug) - enter your command ..."))
  }

  override fun process(command: String) {
    outputCallback(TerminalCommandInput(command))
    commandStartNs = System.nanoTime()
    shell.process(command)
  }

  override fun stopCurrentCommand() {
    shell.stopCurrentCommand()
  }
}

As you may have noticed, this is similar to ShellCommandProcessor with the added code for tracking how long each command takes to execute.

Go to MainActivity and replace commandProcessor property with the following:

private val commandProcessor: TerminalCommandProcessor = DebugShellCommandProcessor()

You’ll have to import this:

import com.kodeco.android.kodecoshell.processor.shell.DebugShellCommandProcessor

Now build and run the app.

Try executing the “ps” command.

PS command

Your app still works, and you now get some additional debug info — the time that command took to execute.

Understanding the Interface Segregation Principle

This principle states it’s better to separate interfaces into smaller ones.

To see the benefits of this, open TerminalCommandPrompt. Then change it to implement CommandInputWriter as follows:

class TerminalCommandPrompt(
    private val commandProcessor: TerminalCommandProcessor
) : TerminalItem(), CommandInputWriter {

  @Composable
  @ExperimentalMaterial3Api
  override fun View() {
    CommandInputField(inputWriter = this)
  }

  override fun sendInput(input: String) {
    commandProcessor.process(input)
  }
}

Build and run the app to make sure it’s still working.

If you used only one interface – by putting abstract sendInput function into TerminalItem – all classes extending TerminalItem would have to provide an implementation for it even though they don’t use it. Instead, by separating it into a different interface, only TerminalCommandPrompt can implement it.

Understanding the Dependency Inversion Principle

Instead of depending on concrete implementations, such as ShellCommandProcessor, your classes should depend on abstractions: interfaces or abstract classes that define a contract. In this case, TerminalCommandProcessor.

You’ve already seen how powerful the Liskov substitution principle is — this principle makes it super easy to use. By depending on TerminalCommandProcessor in MainActivity, it’s easy to replace the implementation used. Also, this comes in handy when writing tests. You can pass mock objects to a tested class.

Note: If you want to know more about Mock object, check out this wikipedia article.

Kotlin Specific Tips

Finally, here are a few Kotlin-specific tips.

Kotlin has a useful mechanism for controlling inheritance: sealed classes and interfaces. In short, if you declare a class as sealed, all its subclasses must be within the same module.

For more information, check the official documentation.

In Kotlin, classes can’t have static functions and properties shared across all instances of your class. This is where companion objects come in.

For more information look at the official documentation.

Where to Go From Here?

If you want to know more about most common design patterns used in OOP, check out our resources on patterns used in Android.

If you need a handy list of design patterns, make sure to check this.

Another resource related to design patterns is Design Patterns: Elements of Reusable Object-Oriented Software, by the Gang of Four.

You’ve learned what Object-Oriented Programming best practices are and how to leverage them.

Now go and write readable and maintainable code and spread the word! If you have any comments or questions, please join the forum discussion below!