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

13. Properties
Written by Victoria Gonda

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

In Chapter 11, you were introduced to properties as the data members of Kotlin classes and objects. They are often used to describe the attributes of the object or hold state.

Open the starter project for this chapter to continue learning.

In the example below, the Car class has two properties, both constants that store String values:

class Car(val make: String, val color: String)

The two properties of Car are supplied in the primary constructor, and they store different string values for each instance of Car.

Properties can also be set up with custom accessors, also known as getters and setters. The properties supplied in the class primary constructor use default implementations of the accessors, storing the data in a backing field.

In this chapter, you’ll learn much more about properties. You’ll learn about property initializers, custom accessors, delegated properties, late initialization of properties and extension properties.

Constructor properties

As you may have guessed from the example in the introduction, you’re already familiar with many of the features of properties. To review, imagine you’re building an address book. The common unit you’ll need is a Contact.

Add this class to your Kotlin file:

class Contact(var fullName: String, var emailAddress: String)

You can use this class over and over again to build an array of contacts, each with a different value. The properties you want to store are an individual’s full name and email address.

These are the properties of the Contact class. You provide a data type for each, but opt not to assign a default value, because you plan to assign the name and email address upon initialization. After all, the values will be different for each instance of Contact.

Create a contact in main():

val contact = Contact(
  fullName = "Grace Murray",
  emailAddress = "grace@navy.mil"
)

You create an object by passing values as arguments into the class primary constructor.

As with any function call not using default values, using named arguments in the primary constructor is optional. Because named arguments are optional, you could also create the class like this:

val contact = Contact("Grace Murray", "grace@navy.mil")

Now, add the following statements:

val name = contact.fullName // Grace Murray
val email = contact.emailAddress // grace@navy.mil

Here, you’re accessing the properties. You can access the individual properties using dot notation.

Now, assign a new value to the full name. When Grace married, she changed her last name:

contact.fullName = "Grace Hopper"
val grace = contact.fullName // Grace Hopper

Combining the dot notation with = assignment, you can assign new values. You can assign values to properties as long as they’re defined as variables.

If you’d like to prevent a value from changing, you can define a property as a constant instead using val.

Consider this contact class:

class Contact2(var fullName: String, val emailAddress: String)

Notice that the emailAddress property uses val instead of var.

Now, look what happens when you try to change the value:

var contact2 = Contact2(
  fullName = "Grace Murray",
  emailAddress = "grace@navy.mil"
)

// Error: Val cannot be reassigned
contact2.emailAddress = "grace@gmail.com"

Once you’ve initialized an instance of the Contact2 class, you can’t change emailAddress.

Default values

If you can make a reasonable assumption about what the value of a property should be when the type is initialized, you can give that property a default value.

class Contact3(
  var fullName: String,
  val emailAddress: String,
  var type: String = "Friend"
)
var contact3 = Contact3(
  fullName = "Grace Murray",
  emailAddress = "grace@navy.mil"
)
contact3.type = "Work"
var workContact = Contact3(
  fullName = "Grace Murray",
  emailAddress = "grace@navy.mil",
  type = "Work"
)

Property initializers

Properties can also be initialized outside of the primary constructor, using literals and values passed into the primary constructor, using a property initializer.

class Person(val firstName: String, val lastName: String) {
  val fullName = "$firstName $lastName"
}
val person = Person("Grace", "Hopper")
person.fullName // Grace Hopper
class Address {
  var address1: String
  var address2: String? = null
  var city = ""
  var state: String

  init {
    address1 = ""
    state = ""
  }
}
val address = Address()

Custom accessors

Many properties work just fine with the default accessor implementation, in which dot notation returns the value directly and an assignment statement just sets the value. Properties can also be defined with custom getter and setter methods. If a custom setter is provided, then the property must be declared as a var.

Custom getter

The measurement for a TV is the perfect use case for a custom accessor. The industry definition of the screen size of a TV isn’t the screen’s height or width, but its diagonal measurement.

class TV(var height: Double, var width: Double) {
  // 1
  val diagonal: Int
    get() {
      // 2
      val result = Math.sqrt(height * height + width * width)
      // 3
      return result.roundToInt()
    }
}

val tv = TV(height = 53.93, width = 95.87)
val size = tv.diagonal // 110
tv.width = tv.height
val diagonal = tv.diagonal // 76

Mini-exercise

Do you have a television or a computer monitor? Measure the height and width, plug it into a TV object, and see if the diagonal measurement matches what you think it is.

Custom setter

The property you wrote in the previous section is a called a read-only property. It has a block of code to compute the value of the property: the custom getter. It’s also possible to create a read-write property with two blocks of code: a custom getter and a custom setter. This setter works differently than you might expect. As the property has no place to store a value, the setter usually sets one or more related other properties indirectly.

// 1
var diagonal: Int
  // 2
  get() {
    val result = Math.sqrt(height * height + width * width)
    return result.roundToInt()
  }
  set(value) {
    // 3
    val ratioWidth = 16.0
    val ratioHeight = 9.0
    // 4
    val ratioDiagonal = Math.sqrt(
      ratioWidth * ratioWidth + ratioHeight * ratioHeight
    )
    height = value.toDouble() * ratioHeight / ratioDiagonal
    width = height * ratioWidth / ratioHeight
  }
tv.diagonal = 70
println(tv.height) // 34.32...
println(tv.width)  // 61.01...

Companion object properties

In the previous section, you learned how to associate properties with instances of a particular class. The properties on your instance of TV are separate from the properties on someone else’s instance of TV.

class Level(
  val id: Int,
  var boss: String,
  var unlocked: Boolean
) {
  companion object {
    var highestLevel = 1
  }
}

val level1 = Level(id = 1, boss = "Chameleon", unlocked = true)
val level2 = Level(id = 2, boss = "Squid", unlocked = false)
val level3 = Level(id = 3, boss = "Chupacabra", unlocked = false)
val level4 = Level(id = 4, boss = "Yeti", unlocked = false)
// Error: Unresolved reference
// Can't access members of the companion object on an instance
val highestLevel = level3.highestLevel
val highestLevel = Level.highestLevel // 1
class Level(
  val id: Int,
  var boss: String,
  var unlocked: Boolean
) {
  companion object {
    @JvmStatic var highestLevel = 1
  }
}
Level.getHighestLevel() // Fine, thanks to @JvmStatic
Level.Companion.getHighestLevel() // Fine too, and necessary if @JvmStatic were not used

Delegated properties

Most of the property initialization you’ve seen so far has been straightforward. You provide an initializer for a property, for example, as a literal value or default value, or you use custom accessors to compute the value.

Observable properties

For your Level implementation, it would be useful to automatically set the highestLevel when the player unlocks a new one. For that, you’ll need a way to listen to property changes. Thankfully, you can use a delegated property observable to provide a callback for when the property changes.

class DelegatedLevel(val id: Int, var boss: String) {
  companion object {
    var highestLevel = 1
  }
  var unlocked: Boolean by Delegates.observable(false) {
    _, old, new ->
    if (new && id > highestLevel) {
      highestLevel = id
    }
    println("$old -> $new")
  }
}
val delegatedlevel1 = DelegatedLevel(id = 1, boss = "Chameleon")
val delegatedlevel2 = DelegatedLevel(id = 2, boss = "Squid")

println(DelegatedLevel.highestLevel) // 1

delegatedlevel2.unlocked = true

println(DelegatedLevel.highestLevel) // 2

Limiting a variable

You can also use delegated property observers to limit the value of a variable. Say you had a light bulb that could only support a maximum current flowing through its filament.

class LightBulb {
  companion object {
    const val maxCurrent = 40
  }
  var current by Delegates.vetoable(0) {
    _, _, new ->
    if (new > maxCurrent) {
      println(
        "Current too high, falling back to previous setting.")
      false
    } else {
      true
    }
  }
}
val light = LightBulb()
light.current = 50
var current = light.current // 0
light.current = 40
current = light.current // 40

Lazy properties

If you have a property that might take some time to calculate and you don’t want to slow things down until you actually need the property, say hello to lazy properties.

class Circle(var radius: Double = 0.0) {
  val pi: Double by lazy {
    ((4.0 * Math.atan(1.0 / 5.0)) - Math.atan(1.0 / 239.0)) * 4.0
  }
  val circumference: Double
    get() = pi * radius * 2
}

val circle = Circle(5.0) // got a circle, pi has not been run
val circumference = circle.circumference // 31.42
// also, pi now has a value

Mini-exercises

Of course, you should absolutely trust the value of pi from the standard library. It’s a constant in the standard library, and you can access it as kotlin.math.PI. Given the Circle example above:

lateinit

If you just want to denote that a property will not have a value when the class instance is created, then you can use the lateinit keyword.

class Lamp {
  lateinit var bulb: LightBulb
}
val lamp = Lamp()
// ... lamp has no lightbulb, need to buy some!

println(lamp.bulb)
// Error: kotlin.UninitializedPropertyAccessException:
// lateinit property bulb has not been initialized

// ... bought some new ones
lamp.bulb = LightBulb()

Extension properties

A circle has a radius, diameter and circumference that are all related to one another. But the Circle class above only includes the radius and circumference. It would be nice if the circle could tell you its diameter too, without you having to perform the calculation every time.

val Circle.diameter: Double
  get() = 2.0 * radius
val unitCircle = Circle(1.0)
println(unitCircle.diamater) // > 2.0

Challenges

Here are some challenges to test your new knowledge. Take a peek at the challenge solutions in the chapter materials if you need a hint while completing them.

Challenge 1

Rewrite the IceCream class below to use default values and lazy initialization:

class IceCream {
  val name: String
  val ingredients: ArrayList<String>
}

Challenge 2

At the beginning of the chapter, you saw a Car class. Dive into the inner workings of the car and rewrite the FuelTank class below with delegated property observer functionality:

class FuelTank {
  var level = 0.0 // decimal percentage between 0 and 1
}

Key points

  • Properties are variables and constants that are part of a named type.
  • Default values can be used to assign a value to a property within the class definition.
  • Property initializers and the init block are used to ensure that the properties of an object are initialized when the object is created.
  • Custom accessors are used to execute custom code when a property is accessed or set.
  • The companion object holds properties that are universal to all instances of a particular class.
  • Delegated properties are used when you want to observe, limit or lazily create a property. You’ll want to use lazy properties when a property’s initial value is computationally intensive or when you won’t know the initial value of a property until after you’ve initialized the object.
  • lateinit can be used to defer setting the value of a property reference until after the instance is created.
  • Extension properties allow you to add properties to a class outside of the class definition, for example, if you’re using a class from a library.

Where to go from here?

You saw the basics of properties while learning about classes, and now you’ve seen the more advanced features they have to offer. You’ve already learned a bit about methods in the previous chapters and will learn even more about them in the next one!

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now