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
text.You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.
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:
- The
Person
class now includes theopen
keyword. - The
Student
class now inherits fromPerson
, indicated by a colon after the naming ofStudent
, followed by the class from whichStudent
inherits, which in this case isPerson
.
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
withopen
):
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 OboePlayer
→ BandMember
→ Student
→ Person
. 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 Student
–Person
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
}
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 Student
–StudentAthlete
, 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.