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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Object-Oriented Programming Best Practices with Kotlin
25 mins
- Getting started
- Understanding Object-Oriented Programming?
- Classes and Objects
- Key Principles of OOP
- Understanding Encapsulation and Kotlin Classes
- Abstraction
- Inheritance and Polymorphism
- SOLIDifying your code
- Understanding the Single Responsibility Principle
- Understanding the Open Closed Principle
- Understanding the Liskov Substitution Principle
- Understanding the Interface Segregation Principle
- Understanding the Dependency Inversion Principle
- Kotlin Specific Tips
- Where to Go From Here?
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.
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:
- Returns a
CommandInputField
composable. This takes the input line by line and passes it to theCommandInputWriter
. - An important concept to note here is that you’re passing an anonymous object that implements
CommandInputWriter
. - Implementation of
sendInput
from anonymousCommandInputWriter
passed toCommandInputField
passes the input toTerminalCommandProcessor
object from class constructor.
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.
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 toTerminalCommandProcessor
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.
For practice, test this principle once more by adding two new TerminalItem
classes:
-
TerminalCommandErrorOutput
: for showing errors. The new item should look the same asTerminalItem
but have a different color. -
TerminalCommandInput
: for showing commands that you entered. The new item should look the same asTerminalItem
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: