はじめに
スコープ関数をまだ活用していない方に向けて解説いたします。
本記事だけでも、スコープ関数の雰囲気はつかめるとは思います。しかし、ふんわりとした主観的説明にとどまっておりますので、客観的な厳密な定義の説明を一切しておりません。
スコープ関数の詳しい説明は、「Kotlin スコープ関数 用途まとめ」が、とてもよくまとまっているので、そちらをご参照ください。
動機
「Kotlin スコープ関数 用途まとめ」の中で、
個人的な意見として、原則 nullableのためにletを使うのみに留めたいところです。
そもそも、このような記事を書いて、それなりに閲覧されている程度には、スコープ関数は難しいからです。
と書かれていましたが、私は多用していました。過去形なのは、kotlinでの開発から離れて数年経っているからです。では、なぜこの記事を書くことにしたのでしょうか?
私はここ数日、Javascriptでプログラムを書いています。そのときにスコープ関数が欲しいなと思いました。Javascriptに限らずkotlin以外の言語でスコープ関数を提供している言語を私は知りません。
Javascriptでスコープ関数を自作しようと思って、スコープ関数を復習するにあたり、先にあげたリンク先を覗いたのでした。かつ、kotlinを書いてた頃は、そのリンク先にお世話になったような気がします。
そして引用したように、スコープ関数を利用するのに消極的な見解に出会いました。4〜5年前の最初に読んだ当時、そんな見解が記述されていたかどうかは記憶にないですが、私は確かにスコープ関数を頻繁に利用していました。引用にあったnullable対策のletももちろん使っていましたが、それ以外にもたくさん使っていました。
私の個人的見解になりますが、使いたい人は使えば良いのではないかという意見になります。なにしろjavascriptを書いていてスコープ関数が欲しいと思ったほどなので。
let, also, run, applyの使い分けが微妙で難しいと感じるかもしれないのは同意します。しかし、言うほど難しくもないような気がします。
引用したリンク先のような説明も重要ですが、スコープ関数を使ったコードを読んだり、書いたりするプログラマの利用しているときの気持ちを分かってもらえれば、もっと理解を深めていただけるのではと思い、記事にした次第です。
解説
スコープ関数を利用したプログラム
後の説明の都合により、あえて色付けしていないソースコードを下に示します。スコープ関数を解説するために冗長な書き方をしています。そのためリファクタリングして無駄を排したプログラムを最後に載せています。
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
class Person(var name: String, var bthdate: String)
fun makeSentence(p: Person): String {
val name = p.name
val weekday = p.bthdate.let {
val date = LocalDate.parse(it)
val formmater : DateTimeFormatter = DateTimeFormatter.ofPattern("E", Locale.JAPANESE)
date.format(formmater)
}
return "%sは%s曜日に生まれました。".format(name, weekday)
}
fun main() {
val taro = Person().also {
it.name = "太郎"
it.bthdate = "2001-01-01"
}
val jiro = Person().also {
it.name = "次郎"
it.bthdate = "2002-02-02"
}
val saburo = Person().also {
it.name = "三郎"
it.bthdate = "2003-03-03"
}
println(makeSentence(taro))
println(makeSentence(jiro))
println(makeSentence(saburo))
}
出力結果
太郎は月曜日に生まれました。
次郎は土曜日に生まれました。
三郎は月曜日に生まれました。
スコープ関数を使わないプログラム
使った場合と使わない場合を比較して説明したいので、以下にスコープ関数を利用しない版を書きます。これもあえて色付けしていないものをのせます。
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
class Person(var name: String? = null, var bthdate: String? = null)
fun makeSentence(p: Person): String {
val name = p.name
val date = LocalDate.parse(p.bthdate)
val formmater : DateTimeFormatter = DateTimeFormatter.ofPattern("E", Locale.JAPANESE)
val weekday = date.format(formmater)
return "%sは%s曜日に生まれました。".format(name, weekday)
}
fun main() {
var taro = Person()
taro.name = "太郎"
taro.bthdate = "2001-01-01"
var jiro = Person()
jiro.name = "次郎"
jiro.bthdate = "2002-02-02"
var saburo = Person()
saburo.name = "三郎"
saburo.bthdate = "2003-03-03"
println(makeSentence(taro))
println(makeSentence(jiro))
println(makeSentence(saburo))
}
also
also関数を使っている場所のコードは以下のところです。
val taro = Person().also {
it.name = "太郎"
it.bthdate = "2001-01-01"
}
val jiro = Person().also {
it.name = "次郎"
it.bthdate = "2002-02-02"
}
val saburo = Person().also {
it.name = "三郎"
it.bthdate = "2003-03-03"
}
alsoを使ってないのところの抜粋は
var taro = Person()
taro.name = "太郎"
taro.bthdate = "2001-01-01"
var jiro = Person()
jiro.name = "次郎"
jiro.bthdate = "2002-02-02"
var saburo = Person()
saburo.name = "三郎"
saburo.bthdate = "2003-03-03"
の部分です。心の中は、
- 太郎のインスタンスを作ります。
- そして、名前をセットします。
- そして、誕生日をセットします。
- そして、次郎のインスタンスを作ります。
- ...(以下略)
というように、のべーっと平坦にどの行も均等に感じます。
alsoを使った場合、私の心の目では、以下のように見えてます。
val taro = Person().also {
it.name = "太郎"
it.bthdate = "2001-01-01"
}
val jiro = Person().also {
it.name = "次郎"
it.bthdate = "2002-02-02"
}
val saburo = Person().also {
it.name = "三郎"
it.bthdate = "2003-03-03"
}
- 太郎のインスタンスを作ります。
- (ついでに、名前と誕生日もセットします。) - 次郎のインスタンスを作ります。
- (ついでに、名前と誕生日もセットします。) - ...(略)
とアクセントがついたというか、行の強弱を感じます。コードにリズムが出てきます。
変数に、alsoの左の部分(Person())が入ると読めます。alsoの中でプロパティにセットする部分は、おまけに見えてきます。
let
letを使われているところを抜粋します。
val weekday = p.bthdate.let {
val date = LocalDate.parse(it)
val formmater : DateTimeFormatter = DateTimeFormatter.ofPattern("E", Locale.JAPANESE)
date.format(formmater)
}
これも心の目を通すと、以下のように見えます。
val weekday = p.bthdate.let {
val date = LocalDate.parse(it)
val formmater : DateTimeFormatter = DateTimeFormatter.ofPattern("E", Locale.JAPANESE)
date.format(formmater)
}
letの場合は、weekdayには途中ごちゃごちゃなんかしてるけどとりあえず無視して、最終的にはletに渡している関数の戻り値、つまり、最後の date.format(formatter) が入ると読めます。p.bthdateは、一応、心には留めておくけど、ごちゃごちゃしてる中で使われてるんだろうな、きっと、たぶん。的な。それよりも、date.format(formatter) の方が重要でしょ、みたいな。そんな感じです。
alsoとletのまとめ
とどのつまり、alsoはその左側が、letはその右側{}の中の一番最後が変数に代入される。
コードを読むときのalsoとletは、それさえ、分かっていれば当面、OK。
それ以外のコード行(お気持ちプログラムで、うすーく記述した行)は、必要になったら読むけど、初見読みでは軽く扱います、と。
そりゃー、コンピュータは、どの行も区別なく均等に扱うのでしょうし、淡々とこなしていくのでしょう。でも、私は、コードを読み書きするとき、軽重、強弱、重要、非重要など力のいれどころを変えて、扱います。そうしないと辛すぎます。脳のメモリがちっこいので。
$\LARGE{だって、私、人間だもの。}$
(※ここで、タイトル回収)
コードを読む時に、強弱をつけるためのマーカとして、スコープ関数が役に立っているのです。少なくとも私には。
applyとrun
applyは、also+itのthisバージョンです。大量のプロパティセットをするのがGUIプログラミングの頻出パターンで、alsoを使うと、
○○.also {
it.○○ = ○○
it.○○ = ○○
it.○○ = ○○
...
}
になります。この場合、alsoをapplyに置き換えると、itもthisに置き換えられるので、
○○.apply {
this.○○ = ○○
this.○○ = ○○
this.○○ = ○○
...
}
となります。thisは文法的に省略可能なので、
○○.apply {
○○ = ○○
○○ = ○○
○○ = ○○
...
}
となります。
一方、runはletのthisバージョンです。しかし、個人的にはapplyほど利用した記憶がありません。this.○○の出現場所が、applyほど一定のパターンを示さず、出現場所がバラバラなことが多かったからです。そのため、thisを省略するとかえって分かりづらくなりました。それだったら、let+itを使って、itを明示していた気がします。run + thisでthisを明示するのでもいいのですが、thisは省略することが利点のためletを使うことの方が圧倒的に多かったですね。
○○.run {
ごにょごにょごにょ...
this.○○ = = ごにょごにょ...
ごにょごにょごにょ...
this.○○ = ごにょごにょ...
...
}
とか、
○○.run {
ごにょごにょごにょ...
○○.○○(this, ....)
ごにょごにょごにょ...
(ごにょごにょ...ごにょごにょ).○○(this)
...
}
など、thisを利用する場所が多くのコードに埋もれてしまう場合です。前者のコードはthisを省略できますが、省略すると分かりずらくなります。後者のコードは、関数の引数にthisを渡していますので省略できません。いずれにせよ、こういうコードだったら、run+thisではなくlet+itでitを明示して使ってました。同様のことはapplyにもいえて、省略すると分からなくなるならばalso+itを使っていました。
itは好きな変数名にもできます
念の為に書いておきます。先にあげたリンク先でも説明されていますが、ラムダ式の引数を明示すれば、itでなくても、分かりやすい変数名にすることができます。この記事では、これ以上説明しません。
自分の場合は、ほとんどitを使っていましたが、ラムダ式がネストしていてitの指しているものが分かりにくくなる場合などは、itではなく適切な変数名を使ってました。そもそも、極力ネストを避けるように書くべきです。
let, also, run, applyの使い分けが難しいという方へ
この4つを対等に覚えようとするから混乱する気がします。私のお勧めは、まず、letとalsoだけ絞って導入することです。まずは、thisを捨てて、itオンリー。そんなに一編に覚えられません。$\large{だって、私たち、人間だもの。}$
そうすれば、letとalsoの違いが、まず身に付きます。それになれてきたら、itをthisに置き換え、省略できるthisを省略するパターンのrunとapplyを導入すれば良いと思います。
とはいっても、先に書いたとおり、私の場合、applyは利用して、runの利用頻度がほとんどなかったのですが。
リファクタリング
リファクタリングしたプログラムでは、結局、スコープ関数を利用していません(笑)。これは、決してスコープ関数が不要という訳ではありません。解説用に作ったプログラムが、単純すぎただけです。そのため、スコープ関数の出番がなくなってしまいました。
スコープ関数を解説するために、書き方をわざと冗長にしたのでした。
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
fun main() {
class Person(var name: String, var bthdate: LocalDate)
listOf(
Person("太郎", LocalDate.of(2001, 1, 1)),
Person("次郎", LocalDate.of(2002, 2, 2)),
Person("三郎", LocalDate.of(2003, 3, 3)),
).map {
val weekday = it.bthdate.format(DateTimeFormatter.ofPattern("E", Locale.JAPANESE))
"%sは%s曜日に生まれました。".format(it.name, weekday)
}.forEach(::println)
}
スコープ関数こそ使っていませんが、map関数がlet関数に近い書き味になっています。
// letに近い書き味
.map {
...
...it...
...
}
このことからも、ストリーム的な書き方をするプログラマにとってスコープ関数は、とても自然に感じられると思います。
最後に
ここまで書いてから、
というページを見つけました。
apply以外は、おおぬね、同意します。とはいえ教条的に禁止するより、使いたいなら使ってもいいよ、くらいのスタンスの方が好きですが。よほど変な使い方されなければ、個人的には全然OKなんですけどね。
まあ、お仕事プログラムだと、そうもいってられないのかな。チーム次第なところもあるとは思いますが。
で、いくつか言及しておきたいところはあります。
まず、私も大いに同意したところは以下のような場合です。
hoge?.let {
... it.○○ ...
}
みたい戻り値を利用しないのは、違和感がありますので使いかません。こういう場合は、素直に
if(hoge != null) {
... hoge.○○ ...
}
とするか、コレクション系メソッドのforEach相当の拡張関数を自作してその中で実行しますね。自作拡張関数については、別記事におこすかもしれません。
あと、「Twitterで教えていただいた」とある
foo?.let { it.getInt() } ?: 0
に関してです。foo != null && it.getInt() == null
の場合、foo?.let { it.getInt() } ?: 0
が0
と評価されるからエルビス演算子とletの組み合わせは使わない方がいいという意見がありました。
これに関しては、「ちょっと何言ってるか分からない(by 富澤)」ですね。むしろ積極的に使いたいところですけど。
もっとも、この例の場合は、
foo?.getInt() ?: 0
とするだけで、わざわざletは使わないでしょうけど。
val bar = foo?.getInt() ?: 0
なんて、私の中では頻出パターンですよ。何がまずいのでしょうか?
100歩譲って、
val bar = foo?.getInt()
// ないし
val bar = foo?.let { it.getInt() }
として、foo == null || foo.getInt() == null
の場合、bar
をnull
にしたいというのならば分かります。
繰り返しになりますが、Twitterで書かれていた
foo?.let { it.getInt() } ?: 0
に戻ります。foo != null && it.getInt() == null
の場合、foo?.let { it.getInt() } ?: 0
が0
と評価されるのがダメならば、foo == null
の場合は、0
と評価し、foo.getInt() == null
の場合はnull
と評価したいということなんでしょうか?
そんなプログラム、個人的には嫌です。