1. Previous: Chapter 10
  2. Next: Chapter 12
Kotlin: An Illustrated Guide • Chapter 11

Scopes and Scope Functions

Chapter cover image

In the last chapter, we learned how to create extension functions, which can be called using dot notation. In this chapter, we’ll learn about the five scope functions, which are particular functions (four of which are extension functions) that Kotlin gives you out of the box. Before we can understand scope functions, though, it helps to first understand scopes.

Introduction to Scopes

In Kotlin, a scope is a section of code where you can declare new variables, functions, classes, and more. In fact, every time that you’ve declared a new variable, you’ve declared it inside of some scope. For example, when we create a new Kotlin file (one that ends in .kt) and add a variable to it, that variable is declared within the file’s scope.

val pi = 3.14

In this case, the variable pi is declared within that file’s top-level scope.

A top-level file scope. val pi = 3.14 Top-Level File Scope

When we declare a class in that same file, the body of that class creates another scope - one that is contained within the top-level scope of the file. Let’s create a Circle class in that file, and add a diameter property to it.

val pi = 3.14

class Circle(var radius: Double) {
    val diameter = radius * 2
}

Now we can identify two scopes in this file:

  1. The top-level file scope, where the pi variable and the Circle class are declared.
  2. The class body scope of the Circle class, where diameter is declared.
A class body scope contained within the top-level file scope. val pi = 3.14 class Circle ( var radius: Double) { val diameter = radius * 2 } Class Body Scope Top-Level File Scope

We can add new things like variables, functions, or classes to either of these scopes.

When one scope is contained within another, we call it a nested scope. In the example above, the body of the Circle class is a scope that is nested within the scope of the file.

The class body scope is a nested scope - nested within the top-level file scope. val pi = 3.14 class Circle ( var radius: Double) { val diameter = radius * 2 } Nested Scope

We can take it even further. If we add a function to that class, that function’s body creates yet another a scope that’s nested within the class scope, which is nested within the original scope!

A function body scope, nested within a class body scope, nested within the top-level file scope. val pi = 3.14 class Circle ( var radius: Double) { val diameter = radius * 2 fun circumference (): Double { val result = pi * diameter return result } } Class Body Scope Function Body Scope Top-Level File Scope

This third scope is yet another place where we can add new variables, functions, and classes.

A new scope is also created when you write a lambda. Let’s add a new function that uses a lambda. This will add a scope for the function body and a scope for the lambda.

Adding a function that calls a lambda. The function and lambda both introduce a new scope. val pi = 3.14 class Circle ( var radius: Double) { val diameter = radius * 2 fun circumference (): Double { val result = pi * diameter return result } } fun createCircles (radii: List<Double>): List<Circle> { return radii. map { radius Circle (radius) } } Class Body Scope Function Body Scope Function Body Scope Lambda Scope Top-Level File Scope

In the code above, we can now identify five scopes. We can add a new variable, function, or class within any one of them!1

You might have noticed that, other than the outer-most scope, each scope here begins with an opening brace { and ends with a closing brace }. This isn’t a hard-and-fast rule (for example, some functions have expression bodies, and therefore no braces), but can serve as a helpful way to generally identify scopes. This also means that if we indent the code consistently, it makes it easier to tell where each new scope is being introduced.

One of the most important things about scopes is that they affect where you can use the things that are declared inside of them. Let’s look at that next!

Scopes and Visibility

Artwork: Stars, moon, and planet

As you grow up speaking a language natively, you gradually develop a sense for the rules of that language. When you were a child, your parents might have gently corrected your grammar here and there, and over time, you were eventually able to intuit the rules, even if you couldn’t always explain them to someone.

Similarly, as you’ve been writing Kotlin code, you’ve developed a sense for when you can use a particular variable, function, or class. Visibility is the term used to describe where in the code you can or cannot use something (such as a variable, function, or class). As with your native language, you can also intuit these visibility rules. However, you’ll be a more productive Kotlin developer if you actually know what those rules are.

In a moment, we’ll look at two kinds of scopes, and how they affect visibility. As you read this next section, keep in mind the difference between declaring something and using it. A variable, function, or class is declared at the point where you introduce it with the val, var, fun, or class keyword. When you evaluate a variable, call a function, and so on, you’re using it.

Declaring a variable versus using a variable. val pi = 3.14 class Circle ( var radius: Double) { val circumference = radius * 2 * pi } Declaring pi Using pi

Visibility describes where in the code you can use something, and that is determined by where in the code that thing was declared. We’ll see plenty of examples of this in the next sections of this chapter.

Statement Scopes

There are two different kinds of scopes in Kotlin, and the kind of the scope affects the visibility of the things declared inside of it. Let’s start with statement scopes, which are easiest to understand. The visibility rule for a statement scope is simple:

You can only use something that was declared in a statement scope after the point where it was declared.

For example, a function body has a statement scope. Inside that function body, if you try to use a variable that hasn’t yet been declared, you’ll get a compiler error. In the following code, we declare a diameter() function inside the circumference() function, but try to use it before it was declared.

class Circle(val radius: Double) {
    fun circumference(): Double {
        val result = pi * diameter()
        fun diameter() = radius * 2
        return result
    }
}
Error

We can’t call the diameter() function at this point in the code, because the function is declared later in the code (that is, on the next line) inside this statement scope.

To correct this error, we just need to move the line that declares diameter so that it comes before the line that uses it.

class Circle(val radius: Double) {
    fun circumference(): Double {
        fun diameter() = radius * 2
        val result = pi * diameter()
        return result
    }
}

So, something that is declared inside a statement scope can only be used after the point that it was declared. Easy!

As we saw here, a function body is one example of a statement scopes. Other examples include constructor bodies, lambdas, and Kotlin Script files (when your file ends in .kts).

Declaration Scopes

A second kind of scope in Kotlin is called a declaration scope. Unlike statement scopes, things declared within a declaration scope can be used from a point in the code either before or after that declaration.

A class body is an example of a declaration scope. We can update the Circle class from the previous listing so that the diameter() function is declared in the class body (a declaration scope) instead of the circumference() function body (a statement scope). We’ll also change circumference to a property to make that line similar to the result assignment in Listing 11.3.

class Circle(val radius: Double) {
    val circumference = pi * diameter()
    fun diameter() = radius * 2
}

Now, even though diameter() is declared after circumference(), everything compiles and runs just fine.

So, in declaration scopes, things can be used either before or after the point where they are declared.

A notable exception to this rule is that variables and properties that are declared and used in the same declaration scope must still be declared before they are used. For example, if we were to simply change both circumference() and diameter() to properties without changing their order, we’ll get an error from the compiler.

class Circle(val radius: Double) {
    val circumference = pi * diameter
    val diameter = radius * 2
}
Error

As we saw, a class body is one example of a declaration scope. The top level of a regular Kotlin file (when it ends in .kt) is another example.

Nested Scopes and Visibility

In general, if you want to know what variables, functions, and classes are available to you inside a particular scope, you can “crawl your way out” from that scope, toward the outermost scope - the one at the file level. As you’re crawling:

  1. If you crawl into a statement scope, you can only use things declared earlier in the scope.
  2. However, if you crawl into a declaration scope, you can use things declared either earlier or later in the scope.

Let’s demonstrate how this works. We’ll start with a file that has the following code.

val pi = 3.14

fun main() {
    val radii = listOf(1.0, 2.0, 3.0)

    class Circle(
        val radius: Double
    ) {
        fun circumference(): Double {
            val multiplier = 2.0
            // Which variables are visible here?
            val diameter = radius * multiplier
            return multiplier * pi * radius
        }

        val area = pi * radius * radius
    }

    val areas = radii.map {
        Circle(it).area
    }
}

Which variables are visible at the comment?

To answer that question, we can start by identifying which of the scopes in this listing are statement scopes and which are declaration scopes. Remember…

  • Function bodies and lambdas have a statement scope.
  • Class bodies and the file itself have declaration scopes.

The following illustrates the different declaration and statement scopes in the code. We’re starting at the yellow dot, which we’ll call the starting point, and our goal is to figure out which variables are visible at that point in the code.

Identifying declaration scopes and statement scopes. val pi = 3.14 fun main () { val radii = listOf ( 1.0 , 2.0 , 3.0 ) class Circle( val radius : Double ) { fun circumference (): Double { val multiplier = 2.0 Which variables are visible here? val diameter = radius * multiplier return multiplier * pi * radius } val area = pi * radius * radius } val areas = radii. map { Circle (it). area } } Declaration Scope Statement Scope Declaration Scope Statement Scope Statement Scope

The starting point is inside a statement scope. Because things declared in a statement scope can only be used after they have been declared, we start by scanning only upward - not downward.

Scanning upward in a statement scope. val pi = 3.14 fun main () { val radii = listOf ( 1.0 , 2.0 , 3.0 ) class Circle( val radius : Double ) { fun circumference (): Double { val multiplier = 2.0 Which variables are visible here? val diameter = radius * multiplier return multiplier * pi * radius } val area = pi * radius * radius } val areas = radii. map { Circle (it). area } } Declaration Scope Statement Scope Declaration Scope Statement Scope Statement Scope

When we scan upward, we run into the multiplier variable. So, the multiplier variable is visible at the starting point. The diameter variable, however, is not visible, because it’s declared after the starting point.

Having scanned this statement scope, we’re now ready to crawl into the next scope outward - that is, we crawl into the scope that contains the circumference() function. This is a class body, which has a declaration scope. With a declaration scope, we scan both upward and downward.

Scanning upward and downward in a declaration scope. val pi = 3.14 fun main () { val radii = listOf ( 1.0 , 2.0 , 3.0 ) class Circle( val radius : Double ) { fun circumference (): Double { val multiplier = 2.0 Which variables are visible here? val diameter = radius * multiplier return multiplier * pi * radius } val area = pi * radius * radius } val areas = radii. map { Circle (it). area } } Declaration Scope Statement Scope Declaration Scope Statement Scope Statement Scope

Scanning in both directions, we come across both the radius parameter, which is declared before the function, and area, which is declared after the function. So, both of these variables are also visible at the starting point.

Next, we crawl out to the containing scope - that is, the scope of the main() function. Because this is a function body (and therefore has a statement scope), we crawl only upward.

Crawling out into a statement scope and scanning upward. val pi = 3.14 fun main () { val radii = listOf ( 1.0 , 2.0 , 3.0 ) class Circle( val radius : Double ) { fun circumference (): Double { val multiplier = 2.0 Which variables are visible here? val diameter = radius * multiplier return multiplier * pi * radius } val area = pi * radius * radius } val areas = radii. map { Circle (it). area } } Declaration Scope Statement Scope Declaration Scope Statement Scope Statement Scope

Scanning upward, we run into the radii variable, which is also visible at the starting point. However, the areas variable is not visible, because it’s below.

Finally, we crawl out to the top-level file scope. Files have a declaration scope, so we scan both directions.

Crawling out into a declaration scope, scanning upward and downward. val pi = 3.14 fun main () { val radii = listOf ( 1.0 , 2.0 , 3.0 ) class Circle( val radius : Double ) { fun circumference (): Double { val multiplier = 2.0 Which variables are visible here? val diameter = radius * multiplier return multiplier * pi * radius } val area = pi * radius * radius } val areas = radii. map { Circle (it). area } } Declaration Scope Statement Scope Declaration Scope Statement Scope Statement Scope

The pi variable is found when scanning upward, and there are no variables found when scanning downward in this scope.

So, the answer to our question, “which variables are visible here?” is:

  • pi
  • radii
  • radius
  • multiplier
  • area

Although we concerned ourselves only with variables here, note that the same scanning approach works for other things as well, such as functions and classes. So, at the starting point, you can also:

  • … call the main() function.
  • … instantiate a new Circle object.
  • … call the circumference() function (from inside itself).

Again, you’ve probably developed an intuition around most of these rules. And as you write Kotlin from day to day, you’ll normally just rely on the compiler and IDE (such as IntelliJ or Android Studio) to tell you whether something is visible to you at a particular point in code. Still, it’s helpful to know the rules, so that you can structure your code the right way, ensuring that each thing has the visibility you want it to have!

Now that we’ve got a solid understanding of scopes and visibility, we’re ready to dive into scope functions!

Introduction to Scope Functions

There are five functions in Kotlin’s standard library that are designated as scope functions. Each of them is a higher-order function that you typically call with a lambda, which introduces a new statement scope. The point of a scope function is to take an existing object - called a context object 2 - and represent it in a particular way inside that new scope.

Let’s start with a simple example - a scope function called with().

with()

When you need to use the same variable over and over again, you can end up with a lot of duplication. For example, suppose we need to update an address object.

address.street1 = "9801 Maple Ave"
address.street2 = "Apartment 255"
address.city = "Rocksteady"
address.state = "IN"
address.postalCode = "12345"

When writing this code, it’s tedious to type address on each line, and when reading this code, seeing address on each line doesn’t really make it any easier to understand. This duplication actually detracts from the important thing on each line - the property that is being updated.

We can use the with() scope function to introduce a new scope where the address becomes an implicit receiver. Here’s how it looks:

with(address) {
     street1 = "9801 Maple Ave"
     street2 = "Apartment 255"
     city = "Rocksteady"
     state = "IN"
     postalCode = "12345"
}

The with() function is called with two arguments:

  1. The object that you want to become an implicit receiver. This is the context object. Here, it’s address.
  2. A lambda in which the context object will be the implicit receiver.
Breakdown of the with() function - showing the context object and lambda. with (address) { street1 = "9801 Maple Ave" street2 = "Apartment 255" city = "Rocksteady" state = "IN" postalCode = "12345" } Context object Lambda

As you probably recall from the last chapter, an implicit receiver can be used with no variable name at all, so inside the lambda above, we can assign values to street1, street2, and so on, without prefixing the name of the variable as we had to do in Listing 11.8.

So again, the with() scope function introduces a new scope (the lambda) in which the context object is represented as an implicit receiver.

The remaining four scope functions are all extension functions. Next, let’s look at one called run(), because it’s very similar to with().

run()

The run() function works the same as with(), but it’s an extension function instead of a normal, top-level function.3 This means we’ll need to invoke it with dot notation. Let’s rewrite Listing 11.9 so that it uses run() instead of with().

address.run {
    street1 = "9801 Maple Ave"
    street2 = "Apartment 255"
    city = "Rocksteady"
    state = "IN"
    postalCode = "12345"
}

Other than the first line, this code listing is identical to the previous one. The context object is passed as a receiver instead of a regular function argument, but the lambda is the same.

Breakdown of the run() function - showing the context object and lambda. address. run { street1 = "9801 Maple Ave" street2 = "Apartment 255" city = "Rocksteady" state = "IN" postalCode = "12345" } Context object Lambda

Even though run() and with() are very similar, run() does have some different characteristics because it’s an extension function. For instance, we saw in the last chapter how extension functions can be inserted into a call chain.

In fact, instead of defining your own extension functions for use in a call chain, you can often use a scope function like run(). For example, in the last chapter, we created a very simple extension function called singleQuoted() (in Listing 10.12), and called it in the middle of a call chain (in Listing 10.14), like this:

fun String.singleQuoted() = "'$this'"

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .singleQuoted()
    .uppercase()

// 'ROBOTS FROM PLANET X3'

Because singleQuoted() is so simple (it’s just a single expression!) we can remove the singleQuoted() function entirely, and replace it with a simple call to run(), like this:

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .run { "'$this'" }
    .uppercase()

// 'ROBOTS FROM PLANET X3'

The run() function returns the result of its lambda, so the code in this listing works identically to Listing 11.11 above. Of course, if you need to make a string single quoted in lots of places in your code, you’d be better off sticking with the singleQuoted() extension function. That way, if you need to change the way it works, you can fix it in one spot instead of lots of places. If you’ve only got a single call site, though, a scope function can be a good option!

Another advantage of using run() instead of with() is that you can use the safe-call operator to handle cases where the context object might be null. We’ll look at this more closely toward the end of this chapter.

For now, the important things to remember about run() are that:

  1. Inside the lambda, the context object is represented as the implicit receiver.
  2. The run() function returns the result of the lambda.
The run() function returns the result of the lambda, and the context object is represented as the implicit receiver inside the lambda. Context object is implicit receiver Returns result of lambda obj. run { }

Next, let’s take a look at another scope function, called let().

let()

let() might be the most frequently-used scope function. It’s very similar to run(), but instead of representing the context object as an implicit receiver, it’s represented as the parameter of its lambda. Let’s rewrite the previous listing to use let() instead of run().

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .let { titleWithoutPrefix -> "'$titleWithoutPrefix'" }
    .uppercase()

// 'ROBOTS FROM PLANET X3'

This is very similar to the previous listing, but instead of using this, we used a lambda parameter called titleWithoutPrefix. This parameter name is pretty long. Let’s change it to use the implicit it, so that it will be nice and concise.

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .let { "'$it'" }
    .uppercase()

// 'ROBOTS FROM PLANET X3'

As with run() and with(), the let() function returns the result of the lambda.

Comparing let() with run(). Context object is lambda parameter obj. let { } Context object is implicit receiver Returns result of lambda obj. run { }

A scope function that’s similar to let() is called also(). Let’s look at that next.

also()

As with let(), the also() function represents the context object as the lambda parameter, too. However, unlike let(), which returns the result of the lambda, the also() function returns the context object. This makes it a great choice for inserting into a call chain when you want to do something “on the side” - that is, without changing the value at that point in the chain.

For example, we might want to print out the value at some point in the call chain. Here’s the code from Listing 11.11, with also() inserted after the call to remove the prefix.

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .also { println(it) } // Robots from Planet X3
    .singleQuoted()
    .uppercase()

// 'ROBOTS FROM PLANET X3'

The also() call here prints out the result of title.removePrefix("The "), without interfering with the rest of the call chain. Regardless of whether we include or omit the line with the also() call, the singleQuoted() call will be called upon the same value - "Robots from Planet X3".

By the way, as you might remember from Chapter 7, you can use a function reference instead of a lambda, so we could choose to write the previous code listing like this:

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .also(::println) // Robots from Planet X3
    .singleQuoted()
    .uppercase()

// 'ROBOTS FROM PLANET X3'

Here’s how also() fits in among run() and let().:

Comparing also() with run() and let(). Returns context object obj. also { } Context object is lambda parameter obj. let { } Context object is implicit receiver Returns result of lambda obj. run { }

As you can see, we’ve roughly created a chart, and there’s one spot that’s empty. Let’s fill in that last spot as we look at the final scope function, apply().

apply()

Like also(), the apply() function returns the context object rather than the result of the lambda. However, like run(), the apply() function represents the context object as the implicit receiver. We can update Listing 11.15 to use apply() instead of also(), and it would do the same thing.

val title = "The Robots from Planet X3"
val newTitle = title
    .removePrefix("The ")
    .apply { println(this) } // Robots from Planet X3
    .singleQuoted()
    .uppercase()

// 'ROBOTS FROM PLANET X3'

However, in practice, Kotlin developers would typically prefer to use also() in this case. The apply() function really shines when you want to customize an object after you construct it. For example, after you call a constructor, you might want to set some other property on that object, or call one of its functions to initialize it - that is, to make the object ready for use.

val dropTarget = DropTarget().apply { 
    addDropTargetListener(myListener) 
}

With this, we can fill out the remaining spot on the chart:

Comparing apply() with run(), let(), and also(). obj. apply { } Returns context object obj. also { } Context object is lambda parameter obj. let { } Context object is implicit receiver Returns result of lambda obj. run { }

As you can see, the scope functions are all similar, but they differ on two things:

  1. How they refer to the context object.
  2. What they return.

I’ve omitted with() from this chart, because it’s the same thing as run(), except that it’s a traditional function instead of an extension function.

With all these scope functions to choose from, how do you know which one to use?

Choosing the Most Appropriate Scope Function

When deciding which scope function to use, start by asking yourself, “what do I need the scope function to return?”.

  1. If you need the lambda result, narrow your options down to either let() or run().
  2. If you need the context object, narrow your options down to either also() or apply().

After that, choose between the remaining two options based on your preference for how to represent the context object inside the lambda. If you need to use functions or properties on the object but not the object itself, then run() or apply() would probably be a good fit. Otherwise, let() or also() generally would be a good way to go.

A decision tree describing how to choose a scope function.

One other caveat - it’s possible to create a variable or lambda parameter with the same name as a variable from an outer scope, and it’s usually best to avoid that. Let’s look at that next.

Shadowing Names

When a nested scope declares a name for a variable, function, or class that’s also declared in an outer scope, we say that the name in the outer scope is shadowed by the name in the inner scope. Here’s very simple a simple example, where both a book and a chapter have a title.

Demonstrates shadowing. class Book( val title : String) { fun printChapter (number: Int , title: String) { println ( "Chapter $ number : $ title " ) } } Inside the function body here this property is shadowed by this parameter because they have the same name.

It’s perfectly valid to shadow names like this, but there are a few things to keep in mind.

  1. When you read code like this, it’s possible to get confused, thinking that you’re referring to the name from the outer scope.
  2. It is more difficult - and sometimes impossible - to refer to a variable that’s declared in an outer scope from an inner scope that has shadowed that variable’s name. Sometimes there are solutions - in the example above, you could still refer to the book’s title with this.title. In cases when the shadowed variable is at the top level, you can refer to it by prefixing it with the package name. But in some cases, your only option might be to rename one of the two names.

So in general, it’s best to avoid shadowing.

Shadowing and Implicit Receivers

An interesting form of shadowing happens when an implicit receiver is shadowed by the implicit receiver of a nested scope! And it works differently depending on whether you include or omit the this prefix! For example, let’s say we’ve got classes and objects for a Person and a Dog.

class Person(val name: String) {
    fun sayHello() = println("Hello!")
}

class Dog(val name: String) {
    fun bark() = println("Ruff!")
}

val person = Person("Julia")
val dog = Dog("Sparky")

Now, we can shadow the implicit receiver if we nest one with() call inside another with() call, like this:

with(person) {
    with(dog) {
        println(name)
    }
}

In the outer scope, the implicit receiver is person, but in the inner scope, the implicit receiver is dog:

In the outer scope, the implicit receiver is person. In the inner scope, it's dog. with ( person ) { with ( dog ) { println ( name ) } } Implicit receiver here is person Implicit receiver here is dog

As you’d probably guess, name inside that innermost scope refers to the name of the dog object, so it’s Sparky. You can also call bark() on that object.

with(person) {
    with(dog) {
        println(name) // Prints Sparky from the dog object
        bark()        // Calls bark() on the dog object
    }
}

But here’s the fun part - In that same scope, you can also call sayHello() on the person object without explicitly prefixing it with person!

with(person) {
    with(dog) {
        println(name) // Prints Sparky from the dog object
        bark()        // Calls bark() on the dog object
        sayHello()    // Calls sayHello() on the person object
    }
}

So, in this example, both person and dog contribute to the implicit receiver in that innermost scope. You can visualize it like this:

The effective implicit receiver is a combination of the outer scopes' implicit receivers and the innermost scope implicit receiver. Sparky println("Hello") println("Ruff!") bark() sayHello() name Effective Receiver Sparky println("Ruff!") bark() name Inner Scope Receiver Julia println("Hello") sayHello() name Outer Scope Receiver + =

In other words, the effective implicit receiver becomes a combination of all implicit receivers from the innermost scope to the outermost scope. When a name conflict exists (for example, both Person and Dog have a name property), priority is given to the inner scope.

Shadowing, Implicit Receivers, and this

Now, all of that is true when you use the implicit receiver without the this keyword. However, if you refer to the implicit receiver with the prefix this, it will only have the functions and properties from the implicit receiver of the innermost scope. In the example above, this will refer to the dog without any contributions from the person object.

To demonstrate this, let’s try adding this. before name, bark(), and sayHello():

with(person) {
    with(dog) {
        println(this.name) // Prints Sparky
        this.bark()        // Calls bark() on the dog object
        this.sayHello()    // Compiler error - Unresolved reference: sayHello
    }
}
Error

As you can see, it works fine for this.name and this.bark(), but this.sayHello() gives us an error, because this only refers to the dog.

So, just remember:

  1. When using this, it will only refer to the exact implicit receiver in that scope.
  2. When omitting this, the effective receiver is a combination of the implicit receivers, from the innermost to the outermost scope.

Before we wrap up this chapter, let’s look at how scope functions are used with null-safety features in Kotlin.

Scope Functions and Null Checks

Other than with(), all of the scope functions are extension functions. As with all extension functions, you can use the safe-call operator when calling them, so that they’re only actually called if the receiver is not null, as we saw in the previous chapter.

The safe-call operator is often used with scope functions. In fact, many Kotlin developers use let() with the safe-call operator to run a small block of code whenever the object is not null. For example, when we first learned about nulls in Chapter 6, we needed to make sure the code would only order coffee when the customer had a payment. Here’s a code snippet inspired by Listing 6.19 from that chapter.

if (payment != null) {
    orderCoffee(payment)
}

There’s absolutely nothing wrong with writing the code this way. However, it’s also common for Kotlin developers to write this code with a scope function and safe-call operator, like this:

payment?.let { orderCoffee(it) }

It’s good to be able to recognize both ways of expressing this. The second way is especially helpful when you need to insert it into a call chain, of course.

In some cases, you might also have an else with your conditional, like this:

if (payment != null) {
    orderCoffee(payment)
} else {
    println("I can't order coffee today")
}

To get the same effect with a scope function, you can use an elvis operator to express the else case, like this:

payment?.let { orderCoffee(it) } ?: println("I can't order coffee today")

Good ol’ fashioned if/else conditionals are easy to understand for most developers, though, so consider starting there, only using the scope function / safe-call / elvis approach for null checks when it fits better with the surrounding context, such as inside a call chain.

Summary

Enjoying this book?
Pick up the Leanpub edition today!

Kotlin: An Illustrated Guide is now available on Leanpub See the book on Leanpub

This chapter covered a lot of ground, including:

Code written with scope functions can be easier to read, but don’t overdo it! If you use scope functions everywhere, or if you start using one scope function inside the lambda of another, it can actually make your code more difficult to understand. Used properly, though, scope functions can be immensely helpful.

In the next chapter, we’ll start looking at abstractions, including interfaces, subtypes, and supertypes. See you then!

Thanks to James Lorenzen and Jayant Varma for reviewing this chapter!


  1. There are actually more than five scopes here. As mentioned above, parameter lists have their own scopes, but you’ll typically only declare parameters there. Also, depending on whether a class contains other things like secondary constructors, enum classes, and companion objects, there could be a “static” scope. In order to stay focused on the main concepts of scopes, we’ll ignore those for this chapter. If you’re curious, you can read all about them in the Declarations chapter of the Kotlin language specification. [return]
  2. The term context object is used in the official Kotlin documentation for scope functions, so I’m using it here as well. If you’re an Android developer, this could be confusing, since Android has a specific Context class. Keep in mind that these are two entirely different concepts. You can use a scope function with any object. [return]
  3. There’s actually also a top-level function called run(), which can be helpful when you need to squeeze multiple statements into a spot where Kotlin expects a single expression. We’re not going to cover that version of the function in this chapter, though. [return]

Share this article:

  • Share on Twitter
  • Share on Facebook
  • Share on Reddit