Generics, objects, and extensions

1. Introduction

Over the decades, programmers devised several programming language features to help you write better code—expressing the same idea with less code, abstraction to express complex ideas, and writing code that prevents other developers from accidentally making mistakes are just a few examples. The Kotlin language is no exception, and there are a number of features intended to help developers write more expressive code.

Unfortunately, these features can make things tricky if this is your first time programming. While they might sound useful, the extent of their usefulness and the problems they solve may not always be apparent. Chances are you've already seen some features used in Compose and other libraries.

While there's no substitute for experience, this codelab exposes you to several Kotlin concepts that help you structure larger apps:

  • Generics
  • Different kinds of classes (enum classes and data classes)
  • Singleton and companion objects
  • Extension properties and functions
  • Scope functions

By the end of this codelab, you should have a deeper knowledge of the code you've already seen in this course, and learn some examples of when you'll encounter or use these concepts in your own apps.

Prerequisites

  • Familiarity with object-oriented programming concepts, including inheritance.
  • How to define and implement interfaces.

What you'll learn

  • How to define a generic type parameter for a class.
  • How to instantiate a generic class.
  • When to use enum and data classes.
  • How to define a generic type parameter that must implement an interface.
  • How to use scope functions to access class properties and methods.
  • How to define singleton objects and companion objects for a class.
  • How to extend existing classes with new properties and methods.

What you'll need

  • A web browser with access to the Kotlin Playground.

2. Make a reusable class with generics

Let's say you're writing an app for an online quiz, similar to the quizzes you've seen in this course. There are often multiple types of quiz questions, such as fill-in-the-blank, or true or false. An individual quiz question can be represented by a class, with several properties.

The question text in a quiz can be represented by a string. Quiz questions also need to represent the answer. However, different question types—such as true or false—may need to represent the answer using a different data type. Let's define three different types of questions.

  • Fill-in-the-blank question: The answer is a word represented by a String.
  • True or false question: The answer is represented by a Boolean.
  • Math problems: The answer is a numeric value. The answer for a simple arithmetic problem is represented by an Int.

In addition, quiz questions in our example, regardless of the type of question, will also have a difficulty rating. The difficulty rating is represented by a string with three possible values: "easy", "medium", or "hard".

Define classes to represent each type of quiz question:

  1. Navigate to the Kotlin playground.
  2. Above the main() function, define a class for fill-in-the-blank questions named FillInTheBlankQuestion, consisting of a String property for the questionText, a String property for the answer, and a String property for the difficulty.
class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difficulty: String
)
  1. Below the FillInTheBlankQuestion class, define another class named TrueOrFalseQuestion for true or false questions, consisting of a String property for the questionText, a Boolean property for the answer, and a String property for the difficulty.
class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difficulty: String
)
  1. Finally, below the other two classes, define a NumericQuestion class, consisting of a String property for the questionText, an Int property for the answer, and a String property for the difficulty.
class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difficulty: String
)
  1. Take a look at the code you wrote. Do you notice the repetition?
class FillInTheBlankQuestion(
    val questionText: String,
    val answer: String,
    val difficulty: String
)

class TrueOrFalseQuestion(
    val questionText: String,
    val answer: Boolean,
    val difficulty: String
)
class NumericQuestion(
    val questionText: String,
    val answer: Int,
    val difficulty: String
)

All three classes have the exact same properties: the questionText, answer, and difficulty. The only difference is the data type of the answer property. You might think that the obvious solution is to create a parent class with the questionText and difficulty, and each subclass defines the answer property.

However, using inheritance has the same problem as above. Every time you add a new type of question, you have to add an answer property. The only difference is the data type. It also looks strange to have a parent class Question that doesn't have an answer property.

When you want a property to have differing data types, subclassing is not the answer. Instead, Kotlin provides something called generic types that allow you to have a single property that can have differing data types, depending on the specific use case.

What is a generic data type?

Generic types, or generics for short, allow a data type, such as a class, to specify an unknown placeholder data type that can be used with its properties and methods. What exactly does this mean?

In the above example, instead of defining an answer property for each possible data type, you can create a single class to represent any question, and use a placeholder name for the data type of the answer property. The actual data type—String, Int, Boolean, etc.—is specified when that class is instantiated. Wherever the placeholder name is used, the data type passed into the class is used instead. The syntax for defining a generic type for a class is shown below:

10a38dbaa8f10ec6.png

A generic data type is provided when instantiating a class, so it needs to be defined as part of the class signature. After the class name comes a left-facing angle bracket (<), followed by a placeholder name for the data type, followed by a right-facing angle bracket (>).

The placeholder name can then be used wherever you use a real data type within the class, such as for a property.

ec3dcacd1a216bd4.png

This is identical to any other property declaration, except the placeholder name is used instead of the data type.

How would your class ultimately know which data type to use? The data type that the generic type uses is passed as a parameter in angle brackets when you instantiate the class.

4a21173cb6d2451b.png

After the class name comes a left-facing angle bracket (<), followed by the actual data type, String, Boolean, Int, etc., followed by a right-facing bracket (>). The data type of the value that you pass in for the generic property must match the data type in the angle brackets. You'll make the answer property generic so that you can use one class to represent any type of quiz question, whether the answer is a String, Boolean, Int, or any arbitrary data type.

Refactor your code to use generics

Refactor your code to use a single class named Question with a generic answer property.

  1. Remove the class definitions for FillInTheBlankQuestion, TrueOrFalseQuestion, and NumericQuestion.
  2. Create a new class named Question.
class Question()
  1. After the class name, but before the parentheses, add a generic type parameter using left- and right-facing angle brackets. Call the generic type T.
class Question<T>()
  1. Add the questionText, answer, and difficulty properties. The questionText should be of type String. The answer should be of type T because its data type is specified when instantiating the Question class. The difficulty property should be of type String.
class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: String
)
  1. To see how this works with multiple question types—fill-in-the-blank, true or false, etc.— create three instances of the Question class in main(), as shown below.
fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", "medium")
    val question2 = Question<Boolean>("The sky is green. True or false", false, "easy")
    val question3 = Question<Int>("How many days are there between full moons?", 28, "hard")
}
  1. Run your code to make sure everything works. You should now have three instances of the Question class—each with different data types for the answer—instead of three different classes, or instead of using inheritance. If you want to handle questions with a different answer type, you can reuse the same Question class.

3. Use an enum class

In the previous section, you defined a difficulty property with three possible values: "easy", "medium", and "hard". While this works, there are a couple of problems.

  1. If you accidentally mistype one of the three possible strings, you could introduce bugs.
  2. If the values change, for example, "medium" is renamed to "average", then you need to update all usages of the string.
  3. There's nothing stopping you or another developer from accidentally using a different string that isn't one of the three valid values.
  4. The code is harder to maintain if you add more difficulty levels.

Kotlin helps you address these problems with a special type of class called an enum class. An enum class is used to create types with a limited set of possible values. In the real world, for example, the four cardinal directions—north, south, east, and west—could be represented by an enum class. There's no need, and the code shouldn't allow, for the use of any additional directions. The syntax for an enum class is shown below.

2046d73e89bd8167.png

Each possible value of an enum is called an enum constant. Enum constants are placed inside the constructor, separated by a comma. The convention is to capitalize every letter in the constant name.

You refer to enum constants using the dot operator.

d6f72b0c1f3218df.png

Use an enum constant

Modify your code to use an enum constant, instead of a String, to represent the difficulty.

  1. Below the Question class, define an enum class called Difficulty.
enum class Difficulty {
    EASY, MEDIUM, HARD
}
  1. In the Question class, change the data type of the difficulty property from String to Difficulty.
class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)
  1. When initializing the three questions, pass in the enum constant for the difficulty.
val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)

4. Use a data class

Many of the classes you've worked with so far, such as subclasses of Activity, have several methods to perform different actions. These classes don't just represent data, but also contain a lot of functionality.

Classes like the Question class, on the other hand, only contain data. They don't have any methods that perform an action. These can be defined as a data class. Defining a class as a data class allows the Kotlin compiler to make certain assumptions, and to automatically implement some methods. For example, toString() is called behind the scenes by the println() function. When you use a data class, toString() and other methods are implemented automatically based on the class's properties.

To define a data class, simply add the data keyword before the class keyword.

4f6effa88d56a850.png

Convert Question to a data class

First, you'll see what happens when you try to call a method like toString() on a class that isn't a data class. Then, you'll convert Question into a data class, so that this and other methods will be implemented by default.

  1. In main(), print the result of calling toString() on question1.
fun main() {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)
    println(question1.toString())
}
  1. Run your code. The output only shows the class name and a unique identifier for the object.
Question@37f8bb67
  1. Make Question into a data class using the data keyword.
data class Question<T>(
    val questionText: String,
    val answer: T,
    val difficulty: Difficulty
)
  1. Run your code again. By marking this as a data class, Kotlin is able to determine how to display the class's properties when calling toString().
Question(questionText=Quoth the raven ___, answer=nevermore, difficulty=MEDIUM)

When a class is defined as a data class, the following methods are implemented.

  • equals()
  • hashCode(): you'll see this method when working with certain collection types.
  • toString()
  • componentN(): component1(), component2(), etc.
  • copy()

5. Use a singleton object

There are many scenarios where you want a class to only have one instance. For example:

  1. Player stats in a mobile game for the current user.
  2. Interacting with a single hardware device, like sending audio through a speaker.
  3. An object to access a remote data source (such as a Firebase database).
  4. Authentication, where only one user should be logged in at a time.

In the above scenarios, you'd probably need to use a class. However, you'll only ever need to instantiate one instance of that class. If there's only one hardware device, or only one user logged in at once, there would be no reason to create more than a single instance. Having two objects that access the same hardware device simultaneously could lead to some really strange and buggy behavior.

You can clearly communicate in your code that an object should have only one instance by defining it as a singleton. A singleton is a class that can only have a single instance. Kotlin provides a special construct, called an object, that can be used to make a singleton class.

Define a singleton object

81e0355283d36761.png

The syntax for an object is similar to that of a class. Simply use the object keyword instead of the class keyword. A singleton object can't have a constructor as you can't create instances directly. Instead, all the properties are defined within the curly braces and are given an initial value.

Some of the examples given earlier might not seem obvious, especially if you haven't worked with specific hardware devices or dealt with authentication yet in your apps. However, you'll see singleton objects come up as you continue learning Android development. Let's see it in action with a simple example using an object for user state, in which only one instance is needed.

For a quiz, it would be great to have a way to keep track of the total number of questions, and the number of questions the student answered so far. You'll only need one instance of this class to exist, so instead of declaring it as a class, declare it as a singleton object.

  1. Create an object named StudentProgress.
object StudentProgress {
}
  1. For this example, we'll assume there are ten total questions, and that three of them are answered so far. Add two Int properties: total with a value of 10, and answered with a value of 3.
object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}

Access a singleton object

Remember how you can't create an instance of a singleton object directly? How then are you able to access its properties?

Because there's only one instance of Progress in existence at one time, you access its properties by referring to the name of the object itself, followed by the dot operator (.), followed by the property name.

2ed33b669a8d055c.png

Update your main() function to access the properties of the singleton object.

  1. In main(), add a call to println() that outputs the answered and total questions from the StudentProgress object.
fun main() {
    ...
    println("${StudentProgress.answered} of ${StudentProgress.total} answered.")
}
  1. Run your code to verify that everything works.
...
3 of 10 answered

Declare objects as companion objects

Classes and objects in Kotlin can be defined inside other types, and can be a great way to organize your code. You can define a singleton object inside another class using a companion object. A companion object allows you to access its properties and methods from inside the class, if the object's properties and methods belong to that class, allowing for more concise syntax.

To declare a companion object, simply add the companion keyword before the object keyword.

e65d858bb7b607c4.png

You'll create a new class called Quiz to store the quiz questions, and make StudentProgress a companion object of the Quiz class.

  1. Below the Difficulty enum, define a new class named Quiz.
class Quiz {
}
  1. Move question1, question2, and question3 from main() into the Quiz class. You also need to remove println(question1.toString()) if you haven't already.
class Quiz {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)

}
  1. Move the StudentProgress object into the Quiz class.
class Quiz {
    val question1 = Question<String>("Quoth the raven ___", "nevermore", Difficulty.MEDIUM)
    val question2 = Question<Boolean>("The sky is green. True or false", false, Difficulty.EASY)
    val question3 = Question<Int>("How many days are there between full moons?", 28, Difficulty.HARD)

    object StudentProgress {
        var total: Int = 10
        var answered: Int = 3
    }
}
  1. Mark the StudentProgress object with the companion keyword.
companion object StudentProgress {
    var total: Int = 10
    var answered: Int = 3
}
  1. Update the call to println() to reference the properties with Quiz.answered and Quiz.total. Even though these properties are declared in the StudentProgress object, they can be accessed with dot notation using only the name of the Quiz class.
fun main() {
    println("${Quiz.answered} of ${Quiz.total} answered.")
}
  1. Run your code to verify the output.
3 of 10

6. Extend classes with new properties and methods

When working with Compose, you may have noticed some interesting syntax when specifying the size of UI elements. Numeric types, such as Double, appear to have properties like dp and sp specifying dimensions.

eb15d8b633c2b813.png

Why would the designers of the Kotlin language include properties and functions on built-in data types, specifically for building Android UI? Were they able to predict the future? Was Kotlin designed to be used with Compose even before Compose existed?

Of course not! When you're writing a class, you often don't know exactly how another developer will use it, or plans to use it, in their app. It's not possible to predict all future use cases, nor is it wise to add unnecessary bloat to your code for some unforeseen use case.

What the Kotlin language does, is give other developers the ability to extend existing data types, adding properties and methods that can be accessed with dot syntax, as if they were part of that data type. A developer who didn't work on the floating point types in Kotlin, for example, such as someone building the Compose library, might choose to add properties and methods specific to UI dimensions.

Since you've seen this syntax when learning Compose in the first two units, it's about time for you to learn how this works under the hood. You'll add some properties and methods to extend existing types.

Add an extension property

To define an extension property, add the type name and a dot operator (.) before the variable name.

e4ad1e0f91ac8583.png

You'll refactor the code in main to print the quiz progress into an extension property.

  1. Below the Quiz class, define an extension property of Quiz.StudentProgress named progressText of type String.
val Quiz.StudentProgress.progressText: String
  1. Define a getter for the extension property that returns the same string used before in main().
val Quiz.StudentProgress.progressText: String
    get() = "${answered} of ${total} answered"
  1. Replace the code in main() with code that prints progressText. Because this is an extension property of the companion object, you can access it with dot notation using the name of the class, Quiz.
fun main() {
    println(Quiz.progressText)
}
  1. Run your code to verify it works.
3 of 10 answered

Add an extension function

To define an extension function, add the type name and a dot operator (.) before the function name.

495b8e34d337ec73.png

You'll add an extension function to output the quiz progress as a progress bar. Since you can't actually make a progress bar in the Kotlin playground, you'll print out a retro-style progress bar using text!

  1. Add an extension function to the StudentProgress object called printProgressBar(). The function should take no parameters and have no return value.
fun Quiz.StudentProgress.printProgressBar() {
}
  1. Print out the character, answered number of times, using repeat(). This dark-shaded portion of the progress bar represents the number of questions answered. Use print() because you don't want a new line after each character.
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
}
  1. Print out the character, the number of times equal to the difference between total and answered, using repeat(). This light-shaded portion represents the remaining questions in the process bar.
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
}
  1. Print a new line using println() with no arguments, and then print progressText.
fun Quiz.StudentProgress.printProgressBar() {
    repeat(Quiz.answered) { print("▓") }
    repeat(Quiz.total - Quiz.answered) { print("▒") }
    println()
    println(Quiz.progressText)
}
  1. Update the code in main() to call printProgressBar().
fun main() {
    Quiz.printProgressBar()
}
  1. Run your code to verify the output.
▓▓▓▒▒▒▒▒▒▒
3 of 10 answered

Is it mandatory to do any of this? Certainly not. However, having the option of extension properties and methods gives you more options to expose your code to other developers. Using dot syntax on other types can make your code easier to read, both for yourself and for other developers.

7. Use scope functions to access class properties and methods

As you've seen already, Kotlin includes a lot of features to make your code more concise.

One such feature you'll encounter as you continue learning Android development is scope functions. Scope functions allow you to concisely access properties and methods from a class without having to repeatedly access the variable name. What exactly does this mean? Let's take a look at an example.

Eliminate repetitive object references with scope functions

Scope functions are higher-order functions that allow you to access properties and methods of an object without referring to the object's name. These are called scope functions because the body of the function passed in takes on the scope of the object that the scope function is called with. For example, some scope functions allow you to access the properties and methods in a class, as if the functions were defined as a method of that class. This can make your code more readable by allowing you to omit the object name when including it is redundant.

To better illustrate this, let's take a look at a few different scope functions that you'll encounter later in the course.

Replace long object names using let()

The let() function allows you to refer to an object in a lambda expression using the identifier it, instead of the object's actual name. This can help you avoid using a long, more descriptive object name repeatedly when accessing more than one property. The let() function is an extension function that can be called on any Kotlin object using dot notation.

Try accessing the properties of question1, question2, and question3 using let():

  1. Add a function to the Quiz class named printQuiz().
fun printQuiz() {
    
}
  1. Add the following code that prints the question's questionText, answer, and difficulty. While multiple properties are accessed for question1, question2, and question3, the entire variable name is used each time. If the variable's name changed, you'd need to update every usage.
fun printQuiz() {
    println(question1.questionText)
    println(question1.answer)
    println(question1.difficulty)
    println()
    println(question2.questionText)
    println(question2.answer)
    println(question2.difficulty)
    println()
    println(question3.questionText)
    println(question3.answer)
    println(question3.difficulty)
    println()
}
  1. Surround the code accessing the questionText, answer, and difficulty properties with a call to the let() function on question1, question2, and question3. Replace the variable name in each lambda expression with it.
fun printQuiz() {
    question1.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
    question2.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
    question3.let {
        println(it.questionText)
        println(it.answer)
        println(it.difficulty)
    }
    println()
}
  1. Create an instance of the Quiz class named quiz.
fun main() {
    Quiz.printProgressBar()
    val quiz = Quiz()
}
  1. Call printQuiz().
fun main() {
    Quiz.printProgressBar()
    val quiz = Quiz()
    quiz.printQuiz()
}
  1. Run your code to verify that everything works.
Quoth the raven ___
nevermore
MEDIUM

Quoth the raven ___
nevermore
MEDIUM

Quoth the raven ___
nevermore
MEDIUM

Call an object's methods without a variable using apply()

One of the cool features of scope functions is that you can call them on an object before that object has even been assigned to a variable. For example, the apply() function is an extension function that can be called on an object using dot notation. The apply() function also returns a reference to that object so that it can be stored in a variable.

Update the code in main() to call the apply() function.

  1. Call apply() after the closing parenthesis when creating an instance of the Quiz class. You can omit the parentheses when calling apply(), and use trailing lambda syntax.
val quiz = Quiz().apply {
}
  1. Move the call to printQuiz() inside the lambda expression. You no longer need to reference the quiz variable or use dot notation.
val quiz = Quiz().apply {
    printQuiz()
}
  1. The apply() function returns the instance of the Quiz class, but since you're no longer using it anywhere, remove the quiz variable. With the apply() function, you don't even need a variable to call methods on the instance of Quiz.
Quiz().apply {
    printQuiz()
}
  1. Run your code. Note that you were able to call this method without a reference to the instance of Quiz. The apply() function returned the objects which were stored in quiz.
▓▓▓▒▒▒▒▒▒▒
3 of 10 answered.
kotlin.Unit
Quoth the raven ___
nevermore
MEDIUM

Quoth the raven ___
nevermore
MEDIUM

Quoth the raven ___
nevermore
MEDIUM

While using scope functions isn't mandatory to achieve the desired output, the above examples illustrate how they can make your code more concise and avoid repeating the same variable name.

The above code demonstrates just two examples, but you're encouraged to bookmark and refer to the Scope Functions documentation as you encounter their usage later in the course.

8. Summary

You just got the chance to see several new Kotlin features in action. Generics allow data types to be passed as parameters to classes, enum classes define a limited set of possible values, and data classes help automatically generate some useful methods for classes.

You also saw how to create a singleton object—which is restricted to one instance, how to make it a companion object of another class, and how to extend existing classes with new get-only properties and new methods. Finally, you saw some examples of how scope functions can provide a simpler syntax when accessing properties and methods.

You'll see these concepts throughout the later units as you learn more about Kotlin, Android development, and Compose. You now have a better understanding of how they work and how they can improve the reusability and readability of your code.

9. Learn more