4

Scopes and Scope Functions

 2 years ago
source link: https://typealias.com/start/kotlin-scopes-and-scope-functions/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
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

Listing 11.1

-

Declaring a variable in a top-level file scope.

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

A top-level file scope.valpi =3.14Top-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
}

Listing 11.2

-

Adding a Circle class to create another scope.

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.valpi =3.14classCircle(varradius: Double) {valdiameter = radius *2}Class Body ScopeTop-Level File Scope

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

By the way: Parameter Scopes

Technically, there’s a third scope here. The parameter list of the Circle constructor (where radius is declared) also has its own scope. For simplicity, we’ll mostly ignore parameter scopes in this chapter.

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.valpi =3.14classCircle(varradius: Double) {valdiameter = 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.valpi =3.14classCircle(varradius: Double) {valdiameter = radius *2funcircumference(): Double {valresult = pi * diameterreturnresult}}Class Body ScopeFunction Body ScopeTop-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.valpi =3.14classCircle(varradius: Double) {valdiameter = radius *2funcircumference(): Double {valresult = pi * diameterreturnresult}}funcreateCircles(radii: List<Double>): List<Circle> {returnradii.map{ radiusCircle(radius)}}Class Body ScopeFunction Body ScopeFunction Body ScopeLambda ScopeTop-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.valpi =3.14classCircle(varradius: Double) {valcircumference = radius *2* pi}Declaring piUsing 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
    }
}

Listing 11.3

-

Error: Unresolved reference: diameter

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
    }
}

Listing 11.4

-

Declaring the diameter() function before using it in a statement scope.

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
}

Listing 11.5

-

Using the diameter() function before declaring it in a declaration scope.

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
}

Listing 11.6

-

Error: Variable diameter must be initialized.

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
    }
}

Listing 11.7

-

Code that will be used to demonstrate scope crawling.

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.valpi=3.14funmain() {valradii =listOf(1.0,2.0,3.0)classCircle(valradius: Double) {funcircumference(): Double {valmultiplier =2.0Which variables are visible here?valdiameter =radius* multiplierreturnmultiplier *pi*radius}valarea=pi*radius*radius}valareas = radii.map{Circle(it).area}}Declaration ScopeStatement ScopeDeclaration ScopeStatement ScopeStatement 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.valpi=3.14funmain() {valradii =listOf(1.0,2.0,3.0)classCircle(valradius: Double) {funcircumference(): Double {valmultiplier =2.0Which variables are visible here?valdiameter =radius* multiplierreturnmultiplier *pi*radius}valarea=pi*radius*radius}valareas = radii.map{Circle(it).area}}Declaration ScopeStatement ScopeDeclaration ScopeStatement ScopeStatement 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.valpi=3.14funmain() {valradii =listOf(1.0,2.0,3.0)classCircle(valradius: Double) {funcircumference(): Double {valmultiplier =2.0Which variables are visible here?valdiameter =radius* multiplierreturnmultiplier *pi*radius}valarea=pi*radius*radius}valareas = radii.map{Circle(it).area}}Declaration ScopeStatement ScopeDeclaration ScopeStatement ScopeStatement 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.

By the way: Parameter Scopes, Again!

For simplicity, I’m treating the parameter lists (such as the one containing the radius constructor parameter here) as part of the function or class body that they pertain to. Technically, a parameter list has its own scope, which is actually adjacent to its corresponding function or class body scope. However, these parameter scopes are linked to their body scope, which makes the parameters visible inside that body.

We’ll see other examples of linked scopes in the future as we explore inheritance in the upcoming chapters.

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.valpi=3.14funmain() {valradii =listOf(1.0,2.0,3.0)classCircle(valradius: Double) {funcircumference(): Double {valmultiplier =2.0Which variables are visible here?valdiameter =radius* multiplierreturnmultiplier *pi*radius}valarea=pi*radius*radius}valareas = radii.map{Circle(it).area}}Declaration ScopeStatement ScopeDeclaration ScopeStatement ScopeStatement 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.valpi=3.14funmain() {valradii =listOf(1.0,2.0,3.0)classCircle(valradius: Double) {funcircumference(): Double {valmultiplier =2.0Which variables are visible here?valdiameter =radius* multiplierreturnmultiplier *pi*radius}valarea=pi*radius*radius}valareas = radii.map{Circle(it).area}}Declaration ScopeStatement ScopeDeclaration ScopeStatement ScopeStatement 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!

Best Practice

It’s usually a good idea to give things the least amount of visibility necessary. This is especially true for variables that are declared with the var keyword. When these kinds of variables are declared at the top-level scope, they can be evaluated and assigned from anywhere in your code. This can make it difficult to know when and why the value is changing as your code runs.

By limiting its visibility, there are fewer places in the code that are able to use or change the value. Troubleshooting becomes much easier when you have less code to sift through!

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

Introduction to Scope Functions

art-telescope.png

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"

Listing 11.8

-

Updating many properties of an address object.

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"
}

Listing 11.9

-

Using with() so that each property assignment does not need to be prefixed with address.

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 objectLambda

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"
}

Listing 11.10

-

Using run() so that each property assignment does not need to be prefixed with address.

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 objectLambda

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'

Listing 11.11

-

Code assembled from listings in Chapter 10.

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'

Listing 11.12

-

Replacing the singleQuoted() extension function with a call to the run() function.

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 isimplicit receiverReturns resultof lambdaobj.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'

Listing 11.13

-

Single-quoting a title with the let() function.

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'

Listing 11.14

-

Single-quoting a title with the let() function, using the implicit it lambda parameter.

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

Comparing let() with run().Context object islambda parameterobj.let{ }Context object isimplicit receiverReturns resultof lambdaobj.run{ }

By the way: `let()` vs `map()`

In some ways, the let() function is similar to the map() function that we learned about in Chapter 8 when we looked at collection operations. However, whereas the map() function maps each element in the receiver, the let() function maps the receiver itself.

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'

Listing 11.15

-

Using also() in a call chain to print out a value.

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'

Listing 11.16

-

Using also() with a function reference.

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

Comparing also() with run() and let().Returnscontext objectobj.also{ }Context object islambda parameterobj.let{ }Context object isimplicit receiverReturns resultof lambdaobj.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'

Listing 11.17

-

Using apply() to print out the value in a call chain. This works, but most Kotlin developers favor also() for this situation.

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) 
}

Listing 11.18

-

Using apply() to initialize a DropTarget immediately after constructing it. It’s constructed and initialized in a single expression.

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

Comparing apply() with run(), let(), and also().obj.apply{ }Returnscontext objectobj.also{ }Context object islambda parameterobj.let{ }Context object isimplicit receiverReturns resultof lambdaobj.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.classBook(valtitle: String) {funprintChapter(number: Int,title: String) {println("Chapter$number:$title")}}Inside the function body herethis property is shadowed bythis parameter because theyhave 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")

Listing 11.19

-

Two classes, and two objects, which will be used to demonstrate shadowing of implicit receivers.

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)
    }
}

Listing 11.20

-

Shadowing the person implicit receiver with the dog implicit receiver.

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 personImplicit 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
    }
}

Listing 11.21

-

Invoking a property and a function on the dog implicit receiver.

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
    }
}

Listing 11.22

-

Invoking a function on the person implicit receiver from a scope where dog is the primary implicit receiver.

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.Sparkyprintln("Hello")println("Ruff!")bark()sayHello()nameEffective ReceiverSparkyprintln("Ruff!")bark()nameInner Scope ReceiverJuliaprintln("Hello")sayHello()nameOuter 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
    }
}

Listing 11.23

-

Error: Unresolved reference: sayHello

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)
}

Listing 11.24

-

Using a conditional to call orderCoffee() only when payment is present, roughly copied from Chapter 6.

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) }

Listing 11.25

-

Using a scope function to call orderCoffee() only when payment is present.

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")
}

Listing 11.26

-

A simple if-else conditional that checks for nulls.

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")

Listing 11.27

-

Rewriting the if-else null-check conditional so that it uses a scope function.

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

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:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK