Properties
Consider the Person class that we introduced when discussing
constructors:
import java.time.LocalDate
class Person(var name: String, val birth: LocalDate) {
var isMarried = false
}
This class has three properties:
name, a writeable property representing the person’s namebirth, a read-only property representing date of birthisMarried, a writeable property recording marital status
Let’s dig into how these properties are represented in the code generated by the compiler.
Probing The Bytecode
-
Examine
Person.kt, in thetasks/task12_5_1subdirectory of your repository. This file contains the implementation ofPersonshown above. -
Compile
Person.ktin the normal way. This should generate a new filePerson.class, containing the Java bytecode representation of the class. -
In a terminal window, in the same directory as the bytecode file, run the Java class disassembler tool on it, like so1:
javap -p PersonStudy the output carefully.
You should see clear correspondences between the bytecode representation
and the properties of the Kotlin class. For example, the Kotlin property
birth is represented in the bytecode by a combination of two things:
- A private variable
birth, of typeLocalDate - A public method
getBirth(), which returns aLocalDateobject
whereas the Kotlin property name is represented by a combination of
three things:
- A private variable
name, of typeString - A public method
getName(), which returns aStringobject - A public method
setName(), which accepts aStringas an argument
The Kotlin property isMarried is represented in a similar way to name,
by a combination of private variable, ‘getter’ method and ‘setter’ method.
When you read the value of a property in Kotlin code, the corresponding getter method is called in the Java bytecode to retrieve the value of the associated private variable.
Similarly, when you assign a new value to a property in Kotlin code, the corresponding setter method is called in the Java bytecode, and this updates the value of the associated private variable.
Here’s an example:
val p = Person("Joe", birth=LocalDate.of(1992, 8, 23))
println(p.name) // JVM calls getName()
p.name = "David" // JVM calls setName("David")
Properties like name, birth, isMarried are stored properties,
with associated hidden variables that store their values. These hidden
variables are termed backing fields. Users of a class never interact
directly with backing fields; all interaction is via the getter and setter
methods generated by the compiler.
Stored properties are abstractions of an underlying implementation that depends on how the property is defined:
valproperties are implemented as a backing field and a gettervarproperties are implemented as a backing field, a getter and a setter
It’s useful to know all of this because it is possible in Kotlin to customize the getter or setter code associated with a property.
Custom Getter
Let’s change the behaviour of the name property so that we always see a
person’s name represented with uppercase characters, regardless of letter
case in the stored name. This isn’t especially useful, but it will serve to
illustrate the process.
-
Edit the file
Getter.kt, in thetasks/task12_5_2subdirectory of your repository. This contains the implementation of thePersonclass seen earlier.Modify the class definition so that it looks like this:
class Person(_name: String, val birth: LocalDate) { var isMarried = false var name = _name get() { return field.uppercase() } }Note carefully the differences between this and the original version of the
Personclass:-
The
nameproperty is now defined in the body of the class, and it is initialized using the value supplied via the_nameparameter of the primary constructor2. -
An additional piece of code, customizing the getter for
name, appears immediately after the definition of the property.
The new getter code is written as a function, with the name
getand an empty parameter list. Inside the body of this function, we use the special namefieldto refer to the backing field of the property. -
-
Add a
main()function that creates aPersonobject and then prints that person’s name. -
Compile and run the program. You should see the name displayed in uppercase.
Custom Setter
The procedure here is similar to that for customizing a getter. The property
needs to be defined in the body of the class and not as part of the primary
constructor. You then need to follow the property definition with a special
set() function.
Let’s do this for our Person class, customizing the setter for the name
property so that a person cannot be given a blank name.
-
Edit the file
Setter.kt, in thetasks/task12_5_3subdirectory of your repository. This contains the implementation of thePersonclass seen earlier.Modify the class definition so that it looks like this:
class Person(_name: String, val birth: LocalDate) { var isMarried = false var name = _name set(value) { require(value.isNotBlank()) { "Name cannot be blank" } field = value } }(Note: to keep things simple, we’ve customized only the setter code here, but you are allowed to customize getter and setter at the same time.)
The
set()function takes a single parameter. You can call this anything you like, butvalueis a sensible choice. This represents the value that appears on the right-hand side of any assignment operation involving the property. You don’t specify a type for this parameter, as type of the corresponding property is already known.The body of the function assigns this value to the special variable
field, which represents the backing field of the property. Before doing that,valueis checked and an exception is thrown if it is not acceptable. The most concise way of doing this is via therequire()precondition function. -
Add a
main()function that creates aPersonobject and then attempts to assign an empty string to itsnameproperty. -
Compile and run the program. It should crash with an
IllegalArgumentException. Examine the exception traceback carefully.
Note that custom setters are not used when you are assigning values to properties during the creation of an object.
So to be absolutely certain that a Person object can never have a blank
string for its name, you would need to write a custom setter like the
one above and build similar validation into the object construction
process, e.g., via an initializer block.
Computed Properties
The property examples considered above all have backing fields, but we can also have properties that don’t have backing fields.
For example, if you define a property with a custom getter and that getter
doesn’t use the special field variable to reference a backing field, the
compiler will not bother creating a backing field for the property. We
sometimes describe this as a computed property, because its value is
determined solely by computation and not by accessing a backing field
associated with the property.
As an example, let’s return to the Circle class discussed earlier:
class Circle(val centre: Point, val radius: Double) {
fun area() = PI * radius * radius
fun perimeter() = 2.0 * PI * radius
}
In this implementation, circle area and perimeter are computed via explicit method calls, but area and perimeter could also be viewed as characteristics that are intrinsic to circles. This makes them good candidates for implementation as computed properties.
To achieve this, all we need to do is redefine area() and perimeter()
to be val properties with a custom getter:
class Circle(val centre: Point, val radius: Double) {
val area get() = PI * radius * radius
val perimeter get() = 2.0 * PI * radius
}
Now, we can use the class like this:
val centre = Point(4.0, 1.0)
val circ = Circle(centre, radius=2.5)
println(circ.area)
println(circ.perimeter)
Note the absence of parentheses here!
It’s important to understand that there is little difference functionally
between these two versions of Circle. In both, a function executes to
compute the area or perimeter of a circle. The difference lies in whether
invocation of that function is explicit or hidden.
Which approach you use is largely a matter of personal taste. A useful rule of thumb is to make something a computed property rather than a method only when it feels more natural to do so, i.e., only when the thing you are computing feels more like an intrinsic quality of instances of the class than an action that is perform on instances of the class.
Another Example
Let’s return to the Person class for a final example of a computed property.
Imagine that we need to know the age in years of a person. We could write a method to compute this, but age feels much more like an intrinsic attribute of a person than a calculation that needs to be performed. This makes it an ideal candidate for implementation as a computed property.
-
Edit
Age.kt, in thetasks/task12_5_4subdirectory of your repository. This contains a now familiar version of thePersonclass:import java.time.LocalDate class Person(var name: String, val birth: LocalDate) { var isMarried = true }This implementation uses Java’s time API to represent date of birth with the
LocalDateclass. We can continue using this API to perform calculations such as determining the time interval between two dates.To calculate time intervals, we can use the
ChronoUnitenum class—specifically theYEARSobject of this class. To gain access to this, add the following import to the file:import java.time.temporal.ChronoUnit.YEARS -
The
YEARSobject has a methodbetween(), which accepts a pair ofLocalDateobjects as arguments. The method computes the interval between the two given dates in units of years, returning it as a long integer.The
birthproperty provides the first of the two required dates. The second should be today’s date, which is obtained easily enough by invoking the methodLocalDate.now(). Thus our implementation of theageproperty looks like this:val age get() = YEARS.between(birth, LocalDate.now()).toInt()Add this code to the class, then check that it compiles successfully.
-
Finally, add a
main()function toAge.kt. This program should create aPersonobject and then print the value of that object’sageproperty. -
Compile and run the program. Check that it is calculating age correctly.
-
Note: for this to work, a Java Development Kit (JDK) needs to be available on the system you are using, and the JDK’s
bindirectory needs to be included in yourPATHvariable. ↩ -
We’ve used
_nameas the name of the constructor parameter, for clarity, but it would have been OK to give it the same name as the property. ↩