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.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 adiameter
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:
- The top-level file scope, where the
pi
variable and theCircle
class are declared.- The class body scope of the
Circle
class, wherediameter
is declared.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.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!
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.
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
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
, orclass
keyword. When you evaluate a variable, call a function, and so on, you’re using it.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 thecircumference()
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 } }
ErrorWe 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 thediameter()
function is declared in the class body (a declaration scope) instead of thecircumference()
function body (a statement scope). We’ll also changecircumference
to a property to make that line similar to theresult
assignment in Listing 11.3.class Circle(val radius: Double) { val circumference = pi * diameter() fun diameter() = radius * 2 }
Now, even though
diameter()
is declared aftercircumference()
, 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()
anddiameter()
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 }
ErrorAs 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:
- If you crawl into a statement scope, you can only use things declared earlier in the scope.
- 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.
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.
When we scan upward, we run into the
multiplier
variable. So, themultiplier
variable is visible at the starting point. Thediameter
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 in both directions, we come across both the
radius
parameter, which is declared before the function, andarea
, 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.Scanning upward, we run into the
radii
variable, which is also visible at the starting point. However, theareas
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.
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, seeingaddress
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 theaddress
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:
- The object that you want to become an implicit receiver. This is the context object. Here, it’s
address
.- A lambda in which the context object will be the implicit receiver.
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 towith()
.
run()
The
run()
function works the same aswith()
, 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 usesrun()
instead ofwith()
.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.
Even though
run()
andwith()
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 calledsingleQuoted()
(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 thesingleQuoted()
function entirely, and replace it with a simple call torun()
, 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 thesingleQuoted()
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 ofwith()
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:
- Inside the lambda, the context object is represented as the implicit receiver.
- The
run()
function returns the result of the lambda.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 torun()
, 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 uselet()
instead ofrun()
.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 calledtitleWithoutPrefix
. 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()
andwith()
, thelet()
function returns the result of the lambda.A scope function that’s similar to
let()
is calledalso()
. Let’s look at that next.
also()
As with
let()
, thealso()
function represents the context object as the lambda parameter, too. However, unlikelet()
, which returns the result of the lambda, thealso()
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 oftitle.removePrefix("The ")
, without interfering with the rest of the call chain. Regardless of whether we include or omit the line with thealso()
call, thesingleQuoted()
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 amongrun()
andlet()
.: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()
, theapply()
function returns the context object rather than the result of the lambda. However, likerun()
, theapply()
function represents the context object as the implicit receiver. We can update Listing 11.15 to useapply()
instead ofalso()
, 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. Theapply()
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:
As you can see, the scope functions are all similar, but they differ on two things:
- How they refer to the context object.
- What they return.
I’ve omitted
with()
from this chart, because it’s the same thing asrun()
, 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?”.
- If you need the lambda result, narrow your options down to either
let()
orrun()
.- If you need the context object, narrow your options down to either
also()
orapply()
.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()
orapply()
would probably be a good fit. Otherwise,let()
oralso()
generally would be a good way to go.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
.It’s perfectly valid to shadow names like this, but there are a few things to keep in mind.
- When you read code like this, it’s possible to get confused, thinking that you’re referring to the name from the outer scope.
- 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 aPerson
and aDog
.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 anotherwith()
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 isdog
:As you’d probably guess,
name
inside that innermost scope refers to thename
of thedog
object, so it’sSparky
. You can also callbark()
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 theperson
object without explicitly prefixing it withperson
!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
anddog
contribute to the implicit receiver in that innermost scope. You can visualize it like this: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
andDog
have aname
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 prefixthis
, it will only have the functions and properties from the implicit receiver of the innermost scope. In the example above,this
will refer to thedog
without any contributions from theperson
object.To demonstrate this, let’s try adding
this.
beforename
,bark()
, andsayHello()
:with(person) { with(dog) { println(this.name) // Prints Sparky this.bark() // Calls bark() on the dog object this.sayHello() // Compiler error - Unresolved reference: sayHello } }
ErrorAs you can see, it works fine for
this.name
andthis.bark()
, butthis.sayHello()
gives us an error, becausethis
only refers to thedog
.So, just remember:
- When using
this
, it will only refer to the exact implicit receiver in that scope.- 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
This chapter covered a lot of ground, including:
- What a scope is, and how it affects visibility of things like variables, functions, and classes.
- The difference between statement scopes and declaration scopes.
- The five scope functions - with, run, let, also, and apply.
- Guidance about how to choose the most appropriate scope function.
- How shadowing affects names and receivers.
- How to use scope functions for null checks.
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!
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. ↩︎
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. ↩︎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. ↩︎