Chapters

Hide chapters

Kotlin Apprentice

Third Edition · Android 11 · Kotlin 1.4 · IntelliJ IDEA 2020.3

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section III: Building Your Own Types

Section 3: 8 chapters
Show chapters Hide chapters

Section IV: Intermediate Topics

Section 4: 9 chapters
Show chapters Hide chapters

15. Advanced Classes
Written by Victoria Gonda

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

An earlier chapter introduced you to the basics of defining and using classes in Kotlin. Classes are used to support traditional object-oriented programming.

Class concepts include inheritance, overriding, polymorphism and composition which makes them suited for this purpose. These extra features require special consideration for construction, class hierarchies, and understanding the class lifecycle in memory.

This chapter will introduce you to the finer points of classes in Kotlin, and help you understand how you can create more complex classes. Open up the starter project or a new Kotlin project to get started.

Introducing inheritance

In an earlier chapter, you saw a Grade class and a pair of class examples: Person and Student.

data class Grade(
  val letter: Char,
  val points: Double,
  val credits: Double
)

class Person(var firstName: String, var lastName: String) {
  fun fullName() = "$firstName $lastName"
}

class Student(var firstName: String, var lastName: String,
  var grades: MutableList<Grade> = mutableListOf<Grade>()) {

  fun recordGrade(grade: Grade) {
    grades.add(grade)
  }
}

There’s an incredible amount of redundancy between Person and Student. They share many of the same properties. Maybe you’ve also noticed that a Student is a Person!

This simple case demonstrates the idea behind class inheritance. Much like in the real world, where you can think of a student as a person, you can represent the same relationship in code by replacing the original Person and Student class implementations with the following. Add these classes to your code:

// 1
open class Person(var firstName: String, var lastName: String) {
  fun fullName() = "$firstName $lastName"
}

// 2
class Student(
  firstName: String,
  lastName: String,
  var grades: MutableList<Grade> = mutableListOf<Grade>()
) : Person(firstName, lastName) {

  open fun recordGrade(grade: Grade) {
    grades.add(grade)
  }
}

In this modified example:

  1. The Person class now includes the open keyword.
  2. The Student class now inherits from Person, indicated by a colon after the naming of Student, followed by the class from which Student inherits, which in this case is Person.

The open keyword means that the Person class is open to be inherited from; the need for open is part of the Kotlin philosophy of requiring choices such as inheritance to be explicitly defined by the programmer.

You must still add parameters such as firstName to the Student constructor, and they are then passed along as arguments to the Person constructor. Notice in the modified example that the var keyword is no longer needed on the parameters, since they are already defined as properties in the Person class.

Through inheritance, Student automatically gets the properties and methods declared in the Person class. In code, it would be accurate to say that a Student is-a Person.

With much less duplication of code, you can now create Student objects that have all the properties and methods of a Person. Add this to main():

val john = Person(firstName = "Johnny", lastName = "Appleseed")
val jane = Student(firstName = "Jane", lastName = "Appleseed")

john.fullName() // Johnny Appleseed
jane.fullName() // Jane Appleseed

Both john and jane have all the properties of a Person because Student inherits from Person.

Additionally, only the Student object will have all of the properties and methods defined in Student. Try this out:

val history = Grade(letter = 'B', points = 9.0, credits = 3.0)
jane.recordGrade(history)
// john.recordGrade(history) // john is not a student!

You can’t record a grade for john because he’s not a student. The shared properties only go one direction.

A class that inherits from another class is known as a subclass or a derived class, and the class from which it inherits is known as a superclass or base class.

The rules for subclassing are fairly simple:

  • A Kotlin class can inherit from only one other class, a concept known as single inheritance.
  • A Kotlin class can only inherit from a class that is open.
  • There’s no limit to the depth of subclassing, meaning you can subclass from a class that is also a subclass, like below (and first redefining Student with open):
open class Student(
  firstName: String,
  lastName: String,
  var grades: MutableList<Grade> = mutableListOf<Grade>()
) : Person(firstName, lastName) {

  open fun recordGrade(grade: Grade) {
    grades.add(grade)
  }
}

open class BandMember(
  firstName: String,
  lastName: String
) : Student(firstName, lastName) {
  open val minimumPracticeTime: Int
    get() { return  2 }
}

class OboePlayer(
  firstName: String,
  lastName: String
): BandMember(firstName, lastName) {
  // This is an example of an override, will be covered soon.
  override val minimumPracticeTime: Int =
    super.minimumPracticeTime * 2
}

A chain of subclasses is called a class hierarchy. In this example, the hierarchy would be OboePlayerBandMemberStudentPerson. A class hierarchy is analogous to a family tree. Because of this analogy, a superclass is also called the parent class of its child class.

Polymorphism

The StudentPerson relationship demonstrates a computer science concept known as polymorphism. In brief, polymorphism is a programming language’s ability to treat an object differently based on context.

fun phonebookName(person: Person): String {
  return "${person.lastName}, ${person.firstName}"
}

val person = Person(
  firstName = "Johnny",
  lastName = "Appleseed"
)
val oboePlayer = OboePlayer(
  firstName = "Jane",
  lastName = "Appleseed"
)

phonebookName(person)     // Appleseed, Johnny
phonebookName(oboePlayer) // Appleseed, Jane

Runtime hierarchy checks

Now that you are coding with polymorphism, you will likely find situations where the specific type behind a variable can be different. For instance, you could define a variable hallMonitor as a Student. Add a hallMonitor to main():

var hallMonitor =
  Student(firstName = "Jill", lastName = "Bananapeel")
hallMonitor = oboePlayer
hallMonitor.minimumPracticeTime // Error!
println(hallMonitor is OboePlayer) // true, since assigned it to oboePlayer
println(hallMonitor !is OboePlayer) // also have !is for "not-is"
println(hallMonitor is Person) // true, because Person is ancestor of OboePlayer
(oboePlayer as Student).minimumPracticeTime // Error: No longer a band member!

(hallMonitor as? BandMember)?.minimumPracticeTime
// 4 if hallMonitor = oboePlayer, else null
fun afterClassActivity(student: Student): String {
  return "Goes home!"
}

fun afterClassActivity(student: BandMember): String {
  return "Goes to practice!"
}
afterClassActivity(oboePlayer) // Goes to practice!
afterClassActivity(oboePlayer as Student) // Goes home!

Inheritance, methods and overrides

Subclasses’ properties and methods are defined in their superclass, plus any additional properties and methods the subclass defines for itself. In that sense, subclasses are additive; for example, you’ve already seen that the Student class can add additional properties and methods for handling a student’s grades. These properties and methods wouldn’t be available to any Person class instances, but they would be available to Student subclasses.

class StudentAthlete(
  firstName: String,
  lastName: String
): Student(firstName, lastName) {
  val failedClasses = mutableListOf<Grade>()

  override fun recordGrade(grade: Grade) {
    super.recordGrade(grade)

    if (grade.letter == 'F') {
      failedClasses.add(grade)
    }
  }

  val isEligible: Boolean
    get() = failedClasses.size < 3
}
recordGrade hides member of supertype Student and needs override modifier
jiyeysMmifi bapur kaglas in canuyjthe Ldovapy evh laohj izuhzawu yeboqial

val math = Grade(letter = 'B', points = 9.0, credits = 3.0)
val science = Grade(letter = 'F', points = 9.0, credits = 3.0)
val physics = Grade(letter = 'F', points = 9.0, credits = 3.0)
val chemistry = Grade(letter = 'F', points = 9.0, credits = 3.0)

val dom = StudentAthlete(firstName = "Dom", lastName = "Grady")
dom.recordGrade(math)
dom.recordGrade(science)
dom.recordGrade(physics)
println(dom.isEligible) // > true
dom.recordGrade(chemistry)
println(dom.isEligible) // > false

Introducing super

You may have also noticed the line super.recordGrade(grade) in the overridden method. The super keyword is similar to this, except it will invoke the method in the nearest implementing superclass. In the example of recordGrade() in StudentAthlete, calling super.recordGrade(grade) will execute the method as defined in the Student class.

When to call super

As you may notice, exactly when you call super can have an important effect on your overridden method.

override fun recordGrade(grade: Grade) {
  var newFailedClasses = mutableListOf<Grade>()
  for (grade in grades) {
    if (grade.letter == 'F') {
      newFailedClasses.add(grade)
    }
  }
  failedClasses = newFailedClasses

  super.recordGrade(grade)
}

Preventing inheritance

Often you’ll want to disallow subclasses of a particular class. Kotlin makes this easy since the default for class definitions is that classes are not open to subclassing; you must use the open keyword to allow inheritance.

class FinalStudent(firstName: String, lastName: String)
    : Person(firstName, lastName)

class FinalStudentAthlete(firstName: String, lastName: String)
    : FinalStudent(firstName, lastName) // Build error!
open class AnotherStudent(firstName: String, lastName: String)
    : Person(firstName, lastName) {

  open fun recordGrade(grade: Grade) {}
  fun recordTardy() {}
}

class AnotherStudentAthlete(firstName: String, lastName: String)
    : AnotherStudent(firstName, lastName) {

  override fun recordGrade(grade: Grade) {} // OK
  override fun recordTardy() {} // Build error! recordTardy is final
}

Abstract classes

In certain situations, you may want to prevent a class from being instantiated, but still be able to be inherited from. This will let you define properties and behavior common to all subclasses. You can only create instances of the subclasses and not the base, parent class. Such parent classes are called abstract.

abstract class Mammal(val birthDate: String) {
  abstract fun consumeFood()
}

class Human(birthDate: String): Mammal(birthDate) {
  override fun consumeFood() {
    // ...
  }
  fun createBirthCertificate() {
    // ...
  }
}
val human = Human("1/1/2000")
val mammal = Mammal("1/1/2000") // Error: Cannot create an instance of an abstract class

Sealed classes

Sealed classes are useful when you want to make sure that the values of a given type can only come from a particular limited set of subtypes. They allow you to define a strict hierarchy of types. The sealed classes themselves are abstract and cannot be instantiated.

sealed class Shape {
  class Circle(val radius: Int): Shape()
  class Square(val sideLength: Int): Shape()
}
val circle1 = Shape.Circle(4)
val circle2 = Shape.Circle(2)
val square1 = Shape.Square(4)
val square2 = Shape.Square(2)

fun size(shape: Shape): Int {
  return when (shape) {
    is Shape.Circle -> shape.radius
    is Shape.Square -> shape.sideLength
  }
}

size(circle1) // radius of 4
size(square2) // sideLength of 2

Secondary constructors

You’ve seen how to define the primary constructors of classes, by appending a list of property parameters and their types to the class name.

class Person(var firstName: String, var lastName: String) {
  fun fullName() = "$firstName $lastName"
}

// is the same as

class Person constructor(var firstName: String, var lastName: String) {
  fun fullName() = "$firstName $lastName"
}
open class Shape {
  constructor(size: Int) {
    // ...
  }

  constructor(size: Int, color: String) : this(size) {
    // ...
  }
}
class Circle : Shape {
  constructor(size: Int) : super(size) {
    // ...
  }

  constructor(size: Int, color: String) : super(size, color) {
    // ...
  }
}

Nested and inner classes

When two classes are closely related to each other, sometimes it’s useful to define one class within the scope of another class. By doing so, you’ve namespaced one class within the other.

class Car(val carName: String) {
  class Engine(val engineName: String)
}
class Car(val carName: String) {
  class Engine(val engineName: String) {
    override fun toString(): String {
      return "$engineName in a $carName" // Error: cannot see carName in outer scope!
    }
  }
}
inner class Engine(val engineName: String) {
  override fun toString(): String {
    return "$engineName engine in a $carName"
  }
}
val mazda = Car("mazda")
val mazdaEngine = mazda.Engine("rotary")
println(mazdaEngine) // > rotary engine in a mazda

Visibility modifiers

While the open keyword determines what you can and cannot override in class hierarchies, visibility modifiers determine what can and cannot be seen both inside and outside of classes.

data class Privilege(val id: Int, val name: String)

open class User(
  val username: String,
  private val id: String,
  protected var age: Int
)

class PrivilegedUser(username: String, id: String, age: Int)
    : User(username, id, age) {
  private val privileges = mutableListOf<Privilege>()

  fun addPrivilege(privilege: Privilege) {
    privileges.add(privilege)
  }

  fun hasPrivilege(id: Int): Boolean {
    return privileges.map { it.id }.contains(id)
  }

  fun about(): String {
    //return "$username, $id" // Error: id is private
    return "$username, $age" // OK: age is protected
  }
}
val privilegedUser =
  PrivilegedUser(username = "sashinka", id = "1234", age = 21)
val privilege = Privilege(1, "invisibility")
privilegedUser.addPrivilege(privilege)
println(privilegedUser.about()) // > sashinka, 21

When and why to subclass

This chapter has introduced you to class inheritance, along with the numerous programming techniques that subclassing enables. But you might be asking, “When should I subclass?”

data class Sport(val name: String)

class Student2(firstName: String, lastName: String)
    : Person(firstName, lastName) {
  var grades = mutableListOf<Grade>()
  var sports = mutableListOf<Sport>()
  // original code
}

Single responsibility

In software development, however, the guideline known as the single responsibility principle states that any class should have a single concern. In StudentStudentAthlete, you might argue that it shouldn’t be the Student class’s job to encapsulate responsibilities that only make sense to student athletes, and it makes sense to create the StudentAthlete subclass rather than keep a list of sports within Student.

Strong types

Subclassing creates an additional type. With Kotlin’s type system, you can declare properties or behavior based on objects that are student athletes, not regular students:

class Team {
  var players = mutableListOf<StudentAthlete>()

  val isEligible: Boolean
    get() {
      for (player in players) {
        if (!player.isEligible) {
          return false
        }
      }
      return true
    }
}

Shared base classes

You can subclass a shared base class multiple times by classes that have mutually exclusive behavior:

// A button that can be pressed.
open class Button {
  fun press() {
  }
}

// An image that can be rendered on a button.
class Image

// A button that is composed entirely of an image.
class ImageButton(var image: Image): Button()

// A button that renders as text.
class TextButton(val text: String): Button()

Extensibility

Sometimes you simply must subclass if you’re extending the behavior of code you don’t own. In the example above, it’s possible Button is part of a framework you’re using, and there’s no way you can modify or extend the source code to fit your needs.

Identity

Finally, it’s important to understand that classes and class hierarchies model what objects are. If your goal is to share behavior (what objects can do) between types, more often than not you should prefer interfaces over subclassing. You’ll learn about interfaces in Chapter 17: “Interfaces”.

Challenges

Key points

  • Class inheritance is one of the most important features of classes and enables polymorphism.
  • Subclassing is a powerful tool, but it’s good to know when to subclass. Subclass when you want to extend an object and could benefit from an “is-a” relationship between subclass and superclass, but be mindful of the inherited state and deep class hierarchies.
  • The open keyword is used to allow inheritance from classes and also to allow methods to be overridden in subclasses.
  • Sealed classes allow you to create a strictly defined class hierarchy that is similar to an enum class but that allow multiple instances of each subtype to be created and hold state.
  • Secondary constructors allow you to define additional constructors that take additional parameters than the primary constructor and take different actions with those parameters.
  • Nested classes allow you to namespace one class within another.
  • Inner classes are nested classes that also have access to the other members of the outer class.
  • Visibility modifiers allow you to control where class members and top-level declarations can be seen within your code and projects.

Where to go from here?

Classes are the programming construct you will most often use to model things in your Kotlin apps, from students to grades to people and much more. Classes allow for the definition of hierarchies of items and also for one type of item to be composed within another.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now