Kotlin スコープ関数(Scope functions) | let, with, run, apply, also

概要

Kotlinのスコープ関数(Scope functions)は、ある文脈(context)の中でコードを実行できるようにする。「文脈(context)」とは、使いたいオブジェクトがコード内でthisまたはitとして使用されることを意味する。Kotlinの標準ライブラリには「スコープ関数」と呼ばれる5つの関数がある。letwithrunapplyalsoである。これらの関数を使用するとコードを簡潔にできる。しかし、これらの関数を理解するには、まず「レシーバーオブジェクト(receiver)」を理解する必要があるため、それを見ていく。

レシーバーオブジェクト(Receiver object)

まず、レシーバーオブジェクトという用語はKotlinの拡張関数(Extension Function)で登場する。レシーバーオブジェクトを理解するには、先に拡張関数について知る必要がある。

拡張関数は、あるクラスのメンバーメソッドであるかのように呼び出せるが、そのクラスの外で宣言された関数である。たとえば、Kotlinで標準提供されているStringクラスの拡張関数を定義すると、新しいメンバーメソッドのように使用できる。

次の例は、Stringクラスの拡張関数を定義したものである。

// 拡張関数の定義
fun String.lastChar(): Char = this[this.length - 1]

標準のStringクラスには最後の文字を返す関数がないが、上のように拡張関数を定義すると、StringクラスのオブジェクトはlastChar()という関数を使って最後の1文字を取得できる。

拡張関数を定義する方法は、通常の関数を定義する方法に対して、funキーワードと関数名の間に拡張するクラス名とドットを付けるだけである。

では、この拡張関数を使ってみよう。

// 拡張関数の使用
val last: Char = "hello".lastChar()
println(last)

Output:

o

拡張関数を使ってStringオブジェクトの最後の文字を取得し、表示されることを確認できる。

上の拡張関数を定義する例では、通常の関数と異なる点がある。thisは本来、現在の関数が定義されたクラスを指し、トップレベル関数であればコンパイルエラーになる。しかし拡張関数でのthisは、拡張されたクラスのオブジェクト、つまり拡張関数を使用するそのオブジェクトを意味する。そのオブジェクトがレシーバーオブジェクトであり、拡張するクラスの型がレシーバー型である。

Receiver type and object

では、レシーバーオブジェクトは何を受け取るのだろうか。 『Kotlin in Action』では、レシーバーオブジェクトを「拡張関数が呼び出される対象となる値(オブジェクト)」と説明している。 「受信」を何かを受け取るという意味で見るなら、拡張関数のコードを実行する対象になるため、「オブジェクトがコードを受け取る」という意味で考えるとよい。 文字どおり、拡張関数を実行する値(オブジェクト)だと考えればよい。

普段よく使う通常の関数で見るなら、次のようになる。

// Kotlinの基本関数形式
fun lastChar(str: String): Char = str[str.length - 1]

上ではstrがレシーバーオブジェクトだと考えればよい。

レシーバー付きラムダ(Lambda with receiver)

レシーバー付きラムダとは、ラムダ式の中で特定オブジェクトのメソッドを呼び出せるラムダを意味し、レシーバーオブジェクトを明示せずにラムダ本文の中で別のオブジェクトのメソッドを呼び出せるようにするものである。

「スコープ関数」という名称について

Kotlin公式ブログの次の記事で使われている「scope functions」に由来する。

What’s new in Standard Library M13 and M14

Prior to M13 there were two so-called scope functions in the Standard Library: let and with. We call them scope functions, because their only purpose is to modify scope of a function passed as the last parameter.

訳すと、letwithで定義された関数の目的は、パラメータとして渡された関数のスコープを変更することなので、「scope functions」と呼んでいるということである。

スコープ関数(Scope functions)

次の定義を見るときは、ジェネリック略語の意味を覚えておくと理解が早い。

  • E: Element
  • K: Key
  • N: Number
  • T: Type
  • V: Value
  • R: Return Type

let

基本定義は次のとおりである。

inline fun <T, R> T.let(block: (T) -> R): R

見れば分かるように、letは任意の型の拡張関数である。

簡単な例を見てみよう。

fun main() {
    val str = "devkuma".let { it.uppercase() }
    println(str)
}

Output:

DEVKUMA

上の例では、Stringである"devkuma"がレシーバーオブジェクトになる。このレシーバーオブジェクトをletは関数のパラメータ(it)として受け取り、大文字に変更して返している。

主にNullable変数でよく使われる。つまり、オブジェクトがnullではない場合にコードを実行するときに使う。Null pointerエラーの発生を防ぐときにかなり便利なスコープ関数である。よく使うコードとして、JavaのOptionalにおける次の3つの方法の役割に近い。

  • map
  • flatMap
  • ifPresent

たとえば、String?変数fooの大文字を取得したい場合は、次のように書ける。

fun main() {  
    var foo: String? = null
    // var foo: String = "foo"
    val upperCase: String? = foo?.let { it.uppercase() }
}

fooがnullの場合は?.呼び出しによってletが実行されずnullが返され、fooがnullではない場合はletit.uppercase()が実行され、fooを大文字に変換して返す。

追加で、上のような場合にはfoo?.let(String::uppercase)のようにも書ける。

with

withの基本定義は次のとおりである。

inline fun <T, R> with(receiver: T, block: T.() -> R): R

withletと異なり、拡張関数ではない。最初の引数に任意の型Tを受け取る。2番目の引数として関数を持つが、Tを受け取って使用するメソッドとして必要になる。

簡単な例を見てみよう。

fun main() {
    val str = with("devkuma") { this.uppercase() }
    println(str)
}

Output:

DEVKUMA

上の例で{ this.uppercase() }this"devkuma"を意味する。もちろんthisは任意なので、{ uppercase() }のように書いてもよい。

主にnullになり得ないオブジェクトに対する複数の操作を、すっきり書くために使われる。

たとえば、次のようにオブジェクトの値を出力できる。

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        println("'with' is called with argument $this")
        println("It contains $size elements")
    }
}

Output:

'with' is called with argument [one, two, three]
It contains 3 elements

また、別の例として、インスタンスを1つ生成して各種設定を行う場合が多いだろう。

import java.awt.Dimension
import javax.swing.JFrame

fun main() {
    val frame: JFrame = with(JFrame("My App")) {
        size = Dimension(600, 400)
        defaultCloseOperation = JFrame.EXIT_ON_CLOSE
        setVisible(true)
        this
    }
}

上の例でのsizeプロパティとsetVisibleメソッドはJFrameのメンバーである。つまり、このような文脈ではthisは任意なので、繰り返し書く必要なく省略できる。

上のような場合にletを使うのは適していない。なぜなら、sizeおよびsetVisibleへアクセスするたびにitが必須になり、it.sizeit.setVisible(true)のように記述しなければならないからである。

上の例のように、withの引数として渡されたインスタンスに対していろいろ行った後、そのインスタンス自体を返すなら、下のapplyのほうが適しているかもしれない。

run

runの基本定義は次のとおりである。

inline fun <T, R> T.run(block: T.() -> R): R

letwithが合わさったように定義されている。runはすべての型Tの拡張関数で、そのTを受け取るメソッドを引数として受け取る。

簡単な使用例を見てみよう。

fun main() {
    val str = "devkuma".run { uppercase() }
    println(str)
}

Output:

DEVKUMA

runletwithの合体版で、オブジェクトに含まれる関数を実行し、その結果を返すときに使う。たとえば、次のようになる。

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    val countEndsWithE = numbers.run {
        add("four")
        add("five")
        count { it.endsWith("e") }
    }
    println("numbers: $numbers")
    println("There are $countEndsWithE elements that end with e.")
}

Output:

numbers: [one, two, three, four, five]
There are 3 elements that end with e.

上の例では、Listであるnumbersに含まれるadd関数をrun内部で実行しており、最後の式でeで終わる文字列の個数をcountEndsWithE変数に返している。

インスタンスを1つ生成して各種設定を行う場合は、次のようになる。

import java.awt.Dimension
import javax.swing.JFrame

fun main() {
    val frame: JFrame? = frameOrNull()
    frame?.run {
        size = Dimension(600, 400)
        defaultCloseOperation = JFrame.EXIT_ON_CLOSE
        setVisible(true)
    }
}

fun frameOrNull(): JFrame? {
    // return null
    return JFrame("My App")
}

apply

applyの基本定義は次のとおりである。

inline fun <T> T.apply(block: T.() -> Unit): T

簡単な使用例を見てみよう。

fun main() {
    val str = "devkuma".apply { uppercase() }
    println(str)
}

Output:

devkuma

今回は他の例と異なり、変数strを出力すると小文字で表示される。applyは引数として受け取った関数を実行して何らかの処理は行うが、戻り値はレシーバーオブジェクトだからである。

withでレシーバーオブジェクトを返そうとするときに使うバージョンと言える。withを使ったコードと比較してみよう。

val frame: JFrame = JFrame("My App").apply {
  size = Dimension(600, 400)
  defaultCloseOperation = JFrame.EXIT_ON_CLOSE
  setVisible(true)
}

withを使ったときは引数関数の最後の戻り値としてthisを明示したが、applyはレシーバーオブジェクトを返すため、thisを明示しなくてもよい。

また、applyはオブジェクトのproperty値を適用するときによく使われる。あるオブジェクトを宣言するとき、コンストラクタだけで値を設定できないなら、applyを通じて値を別途付け加え、連続的に設定できる。次の2つのコードはどちらも同じ結果になるが、apply関数を使った場合のほうが明示的である。

data class Person(val name: String) {
    var age: Int? = null
    var city: String? = null
}

fun main() {
    val person1 = Person("kimkc").apply {
        age = 21
        city = "Seoul"
    }

    val person2 = Person("kimkc")
    person2.age = 21
    person2.city = "Seoul"

    println(person1 == person2)
}

also

alsoはver 1.1で標準ライブラリに追加された拡張関数である。

alsoの基本定義は次のとおりである。

inline fun <T> T.also(block: (T) -> Unit): T

基本定義から分かるように、applyとほぼ同じである。違いは「引数が元のレシーバーオブジェクトの拡張関数ではなく、元のレシーバーオブジェクトを引数とする関数である」という点である。

利点は2つある。まず名前を付けられる。引数の関数をラムダ式として書くと、元のレシーバーオブジェクトであるitを参照する必要があるが、何か意味のある名前を付けたい場合(nameやlabelなど)、コードの可読性が高まる。そしてもう1つの利点は、ラムダ式の内外でthisの意味が変わらないことである。

alsoを使った簡単な例を見てみよう。

fun main() {
    val str = "devkuma".also { it.uppercase() }
    println(str)
}

Output:

devkuma

では、applyと比較してみよう。次のコードはAndroidでapplyを使った例である。

val button = Button(this).apply {
  text = "Click me"
  setOnClickListener { 
    startActivity(Intent(this@MainActivity, NextActivity::class.java))
    // 単なるthisはエラーになる     ^
  }
}

上のコードをalsoを使って変換した例である。

val button = Button(this).also { button -> 
  button.text = "Click me"
  button.setOnClickListener { 
    startActivity(Intent(this, NextActivity::class.java))
  }
}

最後に、次の例はletを使った例である。

val button = Button(this).let { button -> 
  button.text = "Click me"
  button.setOnClickListener { 
    startActivity(Intent(this, NextActivity::class.java))
  }
  button // letの場合、このコードが必ず必要である。
}

「元のレシーバーオブジェクトを引数として受け取る関数」を引数にするletとの違いは、letは戻り値を自分で決めるのに対し、alsoは元のレシーバーオブジェクトが返される点である。

参考