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 2 of 3 of this article. Click here to view the first page.

Inheritance and Polymorphism

It’s time to add the ability to input commands. You’ll do this with the help of another OOP principle — inheritance. MainActivity is set up to show a list of TerminalItem objects. How can you show a different item if a list is set up to show an object of a certain class? The answer lies in inheritance and polymorphism.

Inheritance enables you to create a new class with all the properties and functions “inherited” from another class, also known as deriving a class from another. The class you’re deriving from is also called a superclass.

Note: For more specific information on inheritance in Kotlin, check the official documentation.

One more important thing in inheritance is that you can provide a different implementation of a public function “inherited” from a superclass. This leads us to the next concept.

Polymorphism is related to inheritance and enables you to treat all derived classes as a superclass. For example, you can pass a derived class to TerminalView, and it’ll happily show it thinking it’s a TerminalItem. Why would you do that? Because you could provide your own implementation of View() function that returns a composable to show on screen. This implementation will be an input field for entering commands for the derived class.

So, create a new class named TerminalCommandPrompt extending TerminalItem in processor/model package and replace its contents with the following:

package com.kodeco.android.kodecoshell.processor.model

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import com.kodeco.android.kodecoshell.processor.CommandInputWriter
import com.kodeco.android.kodecoshell.processor.TerminalCommandProcessor
import com.kodeco.android.kodecoshell.processor.ui.CommandInputField

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

}

It takes one constructor parameter, a TerminalCommandProcessor object, which it’ll use to pass the commands to.

Android Studio will show an error. If you hover over it, you’ll see: This type is final, so it cannot be inherited from.

This is because, by default, all classes in Kotlin are final, meaning a class can’t inherit from them.
Add the open keyword to fix this.

Open TerminalItem and add the open keyword before class, so your class looks like this:

open class TerminalItem(private val text: String = "") {

  open fun textToShow(): String = text

  @Composable
  open fun View() {
    Text(
        text = textToShow(),
        fontSize = TextUnit(16f, TextUnitType.Sp),
        fontFamily = FontFamily.Monospace,
    )
  }
}

Now, back to TerminalCommandPrompt class.

It’s time to provide its View() implementation. Add the following function override to the new class:

@Composable
@ExperimentalMaterial3Api
// 1
override fun View() {
  CommandInputField(
      // 2
      inputWriter = object : CommandInputWriter {
        // 3
        override fun sendInput(input: String) {
          commandProcessor.process(input)
        }
      }
  )
}

Let’s go over this step by step:

  1. Returns a CommandInputField composable. This takes the input line by line and passes it to the CommandInputWriter.
  2. An important concept to note here is that you’re passing an anonymous object that implements CommandInputWriter.
  3. Implementation of sendInput from anonymous CommandInputWriter passed to CommandInputField passes the input to TerminalCommandProcessor object from class constructor.
Note: Anonymous objects are out of the scope of this tutorial, but you can check more about them in the official documentation.

There’s one final thing to do, open MainActivity and add the following import:

import com.kodeco.android.kodecoshell.processor.model.TerminalCommandPrompt

Now, replace the TerminalView instantiation with:

TerminalView(commandProcessor, TerminalCommandPrompt(commandProcessor))

This sets the item used for entering commands on TerminalView to TerminalCommandPrompt.

Build and run the app. Yay, you can now enter commands! For example, pwd.

Note that you won’t have permission for some commands, and you’ll get errors.

Permission denied

SOLIDifying your code

Additionally, five more design principles will help you make robust, maintainable and easy-to-understand object-oriented code.

The SOLID principles are:

  • Single Responsibility Principle: Each class should have one responsibility.
  • Open Closed Principle: You should be able to extend the behavior of a component without breaking its usage.
  • Liskov Substitution Principle: If you have a class of one type, you should be able to represent the base class usage with the subclass without breaking the app.
  • Interface Segregation Principle: It’s better to have several small interfaces than only a large one to prevent classes from implementing methods they don’t need.
  • Dependency Inversion Principle: Components should depend on abstractions rather than concrete implementations.

Understanding the Single Responsibility Principle

Each class should have only one thing to do. This makes the code easier to read and maintain. You can also refer to this principle as “decoupling” code.

In the same way, each function should perform one task if possible. A good measure is that you should be able to know what each function does from its name.

Here are some examples of this principle from the KodecoShell app:

  • Shell class: Its task is to send commands to Android shell and notify the results using callbacks. It doesn’t care how you enter the commands or how to display the result.
  • CommandInputField: A Composable that takes care of command input and nothing else.
  • MainActivity: Shows a terminal window UI using Jetpack Compose. It delegates the handling of commands to TerminalCommandProcessor implementation.

Understanding the Open Closed Principle

You’ve seen this principle in action when you added TerminalCommandPrompt item. Extending the functionality by adding new types of items to the list on the screen doesn’t break existing functionality. No extra work in TerminalItem or MainActivity was needed.

This is a result of using polymorphism by providing an implementation of View function in classes derived from TerminalItem. MainActivity doesn’t have to do any extra work if you add more items. This is what the Open Closed Principle is all about.

PS command

For practice, test this principle once more by adding two new TerminalItem classes:

  • TerminalCommandErrorOutput: for showing errors. The new item should look the same as TerminalItem but have a different color.
  • TerminalCommandInput: for showing commands that you entered. The new item should look the same as TerminalItem but have “>” prefixed.

Here’s the solution:

[spoiler title=”Solution”]

package com.kodeco.android.kodecoshell.processor.model

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType

/** Represents command error output in Terminal. */
class TerminalCommandErrorOutput(
    private val errorOutput: String
) : TerminalItem() {
  override fun textToShow(): String = errorOutput

  @Composable
  override fun View() {
    Text(
        text = textToShow(),
        fontSize = TextUnit(16f, TextUnitType.Sp),
        fontFamily = FontFamily.Monospace,
        color = MaterialTheme.colorScheme.error
    )
  }
}
package com.kodeco.android.kodecoshell.processor.model

class TerminalCommandInput(
    private val command: String
) : TerminalItem() {
  override fun textToShow(): String = "> $command"
}

Update ShellCommandProcessor property initializer:

private val shell = Shell(
  outputCallback = { outputCallback(TerminalItem(it)) },
  errorCallback = { outputCallback(TerminalCommandErrorOutput(it)) }
)

Then, process function:

override fun process(command: String) {
  outputCallback(TerminalCommandInput(command))
  shell.process(command)
}

Import the following:

import com.kodeco.android.kodecoshell.processor.model.TerminalCommandErrorOutput
import com.kodeco.android.kodecoshell.processor.model.TerminalCommandInput

[/spoiler]

Build and run the app. Type a command that needs permission or an invalid command. You’ll see something like this:

Permission denied with color