5

Object-Oriented Programming Best Practices with Kotlin [FREE]

 10 months ago
source link: https://www.kodeco.com/40947157-object-oriented-programming-best-practices-with-kotlin
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Home

Object-Oriented Programming Best Practices with Kotlin Home Object-Oriented Programming Best Practices with Kotlin

Object-Oriented Programming Best Practices with Kotlin

Aug 23 2023, Kotlin 1.8, Android 12.0, Android Studio 2022.1.1

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.

Object-Oriented Programming (OOP) is the most popular computer programming paradigm. Using it properly can make your life, and your coworkers’, lives easier. In this tutorial, you’ll build a terminal app to execute shell commands on Android.

In the process, you’ll learn the following:

  • Key principles of Object-Oriented Programming.
  • SOLID principles and how they make your code better.
  • Some Kotlin specific good-to-knows.
Note: This tutorial assumes you know the basics of Android development with Kotlin. However, if you’re new to Kotlin, check out our Kotlin introduction tutorial.

Also, if you’re completely new to Android development, read through our Beginning Android Development tutorials to familiarize yourself with the basics.

Getting started

To begin with, download the Kodeco Shell project using the Download Materials button at the top or bottom of this tutorial.

Open the starter project in Android Studio 2022.2.1 or later by selecting Open on the Android Studio welcome screen:

Android Studio Welcome Screen

The app consists of a single screen similar to Terminal on Windows/Linux/MacOS. It lets you input commands and show their output and errors. Additionally, there are two actions, one to stop a running command and one to clear the output.

Build and run the project. You should see the main, and only, screen of the app:

Main Screen

Whoa, what’s going on here? As you can see, the app currently refuses to run any commands, it just displays a non-cooperative message. Therefore, your job will be to use OOP Best Practices and fix that! You’ll add the ability to input commands and display their output.

Understanding Object-Oriented Programming?

Before adding any code, you should understand what OOP is.

Object-Oriented Programming is a programming model based on data. Everything is modeled as objects that can perform certain actions and communicate with each other.

For example, if you were to represent a car in object-oriented programming, one of the objects would be a Car. It would contain actions such as:

  • Accelerate
  • Brake
  • Steer left
  • Steer right

Classes and Objects

One of the most important distinctions in object-oriented programming is between classes and objects.

Continuing the car analogy, a class would be a concrete car model and make you can buy, for example — Fiat Panda.

A class describes how the car behaves, such as its top speed, how fast it can accelerate, etc. It is like a blueprint for the car.

An object is an instance of a car, if you go to a dealership and get yourself a Fiat Panda, the Panda you’re now driving in is an object.

Class vs Object

Let’s take a look at classes in KodecoShell app:

  • MainActivity class represents the screen shown when you open the app.
  • TerminalCommandProcessor class processes commands that you’ll enter on the screen and takes care of capturing their output and errors.
  • Shell class executes the commands using Android runtime.
  • TerminalItem class represents a chunk of text shown on the screen, a command that was entered, its output or error.

KodecoShell classes

MainActivity uses TerminalCommandProcessor to process the commands the user enters. To do so, it first needs to create an object from it, referred to as “creating an object” or “instantiating an object of a class”.

To achieve this in Kotlin, you use:

private val commandProcessor: TerminalCommandProcessor = TerminalCommandProcessor()

Afterward, you could use it by calling its functions, for example:

commandProcessor.init()

Key Principles of OOP

Now that you know the basics, it’s time to move on to the key principles of OOP:

  • Encapsulation
  • Abstraction
  • Inheritance
  • Polymorphism

These principles make it possible to build code that is easy to understand and maintain.

Understanding Encapsulation and Kotlin Classes

Data inside a class can be restricted. Make sure other classes can only change the data in expected ways and prevent state inconsistencies.

In short, the outside world doesn’t need to know how a class does something, but what it does.

In Kotlin, you use visibility modifiers to control the visibility of properties and functions inside classes. Two of the most important ones are:

  • private: property or function is only visible inside the class where it’s defined.
  • public: default visibility modifier if none is specified, property or function is visible everywhere.
Note: For more information on Kotlin visibility modifiers check the official documentation.

Marking the internal data of a class as private prevents other classes from modifying it unexpectedly and causing errors.

To see this in action, open TerminalCommandProcessor class and add the following import:

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

Then, add the following inside the class:

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

You instantiated a Shell to run shell commands. You can’t access it outside of TerminalCommandProcessor. You want other classes to use process() to process commands via TerminalCommandProcessor.

Note you passed blocks of code for outputCallback and errorCallback parameters. Shell will execute one of them when its process function is called.

To test this, open MainActivity and add the following line at the end of the onCreate function:

commandProcessor.shell.process("ps")

This code tries to use the shell property you’ve just added to TerminalCommandProcessor to run the ps command.

However, Android Studio will show the following error:
Cannot access 'shell': it is private in 'TerminalCommandProcessor'

Delete the line and return to TerminalCommandProcessor. Now change the init() function to the following:

fun init() {
  shell.process("ps")
}

This code executes when the application starts because MainActivity calls TerminalViews‘s LaunchEffect.

Note: For more information on Jetpack Compose and launch effects check the official documentation.

Build and run the app.

As a result, now you should see the output of the ps command, which is the list of the currently running processes.

PS output

Abstraction

This is similar to encapsulation, it allows access to classes through a specific contract. In Kotlin, you can define that contract using interfaces.

Interfaces in Kotlin can contain declarations of functions and properties. But, the main difference between interfaces and classes is that interfaces can’t store state.

In Kotlin, functions in interfaces can have implementations or be abstract. Properties can only be abstract; otherwise, interfaces could store state.

Note: For more information about interfaces in Kotlin check the official documentation.

Open TerminalCommandProcessor and replace class keyword with interface.

Note Android Studio’s error for the shell property: Property initializers aren't allowed in interfaces.

As mentioned, interfaces can’t store state, and you cannot initialize properties.

Delete the shell property to eliminate the error.

You’ll get the same error for the outputCallback property. In this case, remove only the initializer:

var outputCallback: (TerminalItem) -> Unit

Now you have an interface with three functions with implementations.

Replace init function with the following:

fun init()

This is now an abstract function with no implementation. All classes that implement TerminalCommandProcessor interface must provide the implementation of this function.

Replace process and stopCurrentCommand functions with the following:

fun process(command: String)

fun stopCurrentCommand()

Classes in Kotlin can implement one or more interfaces. Each interface a class implements must provide implementations of all its abstract functions and properties.

Create a new class ShellCommandProcessor implementing TerminalCommandProcessor in processor/shell package with the following content:

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

import com.kodeco.android.kodecoshell.processor.TerminalCommandProcessor
import com.kodeco.android.kodecoshell.processor.model.TerminalItem

class ShellCommandProcessor: TerminalCommandProcessor { // 1
  // 2  
  override var outputCallback: (TerminalItem) -> Unit = {}

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

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

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

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

Let’s go over this step-by-step.

  1. You implement TerminalCommandProcessor interface.
  2. You declare a property named outputCallback and use the override keyword to declare that it’s an implementation of property with the same name from TerminalCommandProcessor interface.
  3. You create a private property holding a Shell object for executing commands. You pass the code blocks that pass the command output and errors to outputCallback wrapped in TerminalItem objects.
  4. Implementations of init, process and stopCurrentCommand functions call appropriate Shell object functions.

You need one more MainActivity change to test the new code. So, add the following import:

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

Then, replace commandProcessor property with:

private val commandProcessor: TerminalCommandProcessor = ShellCommandProcessor()

Build and run the app.

Welcome to Kodeco Shell

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:

Answer Reveal
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

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

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!

Contributors

Over 300 content creators. Join our team.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK