スポンサーリンク

2016年11月18日金曜日

Kotlin の let(), apply(), run(), with() を使いこなす

始めに

Kotlin の標準ライブラリの中に let(), apply(), run(), with() という一連の関数があります。これらは Standard.kt という数十行の短かいソースコードの中で、それぞれ一行で定義されています。

public inline fun <T, R> T.let(block: (T) -> R): R = block(this)
public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()
public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }
public inline fun <R> run(block: () -> R): R = block()

これを一目見て理解できる人は相当な強者です。初心者にとっては宇宙語を読んでいるようで、まず理解できないでしょう。

これらの関数はその定義のシンプルさとは裏腹に強力なパワーを秘めています。これらが使いこなせるようになると、Kotlin の表現力がぐっと増します。

どの関数も引数に関数オブジェクトをとり、あるクラスインスタンスに対してその関数オブジェクトを適用する、という基本機能を持っています。しかしそれぞれ実装が違うため、使い方が微妙に異なります。


レシーバと拡張関数

各関数の説明に入る前に基礎知識としてレシーバ機能と拡張関数を説明しておきます。

Kotlin にはレシーバと呼ばれる機能があります。レシーバは、他のクラスのメンバを、あたかも自分のクラスのメンバであるかのように、this 識別子を使ってアクセスできるようにする機能です。このレシーバ機能を巧みに使ったのが拡張関数です。

拡張関数を使うとクラスを継承せずに、メソッドやプロパティを拡張することができます。例えば次の例を考えます。

fun String.lastChar(): Char {
    return this.get(this.length - 1)
}

これは省略すれば以下のように書けます。

fun String.lastChar() = this.get(this.length - 1)

更に省略するとこうにも書けます。

fun String.lastChar() = this[length - 1]

もう何がなんだか。

とにかく、これは String に lastChar() メソッドを追加(拡張)している例です。これをトップレベルで宣言しておくと、 "abcdefg".lastChar() みたいに、どんな String からでもこの拡張メソッドを呼ぶことができるようになります。

Java の枠組みの中でどうやって実現しているのかというと、仕組みは簡単で、元のクラスに本当にメソッドを追加するのではなく、別のクラスを作成してその中に static なメソッドを作成します。そして元クラスからのドット表記でそのメソッドを呼び出せるようにしているだけです。

拡張関数の中では this キーワードで呼び出し元のクラスメンバ(上の例では String のメンバ)にアクセスできるため、あたかも元のクラスのメンバであるかのように記述できます。ここでレシーバ機能が使われています。

let()

let() はどんなオブジェクトからも呼び出せる拡張関数です。なので任意のオブジェクトから

foo.let( ... )

といった風に呼び出すことができます。

let() の引数は関数オブジェクト一つです。通常はラムダ式で記述し、引数の最後のラムダ式は括弧の外に出すことができるルールがあるので、以下の様に記述します。

foo.let {
  ...
}

let() の戻り値はラムダ式の最後に実行されたコードになります。

let()によるスコープ限定
さて、let() を使ったイディオム一つに変数のスコープを限定する、というものがあります。以下の例を見てください。

File("foo").let {
    it.mkDirs()      // Fileインスタンスは it で参照できる
    println(it.name)
    ...
}

Fileインスタンスを作成して、そこから let() を呼び出しています。ラムダ式の中からは、it を使って呼び出し元のインスタンス(この場合は File インスタンス)を参照することができます。it は、ラムダ式の省略時のデフォルト引数名なので、気に入らなければ明示的に他の名前を使うこともできます。

File("foo.txt").let { file ->
    file.mkDirs()      // Fileインスタンスは file で参照できる
    println(file.name)
    ...
}

ここで重要なのは File インスタンスへのアクセスがラムダ式の中に限定され、外部からは一切参照できないことです。つまりスコープがラムダ式の内部に限定されているのです。また一時的なワーク変数などもこのラムダ式の中で定義して使えば、他と衝突する心配がありません。

let()による null-safe アクセス
let() のもう一つのイディオムとして、nullable な変数を安全に参照する方法があります。

val file: File? = ...
file?.let {
    it.mkDirs()
    println(it.name)
    ....
}


.? 演算子(safe call operator) を使って let() を呼び出します。こうすると変数 file が null でない場合のみ let() が実行されるので、ラムダ式の中では it が null でないことが保証されます。

これは if (file != null) { ... } の代替え手段として使えます。しかし可読性の点で優れているかというと、ちょっと微妙な気がします。また、let() の方法は else 節が記述できないので、null で何らかの処理が必要な場合は使えません。

apply()

apply() も任意のオブジェクトから呼び出せる拡張関数として機能します。使い方も let() とほぼ同じですが、呼び出し元オブジェクトはレシーバとして参照されるので(it ではなく) this を使います。更に this は省略可能なのでより記述が簡潔になります。

上の null-safe の例を apply() を使って書くと以下の様になります。

val file: File? = ...
file?.apply { 
    mkdirs()
    println(name)
    ...
}

(let() と違い)何の識別子も無しに呼び出し元オブジェクトのメンバにアクセスしています。実際は、省略された this を通して、拡張関数のレシーバオブジェクトにアクセスしていることに注意して下さい。

apply() 関数の戻り値はレシーバオブジェクト本体、つまり呼び出したインスタンスそのものになります。これも let() との違いです。

run()

run() は普通の関数として直に呼び出すものと、let(), apply() の様に、任意のオブジェクトの拡張関数として呼び出すものの二種類が定義されています。

まず普通の関数として実行するものから。これは非常に簡単です。単純に関数オブジェクトを実行する方法はいくつかありますが、その一つと考えるとよいでしょう。

val sum1 = { 1 + 2 }()
val sum2 = { 1 + 2 }.invoke()
val sum3 = run { 1 + 2 }

こうして眺めると、記述方法として run() を使うのが一番自然な感じがします。しかしこれ意味があるのかというと、よく分かりません。単に val sum = 1 + 2 と書くのと変わりませんから。まあ、ラムダ式を使って書くから、こういう変なことになるのであって、変数で渡された関数オブジェクトを実行するような場合は意味があるのかも知れません。

拡張関数としての run() は apply() と似ています。呼び出し元オブジェクトは、レシーバとして this で参照されます(そして省略可能です)。唯一の違いは、戻り値がレシーバ本体ではなく、ラムダ式の最後の実行コードになる事です。(これは let() と同じ)

null-safe の例を run() で書くと以下の様になります。

val file: File? = ...
file?.run {
    mkdirs()
    println(name)
    ...
}

見た目は関数名が違うだけで、apply() と全く同じです。

with()

他の三つと違い with() は拡張関数ではありません。第一引数に操作対象のクラスインスタンス、第二引数に関数オブジェクトを取る普通の関数です。

操作対象(第一引数)のクラスはレシーバとして登録されるので、ラムダ式の中からは this でアクセス可能です。つまり省略して直接メンバにアクセスできます。

width() の戻り値はラムダ式の最後の実行コードとなります。

null-safe の例を with() で書くと以下の様になります。

val file: File? = ...
with(file!!) {
    mkdirs()
    println(name)
    ...
}

ただし、これは他と同じ意味での null-safe ではありません。!! 演算子なので、null だった場合には例外が発生します。

整理

さて頭が混乱してきたと思うので、それぞれの特徴を整理しておきます。

形態 オブジェクト参照 戻り値
let() 拡張関数 it 最後の実行コード
apply() 拡張関数 this 呼び出し元オブジェクト
run() 拡張関数/通常関数 this 最後の実行コード
with() 通常関数 this 最後の実行コード

実行例

Android の Paint オブジェクトをそれぞれの関数を使って初期化する例を以下に示します。

    val paintByLet   = Paint()
    val paintByApply = Paint()
    val paintByRun   = Paint()
    val paintByWidth = Paint()

    init {
        paintByLet.let {
            it.color = Color.GREEN
            it.style = Paint.Style.STROKE
            it.isAntiAlias = true
            it.textSize = 30.0f
        }

        paintByApply.apply {
            color = Color.MAGENTA
            style = Paint.Style.STROKE
            isAntiAlias = true
            textSize = 50.0f
        }

        paintByRun.run {
            color = Color.CYAN
            style = Paint.Style.STROKE
            isAntiAlias = true
            textSize = 60.0f
        }

        with(paintByWidth) {
            color = Color.RED
            style = Paint.Style.STROKE
            isAntiAlias = true
            textSize = 40.0f
        }
    }

もう一つ例を示します。例えば以下のような Java の関数があったとします。

    @Nullable
    String findUserName(int userId) {
        String userName;
        User user = findUser(userId);
        if (user != null) {
            userName = user.getUserName();
        } else {
            userName = null;
        }
        return userName;
    }

これを let() や run() を使うと、それぞれ以下の様に一行で書くことができます。

fun findUserNameByLet(userId: Int) : String? = findUser(userId)?.let { it.userName }

fun findUserNameByRun(userId: Int) : String? = findUser(userId)?.run { userName }

確かに短かくはなりますが、読み易さの点ではどうかな?という気がします。Java 頭から完全に抜けきれていないと、ちょっと抵抗があります。しかしこういうのに慣れないとKotlinプログラマーとして一流でないのかも知れません。

まとめ

let(), apply(), run(), with() は Kotlin の標準ライブラリ関数でありながら、公式ドキュメントでもまり詳しく説明されていません。目的はほぼ同じですが、微妙に異なる実装してみたら色々なものが出来てしまった、といったような印象を受けます。使い分けがよく分からなくて、皆さん戸惑っているようです。

これらを使いこなせるとKotlinの楽しさがぐっと増します。しかし状況に応じて使い分ける必要がある程違いがあるとは思えません。どれか一つお気に入りのものを常に使って、他は全く使わないというので良いかと思います。

それにしても冒頭で示したように、これらの関数が全てワンライナーだということは驚きです。こういったコードを自由に「読める」ではなく「書ける」ようになりたいものです。

0 件のコメント :

コメントを投稿